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