Skip to main content

panache_parser/parser/
math.rs

1//! In-tree TeX math content parser.
2//!
3//! Produces a lossless structural CST for the *content* between math
4//! delimiters (the delimiters themselves are owned by the host `INLINE_MATH` /
5//! `DISPLAY_MATH` nodes, see `parser/inlines/math.rs`). The returned subtree is
6//! rooted at [`SyntaxKind::MATH_CONTENT`] and is spliced directly into the host
7//! document tree, replacing the opaque content `TEXT` token.
8//!
9//! This is a *syntactic* parse, not a semantic one: TeX is a Turing-complete
10//! macro language, so we only capture structure that a formatter can safely act
11//! on — brace groups, `\begin`/`\end` environments, control sequences,
12//! alignment tabs (`&`), line breaks (`\\`), sub/superscript markers, comments,
13//! and whitespace. Everything else is an ordinary-atom run ([`MATH_TEXT`]).
14//!
15//! The **CST is lossless and never fails** (`node.text() == content` for every
16//! input; worst case is a single `MATH_TEXT` atom). Structural problems
17//! (unbalanced braces, unclosed or mismatched environments) are *not* reported
18//! here: they are derived from the realized tree shape by
19//! [`crate::syntax::math_diagnostics`], the single source of truth shared by the
20//! linter, formatter, and LSP. Keeping the parser diagnostic-free means the
21//! host-aligned ranges come for free from the spliced subtree.
22//!
23//! [`MATH_TEXT`]: SyntaxKind::MATH_TEXT
24
25use crate::parser::inlines::bookdown::try_parse_bookdown_equation_definition;
26use crate::syntax::SyntaxKind;
27use rowan::{GreenNode, GreenNodeBuilder};
28
29/// Flavor-/extension-dependent parsing options for math content. Default is
30/// all-off (pure TeX). The math grammar itself is flavor-agnostic; only
31/// constructs layered on top of TeX by a Markdown flavor live here.
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33pub struct MathParseOptions {
34    /// Recognize bookdown equation labels `(\#eq:label)` as a single
35    /// [`SyntaxKind::MATH_EQUATION_LABEL`] token (gated on the
36    /// `bookdown_equation_references` extension).
37    pub bookdown_equation_labels: bool,
38}
39
40/// Parse math content into a lossless `MATH_CONTENT` green node. `content` is
41/// the raw text between (but excluding) the math delimiters. Never fails:
42/// `SyntaxNode::new_root(result).text() == content` for every input.
43pub fn parse_math_content(content: &str, opts: MathParseOptions) -> GreenNode {
44    let mut parser = MathParser {
45        input: content,
46        pos: 0,
47        builder: GreenNodeBuilder::new(),
48        opts,
49    };
50    parser.builder.start_node(SyntaxKind::MATH_CONTENT.into());
51    parser.parse_elements(Ctx::Top);
52    parser.builder.finish_node();
53    parser.builder.finish()
54}
55
56/// Parse context, controlling which delimiter ends the current element run.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum Ctx {
59    /// Top level of the math content.
60    Top,
61    /// Inside a `{ ... }` brace group; stops at the matching `}`.
62    Group,
63    /// Inside a `\begin{env} ... \end{env}` body; stops at `\end`.
64    Env,
65    /// Inside a `\left<d> ... \right<d>` body; stops at `\right`.
66    LeftRight,
67}
68
69struct MathParser<'a> {
70    input: &'a str,
71    pos: usize,
72    builder: GreenNodeBuilder<'static>,
73    opts: MathParseOptions,
74}
75
76impl MathParser<'_> {
77    fn rest(&self) -> &str {
78        &self.input[self.pos..]
79    }
80
81    fn peek_char(&self) -> Option<char> {
82        self.rest().chars().next()
83    }
84
85    /// Emit a token of `len` bytes (from the current position) with `kind`.
86    fn bump_bytes(&mut self, len: usize, kind: SyntaxKind) {
87        let text = &self.input[self.pos..self.pos + len];
88        self.builder.token(kind.into(), text);
89        self.pos += len;
90    }
91
92    /// If the cursor is at a control word (`\` followed by ASCII letters or
93    /// `@`, matching TeX/texlab's control-word class), return that word
94    /// (without the backslash) without consuming anything.
95    fn peek_control_word(&self) -> Option<&str> {
96        let after = self.rest().strip_prefix('\\')?;
97        let len: usize = after
98            .bytes()
99            .take_while(|b| b.is_ascii_alphabetic() || *b == b'@')
100            .count();
101        if len == 0 { None } else { Some(&after[..len]) }
102    }
103
104    fn parse_elements(&mut self, ctx: Ctx) {
105        while let Some(c) = self.peek_char() {
106            match c {
107                '}' if ctx == Ctx::Group => break,
108                // A `}` outside any group is an unmatched close: keep it as a
109                // faithful (stray) close token. `math_diagnostics` flags it from
110                // the shape (a `MATH_GROUP_CLOSE` with no enclosing `MATH_GROUP`).
111                '}' => self.bump_bytes(1, SyntaxKind::MATH_GROUP_CLOSE),
112                '\\' => {
113                    if self.rest().starts_with("\\\\") {
114                        self.bump_bytes(2, SyntaxKind::MATH_LINE_BREAK);
115                    } else if let Some(word) = self.peek_control_word() {
116                        match word {
117                            "begin" => self.parse_environment(),
118                            "end" if ctx == Ctx::Env => break,
119                            "end" => {
120                                // Stray `\end` with no open `\begin` at this
121                                // level; keep it as a plain command token.
122                                // `math_diagnostics` flags it from the shape.
123                                self.parse_control_word();
124                            }
125                            "left" => self.parse_delimited(),
126                            "right" if ctx == Ctx::LeftRight => break,
127                            "right" => {
128                                // Stray `\right` with no open `\left` at this
129                                // level; keep it as a plain command token.
130                                // `math_diagnostics` flags it from the shape.
131                                self.parse_control_word();
132                            }
133                            _ => self.parse_control_word(),
134                        }
135                    } else {
136                        self.parse_control_symbol();
137                    }
138                }
139                '{' => self.parse_group(),
140                // Bookdown equation label `(\#eq:label)`, only when enabled.
141                // A non-matching `(` falls through to an ordinary open delimiter.
142                '(' if self.opts.bookdown_equation_labels => match self.equation_label_len() {
143                    Some(len) => self.bump_bytes(len, SyntaxKind::MATH_EQUATION_LABEL),
144                    None => self.bump_bytes(1, SyntaxKind::MATH_OPEN),
145                },
146                // Delimiters and punctuation: their TeX mathcode class is fixed
147                // at the character level, so it is a CST fact (unlike operator
148                // class). The ambiguous `| . /` stay in MATH_TEXT.
149                '(' | '[' => self.bump_bytes(1, SyntaxKind::MATH_OPEN),
150                ')' | ']' => self.bump_bytes(1, SyntaxKind::MATH_CLOSE),
151                ',' | ';' => self.bump_bytes(1, SyntaxKind::MATH_PUNCT),
152                '&' => self.bump_bytes(1, SyntaxKind::MATH_ALIGN),
153                '^' | '_' => self.bump_bytes(1, SyntaxKind::MATH_SCRIPT),
154                // Operator atoms (`+ - * = < >`), one token per char. Class and
155                // precedence are *not* assigned here: TeX itself coerces a
156                // binary atom to ordinary by its neighbors (unary minus), so the
157                // class is a property of list position, owned by the formatter.
158                c if is_operator(c) => self.bump_bytes(1, SyntaxKind::MATH_OPERATOR),
159                '%' => self.parse_comment(),
160                ' ' | '\t' => self.parse_spaces(),
161                '\n' => self.bump_bytes(1, SyntaxKind::MATH_NEWLINE),
162                '\r' => {
163                    let len = if self.rest().starts_with("\r\n") {
164                        2
165                    } else {
166                        1
167                    };
168                    self.bump_bytes(len, SyntaxKind::MATH_NEWLINE);
169                }
170                _ => self.parse_text(),
171            }
172        }
173    }
174
175    /// `\begin{env} ... \end{env}`. Matching is done by recursion plus the
176    /// `Env` context. The begin/end name groups are captured as `MATH_GROUP`
177    /// children; a missing `\end` or a name mismatch is left in the shape for
178    /// `math_diagnostics` to report, and never aborts the parse.
179    fn parse_environment(&mut self) {
180        self.builder.start_node(SyntaxKind::MATH_ENVIRONMENT.into());
181        self.parse_control_word(); // \begin
182        self.parse_environment_name(); // {name} group, if present
183        self.parse_elements(Ctx::Env);
184        if self.peek_control_word() == Some("end") {
185            self.parse_control_word(); // \end
186            self.parse_environment_name(); // {name} group, if present
187        }
188        self.builder.finish_node();
189    }
190
191    /// Parse the `{name}` group following `\begin` / `\end`, if present. The
192    /// name is captured as a `MATH_GROUP` in the CST; begin/end matching is
193    /// derived from the tree shape by `math_diagnostics`.
194    fn parse_environment_name(&mut self) {
195        if self.peek_char() == Some('{') {
196            self.parse_group();
197        }
198    }
199
200    fn parse_group(&mut self) {
201        self.builder.start_node(SyntaxKind::MATH_GROUP.into());
202        self.bump_bytes(1, SyntaxKind::MATH_GROUP_OPEN); // {
203        self.parse_elements(Ctx::Group);
204        if self.peek_char() == Some('}') {
205            self.bump_bytes(1, SyntaxKind::MATH_GROUP_CLOSE); // }
206        }
207        // An unclosed group (no `MATH_GROUP_CLOSE`) is left as-is; the missing
208        // close token is what `math_diagnostics` keys on.
209        self.builder.finish_node();
210    }
211
212    /// `\left<d> ... \right<d>`. Both `\left` and `\right` take a delimiter
213    /// argument; TeX allows asymmetric pairs (`\left( … \right]`) and the null
214    /// delimiter `.` (`\left.`), so no delimiter *matching* is attempted — only
215    /// the `\left`/`\right` pairing is structural. A missing `\right` leaves a
216    /// `MATH_DELIMITED` node without its closing command, which
217    /// `math_diagnostics` reports.
218    fn parse_delimited(&mut self) {
219        self.builder.start_node(SyntaxKind::MATH_DELIMITED.into());
220        self.parse_control_word(); // \left
221        self.consume_delimiter(); // opening delimiter argument
222        self.parse_elements(Ctx::LeftRight);
223        if self.peek_control_word() == Some("right") {
224            self.parse_control_word(); // \right
225            self.consume_delimiter(); // closing delimiter argument
226        }
227        self.builder.finish_node();
228    }
229
230    /// Consume the single delimiter that follows `\left` / `\right`, when it sits
231    /// immediately at the cursor. Brackets keep their fixed `MATH_OPEN`/
232    /// `MATH_CLOSE` kind; the ambiguous `. | /` stay `MATH_TEXT` (as elsewhere);
233    /// a control-sequence delimiter (`\{`, `\langle`, `\|`, …) is a `MATH_COMMAND`.
234    /// If a space or anything else intervenes, nothing is consumed here and the
235    /// surrounding element loop tokenizes it normally — losslessness holds either
236    /// way; only the token's node membership shifts.
237    fn consume_delimiter(&mut self) {
238        match self.peek_char() {
239            Some('(' | '[') => self.bump_bytes(1, SyntaxKind::MATH_OPEN),
240            Some(')' | ']') => self.bump_bytes(1, SyntaxKind::MATH_CLOSE),
241            Some('.' | '|' | '/') => self.bump_bytes(1, SyntaxKind::MATH_TEXT),
242            Some('\\') => {
243                if self.peek_control_word().is_some() {
244                    self.parse_control_word();
245                } else {
246                    self.parse_control_symbol();
247                }
248            }
249            _ => {}
250        }
251    }
252
253    /// `\` + a run of control-word characters (e.g. `\alpha`, `\frac`, `\begin`).
254    fn parse_control_word(&mut self) {
255        let word_len = self.peek_control_word().map(str::len).unwrap_or(0);
256        self.bump_bytes(1 + word_len, SyntaxKind::MATH_COMMAND);
257    }
258
259    /// `\` + exactly one following character (e.g. `\%`, `\{`, `\,`), or a
260    /// lone trailing backslash at EOF.
261    fn parse_control_symbol(&mut self) {
262        let after = &self.input[self.pos + 1..];
263        let len = 1 + after.chars().next().map(char::len_utf8).unwrap_or(0);
264        self.bump_bytes(len, SyntaxKind::MATH_COMMAND);
265    }
266
267    /// `%` to (but not including) the end of the line.
268    fn parse_comment(&mut self) {
269        let len = self
270            .rest()
271            .find(['\n', '\r'])
272            .unwrap_or_else(|| self.rest().len());
273        self.bump_bytes(len, SyntaxKind::MATH_COMMENT);
274    }
275
276    fn parse_spaces(&mut self) {
277        let len = self
278            .rest()
279            .bytes()
280            .take_while(|&b| b == b' ' || b == b'\t')
281            .count();
282        self.bump_bytes(len, SyntaxKind::MATH_SPACE);
283    }
284
285    /// A run of ordinary atoms, up to the next structural character. Delimiters
286    /// and punctuation (`( ) [ ] , ;`) bound the run too — they are now their
287    /// own tokens (including the `(` that the dispatcher's equation-label check
288    /// sees while the bookdown extension is on).
289    fn parse_text(&mut self) {
290        let len = self
291            .rest()
292            .find(|c: char| is_special(c))
293            .unwrap_or_else(|| self.rest().len());
294        debug_assert!(len > 0, "parse_text on a special char");
295        self.bump_bytes(len, SyntaxKind::MATH_TEXT);
296    }
297
298    /// If the cursor is at a bookdown equation label `(\#eq:label)`, return its
299    /// byte length. Reuses the shared bookdown definition parser so the
300    /// recognized span matches the rest of the codebase exactly.
301    fn equation_label_len(&self) -> Option<usize> {
302        try_parse_bookdown_equation_definition(self.rest()).map(|(len, _)| len)
303    }
304}
305
306/// Characters that terminate a [`SyntaxKind::MATH_TEXT`] run.
307fn is_special(c: char) -> bool {
308    is_operator(c)
309        || is_delimiter(c)
310        || matches!(
311            c,
312            '\\' | '{' | '}' | '&' | '^' | '_' | '%' | ' ' | '\t' | '\n' | '\r'
313        )
314}
315
316/// Delimiter/punctuation atoms split out of ordinary text into their own
317/// [`SyntaxKind::MATH_OPEN`]/[`SyntaxKind::MATH_CLOSE`]/[`SyntaxKind::MATH_PUNCT`]
318/// tokens. Their TeX mathcode class is fixed at the character level, so it is a
319/// CST fact; the ambiguous `| . /` are deliberately excluded (they stay text).
320fn is_delimiter(c: char) -> bool {
321    matches!(c, '(' | ')' | '[' | ']' | ',' | ';')
322}
323
324/// Operator atoms split out of ordinary text into their own
325/// [`SyntaxKind::MATH_OPERATOR`] token. The TeX mathbin (`+ - *`) and mathrel
326/// (`= < >`) core; the formatter assigns class/precedence/spacing downstream.
327fn is_operator(c: char) -> bool {
328    matches!(c, '+' | '-' | '*' | '=' | '<' | '>')
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::syntax::SyntaxNode;
335
336    fn node(content: &str) -> SyntaxNode {
337        SyntaxNode::new_root(parse_math_content(content, MathParseOptions::default()))
338    }
339
340    fn node_with(content: &str, opts: MathParseOptions) -> SyntaxNode {
341        SyntaxNode::new_root(parse_math_content(content, opts))
342    }
343
344    fn token_kinds(content: &str) -> Vec<SyntaxKind> {
345        node(content)
346            .descendants_with_tokens()
347            .filter_map(|el| el.into_token())
348            .map(|tok| tok.kind())
349            .collect()
350    }
351
352    /// Losslessness is the hard invariant for every input.
353    fn assert_lossless(content: &str) {
354        assert_eq!(
355            node(content).text().to_string(),
356            content,
357            "roundtrip: {content:?}"
358        );
359    }
360
361    #[test]
362    fn root_is_math_content() {
363        assert_eq!(node("x").kind(), SyntaxKind::MATH_CONTENT);
364    }
365
366    #[test]
367    fn plain_text_is_one_atom_run() {
368        // A run with no structural or operator chars stays a single atom.
369        assert_eq!(token_kinds("abc"), vec![SyntaxKind::MATH_TEXT]);
370        assert_lossless("abc");
371        // `/` and `.` are ambiguous, so they stay ordinary atoms (not operators
372        // and not delimiters); only the parens split out.
373        assert_eq!(
374            token_kinds("f(x)/2.5"),
375            vec![
376                SyntaxKind::MATH_TEXT,  // f
377                SyntaxKind::MATH_OPEN,  // (
378                SyntaxKind::MATH_TEXT,  // x
379                SyntaxKind::MATH_CLOSE, // )
380                SyntaxKind::MATH_TEXT,  // /2.5
381            ]
382        );
383        assert_lossless("f(x)/2.5");
384    }
385
386    #[test]
387    fn delimiters_and_punctuation_split_atom_runs() {
388        // `( [` open, `) ]` close, `, ;` punctuation — one token per char, with
389        // a fixed CST kind (their TeX mathcode class is character-level).
390        assert_eq!(
391            token_kinds("[a,b);"),
392            vec![
393                SyntaxKind::MATH_OPEN,  // [
394                SyntaxKind::MATH_TEXT,  // a
395                SyntaxKind::MATH_PUNCT, // ,
396                SyntaxKind::MATH_TEXT,  // b
397                SyntaxKind::MATH_CLOSE, // )
398                SyntaxKind::MATH_PUNCT, // ;
399            ]
400        );
401        assert_lossless("[a,b);");
402        // The ambiguous `| . /` are NOT delimiters — they stay in MATH_TEXT.
403        assert_eq!(token_kinds("a|b.c/d"), vec![SyntaxKind::MATH_TEXT]);
404        assert_lossless("a|b.c/d");
405        // An escaped delimiter stays a control symbol, never a delimiter token.
406        assert_eq!(token_kinds(r"\(\)\[\]"), vec![SyntaxKind::MATH_COMMAND; 4]);
407        assert_lossless(r"\(\)\[\]");
408    }
409
410    #[test]
411    fn operators_split_atom_runs() {
412        // `+ - * = < >` each break the surrounding text into their own
413        // MATH_OPERATOR token. Class/precedence is deferred to the formatter.
414        assert_eq!(
415            token_kinds("a+b=c"),
416            vec![
417                SyntaxKind::MATH_TEXT,     // a
418                SyntaxKind::MATH_OPERATOR, // +
419                SyntaxKind::MATH_TEXT,     // b
420                SyntaxKind::MATH_OPERATOR, // =
421                SyntaxKind::MATH_TEXT,     // c
422            ]
423        );
424        assert_lossless("a+b=c");
425    }
426
427    #[test]
428    fn each_operator_char_is_its_own_token() {
429        for op in ["+", "-", "*", "=", "<", ">"] {
430            assert_eq!(
431                token_kinds(op),
432                vec![SyntaxKind::MATH_OPERATOR],
433                "operator {op:?}"
434            );
435            assert_lossless(op);
436        }
437        // Adjacent operators do not coalesce — one token per char.
438        assert_eq!(
439            token_kinds("a<=b"),
440            vec![
441                SyntaxKind::MATH_TEXT,
442                SyntaxKind::MATH_OPERATOR, // <
443                SyntaxKind::MATH_OPERATOR, // =
444                SyntaxKind::MATH_TEXT,
445            ]
446        );
447        // Unary vs binary minus is NOT distinguished here — both are operators.
448        assert_eq!(
449            token_kinds("-x"),
450            vec![SyntaxKind::MATH_OPERATOR, SyntaxKind::MATH_TEXT]
451        );
452        assert_lossless("-x");
453        // An escaped special stays a control symbol, never an operator.
454        assert_eq!(token_kinds(r"\<"), vec![SyntaxKind::MATH_COMMAND]);
455        assert_lossless(r"\<");
456    }
457
458    #[test]
459    fn operators_inside_groups_and_scripts_are_lossless() {
460        for content in [r"e^{-x}", r"10^{-3}", r"\frac{a+b}{c-d}", r"x_{i+1}"] {
461            assert_lossless(content);
462        }
463    }
464
465    #[test]
466    fn control_word_and_symbol() {
467        assert_eq!(
468            token_kinds(r"\alpha\,"),
469            vec![SyntaxKind::MATH_COMMAND, SyntaxKind::MATH_COMMAND]
470        );
471        assert_lossless(r"\alpha\,");
472        // Escaped specials are control symbols, not structural markers.
473        assert_eq!(token_kinds(r"\&\%\{\}"), vec![SyntaxKind::MATH_COMMAND; 4]);
474        assert_lossless(r"\&\%\{\}");
475    }
476
477    #[test]
478    fn brace_group_nests() {
479        let tree = node(r"x^{2}");
480        let group = tree
481            .descendants()
482            .find(|n| n.kind() == SyntaxKind::MATH_GROUP)
483            .expect("group");
484        let kinds: Vec<_> = group.children_with_tokens().map(|el| el.kind()).collect();
485        assert_eq!(
486            kinds,
487            vec![
488                SyntaxKind::MATH_GROUP_OPEN,
489                SyntaxKind::MATH_TEXT,
490                SyntaxKind::MATH_GROUP_CLOSE
491            ]
492        );
493        assert_lossless(r"x^{2}");
494    }
495
496    #[test]
497    fn line_break_alignment_and_scripts() {
498        assert_eq!(
499            token_kinds(r"x &= 1 \\"),
500            vec![
501                SyntaxKind::MATH_TEXT,       // x
502                SyntaxKind::MATH_SPACE,      // ' '
503                SyntaxKind::MATH_ALIGN,      // &
504                SyntaxKind::MATH_OPERATOR,   // =
505                SyntaxKind::MATH_SPACE,      // ' '
506                SyntaxKind::MATH_TEXT,       // 1
507                SyntaxKind::MATH_SPACE,      // ' '
508                SyntaxKind::MATH_LINE_BREAK, // \\
509            ]
510        );
511        assert_lossless(r"x &= 1 \\");
512        assert_eq!(
513            token_kinds("x^2_i"),
514            vec![
515                SyntaxKind::MATH_TEXT,
516                SyntaxKind::MATH_SCRIPT,
517                SyntaxKind::MATH_TEXT,
518                SyntaxKind::MATH_SCRIPT,
519                SyntaxKind::MATH_TEXT,
520            ]
521        );
522    }
523
524    #[test]
525    fn environment_wraps_body() {
526        let content = "\\begin{aligned}\nx &= 1\n\\end{aligned}";
527        let tree = node(content);
528        let env = tree
529            .descendants()
530            .find(|n| n.kind() == SyntaxKind::MATH_ENVIRONMENT)
531            .expect("environment");
532        assert_eq!(env.text().to_string(), content);
533        let commands = env
534            .children_with_tokens()
535            .filter(|el| el.kind() == SyntaxKind::MATH_COMMAND)
536            .count();
537        assert_eq!(commands, 2);
538        assert_lossless(content);
539    }
540
541    #[test]
542    fn nested_environments() {
543        let content = r"\begin{a}\begin{b}x\end{b}\end{a}";
544        let envs = node(content)
545            .descendants()
546            .filter(|n| n.kind() == SyntaxKind::MATH_ENVIRONMENT)
547            .count();
548        assert_eq!(envs, 2);
549        assert_lossless(content);
550    }
551
552    #[test]
553    fn comment_runs_to_end_of_line() {
554        assert_eq!(
555            token_kinds("a % tail\nb"),
556            vec![
557                SyntaxKind::MATH_TEXT,
558                SyntaxKind::MATH_SPACE,
559                SyntaxKind::MATH_COMMENT,
560                SyntaxKind::MATH_NEWLINE,
561                SyntaxKind::MATH_TEXT,
562            ]
563        );
564        assert_lossless("a % tail\nb");
565    }
566
567    #[test]
568    fn crlf_and_unicode_are_lossless() {
569        assert_lossless("x &= 1\r\ny &= 2\r\n");
570        assert_lossless(r"\alpha + \beta \neq \gamma_{\text{αβγ}}");
571    }
572
573    #[test]
574    fn empty_content() {
575        assert_eq!(node("").text().to_string(), "");
576        assert!(token_kinds("").is_empty());
577    }
578
579    #[test]
580    fn trailing_backslash() {
581        assert_eq!(
582            token_kinds("a\\"),
583            vec![SyntaxKind::MATH_TEXT, SyntaxKind::MATH_COMMAND]
584        );
585        assert_lossless("a\\");
586    }
587
588    // Malformed-math cases stay lossless (diagnostics now live in
589    // `syntax::math::math_diagnostics`, tested there).
590    #[test]
591    fn malformed_math_is_still_lossless() {
592        for content in [
593            "{a",
594            "a}b",
595            r"\begin{aligned} x &= 1",
596            r"\begin{aligned}x\end{matrix}",
597            r"x \end{aligned}",
598        ] {
599            assert_lossless(content);
600        }
601    }
602
603    // --- `\left` / `\right` paired delimiters (MATH_DELIMITED node) ---
604
605    fn delimited_count(content: &str) -> usize {
606        node(content)
607            .descendants()
608            .filter(|n| n.kind() == SyntaxKind::MATH_DELIMITED)
609            .count()
610    }
611
612    #[test]
613    fn left_right_wraps_a_delimited_node() {
614        let content = r"\left( x + y \right)";
615        let tree = node(content);
616        let delim = tree
617            .descendants()
618            .find(|n| n.kind() == SyntaxKind::MATH_DELIMITED)
619            .expect("delimited node");
620        assert_eq!(delim.text().to_string(), content);
621        // The `\left` and `\right` are direct command children of the node.
622        let commands: Vec<String> = delim
623            .children_with_tokens()
624            .filter_map(|el| el.into_token())
625            .filter(|t| t.kind() == SyntaxKind::MATH_COMMAND)
626            .map(|t| t.text().to_string())
627            .collect();
628        assert_eq!(commands, vec![r"\left", r"\right"]);
629        assert_lossless(content);
630    }
631
632    #[test]
633    fn left_right_delimiters_keep_their_token_kinds() {
634        // Opening `(` and closing `)` stay MATH_OPEN / MATH_CLOSE inside the node.
635        assert_eq!(
636            token_kinds(r"\left(x\right)"),
637            vec![
638                SyntaxKind::MATH_COMMAND, // \left
639                SyntaxKind::MATH_OPEN,    // (
640                SyntaxKind::MATH_TEXT,    // x
641                SyntaxKind::MATH_COMMAND, // \right
642                SyntaxKind::MATH_CLOSE,   // )
643            ]
644        );
645    }
646
647    #[test]
648    fn null_delimiter_and_asymmetric_pairs_are_lossless() {
649        // `\left.` null delimiter, `.` stays MATH_TEXT.
650        for content in [
651            r"\left. x \right|",
652            r"\left( x \right]",
653            r"\left\{ x \right\}",
654        ] {
655            assert_eq!(delimited_count(content), 1, "one node: {content:?}");
656            assert_lossless(content);
657        }
658    }
659
660    #[test]
661    fn nested_delimited_is_lossless() {
662        let content = r"\left[ \left( a \right) \right]";
663        assert_eq!(delimited_count(content), 2);
664        assert_lossless(content);
665    }
666
667    #[test]
668    fn unclosed_and_stray_delimiters_stay_lossless() {
669        // Unclosed `\left(` still builds a (single) node; stray `\right)` builds
670        // none. Both are lossless; the diagnostics live in `math_diagnostics`.
671        assert_eq!(delimited_count(r"\left( x"), 1);
672        assert_lossless(r"\left( x");
673        assert_eq!(delimited_count(r"x \right)"), 0);
674        assert_lossless(r"x \right)");
675    }
676
677    #[test]
678    fn leftarrow_and_rightarrow_are_not_delimiters() {
679        // `\leftarrow` / `\rightarrow` are ordinary commands, not `\left`/`\right`.
680        let content = r"a \leftarrow b \rightarrow c";
681        assert_eq!(delimited_count(content), 0);
682        assert_lossless(content);
683    }
684
685    // --- Bookdown equation labels (gated on the extension) ---
686
687    const BOOKDOWN: MathParseOptions = MathParseOptions {
688        bookdown_equation_labels: true,
689    };
690
691    fn label_kinds(content: &str, opts: MathParseOptions) -> Vec<SyntaxKind> {
692        node_with(content, opts)
693            .descendants_with_tokens()
694            .filter_map(|el| el.into_token())
695            .map(|tok| tok.kind())
696            .collect()
697    }
698
699    #[test]
700    fn equation_label_recognized_when_enabled() {
701        let kinds = label_kinds(r"a (\#eq:foo)", BOOKDOWN);
702        assert!(kinds.contains(&SyntaxKind::MATH_EQUATION_LABEL));
703        // The label is a single token spanning the whole `(\#eq:foo)`.
704        let label = node_with(r"a (\#eq:foo)", BOOKDOWN)
705            .descendants_with_tokens()
706            .filter_map(|el| el.into_token())
707            .find(|t| t.kind() == SyntaxKind::MATH_EQUATION_LABEL)
708            .expect("label token");
709        assert_eq!(label.text(), r"(\#eq:foo)");
710    }
711
712    #[test]
713    fn equation_label_ignored_when_disabled() {
714        // Default options: no label token, and plain math is byte-identical.
715        let kinds = label_kinds(r"a (\#eq:foo)", MathParseOptions::default());
716        assert!(!kinds.contains(&SyntaxKind::MATH_EQUATION_LABEL));
717    }
718
719    #[test]
720    fn plain_parens_tokenize_the_same_with_or_without_bookdown() {
721        // A non-label `(` is an ordinary open delimiter in both modes; only a
722        // genuine `(\#eq:...)` label is special, and only when the extension is
723        // on. So `f(x)` tokenizes identically either way.
724        let expected = vec![
725            SyntaxKind::MATH_TEXT,  // f
726            SyntaxKind::MATH_OPEN,  // (
727            SyntaxKind::MATH_TEXT,  // x
728            SyntaxKind::MATH_CLOSE, // )
729        ];
730        assert_eq!(token_kinds("f(x)"), expected);
731        assert_eq!(label_kinds("f(x)", BOOKDOWN), expected);
732    }
733
734    #[test]
735    fn label_parsing_is_lossless() {
736        let content = "\\begin{align}\n  a (\\#eq:solveG)\n\\end{align}";
737        assert_eq!(node_with(content, BOOKDOWN).text().to_string(), content);
738    }
739}