Skip to main content

kimun_notes/components/text_editor/
view.rs

1use super::markdown::{MarkdownSpanner, ParsedBuffer, opener_shape};
2use super::word_wrap::WordWrapLayout;
3use crate::settings::themes::Theme;
4use ratatui::Frame;
5use ratatui::layout::Position;
6use ratatui::layout::Rect;
7use ratatui::text::{Line, Text};
8use ratatui::widgets::Paragraph;
9use std::ops::Range;
10use std::sync::OnceLock;
11use unicode_width::UnicodeWidthStr;
12
13/// Describes how `view.update`'s Gate 1 modified the parse caches this
14/// frame. Read by Gate 2 to decide what subset of `rendered_cache` and
15/// `WordWrapLayout` needs to be rebuilt.
16#[derive(Debug, Clone)]
17enum TextChangeKind {
18    /// No text change this frame (cursor-only update). Gate 2 may keep
19    /// its caches and only refresh the cursor-row entry.
20    None,
21    /// Gate 1 took the incremental splice path; only rows in this
22    /// range had their ParsedLine entries replaced. Gate 2 should
23    /// rebuild rendered_cache only for these rows + the cursor rows.
24    Incremental(std::ops::Range<usize>),
25    /// Full rebuild (initial parse, line-count change, cap trip,
26    /// structural-marker change, post-slice verification miss). Gate 2
27    /// must rebuild rendered_cache for every row.
28    Full,
29}
30
31enum RenderedCacheRebuild {
32    Full,
33    Rows(Vec<usize>),
34    None,
35}
36
37#[derive(Clone)]
38pub struct MarkdownEditorView {
39    pub layout: WordWrapLayout,
40    visual_scroll_offset: usize,
41    pub lines_snapshot: Vec<String>,
42    pub cursor_snapshot: (usize, usize),
43    /// Line ranges of every fenced code block in the buffer. Text-keyed
44    /// (rebuilt only when `text_revision` changes); `is_in_code_block`
45    /// does a cheap point lookup against this list per row so all fenced
46    /// blocks render `force_raw` regardless of where the cursor is.
47    fence_ranges: Vec<Range<usize>>,
48    /// Cursor's last on-screen position (col, row), or `None` when the
49    /// cursor was scrolled off-screen or the view was unfocused at the
50    /// time of the previous `render`. Used as the anchor for floating
51    /// overlays like the autocomplete popup, which is drawn after the
52    /// editor itself.
53    pub last_cursor_screen: Option<(u16, u16)>,
54    /// Per-line parse cache built in `update()`. Eliminates redundant pulldown-cmark
55    /// invocations across `render()`, cursor placement, and click mapping.
56    /// Either a Real or Placeholder parse — see [`ParseState`].
57    parse_state: ParseState,
58    /// Last `text_revision` seen — gates the lines clone and parse-cache rebuild.
59    /// Cursor-only moves do not bump `text_revision`, so navigating with the
60    /// arrow keys reuses the parse cache instead of re-running pulldown-cmark
61    /// over the whole buffer.
62    last_seen_generation: u64,
63    /// `text_revision`/width/cursor at which the layout was last computed.
64    /// Used to skip `WordWrapLayout::compute()` when nothing affecting wrap has changed:
65    /// horizontal cursor movement within the same element (or plain text) is free.
66    last_layout_generation: u64,
67    last_layout_width: u16,
68    last_layout_cursor: (usize, usize),
69    /// Visual row of the cursor, cached after layout so `render()` doesn't call
70    /// `logical_to_visual` a second time.
71    cursor_vrow: usize,
72    /// Per-line rendered-position bitmask, cached between layout recomputes.
73    /// Only the two cursor rows (old and new) are rebuilt when just the cursor row changes;
74    /// all rows are rebuilt when content or width changes.
75    rendered_cache: Vec<Vec<bool>>,
76    /// Current selection range in logical (row, byte-col) coordinates.
77    /// `None` when no selection is active.
78    selection: Option<((usize, usize), (usize, usize))>,
79    /// Diagnostic: true when the most recent Gate 1 invocation used the
80    /// incremental splice path, false when it took the full-parse fallback.
81    /// Read by tests; not part of the production observable surface.
82    last_parse_was_incremental: bool,
83    /// Diagnostic: which widener tier (`Strict` / `Heuristic`)
84    /// produced the most recent successful incremental
85    /// splice. `None` when no incremental splice has happened yet
86    /// (first parse or full-rebuild fallbacks). Read by unit tests
87    /// asserting the chosen widener path.
88    last_splice_path: Option<SplicePath>,
89    /// Tracks how Gate 1 changed (or did not change) the parse caches.
90    /// Gate 2 reads this to decide the scope of rendered_cache rebuild.
91    last_text_change: TextChangeKind,
92}
93
94/// True when `KIMUN_VIEW_VERIFY_INCREMENTAL=1` is set. Reads the
95/// env var once per process and caches. Gates the debug-only
96/// full-kinds assertion in Gate 1 that compares every incremental
97/// splice against a fresh whole-buffer parse. (The per-splice
98/// undamaged-row verify on the heuristic path runs in release
99/// unconditionally — see `try_incremental_parse`.)
100fn verify_incremental_enabled() -> bool {
101    static VERIFY: OnceLock<bool> = OnceLock::new();
102    *VERIFY.get_or_init(|| {
103        std::env::var("KIMUN_VIEW_VERIFY_INCREMENTAL")
104            .map(|v| !v.is_empty() && v != "0")
105            .unwrap_or(false)
106    })
107}
108
109/// Which widener produced the splice for the most recent successful
110/// incremental parse. Test telemetry — read by `last_splice_path`
111/// in unit tests to assert the chosen path. Mirror of
112/// [`SuccessPath`] but kept private since callers shouldn't depend
113/// on widener internals.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum SplicePath {
116    /// Strict reset-boundary widener (`reset_boundaries`) succeeded.
117    Strict,
118    /// `widen_to_safe` heuristic succeeded after the strict
119    /// reset-boundary widener returned `FullRebuild`.
120    Heuristic,
121}
122
123/// The editor's per-buffer parse cache: either a fully-styled **Real
124/// parse** or an unstyled **Placeholder parse** awaiting a background
125/// full parse (see `CONTEXT.md`). Modelling the distinction as a type
126/// makes the wrong-splice hazard unrepresentable: splicing is only
127/// reachable through [`ParseState::splice_real`], whose `Placeholder`
128/// arm is unreachable because Gate 1 declines the incremental path for
129/// placeholders. The placeholder's all-`Plain` line kinds would
130/// otherwise defeat the structural guards and accept a wrong splice.
131#[derive(Clone)]
132enum ParseState {
133    Real(ParsedBuffer),
134    /// `generation` is the `content_revision` the placeholder was
135    /// installed for — handed to the owning component so it knows which
136    /// buffer to parse on the background task. `spawned` flips true once
137    /// that task has been requested, so `take_pending_full_parse` hands
138    /// the generation out exactly once.
139    Placeholder {
140        buf: ParsedBuffer,
141        generation: u64,
142        spawned: bool,
143    },
144}
145
146impl ParseState {
147    /// State-agnostic buffer access. Render and Gate 2 read the buffer
148    /// in both states — the placeholder has valid row counts, so the
149    /// downstream path stays in-bounds; only the markdown styling is
150    /// missing while it is a placeholder.
151    fn buf(&self) -> &ParsedBuffer {
152        match self {
153            Self::Real(b) | Self::Placeholder { buf: b, .. } => b,
154        }
155    }
156
157    fn is_placeholder(&self) -> bool {
158        matches!(self, Self::Placeholder { .. })
159    }
160
161    /// Splice an incremental slice into a Real parse. Called only after
162    /// the `is_placeholder()` gate in Gate 1 has declined the
163    /// incremental path for placeholders, so the `Placeholder` arm is
164    /// unreachable.
165    fn splice_real(&mut self, range: std::ops::Range<usize>, slice: ParsedBuffer) {
166        match self {
167            Self::Real(b) => b.splice(range, slice),
168            Self::Placeholder { .. } => {
169                debug_assert!(false, "splice on placeholder parse");
170            }
171        }
172    }
173}
174
175impl MarkdownEditorView {
176    pub fn new() -> Self {
177        Self {
178            layout: WordWrapLayout::default(),
179            visual_scroll_offset: 0,
180            lines_snapshot: Vec::new(),
181            cursor_snapshot: (0, 0),
182            fence_ranges: Vec::new(),
183            last_cursor_screen: None,
184            // Empty buffer, spliceable — preserves the previous
185            // `placeholder_active: false` initial state.
186            parse_state: ParseState::Real(ParsedBuffer::placeholder(&[])),
187            last_seen_generation: u64::MAX, // force rebuild on first update
188            last_layout_generation: u64::MAX,
189            last_layout_width: 0,
190            last_layout_cursor: (usize::MAX, usize::MAX),
191            cursor_vrow: 0,
192            rendered_cache: Vec::new(),
193            selection: None,
194            last_parse_was_incremental: false,
195            last_splice_path: None,
196            last_text_change: TextChangeKind::Full, // first update is a full rebuild
197        }
198    }
199
200    /// Threshold above which a fallback to full parse runs
201    /// asynchronously instead of blocking the typing thread. On
202    /// buffers below this size the full parse is fast enough
203    /// (<2ms for a paragraph-only 1000-line buffer per bench) that
204    /// blocking is preferable to the one-frame-of-unstyled-text
205    /// the async path imposes.
206    const LARGE_BUFFER_THRESHOLD: usize = 1000;
207
208    /// Returns `Some(generation)` if Gate 1 just installed a
209    /// placeholder `ParsedBuffer` and the owning component should
210    /// spawn a background full parse for this generation. Consumes
211    /// the flag so the owner does not spawn twice; the owner is
212    /// responsible for calling `install_full_parse` when the task
213    /// completes.
214    /// Whether the most recent Gate 1 invocation took the incremental
215    /// splice path. Read-only diagnostic for the incremental-parse
216    /// property tests (`tui/tests/incremental_property.rs`); not part
217    /// of the production render path.
218    pub fn last_parse_was_incremental(&self) -> bool {
219        self.last_parse_was_incremental
220    }
221
222    pub fn take_pending_full_parse(&mut self) -> Option<u64> {
223        if let ParseState::Placeholder {
224            generation,
225            spawned,
226            ..
227        } = &mut self.parse_state
228            && !*spawned
229        {
230            *spawned = true;
231            return Some(*generation);
232        }
233        None
234    }
235
236    /// Install the result of a background full parse. No-op when
237    /// the editor has advanced past `generation` — that result is
238    /// stale and a fresh spawn is already in flight. Invalidates the
239    /// layout + rendered_cache so the next `update()` rebuilds Gate
240    /// 2 against the fresh `ParsedBuffer`.
241    pub fn install_full_parse(&mut self, generation: u64, buf: ParsedBuffer) {
242        if generation != self.last_seen_generation {
243            return; // stale
244        }
245        self.parse_state = ParseState::Real(buf);
246        self.fence_ranges =
247            super::parse_incremental::fence_ranges_from_kinds(&self.parse_state.buf().kinds);
248        // Force Gate 2 full rebuild on the next update: the
249        // placeholder's all-Plain kinds produced different fence
250        // ranges and rendered masks than the real parse will.
251        self.last_text_change = TextChangeKind::Full;
252        self.last_layout_generation = u64::MAX;
253    }
254
255    pub fn update(
256        &mut self,
257        snap: &super::snapshot::EditorSnapshot<'_>,
258        rect: Rect,
259        selection: Option<((usize, usize), (usize, usize))>,
260    ) {
261        // Snapshot owns the (cursor, lines, content_revision) atomicity
262        // — readers below can index `parsed_buffer.lines[cursor.0]`
263        // without `.get()` guards once Gate 1 has rebuilt the parse
264        // cache from these same `lines`.
265        let lines: &[String] = &snap.lines;
266        let cursor = snap.cursor;
267        let generation = snap.content_revision.get();
268        self.selection = selection;
269        if rect.height == 0 {
270            return;
271        }
272
273        // Gate 1: content changed — rebuild parse cache and snapshots.
274        if generation != self.last_seen_generation {
275            let incremental = if self.parse_state.is_placeholder() {
276                None
277            } else {
278                self.try_incremental_parse(lines, cursor)
279            };
280            self.last_text_change = match incremental {
281                Some((range, slice, path)) => {
282                    self.parse_state.splice_real(range.clone(), slice);
283                    self.last_parse_was_incremental = true;
284                    self.last_splice_path = Some(path);
285                    TextChangeKind::Incremental(range)
286                }
287                None => {
288                    if lines.len() >= Self::LARGE_BUFFER_THRESHOLD {
289                        // Async fallback: install a structurally-
290                        // correct but unstyled placeholder so this
291                        // frame can paint immediately; defer the
292                        // real pulldown parse to a background tokio
293                        // task spawned by the owning component (see
294                        // `take_pending_full_parse` / `install_full_parse`).
295                        // The placeholder has the same row count as
296                        // `lines`, so the downstream Gate 2 / render
297                        // path stays in-bounds; only the markdown
298                        // styling is missing for one frame.
299                        self.parse_state = ParseState::Placeholder {
300                            buf: ParsedBuffer::placeholder(lines),
301                            generation,
302                            spawned: false,
303                        };
304                    } else {
305                        self.parse_state = ParseState::Real(ParsedBuffer::parse(lines));
306                    }
307                    self.last_parse_was_incremental = false;
308                    self.last_splice_path = None;
309                    TextChangeKind::Full
310                }
311            };
312            #[cfg(debug_assertions)]
313            if self.last_parse_was_incremental && verify_incremental_enabled() {
314                let fresh = ParsedBuffer::parse(lines);
315                assert_eq!(
316                    self.parse_state.buf().kinds,
317                    fresh.kinds,
318                    "incremental kinds diverge from full parse at generation={generation}"
319                );
320                assert_eq!(
321                    self.parse_state.buf().lazy_depth,
322                    fresh.lazy_depth,
323                    "incremental lazy_depth diverges from full parse at generation={generation}"
324                );
325                assert_eq!(
326                    self.parse_state.buf().reset_boundaries,
327                    fresh.reset_boundaries,
328                    "incremental reset_boundaries diverge from full parse at generation={generation}"
329                );
330                assert_eq!(
331                    self.parse_state.buf().lines.len(),
332                    fresh.lines.len(),
333                    "incremental lines.len() diverges from full parse at generation={generation}"
334                );
335                for (i, (got, exp)) in self
336                    .parse_state
337                    .buf()
338                    .lines
339                    .iter()
340                    .zip(fresh.lines.iter())
341                    .enumerate()
342                {
343                    got.debug_assert_eq_to(exp, i);
344                }
345            }
346            self.fence_ranges =
347                super::parse_incremental::fence_ranges_from_kinds(&self.parse_state.buf().kinds);
348            // Incremental update of `lines_snapshot` mirrors the parse
349            // path: on the splice path only the rows in `range` can
350            // have changed (try_incremental_parse already bails when
351            // line count differs); on the full-parse fallback we lose
352            // damage info, so re-clone everything.
353            //
354            // `String::clone_from` reuses the destination's existing
355            // allocation when capacity permits, so the typical
356            // single-char insert costs one String reallocation
357            // (often zero — capacity stays put) instead of N.
358            match &self.last_text_change {
359                TextChangeKind::Incremental(range) => {
360                    debug_assert_eq!(
361                        self.lines_snapshot.len(),
362                        lines.len(),
363                        "incremental path requires equal line counts"
364                    );
365                    for i in range.clone() {
366                        self.lines_snapshot[i].clone_from(&lines[i]);
367                    }
368                }
369                TextChangeKind::Full | TextChangeKind::None => {
370                    if self.lines_snapshot.len() == lines.len() {
371                        for (dst, src) in self.lines_snapshot.iter_mut().zip(lines.iter()) {
372                            dst.clone_from(src);
373                        }
374                    } else {
375                        self.lines_snapshot.clear();
376                        self.lines_snapshot.extend(lines.iter().cloned());
377                    }
378                }
379            }
380            self.last_seen_generation = generation;
381        } else {
382            self.last_text_change = TextChangeKind::None;
383        }
384
385        self.cursor_snapshot = cursor;
386
387        // Gate 2: layout rebuild.
388        // Skip when content, width, and the *effective element expansion* are all unchanged.
389        // Horizontal cursor movement within the same element (or plain text with no elements)
390        // does not change any wrap boundary — no recompute needed.
391        let new_expanded = self
392            .parse_state
393            .buf()
394            .lines
395            .get(cursor.0)
396            .and_then(|p| p.elem_at(cursor.1));
397        let old_expanded = self
398            .parse_state
399            .buf()
400            .lines
401            .get(self.last_layout_cursor.0)
402            .and_then(|p| p.elem_at(self.last_layout_cursor.1));
403        let need_layout = generation != self.last_layout_generation
404            || rect.width != self.last_layout_width
405            || cursor.0 != self.last_layout_cursor.0
406            || new_expanded != old_expanded;
407
408        if need_layout {
409            let width_changed = rect.width != self.last_layout_width;
410            let cursor_changed = cursor.0 != self.last_layout_cursor.0;
411            let expanded_changed = new_expanded != old_expanded;
412            // Rows whose rendered mask depends on cursor state and may
413            // have flipped this frame: the old and new cursor rows
414            // when the cursor moved between rows, OR the cursor row
415            // when an inline element (link/bold/etc.) was just expanded
416            // or collapsed by a within-row cursor move. Both shapes
417            // change `visible_positions_with`'s `expanded` argument,
418            // so both rendered_cache AND wrap need to re-derive that
419            // row's mask + visual-line splits.
420            let cursor_affected_rows: Vec<usize> = if cursor_changed {
421                let mut rows = vec![self.last_layout_cursor.0, cursor.0];
422                rows.sort();
423                rows.dedup();
424                rows
425            } else if expanded_changed {
426                vec![cursor.0]
427            } else {
428                vec![]
429            };
430            // Drop any row past the current buffer end — happens when a
431            // stale snapshot's cursor row exceeds `lines.len()`. Both
432            // rendered_cache and wrap splices require in-range rows.
433            let cursor_affected_rows: Vec<usize> = cursor_affected_rows
434                .into_iter()
435                .filter(|&r| r < lines.len())
436                .collect();
437            // Determine the set of rows to rebuild in rendered_cache.
438            let rebuild_strategy = if self.rendered_cache.len() != lines.len() {
439                // Line count differs → full rebuild required.
440                RenderedCacheRebuild::Full
441            } else {
442                match &self.last_text_change {
443                    TextChangeKind::Full => RenderedCacheRebuild::Full,
444                    TextChangeKind::Incremental(range) => {
445                        let mut rows: Vec<usize> = range.clone().collect();
446                        rows.extend(cursor_affected_rows.iter().copied());
447                        rows.sort();
448                        rows.dedup();
449                        RenderedCacheRebuild::Rows(rows)
450                    }
451                    TextChangeKind::None => {
452                        if cursor_affected_rows.is_empty() {
453                            RenderedCacheRebuild::None
454                        } else {
455                            RenderedCacheRebuild::Rows(cursor_affected_rows.clone())
456                        }
457                    }
458                }
459            };
460
461            // Width-only change: masks are width-independent; skip rendered_cache rebuild.
462            let _ = width_changed; // acknowledged: width doesn't affect rendered_cache
463            match rebuild_strategy {
464                RenderedCacheRebuild::Full => {
465                    self.rendered_cache = lines
466                        .iter()
467                        .enumerate()
468                        .map(|(i, l)| {
469                            let force_raw = self.is_in_code_block(i);
470                            let cursor_col = if i == cursor.0 { Some(cursor.1) } else { None };
471                            MarkdownSpanner::visible_positions_with(
472                                l,
473                                &self.parse_state.buf().lines[i],
474                                cursor_col,
475                                force_raw,
476                            )
477                        })
478                        .collect();
479                }
480                RenderedCacheRebuild::Rows(rows) => {
481                    for row in rows {
482                        if row >= lines.len() {
483                            continue; // defensive
484                        }
485                        let force_raw = self.is_in_code_block(row);
486                        let cursor_col = if row == cursor.0 {
487                            Some(cursor.1)
488                        } else {
489                            None
490                        };
491                        let new_entry = MarkdownSpanner::visible_positions_with(
492                            &lines[row],
493                            &self.parse_state.buf().lines[row],
494                            cursor_col,
495                            force_raw,
496                        );
497                        if let Some(entry) = self.rendered_cache.get_mut(row) {
498                            *entry = new_entry;
499                        }
500                    }
501                }
502                RenderedCacheRebuild::None => {
503                    // Width-only change or no change: masks are width-independent; nothing to rebuild.
504                }
505            }
506
507            // Width-aware wrap path:
508            // - Width change or line-count change: full recompute (wrap
509            //   depends on width; visual_lines indexing depends on row count).
510            // - TextChangeKind::Full: full recompute.
511            // - TextChangeKind::Incremental(range): splice the edited
512            //   rows plus any cursor-affected rows whose mask flipped.
513            // - TextChangeKind::None: splice only the cursor-affected
514            //   rows. Wrap depends on the rendered mask
515            //   (`wrap_one_row` reads `rendered_row`), and the mask is
516            //   cursor-position-sensitive whenever the cursor crosses
517            //   an inline element boundary — same row or different
518            //   row.
519            let line_count_changed = self.layout.row_starts_len() != lines.len();
520            if width_changed || line_count_changed {
521                self.layout = WordWrapLayout::compute(lines, rect.width, &self.rendered_cache);
522            } else {
523                match &self.last_text_change {
524                    TextChangeKind::Full => {
525                        self.layout =
526                            WordWrapLayout::compute(lines, rect.width, &self.rendered_cache);
527                    }
528                    TextChangeKind::Incremental(range) => {
529                        let start = range
530                            .start
531                            .min(cursor_affected_rows.first().copied().unwrap_or(range.start));
532                        let end = range.end.max(
533                            cursor_affected_rows
534                                .last()
535                                .copied()
536                                .map(|r| r + 1)
537                                .unwrap_or(range.end),
538                        );
539                        self.layout.splice_range(
540                            lines,
541                            rect.width,
542                            &self.rendered_cache,
543                            start..end,
544                        );
545                    }
546                    TextChangeKind::None => {
547                        if let (Some(&first), Some(&last)) =
548                            (cursor_affected_rows.first(), cursor_affected_rows.last())
549                        {
550                            self.layout.splice_range(
551                                lines,
552                                rect.width,
553                                &self.rendered_cache,
554                                first..last + 1,
555                            );
556                        }
557                    }
558                }
559            }
560            self.last_layout_generation = generation;
561            self.last_layout_width = rect.width;
562            self.last_layout_cursor = cursor;
563        }
564
565        // Cache cursor_vrow for render() — avoids a second logical_to_visual call.
566        self.cursor_vrow = self.layout.logical_to_visual(cursor.0, cursor.1).0;
567        let height = rect.height as usize;
568        if self.cursor_vrow < self.visual_scroll_offset {
569            self.visual_scroll_offset = self.cursor_vrow;
570        } else if self.cursor_vrow >= self.visual_scroll_offset + height {
571            self.visual_scroll_offset = self.cursor_vrow - height + 1;
572        }
573    }
574
575    /// Attempt an incremental Gate-1 parse.
576    ///
577    /// Returns `Some((range, slice, path))` when the damage can be
578    /// cheaply isolated and widened to safe boundaries; `None` when
579    /// the caller should fall back to a fresh full-buffer
580    /// `ParsedBuffer::parse`. The `path` indicates which widener
581    /// tier produced the splice (see [`SplicePath`]).
582    fn try_incremental_parse(
583        &self,
584        lines: &[String],
585        cursor: (usize, usize),
586    ) -> Option<(std::ops::Range<usize>, ParsedBuffer, SplicePath)> {
587        use super::parse_incremental::{
588            LineConstructKind, WidenResult, compute_damage_range, expand_to_reset_boundary,
589            widen_to_safe,
590        };
591        use super::widener_metrics::{BailReason, METRICS, SuccessPath};
592
593        if self.parse_state.buf().lines.is_empty() {
594            return None; // First parse — no snapshot to diff against. Uncategorised.
595        }
596        // Line count changes (insertions/deletions) require a full rebuild:
597        // the widened range covers the same number of lines in the new buffer
598        // as in the old kinds array, so a splice cannot reconcile the length
599        // mismatch.
600        if lines.len() != self.parse_state.buf().lines.len() {
601            return METRICS.bail(BailReason::LineCountChange);
602        }
603        let Some(damaged) = compute_damage_range(&self.lines_snapshot, lines, cursor.0) else {
604            return METRICS.bail(BailReason::NoDamage);
605        };
606
607        // Structural-marker change guard: any edit that converts a fence
608        // marker line into a non-marker (or vice versa) can shift the
609        // fence's extent beyond the widening window. Same for setext
610        // underlines. Conservative fallback to full parse for correctness.
611        for row in damaged.clone() {
612            let old_kind = self.parse_state.buf().kinds[row];
613            let old_line = self.lines_snapshot[row].as_str();
614            let new_line = lines[row].as_str();
615
616            // Old kind was a structural marker whose role an in-place edit
617            // can change (fence opener↔closer↔content, setext underline
618            // re-heading the line above) or which lazy-extends past the
619            // widening window (indented code / HTML block per CommonMark
620            // §4.4 / §4.6). These read pulldown's real classification, so
621            // any edit on such a row punts to a full parse.
622            if matches!(
623                old_kind,
624                LineConstructKind::FenceMarker
625                    | LineConstructKind::SetextUnderline
626                    | LineConstructKind::IndentedCode
627                    | LineConstructKind::HtmlBlock
628            ) {
629                return METRICS.bail(BailReason::KindGuard);
630            }
631            // Context-free block-opener shape flip: the edit gained or lost
632            // a fence / setext / indented-code / HTML / list / blockquote
633            // opener shape. Any such flip can open or close a (possibly
634            // lazy-continuable) construct that reshapes the document beyond
635            // the widening window — e.g. `"x"` → `"* x"` next to a
636            // blank-separated list leaks a loose-list merge. Comparing the
637            // whole `OpenerShape` catches a flip in any field at once.
638            if opener_shape(new_line) != opener_shape(old_line) {
639                return METRICS.bail(BailReason::KindGuard);
640            }
641
642            // V2 lazy-construct neighbourhood guard: edit at row R
643            // can re-shape a lazy construct open at R-1, R, or R+1.
644            // R-1: blockquote paragraph lazy-continuation across a
645            // former blank (§5.1). R: edit inside the construct. R+1:
646            // paragraph eating a would-be IndentedCode start.
647            //
648            // §3.0 conditional relaxation (intra-construct-reset-boundaries):
649            // when the damaged row's old kind is ListMarker AND
650            // lazy_depth[row] == 1 (a top-level list, not nested inside
651            // an outer lazy construct), the bail is skipped. List-marker
652            // content edits are safe by construction: per-row
653            // ListMarker/ListContinuation classification stays identical
654            // across slice-vs-parent, and rows past widened.end are
655            // unaffected by the slice's list-vs-non-list determination.
656            // The widener's heuristic tier (widen_to_safe over the
657            // loose-list blanks; or, on small buffers, the strict tier
658            // widening to the whole buffer) takes the splice. The
659            // post-slice verify backs this. The opener-shape /
660            // blank-transition flips run as
661            // separate guards above and below this check, so the relax
662            // only ever fires on pure content edits.
663            //
664            // Initial relaxation also accepted ListContinuation +
665            // Blockquote + Plain and arbitrary lazy_depth; both unlocks
666            // reverted after the 100k proptest soak exposed downstream-
667            // row-classification flips past widened.end that the
668            // post-slice verify (which only covers rows INSIDE widened)
669            // doesn't catch. The deeper fix is a post-widening sanity
670            // check on `widened.end + 1` — see the design doc's
671            // "Blockquote/Plain/ListContinuation unlocks" follow-up.
672            let lazy = &self.parse_state.buf().lazy_depth;
673            if lazy.is_empty() {
674                // Defensive: invariant violation (lazy_depth.len() should
675                // match lines.len()). Count as KindGuard to keep the
676                // attempted-vs-success accounting consistent.
677                return METRICS.bail(BailReason::KindGuard);
678            }
679            let lo = row.saturating_sub(1);
680            let hi = (row + 1).min(lazy.len() - 1);
681            if lazy[lo..=hi].iter().any(|&d| d > 0) {
682                // §3.0 conditional relaxation — TIGHT VERSION.
683                // Qualifying conditions (narrowed across two soak
684                // rounds — see openspec change for the rationale):
685                //   - old_kind == ListMarker (NOT ListContinuation)
686                //   - lazy_depth[row] == 1 (top-level list only)
687                //
688                // ListContinuation rows are excluded after the 100k
689                // soak surfaced a case where an edit on a
690                // ListContinuation row (specifically a `>     ` row
691                // inside a list, lazy_depth=1) caused the row AT
692                // `damaged.end` (a blank, lazy_depth=0 in pre-edit)
693                // to flip to ListContinuation in post-edit fresh
694                // parse. The strict reset boundary at that row was
695                // valid pre-edit but became invalid post-edit, and
696                // the splice chose a widened range based on
697                // pre-edit boundaries that didn't capture the new
698                // row past `widened.end`.
699                //
700                // ListMarker rows are immune: a content edit on
701                // "- a" → "- aX" cannot change row+1's classification
702                // because the row+1 was either (a) Plain → became
703                // ListContinuation via the post-pass regardless of
704                // the edit, or (b) Blank/something-else that's outside
705                // the list and unaffected by item-content changes.
706                //
707                // The depth==1 clause blocks edits on lists nested
708                // inside another lazy construct (a list inside a
709                // blockquote) where the OUTER construct can shift.
710                //
711                // Blockquote / Plain / ListContinuation unlocks are
712                // deferred to a follow-up that adds a post-widening
713                // sanity check on `widened.end + 1` (cheap re-parse
714                // of one extra row to detect downstream flips).
715                let kind_qualifies = matches!(old_kind, LineConstructKind::ListMarker);
716                let depth_qualifies = row < lazy.len() && lazy[row] == 1;
717                if kind_qualifies && depth_qualifies {
718                    // Don't bail — let blank-transition guard run
719                    // and reach the widener stage.
720                } else {
721                    return METRICS.bail(BailReason::LazyDepth);
722                }
723            }
724
725            // V2 blank-transition guard: a row flipping between blank
726            // and non-blank invalidates the pre-edit reset boundary
727            // at that row in the post-edit world (paragraph lazy-
728            // continuation, empty list-item shapes like `*` that
729            // parse as ListMarker in slice but as paragraph
730            // continuation in full). Use the pre-edit `kinds` for
731            // the "blank" classification instead of `line.trim()` so
732            // the predicate matches the parser's view exactly.
733            let old_blank = matches!(old_kind, LineConstructKind::Blank);
734            let new_blank = new_line.trim().is_empty();
735            if old_blank != new_blank {
736                let above_non_blank = row > 0
737                    && !matches!(
738                        self.parse_state.buf().kinds[row - 1],
739                        LineConstructKind::Blank
740                    );
741                let below_non_blank = row + 1 < self.parse_state.buf().kinds.len()
742                    && !matches!(
743                        self.parse_state.buf().kinds[row + 1],
744                        LineConstructKind::Blank
745                    );
746                if above_non_blank || below_non_blank {
747                    return METRICS.bail(BailReason::BlankTransition);
748                }
749            }
750        }
751
752        // Two-tier widener:
753        //
754        //   1. `expand_to_reset_boundary(reset_boundaries, ...)` —
755        //      strict. Provably equivalent to a fresh parse; no
756        //      post-slice verify needed.
757        //   2. `widen_to_safe` — heuristic fallback. NOT provably
758        //      equivalent; the post-slice verify (below, release-on)
759        //      is the correctness mechanism and bails to a full
760        //      rebuild on any divergence.
761        //
762        // After a §3.0 relax fires the strict widener usually
763        // cap-trips (lazy_depth > 0 around the edit means no nearby
764        // blank-with-depth-0 reset boundary), but we still try strict
765        // first — it costs only a binary search and succeeds in
766        // degenerate cases (e.g. small buffers where strict widens
767        // safely to the whole buffer). On failure we fall to
768        // widen_to_safe.
769        //
770        // A former middle tier (`intra_construct_boundaries`, the V3
771        // "IntraConstruct" path) was removed: it fired only on loose-
772        // list edits and `widen_to_safe` covers every such case with
773        // zero extra full rebuilds (measured), differing only in
774        // reparse span (~11 vs ~2 rows — both far under the 256 cap).
775        let mut splice_path = SplicePath::Strict;
776        let widened = match expand_to_reset_boundary(
777            &self.parse_state.buf().reset_boundaries,
778            self.parse_state.buf().lines.len(),
779            damaged.clone(),
780        ) {
781            WidenResult::Widened(r) => r,
782            WidenResult::FullRebuild => {
783                match widen_to_safe(&self.parse_state.buf().kinds, damaged.clone()) {
784                    WidenResult::Widened(r) => {
785                        splice_path = SplicePath::Heuristic;
786                        r
787                    }
788                    WidenResult::FullRebuild => return METRICS.bail(BailReason::CapTrip),
789                }
790            }
791        };
792        let slice = ParsedBuffer::parse_range(lines, widened.clone());
793
794        // Post-slice undamaged-row verification.
795        //
796        // - Strict path: skipped. Provably equivalent to a fresh
797        //   parse (see `reset_boundaries` docstring).
798        // - Heuristic path: NOT provably equivalent, so this verify
799        //   is the correctness mechanism and runs in release. It is
800        //   cheap: `slice` was already parsed above
801        //   (unconditionally), and the loop only compares
802        //   kinds/elements.len()/content_vis over the `widened` rows —
803        //   bounded by the widen cap (≤256), negligible against the
804        //   parse_range that already ran. A divergence (e.g. a pulldown
805        //   version bump changing tokenisation) bails to a full rebuild
806        //   rather than shipping a corrupt splice. The 600k proptest
807        //   cases (100k × 6 strategies, 0 verify_failed) stay in the
808        //   regression harness; this guard is the release backstop.
809        let verify_eligible_path = matches!(splice_path, SplicePath::Heuristic);
810        if verify_eligible_path {
811            for row in widened.clone() {
812                if damaged.contains(&row) {
813                    continue; // Damaged row: kind change is expected/irrelevant.
814                }
815                let idx = row - widened.start;
816                if slice.kinds[idx] != self.parse_state.buf().kinds[row] {
817                    return METRICS.bail(BailReason::VerifyFailed);
818                }
819                if slice.lines[idx].elements.len()
820                    != self.parse_state.buf().lines[row].elements.len()
821                {
822                    return METRICS.bail(BailReason::VerifyFailed);
823                }
824                if slice.lines[idx].content_vis != self.parse_state.buf().lines[row].content_vis {
825                    return METRICS.bail(BailReason::VerifyFailed);
826                }
827            }
828        }
829
830        METRICS.ok(match splice_path {
831            SplicePath::Strict => SuccessPath::ResetBoundary,
832            SplicePath::Heuristic => SuccessPath::WidenToSafe,
833        });
834        Some((widened, slice, splice_path))
835    }
836
837    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
838        if rect.height == 0 {
839            return;
840        }
841        let lines = &self.lines_snapshot;
842        let cursor = self.cursor_snapshot;
843        let scroll = self.visual_scroll_offset;
844        let height = rect.height as usize;
845        let vlines = self.layout.visual_lines();
846
847        let selection = self.selection;
848        let parsed_lines = &self.parse_state.buf().lines;
849        let fence_ranges = &self.fence_ranges;
850
851        let visible: Vec<Line> = vlines
852            .iter()
853            .skip(scroll)
854            .take(height)
855            .map(|vl| {
856                let cursor_col = if vl.logical_row == cursor.0 {
857                    Some(cursor.1)
858                } else {
859                    None
860                };
861                let force_raw = fence_ranges.iter().any(|r| r.contains(&vl.logical_row));
862                // Snapshot invariant: every `vl.logical_row` is < lines.len()
863                // because `layout` and `lines_snapshot` were rebuilt from
864                // the same `EditorSnapshot` in the last `update()`.
865                let logical_line = lines[vl.logical_row].as_str();
866                let parsed = &parsed_lines[vl.logical_row];
867                let content = vl.content(logical_line);
868                let spans = MarkdownSpanner::render_with(
869                    content,
870                    logical_line,
871                    parsed,
872                    vl.start_col,
873                    cursor_col,
874                    vl.is_first_visual_line,
875                    force_raw,
876                    rect.width,
877                    theme,
878                );
879
880                // Apply selection highlight if this visual line is within the selection.
881                let spans = if let Some(((sel_sr, sel_sc), (sel_er, sel_ec))) = selection {
882                    let row = vl.logical_row;
883                    if row >= sel_sr && row <= sel_er {
884                        let start_rendered = if row == sel_sr {
885                            MarkdownSpanner::rendered_cursor_col_with(
886                                logical_line,
887                                parsed,
888                                vl.start_col,
889                                sel_sc,
890                                vl.is_first_visual_line,
891                                force_raw,
892                            )
893                        } else {
894                            0
895                        };
896                        let end_rendered = if row == sel_er {
897                            MarkdownSpanner::rendered_cursor_col_with(
898                                logical_line,
899                                parsed,
900                                vl.start_col,
901                                sel_ec,
902                                vl.is_first_visual_line,
903                                force_raw,
904                            )
905                        } else {
906                            // Entire line is selected; use a sentinel larger than any line width.
907                            u16::MAX as usize
908                        };
909                        apply_selection_highlight(spans, start_rendered..end_rendered, theme)
910                    } else {
911                        spans
912                    }
913                } else {
914                    spans
915                };
916
917                Line::from(spans)
918            })
919            .collect();
920
921        f.render_widget(
922            Paragraph::new(Text::from(visible)).style(theme.base_style()),
923            rect,
924        );
925
926        // Draw terminal cursor when focused. The `EditorSnapshot` the
927        // last `update()` consumed guarantees `cursor.0` is in-bounds
928        // for `parsed_buffer.lines` and `layout.visual_lines()` —
929        // both were rebuilt from the same snapshot. The single
930        // remaining edge case is an empty buffer (no rows at all),
931        // handled by the early `is_empty` short-circuit below; the
932        // previous defensive `.get()` chain (commit c03dc728) was
933        // there to absorb stale Nvim snapshots where cursor outran
934        // lines, which the snapshot invariant now rules out.
935        self.last_cursor_screen = None;
936        if focused
937            && !self.parse_state.buf().lines.is_empty()
938            && !self.layout.visual_lines().is_empty()
939        {
940            let cursor_vrow = self.cursor_vrow;
941            if cursor_vrow >= scroll && cursor_vrow < scroll + height {
942                let vl = &self.layout.visual_lines()[cursor_vrow];
943                let parsed = &self.parse_state.buf().lines[cursor.0];
944                // Snapshot invariant + outer `!is_empty()` guard: cursor.0
945                // is in-bounds for `lines_snapshot` here.
946                let logical_line = lines[cursor.0].as_str();
947                let force_raw = self.is_in_code_block(cursor.0);
948                let rendered_col = MarkdownSpanner::rendered_cursor_col_with(
949                    logical_line,
950                    parsed,
951                    vl.start_col,
952                    cursor.1,
953                    vl.is_first_visual_line,
954                    force_raw,
955                );
956                let cx = rect.x + rendered_col as u16;
957                let cy = rect.y + (cursor_vrow - scroll) as u16;
958                f.set_cursor_position(Position { x: cx, y: cy });
959                self.last_cursor_screen = Some((cx, cy));
960            }
961        }
962    }
963
964    /// Test accessor: the kinds vector of the current parsed buffer.
965    /// Used by the proptest harness to assert incremental = full parse.
966    pub fn parsed_buffer_kinds(&self) -> &[super::parse_incremental::LineConstructKind] {
967        &self.parse_state.buf().kinds
968    }
969
970    /// Test accessor: the parsed lines of the current parsed buffer.
971    pub fn parsed_buffer_lines(&self) -> &[super::markdown::ParsedLine] {
972        &self.parse_state.buf().lines
973    }
974
975    /// Test accessor: the rendered-position bitmask cache.
976    /// Used by tests to construct a fresh `WordWrapLayout` from the same
977    /// masks the view is using, for equivalence checks.
978    #[cfg(test)]
979    pub(crate) fn rendered_cache_for_testing(&self) -> &[Vec<bool>] {
980        &self.rendered_cache
981    }
982
983    fn is_in_code_block(&self, row: usize) -> bool {
984        // Every line inside any fenced block renders force-raw (no markdown
985        // re-styling, distinct fg color). Previously this checked only the
986        // fence the cursor was sitting in, so fenced blocks elsewhere in
987        // the buffer looked like plain text until the cursor moved into
988        // them.
989        self.fence_ranges.iter().any(|r| r.contains(&row))
990    }
991
992    /// Markdown-aware mouse click: maps a rendered screen column to
993    /// the correct logical column, accounting for hidden markdown
994    /// sigils (links, bold markers, etc.).
995    ///
996    /// Reads `self`'s view-internal caches (`layout`, `lines_snapshot`,
997    /// `parsed_buffer`), all rebuilt from the same `EditorSnapshot`
998    /// in the last `update()` call. The snapshot invariant guarantees
999    /// `vl.logical_row` is a valid index into both `lines_snapshot`
1000    /// and `parsed_buffer.lines`, so direct indexing is safe — the
1001    /// previous defensive `(Some, Some) else fallback` block (Fix #2
1002    /// in the holistic review) is no longer needed.
1003    /// Map a screen-relative click (row/col offset from the editor's
1004    /// top-left corner) to logical (row, col). Owns the
1005    /// visual-scroll-offset arithmetic so callers do not reach into
1006    /// `visual_scroll_offset` — the view knows where it is scrolled.
1007    pub fn click_at_screen(&self, screen_row: usize, screen_col: usize) -> (u16, u16) {
1008        let vrow = screen_row + self.visual_scroll_offset;
1009        self.click_to_logical_u16(vrow, screen_col)
1010    }
1011
1012    fn click_to_logical_u16(&self, vrow: usize, vcol: usize) -> (u16, u16) {
1013        let vlines = self.layout.visual_lines();
1014        if vlines.is_empty() {
1015            return (0, 0);
1016        }
1017        let vrow = vrow.min(vlines.len() - 1);
1018        let vl = &vlines[vrow];
1019        let row_u16 = vl.logical_row.min(u16::MAX as usize) as u16;
1020        let logical_line = self.lines_snapshot[vl.logical_row].as_str();
1021        let parsed = &self.parse_state.buf().lines[vl.logical_row];
1022        let force_raw = self.is_in_code_block(vl.logical_row);
1023        let logical_col = MarkdownSpanner::rendered_col_to_logical_with(
1024            logical_line,
1025            parsed,
1026            vl.start_col,
1027            vcol,
1028            vl.is_first_visual_line,
1029            force_raw,
1030        );
1031        let col = logical_col.min(u16::MAX as usize) as u16;
1032        (row_u16, col)
1033    }
1034}
1035
1036impl Default for MarkdownEditorView {
1037    fn default() -> Self {
1038        Self::new()
1039    }
1040}
1041
1042/// Returns the byte offset into `s` after consuming exactly `target_width` display columns.
1043/// If `target_width` exceeds the string's display width, returns `s.len()`.
1044fn byte_offset_for_display_width(s: &str, target_width: usize) -> usize {
1045    let mut consumed = 0usize;
1046    for (byte_pos, ch) in s.char_indices() {
1047        if consumed >= target_width {
1048            return byte_pos;
1049        }
1050        consumed += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
1051    }
1052    s.len()
1053}
1054
1055/// Re-style spans to apply `bg_selected` over the given rendered-column range.
1056///
1057/// `sel_cols` is a range of rendered (screen) column offsets within the visual line.
1058/// Spans that overlap the range are split at the boundaries; the overlapping portion
1059/// receives `.bg(theme.bg_selected)`. Non-overlapping portions keep their original style.
1060fn apply_selection_highlight<'a>(
1061    spans: Vec<ratatui::text::Span<'a>>,
1062    sel_cols: std::ops::Range<usize>,
1063    theme: &Theme,
1064) -> Vec<ratatui::text::Span<'a>> {
1065    if sel_cols.is_empty() {
1066        return spans;
1067    }
1068
1069    let highlight_bg = theme.bg_selected.to_ratatui();
1070    let mut result = Vec::new();
1071    let mut col = 0usize;
1072
1073    for span in spans {
1074        let content: &str = &span.content;
1075        let span_width = content.width();
1076        let span_end = col + span_width;
1077
1078        let overlap_start = sel_cols.start.max(col);
1079        let overlap_end = sel_cols.end.min(span_end);
1080
1081        if overlap_start >= overlap_end {
1082            // No overlap — emit as-is.
1083            result.push(span);
1084        } else {
1085            // Walk grapheme clusters by display width to find byte boundaries.
1086            let prefix_width = overlap_start - col;
1087            let selected_width = overlap_end - overlap_start;
1088
1089            let prefix_byte = byte_offset_for_display_width(content, prefix_width);
1090            let selected_byte_end =
1091                byte_offset_for_display_width(&content[prefix_byte..], selected_width)
1092                    + prefix_byte;
1093
1094            // Prefix (before selection)
1095            if prefix_byte > 0 {
1096                result.push(ratatui::text::Span::styled(
1097                    content[..prefix_byte].to_string(),
1098                    span.style,
1099                ));
1100            }
1101            // Selected portion
1102            result.push(ratatui::text::Span::styled(
1103                content[prefix_byte..selected_byte_end].to_string(),
1104                span.style.bg(highlight_bg),
1105            ));
1106            // Suffix (after selection)
1107            if selected_byte_end < content.len() {
1108                result.push(ratatui::text::Span::styled(
1109                    content[selected_byte_end..].to_string(),
1110                    span.style,
1111                ));
1112            }
1113        }
1114
1115        col = span_end;
1116    }
1117
1118    result
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124    use ratatui::layout::Rect;
1125    use std::num::NonZeroU64;
1126
1127    fn rect(h: u16) -> Rect {
1128        Rect {
1129            x: 0,
1130            y: 0,
1131            width: 40,
1132            height: h,
1133        }
1134    }
1135
1136    /// Test-only wrapper that builds an `EditorSnapshot::borrowed`
1137    /// from the legacy `(lines, cursor, generation)` shape, so the
1138    /// hundreds of existing call sites don't each have to construct
1139    /// the snapshot inline.
1140    ///
1141    /// Mirrors `snapshot_from_backend`'s producer-side cursor clamp,
1142    /// so tests that pass an intentionally-stale `cursor` (e.g. the
1143    /// regression for the Nvim shrink panic) still exercise the
1144    /// real production path: producer clamps, render trusts.
1145    fn update_view(
1146        v: &mut MarkdownEditorView,
1147        lines: &[String],
1148        cursor: (usize, usize),
1149        rect: Rect,
1150        generation: u64,
1151        selection: Option<((usize, usize), (usize, usize))>,
1152    ) {
1153        let rev = NonZeroU64::new(generation.max(1)).unwrap();
1154        let clamped = if lines.is_empty() {
1155            (0, 0)
1156        } else {
1157            (cursor.0.min(lines.len() - 1), cursor.1)
1158        };
1159        let snap = super::super::snapshot::EditorSnapshot::borrowed(lines, clamped, rev);
1160        v.update(&snap, rect, selection);
1161    }
1162
1163    #[test]
1164    fn new_has_zero_scroll() {
1165        assert_eq!(MarkdownEditorView::new().visual_scroll_offset, 0);
1166    }
1167
1168    #[test]
1169    fn zero_height_rect_does_not_panic() {
1170        let mut v = MarkdownEditorView::new();
1171        update_view(&mut v, &["hello".to_string()], (0, 0), rect(0), 1, None);
1172    }
1173
1174    #[test]
1175    fn scroll_follows_cursor_down() {
1176        let mut v = MarkdownEditorView::new();
1177        let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
1178        update_view(&mut v, &lines, (4, 0), rect(3), 1, None);
1179        assert!(v.visual_scroll_offset >= 2);
1180    }
1181
1182    #[test]
1183    fn scroll_follows_cursor_up() {
1184        let mut v = MarkdownEditorView::new();
1185        let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
1186        update_view(&mut v, &lines, (4, 0), rect(3), 1, None);
1187        update_view(&mut v, &lines, (0, 0), rect(3), 1, None); // same generation — scroll still adjusts
1188        assert_eq!(v.visual_scroll_offset, 0);
1189    }
1190
1191    #[test]
1192    fn visual_to_logical_u16_accounts_for_scroll() {
1193        let mut v = MarkdownEditorView::new();
1194        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
1195        update_view(&mut v, &lines, (5, 0), rect(3), 1, None);
1196        let scroll = v.visual_scroll_offset;
1197        let (row, _col) = v.click_to_logical_u16(scroll, 0);
1198        assert_eq!(row as usize, scroll);
1199    }
1200
1201    #[test]
1202    fn code_block_detection_cursor_inside() {
1203        let lines = vec![
1204            "text".to_string(),
1205            "```rust".to_string(),
1206            "let x = 1;".to_string(),
1207            "```".to_string(),
1208            "more".to_string(),
1209        ];
1210        let pb = ParsedBuffer::parse(&lines);
1211        let ranges = super::super::parse_incremental::fence_ranges_from_kinds(&pb.kinds);
1212        let block = ranges.iter().find(|r| r.contains(&2)).cloned();
1213        assert!(block.is_some());
1214        let r = block.unwrap();
1215        assert_eq!(r.start, 1);
1216        assert_eq!(r.end, 4);
1217    }
1218
1219    #[test]
1220    fn code_block_detection_cursor_outside() {
1221        let lines = vec![
1222            "text".to_string(),
1223            "```".to_string(),
1224            "code".to_string(),
1225            "```".to_string(),
1226        ];
1227        let pb = ParsedBuffer::parse(&lines);
1228        let ranges = super::super::parse_incremental::fence_ranges_from_kinds(&pb.kinds);
1229        assert!(ranges.iter().find(|r| r.contains(&0)).is_none());
1230    }
1231
1232    #[test]
1233    fn click_to_logical_does_not_panic_on_stale_layout() {
1234        // Regression: click_to_logical_u16 raw-indexed parsed_buffer.lines
1235        // by vl.logical_row. A stale layout whose visual_lines outlive a
1236        // shrink of parsed_buffer.lines would panic on mouse click. The
1237        // guard now falls back to a raw visual-col mapping.
1238        let mut v = MarkdownEditorView::new();
1239        let long: Vec<String> = (0..20).map(|i| format!("line{}", i)).collect();
1240        update_view(&mut v, &long, (0, 0), rect(10), 1, None);
1241        // Drive a shrink so layout.visual_lines outruns parsed_buffer.lines
1242        // briefly. update() rebuilds layout from the new lines, so the
1243        // pure shrink shouldn't desynchronize them — but we still want a
1244        // black-box test that simulates a click against the last vrow.
1245        let vrows = v.layout.visual_lines().len();
1246        if vrows > 0 {
1247            let _ = v.click_to_logical_u16(vrows.saturating_sub(1), 0);
1248            let _ = v.click_to_logical_u16(vrows + 5, 0);
1249        }
1250    }
1251
1252    #[test]
1253    fn render_does_not_panic_on_stale_cursor_past_line_count() {
1254        // Regression: render() previously did self.parsed_cache[cursor.0]
1255        // and self.layout.visual_lines()[cursor_vrow] directly. A stale
1256        // Nvim snapshot whose cursor row landed past the new line count
1257        // would panic the render thread. Now the test exercises the
1258        // producer-side clamp (via `update_view`'s mirror of
1259        // `snapshot_from_backend`): the snapshot constructor clamps
1260        // the cursor, render trusts the invariant, and direct
1261        // indexing is safe.
1262        use ratatui::Terminal;
1263        use ratatui::backend::TestBackend;
1264        let theme = Theme::gruvbox_dark();
1265        let backend = TestBackend::new(40, 10);
1266        let mut terminal = Terminal::new(backend).unwrap();
1267
1268        let mut v = MarkdownEditorView::new();
1269        // Populate with 2 lines and a valid cursor first so parsed_cache /
1270        // layout are non-empty.
1271        update_view(
1272            &mut v,
1273            &["alpha".to_string(), "beta".to_string()],
1274            (0, 0),
1275            rect(8),
1276            1,
1277            None,
1278        );
1279        // Now feed a cursor row that exceeds the line count for this update
1280        // (simulates a stale snapshot arriving after a shrink). update() at
1281        // line 277 already uses `lines.get(cursor.0)` so it won't panic; the
1282        // real risk was the [] indexes inside render(). cursor_snapshot ends
1283        // up at (5, 0) which exceeds the parsed_cache len of 2 below.
1284        update_view(
1285            &mut v,
1286            &["alpha".to_string(), "beta".to_string()],
1287            (5, 0),
1288            rect(8),
1289            1,
1290            None,
1291        );
1292        // Render with focus so the cursor branch runs.
1293        terminal
1294            .draw(|f| v.render(f, f.area(), &theme, true))
1295            .expect("render must not panic on stale cursor");
1296    }
1297
1298    #[test]
1299    fn cursor_into_link_refreshes_layout_for_same_row() {
1300        // Regression: when the cursor moves within a row, crossing into
1301        // or out of an expandable inline element (link/bold/etc.), the
1302        // rendered mask flips (the element reveals or hides its hidden
1303        // sigils). Both rendered_cache and the wrap layout depend on
1304        // the mask. Previously Gate 2 took the `TextChangeKind::None`
1305        // wrap branch and skipped re-splicing, leaving stale visual
1306        // lines until the next text edit.
1307        //
1308        // Use a link whose hidden URL is long enough that revealing it
1309        // forces an extra wrap line at width 40 — that lets us
1310        // black-box detect the mask flip via visual_lines.len().
1311        let mut v = MarkdownEditorView::new();
1312        let lines =
1313            vec!["see [link](http://example.com/very/long/path/to/some/page) more".to_string()];
1314        // First update: cursor outside the link (col 0).
1315        update_view(&mut v, &lines, (0, 0), rect(5), 1, None);
1316        let n_outside = v.layout.visual_lines().len();
1317
1318        // Second update: cursor inside the link element.
1319        update_view(&mut v, &lines, (0, 8), rect(5), 1, None);
1320        let layout_inside = v.layout.visual_lines().to_vec();
1321
1322        // Fresh view with cursor already inside must produce the same layout.
1323        let mut fresh = MarkdownEditorView::new();
1324        update_view(&mut fresh, &lines, (0, 8), rect(5), 1, None);
1325        let layout_fresh = fresh.layout.visual_lines().to_vec();
1326        assert_eq!(
1327            layout_inside, layout_fresh,
1328            "post-move layout must match a fresh full-recompute"
1329        );
1330        assert!(
1331            layout_inside.len() > n_outside,
1332            "expanding the link's hidden URL must produce more visual lines"
1333        );
1334    }
1335
1336    #[test]
1337    fn try_incremental_parse_falls_back_on_indented_code_flip() {
1338        // Regression: a Plain row flipping to IndentedCode (4 leading
1339        // spaces) can lazy-extend an indented-code block across the
1340        // following Plain rows in the full buffer. The widened slice
1341        // can't see that context. Guard must trip fallback.
1342        let mut v = MarkdownEditorView::new();
1343        let lines = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
1344        update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
1345        let new_lines = vec![
1346            "alpha".to_string(),
1347            "    beta".to_string(),
1348            "gamma".to_string(),
1349        ];
1350        // try_incremental_parse must return None (full-rebuild signal).
1351        assert!(
1352            v.try_incremental_parse(&new_lines, (1, 0)).is_none(),
1353            "indented-code flip must force a full rebuild"
1354        );
1355    }
1356
1357    /// V2 structural guard regression. Buffer `["    code", "",
1358    /// "    more"]` has lazy_depth `[1, 1, 1]` (indented code
1359    /// multi-chunk per CommonMark §4.4). An edit at row 1 (the blank
1360    /// inside the block) must trigger fallback, even though the row
1361    /// is itself Blank and would otherwise be a safe-looking
1362    /// boundary candidate.
1363    #[test]
1364    fn try_incremental_parse_falls_back_when_damaged_row_is_inside_lazy_block() {
1365        let mut v = MarkdownEditorView::new();
1366        let lines = vec![
1367            "    code".to_string(),
1368            "".to_string(),
1369            "    more".to_string(),
1370        ];
1371        update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
1372        assert_eq!(
1373            v.parse_state.buf().lazy_depth,
1374            vec![1, 1, 1],
1375            "precondition: parsed_buffer.lazy_depth must mark all three rows as inside the block"
1376        );
1377        let new_lines = vec![
1378            "    code".to_string(),
1379            "x".to_string(),
1380            "    more".to_string(),
1381        ];
1382        assert!(
1383            v.try_incremental_parse(&new_lines, (1, 1)).is_none(),
1384            "edit inside an open lazy-continuable block must force a full rebuild"
1385        );
1386    }
1387
1388    #[test]
1389    fn try_incremental_parse_falls_back_on_html_block_flip() {
1390        // Regression: a Plain row flipping to an HTML-block opener
1391        // (`<div>`) starts a block that lazy-extends through subsequent
1392        // Plain rows in the full buffer.
1393        let mut v = MarkdownEditorView::new();
1394        let lines = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
1395        update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
1396        let new_lines = vec![
1397            "alpha".to_string(),
1398            "<div>".to_string(),
1399            "gamma".to_string(),
1400        ];
1401        assert!(
1402            v.try_incremental_parse(&new_lines, (1, 0)).is_none(),
1403            "HTML-block opener flip must force a full rebuild"
1404        );
1405    }
1406
1407    #[test]
1408    fn is_in_code_block_returns_true_for_any_fence_regardless_of_cursor() {
1409        // Regression: after commit cceef444, every fenced block renders
1410        // force-raw — not just the one the cursor sits in. Verify by
1411        // probing `is_in_code_block` for a row in a fence while the
1412        // cursor is positioned elsewhere.
1413        let mut v = MarkdownEditorView::new();
1414        let lines = vec![
1415            "intro".to_string(),
1416            "```".to_string(),
1417            "code".to_string(),
1418            "```".to_string(),
1419            "outro".to_string(),
1420        ];
1421        // Cursor on the prose line; fence interior must still report in-block.
1422        update_view(&mut v, &lines, (4, 0), rect(10), 1, None);
1423        assert!(v.is_in_code_block(2), "fence interior is in-block");
1424        assert!(!v.is_in_code_block(0), "prose line is not in-block");
1425        assert!(!v.is_in_code_block(4), "trailing prose is not in-block");
1426    }
1427
1428    #[test]
1429    fn parsed_cache_populated_after_update() {
1430        let mut v = MarkdownEditorView::new();
1431        let lines = vec!["hello".to_string(), "**bold**".to_string()];
1432        update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
1433        assert_eq!(v.parse_state.buf().lines.len(), 2);
1434    }
1435
1436    #[test]
1437    fn layout_skipped_on_horizontal_cursor_move_in_plain_text() {
1438        let mut v = MarkdownEditorView::new();
1439        let lines = vec!["hello world".to_string()];
1440        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
1441        let layout_gen_after_first = v.last_layout_generation;
1442        // Move cursor right — same row, no elements, same generation → layout must be skipped.
1443        update_view(&mut v, &lines, (0, 5), rect(40), 1, None);
1444        assert_eq!(
1445            v.last_layout_cursor,
1446            (0, 0),
1447            "layout cursor unchanged = layout was skipped"
1448        );
1449        assert_eq!(v.last_layout_generation, layout_gen_after_first);
1450    }
1451
1452    #[test]
1453    fn layout_recomputed_on_row_change() {
1454        let mut v = MarkdownEditorView::new();
1455        let lines: Vec<String> = (0..3).map(|i| format!("line{}", i)).collect();
1456        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
1457        update_view(&mut v, &lines, (1, 0), rect(40), 1, None); // cursor moves to row 1
1458        assert_eq!(v.last_layout_cursor.0, 1, "layout recomputed on row change");
1459    }
1460
1461    #[test]
1462    fn layout_recomputed_on_width_change() {
1463        let mut v = MarkdownEditorView::new();
1464        let lines = vec!["hello world foo bar".to_string()];
1465        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
1466        update_view(
1467            &mut v,
1468            &lines,
1469            (0, 0),
1470            Rect {
1471                x: 0,
1472                y: 0,
1473                width: 10,
1474                height: 10,
1475            },
1476            1,
1477            None,
1478        );
1479        assert_eq!(v.last_layout_width, 10);
1480    }
1481
1482    #[test]
1483    fn same_generation_skips_snapshot_rebuild() {
1484        let mut v = MarkdownEditorView::new();
1485        let lines = vec!["original".to_string()];
1486        update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
1487        // Update with different content but same generation — snapshot must NOT change.
1488        let lines2 = vec!["changed".to_string()];
1489        update_view(&mut v, &lines2, (0, 0), rect(10), 1, None);
1490        assert_eq!(v.lines_snapshot, vec!["original".to_string()]);
1491    }
1492
1493    #[test]
1494    fn new_generation_triggers_snapshot_rebuild() {
1495        let mut v = MarkdownEditorView::new();
1496        let lines = vec!["original".to_string()];
1497        update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
1498        let lines2 = vec!["changed".to_string()];
1499        update_view(&mut v, &lines2, (0, 0), rect(10), 2, None);
1500        assert_eq!(v.lines_snapshot, vec!["changed".to_string()]);
1501    }
1502
1503    #[test]
1504    fn update_stores_selection() {
1505        let mut v = MarkdownEditorView::new();
1506        let lines = vec!["hello world".to_string()];
1507        update_view(&mut v, &lines, (0, 0), rect(40), 1, Some(((0, 0), (0, 5))));
1508        assert_eq!(v.selection, Some(((0, 0), (0, 5))));
1509    }
1510
1511    #[test]
1512    fn update_clears_selection_when_none() {
1513        let mut v = MarkdownEditorView::new();
1514        let lines = vec!["hello world".to_string()];
1515        update_view(&mut v, &lines, (0, 0), rect(40), 1, Some(((0, 0), (0, 5))));
1516        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
1517        assert_eq!(v.selection, None);
1518    }
1519
1520    #[test]
1521    fn typing_single_char_in_long_buffer_uses_incremental_path() {
1522        let mut v = MarkdownEditorView::new();
1523        let mut lines: Vec<String> = (0..1000).map(|i| format!("paragraph {i}")).collect();
1524        update_view(&mut v, &lines, (500, 0), rect(40), 1, None);
1525        // The 1000-line buffer takes the async-parse placeholder path on
1526        // first parse. Simulate the background task completing before the
1527        // edit so the next update splices against a real (non-placeholder)
1528        // buffer; Gate 1 deliberately refuses to incrementally splice the
1529        // all-`Plain` placeholder.
1530        v.install_full_parse(1, ParsedBuffer::parse(&lines));
1531
1532        // Single-char insert at row 500.
1533        lines[500].push('x');
1534        let edited_len = lines[500].len();
1535        update_view(&mut v, &lines, (500, edited_len), rect(40), 2, None);
1536
1537        // The spliced result must equal a fresh full parse.
1538        let fresh = ParsedBuffer::parse(&lines);
1539        assert_eq!(v.parse_state.buf().lines.len(), fresh.lines.len());
1540        assert_eq!(v.parse_state.buf().kinds, fresh.kinds);
1541        // Regression: the heuristic widener splices a slice whose
1542        // local sentinel boundaries (slice rows 0 and len) are NOT
1543        // genuine reset boundaries of the merged buffer. splice must
1544        // not promote them — a 1000-line single-paragraph buffer has
1545        // reset boundaries only at [0, 1000].
1546        assert_eq!(
1547            v.parse_state.buf().reset_boundaries,
1548            fresh.reset_boundaries,
1549            "heuristic splice must not introduce spurious reset boundaries"
1550        );
1551        // And the incremental path was actually taken.
1552        assert!(
1553            v.last_parse_was_incremental,
1554            "single-char paragraph edit should take incremental path"
1555        );
1556    }
1557
1558    #[test]
1559    fn edit_while_placeholder_active_refuses_incremental_and_rearms() {
1560        // Regression: a large-buffer edit installs an unstyled placeholder
1561        // (all-`Plain` kinds) pending a background full parse. If the next
1562        // edit lands before the parse completes, Gate 1 must NOT splice the
1563        // placeholder — its all-`Plain` kinds defeat the structural guards
1564        // and would lock in a wrong parse that install_full_parse then drops
1565        // as stale. The edit must re-install a placeholder + re-arm pending.
1566        let mut v = MarkdownEditorView::new();
1567        let mut lines: Vec<String> = (0..1000).map(|i| format!("paragraph {i}")).collect();
1568        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
1569        assert!(
1570            v.parse_state.is_placeholder(),
1571            "first parse installs placeholder"
1572        );
1573        assert_eq!(v.take_pending_full_parse(), Some(1));
1574
1575        // Edit before the background parse resolves the placeholder.
1576        lines[0].push_str("```");
1577        update_view(&mut v, &lines, (0, lines[0].len()), rect(40), 2, None);
1578        assert!(
1579            !v.last_parse_was_incremental,
1580            "must not splice the placeholder"
1581        );
1582        assert!(
1583            v.parse_state.is_placeholder(),
1584            "still placeholder pending parse"
1585        );
1586        assert_eq!(
1587            v.take_pending_full_parse(),
1588            Some(2),
1589            "re-armed for new generation"
1590        );
1591
1592        // Background parse for the latest generation completes.
1593        v.install_full_parse(2, ParsedBuffer::parse(&lines));
1594        assert!(
1595            !v.parse_state.is_placeholder(),
1596            "placeholder cleared on install"
1597        );
1598        assert_eq!(v.parse_state.buf().kinds, ParsedBuffer::parse(&lines).kinds);
1599    }
1600
1601    #[test]
1602    #[should_panic(expected = "splice on placeholder parse")]
1603    fn splice_real_on_placeholder_is_rejected() {
1604        // The type makes the wrong-splice hazard unrepresentable on the
1605        // Gate 1 path; this guards the `ParseState::splice_real` contract
1606        // directly so a future caller can't route a splice into a
1607        // placeholder without tripping the assert.
1608        let mut state = ParseState::Placeholder {
1609            buf: ParsedBuffer::placeholder(&["x".to_string()]),
1610            generation: 1,
1611            spawned: false,
1612        };
1613        state.splice_real(0..1, ParsedBuffer::parse(&["y".to_string()]));
1614    }
1615
1616    #[test]
1617    fn fence_toggle_triggers_full_rebuild_fallback() {
1618        let mut v = MarkdownEditorView::new();
1619        // Use 700 lines so that an unclosed fence at row 350 widens to
1620        // end-of-buffer (~351 rows), exceeding the absolute cap (256).
1621        // Below the perf #9 LARGE_BUFFER_THRESHOLD (1000), so the
1622        // fallback runs synchronously and `parsed_buffer.kinds`
1623        // matches a fresh full parse immediately.
1624        let mut lines: Vec<String> = (0..700).map(|i| format!("paragraph {i}")).collect();
1625        update_view(&mut v, &lines, (350, 0), rect(40), 1, None);
1626
1627        // Open a fence mid-buffer — structurally invasive, line count changes.
1628        lines.insert(350, "```".to_string());
1629        update_view(&mut v, &lines, (350, 3), rect(40), 2, None);
1630
1631        let fresh = ParsedBuffer::parse(&lines);
1632        assert_eq!(
1633            v.parse_state.buf().kinds,
1634            fresh.kinds,
1635            "spliced kinds must equal fresh full parse"
1636        );
1637        // The unclosed fence at row 350 widens to end-of-buffer (~351 lines,
1638        // > 256 cap_abs), so the cap trips and the fallback fires.
1639        assert!(
1640            !v.last_parse_was_incremental,
1641            "fence toggle (unclosed fence, 700-line buffer) should fall back to full rebuild"
1642        );
1643        // Buffer < LARGE_BUFFER_THRESHOLD → sync fallback, no
1644        // pending-async signal.
1645        assert!(
1646            v.take_pending_full_parse().is_none(),
1647            "small-buffer fallback must NOT defer to async"
1648        );
1649    }
1650
1651    #[test]
1652    fn fence_toggle_on_large_buffer_defers_to_async_fallback() {
1653        // Regression for perf #9: above LARGE_BUFFER_THRESHOLD, the
1654        // fallback installs a placeholder ParsedBuffer + signals
1655        // pending instead of blocking the typing thread on
1656        // ParsedBuffer::parse. The owning component spawns the real
1657        // parse on tokio and calls install_full_parse when done.
1658        let mut v = MarkdownEditorView::new();
1659        let mut lines: Vec<String> = (0..1500).map(|i| format!("paragraph {i}")).collect();
1660        update_view(&mut v, &lines, (750, 0), rect(40), 1, None);
1661
1662        // Force a fallback path on a large buffer.
1663        lines.insert(750, "```".to_string());
1664        update_view(&mut v, &lines, (750, 3), rect(40), 2, None);
1665
1666        assert!(
1667            !v.last_parse_was_incremental,
1668            "fence toggle on 1500-line buffer should fall back"
1669        );
1670        let pending = v.take_pending_full_parse();
1671        assert!(
1672            pending.is_some(),
1673            "large-buffer fallback must signal pending async parse"
1674        );
1675        // Placeholder kinds: every row is Plain — no fence detection yet.
1676        assert!(
1677            v.parse_state
1678                .buf()
1679                .kinds
1680                .iter()
1681                .all(|k| matches!(k, super::super::parse_incremental::LineConstructKind::Plain)),
1682            "placeholder must classify every row as Plain"
1683        );
1684        assert_eq!(
1685            v.parse_state.buf().lines.len(),
1686            lines.len(),
1687            "placeholder row count must match input"
1688        );
1689
1690        // Caller (TextEditorComponent in production) spawns the real
1691        // parse and installs the result. Simulate that here.
1692        let real = ParsedBuffer::parse(&lines);
1693        let generation = pending.unwrap();
1694        v.install_full_parse(generation, real);
1695        let fresh = ParsedBuffer::parse(&lines);
1696        assert_eq!(
1697            v.parse_state.buf().kinds,
1698            fresh.kinds,
1699            "post-install kinds must match fresh full parse"
1700        );
1701    }
1702
1703    fn full_rebuild_equals_view_state(v: &MarkdownEditorView, lines: &[String]) {
1704        let fresh = ParsedBuffer::parse(lines);
1705        assert_eq!(v.parse_state.buf().kinds, fresh.kinds, "kinds diverge");
1706        assert_eq!(
1707            v.parse_state.buf().lines.len(),
1708            fresh.lines.len(),
1709            "row count diverge"
1710        );
1711        for (i, (got, exp)) in v
1712            .parse_state
1713            .buf()
1714            .lines
1715            .iter()
1716            .zip(fresh.lines.iter())
1717            .enumerate()
1718        {
1719            got.debug_assert_eq_to(exp, i);
1720        }
1721    }
1722
1723    #[test]
1724    fn incremental_falls_back_when_fence_marker_modified() {
1725        // Regression: editing a row that is currently a FenceMarker can
1726        // change the fence's extent across the rest of the buffer.
1727        // Incremental parsing's window-bounded widening cannot capture
1728        // this, so we must fall back to a full parse.
1729        let mut v = MarkdownEditorView::new();
1730        let mut lines = vec!["```".to_string(), "".to_string(), "```".to_string()];
1731        // Fill out the buffer with blank lines so the cap doesn't trip first.
1732        for _ in 0..31 {
1733            lines.push(String::new());
1734        }
1735        update_view(&mut v, &lines, (2, 0), rect(40), 1, None);
1736
1737        // Edit the closing fence marker — append a char so it's no longer a closer.
1738        let mut new_lines = lines.clone();
1739        new_lines[2].push('0');
1740        update_view(&mut v, &new_lines, (2, 4), rect(40), 2, None);
1741
1742        assert!(
1743            !v.last_parse_was_incremental,
1744            "fence-marker edit must trigger full-rebuild fallback"
1745        );
1746        // And the resulting state must equal a fresh parse (which the
1747        // fallback path does anyway, but assert defensively).
1748        full_rebuild_equals_view_state(&v, &new_lines);
1749    }
1750
1751    #[test]
1752    fn incremental_paste_large_block_falls_back() {
1753        let mut v = MarkdownEditorView::new();
1754        let mut lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
1755        update_view(&mut v, &lines, (25, 0), rect(40), 1, None);
1756
1757        // Insert 300 lines at row 25.
1758        let payload: Vec<String> = (0..300).map(|i| format!("pasted {i}")).collect();
1759        for (offset, p) in payload.into_iter().enumerate() {
1760            lines.insert(25 + offset, p);
1761        }
1762        update_view(&mut v, &lines, (25, 0), rect(40), 2, None);
1763        assert!(
1764            !v.last_parse_was_incremental,
1765            "300-line paste must fall back"
1766        );
1767        full_rebuild_equals_view_state(&v, &lines);
1768    }
1769
1770    #[test]
1771    fn incremental_enter_at_line_end() {
1772        let mut v = MarkdownEditorView::new();
1773        let lines = vec!["alpha".to_string(), "beta".to_string()];
1774        update_view(&mut v, &lines, (0, 5), rect(40), 1, None);
1775
1776        // Press Enter at end of "alpha".
1777        let new_lines = vec!["alpha".to_string(), "".to_string(), "beta".to_string()];
1778        update_view(&mut v, &new_lines, (1, 0), rect(40), 2, None);
1779        full_rebuild_equals_view_state(&v, &new_lines);
1780    }
1781
1782    #[test]
1783    fn incremental_backspace_merging_lines() {
1784        let mut v = MarkdownEditorView::new();
1785        let lines = vec!["alpha".to_string(), "beta".to_string()];
1786        update_view(&mut v, &lines, (1, 0), rect(40), 1, None);
1787
1788        // Backspace at start of "beta" merges into "alphabeta".
1789        let new_lines = vec!["alphabeta".to_string()];
1790        update_view(&mut v, &new_lines, (0, 5), rect(40), 2, None);
1791        full_rebuild_equals_view_state(&v, &new_lines);
1792    }
1793
1794    #[test]
1795    fn incremental_inside_fence_widens_both_markers() {
1796        let mut v = MarkdownEditorView::new();
1797        let lines = vec![
1798            "intro".to_string(),
1799            "".to_string(),
1800            "```rust".to_string(),
1801            "let x = 1;".to_string(),
1802            "let y = 2;".to_string(),
1803            "```".to_string(),
1804            "".to_string(),
1805            "outro".to_string(),
1806        ];
1807        update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
1808
1809        // Edit inside the fence (same-length, no line-count change).
1810        let mut new_lines = lines.clone();
1811        new_lines[3] = "let x = 999;".to_string();
1812        update_view(&mut v, &new_lines, (3, 8), rect(40), 2, None);
1813        full_rebuild_equals_view_state(&v, &new_lines);
1814    }
1815
1816    #[test]
1817    fn incremental_list_continuation_widens_to_outer_marker() {
1818        let mut v = MarkdownEditorView::new();
1819        let lines = vec![
1820            "- top".to_string(),
1821            "  body of top".to_string(),
1822            "  - nested".to_string(),
1823            "    body of nested".to_string(),
1824            "    body two".to_string(),
1825            "".to_string(),
1826            "outro".to_string(),
1827        ];
1828        update_view(&mut v, &lines, (4, 0), rect(40), 1, None);
1829
1830        // Edit the nested continuation line.
1831        let mut new_lines = lines.clone();
1832        new_lines[4] = "    body two changed".to_string();
1833        update_view(&mut v, &new_lines, (4, 10), rect(40), 2, None);
1834        full_rebuild_equals_view_state(&v, &new_lines);
1835    }
1836
1837    #[test]
1838    fn incremental_setext_underline_edit() {
1839        let mut v = MarkdownEditorView::new();
1840        let lines = vec![
1841            "heading text".to_string(),
1842            "====".to_string(),
1843            "".to_string(),
1844            "body".to_string(),
1845        ];
1846        update_view(&mut v, &lines, (1, 0), rect(40), 1, None);
1847
1848        // Edit the underline (same line count).
1849        let mut new_lines = lines.clone();
1850        new_lines[1] = "======".to_string();
1851        update_view(&mut v, &new_lines, (1, 6), rect(40), 2, None);
1852        full_rebuild_equals_view_state(&v, &new_lines);
1853    }
1854
1855    #[test]
1856    fn incremental_blockquote_paragraph_edit() {
1857        let mut v = MarkdownEditorView::new();
1858        let lines = vec![
1859            "intro".to_string(),
1860            "".to_string(),
1861            "> quoted line one".to_string(),
1862            "> quoted line two".to_string(),
1863            "> quoted line three".to_string(),
1864            "".to_string(),
1865            "outro".to_string(),
1866        ];
1867        update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
1868
1869        let mut new_lines = lines.clone();
1870        new_lines[3] = "> quoted line TWO".to_string();
1871        update_view(&mut v, &new_lines, (3, 17), rect(40), 2, None);
1872        full_rebuild_equals_view_state(&v, &new_lines);
1873    }
1874
1875    #[test]
1876    fn incremental_html_block_edit() {
1877        let mut v = MarkdownEditorView::new();
1878        let lines = vec![
1879            "before".to_string(),
1880            "".to_string(),
1881            "<div>".to_string(),
1882            "body".to_string(),
1883            "</div>".to_string(),
1884            "".to_string(),
1885            "after".to_string(),
1886        ];
1887        update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
1888
1889        let mut new_lines = lines.clone();
1890        new_lines[3] = "body changed".to_string();
1891        update_view(&mut v, &new_lines, (3, 12), rect(40), 2, None);
1892        full_rebuild_equals_view_state(&v, &new_lines);
1893    }
1894
1895    #[test]
1896    fn g1_nested_list_three_indent_continuation() {
1897        // Deeply nested continuation: damaged range touches a 3-indent
1898        // continuation line. Widening must reach the outermost col-0
1899        // ListMarker — otherwise parse_range sees `      text` as
1900        // IndentedCode.
1901        let mut v = MarkdownEditorView::new();
1902        let lines = vec![
1903            "intro".to_string(),
1904            "".to_string(),
1905            "- level 0".to_string(),
1906            "  - level 1".to_string(),
1907            "    - level 2".to_string(),
1908            "      continuation at 6 indent".to_string(),
1909            "".to_string(),
1910            "after".to_string(),
1911        ];
1912        update_view(&mut v, &lines, (5, 0), rect(40), 1, None);
1913
1914        let mut new_lines = lines.clone();
1915        new_lines[5] = "      continuation at 6 indent EDITED".to_string();
1916        update_view(&mut v, &new_lines, (5, 30), rect(40), 2, None);
1917        full_rebuild_equals_view_state(&v, &new_lines);
1918    }
1919
1920    #[test]
1921    fn g3_hashtag_inside_fence_not_labeled_after_incremental_edit() {
1922        // `#tag` inside a fenced code block must NOT produce a Label element.
1923        // After an incremental edit fully inside the fence, the widened
1924        // slice includes both fence markers — the label-suppression scan
1925        // sees the fence and skips. This test verifies the round-trip.
1926        let mut v = MarkdownEditorView::new();
1927        let lines = vec![
1928            "intro".to_string(),
1929            "".to_string(),
1930            "```".to_string(),
1931            "let s = \"#tag\";".to_string(),
1932            "// another #tag".to_string(),
1933            "```".to_string(),
1934            "".to_string(),
1935            "outro".to_string(),
1936        ];
1937        update_view(&mut v, &lines, (4, 0), rect(40), 1, None);
1938
1939        use crate::components::text_editor::markdown::ElementKind;
1940
1941        // Pre-condition: no Label elements in the fence interior.
1942        for row in 3..5 {
1943            let has_label = v.parse_state.buf().lines[row]
1944                .elements
1945                .iter()
1946                .any(|e| matches!(e.kind, ElementKind::Label));
1947            assert!(
1948                !has_label,
1949                "row {row} should have no Label inside the fence"
1950            );
1951        }
1952
1953        // Edit one of the in-fence lines.
1954        let mut new_lines = lines.clone();
1955        new_lines[4] = "// edited #tag here".to_string();
1956        update_view(&mut v, &new_lines, (4, 19), rect(40), 2, None);
1957
1958        // Post-condition: still no Label elements in the fence interior.
1959        for row in 3..5 {
1960            let has_label = v.parse_state.buf().lines[row]
1961                .elements
1962                .iter()
1963                .any(|e| matches!(e.kind, ElementKind::Label));
1964            assert!(
1965                !has_label,
1966                "row {row} should still have no Label after incremental edit"
1967            );
1968        }
1969        full_rebuild_equals_view_state(&v, &new_lines);
1970    }
1971
1972    #[test]
1973    fn g8a_typing_into_empty_buffer() {
1974        let mut v = MarkdownEditorView::new();
1975        let empty = vec!["".to_string()];
1976        update_view(&mut v, &empty, (0, 0), rect(40), 1, None);
1977
1978        let one = vec!["h".to_string()];
1979        update_view(&mut v, &one, (0, 1), rect(40), 2, None);
1980        full_rebuild_equals_view_state(&v, &one);
1981
1982        let two = vec!["he".to_string()];
1983        update_view(&mut v, &two, (0, 2), rect(40), 3, None);
1984        full_rebuild_equals_view_state(&v, &two);
1985
1986        let many = vec!["hello world".to_string()];
1987        update_view(&mut v, &many, (0, 11), rect(40), 4, None);
1988        full_rebuild_equals_view_state(&v, &many);
1989    }
1990
1991    #[test]
1992    fn g8b_delete_last_char_one_line_buffer() {
1993        let mut v = MarkdownEditorView::new();
1994        let one = vec!["h".to_string()];
1995        update_view(&mut v, &one, (0, 1), rect(40), 1, None);
1996
1997        let empty = vec!["".to_string()];
1998        update_view(&mut v, &empty, (0, 0), rect(40), 2, None);
1999        full_rebuild_equals_view_state(&v, &empty);
2000    }
2001
2002    #[test]
2003    fn incremental_text_change_produces_same_layout_as_full_recompute() {
2004        let mut v = MarkdownEditorView::new();
2005        let lines: Vec<String> = (0..200)
2006            .map(|i| format!("paragraph {i} with some text that may wrap depending on width"))
2007            .collect();
2008        update_view(&mut v, &lines, (100, 0), rect(40), 1, None);
2009        let baseline_visual_lines = v.layout.visual_lines().to_vec();
2010
2011        // Edit a paragraph mid-buffer (no line count change).
2012        let mut edited = lines.clone();
2013        edited[100].push_str(" extra text");
2014        update_view(&mut v, &edited, (100, edited[100].len()), rect(40), 2, None);
2015
2016        // After incremental wrap, layout must equal a fresh compute of the edited buffer.
2017        let fresh_layout = WordWrapLayout::compute(&edited, 40, v.rendered_cache_for_testing());
2018
2019        let actual = v.layout.visual_lines();
2020        let fresh = fresh_layout.visual_lines();
2021        assert_eq!(actual.len(), fresh.len(), "visual_lines count diverges");
2022        for (i, (a, f)) in actual.iter().zip(fresh.iter()).enumerate() {
2023            assert_eq!(a, f, "visual line {i} diverges");
2024        }
2025
2026        // Sanity: a row outside the edit should have unchanged visual lines.
2027        let row_50_before = baseline_visual_lines
2028            .iter()
2029            .filter(|vl| vl.logical_row == 50)
2030            .count();
2031        let row_50_after = v
2032            .layout
2033            .visual_lines()
2034            .iter()
2035            .filter(|vl| vl.logical_row == 50)
2036            .count();
2037        assert_eq!(
2038            row_50_before, row_50_after,
2039            "row 50 visual_lines count should be unchanged"
2040        );
2041
2042        assert!(v.last_parse_was_incremental, "expected incremental path");
2043    }
2044
2045    #[test]
2046    fn incremental_text_change_does_not_rebuild_all_of_rendered_cache() {
2047        // Verify that after an incremental text edit, rendered_cache rows
2048        // outside the widened range are NOT re-derived from scratch. We
2049        // can't directly observe the rebuild, but we CAN verify the cache
2050        // contents stay correct (matching a full rebuild's output).
2051        let mut v = MarkdownEditorView::new();
2052        let lines: Vec<String> = (0..200)
2053            .map(|i| format!("paragraph {i} with some text"))
2054            .collect();
2055        update_view(&mut v, &lines, (100, 0), rect(40), 1, None);
2056
2057        // Snapshot rendered_cache before the edit.
2058        let before: Vec<Vec<bool>> = v
2059            .rendered_cache
2060            .iter()
2061            .enumerate()
2062            .filter(|(i, _)| *i < 50 || *i > 150)
2063            .map(|(_, v)| v.clone())
2064            .collect();
2065
2066        // Edit a paragraph in the middle.
2067        let mut edited = lines.clone();
2068        edited[100].push('x');
2069        update_view(&mut v, &edited, (100, edited[100].len()), rect(40), 2, None);
2070
2071        // Rows far outside the damaged range must be byte-identical.
2072        let after: Vec<Vec<bool>> = v
2073            .rendered_cache
2074            .iter()
2075            .enumerate()
2076            .filter(|(i, _)| *i < 50 || *i > 150)
2077            .map(|(_, v)| v.clone())
2078            .collect();
2079        assert_eq!(
2080            before, after,
2081            "rendered_cache rows outside damaged range must be unchanged"
2082        );
2083
2084        // The incremental path must have been taken.
2085        assert!(v.last_parse_was_incremental);
2086    }
2087
2088    // §3.4 — heuristic widener fires on an in-list content edit.
2089    //
2090    // Needs a buffer big enough that strict widener (which on a
2091    // loose list with no interior reset boundaries expands to
2092    // `[0, lines.len()]`) cap-trips, so the edit falls to
2093    // widen_to_safe over the loose-list blanks. With
2094    // MAX_INCREMENTAL_LINES=256 we use ~500 items.
2095
2096    fn make_loose_list(n_items: usize) -> Vec<String> {
2097        let mut out = Vec::with_capacity(n_items * 2);
2098        for i in 0..n_items {
2099            out.push(format!("- item {i}"));
2100            if i + 1 < n_items {
2101                out.push(String::new());
2102            }
2103        }
2104        out
2105    }
2106
2107    #[test]
2108    fn try_incremental_parse_uses_heuristic_on_in_list_edit() {
2109        let mut v = MarkdownEditorView::new();
2110        let lines = make_loose_list(300);
2111        let mid_row = 200;
2112        update_view(&mut v, &lines, (mid_row, 0), rect(20), 1, None);
2113
2114        let mut edited = lines.clone();
2115        edited[mid_row].push('x');
2116        update_view(
2117            &mut v,
2118            &edited,
2119            (mid_row, edited[mid_row].len()),
2120            rect(20),
2121            2,
2122            None,
2123        );
2124
2125        assert!(
2126            v.last_parse_was_incremental,
2127            "edit inside large loose list must take incremental path \
2128             (lazy-guard relaxation + widen_to_safe over the loose-list blanks)"
2129        );
2130        assert_eq!(
2131            v.last_splice_path,
2132            Some(SplicePath::Heuristic),
2133            "expected Heuristic path on large loose list edit, got {:?}",
2134            v.last_splice_path
2135        );
2136    }
2137
2138    // §3.5 — lazy-guard relaxation must NOT skip when the edit is a
2139    // list-marker flip. The marker-flip guard above the lazy guard
2140    // should bail first, and even if it didn't, the lazy guard's
2141    // kind_qualifies check should also bail since ListMarker is the
2142    // OLD kind but the new line is a different marker (still a list
2143    // marker, so the `looks_like_list_marker` flip check passes —
2144    // both old and new look like list markers; the lazy guard would
2145    // relax). However the kinds-comparison test ensures the edit
2146    // becomes a divergent classification only via the verify path.
2147    //
2148    // Actually re-reading: marker-style flip "- a" → "* a" does NOT
2149    // change `looks_like_list_marker` (both return true). The lazy
2150    // guard relaxation lets it through. The widener attempts splice.
2151    // If the slice's per-row kinds match the parent's, no divergence;
2152    // splice succeeds. If marker-style switches the classification,
2153    // verify catches it.
2154    //
2155    // The §3.5 spec scenario "- a" → "* a" produces ListMarker in
2156    // both. Slice parses "* a" alone as a list with `*` marker;
2157    // kinds[0] = ListMarker. Parent had ListMarker too. No
2158    // divergence. Splice succeeds via the heuristic widener.
2159    //
2160    // This test instead asserts the negative: a more-aggressive
2161    // structural change (e.g. removing the marker entirely, turning
2162    // a list row into a Plain row) must bail via the existing
2163    // looks_like_list_marker flip guard (KindGuard bail).
2164    #[test]
2165    fn try_incremental_parse_lazy_guard_still_bails_on_marker_removal() {
2166        let mut v = MarkdownEditorView::new();
2167        let lines: Vec<String> = vec!["- a".into(), "".into(), "- b".into()];
2168        update_view(&mut v, &lines, (0, 3), rect(20), 1, None);
2169
2170        let mut edited = lines.clone();
2171        edited[0] = "a".into(); // remove marker — `- a` → `a`
2172        update_view(&mut v, &edited, (0, 1), rect(20), 2, None);
2173
2174        // The looks_like_list_marker flip guard above the lazy guard
2175        // must bail this case (KindGuard). The lazy-guard relaxation
2176        // never sees it.
2177        assert!(
2178            !v.last_parse_was_incremental,
2179            "list-marker removal must NOT take incremental path \
2180             — looks_like_list_marker flip guard bails first"
2181        );
2182    }
2183}