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_LINE_BREAK
43            | SyntaxKind::MATH_COMMENT
44            | SyntaxKind::MATH_EQUATION_LABEL
45    )
46}
47
48pub struct DisplayMath(SyntaxNode);
49
50impl AstNode for DisplayMath {
51    type Language = PanacheLanguage;
52
53    fn can_cast(kind: SyntaxKind) -> bool {
54        kind == SyntaxKind::DISPLAY_MATH
55    }
56
57    fn cast(syntax: SyntaxNode) -> Option<Self> {
58        if Self::can_cast(syntax.kind()) {
59            Some(Self(syntax))
60        } else {
61            None
62        }
63    }
64
65    fn syntax(&self) -> &SyntaxNode {
66        &self.0
67    }
68}
69
70impl DisplayMath {
71    pub fn opening_marker(&self) -> Option<String> {
72        self.0.children_with_tokens().find_map(|child| {
73            child.into_token().and_then(|token| {
74                (token.kind() == SyntaxKind::DISPLAY_MATH_MARKER).then(|| token.text().to_string())
75            })
76        })
77    }
78
79    pub fn closing_marker(&self) -> Option<String> {
80        self.0
81            .children_with_tokens()
82            .filter_map(|child| child.into_token())
83            .filter(|token| token.kind() == SyntaxKind::DISPLAY_MATH_MARKER)
84            .nth(1)
85            .map(|token| token.text().to_string())
86    }
87
88    /// The raw math content between the delimiters, reconstructed from the
89    /// `MATH_CONTENT` subtree (excluding host container prefixes — see
90    /// [`math_content_text`]).
91    pub fn content(&self) -> String {
92        math_content_text(&self.0)
93    }
94
95    pub fn is_environment_form(&self) -> bool {
96        let opening = self.opening_marker().unwrap_or_default();
97        let closing = self.closing_marker().unwrap_or_default();
98        opening.starts_with("\\begin{") && closing.starts_with("\\end{")
99    }
100
101    pub fn has_unescaped_single_dollar_in_content(&self) -> bool {
102        let content = self.content();
103        let chars: Vec<char> = content.chars().collect();
104        let mut idx = 0usize;
105        let mut backslashes = 0usize;
106
107        while idx < chars.len() {
108            let ch = chars[idx];
109            if ch == '\\' {
110                backslashes += 1;
111                idx += 1;
112                continue;
113            }
114
115            let escaped = backslashes % 2 == 1;
116            backslashes = 0;
117            if ch == '$' && !escaped {
118                if idx + 1 < chars.len() && chars[idx + 1] == '$' {
119                    idx += 2;
120                    continue;
121                }
122                return true;
123            }
124            idx += 1;
125        }
126
127        false
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::parse;
135
136    #[test]
137    fn display_math_dollar_markers_and_content() {
138        let tree = parse("$$\nx^2 + y^2\n$$\n", None);
139        let math = tree
140            .descendants()
141            .find_map(DisplayMath::cast)
142            .expect("display math");
143
144        assert_eq!(math.opening_marker().as_deref(), Some("$$"));
145        assert_eq!(math.closing_marker().as_deref(), Some("$$"));
146        assert!(math.content().contains("x^2 + y^2"));
147        assert!(!math.is_environment_form());
148    }
149
150    #[test]
151    fn display_math_environment_form_detection() {
152        let tree = parse("\\begin{align}\na &= b\\\\\n\\end{align}\n", None);
153        let math = tree
154            .descendants()
155            .find_map(DisplayMath::cast)
156            .expect("display math");
157
158        assert!(math.is_environment_form());
159        assert_eq!(math.opening_marker().as_deref(), Some("\\begin{align}"));
160        assert_eq!(math.closing_marker().as_deref(), Some("\\end{align}\n"));
161    }
162
163    #[test]
164    fn display_math_detects_unescaped_single_dollar() {
165        let tree = parse("$$\nalpha $beta$ gamma\n$$\n", None);
166        let math = tree
167            .descendants()
168            .find_map(DisplayMath::cast)
169            .expect("display math");
170        assert!(math.has_unescaped_single_dollar_in_content());
171    }
172}