panache_parser/syntax/
math.rs1use super::{AstNode, PanacheLanguage, SyntaxKind, SyntaxNode};
4
5pub 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
28fn 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 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}