kimun_notes/components/text_editor/parse_incremental.rs
1#![allow(dead_code)]
2//! Incremental-parse machinery: line-construct classification cache,
3//! damage-diff against the previous buffer snapshot, safe-boundary
4//! widening, and fence-range derivation. Pure functions only — no
5//! `pulldown_cmark` calls (those live in `markdown.rs`).
6
7use std::ops::Range;
8
9/// Coarse classification of a buffer line for safe-boundary widening.
10///
11/// A line is a *safe boundary* when re-parsing a slice ending on that
12/// line is equivalent to the corresponding slice of a full-buffer parse.
13/// `Blank` and `Plain` are unconditional boundaries when their neighbour
14/// is also `Blank`/`Plain` or end-of-buffer. Structural markers
15/// (`FenceMarker`, `ListMarker`, etc.) are NEVER boundaries — widening
16/// must reach the outer terminator of whatever construct they belong to.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum LineConstructKind {
19 Blank,
20 Plain,
21 FenceMarker,
22 FenceContent,
23 IndentedCode,
24 ListMarker,
25 ListContinuation,
26 Blockquote(u8),
27 SetextUnderline,
28 HtmlBlock,
29 Heading,
30}
31
32/// Result of widening a damaged range to safe construct boundaries.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum WidenResult {
35 /// Widened range; caller passes this to `ParsedBuffer::parse_range`.
36 Widened(Range<usize>),
37 /// Range cannot be cheaply widened (cap trip, unbounded construct).
38 /// Caller falls back to `ParsedBuffer::parse_lines(lines)`.
39 FullRebuild,
40}
41
42/// Maximum fraction of buffer the widened range may cover before we
43/// abandon incremental and fall back to a full parse. Half the buffer
44/// is the empirical cross-over where parse+splice overhead exceeds a
45/// fresh full parse on the same input.
46pub(super) const MAX_INCREMENTAL_FRACTION: f32 = 0.5;
47
48/// Absolute cap on the widened range. Independent of buffer size; keeps
49/// large-fence edits bounded even on small buffers.
50pub(super) const MAX_INCREMENTAL_LINES: usize = 256;
51
52/// Cursor-row hint scan window for `compute_damage_range`. Empirically
53/// covers single-character edits, IME composition of up to 3 graphemes,
54/// and one Enter at line end. Multi-line pastes intentionally fall
55/// through to the LCP/LCS slow path.
56pub(super) const CURSOR_HINT_WINDOW: usize = 4;
57
58/// Compute the row range that differs between `old` and `new`, with a
59/// cursor-row hint to accelerate the common single-character-edit case.
60///
61/// **Contract:** `cursor_row` must be the row that was actually edited
62/// (the editor's cursor position after the keystroke), and it must be the
63/// **only** edited row outside the hint window. The fast path trusts this —
64/// given a `cursor_row` that does not identify the sole edit point, the
65/// function under-reports the damaged range and rows outside it keep a stale
66/// `ParsedLine`.
67///
68/// Callers performing an edit that does not satisfy this must not call it:
69/// [`super::view::MarkdownEditorView::note_bulk_edit`] forces the slow path
70/// for the next update, and every bulk-edit site is expected to use it.
71///
72/// This used to say distant simultaneous edits "can only happen via
73/// programmatic buffer replacement, which goes through `set_text`". That is
74/// false. A **replace all** rewrites rows across the whole buffer, deliberately
75/// preserves the line count, does not go through `set_text`, and restores the
76/// cursor to a row it may itself have edited — satisfying every precondition of
77/// the fast path while violating its meaning. Concretely, rewriting rows 0 and
78/// 6 of a seven-row buffer with `cursor_row = 6` returns `6..7`.
79///
80/// No mis-render was reproducible from that, because the incremental parser's
81/// later guards (kind, opener-shape, lazy-depth, the widening cap) happen to
82/// catch a distant edit — but none of them is aimed at this, so the safety was
83/// incidental. Hence the explicit signal rather than a reliance on them.
84///
85/// Returns `None` when the buffers are byte-identical (defensive
86/// guard — callers should already have gated on `text_revision`).
87///
88/// Fast path: same line count, the row at `cursor_row` differs, and
89/// no other line in `±CURSOR_HINT_WINDOW` differs. Returns
90/// `Some(cursor_row..cursor_row + 1)`. O(`CURSOR_HINT_WINDOW`).
91///
92/// Slow path: longest common prefix (LCP) and longest common suffix
93/// (LCS); damaged range is the middle slice. O(min(buffer_size,
94/// damage_size)).
95pub fn compute_damage_range(
96 old: &[String],
97 new: &[String],
98 cursor_row: usize,
99) -> Option<Range<usize>> {
100 if old == new {
101 return None;
102 }
103
104 // Fast path: same line count, cursor row differs, no other diff in window.
105 if old.len() == new.len() && cursor_row < old.len() && old[cursor_row] != new[cursor_row] {
106 let lo = cursor_row.saturating_sub(CURSOR_HINT_WINDOW);
107 let hi = (cursor_row + CURSOR_HINT_WINDOW + 1).min(old.len());
108 let other_diff_in_window = (lo..hi).any(|i| i != cursor_row && old[i] != new[i]);
109 if !other_diff_in_window {
110 return Some(cursor_row..cursor_row + 1);
111 }
112 }
113
114 // Slow path: longest common prefix + suffix. O(buffer_len)
115 // String equalities; each compare is a length check + at most one
116 // SIMD memcmp on the first-differing byte. ~14µs on a 5000-line
117 // buffer for a single-row backspace.
118 //
119 // A cursor-anchored bound was explored as perf #12 and rejected:
120 // - Capping the scan at `cursor_row + slack` saves nothing,
121 // because the scan naturally stops at the first-differing
122 // row, which IS `cursor_row` for keystroke-driven edits.
123 // - Starting the LCP scan at `cursor_row - slack` (trusting
124 // rows above to be unchanged) would skip the prefix scan but
125 // introduces silent miscompilation risk on edits whose actual
126 // diff is far from the cursor (paste, undo, programmatic
127 // edit) — the post-slice verify only checks rows WITHIN the
128 // widened range, so a misidentified damage range outside
129 // that range is not caught.
130 // - Maintaining per-row hashes alongside `lines_snapshot` would
131 // let us replace string compares with u64 compares, but
132 // requires plumbing damage hints from the editor's edit
133 // surface to view.update for incremental hash maintenance —
134 // bigger change than the 10µs win justifies.
135 //
136 // Until per-row hashes ship as part of a broader edit-surface
137 // refactor, the full O(buffer) scan stays.
138 let lcp = old
139 .iter()
140 .zip(new.iter())
141 .take_while(|(a, b)| a == b)
142 .count();
143 let lcs = old
144 .iter()
145 .rev()
146 .zip(new.iter().rev())
147 .take_while(|(a, b)| a == b)
148 .count();
149 // Guard against overlap when both buffers share a long common stretch.
150 // Clamp lcs so the resulting range is non-empty and start <= end.
151 let new_end = new.len().saturating_sub(lcs);
152 let old_end = old.len().saturating_sub(lcs);
153 let start = lcp.min(new_end).min(old_end);
154 let end = new_end.max(start);
155 Some(start..end)
156}
157
158/// Return true when `kind` is a self-contained, safe boundary line.
159/// Blank lines and ordinary paragraph lines are safe; everything else
160/// belongs to a multi-line construct that widening must include in
161/// full.
162fn is_safe_boundary(kind: LineConstructKind) -> bool {
163 matches!(kind, LineConstructKind::Blank | LineConstructKind::Plain)
164}
165
166/// Walk upward from `damaged_start` (the first damaged row) until the
167/// row just above is a safe boundary. Returns the new start row
168/// (inclusive).
169///
170/// `ListMarker` and `ListContinuation` are non-safe, so the walk
171/// passes through them automatically — landing on the safe row above
172/// the outermost list (Blank, or Plain that is not a continuation),
173/// which is the G1-required outermost-list-ancestor stopping point.
174fn widen_up(kinds: &[LineConstructKind], damaged_start: usize) -> usize {
175 let mut row = damaged_start;
176 while row > 0 {
177 let candidate = row - 1;
178 if is_safe_boundary(kinds[candidate]) {
179 return candidate;
180 }
181 row = candidate;
182 }
183 0
184}
185
186/// Walk downward from `damaged.end` (the first row past the damage)
187/// until we land on a safe boundary or end of buffer. Returns the
188/// exclusive end index.
189fn widen_down(kinds: &[LineConstructKind], damaged_end: usize) -> usize {
190 let mut row = damaged_end;
191 while row < kinds.len() {
192 if is_safe_boundary(kinds[row]) {
193 return row + 1;
194 }
195 row += 1;
196 }
197 kinds.len()
198}
199
200/// Expand `damaged` to the nearest reset boundaries on each side.
201/// A reset boundary is a row where pulldown-cmark's parser state is
202/// provably reset (see `ParsedBuffer::reset_boundaries`), so the
203/// returned range is provably equivalent to a fresh parse over the
204/// same slice — no post-slice verification needed in release.
205///
206/// `boundaries` must be sorted and contain `0` and `lines_len` as
207/// sentinels (every `ParsedBuffer::parse` ensures this). Returns
208/// `FullRebuild` if the expanded range trips either cap (same
209/// semantics as `widen_to_safe`).
210///
211/// This replaces the heuristic `widen_to_safe`-plus-structural-marker
212/// guard tower. The latter is kept available as a behavioural
213/// comparison source for one release cycle (per the openspec
214/// migration plan) before being deleted.
215pub fn expand_to_reset_boundary(
216 boundaries: &[usize],
217 lines_len: usize,
218 damaged: Range<usize>,
219) -> WidenResult {
220 if lines_len == 0 {
221 return WidenResult::FullRebuild;
222 }
223 debug_assert!(
224 damaged.start <= lines_len && damaged.end <= lines_len,
225 "expand_to_reset_boundary: damaged range {:?} out of bounds for lines_len = {}",
226 damaged,
227 lines_len,
228 );
229
230 // Greatest boundary <= damaged.start.
231 let start = boundaries
232 .iter()
233 .rev()
234 .find(|&&b| b <= damaged.start)
235 .copied()
236 .unwrap_or(0);
237 // Least boundary >= damaged.end. Sentinel `lines_len` is always
238 // present in a well-formed boundary set so the `unwrap_or` is
239 // unreachable; kept as a defensive fallback to avoid an inverted
240 // range if the invariant is ever violated.
241 let end = boundaries
242 .iter()
243 .find(|&&b| b >= damaged.end)
244 .copied()
245 .unwrap_or(lines_len);
246
247 let widened_len = end - start;
248 let cap_abs = MAX_INCREMENTAL_LINES;
249 // Same cap policy as widen_to_safe; see its docstring for the
250 // rationale on flooring `cap_frac` at `cap_abs`.
251 let cap_frac = (((lines_len as f32) * MAX_INCREMENTAL_FRACTION) as usize).max(cap_abs);
252 if widened_len > cap_abs || widened_len > cap_frac {
253 return WidenResult::FullRebuild;
254 }
255 WidenResult::Widened(start..end)
256}
257
258/// Widen `damaged` outward to safe construct boundaries, applying
259/// D5's +1 extra row and the D4 cap.
260///
261/// Returns `Widened(range)` when the widened range fits under the cap,
262/// or `FullRebuild` when the cap is exceeded or the buffer is empty.
263///
264/// Kept available for one release cycle as a behavioural comparison
265/// source against `expand_to_reset_boundary` (see openspec change
266/// `parse-reset-boundaries`). New call sites should use
267/// `expand_to_reset_boundary` instead.
268pub fn widen_to_safe(kinds: &[LineConstructKind], damaged: Range<usize>) -> WidenResult {
269 if kinds.is_empty() {
270 return WidenResult::FullRebuild;
271 }
272 debug_assert!(
273 damaged.start <= kinds.len() && damaged.end <= kinds.len(),
274 "widen_to_safe: damaged range {:?} out of bounds for kinds.len() = {}",
275 damaged,
276 kinds.len(),
277 );
278
279 let mut start = widen_up(kinds, damaged.start);
280 let mut end = widen_down(kinds, damaged.end);
281
282 // D5: widen one extra row on each side.
283 start = start.saturating_sub(1);
284 end = (end + 1).min(kinds.len());
285
286 let widened_len = end - start;
287 let cap_abs = MAX_INCREMENTAL_LINES;
288 // Fractional cap encodes the empirical "fresh full parse beats
289 // parse+splice" cross-over. It is only meaningful once full-parse
290 // cost is non-trivial; floor it at `cap_abs` so a 50%-widening on
291 // a tiny buffer (where both options are sub-millisecond) stays on
292 // the incremental path. Above `2 * cap_abs` lines the fractional
293 // cap dominates and catches large widenings the absolute cap
294 // would otherwise miss — this is the regime the previous `&&`
295 // operator left unguarded.
296 let cap_frac = (((kinds.len() as f32) * MAX_INCREMENTAL_FRACTION) as usize).max(cap_abs);
297 if widened_len > cap_abs || widened_len > cap_frac {
298 return WidenResult::FullRebuild;
299 }
300
301 WidenResult::Widened(start..end)
302}
303
304/// Derive fence-range half-open intervals from the per-line construct
305/// kinds. The view layer uses these to decide which logical rows
306/// render `force_raw` (no markdown re-styling, code-block fg color).
307///
308/// Half-open: a fence spanning rows `start..=end_inclusive` (both markers
309/// included) is returned as `start..end_inclusive + 1`. An unclosed
310/// fence runs to the end of the buffer.
311pub fn fence_ranges_from_kinds(kinds: &[LineConstructKind]) -> Vec<Range<usize>> {
312 let mut ranges = Vec::new();
313 let mut i = 0;
314 while i < kinds.len() {
315 if kinds[i] == LineConstructKind::FenceMarker {
316 let start = i;
317 i += 1;
318 while i < kinds.len() && kinds[i] == LineConstructKind::FenceContent {
319 i += 1;
320 }
321 if i < kinds.len() && kinds[i] == LineConstructKind::FenceMarker {
322 ranges.push(start..i + 1);
323 i += 1;
324 } else {
325 // Unclosed fence — extends to end of buffer.
326 ranges.push(start..kinds.len());
327 }
328 } else {
329 i += 1;
330 }
331 }
332 ranges
333}
334
335/// Line ranges of every code block (fenced AND indented) in the buffer,
336/// in ascending order. Reuses [`fence_ranges_from_kinds`] for fenced blocks
337/// (incl. unclosed-fence handling) and adds maximal `IndentedCode` runs.
338/// Used by the view to paint the code-box background.
339pub fn code_block_ranges_from_kinds(kinds: &[LineConstructKind]) -> Vec<Range<usize>> {
340 let mut ranges = fence_ranges_from_kinds(kinds);
341 let mut i = 0;
342 while i < kinds.len() {
343 if kinds[i] == LineConstructKind::IndentedCode {
344 let start = i;
345 while i < kinds.len() && kinds[i] == LineConstructKind::IndentedCode {
346 i += 1;
347 }
348 ranges.push(start..i);
349 } else {
350 i += 1;
351 }
352 }
353 // Fenced ranges are collected first then indented ones appended; sort so the
354 // combined list is ascending. Fenced and indented spans never overlap.
355 ranges.sort_by_key(|r| r.start);
356 ranges
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use crate::components::text_editor::markdown::ParsedBuffer;
363
364 fn kinds_of(lines: &[&str]) -> Vec<LineConstructKind> {
365 let owned: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
366 ParsedBuffer::parse_lines(&owned).kinds
367 }
368
369 #[test]
370 fn plain_paragraph() {
371 assert_eq!(kinds_of(&["hello world"]), vec![LineConstructKind::Plain]);
372 }
373
374 #[test]
375 fn blank_line() {
376 assert_eq!(kinds_of(&[""]), vec![LineConstructKind::Blank]);
377 }
378
379 #[test]
380 fn atx_heading() {
381 assert_eq!(kinds_of(&["# title"]), vec![LineConstructKind::Heading]);
382 }
383
384 #[test]
385 fn setext_underline_above_is_plain() {
386 let k = kinds_of(&["title", "====="]);
387 assert_eq!(
388 k,
389 vec![LineConstructKind::Plain, LineConstructKind::SetextUnderline]
390 );
391 }
392
393 #[test]
394 fn fence_pair() {
395 let k = kinds_of(&["```rust", "let x = 1;", "```"]);
396 assert_eq!(
397 k,
398 vec![
399 LineConstructKind::FenceMarker,
400 LineConstructKind::FenceContent,
401 LineConstructKind::FenceMarker,
402 ]
403 );
404 }
405
406 #[test]
407 fn list_marker_and_continuation() {
408 let k = kinds_of(&["- item", " continuation"]);
409 assert_eq!(
410 k,
411 vec![
412 LineConstructKind::ListMarker,
413 LineConstructKind::ListContinuation
414 ]
415 );
416 }
417
418 #[test]
419 fn blockquote_levels() {
420 let k = kinds_of(&[">> two"]);
421 assert_eq!(k, vec![LineConstructKind::Blockquote(2)]);
422 }
423
424 #[test]
425 fn indented_code() {
426 let k = kinds_of(&["", " let x = 1;"]);
427 assert_eq!(k[1], LineConstructKind::IndentedCode);
428 }
429
430 #[test]
431 fn html_block() {
432 let k = kinds_of(&["<div>", "body", "</div>"]);
433 assert!(matches!(k[0], LineConstructKind::HtmlBlock));
434 }
435
436 #[test]
437 fn inline_html_inside_paragraph_does_not_become_html_block() {
438 // Regression: `Event::InlineHtml` previously painted the
439 // paragraph row as HtmlBlock, defeating safe-boundary widening
440 // for any paragraph containing inline HTML like `<br>` or
441 // `<span>`.
442 let k = kinds_of(&["hello <br> world"]);
443 assert_eq!(
444 k[0],
445 LineConstructKind::Plain,
446 "paragraph with inline HTML must stay Plain"
447 );
448 let k = kinds_of(&["see <span>x</span> end"]);
449 assert_eq!(k[0], LineConstructKind::Plain);
450 }
451
452 fn lines(strs: &[&str]) -> Vec<String> {
453 strs.iter().map(|s| s.to_string()).collect()
454 }
455
456 #[test]
457 fn damage_single_char_insert_uses_cursor_hint() {
458 let old = lines(&["hello", "world"]);
459 let new = lines(&["hello", "worldx"]);
460 assert_eq!(compute_damage_range(&old, &new, 1), Some(1..2));
461 }
462
463 #[test]
464 fn damage_no_change_returns_none() {
465 let old = lines(&["a", "b"]);
466 assert_eq!(compute_damage_range(&old, &old, 0), None);
467 }
468
469 /// The fast path's blind spot, pinned so the contract's cost is visible: a
470 /// buffer edited in two distant places, with the cursor on one of them, is
471 /// reported as a one-row change. Callers that can produce this shape — a
472 /// **replace all**, a whole-buffer `set_text`, a grouped undo of either — must
473 /// call `MarkdownEditorView::note_bulk_edit` instead of relying on this.
474 #[test]
475 fn the_cursor_hint_under_reports_a_two_place_edit() {
476 let old: Vec<String> = ["todo", "a", "b", "c", "d", "e", "todo"]
477 .iter()
478 .map(|s| s.to_string())
479 .collect();
480 let new: Vec<String> = ["X", "a", "b", "c", "d", "e", "X"]
481 .iter()
482 .map(|s| s.to_string())
483 .collect();
484 assert_eq!(
485 compute_damage_range(&old, &new, 6),
486 Some(6..7),
487 "row 0 is silently omitted — this is why the hint must be suppressed \
488 for edits the cursor does not describe"
489 );
490 // Suppressing the hint (an out-of-range row) falls through to LCP/LCS,
491 // which spans both edits.
492 assert_eq!(compute_damage_range(&old, &new, usize::MAX), Some(0..7));
493 }
494
495 #[test]
496 fn damage_enter_at_line_end_uses_lcp_lcs() {
497 let old = lines(&["alpha", "beta"]);
498 let new = lines(&["alpha", "be", "ta"]);
499 let dmg = compute_damage_range(&old, &new, 1).unwrap();
500 assert_eq!(dmg.start, 1);
501 assert_eq!(dmg.end, new.len()); // damaged = [1..3)
502 }
503
504 #[test]
505 fn damage_backspace_merging_lines() {
506 let old = lines(&["alpha", "beta", "gamma"]);
507 let new = lines(&["alphabeta", "gamma"]);
508 let dmg = compute_damage_range(&old, &new, 0).unwrap();
509 assert_eq!(dmg.start, 0);
510 }
511
512 #[test]
513 fn damage_multi_diff_within_window_falls_through_to_slow_path() {
514 // Two rows differ, both within CURSOR_HINT_WINDOW of the cursor.
515 // Fast path's other-diff-in-window check trips → LCP/LCS slow path.
516 let old = lines(&["a", "b", "c", "d", "e"]);
517 let mut new = old.clone();
518 new[1] = "B".to_string();
519 new[2] = "C".to_string();
520 // Cursor at row 1; the window covers rows 0..=4 (full buffer here).
521 let dmg = compute_damage_range(&old, &new, 1).unwrap();
522 // Slow path: LCP=1, LCS=2 → 1..3
523 assert_eq!(dmg, 1..3);
524 }
525
526 fn kinds_str(s: &str) -> Vec<LineConstructKind> {
527 // Compact spec: one char per line.
528 // P=Plain, B=Blank, F=FenceMarker, C=FenceContent,
529 // L=ListMarker, l=ListContinuation, Q=Blockquote(1),
530 // S=SetextUnderline, H=Heading, I=IndentedCode, X=HtmlBlock.
531 s.chars()
532 .map(|c| match c {
533 'P' => LineConstructKind::Plain,
534 'B' => LineConstructKind::Blank,
535 'F' => LineConstructKind::FenceMarker,
536 'C' => LineConstructKind::FenceContent,
537 'L' => LineConstructKind::ListMarker,
538 'l' => LineConstructKind::ListContinuation,
539 'Q' => LineConstructKind::Blockquote(1),
540 'S' => LineConstructKind::SetextUnderline,
541 'H' => LineConstructKind::Heading,
542 'I' => LineConstructKind::IndentedCode,
543 'X' => LineConstructKind::HtmlBlock,
544 _ => panic!("bad kind char {c}"),
545 })
546 .collect()
547 }
548
549 #[test]
550 fn widen_plain_paragraph_to_blank_boundaries() {
551 // P B P P P B P — damage row 3 → widen to blank rows 1 and 5
552 // (plus the D5 +1 each side: 0 and 6 — but the buffer ends are
553 // also boundaries; clamp).
554 let k = kinds_str("PBPPPBP");
555 match widen_to_safe(&k, 3..4) {
556 WidenResult::Widened(r) => {
557 // Must include the blank rows at 1 and 5 (or wider).
558 assert!(r.start <= 1, "widen.start <= 1, got {}", r.start);
559 assert!(r.end >= 6, "widen.end >= 6, got {}", r.end);
560 }
561 x => panic!("expected Widened, got {x:?}"),
562 }
563 }
564
565 #[test]
566 fn widen_fence_interior_includes_both_markers() {
567 // P B F C C C F B P — damage row 4 (inside fence) → widen
568 // to include both fence markers + one extra line on each side.
569 let k = kinds_str("PBFCCCFBP");
570 match widen_to_safe(&k, 4..5) {
571 WidenResult::Widened(r) => {
572 assert!(
573 r.start <= 2,
574 "must include opening fence marker at row 2, got start {}",
575 r.start
576 );
577 assert!(
578 r.end >= 7,
579 "must include closing fence marker at row 6 (end >= 7), got end {}",
580 r.end
581 );
582 }
583 x => panic!("expected Widened, got {x:?}"),
584 }
585 }
586
587 #[test]
588 fn widen_list_continuation_reaches_outermost_marker() {
589 // L l L l l l B P — damage at row 4 (nested continuation) → widen
590 // up to outermost ListMarker at row 0.
591 let k = kinds_str("LlLlllBP");
592 match widen_to_safe(&k, 4..5) {
593 WidenResult::Widened(r) => assert_eq!(r.start, 0, "must reach col-0 list marker"),
594 x => panic!("expected Widened, got {x:?}"),
595 }
596 }
597
598 #[test]
599 fn widen_setext_underline_includes_text_line_above() {
600 // P S P — damage at row 1 (underline) → widen to include row 0
601 // (heading text line).
602 let k = kinds_str("PSP");
603 match widen_to_safe(&k, 1..2) {
604 WidenResult::Widened(r) => {
605 assert_eq!(r.start, 0, "must include row above setext underline")
606 }
607 x => panic!("expected Widened, got {x:?}"),
608 }
609 }
610
611 #[test]
612 fn widen_html_block_includes_whole_block() {
613 // P X X X B P — damage at row 2 (middle of HTML) → widen to
614 // include all HtmlBlock rows.
615 let k = kinds_str("PXXXBP");
616 match widen_to_safe(&k, 2..3) {
617 WidenResult::Widened(r) => {
618 assert!(
619 r.start <= 1,
620 "must include first HtmlBlock row, got start {}",
621 r.start
622 );
623 assert!(
624 r.end >= 4,
625 "must include last HtmlBlock row, got end {}",
626 r.end
627 );
628 }
629 x => panic!("expected Widened, got {x:?}"),
630 }
631 }
632
633 #[test]
634 fn widen_exceeds_cap_returns_full_rebuild() {
635 // 300-line all-FenceContent buffer; the damage is one line;
636 // widening tries to reach the fence ends but the buffer is
637 // uniformly fence content, so widening goes to 0..300, which
638 // exceeds MAX_INCREMENTAL_LINES (256).
639 let k = vec![LineConstructKind::FenceContent; 300];
640 assert_eq!(widen_to_safe(&k, 150..151), WidenResult::FullRebuild);
641 }
642
643 #[test]
644 fn widen_trips_when_fractional_cap_exceeds_absolute() {
645 // Regression: cap-trip used `&&` instead of `||`, so on a buffer
646 // big enough that `cap_frac > cap_abs` (kinds.len() > 512), a
647 // widened range between the two thresholds slipped through.
648 // 600-line buffer of FenceContent → cap_abs=256, cap_frac=300.
649 // Widening covers the whole buffer (no safe boundaries), so
650 // widened_len=600 must trip the fallback.
651 let k = vec![LineConstructKind::FenceContent; 600];
652 assert_eq!(widen_to_safe(&k, 300..301), WidenResult::FullRebuild);
653 }
654
655 #[test]
656 fn widen_at_buffer_start_clamps_to_zero() {
657 let k = kinds_str("PPPPP");
658 match widen_to_safe(&k, 0..1) {
659 WidenResult::Widened(r) => assert_eq!(r.start, 0),
660 x => panic!("expected Widened, got {x:?}"),
661 }
662 }
663
664 #[test]
665 fn widen_at_buffer_end_clamps_to_len() {
666 let k = kinds_str("PPPPP");
667 match widen_to_safe(&k, 4..5) {
668 WidenResult::Widened(r) => assert_eq!(r.end, 5),
669 x => panic!("expected Widened, got {x:?}"),
670 }
671 }
672
673 #[test]
674 fn parse_records_boundaries_for_blank_separated_paragraphs() {
675 // Realistic markdown layout: each paragraph followed by a
676 // blank line. Pulldown ends each Paragraph; depth drops to
677 // 0 at the following blank row. The boundary set should
678 // contain every blank row.
679 use super::super::markdown::ParsedBuffer;
680 let mut lines: Vec<String> = Vec::with_capacity(8);
681 for i in 0..4 {
682 lines.push(format!("paragraph {i}"));
683 lines.push(String::new());
684 }
685 let pb = ParsedBuffer::parse_lines(&lines);
686 // Expected: 0, then every Blank row (1, 3, 5, 7), then lines.len() (8).
687 // The blank at row 7 == lines.len()-1 may or may not be
688 // present depending on whether depth==0 was reached at that
689 // row; check the interior at least.
690 assert!(pb.reset_boundaries.contains(&0), "sentinel 0 missing");
691 assert!(
692 pb.reset_boundaries.contains(&lines.len()),
693 "sentinel lines.len() missing"
694 );
695 assert!(
696 pb.reset_boundaries.contains(&1),
697 "blank after paragraph 0 should be a boundary, got {:?}",
698 pb.reset_boundaries
699 );
700 assert!(
701 pb.reset_boundaries.contains(&3),
702 "blank after paragraph 1 should be a boundary, got {:?}",
703 pb.reset_boundaries
704 );
705 }
706
707 #[test]
708 fn expand_to_reset_uses_nearest_sentinels() {
709 // Only sentinels [0, 5] in the boundary set — every edit
710 // expands to the full buffer.
711 let boundaries = vec![0, 5];
712 match expand_to_reset_boundary(&boundaries, 5, 2..3) {
713 WidenResult::Widened(r) => assert_eq!(r, 0..5),
714 x => panic!("expected Widened, got {x:?}"),
715 }
716 }
717
718 #[test]
719 fn expand_to_reset_snaps_to_interior_boundaries() {
720 // Boundaries at rows 0, 3, 6, 10 (e.g. blank-separated
721 // blocks). Damage at row 4 expands to 3..6.
722 let boundaries = vec![0, 3, 6, 10];
723 match expand_to_reset_boundary(&boundaries, 10, 4..5) {
724 WidenResult::Widened(r) => assert_eq!(r, 3..6),
725 x => panic!("expected Widened, got {x:?}"),
726 }
727 }
728
729 #[test]
730 fn expand_to_reset_damage_at_exact_boundary_is_zero_span() {
731 // Damage range coincides with a boundary point. The function
732 // returns the smallest enclosing boundary pair.
733 let boundaries = vec![0, 3, 6, 10];
734 // damaged.start == damaged.end == 6. Expands to 6..6 (empty).
735 match expand_to_reset_boundary(&boundaries, 10, 6..6) {
736 WidenResult::Widened(r) => assert_eq!(r, 6..6),
737 x => panic!("expected Widened, got {x:?}"),
738 }
739 }
740
741 #[test]
742 fn expand_to_reset_empty_buffer_falls_back() {
743 let boundaries = vec![0];
744 assert_eq!(
745 expand_to_reset_boundary(&boundaries, 0, 0..0),
746 WidenResult::FullRebuild
747 );
748 }
749
750 #[test]
751 fn expand_to_reset_caps_trip_fallback() {
752 // 600-row buffer, no interior boundaries. Damage at 300
753 // expands to 0..600 which exceeds cap_abs (256) and cap_frac
754 // (300, floored at cap_abs).
755 let boundaries = vec![0, 600];
756 assert_eq!(
757 expand_to_reset_boundary(&boundaries, 600, 300..301),
758 WidenResult::FullRebuild
759 );
760 }
761
762 #[test]
763 fn widen_blockquote_includes_whole_block() {
764 // P Q Q Q B P — damage in the middle of a blockquote → widen
765 // to include the whole blockquote.
766 let k = kinds_str("PQQQBP");
767 match widen_to_safe(&k, 2..3) {
768 WidenResult::Widened(r) => {
769 assert!(
770 r.start <= 1,
771 "must include first Blockquote row, got start {}",
772 r.start
773 );
774 assert!(
775 r.end >= 4,
776 "must include last Blockquote row, got end {}",
777 r.end
778 );
779 }
780 x => panic!("expected Widened, got {x:?}"),
781 }
782 }
783
784 #[test]
785 fn widen_multi_list_does_not_over_pull_across_blank() {
786 // Two independent lists separated by a blank line. Damage in
787 // the second list must not pull the first list into the slice.
788 let k = kinds_str("LlBLll");
789 match widen_to_safe(&k, 4..5) {
790 WidenResult::Widened(r) => {
791 // The blank at row 2 is the separator. Widening must
792 // stop there (or at the row above, after D5 +1).
793 assert!(
794 r.start >= 1,
795 "widen.start must be >= 1 (D5 may pull past Blank by one row), got {}",
796 r.start
797 );
798 assert!(
799 r.start <= 2,
800 "widen.start must not pull in list A, got {}",
801 r.start
802 );
803 }
804 x => panic!("expected Widened, got {x:?}"),
805 }
806 }
807
808 #[test]
809 fn fence_ranges_single_fence() {
810 // P F C C F P — fence covers rows 1..5 (half-open: both markers + content).
811 let k = kinds_str("PFCCFP");
812 let r = fence_ranges_from_kinds(&k);
813 assert_eq!(r, vec![1..5]);
814 }
815
816 #[test]
817 fn fence_ranges_two_fences() {
818 // F C F P F C F — two fences at 0..3 and 4..7.
819 let k = kinds_str("FCFPFCF");
820 let r = fence_ranges_from_kinds(&k);
821 assert_eq!(r, vec![0..3, 4..7]);
822 }
823
824 #[test]
825 fn fence_ranges_unclosed_extends_to_end() {
826 // P F C C C — unclosed fence runs to end of buffer.
827 let k = kinds_str("PFCCC");
828 let r = fence_ranges_from_kinds(&k);
829 assert_eq!(r, vec![1..5]);
830 }
831
832 #[test]
833 fn fence_ranges_empty() {
834 assert!(fence_ranges_from_kinds(&[]).is_empty());
835 }
836
837 #[test]
838 fn code_block_ranges_covers_fenced_and_indented() {
839 // Fenced block then a blank then an indented code block.
840 let k = kinds_of(&[
841 "```", // FenceMarker
842 "let x = 1;", // FenceContent
843 "```", // FenceMarker
844 "", // Blank
845 " indented", // IndentedCode
846 " code", // IndentedCode
847 ]);
848 let r = code_block_ranges_from_kinds(&k);
849 assert_eq!(r, vec![0..3, 4..6]);
850 }
851
852 #[test]
853 fn investigate_list_fence_indented_code_interaction() {
854 // Initial: row 7 " a" is after "- a" (row 1) with 5 blank lines in between.
855 // After editing row 9 (blank → space inside fence), fresh parse changes row 7.
856 let initial: Vec<String> = vec![
857 "".to_string(), // 0: Blank
858 "- a".to_string(), // 1: ListMarker
859 "".to_string(), // 2: Blank
860 "".to_string(), // 3: Blank
861 "".to_string(), // 4: Blank
862 "".to_string(), // 5: Blank
863 "".to_string(), // 6: Blank
864 " a".to_string(), // 7: ? - before fence
865 "```".to_string(), // 8: FenceMarker
866 "".to_string(), // 9: FenceContent -> edit to " "
867 "".to_string(), // 10: FenceContent
868 "".to_string(), // 11: FenceContent
869 "".to_string(), // 12: FenceContent
870 "".to_string(), // 13: FenceContent
871 "".to_string(), // 14: FenceContent
872 "".to_string(), // 15: FenceContent
873 "".to_string(), // 16: FenceContent
874 "> a".to_string(), // 17: FenceContent
875 "".to_string(), // 18: FenceContent
876 "> ".to_string(), // 19: FenceContent
877 "".to_string(), // 20: FenceContent
878 "".to_string(), // 21: FenceContent
879 "".to_string(), // 22: FenceContent (last row → FenceMarker?)
880 ];
881 let initial_pb = ParsedBuffer::parse_lines(&initial);
882 eprintln!("initial kinds: {:?}", initial_pb.kinds);
883
884 let mut edited = initial.clone();
885 edited[9].push(' ');
886 let edited_pb = ParsedBuffer::parse_lines(&edited);
887 eprintln!("edited kinds: {:?}", edited_pb.kinds);
888
889 // Compare just the first 10 rows to see where divergence starts
890 for i in 0..23 {
891 if initial_pb.kinds[i] != edited_pb.kinds[i] {
892 eprintln!(
893 "Row {} differs: initial={:?}, edited={:?}",
894 i, initial_pb.kinds[i], edited_pb.kinds[i]
895 );
896 }
897 }
898 }
899}