Skip to main content

panache_parser/syntax/
math.rs

1//! Math AST node wrappers.
2
3use rowan::TextRange;
4
5use super::{AstNode, PanacheLanguage, SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken};
6
7/// Reconstruct the raw math content of a math node from its `MATH_CONTENT`
8/// subtree, keeping only the math tokens.
9///
10/// Container machinery (blockquotes, list continuations, …) interleaves host
11/// prefix tokens (`BLOCK_QUOTE_MARKER`, `WHITESPACE`, `NEWLINE`) into the
12/// subtree on continuation lines for lossless capture. Those prefixes are not
13/// part of the math, so they are excluded here — otherwise e.g. a blockquote
14/// `>` would leak into the content and re-accumulate on every format pass.
15pub fn math_content_text(math: &SyntaxNode) -> String {
16    let Some(content) = math
17        .children()
18        .find(|node| node.kind() == SyntaxKind::MATH_CONTENT)
19    else {
20        return String::new();
21    };
22    content
23        .descendants_with_tokens()
24        .filter_map(|el| el.into_token())
25        .filter(|tok| is_math_content_token(tok.kind()))
26        .map(|tok| tok.text().to_string())
27        .collect()
28}
29
30/// Whether `kind` is a math-content token emitted by the math parser (as
31/// opposed to a host container prefix interleaved into the subtree).
32fn is_math_content_token(kind: SyntaxKind) -> bool {
33    matches!(
34        kind,
35        SyntaxKind::MATH_TEXT
36            | SyntaxKind::MATH_SPACE
37            | SyntaxKind::MATH_NEWLINE
38            | SyntaxKind::MATH_COMMAND
39            | SyntaxKind::MATH_GROUP_OPEN
40            | SyntaxKind::MATH_GROUP_CLOSE
41            | SyntaxKind::MATH_ALIGN
42            | SyntaxKind::MATH_SCRIPT
43            | SyntaxKind::MATH_OPERATOR
44            | SyntaxKind::MATH_OPEN
45            | SyntaxKind::MATH_CLOSE
46            | SyntaxKind::MATH_PUNCT
47            | SyntaxKind::MATH_LINE_BREAK
48            | SyntaxKind::MATH_COMMENT
49            | SyntaxKind::MATH_EQUATION_LABEL
50    )
51}
52
53pub struct DisplayMath(SyntaxNode);
54
55impl AstNode for DisplayMath {
56    type Language = PanacheLanguage;
57
58    fn can_cast(kind: SyntaxKind) -> bool {
59        kind == SyntaxKind::DISPLAY_MATH
60    }
61
62    fn cast(syntax: SyntaxNode) -> Option<Self> {
63        if Self::can_cast(syntax.kind()) {
64            Some(Self(syntax))
65        } else {
66            None
67        }
68    }
69
70    fn syntax(&self) -> &SyntaxNode {
71        &self.0
72    }
73}
74
75impl DisplayMath {
76    pub fn opening_marker(&self) -> Option<String> {
77        self.0.children_with_tokens().find_map(|child| {
78            child.into_token().and_then(|token| {
79                (token.kind() == SyntaxKind::DISPLAY_MATH_MARKER).then(|| token.text().to_string())
80            })
81        })
82    }
83
84    pub fn closing_marker(&self) -> Option<String> {
85        self.0
86            .children_with_tokens()
87            .filter_map(|child| child.into_token())
88            .filter(|token| token.kind() == SyntaxKind::DISPLAY_MATH_MARKER)
89            .nth(1)
90            .map(|token| token.text().to_string())
91    }
92
93    /// The raw math content between the delimiters, reconstructed from the
94    /// `MATH_CONTENT` subtree (excluding host container prefixes — see
95    /// [`math_content_text`]).
96    pub fn content(&self) -> String {
97        math_content_text(&self.0)
98    }
99
100    pub fn is_environment_form(&self) -> bool {
101        let opening = self.opening_marker().unwrap_or_default();
102        let closing = self.closing_marker().unwrap_or_default();
103        opening.starts_with("\\begin{") && closing.starts_with("\\end{")
104    }
105
106    pub fn has_unescaped_single_dollar_in_content(&self) -> bool {
107        let content = self.content();
108        let chars: Vec<char> = content.chars().collect();
109        let mut idx = 0usize;
110        let mut backslashes = 0usize;
111
112        while idx < chars.len() {
113            let ch = chars[idx];
114            if ch == '\\' {
115                backslashes += 1;
116                idx += 1;
117                continue;
118            }
119
120            let escaped = backslashes % 2 == 1;
121            backslashes = 0;
122            if ch == '$' && !escaped {
123                if idx + 1 < chars.len() && chars[idx + 1] == '$' {
124                    idx += 2;
125                    continue;
126                }
127                return true;
128            }
129            idx += 1;
130        }
131
132        false
133    }
134}
135
136/// The `MATH_CONTENT` subtree root: the parsed TeX content between (but
137/// excluding) the math delimiters. Spliced into the host document tree, so its
138/// tokens carry host-aligned ranges.
139pub struct MathContent(SyntaxNode);
140
141impl AstNode for MathContent {
142    type Language = PanacheLanguage;
143
144    fn can_cast(kind: SyntaxKind) -> bool {
145        kind == SyntaxKind::MATH_CONTENT
146    }
147
148    fn cast(syntax: SyntaxNode) -> Option<Self> {
149        Self::can_cast(syntax.kind()).then_some(Self(syntax))
150    }
151
152    fn syntax(&self) -> &SyntaxNode {
153        &self.0
154    }
155}
156
157impl MathContent {
158    /// Structural problems in this subtree (see [`math_diagnostics`]).
159    pub fn diagnostics(&self) -> Vec<MathDiagnostic> {
160        math_diagnostics(&self.0)
161    }
162}
163
164/// A `{ ... }` brace group.
165pub struct MathGroup(SyntaxNode);
166
167impl AstNode for MathGroup {
168    type Language = PanacheLanguage;
169
170    fn can_cast(kind: SyntaxKind) -> bool {
171        kind == SyntaxKind::MATH_GROUP
172    }
173
174    fn cast(syntax: SyntaxNode) -> Option<Self> {
175        Self::can_cast(syntax.kind()).then_some(Self(syntax))
176    }
177
178    fn syntax(&self) -> &SyntaxNode {
179        &self.0
180    }
181}
182
183impl MathGroup {
184    /// The opening `{` token.
185    pub fn open_token(&self) -> Option<SyntaxToken> {
186        token_child(&self.0, SyntaxKind::MATH_GROUP_OPEN)
187    }
188
189    /// The closing `}` token, absent when the group is unclosed.
190    pub fn close_token(&self) -> Option<SyntaxToken> {
191        token_child(&self.0, SyntaxKind::MATH_GROUP_CLOSE)
192    }
193
194    /// Whether the group carries a matching `}`.
195    pub fn is_closed(&self) -> bool {
196        self.close_token().is_some()
197    }
198}
199
200/// A `\begin{env} ... \end{env}` environment.
201pub struct MathEnvironment(SyntaxNode);
202
203impl AstNode for MathEnvironment {
204    type Language = PanacheLanguage;
205
206    fn can_cast(kind: SyntaxKind) -> bool {
207        kind == SyntaxKind::MATH_ENVIRONMENT
208    }
209
210    fn cast(syntax: SyntaxNode) -> Option<Self> {
211        Self::can_cast(syntax.kind()).then_some(Self(syntax))
212    }
213
214    fn syntax(&self) -> &SyntaxNode {
215        &self.0
216    }
217}
218
219impl MathEnvironment {
220    /// The `\begin` command token.
221    pub fn begin_token(&self) -> Option<SyntaxToken> {
222        command_child(&self.0, r"\begin")
223    }
224
225    /// The `\end` command token, absent when the environment is unclosed.
226    pub fn end_token(&self) -> Option<SyntaxToken> {
227        command_child(&self.0, r"\end")
228    }
229
230    /// Whether the environment carries a matching `\end`.
231    pub fn is_closed(&self) -> bool {
232        self.end_token().is_some()
233    }
234
235    /// The `{name}` group following `\begin`, braces stripped.
236    pub fn begin_name(&self) -> Option<String> {
237        let children: Vec<SyntaxElement> = self.0.children_with_tokens().collect();
238        let bi = children.iter().position(|c| is_command(c, r"\begin"))?;
239        group_name_after(&children, bi)
240    }
241
242    /// The `{name}` group following `\end`, braces stripped.
243    pub fn end_name(&self) -> Option<String> {
244        let children: Vec<SyntaxElement> = self.0.children_with_tokens().collect();
245        let ei = children.iter().position(|c| is_command(c, r"\end"))?;
246        group_name_after(&children, ei)
247    }
248}
249
250/// A `\left<d> ... \right<d>` paired-delimiter run.
251pub struct MathDelimited(SyntaxNode);
252
253impl AstNode for MathDelimited {
254    type Language = PanacheLanguage;
255
256    fn can_cast(kind: SyntaxKind) -> bool {
257        kind == SyntaxKind::MATH_DELIMITED
258    }
259
260    fn cast(syntax: SyntaxNode) -> Option<Self> {
261        Self::can_cast(syntax.kind()).then_some(Self(syntax))
262    }
263
264    fn syntax(&self) -> &SyntaxNode {
265        &self.0
266    }
267}
268
269impl MathDelimited {
270    /// The opening `\left` command token.
271    pub fn left_token(&self) -> Option<SyntaxToken> {
272        command_child(&self.0, r"\left")
273    }
274
275    /// The closing `\right` command token, absent when the run is unclosed.
276    pub fn right_token(&self) -> Option<SyntaxToken> {
277        command_child(&self.0, r"\right")
278    }
279
280    /// Whether the run carries a matching `\right`.
281    pub fn is_closed(&self) -> bool {
282        self.right_token().is_some()
283    }
284}
285
286/// A structural problem found in a realized `MATH_CONTENT` subtree. The `range`
287/// is host-aligned (the subtree is spliced into the host document tree), so a
288/// consumer turns it straight into a diagnostic span with no remapping.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub struct MathDiagnostic {
291    pub kind: MathDiagnosticKind,
292    pub range: TextRange,
293}
294
295/// The kind of a [`MathDiagnostic`]. A neutral structural identity; downstream
296/// consumers (the linter, LSP) map it to their own code and message. The parser
297/// crate deliberately does not own linter code strings.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum MathDiagnosticKind {
300    /// A `{` group with no closing `}` (`MATH_GROUP` lacking `MATH_GROUP_CLOSE`).
301    UnclosedGroup,
302    /// A `}` with no matching `{` (`MATH_GROUP_CLOSE` outside a `MATH_GROUP`).
303    UnexpectedCloseBrace,
304    /// A `\begin` with no matching `\end`.
305    UnclosedEnvironment,
306    /// A `\begin{a}` closed by `\end{b}` with a different name.
307    MismatchedEnvironment,
308    /// A `\end` with no open `\begin`.
309    UnexpectedEnd,
310    /// A `\left` with no matching `\right` (`MATH_DELIMITED` lacking `\right`).
311    UnclosedDelimiter,
312    /// A `\right` with no open `\left` (`\right` outside a `MATH_DELIMITED`).
313    UnexpectedRight,
314}
315
316/// Walk a realized `MATH_CONTENT` subtree and report structural problems from
317/// its shape. This is the single source of truth for math diagnostics, shared
318/// by the `math-syntax` linter rule, the formatter (malformed math is left
319/// verbatim rather than reflowed), and the LSP. Ranges are host-aligned.
320///
321/// `content` is expected to be a `MATH_CONTENT` node; its own descendants are
322/// walked, so both a standalone sub-parse root and an embedded host-tree node
323/// work.
324pub fn math_diagnostics(content: &SyntaxNode) -> Vec<MathDiagnostic> {
325    let mut out = Vec::new();
326    for node in content.descendants() {
327        if let Some(group) = MathGroup::cast(node.clone()) {
328            check_group(&group, &mut out);
329        } else if let Some(env) = MathEnvironment::cast(node.clone()) {
330            check_environment(&env, &mut out);
331        } else if let Some(delim) = MathDelimited::cast(node.clone()) {
332            check_delimited(&delim, &mut out);
333        }
334        // Stray tokens are flagged by their parent context: each token is a
335        // direct child of exactly one node, so iterating every node's direct
336        // children visits every token once.
337        for child in node.children_with_tokens() {
338            let Some(token) = child.as_token() else {
339                continue;
340            };
341            match token.kind() {
342                SyntaxKind::MATH_GROUP_CLOSE if node.kind() != SyntaxKind::MATH_GROUP => {
343                    out.push(MathDiagnostic {
344                        kind: MathDiagnosticKind::UnexpectedCloseBrace,
345                        range: token.text_range(),
346                    });
347                }
348                SyntaxKind::MATH_COMMAND
349                    if node.kind() != SyntaxKind::MATH_ENVIRONMENT && token.text() == r"\end" =>
350                {
351                    out.push(MathDiagnostic {
352                        kind: MathDiagnosticKind::UnexpectedEnd,
353                        range: token.text_range(),
354                    });
355                }
356                SyntaxKind::MATH_COMMAND
357                    if node.kind() != SyntaxKind::MATH_DELIMITED && token.text() == r"\right" =>
358                {
359                    out.push(MathDiagnostic {
360                        kind: MathDiagnosticKind::UnexpectedRight,
361                        range: token.text_range(),
362                    });
363                }
364                _ => {}
365            }
366        }
367    }
368    out
369}
370
371/// A `MATH_GROUP` is well-formed only if it carries a closing `}`; the parser
372/// emits `MATH_GROUP_CLOSE` solely when the brace is matched.
373fn check_group(group: &MathGroup, out: &mut Vec<MathDiagnostic>) {
374    if group.is_closed() {
375        return;
376    }
377    if let Some(open) = group.open_token() {
378        out.push(MathDiagnostic {
379            kind: MathDiagnosticKind::UnclosedGroup,
380            range: open.text_range(),
381        });
382    }
383}
384
385fn check_environment(env: &MathEnvironment, out: &mut Vec<MathDiagnostic>) {
386    let Some(end) = env.end_token() else {
387        // No closing `\end`: point at the opening `\begin` (or the whole node).
388        let range = env
389            .begin_token()
390            .map(|t| t.text_range())
391            .unwrap_or_else(|| env.syntax().text_range());
392        out.push(MathDiagnostic {
393            kind: MathDiagnosticKind::UnclosedEnvironment,
394            range,
395        });
396        return;
397    };
398    if env.begin_name().unwrap_or_default() != env.end_name().unwrap_or_default() {
399        // Point at the `\end` name group (or the `\end` token if unnamed).
400        let children: Vec<SyntaxElement> = env.syntax().children_with_tokens().collect();
401        let range = children
402            .iter()
403            .position(|c| is_command(c, r"\end"))
404            .and_then(|ei| group_range_after(&children, ei))
405            .unwrap_or_else(|| end.text_range());
406        out.push(MathDiagnostic {
407            kind: MathDiagnosticKind::MismatchedEnvironment,
408            range,
409        });
410    }
411}
412
413/// A `MATH_DELIMITED` run is well-formed only if it carries a closing `\right`;
414/// the parser emits it solely when the `\left` was matched.
415fn check_delimited(delim: &MathDelimited, out: &mut Vec<MathDiagnostic>) {
416    if delim.is_closed() {
417        return;
418    }
419    if let Some(left) = delim.left_token() {
420        out.push(MathDiagnostic {
421            kind: MathDiagnosticKind::UnclosedDelimiter,
422            range: left.text_range(),
423        });
424    }
425}
426
427/// The first direct token child of `node` with the given `kind`.
428fn token_child(node: &SyntaxNode, kind: SyntaxKind) -> Option<SyntaxToken> {
429    node.children_with_tokens()
430        .filter_map(|c| c.into_token())
431        .find(|t| t.kind() == kind)
432}
433
434/// The first direct `MATH_COMMAND` token child with exactly `text`.
435fn command_child(node: &SyntaxNode, text: &str) -> Option<SyntaxToken> {
436    node.children_with_tokens()
437        .filter_map(|c| c.into_token())
438        .find(|t| t.kind() == SyntaxKind::MATH_COMMAND && t.text() == text)
439}
440
441fn is_command(el: &SyntaxElement, text: &str) -> bool {
442    el.as_token()
443        .is_some_and(|t| t.kind() == SyntaxKind::MATH_COMMAND && t.text() == text)
444}
445
446/// Inner text of the first `MATH_GROUP` after `idx` (the environment name
447/// group), with its braces stripped — mirrors `parse_environment_name`.
448fn group_name_after(children: &[SyntaxElement], idx: usize) -> Option<String> {
449    children[idx + 1..].iter().find_map(|c| {
450        c.as_node()
451            .filter(|n| n.kind() == SyntaxKind::MATH_GROUP)
452            .map(|g| {
453                g.text()
454                    .to_string()
455                    .trim_start_matches('{')
456                    .trim_end_matches('}')
457                    .to_string()
458            })
459    })
460}
461
462fn group_range_after(children: &[SyntaxElement], idx: usize) -> Option<TextRange> {
463    children[idx + 1..].iter().find_map(|c| {
464        c.as_node()
465            .filter(|n| n.kind() == SyntaxKind::MATH_GROUP)
466            .map(|g| g.text_range())
467    })
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::parse;
474
475    #[test]
476    fn display_math_dollar_markers_and_content() {
477        let tree = parse("$$\nx^2 + y^2\n$$\n", None);
478        let math = tree
479            .descendants()
480            .find_map(DisplayMath::cast)
481            .expect("display math");
482
483        assert_eq!(math.opening_marker().as_deref(), Some("$$"));
484        assert_eq!(math.closing_marker().as_deref(), Some("$$"));
485        assert!(math.content().contains("x^2 + y^2"));
486        assert!(!math.is_environment_form());
487    }
488
489    #[test]
490    fn display_math_environment_form_detection() {
491        let tree = parse("\\begin{align}\na &= b\\\\\n\\end{align}\n", None);
492        let math = tree
493            .descendants()
494            .find_map(DisplayMath::cast)
495            .expect("display math");
496
497        assert!(math.is_environment_form());
498        assert_eq!(math.opening_marker().as_deref(), Some("\\begin{align}"));
499        assert_eq!(math.closing_marker().as_deref(), Some("\\end{align}\n"));
500    }
501
502    #[test]
503    fn display_math_detects_unescaped_single_dollar() {
504        let tree = parse("$$\nalpha $beta$ gamma\n$$\n", None);
505        let math = tree
506            .descendants()
507            .find_map(DisplayMath::cast)
508            .expect("display math");
509        assert!(math.has_unescaped_single_dollar_in_content());
510    }
511
512    // --- Diagnostics derived from the realized MATH_CONTENT subtree ---
513
514    use crate::parser::math::{MathParseOptions, parse_math_content};
515
516    /// Build a standalone `MATH_CONTENT` root from raw content and report its
517    /// diagnostic kinds. The sub-parse root is itself `MATH_CONTENT`, matching
518    /// the embedded-node case the linter/formatter feed in.
519    fn diag_kinds(content: &str) -> Vec<MathDiagnosticKind> {
520        let node = SyntaxNode::new_root(parse_math_content(content, MathParseOptions::default()));
521        math_diagnostics(&node)
522            .into_iter()
523            .map(|d| d.kind)
524            .collect()
525    }
526
527    #[test]
528    fn unclosed_group_is_diagnosed_at_the_open_brace() {
529        let node = SyntaxNode::new_root(parse_math_content("{a", MathParseOptions::default()));
530        let diags = math_diagnostics(&node);
531        assert_eq!(
532            diags.iter().map(|d| d.kind).collect::<Vec<_>>(),
533            vec![MathDiagnosticKind::UnclosedGroup]
534        );
535        let start: usize = diags[0].range.start().into();
536        let end: usize = diags[0].range.end().into();
537        assert_eq!(&"{a"[start..end], "{");
538    }
539
540    #[test]
541    fn stray_close_brace_is_diagnosed() {
542        assert_eq!(
543            diag_kinds("a}b"),
544            vec![MathDiagnosticKind::UnexpectedCloseBrace]
545        );
546    }
547
548    #[test]
549    fn unclosed_environment_is_diagnosed() {
550        assert_eq!(
551            diag_kinds(r"\begin{aligned} x &= 1"),
552            vec![MathDiagnosticKind::UnclosedEnvironment]
553        );
554    }
555
556    #[test]
557    fn mismatched_environment_is_diagnosed() {
558        assert_eq!(
559            diag_kinds(r"\begin{aligned}x\end{matrix}"),
560            vec![MathDiagnosticKind::MismatchedEnvironment]
561        );
562    }
563
564    #[test]
565    fn stray_end_is_diagnosed() {
566        assert_eq!(
567            diag_kinds(r"x \end{aligned}"),
568            vec![MathDiagnosticKind::UnexpectedEnd]
569        );
570    }
571
572    #[test]
573    fn well_formed_math_has_no_diagnostics() {
574        assert!(diag_kinds(r"\frac{1}{2} + x^{2}").is_empty());
575        assert!(diag_kinds(r"\begin{a}\begin{b}x\end{b}\end{a}").is_empty());
576        assert!(diag_kinds(r"\left( x + y \right]").is_empty());
577        assert!(diag_kinds(r"\left. x \right|").is_empty());
578    }
579
580    #[test]
581    fn unclosed_delimiter_is_diagnosed_at_the_left() {
582        let node =
583            SyntaxNode::new_root(parse_math_content(r"\left( x", MathParseOptions::default()));
584        let diags = math_diagnostics(&node);
585        assert_eq!(
586            diags.iter().map(|d| d.kind).collect::<Vec<_>>(),
587            vec![MathDiagnosticKind::UnclosedDelimiter]
588        );
589        let start: usize = diags[0].range.start().into();
590        let end: usize = diags[0].range.end().into();
591        assert_eq!(&r"\left( x"[start..end], r"\left");
592    }
593
594    #[test]
595    fn stray_right_is_diagnosed() {
596        assert_eq!(
597            diag_kinds(r"x \right)"),
598            vec![MathDiagnosticKind::UnexpectedRight]
599        );
600    }
601}