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 before being deleted.
214pub fn expand_to_reset_boundary(
215    boundaries: &[usize],
216    lines_len: usize,
217    damaged: Range<usize>,
218) -> WidenResult {
219    if lines_len == 0 {
220        return WidenResult::FullRebuild;
221    }
222    debug_assert!(
223        damaged.start <= lines_len && damaged.end <= lines_len,
224        "expand_to_reset_boundary: damaged range {:?} out of bounds for lines_len = {}",
225        damaged,
226        lines_len,
227    );
228
229    // Greatest boundary <= damaged.start.
230    let start = boundaries
231        .iter()
232        .rev()
233        .find(|&&b| b <= damaged.start)
234        .copied()
235        .unwrap_or(0);
236    // Least boundary >= damaged.end. Sentinel `lines_len` is always
237    // present in a well-formed boundary set so the `unwrap_or` is
238    // unreachable; kept as a defensive fallback to avoid an inverted
239    // range if the invariant is ever violated.
240    let end = boundaries
241        .iter()
242        .find(|&&b| b >= damaged.end)
243        .copied()
244        .unwrap_or(lines_len);
245
246    let widened_len = end - start;
247    let cap_abs = MAX_INCREMENTAL_LINES;
248    // Same cap policy as widen_to_safe; see its docstring for the
249    // rationale on flooring `cap_frac` at `cap_abs`.
250    let cap_frac = (((lines_len as f32) * MAX_INCREMENTAL_FRACTION) as usize).max(cap_abs);
251    if widened_len > cap_abs || widened_len > cap_frac {
252        return WidenResult::FullRebuild;
253    }
254    WidenResult::Widened(start..end)
255}
256
257/// Widen `damaged` outward to safe construct boundaries, applying
258/// D5's +1 extra row and the D4 cap.
259///
260/// Returns `Widened(range)` when the widened range fits under the cap,
261/// or `FullRebuild` when the cap is exceeded or the buffer is empty.
262///
263/// Kept available for one release cycle as a behavioural comparison
264/// source against `expand_to_reset_boundary`. New call sites should use
265/// `expand_to_reset_boundary` instead.
266pub fn widen_to_safe(kinds: &[LineConstructKind], damaged: Range<usize>) -> WidenResult {
267    if kinds.is_empty() {
268        return WidenResult::FullRebuild;
269    }
270    debug_assert!(
271        damaged.start <= kinds.len() && damaged.end <= kinds.len(),
272        "widen_to_safe: damaged range {:?} out of bounds for kinds.len() = {}",
273        damaged,
274        kinds.len(),
275    );
276
277    let mut start = widen_up(kinds, damaged.start);
278    let mut end = widen_down(kinds, damaged.end);
279
280    // D5: widen one extra row on each side.
281    start = start.saturating_sub(1);
282    end = (end + 1).min(kinds.len());
283
284    let widened_len = end - start;
285    let cap_abs = MAX_INCREMENTAL_LINES;
286    // Fractional cap encodes the empirical "fresh full parse beats
287    // parse+splice" cross-over. It is only meaningful once full-parse
288    // cost is non-trivial; floor it at `cap_abs` so a 50%-widening on
289    // a tiny buffer (where both options are sub-millisecond) stays on
290    // the incremental path. Above `2 * cap_abs` lines the fractional
291    // cap dominates and catches large widenings the absolute cap
292    // would otherwise miss — this is the regime the previous `&&`
293    // operator left unguarded.
294    let cap_frac = (((kinds.len() as f32) * MAX_INCREMENTAL_FRACTION) as usize).max(cap_abs);
295    if widened_len > cap_abs || widened_len > cap_frac {
296        return WidenResult::FullRebuild;
297    }
298
299    WidenResult::Widened(start..end)
300}
301
302/// Derive fence-range half-open intervals from the per-line construct
303/// kinds. The view layer uses these to decide which logical rows
304/// render `force_raw` (no markdown re-styling, code-block fg color).
305///
306/// Half-open: a fence spanning rows `start..=end_inclusive` (both markers
307/// included) is returned as `start..end_inclusive + 1`. An unclosed
308/// fence runs to the end of the buffer.
309pub fn fence_ranges_from_kinds(kinds: &[LineConstructKind]) -> Vec<Range<usize>> {
310    let mut ranges = Vec::new();
311    let mut i = 0;
312    while i < kinds.len() {
313        if kinds[i] == LineConstructKind::FenceMarker {
314            let start = i;
315            i += 1;
316            while i < kinds.len() && kinds[i] == LineConstructKind::FenceContent {
317                i += 1;
318            }
319            if i < kinds.len() && kinds[i] == LineConstructKind::FenceMarker {
320                ranges.push(start..i + 1);
321                i += 1;
322            } else {
323                // Unclosed fence — extends to end of buffer.
324                ranges.push(start..kinds.len());
325            }
326        } else {
327            i += 1;
328        }
329    }
330    ranges
331}
332
333/// Line ranges of every code block (fenced AND indented) in the buffer,
334/// in ascending order. Reuses [`fence_ranges_from_kinds`] for fenced blocks
335/// (incl. unclosed-fence handling) and adds maximal `IndentedCode` runs.
336/// Used by the view to paint the code-box background.
337pub fn code_block_ranges_from_kinds(kinds: &[LineConstructKind]) -> Vec<Range<usize>> {
338    let mut ranges = fence_ranges_from_kinds(kinds);
339    let mut i = 0;
340    while i < kinds.len() {
341        if kinds[i] == LineConstructKind::IndentedCode {
342            let start = i;
343            while i < kinds.len() && kinds[i] == LineConstructKind::IndentedCode {
344                i += 1;
345            }
346            ranges.push(start..i);
347        } else {
348            i += 1;
349        }
350    }
351    // Fenced ranges are collected first then indented ones appended; sort so the
352    // combined list is ascending. Fenced and indented spans never overlap.
353    ranges.sort_by_key(|r| r.start);
354    ranges
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::components::text_editor::markdown::ParsedBuffer;
361
362    fn kinds_of(lines: &[&str]) -> Vec<LineConstructKind> {
363        let owned: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
364        ParsedBuffer::parse_lines(&owned).kinds
365    }
366
367    #[test]
368    fn plain_paragraph() {
369        assert_eq!(kinds_of(&["hello world"]), vec![LineConstructKind::Plain]);
370    }
371
372    #[test]
373    fn blank_line() {
374        assert_eq!(kinds_of(&[""]), vec![LineConstructKind::Blank]);
375    }
376
377    #[test]
378    fn atx_heading() {
379        assert_eq!(kinds_of(&["# title"]), vec![LineConstructKind::Heading]);
380    }
381
382    #[test]
383    fn setext_underline_above_is_plain() {
384        let k = kinds_of(&["title", "====="]);
385        assert_eq!(
386            k,
387            vec![LineConstructKind::Plain, LineConstructKind::SetextUnderline]
388        );
389    }
390
391    #[test]
392    fn fence_pair() {
393        let k = kinds_of(&["```rust", "let x = 1;", "```"]);
394        assert_eq!(
395            k,
396            vec![
397                LineConstructKind::FenceMarker,
398                LineConstructKind::FenceContent,
399                LineConstructKind::FenceMarker,
400            ]
401        );
402    }
403
404    #[test]
405    fn list_marker_and_continuation() {
406        let k = kinds_of(&["- item", "  continuation"]);
407        assert_eq!(
408            k,
409            vec![
410                LineConstructKind::ListMarker,
411                LineConstructKind::ListContinuation
412            ]
413        );
414    }
415
416    #[test]
417    fn blockquote_levels() {
418        let k = kinds_of(&[">> two"]);
419        assert_eq!(k, vec![LineConstructKind::Blockquote(2)]);
420    }
421
422    #[test]
423    fn indented_code() {
424        let k = kinds_of(&["", "    let x = 1;"]);
425        assert_eq!(k[1], LineConstructKind::IndentedCode);
426    }
427
428    #[test]
429    fn html_block() {
430        let k = kinds_of(&["<div>", "body", "</div>"]);
431        assert!(matches!(k[0], LineConstructKind::HtmlBlock));
432    }
433
434    #[test]
435    fn inline_html_inside_paragraph_does_not_become_html_block() {
436        // Regression: `Event::InlineHtml` previously painted the
437        // paragraph row as HtmlBlock, defeating safe-boundary widening
438        // for any paragraph containing inline HTML like `<br>` or
439        // `<span>`.
440        let k = kinds_of(&["hello <br> world"]);
441        assert_eq!(
442            k[0],
443            LineConstructKind::Plain,
444            "paragraph with inline HTML must stay Plain"
445        );
446        let k = kinds_of(&["see <span>x</span> end"]);
447        assert_eq!(k[0], LineConstructKind::Plain);
448    }
449
450    fn lines(strs: &[&str]) -> Vec<String> {
451        strs.iter().map(|s| s.to_string()).collect()
452    }
453
454    #[test]
455    fn damage_single_char_insert_uses_cursor_hint() {
456        let old = lines(&["hello", "world"]);
457        let new = lines(&["hello", "worldx"]);
458        assert_eq!(compute_damage_range(&old, &new, 1), Some(1..2));
459    }
460
461    #[test]
462    fn damage_no_change_returns_none() {
463        let old = lines(&["a", "b"]);
464        assert_eq!(compute_damage_range(&old, &old, 0), None);
465    }
466
467    /// The fast path's blind spot, pinned so the contract's cost is visible: a
468    /// buffer edited in two distant places, with the cursor on one of them, is
469    /// reported as a one-row change. Callers that can produce this shape — a
470    /// **replace all**, a whole-buffer `set_text`, a grouped undo of either — must
471    /// call `MarkdownEditorView::note_bulk_edit` instead of relying on this.
472    #[test]
473    fn the_cursor_hint_under_reports_a_two_place_edit() {
474        let old: Vec<String> = ["todo", "a", "b", "c", "d", "e", "todo"]
475            .iter()
476            .map(|s| s.to_string())
477            .collect();
478        let new: Vec<String> = ["X", "a", "b", "c", "d", "e", "X"]
479            .iter()
480            .map(|s| s.to_string())
481            .collect();
482        assert_eq!(
483            compute_damage_range(&old, &new, 6),
484            Some(6..7),
485            "row 0 is silently omitted — this is why the hint must be suppressed \
486         for edits the cursor does not describe"
487        );
488        // Suppressing the hint (an out-of-range row) falls through to LCP/LCS,
489        // which spans both edits.
490        assert_eq!(compute_damage_range(&old, &new, usize::MAX), Some(0..7));
491    }
492
493    #[test]
494    fn damage_enter_at_line_end_uses_lcp_lcs() {
495        let old = lines(&["alpha", "beta"]);
496        let new = lines(&["alpha", "be", "ta"]);
497        let dmg = compute_damage_range(&old, &new, 1).unwrap();
498        assert_eq!(dmg.start, 1);
499        assert_eq!(dmg.end, new.len()); // damaged = [1..3)
500    }
501
502    #[test]
503    fn damage_backspace_merging_lines() {
504        let old = lines(&["alpha", "beta", "gamma"]);
505        let new = lines(&["alphabeta", "gamma"]);
506        let dmg = compute_damage_range(&old, &new, 0).unwrap();
507        assert_eq!(dmg.start, 0);
508    }
509
510    #[test]
511    fn damage_multi_diff_within_window_falls_through_to_slow_path() {
512        // Two rows differ, both within CURSOR_HINT_WINDOW of the cursor.
513        // Fast path's other-diff-in-window check trips → LCP/LCS slow path.
514        let old = lines(&["a", "b", "c", "d", "e"]);
515        let mut new = old.clone();
516        new[1] = "B".to_string();
517        new[2] = "C".to_string();
518        // Cursor at row 1; the window covers rows 0..=4 (full buffer here).
519        let dmg = compute_damage_range(&old, &new, 1).unwrap();
520        // Slow path: LCP=1, LCS=2 → 1..3
521        assert_eq!(dmg, 1..3);
522    }
523
524    fn kinds_str(s: &str) -> Vec<LineConstructKind> {
525        // Compact spec: one char per line.
526        // P=Plain, B=Blank, F=FenceMarker, C=FenceContent,
527        // L=ListMarker, l=ListContinuation, Q=Blockquote(1),
528        // S=SetextUnderline, H=Heading, I=IndentedCode, X=HtmlBlock.
529        s.chars()
530            .map(|c| match c {
531                'P' => LineConstructKind::Plain,
532                'B' => LineConstructKind::Blank,
533                'F' => LineConstructKind::FenceMarker,
534                'C' => LineConstructKind::FenceContent,
535                'L' => LineConstructKind::ListMarker,
536                'l' => LineConstructKind::ListContinuation,
537                'Q' => LineConstructKind::Blockquote(1),
538                'S' => LineConstructKind::SetextUnderline,
539                'H' => LineConstructKind::Heading,
540                'I' => LineConstructKind::IndentedCode,
541                'X' => LineConstructKind::HtmlBlock,
542                _ => panic!("bad kind char {c}"),
543            })
544            .collect()
545    }
546
547    #[test]
548    fn widen_plain_paragraph_to_blank_boundaries() {
549        // P B P P P B P — damage row 3 → widen to blank rows 1 and 5
550        // (plus the D5 +1 each side: 0 and 6 — but the buffer ends are
551        // also boundaries; clamp).
552        let k = kinds_str("PBPPPBP");
553        match widen_to_safe(&k, 3..4) {
554            WidenResult::Widened(r) => {
555                // Must include the blank rows at 1 and 5 (or wider).
556                assert!(r.start <= 1, "widen.start <= 1, got {}", r.start);
557                assert!(r.end >= 6, "widen.end >= 6, got {}", r.end);
558            }
559            x => panic!("expected Widened, got {x:?}"),
560        }
561    }
562
563    #[test]
564    fn widen_fence_interior_includes_both_markers() {
565        // P B F C C C F B P — damage row 4 (inside fence) → widen
566        // to include both fence markers + one extra line on each side.
567        let k = kinds_str("PBFCCCFBP");
568        match widen_to_safe(&k, 4..5) {
569            WidenResult::Widened(r) => {
570                assert!(
571                    r.start <= 2,
572                    "must include opening fence marker at row 2, got start {}",
573                    r.start
574                );
575                assert!(
576                    r.end >= 7,
577                    "must include closing fence marker at row 6 (end >= 7), got end {}",
578                    r.end
579                );
580            }
581            x => panic!("expected Widened, got {x:?}"),
582        }
583    }
584
585    #[test]
586    fn widen_list_continuation_reaches_outermost_marker() {
587        // L l L l l l B P — damage at row 4 (nested continuation) → widen
588        // up to outermost ListMarker at row 0.
589        let k = kinds_str("LlLlllBP");
590        match widen_to_safe(&k, 4..5) {
591            WidenResult::Widened(r) => assert_eq!(r.start, 0, "must reach col-0 list marker"),
592            x => panic!("expected Widened, got {x:?}"),
593        }
594    }
595
596    #[test]
597    fn widen_setext_underline_includes_text_line_above() {
598        // P S P — damage at row 1 (underline) → widen to include row 0
599        // (heading text line).
600        let k = kinds_str("PSP");
601        match widen_to_safe(&k, 1..2) {
602            WidenResult::Widened(r) => {
603                assert_eq!(r.start, 0, "must include row above setext underline")
604            }
605            x => panic!("expected Widened, got {x:?}"),
606        }
607    }
608
609    #[test]
610    fn widen_html_block_includes_whole_block() {
611        // P X X X B P — damage at row 2 (middle of HTML) → widen to
612        // include all HtmlBlock rows.
613        let k = kinds_str("PXXXBP");
614        match widen_to_safe(&k, 2..3) {
615            WidenResult::Widened(r) => {
616                assert!(
617                    r.start <= 1,
618                    "must include first HtmlBlock row, got start {}",
619                    r.start
620                );
621                assert!(
622                    r.end >= 4,
623                    "must include last HtmlBlock row, got end {}",
624                    r.end
625                );
626            }
627            x => panic!("expected Widened, got {x:?}"),
628        }
629    }
630
631    #[test]
632    fn widen_exceeds_cap_returns_full_rebuild() {
633        // 300-line all-FenceContent buffer; the damage is one line;
634        // widening tries to reach the fence ends but the buffer is
635        // uniformly fence content, so widening goes to 0..300, which
636        // exceeds MAX_INCREMENTAL_LINES (256).
637        let k = vec![LineConstructKind::FenceContent; 300];
638        assert_eq!(widen_to_safe(&k, 150..151), WidenResult::FullRebuild);
639    }
640
641    #[test]
642    fn widen_trips_when_fractional_cap_exceeds_absolute() {
643        // Regression: cap-trip used `&&` instead of `||`, so on a buffer
644        // big enough that `cap_frac > cap_abs` (kinds.len() > 512), a
645        // widened range between the two thresholds slipped through.
646        // 600-line buffer of FenceContent → cap_abs=256, cap_frac=300.
647        // Widening covers the whole buffer (no safe boundaries), so
648        // widened_len=600 must trip the fallback.
649        let k = vec![LineConstructKind::FenceContent; 600];
650        assert_eq!(widen_to_safe(&k, 300..301), WidenResult::FullRebuild);
651    }
652
653    #[test]
654    fn widen_at_buffer_start_clamps_to_zero() {
655        let k = kinds_str("PPPPP");
656        match widen_to_safe(&k, 0..1) {
657            WidenResult::Widened(r) => assert_eq!(r.start, 0),
658            x => panic!("expected Widened, got {x:?}"),
659        }
660    }
661
662    #[test]
663    fn widen_at_buffer_end_clamps_to_len() {
664        let k = kinds_str("PPPPP");
665        match widen_to_safe(&k, 4..5) {
666            WidenResult::Widened(r) => assert_eq!(r.end, 5),
667            x => panic!("expected Widened, got {x:?}"),
668        }
669    }
670
671    #[test]
672    fn parse_records_boundaries_for_blank_separated_paragraphs() {
673        // Realistic markdown layout: each paragraph followed by a
674        // blank line. Pulldown ends each Paragraph; depth drops to
675        // 0 at the following blank row. The boundary set should
676        // contain every blank row.
677        use super::super::markdown::ParsedBuffer;
678        let mut lines: Vec<String> = Vec::with_capacity(8);
679        for i in 0..4 {
680            lines.push(format!("paragraph {i}"));
681            lines.push(String::new());
682        }
683        let pb = ParsedBuffer::parse_lines(&lines);
684        // Expected: 0, then every Blank row (1, 3, 5, 7), then lines.len() (8).
685        // The blank at row 7 == lines.len()-1 may or may not be
686        // present depending on whether depth==0 was reached at that
687        // row; check the interior at least.
688        assert!(pb.reset_boundaries.contains(&0), "sentinel 0 missing");
689        assert!(
690            pb.reset_boundaries.contains(&lines.len()),
691            "sentinel lines.len() missing"
692        );
693        assert!(
694            pb.reset_boundaries.contains(&1),
695            "blank after paragraph 0 should be a boundary, got {:?}",
696            pb.reset_boundaries
697        );
698        assert!(
699            pb.reset_boundaries.contains(&3),
700            "blank after paragraph 1 should be a boundary, got {:?}",
701            pb.reset_boundaries
702        );
703    }
704
705    #[test]
706    fn expand_to_reset_uses_nearest_sentinels() {
707        // Only sentinels [0, 5] in the boundary set — every edit
708        // expands to the full buffer.
709        let boundaries = vec![0, 5];
710        match expand_to_reset_boundary(&boundaries, 5, 2..3) {
711            WidenResult::Widened(r) => assert_eq!(r, 0..5),
712            x => panic!("expected Widened, got {x:?}"),
713        }
714    }
715
716    #[test]
717    fn expand_to_reset_snaps_to_interior_boundaries() {
718        // Boundaries at rows 0, 3, 6, 10 (e.g. blank-separated
719        // blocks). Damage at row 4 expands to 3..6.
720        let boundaries = vec![0, 3, 6, 10];
721        match expand_to_reset_boundary(&boundaries, 10, 4..5) {
722            WidenResult::Widened(r) => assert_eq!(r, 3..6),
723            x => panic!("expected Widened, got {x:?}"),
724        }
725    }
726
727    #[test]
728    fn expand_to_reset_damage_at_exact_boundary_is_zero_span() {
729        // Damage range coincides with a boundary point. The function
730        // returns the smallest enclosing boundary pair.
731        let boundaries = vec![0, 3, 6, 10];
732        // damaged.start == damaged.end == 6. Expands to 6..6 (empty).
733        match expand_to_reset_boundary(&boundaries, 10, 6..6) {
734            WidenResult::Widened(r) => assert_eq!(r, 6..6),
735            x => panic!("expected Widened, got {x:?}"),
736        }
737    }
738
739    #[test]
740    fn expand_to_reset_empty_buffer_falls_back() {
741        let boundaries = vec![0];
742        assert_eq!(
743            expand_to_reset_boundary(&boundaries, 0, 0..0),
744            WidenResult::FullRebuild
745        );
746    }
747
748    #[test]
749    fn expand_to_reset_caps_trip_fallback() {
750        // 600-row buffer, no interior boundaries. Damage at 300
751        // expands to 0..600 which exceeds cap_abs (256) and cap_frac
752        // (300, floored at cap_abs).
753        let boundaries = vec![0, 600];
754        assert_eq!(
755            expand_to_reset_boundary(&boundaries, 600, 300..301),
756            WidenResult::FullRebuild
757        );
758    }
759
760    #[test]
761    fn widen_blockquote_includes_whole_block() {
762        // P Q Q Q B P — damage in the middle of a blockquote → widen
763        // to include the whole blockquote.
764        let k = kinds_str("PQQQBP");
765        match widen_to_safe(&k, 2..3) {
766            WidenResult::Widened(r) => {
767                assert!(
768                    r.start <= 1,
769                    "must include first Blockquote row, got start {}",
770                    r.start
771                );
772                assert!(
773                    r.end >= 4,
774                    "must include last Blockquote row, got end {}",
775                    r.end
776                );
777            }
778            x => panic!("expected Widened, got {x:?}"),
779        }
780    }
781
782    #[test]
783    fn widen_multi_list_does_not_over_pull_across_blank() {
784        // Two independent lists separated by a blank line. Damage in
785        // the second list must not pull the first list into the slice.
786        let k = kinds_str("LlBLll");
787        match widen_to_safe(&k, 4..5) {
788            WidenResult::Widened(r) => {
789                // The blank at row 2 is the separator. Widening must
790                // stop there (or at the row above, after D5 +1).
791                assert!(
792                    r.start >= 1,
793                    "widen.start must be >= 1 (D5 may pull past Blank by one row), got {}",
794                    r.start
795                );
796                assert!(
797                    r.start <= 2,
798                    "widen.start must not pull in list A, got {}",
799                    r.start
800                );
801            }
802            x => panic!("expected Widened, got {x:?}"),
803        }
804    }
805
806    #[test]
807    fn fence_ranges_single_fence() {
808        // P F C C F P — fence covers rows 1..5 (half-open: both markers + content).
809        let k = kinds_str("PFCCFP");
810        let r = fence_ranges_from_kinds(&k);
811        assert_eq!(r, vec![1..5]);
812    }
813
814    #[test]
815    fn fence_ranges_two_fences() {
816        // F C F P F C F — two fences at 0..3 and 4..7.
817        let k = kinds_str("FCFPFCF");
818        let r = fence_ranges_from_kinds(&k);
819        assert_eq!(r, vec![0..3, 4..7]);
820    }
821
822    #[test]
823    fn fence_ranges_unclosed_extends_to_end() {
824        // P F C C C — unclosed fence runs to end of buffer.
825        let k = kinds_str("PFCCC");
826        let r = fence_ranges_from_kinds(&k);
827        assert_eq!(r, vec![1..5]);
828    }
829
830    #[test]
831    fn fence_ranges_empty() {
832        assert!(fence_ranges_from_kinds(&[]).is_empty());
833    }
834
835    #[test]
836    fn code_block_ranges_covers_fenced_and_indented() {
837        // Fenced block then a blank then an indented code block.
838        let k = kinds_of(&[
839            "```",          // FenceMarker
840            "let x = 1;",   // FenceContent
841            "```",          // FenceMarker
842            "",             // Blank
843            "    indented", // IndentedCode
844            "    code",     // IndentedCode
845        ]);
846        let r = code_block_ranges_from_kinds(&k);
847        assert_eq!(r, vec![0..3, 4..6]);
848    }
849
850    #[test]
851    fn investigate_list_fence_indented_code_interaction() {
852        // Initial: row 7 "    a" is after "- a" (row 1) with 5 blank lines in between.
853        // After editing row 9 (blank → space inside fence), fresh parse changes row 7.
854        let initial: Vec<String> = vec![
855            "".to_string(),      // 0: Blank
856            "- a".to_string(),   // 1: ListMarker
857            "".to_string(),      // 2: Blank
858            "".to_string(),      // 3: Blank
859            "".to_string(),      // 4: Blank
860            "".to_string(),      // 5: Blank
861            "".to_string(),      // 6: Blank
862            "    a".to_string(), // 7: ? - before fence
863            "```".to_string(),   // 8: FenceMarker
864            "".to_string(),      // 9: FenceContent -> edit to " "
865            "".to_string(),      // 10: FenceContent
866            "".to_string(),      // 11: FenceContent
867            "".to_string(),      // 12: FenceContent
868            "".to_string(),      // 13: FenceContent
869            "".to_string(),      // 14: FenceContent
870            "".to_string(),      // 15: FenceContent
871            "".to_string(),      // 16: FenceContent
872            "> a".to_string(),   // 17: FenceContent
873            "".to_string(),      // 18: FenceContent
874            ">  ".to_string(),   // 19: FenceContent
875            "".to_string(),      // 20: FenceContent
876            "".to_string(),      // 21: FenceContent
877            "".to_string(),      // 22: FenceContent (last row → FenceMarker?)
878        ];
879        let initial_pb = ParsedBuffer::parse_lines(&initial);
880        eprintln!("initial kinds: {:?}", initial_pb.kinds);
881
882        let mut edited = initial.clone();
883        edited[9].push(' ');
884        let edited_pb = ParsedBuffer::parse_lines(&edited);
885        eprintln!("edited  kinds: {:?}", edited_pb.kinds);
886
887        // Compare just the first 10 rows to see where divergence starts
888        for i in 0..23 {
889            if initial_pb.kinds[i] != edited_pb.kinds[i] {
890                eprintln!(
891                    "Row {} differs: initial={:?}, edited={:?}",
892                    i, initial_pb.kinds[i], edited_pb.kinds[i]
893                );
894            }
895        }
896    }
897}