Skip to main content

panache_parser/
parser.rs

1//! Parser module for Pandoc/Quarto documents.
2//!
3//! This module implements a single-pass parser that constructs a lossless syntax tree (CST) for
4//! Quarto documents.
5
6use crate::options::ParserOptions;
7use crate::parser::inlines::refdef_map::{RefdefMap, collect_refdef_labels};
8use crate::range_utils::find_incremental_restart_offset;
9use crate::syntax::{SyntaxKind, SyntaxNode};
10use rowan::{GreenNode, GreenToken, NodeOrToken};
11
12pub mod blocks;
13pub mod diagnostics;
14pub mod inlines;
15pub mod math;
16pub mod utils;
17pub mod yaml;
18
19mod block_dispatcher;
20mod core;
21
22// Re-export main parser
23pub use core::Parser;
24pub use diagnostics::{Diagnostics, SyntaxError, SyntaxErrorSource};
25
26/// Parses a Quarto document string into a syntax tree.
27///
28/// Single-pass architecture: blocks emit inline structure during parsing.
29///
30/// Convenience wrapper that scans the input for reference-definition
31/// labels via [`collect_refdef_labels`] before parsing. Callers that
32/// already have a precomputed [`RefdefMap`] (e.g. salsa-cached) should
33/// use [`parse_with_refdefs`] instead to skip the scan.
34///
35/// # Examples
36///
37/// ```rust
38/// use panache_parser::parser::parse;
39///
40/// let input = "# Heading\n\nParagraph text.";
41/// let tree = parse(input, None);
42/// println!("{:#?}", tree);
43/// ```
44///
45/// # Arguments
46///
47/// * `input` - The Quarto document content to parse
48/// * `config` - Optional configuration. If None, uses default config.
49pub fn parse(input: &str, config: Option<ParserOptions>) -> SyntaxNode {
50    parse_with_errors(input, config).0
51}
52
53/// Like [`parse`], but also returns the syntax errors the parser found in
54/// embedded sublanguages (currently malformed frontmatter / hashpipe YAML).
55///
56/// The errors carry host-aligned ranges, so consumers (the linter) can turn
57/// them straight into diagnostics without re-parsing the region or remapping
58/// offsets. For pure Markdown the error list is empty.
59pub fn parse_with_errors(
60    input: &str,
61    config: Option<ParserOptions>,
62) -> (SyntaxNode, Vec<SyntaxError>) {
63    let mut config = config.unwrap_or_default();
64    populate_refdef_labels(input, &mut config);
65    Parser::new(input, &config).parse_with_errors()
66}
67
68/// Parse with a caller-supplied refdef set.
69///
70/// Skips the [`collect_refdef_labels`] scan that [`parse`] performs.
71/// Use this when the caller already has a cached [`RefdefMap`] for
72/// `input` — e.g. from a salsa-tracked query — to avoid a redundant
73/// document-level scan on every parse.
74///
75/// The supplied `refdefs` becomes the parser's refdef set, overriding
76/// any value previously set on `options.refdef_labels`.
77pub fn parse_with_refdefs(
78    input: &str,
79    options: Option<ParserOptions>,
80    refdefs: RefdefMap,
81) -> SyntaxNode {
82    parse_with_refdefs_and_errors(input, options, refdefs).0
83}
84
85/// Like [`parse_with_refdefs`], but also returns embedded-sublanguage syntax
86/// errors (see [`parse_with_errors`]). Used by the salsa parse query, which
87/// caches the tree and the errors together from a single parse.
88pub fn parse_with_refdefs_and_errors(
89    input: &str,
90    options: Option<ParserOptions>,
91    refdefs: RefdefMap,
92) -> (SyntaxNode, Vec<SyntaxError>) {
93    let mut options = options.unwrap_or_default();
94    options.refdef_labels = Some(refdefs);
95    Parser::new(input, &options).parse_with_errors()
96}
97
98/// Pre-compute the document-level reference link label set.
99///
100/// CommonMark §6.3 makes reference link resolution depend on whether
101/// the label matches a definition that may appear anywhere in the
102/// document (including after the use site). The IR-based bracket
103/// resolution pass in `inlines::inline_ir` consults this set to
104/// distinguish a real shortcut/reference link from bracket-shaped
105/// literal text.
106///
107/// Pandoc-markdown agrees on the document-scoped lookup rule: a
108/// `[foo][bar]` shape with no `[bar]: ...` definition is literal text.
109/// Both dialects populate this set so the dispatcher's reference-link
110/// branch (under Pandoc) and the IR's `process_brackets` pass (under
111/// CommonMark) can consult it uniformly.
112///
113/// Only populated when the caller hasn't already supplied one.
114fn populate_refdef_labels(input: &str, config: &mut ParserOptions) {
115    if config.refdef_labels.is_some() {
116        return;
117    }
118    config.refdef_labels = Some(collect_refdef_labels(input, config.dialect));
119}
120
121pub struct IncrementalParseResult {
122    pub tree: SyntaxNode,
123    pub reparse_range: (usize, usize),
124    pub strategy: &'static str,
125}
126
127/// Incrementally update a syntax tree by reparsing either a bounded section
128/// window (between top-level headings) or from a safe restart boundary to EOF.
129///
130/// Convenience wrapper that scans `input` for refdef labels via
131/// [`collect_refdef_labels`] before reparsing. Callers that already have
132/// a precomputed [`RefdefMap`] (e.g. salsa-cached) should use
133/// [`parse_incremental_suffix_with_refdefs`] to skip the scan.
134pub fn parse_incremental_suffix(
135    input: &str,
136    config: Option<ParserOptions>,
137    old_tree: &SyntaxNode,
138    old_edit_range: (usize, usize),
139    new_edit_range: (usize, usize),
140) -> IncrementalParseResult {
141    let mut config = config.unwrap_or_default();
142    populate_refdef_labels(input, &mut config);
143    parse_incremental_suffix_inner(input, config, old_tree, old_edit_range, new_edit_range)
144}
145
146/// Incremental reparse with a caller-supplied refdef set.
147///
148/// See [`parse_with_refdefs`] for the rationale; this is the
149/// incremental-parse counterpart. The supplied `refdefs` overrides any
150/// value previously set on `options.refdef_labels`.
151pub fn parse_incremental_suffix_with_refdefs(
152    input: &str,
153    options: Option<ParserOptions>,
154    refdefs: RefdefMap,
155    old_tree: &SyntaxNode,
156    old_edit_range: (usize, usize),
157    new_edit_range: (usize, usize),
158) -> IncrementalParseResult {
159    let mut options = options.unwrap_or_default();
160    options.refdef_labels = Some(refdefs);
161    parse_incremental_suffix_inner(input, options, old_tree, old_edit_range, new_edit_range)
162}
163
164fn parse_incremental_suffix_inner(
165    input: &str,
166    config: ParserOptions,
167    old_tree: &SyntaxNode,
168    old_edit_range: (usize, usize),
169    new_edit_range: (usize, usize),
170) -> IncrementalParseResult {
171    let input_len = input.len();
172
173    let Some(old_edit) = normalize_range(old_edit_range) else {
174        return full_reparse_result(input, &config);
175    };
176    let Some(new_edit) = normalize_range(new_edit_range) else {
177        return full_reparse_result(input, &config);
178    };
179    if new_edit.1 > input_len {
180        return full_reparse_result(input, &config);
181    }
182
183    if old_tree.kind() != SyntaxKind::DOCUMENT {
184        return full_reparse_result(input, &config);
185    }
186
187    if let Some(section_window) =
188        find_top_level_heading_section_window(old_tree, old_edit, new_edit, input_len)
189        && let Some(result) = reparse_section_window(input, &config, old_tree, section_window)
190    {
191        return result;
192    }
193
194    let restart = find_incremental_restart_offset(old_tree, old_edit.0, old_edit.1);
195    let old_restart = align_to_document_child_start(old_tree, restart);
196
197    if (old_edit.0..old_edit.1).contains(&old_restart) {
198        return full_reparse_result(input, &config);
199    }
200
201    let new_restart = map_old_offset_to_new(old_restart, old_edit, new_edit, input_len);
202    if !input.is_char_boundary(new_restart) {
203        return full_reparse_result(input, &config);
204    }
205
206    let suffix_text = &input[new_restart..];
207    let suffix_tree = Parser::new(suffix_text, &config).parse();
208
209    let mut children: Vec<NodeOrToken<GreenNode, GreenToken>> = old_tree
210        .children_with_tokens()
211        .filter_map(|element| {
212            let range = element.text_range();
213            let end: usize = range.end().into();
214            if end <= old_restart {
215                Some(element_to_green(element))
216            } else {
217                None
218            }
219        })
220        .collect();
221    children.extend(suffix_tree.children_with_tokens().map(element_to_green));
222
223    let tree = SyntaxNode::new_root(GreenNode::new(SyntaxKind::DOCUMENT.into(), children));
224    let len: usize = tree.text_range().end().into();
225
226    IncrementalParseResult {
227        tree,
228        reparse_range: (new_restart, len),
229        strategy: "suffix_window",
230    }
231}
232
233fn normalize_range(range: (usize, usize)) -> Option<(usize, usize)> {
234    (range.0 <= range.1).then_some(range)
235}
236
237fn full_reparse_result(input: &str, config: &ParserOptions) -> IncrementalParseResult {
238    let tree = Parser::new(input, config).parse();
239    let len: usize = tree.text_range().end().into();
240    IncrementalParseResult {
241        tree,
242        reparse_range: (0, len),
243        strategy: "full_reparse",
244    }
245}
246
247fn align_to_document_child_start(tree: &SyntaxNode, offset: usize) -> usize {
248    for child in tree.children_with_tokens() {
249        let range = child.text_range();
250        let start: usize = range.start().into();
251        let end: usize = range.end().into();
252        if offset <= start {
253            return start;
254        }
255        if offset < end {
256            return start;
257        }
258    }
259    let len: usize = tree.text_range().end().into();
260    len
261}
262
263fn map_old_offset_to_new(
264    old_offset: usize,
265    old_edit: (usize, usize),
266    new_edit: (usize, usize),
267    new_len: usize,
268) -> usize {
269    if old_offset <= old_edit.0 {
270        return old_offset;
271    }
272    if old_offset >= old_edit.1 {
273        let old_span = old_edit.1 - old_edit.0;
274        let new_span = new_edit.1 - new_edit.0;
275        let delta = new_span as isize - old_span as isize;
276        return old_offset.saturating_add_signed(delta).min(new_len);
277    }
278    new_edit.1.min(new_len)
279}
280
281fn element_to_green(element: crate::syntax::SyntaxElement) -> NodeOrToken<GreenNode, GreenToken> {
282    match element {
283        NodeOrToken::Node(node) => NodeOrToken::Node(node.green().into_owned()),
284        NodeOrToken::Token(token) => NodeOrToken::Token(token.green().to_owned()),
285    }
286}
287
288#[derive(Debug, Clone, Copy)]
289struct SectionWindow {
290    old_start: usize,
291    old_end: usize,
292    new_start: usize,
293    new_end: usize,
294}
295
296fn find_top_level_heading_section_window(
297    old_tree: &SyntaxNode,
298    old_edit: (usize, usize),
299    new_edit: (usize, usize),
300    new_len: usize,
301) -> Option<SectionWindow> {
302    let old_len: usize = old_tree.text_range().end().into();
303    let mut previous_heading: Option<(usize, usize)> = None;
304    let mut next_heading: Option<(usize, usize)> = None;
305
306    for child in old_tree.children() {
307        if child.kind() != SyntaxKind::HEADING {
308            continue;
309        }
310
311        let range = child.text_range();
312        let start: usize = range.start().into();
313        let end: usize = range.end().into();
314
315        if start <= old_edit.0 {
316            previous_heading = Some((start, end));
317        } else {
318            next_heading = Some((start, end));
319            break;
320        }
321    }
322
323    let (previous_start, previous_end) = previous_heading?;
324    let (next_start, next_end) = next_heading.unwrap_or((old_len, old_len));
325
326    if ranges_intersect(old_edit, (previous_start, previous_end))
327        || ranges_intersect(old_edit, (next_start, next_end))
328    {
329        return None;
330    }
331
332    // Be conservative and only use the section window for edits that are
333    // strictly inside the section body (not touching heading boundaries).
334    if old_edit.0 <= previous_end || old_edit.1 >= next_start {
335        return None;
336    }
337
338    let new_start = map_old_offset_to_new(previous_start, old_edit, new_edit, new_len);
339    let new_end = map_old_offset_to_new(next_start, old_edit, new_edit, new_len);
340    if new_start >= new_end || new_end > new_len {
341        return None;
342    }
343
344    Some(SectionWindow {
345        old_start: previous_start,
346        old_end: next_start,
347        new_start,
348        new_end,
349    })
350}
351
352fn ranges_intersect(a: (usize, usize), b: (usize, usize)) -> bool {
353    a.0 < b.1 && b.0 < a.1
354}
355
356fn reparse_section_window(
357    input: &str,
358    config: &ParserOptions,
359    old_tree: &SyntaxNode,
360    section_window: SectionWindow,
361) -> Option<IncrementalParseResult> {
362    if !input.is_char_boundary(section_window.new_start)
363        || !input.is_char_boundary(section_window.new_end)
364    {
365        return None;
366    }
367
368    let reparsed_window = Parser::new(
369        &input[section_window.new_start..section_window.new_end],
370        config,
371    )
372    .parse();
373
374    let mut children: Vec<NodeOrToken<GreenNode, GreenToken>> = Vec::new();
375    let mut inserted_window = false;
376
377    for element in old_tree.children_with_tokens() {
378        let range = element.text_range();
379        let start: usize = range.start().into();
380        let end: usize = range.end().into();
381
382        if end <= section_window.old_start {
383            children.push(element_to_green(element));
384            continue;
385        }
386
387        if start >= section_window.old_end {
388            if !inserted_window {
389                children.extend(reparsed_window.children_with_tokens().map(element_to_green));
390                inserted_window = true;
391            }
392            children.push(element_to_green(element));
393            continue;
394        }
395
396        // Overlapping element is replaced by the reparsed section window.
397    }
398
399    if !inserted_window {
400        children.extend(reparsed_window.children_with_tokens().map(element_to_green));
401    }
402
403    let tree = SyntaxNode::new_root(GreenNode::new(SyntaxKind::DOCUMENT.into(), children));
404    Some(IncrementalParseResult {
405        tree,
406        reparse_range: (section_window.new_start, section_window.new_end),
407        strategy: "section_window",
408    })
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    fn apply_edit(text: &str, old: (usize, usize), insert: &str) -> String {
416        let mut out = String::with_capacity(text.len() - (old.1 - old.0) + insert.len());
417        out.push_str(&text[..old.0]);
418        out.push_str(insert);
419        out.push_str(&text[old.1..]);
420        out
421    }
422
423    fn quarto_options() -> crate::options::ParserOptions {
424        crate::options::ParserOptions {
425            flavor: crate::options::Flavor::Quarto,
426            extensions: crate::options::Extensions::for_flavor(crate::options::Flavor::Quarto),
427            ..Default::default()
428        }
429    }
430
431    #[test]
432    fn parse_with_errors_reports_malformed_hashpipe_yaml() {
433        let input = "```{r}\n#| echo: [\n1 + 1\n```\n";
434        let (_tree, errors) = parse_with_errors(input, Some(quarto_options()));
435        assert_eq!(errors.len(), 1, "expected one yaml error, got {errors:?}");
436        assert_eq!(errors[0].source, SyntaxErrorSource::Yaml);
437        let start: usize = errors[0].range.start().into();
438        assert_eq!(start, input.find('[').unwrap());
439    }
440
441    #[test]
442    fn parse_with_errors_reports_malformed_frontmatter_yaml() {
443        let input = "---\ntitle: [\n---\n";
444        let (_tree, errors) = parse_with_errors(input, None);
445        assert_eq!(errors.len(), 1, "expected one yaml error, got {errors:?}");
446        assert_eq!(errors[0].source, SyntaxErrorSource::Yaml);
447        let start: usize = errors[0].range.start().into();
448        assert_eq!(start, input.find('[').unwrap());
449    }
450
451    #[test]
452    fn parse_with_errors_empty_for_valid_document() {
453        let input = "---\ntitle: ok\n---\n\n```{r}\n#| echo: false\n1\n```\n";
454        let (_tree, errors) = parse_with_errors(input, Some(quarto_options()));
455        assert!(
456            errors.is_empty(),
457            "valid document should have no errors: {errors:?}"
458        );
459    }
460
461    #[test]
462    fn incremental_suffix_matches_full_parse_for_tail_edit() {
463        let input = "# H\n\npara one\n\npara two\n\npara three\n";
464        let old_tree = parse(input, None);
465        let old_edit = (30, 35);
466        let updated = apply_edit(input, old_edit, "tail section");
467        let new_edit = (30, 42);
468
469        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit).tree;
470        let full = parse(&updated, None);
471        assert_eq!(inc.to_string(), full.to_string());
472    }
473
474    #[test]
475    fn incremental_suffix_matches_full_parse_for_middle_edit() {
476        let input = "# H\n\n- a\n- b\n\nfinal para\n";
477        let old_tree = parse(input, None);
478        let old_edit = (10, 11);
479        let updated = apply_edit(input, old_edit, "alpha");
480        let new_edit = (10, 15);
481
482        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit).tree;
483        let full = parse(&updated, None);
484        assert_eq!(inc.to_string(), full.to_string());
485    }
486
487    #[test]
488    fn incremental_suffix_matches_full_parse_for_setext_transition() {
489        let input = "Intro\nSecond\n\nTail\n";
490        let old_tree = parse(input, None);
491        let old_edit = (5, 5);
492        let updated = apply_edit(input, old_edit, "\n-----");
493        let new_edit = (5, 11);
494
495        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit).tree;
496        let full = parse(&updated, None);
497        assert_eq!(inc.to_string(), full.to_string());
498    }
499
500    #[test]
501    fn incremental_suffix_matches_full_parse_for_lazy_blockquote_change() {
502        let input = "> quoted\nlazy\n\nnext\n";
503        let old_tree = parse(input, None);
504        let old_edit = (9, 13);
505        let updated = apply_edit(input, old_edit, "> line");
506        let new_edit = (9, 15);
507
508        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit).tree;
509        let full = parse(&updated, None);
510        assert_eq!(inc.to_string(), full.to_string());
511    }
512
513    #[test]
514    fn incremental_uses_heading_section_window_when_available() {
515        let input = "# Intro\n\nalpha\n\n# Middle\n\nbeta section\n\n# End\n\nomega\n";
516        let old_tree = parse(input, None);
517        let start = input.find("beta").expect("beta in test input");
518        let old_edit = (start, start + 4);
519        let updated = apply_edit(input, old_edit, "BETA");
520        let new_edit = (start, start + 4);
521
522        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit);
523        let full = parse(&updated, None);
524        assert_eq!(inc.tree.to_string(), full.to_string());
525        assert!(
526            inc.reparse_range.0 > 0,
527            "section reparse should not start at 0"
528        );
529        assert!(
530            inc.reparse_range.1 < updated.len(),
531            "section reparse should stop before EOF"
532        );
533    }
534
535    #[test]
536    fn incremental_uses_section_window_for_last_section() {
537        let input = "# Intro\n\nalpha\n\n# Last\n\nbeta section\n";
538        let old_tree = parse(input, None);
539        let start = input.find("beta").expect("beta in test input");
540        let old_edit = (start, start + 4);
541        let updated = apply_edit(input, old_edit, "BETA");
542        let new_edit = (start, start + 4);
543
544        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit);
545        let full = parse(&updated, None);
546        assert_eq!(inc.tree.to_string(), full.to_string());
547        assert!(
548            inc.reparse_range.0 > 0,
549            "last section should start at the last heading boundary"
550        );
551        assert_eq!(
552            inc.reparse_range.1,
553            updated.len(),
554            "last section should end at EOF"
555        );
556    }
557
558    #[test]
559    fn incremental_does_not_use_section_window_when_edit_touches_heading() {
560        let input = "# Intro\n\nalpha\n\n# Middle\n\nbeta\n\n# End\n\nomega\n";
561        let old_tree = parse(input, None);
562        let middle_start = input
563            .find("# Middle")
564            .expect("middle heading in test input");
565        let old_edit = (middle_start, middle_start + 1);
566        let updated = apply_edit(input, old_edit, "#");
567        let new_edit = (middle_start, middle_start + 1);
568
569        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit);
570        let full = parse(&updated, None);
571        assert_eq!(inc.tree.to_string(), full.to_string());
572        assert_eq!(
573            inc.reparse_range.1,
574            updated.len(),
575            "edits on headings should avoid section-window reparsing"
576        );
577    }
578
579    #[test]
580    fn incremental_does_not_use_section_window_when_edit_crosses_next_heading() {
581        let input = "# Intro\n\nalpha\n\n# Middle\n\nbeta\n\n# End\n\nomega\n";
582        let old_tree = parse(input, None);
583        let beta_start = input.find("beta").expect("beta in test input");
584        let end_start = input.find("# End").expect("end heading in test input");
585        let old_edit = (beta_start, end_start + 2);
586        let updated = apply_edit(input, old_edit, "beta\n\n# ");
587        let new_edit = (beta_start, beta_start + 8);
588
589        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit);
590        let full = parse(&updated, None);
591        assert_eq!(inc.tree.to_string(), full.to_string());
592        assert_eq!(
593            inc.reparse_range.1,
594            updated.len(),
595            "cross-heading edits should avoid section-window reparsing"
596        );
597    }
598
599    #[test]
600    fn incremental_ignores_nested_headings_for_window_boundaries() {
601        let input = "# Intro\n\n> ## Nested\n> quote body\n\n# End\n\nomega\n";
602        let old_tree = parse(input, None);
603        let quote_start = input.find("quote body").expect("quote body in test input");
604        let old_edit = (quote_start, quote_start + 5);
605        let updated = apply_edit(input, old_edit, "QUOTE");
606        let new_edit = (quote_start, quote_start + 5);
607
608        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit);
609        let full = parse(&updated, None);
610        assert_eq!(inc.tree.to_string(), full.to_string());
611        assert!(
612            inc.reparse_range.1 < updated.len(),
613            "window boundary should be the next top-level heading, not nested heading"
614        );
615    }
616
617    #[test]
618    fn incremental_section_window_handles_list_tight_loose_transition() {
619        let input = "# Intro\n\nprelude\n\n# Middle\n\n- one\n- two\n\n# End\n\nomega\n";
620        let old_tree = parse(input, None);
621        let two_start = input.find("- two").expect("list item in test input");
622        let old_edit = (two_start, two_start);
623        let updated = apply_edit(input, old_edit, "\n");
624        let new_edit = (two_start, two_start + 1);
625
626        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit);
627        let full = parse(&updated, None);
628        assert_eq!(inc.tree.to_string(), full.to_string());
629        assert!(
630            inc.reparse_range.0 > 0 && inc.reparse_range.1 < updated.len(),
631            "list transition inside section should remain section-bounded"
632        );
633    }
634
635    #[test]
636    fn incremental_section_window_handles_blockquote_lazy_transition() {
637        let input = "# Intro\n\nprelude\n\n# Middle\n\n> quoted\nlazy line\n\n# End\n\nomega\n";
638        let old_tree = parse(input, None);
639        let lazy_start = input.find("lazy line").expect("lazy line in test input");
640        let old_edit = (lazy_start, lazy_start);
641        let updated = apply_edit(input, old_edit, "> ");
642        let new_edit = (lazy_start, lazy_start + 2);
643
644        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit);
645        let full = parse(&updated, None);
646        assert_eq!(inc.tree.to_string(), full.to_string());
647        assert!(
648            inc.reparse_range.0 > 0 && inc.reparse_range.1 < updated.len(),
649            "blockquote continuation change inside section should remain section-bounded"
650        );
651    }
652
653    #[test]
654    fn incremental_section_window_handles_fenced_div_with_nested_heading() {
655        let input = "# Intro\n\nprelude\n\n# Middle\n\n::: {.callout-note}\n## Nested\nbody text\n:::\n\n# End\n\nomega\n";
656        let old_tree = parse(input, None);
657        let body_start = input.find("body text").expect("body text in test input");
658        let old_edit = (body_start, body_start + 4);
659        let updated = apply_edit(input, old_edit, "BODY");
660        let new_edit = (body_start, body_start + 4);
661
662        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit);
663        let full = parse(&updated, None);
664        assert_eq!(inc.tree.to_string(), full.to_string());
665        assert!(
666            inc.reparse_range.0 > 0 && inc.reparse_range.1 < updated.len(),
667            "fenced div edits should use top-level heading boundaries"
668        );
669    }
670
671    #[test]
672    fn incremental_handles_inserting_heading_inside_section_window() {
673        let input = "# Intro\n\nalpha\n\n# Middle\n\nbeta\n\n# End\n\nomega\n";
674        let old_tree = parse(input, None);
675        let beta_start = input.find("beta").expect("beta in test input");
676        let old_edit = (beta_start, beta_start);
677        let updated = apply_edit(input, old_edit, "## Inserted\n\n");
678        let new_edit = (beta_start, beta_start + "## Inserted\n\n".len());
679
680        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit);
681        let full = parse(&updated, None);
682        assert_eq!(inc.tree.to_string(), full.to_string());
683        assert_eq!(
684            inc.strategy, "section_window",
685            "heading insertions within a bounded section should remain section-window mode"
686        );
687    }
688
689    #[test]
690    fn incremental_falls_back_when_deleting_next_heading_boundary() {
691        let input = "# Intro\n\nalpha\n\n# Middle\n\nbeta\n\n# End\n\nomega\n";
692        let old_tree = parse(input, None);
693        let end_start = input.find("# End\n").expect("end heading in test input");
694        let old_edit = (end_start, end_start + "# End\n\n".len());
695        let updated = apply_edit(input, old_edit, "");
696        let new_edit = (end_start, end_start);
697
698        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit);
699        let full = parse(&updated, None);
700        assert_eq!(inc.tree.to_string(), full.to_string());
701        assert_ne!(
702            inc.strategy, "section_window",
703            "heading deletions across boundaries should avoid section-window mode"
704        );
705    }
706
707    #[test]
708    fn incremental_falls_back_when_editing_blank_line_after_heading() {
709        let input = "# Intro\n\nalpha\n\n# Middle\n\nbeta\n\n# End\n\nomega\n";
710        let old_tree = parse(input, None);
711        let boundary = input
712            .find("# Middle\n\n")
713            .expect("middle heading boundary in test input");
714        let blank_line_start = boundary + "# Middle\n".len();
715        let old_edit = (blank_line_start, blank_line_start + 1);
716        let updated = apply_edit(input, old_edit, "");
717        let new_edit = (blank_line_start, blank_line_start);
718
719        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit);
720        let full = parse(&updated, None);
721        assert_eq!(inc.tree.to_string(), full.to_string());
722        assert_ne!(
723            inc.strategy, "section_window",
724            "heading-adjacent blank line edits should avoid section-window mode"
725        );
726    }
727
728    #[test]
729    fn incremental_handles_frontmatter_to_first_heading_edit() {
730        let input = "---\ntitle: Demo\n---\n\n# Intro\n\nalpha\n\n# Next\n\nomega\n";
731        let old_tree = parse(input, None);
732        let title_start = input.find("Demo").expect("frontmatter value in test input");
733        let old_edit = (title_start, title_start + 4);
734        let updated = apply_edit(input, old_edit, "Updated Demo");
735        let new_edit = (title_start, title_start + "Updated Demo".len());
736
737        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit);
738        let full = parse(&updated, None);
739        assert_eq!(inc.tree.to_string(), full.to_string());
740        assert_ne!(
741            inc.strategy, "section_window",
742            "frontmatter edits before first heading should use conservative mode"
743        );
744    }
745
746    #[test]
747    fn incremental_handles_frontmatter_delimiter_edit() {
748        let input = "---\ntitle: Demo\n---\n\n# Intro\n\nalpha\n";
749        let old_tree = parse(input, None);
750        let first_delim_start = 0;
751        let old_edit = (first_delim_start, first_delim_start + 3);
752        let updated = apply_edit(input, old_edit, "----");
753        let new_edit = (first_delim_start, first_delim_start + 4);
754
755        let inc = parse_incremental_suffix(&updated, None, &old_tree, old_edit, new_edit);
756        let full = parse(&updated, None);
757        assert_eq!(inc.tree.to_string(), full.to_string());
758        assert_ne!(
759            inc.strategy, "section_window",
760            "frontmatter delimiter edits should stay in conservative mode"
761        );
762    }
763}