Skip to main content

logicaffeine_web/ui/components/
indent.rs

1//! What Enter and Tab do in the Studio's editor. Each language's indentation is worked out on
2//! the text alone — which lines open a block, which close one, how wide a step is — so the
3//! browser only has to apply the edit.
4
5use super::code_editor::Language;
6
7/// How a language indents: its step, and the lines that open and close a block.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub struct Indentation {
10    pub step: &'static str,
11    grammar: Grammar,
12}
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15enum Grammar {
16    /// LOGOS: a line ending in `:` opens a block.
17    Logos,
18    /// Logic mode: sentences and theorems keep the line's own indentation.
19    Logic,
20    /// Math: literate lines ending in `:`, `## Theorem:` headers, and the kernel's `:=`,
21    /// `match … with` and `Proof.` open blocks; `end`/`end.` and `Qed.` close them.
22    Vernacular,
23    /// English hardware specs keep the line's own indentation.
24    Spec,
25    /// Verilog: `module … ;`, `begin` and `case (…)` open blocks; `end…` closes them.
26    Verilog,
27    /// Generated Rust: `{` opens a block, `}` closes it.
28    Rust,
29}
30
31impl Indentation {
32    /// The indentation `language` follows; Hardware by what `source` is, Verilog or a spec.
33    pub fn of(language: Language, source: &str) -> Indentation {
34        use crate::ui::pages::hardware_report::is_verilog;
35        let (step, grammar) = match language {
36            Language::Logos => ("    ", Grammar::Logos),
37            Language::Logic => ("    ", Grammar::Logic),
38            Language::Vernacular => ("    ", Grammar::Vernacular),
39            Language::Rust => ("    ", Grammar::Rust),
40            Language::Hardware if is_verilog(source) => ("  ", Grammar::Verilog),
41            Language::Hardware => ("    ", Grammar::Spec),
42        };
43        Indentation { step, grammar }
44    }
45
46    /// Whether `line` opens a block whose lines sit one step deeper.
47    pub fn opens_block(&self, line: &str) -> bool {
48        let code = self.code_of(line).trim_end();
49        match self.grammar {
50            Grammar::Logos => code.ends_with(':'),
51            Grammar::Logic | Grammar::Spec => false,
52            Grammar::Vernacular => {
53                let header = code.trim_start();
54                code.ends_with(':')
55                    || code.ends_with(":=")
56                    || ends_with_word(code, "with")
57                    || header == "Proof."
58                    || (header.starts_with("## ") && is_theorem_header(&header[3..]))
59            }
60            Grammar::Verilog => {
61                let head = code.trim_start();
62                ends_with_word(code, "begin")
63                    || (starts_with_word(head, "module") && code.ends_with(';'))
64                    || ["case", "casez", "casex"].iter().any(|w| starts_with_word(head, w))
65            }
66            Grammar::Rust => code.ends_with('{'),
67        }
68    }
69
70    /// Whether `line` closes the block it sits in, and belongs one step shallower than it.
71    pub fn closes_block(&self, line: &str) -> bool {
72        let code = self.code_of(line).trim();
73        match self.grammar {
74            Grammar::Logos | Grammar::Logic | Grammar::Spec => false,
75            Grammar::Vernacular => code == "end" || code == "end." || code == "Qed.",
76            Grammar::Verilog => {
77                ["end", "endmodule", "endcase", "endfunction", "endtask", "endgenerate"]
78                    .iter()
79                    .any(|w| starts_with_word(code, w))
80            }
81            Grammar::Rust => code.starts_with('}'),
82        }
83    }
84
85    /// `line` without its comment.
86    fn code_of<'l>(&self, line: &'l str) -> &'l str {
87        let marker = match self.grammar {
88            Grammar::Logos | Grammar::Vernacular => "--",
89            Grammar::Verilog | Grammar::Rust => "//",
90            Grammar::Logic | Grammar::Spec => "#",
91        };
92        match line.find(marker) {
93            Some(at) if !(self.grammar == Grammar::Logic && line.trim_start().starts_with("##")) => &line[..at],
94            _ => line,
95        }
96    }
97
98    /// What Enter does at a caret whose line holds `line`, `at` bytes of it before the caret.
99    /// It breaks the line, and the new line starts at the line's own indentation — one step
100    /// deeper when the text before the caret opens a block. A line that closes a block but
101    /// still sits at its block's depth is first moved back one step.
102    pub fn enter(&self, line: &str, at: usize) -> Enter {
103        let before = &line[..at];
104        let own = leading_whitespace(line);
105        let reindent = (self.closes_block(line) && !own.is_empty()).then(|| self.outdent_line(own));
106        let base = reindent.clone().unwrap_or_else(|| own.to_string());
107        let deeper = self.opens_block(before);
108        let next = if deeper { format!("{base}{}", self.step) } else { base };
109        // A caret inside the leading space carries no more indentation than it has.
110        let next = if before.trim().is_empty() { before.to_string() } else { next };
111        Enter { reindent, insert: format!("\n{next}") }
112    }
113
114    /// The spaces Tab inserts at `column` (counted in characters from 0): to the next stop.
115    pub fn tab_fill(&self, column: usize) -> String {
116        let width = self.step.len();
117        " ".repeat(width - column % width)
118    }
119
120    /// `lines` (whole lines, joined by `\n`) moved one step deeper; blank lines stay empty.
121    pub fn indent_lines(&self, lines: &str) -> String {
122        lines
123            .split('\n')
124            .map(|l| if l.trim().is_empty() { l.to_string() } else { format!("{}{l}", self.step) })
125            .collect::<Vec<_>>()
126            .join("\n")
127    }
128
129    /// `lines` moved one step shallower, each as far as its own leading spaces allow (a tab
130    /// counts as a whole step).
131    pub fn outdent_lines(&self, lines: &str) -> String {
132        lines.split('\n').map(|l| self.outdent_line(l)).collect::<Vec<_>>().join("\n")
133    }
134
135    fn outdent_line(&self, line: &str) -> String {
136        if let Some(rest) = line.strip_prefix('\t') {
137            return rest.to_string();
138        }
139        let spaces = line.len() - line.trim_start_matches(' ').len();
140        line[spaces.min(self.step.len())..].to_string()
141    }
142}
143
144/// What Enter does: the indentation to give the caret's own line first, if it changes, and the
145/// text to insert at the caret.
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct Enter {
148    pub reindent: Option<String>,
149    pub insert: String,
150}
151
152fn leading_whitespace(line: &str) -> &str {
153    &line[..line.len() - line.trim_start().len()]
154}
155
156fn is_theorem_header(rest: &str) -> bool {
157    ["Theorem", "Lemma", "Corollary", "Proposition"]
158        .iter()
159        .any(|w| rest.strip_prefix(w).is_some_and(|after| after.starts_with(':')))
160}
161
162fn is_word(c: char) -> bool {
163    c.is_alphanumeric() || c == '_'
164}
165
166fn starts_with_word(text: &str, word: &str) -> bool {
167    text.strip_prefix(word).is_some_and(|rest| !rest.starts_with(is_word))
168}
169
170fn ends_with_word(text: &str, word: &str) -> bool {
171    text.strip_suffix(word).is_some_and(|rest| !rest.ends_with(is_word))
172}
173
174#[cfg(test)]
175#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/components/indent/tests.rs"]
176mod tests;