Skip to main content

panache_parser/syntax/
math.rs

1//! Math AST node wrappers.
2
3use super::{AstNode, PanacheLanguage, SyntaxKind, SyntaxNode};
4
5/// Reconstruct the raw math content of a math node from its `MATH_CONTENT`
6/// subtree, keeping only the math tokens.
7///
8/// Container machinery (blockquotes, list continuations, …) interleaves host
9/// prefix tokens (`BLOCK_QUOTE_MARKER`, `WHITESPACE`, `NEWLINE`) into the
10/// subtree on continuation lines for lossless capture. Those prefixes are not
11/// part of the math, so they are excluded here — otherwise e.g. a blockquote
12/// `>` would leak into the content and re-accumulate on every format pass.
13pub fn math_content_text(math: &SyntaxNode) -> String {
14    let Some(content) = math
15        .children()
16        .find(|node| node.kind() == SyntaxKind::MATH_CONTENT)
17    else {
18        return String::new();
19    };
20    content
21        .descendants_with_tokens()
22        .filter_map(|el| el.into_token())
23        .filter(|tok| is_math_content_token(tok.kind()))
24        .map(|tok| tok.text().to_string())
25        .collect()
26}
27
28/// Whether `kind` is a math-content token emitted by the math parser (as
29/// opposed to a host container prefix interleaved into the subtree).
30fn is_math_content_token(kind: SyntaxKind) -> bool {
31    matches!(
32        kind,
33        SyntaxKind::MATH_TEXT
34            | SyntaxKind::MATH_SPACE
35            | SyntaxKind::MATH_NEWLINE
36            | SyntaxKind::MATH_COMMAND
37            | SyntaxKind::MATH_GROUP_OPEN
38            | SyntaxKind::MATH_GROUP_CLOSE
39            | SyntaxKind::MATH_ALIGN
40            | SyntaxKind::MATH_SCRIPT
41            | SyntaxKind::MATH_OPERATOR
42            | SyntaxKind::MATH_OPEN
43            | SyntaxKind::MATH_CLOSE
44            | SyntaxKind::MATH_PUNCT
45            | SyntaxKind::MATH_LINE_BREAK
46            | SyntaxKind::MATH_COMMENT
47            | SyntaxKind::MATH_EQUATION_LABEL
48    )
49}
50
51pub struct DisplayMath(SyntaxNode);
52
53impl AstNode for DisplayMath {
54    type Language = PanacheLanguage;
55
56    fn can_cast(kind: SyntaxKind) -> bool {
57        kind == SyntaxKind::DISPLAY_MATH
58    }
59
60    fn cast(syntax: SyntaxNode) -> Option<Self> {
61        if Self::can_cast(syntax.kind()) {
62            Some(Self(syntax))
63        } else {
64            None
65        }
66    }
67
68    fn syntax(&self) -> &SyntaxNode {
69        &self.0
70    }
71}
72
73impl DisplayMath {
74    pub fn opening_marker(&self) -> Option<String> {
75        self.0.children_with_tokens().find_map(|child| {
76            child.into_token().and_then(|token| {
77                (token.kind() == SyntaxKind::DISPLAY_MATH_MARKER).then(|| token.text().to_string())
78            })
79        })
80    }
81
82    pub fn closing_marker(&self) -> Option<String> {
83        self.0
84            .children_with_tokens()
85            .filter_map(|child| child.into_token())
86            .filter(|token| token.kind() == SyntaxKind::DISPLAY_MATH_MARKER)
87            .nth(1)
88            .map(|token| token.text().to_string())
89    }
90
91    /// The raw math content between the delimiters, reconstructed from the
92    /// `MATH_CONTENT` subtree (excluding host container prefixes — see
93    /// [`math_content_text`]).
94    pub fn content(&self) -> String {
95        math_content_text(&self.0)
96    }
97
98    pub fn is_environment_form(&self) -> bool {
99        let opening = self.opening_marker().unwrap_or_default();
100        let closing = self.closing_marker().unwrap_or_default();
101        opening.starts_with("\\begin{") && closing.starts_with("\\end{")
102    }
103
104    pub fn has_unescaped_single_dollar_in_content(&self) -> bool {
105        let content = self.content();
106        let chars: Vec<char> = content.chars().collect();
107        let mut idx = 0usize;
108        let mut backslashes = 0usize;
109
110        while idx < chars.len() {
111            let ch = chars[idx];
112            if ch == '\\' {
113                backslashes += 1;
114                idx += 1;
115                continue;
116            }
117
118            let escaped = backslashes % 2 == 1;
119            backslashes = 0;
120            if ch == '$' && !escaped {
121                if idx + 1 < chars.len() && chars[idx + 1] == '$' {
122                    idx += 2;
123                    continue;
124                }
125                return true;
126            }
127            idx += 1;
128        }
129
130        false
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::parse;
138
139    #[test]
140    fn display_math_dollar_markers_and_content() {
141        let tree = parse("$$\nx^2 + y^2\n$$\n", None);
142        let math = tree
143            .descendants()
144            .find_map(DisplayMath::cast)
145            .expect("display math");
146
147        assert_eq!(math.opening_marker().as_deref(), Some("$$"));
148        assert_eq!(math.closing_marker().as_deref(), Some("$$"));
149        assert!(math.content().contains("x^2 + y^2"));
150        assert!(!math.is_environment_form());
151    }
152
153    #[test]
154    fn display_math_environment_form_detection() {
155        let tree = parse("\\begin{align}\na &= b\\\\\n\\end{align}\n", None);
156        let math = tree
157            .descendants()
158            .find_map(DisplayMath::cast)
159            .expect("display math");
160
161        assert!(math.is_environment_form());
162        assert_eq!(math.opening_marker().as_deref(), Some("\\begin{align}"));
163        assert_eq!(math.closing_marker().as_deref(), Some("\\end{align}\n"));
164    }
165
166    #[test]
167    fn display_math_detects_unescaped_single_dollar() {
168        let tree = parse("$$\nalpha $beta$ gamma\n$$\n", None);
169        let math = tree
170            .descendants()
171            .find_map(DisplayMath::cast)
172            .expect("display math");
173        assert!(math.has_unescaped_single_dollar_in_content());
174    }
175}