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 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}