Skip to main content

panache_parser/parser/inlines/
math.rs

1//! Math parsing for both inline and display math.
2//!
3//! This module handles all math-related parsing:
4//! - **Inline math**: `$...$`, `$`...`$`, `\(...\)`, `\\(...\\)`. Under the
5//!   Pandoc dialect these may span a single newline within a paragraph (a blank
6//!   line ends the span); under the CommonMark dialect (GFM, etc.) they are
7//!   single line only. Callers pass `allow_multiline` from `dialect == Pandoc`.
8//! - **Display math**: `$$...$$`, `\[...\]`, `\\[...\\]` - can span multiple lines
9//!
10//! Display math can appear both inline (within paragraphs) and as block-level elements.
11//! The parsing functions return `Option<(usize, &str)>` tuples containing the length
12//! consumed and the math content, allowing calling contexts to emit appropriate nodes.
13
14use super::sink::InlineSink;
15use crate::parser::blocks::raw_blocks::{extract_environment_name, is_inline_math_environment};
16use crate::parser::math::{MathParseOptions, parse_math_content};
17use crate::parser::utils::tree_copy::copy_green_node;
18use crate::syntax::SyntaxKind;
19
20/// Emit the math content as a structural, lossless `MATH_CONTENT` subtree
21/// (brace groups, environments, control sequences, alignment, …) rather than an
22/// opaque `TEXT` token, so the formatter and linter can act on its structure.
23/// See [`crate::parser::math`]. Lossless: the subtree's text equals `content`.
24fn emit_math_content(builder: &mut impl InlineSink, content: &str, opts: MathParseOptions) {
25    copy_green_node(builder, &parse_math_content(content, opts));
26}
27
28/// Derive math-content parse options from the parser config. Keeps the
29/// flavor/extension → math-grammar mapping in one place.
30pub fn math_opts(config: &crate::options::ParserOptions) -> MathParseOptions {
31    MathParseOptions {
32        bookdown_equation_labels: config.extensions.bookdown_equation_references,
33    }
34}
35
36/// Whether a newline reached inside an inline math span ends the math.
37///
38/// When `allow_multiline` is false (CommonMark dialect — GFM, etc.) any newline
39/// ends the span: inline math is single line only. When true (Pandoc dialect)
40/// the span may fold a single newline within a paragraph and only a blank line
41/// — a newline followed by only whitespace and then another line break or end
42/// of input — terminates it. `after_nl` is the text immediately following the
43/// newline. By the time inline parsing runs the block parser has already split
44/// paragraphs at blank lines, so the blank-line arm is a defensive guard.
45fn newline_ends_inline_math(after_nl: &str, allow_multiline: bool) -> bool {
46    if !allow_multiline {
47        return true;
48    }
49    let next = after_nl.trim_start_matches([' ', '\t']);
50    next.is_empty() || next.starts_with('\n') || next.starts_with('\r')
51}
52
53/// Try to parse an inline math span starting at the current position.
54/// Returns the number of characters consumed if successful, or None if not inline math.
55///
56/// Per Pandoc spec (tex_math_dollars extension):
57/// - Opening $ must have non-space character immediately to its right
58/// - Closing $ must have non-space character immediately to its left
59/// - Closing $ must not be followed immediately by a digit
60pub fn try_parse_inline_math(text: &str, allow_multiline: bool) -> Option<(usize, &str)> {
61    // Must start with exactly one $
62    if !text.starts_with('$') || text.starts_with("$$") {
63        return None;
64    }
65
66    let rest = &text[1..];
67
68    // Opening $ must have non-space character immediately to its right
69    if rest.is_empty() || rest.starts_with(char::is_whitespace) {
70        return None;
71    }
72
73    // Look for closing $
74    let mut pos = 0;
75    while pos < rest.len() {
76        let ch = rest[pos..].chars().next()?;
77
78        if ch == '$' {
79            // Check if it's escaped
80            if pos > 0 && rest.as_bytes()[pos - 1] == b'\\' {
81                // Escaped dollar, continue searching
82                pos += 1;
83                continue;
84            }
85
86            // Closing $ must have non-space character immediately to its left
87            if pos == 0 || rest[..pos].ends_with(char::is_whitespace) {
88                // Continue searching - this $ doesn't close the math
89                pos += 1;
90                continue;
91            }
92
93            // Closing $ must not be followed immediately by a digit
94            if let Some(next_ch) = rest[pos + 1..].chars().next()
95                && next_ch.is_ascii_digit()
96            {
97                // Continue searching - this $ doesn't close the math
98                pos += 1;
99                continue;
100            }
101
102            // Found valid closing $
103            let math_content = &rest[..pos];
104            let total_len = 1 + pos + 1; // opening $ + content + closing $
105            return Some((total_len, math_content));
106        }
107
108        if ch == '\n' && newline_ends_inline_math(&rest[pos + 1..], allow_multiline) {
109            return None;
110        }
111
112        pos += ch.len_utf8();
113    }
114
115    // No matching close found
116    None
117}
118
119/// Try to parse GFM inline math: $`...`$
120/// Extension: tex_math_gfm
121pub fn try_parse_gfm_inline_math(text: &str, allow_multiline: bool) -> Option<(usize, &str)> {
122    if !text.starts_with("$`") {
123        return None;
124    }
125
126    let rest = &text[2..];
127    if rest.is_empty() {
128        return None;
129    }
130
131    let mut pos = 0;
132    while pos < rest.len() {
133        let ch = rest[pos..].chars().next()?;
134        if ch == '\n' && newline_ends_inline_math(&rest[pos + 1..], allow_multiline) {
135            return None;
136        }
137        if rest[pos..].starts_with("`$") {
138            if pos == 0 {
139                return None;
140            }
141            let math_content = &rest[..pos];
142            let total_len = 2 + pos + 2; // $` + content + `$
143            return Some((total_len, math_content));
144        }
145        pos += ch.len_utf8();
146    }
147
148    None
149}
150
151/// Try to parse single backslash inline math: \(...\)
152/// Extension: tex_math_single_backslash
153pub fn try_parse_single_backslash_inline_math(
154    text: &str,
155    allow_multiline: bool,
156) -> Option<(usize, &str)> {
157    if !text.starts_with(r"\(") {
158        return None;
159    }
160
161    let rest = &text[2..]; // Skip \(
162
163    // Look for closing \)
164    let mut pos = 0;
165    while pos < rest.len() {
166        let ch = rest[pos..].chars().next()?;
167
168        if ch == '\\' && rest[pos..].starts_with(r"\)") {
169            // Found closing \)
170            let math_content = &rest[..pos];
171            let total_len = 2 + pos + 2; // \( + content + \)
172            return Some((total_len, math_content));
173        }
174
175        if ch == '\n' && newline_ends_inline_math(&rest[pos + 1..], allow_multiline) {
176            return None;
177        }
178
179        pos += ch.len_utf8();
180    }
181
182    None
183}
184
185/// Try to parse double backslash inline math: \\(...\\)
186/// Extension: tex_math_double_backslash
187pub fn try_parse_double_backslash_inline_math(
188    text: &str,
189    allow_multiline: bool,
190) -> Option<(usize, &str)> {
191    if !text.starts_with(r"\\(") {
192        return None;
193    }
194
195    let rest = &text[3..]; // Skip \\(
196
197    // Look for closing \\)
198    let mut pos = 0;
199    while pos < rest.len() {
200        let ch = rest[pos..].chars().next()?;
201
202        if ch == '\\' && rest[pos..].starts_with(r"\\)") {
203            // Found closing \\)
204            let math_content = &rest[..pos];
205            let total_len = 3 + pos + 3; // \\( + content + \\)
206            return Some((total_len, math_content));
207        }
208
209        if ch == '\n' && newline_ends_inline_math(&rest[pos + 1..], allow_multiline) {
210            return None;
211        }
212
213        pos += ch.len_utf8();
214    }
215
216    None
217}
218
219/// Try to parse display math ($$...$$) starting at the current position.
220/// Returns the number of characters consumed and the math content if successful.
221/// Display math can span multiple lines in inline contexts.
222///
223/// Per Pandoc spec (tex_math_dollars extension):
224/// - Opening delimiter is at least $$
225/// - Closing delimiter must have at least as many $ as opening
226/// - Content can span multiple lines
227pub fn try_parse_display_math(text: &str) -> Option<(usize, &str)> {
228    // Must start with at least $$
229    if !text.starts_with("$$") {
230        return None;
231    }
232
233    // Count opening dollar signs
234    let opening_count = text.chars().take_while(|&c| c == '$').count();
235    if opening_count < 2 {
236        return None;
237    }
238
239    let rest = &text[opening_count..];
240
241    // Look for matching closing delimiter
242    let mut pos = 0;
243    while pos < rest.len() {
244        let ch = rest[pos..].chars().next()?;
245
246        if ch == '$' {
247            // Check if it's escaped
248            if pos > 0 && rest.as_bytes()[pos - 1] == b'\\' {
249                // Escaped dollar, continue searching
250                pos += ch.len_utf8();
251                continue;
252            }
253
254            // Count closing dollar signs
255            let closing_count = rest[pos..].chars().take_while(|&c| c == '$').count();
256
257            // Must have at least as many closing dollars as opening
258            if closing_count >= opening_count {
259                let math_content = &rest[..pos];
260                let total_len = opening_count + pos + closing_count;
261                return Some((total_len, math_content));
262            }
263
264            // Not enough dollars, skip this run and continue
265            pos += closing_count;
266            continue;
267        }
268
269        pos += ch.len_utf8();
270    }
271
272    // No matching close found
273    None
274}
275
276/// Try to parse single backslash display math: \[...\]
277/// Extension: tex_math_single_backslash
278///
279/// Per Pandoc spec:
280/// - Content can span multiple lines
281/// - No escape handling needed (backslash is the delimiter)
282pub fn try_parse_single_backslash_display_math(text: &str) -> Option<(usize, &str)> {
283    if !text.starts_with(r"\[") {
284        return None;
285    }
286
287    let rest = &text[2..]; // Skip \[
288
289    // Look for closing \]
290    let mut pos = 0;
291    while pos < rest.len() {
292        let ch = rest[pos..].chars().next()?;
293
294        if ch == '\\' && rest[pos..].starts_with(r"\]") {
295            // Found closing \]
296            let math_content = &rest[..pos];
297            let total_len = 2 + pos + 2; // \[ + content + \]
298            return Some((total_len, math_content));
299        }
300
301        pos += ch.len_utf8();
302    }
303
304    None
305}
306
307/// Try to parse double backslash display math: \\[...\\]
308/// Extension: tex_math_double_backslash
309///
310/// Per Pandoc spec:
311/// - Content can span multiple lines
312/// - Double backslash is the delimiter
313pub fn try_parse_double_backslash_display_math(text: &str) -> Option<(usize, &str)> {
314    if !text.starts_with(r"\\[") {
315        return None;
316    }
317
318    let rest = &text[3..]; // Skip \\[
319
320    // Look for closing \\]
321    let mut pos = 0;
322    while pos < rest.len() {
323        let ch = rest[pos..].chars().next()?;
324
325        if ch == '\\' && rest[pos..].starts_with(r"\\]") {
326            // Found closing \\]
327            let math_content = &rest[..pos];
328            let total_len = 3 + pos + 3; // \\[ + content + \\]
329            return Some((total_len, math_content));
330        }
331
332        pos += ch.len_utf8();
333    }
334
335    None
336}
337
338/// Try to parse a LaTeX math environment (\begin{equation}...\end{equation})
339/// as display math. Returns (total_len, begin_marker, content, end_marker).
340pub fn try_parse_math_environment(text: &str) -> Option<(usize, &str, &str, &str)> {
341    let env_name = extract_environment_name(text)?;
342    if !is_inline_math_environment(env_name) {
343        return None;
344    }
345
346    let begin_marker_len = text.find('}')? + 1;
347    let begin_marker = &text[..begin_marker_len];
348    let end_marker = format!("\\end{{{}}}", env_name);
349
350    let after_begin = &text[begin_marker_len..];
351    let end_rel = after_begin.find(&end_marker)?;
352    let end_start = begin_marker_len + end_rel;
353    let end_marker_end = end_start + end_marker.len();
354
355    let mut end_line_end = end_marker_end;
356    while end_line_end < text.len() {
357        let ch = text[end_line_end..].chars().next()?;
358        if ch == '\n' || ch == '\r' {
359            break;
360        }
361        end_line_end += ch.len_utf8();
362    }
363
364    if end_line_end < text.len() {
365        if text[end_line_end..].starts_with("\r\n") {
366            end_line_end += 2;
367        } else {
368            end_line_end += 1;
369        }
370    }
371
372    let content = &text[begin_marker_len..end_start];
373    let end_marker_text = &text[end_start..end_line_end];
374    Some((end_line_end, begin_marker, content, end_marker_text))
375}
376
377/// Emit an inline math node to the builder.
378pub fn emit_inline_math(builder: &mut impl InlineSink, content: &str, opts: MathParseOptions) {
379    builder.start_node(SyntaxKind::INLINE_MATH.into());
380
381    // Opening $
382    builder.token(SyntaxKind::INLINE_MATH_MARKER.into(), "$");
383
384    // Math content
385    emit_math_content(builder, content, opts);
386
387    // Closing $
388    builder.token(SyntaxKind::INLINE_MATH_MARKER.into(), "$");
389
390    builder.finish_node();
391}
392
393/// Emit a GFM inline math node: $`...`$
394pub fn emit_gfm_inline_math(builder: &mut impl InlineSink, content: &str, opts: MathParseOptions) {
395    builder.start_node(SyntaxKind::INLINE_MATH.into());
396    builder.token(SyntaxKind::INLINE_MATH_MARKER.into(), "$`");
397    emit_math_content(builder, content, opts);
398    builder.token(SyntaxKind::INLINE_MATH_MARKER.into(), "`$");
399    builder.finish_node();
400}
401
402/// Emit a single backslash inline math node: \(...\)
403pub fn emit_single_backslash_inline_math(
404    builder: &mut impl InlineSink,
405    content: &str,
406    opts: MathParseOptions,
407) {
408    builder.start_node(SyntaxKind::INLINE_MATH.into());
409
410    builder.token(SyntaxKind::INLINE_MATH_MARKER.into(), r"\(");
411    emit_math_content(builder, content, opts);
412    builder.token(SyntaxKind::INLINE_MATH_MARKER.into(), r"\)");
413
414    builder.finish_node();
415}
416
417/// Emit a double backslash inline math node: \\(...\\)
418pub fn emit_double_backslash_inline_math(
419    builder: &mut impl InlineSink,
420    content: &str,
421    opts: MathParseOptions,
422) {
423    builder.start_node(SyntaxKind::INLINE_MATH.into());
424
425    builder.token(SyntaxKind::INLINE_MATH_MARKER.into(), r"\\(");
426    emit_math_content(builder, content, opts);
427    builder.token(SyntaxKind::INLINE_MATH_MARKER.into(), r"\\)");
428
429    builder.finish_node();
430}
431
432/// Emit a display math node to the builder (when occurring inline in paragraph).
433pub fn emit_display_math(
434    builder: &mut impl InlineSink,
435    content: &str,
436    dollar_count: usize,
437    opts: MathParseOptions,
438) {
439    builder.start_node(SyntaxKind::DISPLAY_MATH.into());
440
441    // Opening $$
442    let marker = "$".repeat(dollar_count);
443    builder.token(SyntaxKind::DISPLAY_MATH_MARKER.into(), &marker);
444
445    // Math content
446    emit_math_content(builder, content, opts);
447
448    // Closing $$
449    builder.token(SyntaxKind::DISPLAY_MATH_MARKER.into(), &marker);
450
451    builder.finish_node();
452}
453
454/// Emit a display math environment node using raw \begin...\end... markers.
455pub fn emit_display_math_environment(
456    builder: &mut impl InlineSink,
457    begin_marker: &str,
458    content: &str,
459    end_marker: &str,
460    opts: MathParseOptions,
461) {
462    builder.start_node(SyntaxKind::DISPLAY_MATH.into());
463    builder.token(SyntaxKind::DISPLAY_MATH_MARKER.into(), begin_marker);
464    emit_math_content(builder, content, opts);
465    builder.token(SyntaxKind::DISPLAY_MATH_MARKER.into(), end_marker);
466    builder.finish_node();
467}
468
469/// Emit a single backslash display math node: \[...\]
470pub fn emit_single_backslash_display_math(
471    builder: &mut impl InlineSink,
472    content: &str,
473    opts: MathParseOptions,
474) {
475    builder.start_node(SyntaxKind::DISPLAY_MATH.into());
476
477    builder.token(SyntaxKind::DISPLAY_MATH_MARKER.into(), r"\[");
478    emit_math_content(builder, content, opts);
479    builder.token(SyntaxKind::DISPLAY_MATH_MARKER.into(), r"\]");
480
481    builder.finish_node();
482}
483
484/// Emit a double backslash display math node: \\[...\\]
485pub fn emit_double_backslash_display_math(
486    builder: &mut impl InlineSink,
487    content: &str,
488    opts: MathParseOptions,
489) {
490    builder.start_node(SyntaxKind::DISPLAY_MATH.into());
491
492    builder.token(SyntaxKind::DISPLAY_MATH_MARKER.into(), r"\\[");
493    emit_math_content(builder, content, opts);
494    builder.token(SyntaxKind::DISPLAY_MATH_MARKER.into(), r"\\]");
495
496    builder.finish_node();
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn test_parse_simple_inline_math() {
505        let result = try_parse_inline_math("$x = y$", true);
506        assert_eq!(result, Some((7, "x = y")));
507    }
508
509    #[test]
510    fn test_parse_inline_math_with_spaces_inside() {
511        // Spaces inside math are OK, just not immediately after opening or before closing
512        let result = try_parse_inline_math("$a + b$", true);
513        assert_eq!(result, Some((7, "a + b")));
514    }
515
516    #[test]
517    fn test_parse_inline_math_complex() {
518        let result = try_parse_inline_math(r"$\frac{1}{2}$", true);
519        assert_eq!(result, Some((13, r"\frac{1}{2}")));
520    }
521
522    #[test]
523    fn test_not_inline_math_display() {
524        // $$ is display math, not inline
525        let result = try_parse_inline_math("$$x = y$$", true);
526        assert_eq!(result, None);
527    }
528
529    #[test]
530    fn test_inline_math_no_close() {
531        let result = try_parse_inline_math("$no close", true);
532        assert_eq!(result, None);
533    }
534
535    #[test]
536    fn test_inline_math_spans_single_newline_pandoc() {
537        // Pandoc dialect treats `$...$` spanning a single newline within a
538        // paragraph as math (`tex_math_dollars`).
539        let result = try_parse_inline_math("$x =\ny$", true);
540        assert_eq!(result, Some((7, "x =\ny")));
541    }
542
543    #[test]
544    fn test_inline_math_single_line_only_commonmark() {
545        // CommonMark dialect (GFM, etc.): inline math never spans a newline.
546        let result = try_parse_inline_math("$x =\ny$", false);
547        assert_eq!(result, None);
548    }
549
550    #[test]
551    fn test_inline_math_stops_at_blank_line() {
552        // A blank line is a paragraph break and ends the math even in Pandoc.
553        let result = try_parse_inline_math("$x =\n\ny$", true);
554        assert_eq!(result, None);
555    }
556
557    #[test]
558    fn test_not_inline_math() {
559        let result = try_parse_inline_math("no dollar", true);
560        assert_eq!(result, None);
561    }
562
563    #[test]
564    fn test_inline_math_with_trailing_text() {
565        let result = try_parse_inline_math("$x$ and more", true);
566        assert_eq!(result, Some((3, "x")));
567    }
568
569    #[test]
570    fn test_spec_opening_must_have_non_space_right() {
571        // Per Pandoc spec: opening $ must have non-space immediately to right
572        let result = try_parse_inline_math("$ x$", true);
573        assert_eq!(result, None, "Opening $ with space should not parse");
574    }
575
576    #[test]
577    fn test_spec_closing_must_have_non_space_left() {
578        // Per Pandoc spec: closing $ must have non-space immediately to left
579        let result = try_parse_inline_math("$x $", true);
580        assert_eq!(result, None, "Closing $ with space should not parse");
581    }
582
583    #[test]
584    fn test_spec_closing_not_followed_by_digit() {
585        // Per Pandoc spec: closing $ must not be followed by digit
586        let result = try_parse_inline_math("$x$5", true);
587        assert_eq!(result, None, "Closing $ followed by digit should not parse");
588    }
589
590    #[test]
591    fn test_spec_dollar_amounts() {
592        // $20,000 should not parse as math
593        let result = try_parse_inline_math("$20,000", true);
594        assert_eq!(result, None, "Dollar amounts should not parse as math");
595    }
596
597    #[test]
598    fn test_valid_math_after_spec_checks() {
599        // $x$ alone should still parse
600        let result = try_parse_inline_math("$x$", true);
601        assert_eq!(result, Some((3, "x")), "Valid math should parse");
602    }
603
604    #[test]
605    fn test_math_followed_by_non_digit() {
606        // $x$a should parse (not followed by digit)
607        let result = try_parse_inline_math("$x$a", true);
608        assert_eq!(
609            result,
610            Some((3, "x")),
611            "Math followed by non-digit should parse"
612        );
613    }
614
615    // Display math tests
616    #[test]
617    fn test_parse_display_math_simple() {
618        let result = try_parse_display_math("$$x = y$$");
619        assert_eq!(result, Some((9, "x = y")));
620    }
621
622    #[test]
623    fn test_parse_display_math_multiline() {
624        let result = try_parse_display_math("$$\nx = y\n$$");
625        assert_eq!(result, Some((11, "\nx = y\n")));
626    }
627
628    #[test]
629    fn test_parse_display_math_triple_dollars() {
630        let result = try_parse_display_math("$$$x = y$$$");
631        assert_eq!(result, Some((11, "x = y")));
632    }
633
634    #[test]
635    fn test_parse_display_math_no_close() {
636        let result = try_parse_display_math("$$no close");
637        assert_eq!(result, None);
638    }
639
640    #[test]
641    fn test_not_display_math() {
642        let result = try_parse_display_math("$single dollar");
643        assert_eq!(result, None);
644    }
645
646    #[test]
647    fn test_display_math_with_trailing_text() {
648        let result = try_parse_display_math("$$x = y$$ and more");
649        assert_eq!(result, Some((9, "x = y")));
650    }
651
652    // Single backslash math tests
653    #[test]
654    fn test_single_backslash_inline_math() {
655        let result = try_parse_single_backslash_inline_math(r"\(x^2\)", true);
656        assert_eq!(result, Some((7, "x^2")));
657    }
658
659    #[test]
660    fn test_single_backslash_inline_math_complex() {
661        let result = try_parse_single_backslash_inline_math(r"\(\frac{a}{b}\)", true);
662        assert_eq!(result, Some((15, r"\frac{a}{b}")));
663    }
664
665    #[test]
666    fn test_single_backslash_inline_math_no_close() {
667        let result = try_parse_single_backslash_inline_math(r"\(no close", true);
668        assert_eq!(result, None);
669    }
670
671    #[test]
672    fn test_single_backslash_inline_math_spans_single_newline() {
673        // Matches pandoc with `tex_math_single_backslash`: a single newline
674        // is allowed, a blank line ends the math. Under CommonMark it never
675        // spans a newline.
676        let result = try_parse_single_backslash_inline_math("\\(x =\ny\\)", true);
677        assert_eq!(result, Some((9, "x =\ny")));
678        let blank = try_parse_single_backslash_inline_math("\\(x =\n\ny\\)", true);
679        assert_eq!(blank, None);
680        let cm = try_parse_single_backslash_inline_math("\\(x =\ny\\)", false);
681        assert_eq!(cm, None);
682    }
683
684    #[test]
685    fn test_single_backslash_display_math() {
686        let result = try_parse_single_backslash_display_math(r"\[E = mc^2\]");
687        assert_eq!(result, Some((12, "E = mc^2")));
688    }
689
690    #[test]
691    fn test_single_backslash_display_math_multiline() {
692        let result = try_parse_single_backslash_display_math("\\[\nx = y\n\\]");
693        assert_eq!(result, Some((11, "\nx = y\n")));
694    }
695
696    #[test]
697    fn test_single_backslash_display_math_no_close() {
698        let result = try_parse_single_backslash_display_math(r"\[no close");
699        assert_eq!(result, None);
700    }
701
702    // Double backslash math tests
703    #[test]
704    fn test_double_backslash_inline_math() {
705        let result = try_parse_double_backslash_inline_math(r"\\(x^2\\)", true);
706        assert_eq!(result, Some((9, "x^2")));
707    }
708
709    #[test]
710    fn test_double_backslash_inline_math_complex() {
711        let result = try_parse_double_backslash_inline_math(r"\\(\alpha + \beta\\)", true);
712        assert_eq!(result, Some((20, r"\alpha + \beta")));
713    }
714
715    #[test]
716    fn test_double_backslash_inline_math_no_close() {
717        let result = try_parse_double_backslash_inline_math(r"\\(no close", true);
718        assert_eq!(result, None);
719    }
720
721    #[test]
722    fn test_double_backslash_inline_math_spans_single_newline() {
723        // Matches pandoc with `tex_math_double_backslash`: a single newline is
724        // allowed, a blank line ends the math. Under CommonMark it never spans
725        // a newline.
726        let result = try_parse_double_backslash_inline_math("\\\\(x =\ny\\\\)", true);
727        assert_eq!(result, Some((11, "x =\ny")));
728        let blank = try_parse_double_backslash_inline_math("\\\\(x =\n\ny\\\\)", true);
729        assert_eq!(blank, None);
730        let cm = try_parse_double_backslash_inline_math("\\\\(x =\ny\\\\)", false);
731        assert_eq!(cm, None);
732    }
733
734    #[test]
735    fn test_double_backslash_display_math() {
736        let result = try_parse_double_backslash_display_math(r"\\[E = mc^2\\]");
737        assert_eq!(result, Some((14, "E = mc^2")));
738    }
739
740    #[test]
741    fn test_double_backslash_display_math_multiline() {
742        let result = try_parse_double_backslash_display_math("\\\\[\nx = y\n\\\\]");
743        assert_eq!(result, Some((13, "\nx = y\n")));
744    }
745
746    #[test]
747    fn test_double_backslash_display_math_no_close() {
748        let result = try_parse_double_backslash_display_math(r"\\[no close");
749        assert_eq!(result, None);
750    }
751
752    // Additional edge case tests
753    #[test]
754    fn test_display_math_escaped_dollar() {
755        // Escaped dollar should be skipped
756        let result = try_parse_display_math(r"$$a = \$100$$");
757        assert_eq!(result, Some((13, r"a = \$100")));
758    }
759
760    #[test]
761    fn test_display_math_with_content_on_fence_line() {
762        // Content can appear on same line as opening delimiter
763        let result = try_parse_display_math("$$x = y\n$$");
764        assert_eq!(result, Some((10, "x = y\n")));
765    }
766}