panache_parser/syntax/
math.rs1use rowan::TextRange;
4
5use super::{AstNode, PanacheLanguage, SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken};
6
7pub 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
30fn 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 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
136pub 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 pub fn diagnostics(&self) -> Vec<MathDiagnostic> {
160 math_diagnostics(&self.0)
161 }
162}
163
164pub 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 pub fn open_token(&self) -> Option<SyntaxToken> {
186 token_child(&self.0, SyntaxKind::MATH_GROUP_OPEN)
187 }
188
189 pub fn close_token(&self) -> Option<SyntaxToken> {
191 token_child(&self.0, SyntaxKind::MATH_GROUP_CLOSE)
192 }
193
194 pub fn is_closed(&self) -> bool {
196 self.close_token().is_some()
197 }
198}
199
200pub 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 pub fn begin_token(&self) -> Option<SyntaxToken> {
222 command_child(&self.0, r"\begin")
223 }
224
225 pub fn end_token(&self) -> Option<SyntaxToken> {
227 command_child(&self.0, r"\end")
228 }
229
230 pub fn is_closed(&self) -> bool {
232 self.end_token().is_some()
233 }
234
235 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 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
250pub 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 pub fn left_token(&self) -> Option<SyntaxToken> {
272 command_child(&self.0, r"\left")
273 }
274
275 pub fn right_token(&self) -> Option<SyntaxToken> {
277 command_child(&self.0, r"\right")
278 }
279
280 pub fn is_closed(&self) -> bool {
282 self.right_token().is_some()
283 }
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub struct MathDiagnostic {
291 pub kind: MathDiagnosticKind,
292 pub range: TextRange,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum MathDiagnosticKind {
300 UnclosedGroup,
302 UnexpectedCloseBrace,
304 UnclosedEnvironment,
306 MismatchedEnvironment,
308 UnexpectedEnd,
310 UnclosedDelimiter,
312 UnexpectedRight,
314}
315
316pub 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 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
371fn 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 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 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
413fn 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
427fn 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
434fn 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
446fn 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 use crate::parser::math::{MathParseOptions, parse_math_content};
515
516 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}