Skip to main content

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}