1use 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
22pub use core::Parser;
24pub use diagnostics::{Diagnostics, SyntaxError, SyntaxErrorSource};
25
26pub fn parse(input: &str, config: Option<ParserOptions>) -> SyntaxNode {
50 parse_with_errors(input, config).0
51}
52
53pub 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
68pub 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
85pub 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
98fn 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
127pub 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
146pub 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 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 }
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}