fresh/view/line_wrap_cache.rs
1//! Line-wrap pipeline-output cache.
2//!
3//! A bounded per-buffer cache from `LineWrapKey` to `Arc<Vec<ViewLine>>` —
4//! the final output of the render pipeline for a single logical line.
5//!
6//! See `docs/internal/line-wrap-cache-plan.md` for the full design. In
7//! brief:
8//!
9//! * **Single source of truth.** The value stored is what the renderer
10//! actually produces. Every consumer that needs to know "how many
11//! visual rows?", "where does byte X land visually?", "what byte is at
12//! visual column N?" reads the same `ViewLine` structures via the
13//! methods `ViewLine` already exposes (`source_byte_at_char`,
14//! `char_at_visual_col`, `source_byte_at_visual_col`, `visual_col_at_char`,
15//! `visual_width`). No second implementation to drift from.
16//!
17//! * **Two writers, one pipeline.** The renderer populates cache entries
18//! as a side effect of its normal per-frame work; the miss handler in
19//! this module runs the same four-step pipeline scoped to a single
20//! logical line. Same inputs → same output.
21//!
22//! * **Invalidation by key.** The key includes `pipeline_inputs_version`
23//! (a packed u64 derived from `buffer.version()`, `SoftBreakManager::
24//! version()`, and `ConcealManager::version()`) plus every geometry /
25//! view dimension the pipeline reads. Mutating any of those produces a
26//! different key; old entries become unreachable and age out via FIFO
27//! eviction. There is no active invalidate step.
28//!
29//! * **Byte-budget eviction.** Because `Vec<ViewLine>` sizes vary from
30//! a few hundred bytes for a short line to megabytes for a long line
31//! wrapping into thousands of rows, count-based eviction is the wrong
32//! metric. The cache tracks approximate total memory and evicts
33//! oldest-first when a new insert would exceed the byte budget.
34//!
35//! Structural invariants maintained at all times:
36//!
37//! self.map.len() == self.order.len()
38//! self.current_bytes <= self.byte_budget (after any insert)
39
40use crate::state::EditorState;
41use crate::view::ui::split_rendering::base_tokens::build_base_tokens;
42use crate::view::ui::split_rendering::transforms::{
43 apply_conceal_ranges, apply_soft_breaks, apply_wrapping_transform, splice_inline_virtual_text,
44};
45use crate::view::ui::view_pipeline::{ViewLine, ViewLineIterator};
46use fresh_core::api::ViewTokenWireKind;
47use std::collections::{HashMap, VecDeque};
48use std::sync::Arc;
49
50/// Default byte budget: 8 MiB. Comfortably holds the full layout for a
51/// small-to-medium buffer, a handful of huge lines, or any interactive
52/// scroll span. A single 200 KB line wrapping to ~2000 rows takes
53/// roughly 2 MB in its `Vec<ViewLine>` form, so the budget can absorb
54/// several such lines before churning.
55pub const DEFAULT_BYTE_BUDGET: usize = 8 * 1024 * 1024;
56
57/// View mode the pipeline is running in. Conceals and some plugin-
58/// rendered content only apply in Compose. Kept as a small plain enum
59/// so the key stays cheap to hash.
60#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
61pub enum CacheViewMode {
62 Source,
63 Compose,
64}
65
66/// Full set of inputs that determine a single logical line's wrapped
67/// layout. Every mutable input must be represented here — if the
68/// caller forgets one, stale entries can be returned.
69///
70/// The `pipeline_inputs_version` folds in the buffer version plus the
71/// soft-break and conceal managers' versions (see
72/// [`pipeline_inputs_version`]). The remaining fields are geometry /
73/// viewport config.
74#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
75pub struct LineWrapKey {
76 pub pipeline_inputs_version: u64,
77 pub view_mode: CacheViewMode,
78 pub line_start: usize,
79 pub effective_width: u32,
80 pub gutter_width: u16,
81 pub wrap_column: Option<u32>,
82 pub hanging_indent: bool,
83 pub line_wrap_enabled: bool,
84}
85
86/// Derive the combined pipeline-inputs version from the three source
87/// versions. Any change to any of them flips the combined value. This
88/// is not a hash — it's a packed integer with enough bit-budget to make
89/// accidental collisions astronomically unlikely in a single session.
90///
91/// * `buffer_version` gets the low 32 bits (wrapped to u32). Buffer
92/// edits are the most frequent source of change.
93/// * `soft_breaks_version` is shifted up 32 bits.
94/// * `conceal_version` is shifted up 48 bits.
95/// * `virtual_text_version` is shifted up 16 bits. Folded so that
96/// adding / removing plugin virtual lines (e.g.
97/// markdown_compose's table borders, git blame headers)
98/// invalidates the same caches the other three sources do —
99/// `VisualRowIndex` adds virtual line counts to its prefix sums and
100/// would otherwise serve a stale total when the plugin re-tiles a
101/// table.
102#[inline]
103pub fn pipeline_inputs_version(
104 buffer_version: u64,
105 soft_breaks_version: u32,
106 conceal_version: u32,
107 virtual_text_version: u32,
108) -> u64 {
109 (buffer_version & 0xFFFF_FFFF)
110 ^ ((soft_breaks_version as u64) << 32)
111 ^ ((conceal_version as u64) << 48)
112 ^ ((virtual_text_version as u64) << 16)
113}
114
115/// Estimate the in-memory size of a `Vec<ViewLine>` for byte-budget
116/// accounting. Rough but stable — we'd rather over- than under-estimate
117/// so the budget stays honest.
118///
119/// Per `ViewLine`:
120/// - `text` (String): bytes in the rendered text
121/// - `char_source_bytes` (Vec<Option<usize>>): 16 bytes × chars
122/// - `char_styles` (Vec<Option<ViewTokenStyle>>): ~32 bytes × chars
123/// - `char_visual_cols` (Vec<usize>): 8 bytes × chars
124/// - `visual_to_char` (Vec<usize>): 8 bytes × visual cols
125/// - overhead (HashSet, enum, bool, alignment padding): ~64 bytes
126///
127/// Round up to `visual_width * 64 + text.len() + 96` for simplicity.
128fn estimate_view_lines_bytes(lines: &[ViewLine]) -> usize {
129 let mut total = 48; // Arc + Vec overhead
130 for line in lines {
131 let chars = line.char_source_bytes.len();
132 let visual = line.visual_to_char.len();
133 total += line.text.len() + chars * 56 + visual * 8 + 96;
134 }
135 total
136}
137
138/// Bounded FIFO cache from `LineWrapKey` to `Arc<Vec<ViewLine>>`.
139///
140/// FIFO (not LRU) because the dominant access pattern is sequential
141/// scrolling: each line is queried a few times in close succession, then
142/// rarely again. FIFO is simpler to reason about and matches this
143/// pattern well enough. If future profiling shows churn we can swap the
144/// eviction policy — the external API doesn't change.
145#[derive(Debug, Clone)]
146pub struct LineWrapCache {
147 map: HashMap<LineWrapKey, Arc<Vec<ViewLine>>>,
148 order: VecDeque<LineWrapKey>,
149 byte_budget: usize,
150 current_bytes: usize,
151}
152
153impl Default for LineWrapCache {
154 fn default() -> Self {
155 Self::with_byte_budget(DEFAULT_BYTE_BUDGET)
156 }
157}
158
159impl LineWrapCache {
160 pub fn with_byte_budget(byte_budget: usize) -> Self {
161 assert!(byte_budget > 0, "LineWrapCache byte_budget must be > 0");
162 Self {
163 map: HashMap::new(),
164 order: VecDeque::new(),
165 byte_budget,
166 current_bytes: 0,
167 }
168 }
169
170 pub fn len(&self) -> usize {
171 debug_assert_eq!(
172 self.map.len(),
173 self.order.len(),
174 "LineWrapCache invariant: map.len() == order.len()"
175 );
176 self.map.len()
177 }
178
179 pub fn is_empty(&self) -> bool {
180 self.len() == 0
181 }
182
183 pub fn byte_budget(&self) -> usize {
184 self.byte_budget
185 }
186
187 pub fn current_bytes(&self) -> usize {
188 self.current_bytes
189 }
190
191 /// Look up a cached value. Returns `None` on miss. The returned
192 /// `Arc` is a cheap clone; callers can hold it without copying the
193 /// underlying `Vec<ViewLine>`.
194 pub fn get(&self, key: &LineWrapKey) -> Option<Arc<Vec<ViewLine>>> {
195 self.map.get(key).cloned()
196 }
197
198 /// Query by key; on miss, run `compute` and store its result. The
199 /// primary entry point for both the renderer's write path and the
200 /// scroll-math miss handler.
201 ///
202 /// Returns the (possibly just-computed) value as an `Arc`. The
203 /// `compute` closure is called at most once per cache miss; hits do
204 /// not invoke it.
205 pub fn get_or_insert_with<F>(&mut self, key: LineWrapKey, compute: F) -> Arc<Vec<ViewLine>>
206 where
207 F: FnOnce() -> Vec<ViewLine>,
208 {
209 if let Some(v) = self.map.get(&key) {
210 return v.clone();
211 }
212 let value = Arc::new(compute());
213 self.insert_fresh(key, value.clone());
214 value
215 }
216
217 /// Unconditionally store a value for `key`. If `key` is already
218 /// present, its value is replaced in place and its FIFO position is
219 /// **not** changed (this keeps the queue simple — re-inserts don't
220 /// refresh age). Byte-budget accounting is updated.
221 pub fn put(&mut self, key: LineWrapKey, value: Arc<Vec<ViewLine>>) {
222 if let Some(existing) = self.map.get_mut(&key) {
223 let old_bytes = estimate_view_lines_bytes(existing);
224 let new_bytes = estimate_view_lines_bytes(&value);
225 *existing = value;
226 self.current_bytes = self.current_bytes + new_bytes - old_bytes.min(self.current_bytes);
227 return;
228 }
229 self.insert_fresh(key, value);
230 }
231
232 /// Remove all entries. Used by tests and by future
233 /// plugin-lifecycle events.
234 pub fn clear(&mut self) {
235 self.map.clear();
236 self.order.clear();
237 self.current_bytes = 0;
238 }
239
240 /// Insert a never-before-seen key, evicting oldest-first until the
241 /// new entry fits inside `byte_budget`.
242 fn insert_fresh(&mut self, key: LineWrapKey, value: Arc<Vec<ViewLine>>) {
243 debug_assert!(!self.map.contains_key(&key));
244 let new_bytes = estimate_view_lines_bytes(&value);
245
246 // Evict until (current_bytes + new_bytes) fits. Always keep at
247 // least one slot — if the single new entry alone exceeds the
248 // budget, we still accept it (the cache was asked to hold it;
249 // the alternative is silently dropping data the caller just
250 // paid to compute).
251 while self.current_bytes + new_bytes > self.byte_budget && !self.order.is_empty() {
252 if let Some(oldest_key) = self.order.pop_front() {
253 if let Some(oldest_val) = self.map.remove(&oldest_key) {
254 let shed = estimate_view_lines_bytes(&oldest_val);
255 self.current_bytes = self.current_bytes.saturating_sub(shed);
256 }
257 }
258 }
259
260 self.map.insert(key, value);
261 self.order.push_back(key);
262 self.current_bytes += new_bytes;
263 debug_assert_eq!(self.map.len(), self.order.len());
264 }
265}
266
267/// Materialise a line's layout as `Vec<ViewLine>` from plain text
268/// alone — no buffer iteration, no soft breaks, no conceals.
269///
270/// Useful at sites that have `line_text: &str` in hand and can't
271/// easily reach `EditorState` (or are inside a `line_iterator` borrow).
272/// The produced `ViewLine`s match the renderer's word-boundary wrap
273/// on the same text at the same geometry, so row counts and cursor
274/// mappings agree with `layout_for_line` in the absence of soft
275/// breaks / conceals. When soft breaks or conceals ARE active for
276/// the line, callers should prefer `layout_for_line` to get accurate
277/// layout.
278pub fn layout_for_plain_text(
279 line_text: &str,
280 effective_width: usize,
281 gutter_width: usize,
282 hanging_indent: bool,
283 tab_size: usize,
284) -> Vec<ViewLine> {
285 use crate::view::ui::view_pipeline::LineStart;
286 use fresh_core::api::ViewTokenWire;
287 let tokens = vec![ViewTokenWire {
288 source_offset: Some(0),
289 kind: ViewTokenWireKind::Text(line_text.to_string()),
290 style: None,
291 }];
292 let wrapped = apply_wrapping_transform(tokens, effective_width, gutter_width, hanging_indent);
293 let mut lines: Vec<ViewLine> =
294 ViewLineIterator::new(&wrapped, false, true, tab_size, false).collect();
295 // Invariant: every logical line is at least one visual row. An
296 // empty input produces zero ViewLines through the iterator; emit
297 // one placeholder so callers (scrollbar row counts, scroll math)
298 // see consistent ≥1 results matching `compute_line_layout`.
299 if lines.is_empty() {
300 lines.push(ViewLine {
301 text: String::new(),
302 source_start_byte: Some(0),
303 char_source_bytes: Vec::new(),
304 char_styles: Vec::new(),
305 char_visual_cols: Vec::new(),
306 visual_to_char: Vec::new(),
307 tab_starts: std::collections::HashSet::new(),
308 line_start: LineStart::Beginning,
309 ends_with_newline: false,
310 virtual_gutter_glyph: None,
311 virtual_line_style: None,
312 });
313 }
314 lines
315}
316
317/// Look up a line's layout in the cache, running the mini-pipeline to
318/// fill on miss. The primary read-path entry point for consumers that
319/// need full `ViewLine` layout (not just row count).
320///
321/// Guarantees that the value returned matches what the renderer would
322/// produce for the same line under the same pipeline inputs: same
323/// function chain is called either way, so cache hit and miss are
324/// indistinguishable to the caller.
325pub fn layout_for_line(
326 state: &mut EditorState,
327 line_start: usize,
328 line_end: usize,
329 geom: &WrapGeometry,
330) -> Arc<Vec<ViewLine>> {
331 let version = pipeline_inputs_version(
332 state.buffer.version(),
333 state.soft_breaks.version(),
334 state.conceals.version(),
335 state.virtual_texts.version(),
336 );
337 let key = geom.key(line_start, version);
338 if let Some(cached) = state.line_wrap_cache.get(&key) {
339 return cached;
340 }
341 let layout = compute_line_layout(state, line_start, line_end, geom);
342 let arc = Arc::new(layout);
343 state.line_wrap_cache.put(key, arc.clone());
344 arc
345}
346
347/// Given a logical line's layout and a character position within the
348/// LOGICAL line (not the ViewLine), return `(segment_idx,
349/// col_in_segment)` — the index of the `ViewLine` the character falls
350/// into, and the visual column within that `ViewLine`.
351///
352/// Replaces `primitives::line_wrapping::char_position_to_segment` for
353/// callers that have a cached `Vec<ViewLine>`.
354///
355/// The trick: continuation `ViewLine`s can carry hanging-indent
356/// characters at their start whose `source_offset` is `None` (they
357/// don't correspond to any source byte). Those chars must NOT count
358/// toward the source-character position we're walking past. So we
359/// sum *source* characters per row (char_source_bytes entries that
360/// are `Some(_)`) to find the row containing `char_pos_in_line`, and
361/// within that row we locate the specific char whose source_offset
362/// matches.
363///
364/// If `layout` is empty, returns `(0, 0)`. If the position is past
365/// the end of the last row, returns the last row with the last
366/// visual column of that row.
367pub fn char_position_in_layout(layout: &[ViewLine], char_pos_in_line: usize) -> (usize, usize) {
368 if layout.is_empty() {
369 return (0, 0);
370 }
371 let mut source_chars_consumed = 0usize;
372 for (i, line) in layout.iter().enumerate() {
373 let source_chars_in_row = line
374 .char_source_bytes
375 .iter()
376 .filter(|b| b.is_some())
377 .count();
378 if char_pos_in_line < source_chars_consumed + source_chars_in_row {
379 // The target source-char is in this row. Find the
380 // `char_idx` whose position-among-source-chars equals
381 // the within-row offset, then convert to visual column.
382 let within_row = char_pos_in_line - source_chars_consumed;
383 let mut source_count = 0usize;
384 for (char_idx, byte) in line.char_source_bytes.iter().enumerate() {
385 if byte.is_some() {
386 if source_count == within_row {
387 return (i, line.visual_col_at_char(char_idx));
388 }
389 source_count += 1;
390 }
391 }
392 // Fallback: shouldn't happen given the length check above,
393 // but don't return garbage if it does.
394 return (i, line.visual_width().saturating_sub(1));
395 }
396 source_chars_consumed += source_chars_in_row;
397 }
398 // Past the end: return the last row's last visual column. (A
399 // cursor one past the last source char on the last row lands
400 // here.)
401 let last_idx = layout.len() - 1;
402 let last = &layout[last_idx];
403 let last_col = last.visual_width().saturating_sub(1);
404 (last_idx, last_col)
405}
406
407/// Geometry + view config inputs to the wrap pipeline that aren't carried
408/// by `EditorState`. Bundled so the plumbing through call sites doesn't
409/// grow a laundry list of parameters.
410#[derive(Debug, Clone, Copy)]
411pub struct WrapGeometry {
412 pub effective_width: usize,
413 pub gutter_width: usize,
414 pub hanging_indent: bool,
415 pub wrap_column: Option<u32>,
416 pub line_wrap_enabled: bool,
417 pub view_mode: CacheViewMode,
418}
419
420impl WrapGeometry {
421 /// Build a cache key for a logical line at `line_start` under these
422 /// geometry and pipeline-input versions.
423 pub fn key(&self, line_start: usize, pipeline_inputs_version: u64) -> LineWrapKey {
424 LineWrapKey {
425 pipeline_inputs_version,
426 view_mode: self.view_mode,
427 line_start,
428 effective_width: self.effective_width as u32,
429 gutter_width: self.gutter_width as u16,
430 wrap_column: self.wrap_column,
431 hanging_indent: self.hanging_indent,
432 line_wrap_enabled: self.line_wrap_enabled,
433 }
434 }
435}
436
437/// Run the same pipeline the renderer runs, scoped to exactly one
438/// logical line starting at `line_start`, and return the rendered
439/// [`ViewLine`]s for that line. Used by the cache miss handler.
440///
441/// When `geom.line_wrap_enabled` is false, returns a single
442/// placeholder `ViewLine` — an unwrapped line always occupies exactly
443/// one visual row. (Callers that only need a count can read
444/// `.len()`; callers that need coordinate mappings would not query
445/// this path with wrapping off.)
446///
447/// The four pipeline steps mirror `view_data::build_view_data`:
448/// 1. `build_base_tokens(top_byte=line_start, count=1)`
449/// 2. `apply_soft_breaks` (Compose mode, when any soft breaks overlap)
450/// 3. `apply_conceal_ranges` (Compose mode, when any conceals overlap)
451/// 4. `apply_wrapping_transform`
452/// followed by `ViewLineIterator::collect()` to materialise the
453/// `Vec<ViewLine>`.
454///
455/// The result is what the renderer would produce for this single
456/// logical line — the single source of truth the cache exists to
457/// share.
458pub fn compute_line_layout(
459 state: &mut EditorState,
460 line_start: usize,
461 line_end: usize,
462 geom: &WrapGeometry,
463) -> Vec<ViewLine> {
464 let is_binary = state.buffer.is_binary();
465 let line_ending = state.buffer.line_ending();
466 let estimated_line_length = state.buffer.estimated_line_length();
467 let tab_size = state.buffer_settings.tab_size;
468
469 // Step 1: build tokens for just this one logical line.
470 let mut tokens = build_base_tokens(
471 &mut state.buffer,
472 line_start,
473 estimated_line_length,
474 1, // just this one logical line
475 is_binary,
476 line_ending,
477 &[], // no fold skip ranges — folds affect what's rendered, not per-line wrap count
478 );
479
480 let is_compose = matches!(geom.view_mode, CacheViewMode::Compose);
481
482 // Step 2: soft breaks (Compose mode only; same gating as the renderer).
483 if is_compose && !state.soft_breaks.is_empty() {
484 let sb = state
485 .soft_breaks
486 .query_viewport(line_start, line_end, &state.marker_list);
487 if !sb.is_empty() {
488 tokens = apply_soft_breaks(tokens, &sb);
489 }
490 }
491
492 // Step 3: conceal ranges (Compose mode only).
493 if is_compose && !state.conceals.is_empty() {
494 let cr = state
495 .conceals
496 .query_viewport(line_start, line_end, &state.marker_list);
497 if !cr.is_empty() {
498 tokens = apply_conceal_ranges(tokens, &cr);
499 }
500 }
501
502 // Step 3.5: splice inline virtual text (inlay hints) so this per-line
503 // layout matches the renderer's — its width must affect wrap boundaries
504 // and visual-column counts identically. `theme` is `None`: this output
505 // feeds scroll-math / coordinate queries (never drawn), so only cell
506 // width matters, not colour.
507 if !state.virtual_texts.is_empty() {
508 tokens = splice_inline_virtual_text(tokens, state, None, line_start, line_end);
509 }
510
511 // Step 4: wrap (only when line-wrap is actually enabled). When
512 // disabled, pass tokens through unchanged; ViewLineIterator will
513 // still yield one ViewLine per Newline boundary.
514 if geom.line_wrap_enabled {
515 tokens = apply_wrapping_transform(
516 tokens,
517 geom.effective_width,
518 geom.gutter_width,
519 geom.hanging_indent,
520 );
521 }
522
523 // Materialise the ViewLines. `build_base_tokens` may emit tokens
524 // for more than one logical line; collect only the first logical
525 // line's ViewLines (those up to and including the first Newline).
526 let all_lines: Vec<ViewLine> =
527 ViewLineIterator::new(&tokens, is_binary, !is_binary, tab_size, false).collect();
528
529 // The `ViewLineIterator` produces one `ViewLine` per visual row.
530 // The Newline tokens inside split the stream at logical-line
531 // boundaries: every `ViewLine` after the first whose `line_start`
532 // is `AfterSourceNewline` begins a NEW logical line, which we
533 // don't want. Keep only rows up to (but not including) the first
534 // such transition.
535 let mut result = Vec::with_capacity(all_lines.len().min(8));
536 for (i, line) in all_lines.into_iter().enumerate() {
537 use crate::view::ui::view_pipeline::LineStart;
538 if i > 0 && matches!(line.line_start, LineStart::AfterSourceNewline) {
539 break;
540 }
541 result.push(line);
542 }
543 if result.is_empty() {
544 // Defensive: even a completely empty logical line corresponds
545 // to exactly one visual row. The iterator should always
546 // produce at least one, but be safe.
547 result.push(ViewLine {
548 text: String::new(),
549 source_start_byte: Some(line_start),
550 char_source_bytes: Vec::new(),
551 char_styles: Vec::new(),
552 char_visual_cols: Vec::new(),
553 visual_to_char: Vec::new(),
554 tab_starts: std::collections::HashSet::new(),
555 line_start: crate::view::ui::view_pipeline::LineStart::Beginning,
556 ends_with_newline: false,
557 virtual_gutter_glyph: None,
558 virtual_line_style: None,
559 });
560 }
561 result
562}
563
564/// Row count only. Thin wrapper over [`compute_line_layout`] for
565/// callers that need just the visual-row count — scroll math,
566/// thumb-size math. Prefer calling through the cache
567/// (`get_or_insert_with(key, || compute_line_layout(...)).len()`).
568pub fn count_visual_rows_via_pipeline(
569 state: &mut EditorState,
570 line_start: usize,
571 line_end: usize,
572 geom: &WrapGeometry,
573) -> u32 {
574 compute_line_layout(state, line_start, line_end, geom).len() as u32
575}
576
577/// Combined version of all pipeline inputs on the given state. Fold into
578/// a `LineWrapKey` to make stale entries unreachable on any mutation.
579#[inline]
580pub fn state_pipeline_inputs_version(state: &EditorState) -> u64 {
581 pipeline_inputs_version(
582 state.buffer.version(),
583 state.soft_breaks.version(),
584 state.conceals.version(),
585 state.virtual_texts.version(),
586 )
587}
588
589/// Build a placeholder `Vec<ViewLine>` of a given row count for cache
590/// consumers that only need `.len()` (e.g. scroll math's count-only
591/// queries, or the per-viewport row-count memoization). The returned
592/// `ViewLine`s have empty char/visual mappings — they carry no real
593/// layout information.
594///
595/// This exists because the cache is typed on `Vec<ViewLine>` so the
596/// cross-consumer path can share real layout, but some call sites
597/// don't yet have access to `EditorState` (needed by
598/// [`compute_line_layout`]). When those sites are migrated to take
599/// `&mut EditorState`, this helper can go away.
600pub fn placeholder_layout_for_row_count(n: u32) -> Vec<ViewLine> {
601 use crate::view::ui::view_pipeline::LineStart;
602 (0..n)
603 .map(|_| ViewLine {
604 text: String::new(),
605 source_start_byte: None,
606 char_source_bytes: Vec::new(),
607 char_styles: Vec::new(),
608 char_visual_cols: Vec::new(),
609 visual_to_char: Vec::new(),
610 tab_starts: std::collections::HashSet::new(),
611 line_start: LineStart::Beginning,
612 ends_with_newline: false,
613 virtual_gutter_glyph: None,
614 virtual_line_style: None,
615 })
616 .collect()
617}
618
619/// Count visual rows for a single line's text after applying the
620/// plugin's soft breaks AND the renderer's word-wrap. Mirrors the
621/// renderer's full pipeline (`apply_soft_breaks` → `apply_wrapping_transform`)
622/// so the scroll math agrees row-for-row with the rendered output even
623/// when the plugin has injected breaks at narrower-than-viewport
624/// widths (e.g. markdown_compose's per-paragraph wrap).
625///
626/// `soft_breaks_in_line` is the slice of `(byte_position, indent)` pairs
627/// for breaks falling **inside** `[line_start, line_start + line_text.len())`.
628/// Callers should pre-filter from the buffer-wide list.
629///
630/// When `soft_breaks_in_line` is empty this is a thin wrapper over
631/// [`count_visual_rows_for_text`].
632pub fn count_visual_rows_for_text_with_soft_breaks(
633 line_text: &str,
634 line_start: usize,
635 soft_breaks_in_line: &[(usize, u16)],
636 effective_width: usize,
637 gutter_width: usize,
638 hanging_indent: bool,
639) -> u32 {
640 if soft_breaks_in_line.is_empty() {
641 return count_visual_rows_for_text(
642 line_text,
643 effective_width,
644 gutter_width,
645 hanging_indent,
646 );
647 }
648
649 let mut total: u32 = 0;
650 let mut prev_end: usize = 0; // byte offset within `line_text`
651 let mut prev_indent: u16 = 0;
652
653 for &(pos, indent) in soft_breaks_in_line {
654 // Defensive: callers pre-filter, but ignore anything out of
655 // range so a stale break list can't OOB-slice the line.
656 if pos < line_start {
657 continue;
658 }
659 let rel = pos - line_start;
660 if rel >= line_text.len() {
661 continue;
662 }
663 if rel < prev_end {
664 // Break list is sorted; this would only fire on a
665 // duplicate or a not-byte-aligned offset. Skip rather
666 // than panic.
667 continue;
668 }
669 if !line_text.is_char_boundary(rel) {
670 // Stale break list: an edit earlier in the line shifted
671 // the text under positions computed against the old
672 // content, so the offset can land mid-char.
673 continue;
674 }
675 let segment = &line_text[prev_end..rel];
676 total = total.saturating_add(count_segment_rows_with_indent(
677 segment,
678 prev_indent,
679 effective_width,
680 gutter_width,
681 hanging_indent,
682 ));
683 // The renderer's `apply_soft_breaks` consumes the Space token
684 // *at* the break position when one is present (see
685 // transforms.rs::apply_soft_breaks). Skip exactly one
686 // character at `rel` to mirror that — UTF-8 safe.
687 let consumed = line_text[rel..]
688 .chars()
689 .next()
690 .map(|c| c.len_utf8())
691 .unwrap_or(0);
692 prev_end = (rel + consumed).min(line_text.len());
693 prev_indent = indent;
694 }
695 let segment = &line_text[prev_end..];
696 total = total.saturating_add(count_segment_rows_with_indent(
697 segment,
698 prev_indent,
699 effective_width,
700 gutter_width,
701 hanging_indent,
702 ));
703 total.max(1)
704}
705
706/// Helper for [`count_visual_rows_for_text_with_soft_breaks`]:
707/// row count for one inter-break segment with `leading_indent`
708/// columns reserved at the front. An empty segment still occupies
709/// one visual row (matches the renderer, which emits a trailing
710/// `Break` for the broken position).
711fn count_segment_rows_with_indent(
712 segment: &str,
713 leading_indent: u16,
714 effective_width: usize,
715 gutter_width: usize,
716 hanging_indent: bool,
717) -> u32 {
718 if segment.is_empty() && leading_indent == 0 {
719 return 1;
720 }
721 if leading_indent == 0 {
722 return count_visual_rows_for_text(segment, effective_width, gutter_width, hanging_indent);
723 }
724 // Prepend the indent columns; this lets the renderer's word-wrap
725 // see the same `current_line_width` it would after
726 // `apply_soft_breaks` injected indent Spaces.
727 let mut prefixed = String::with_capacity(leading_indent as usize + segment.len());
728 for _ in 0..leading_indent {
729 prefixed.push(' ');
730 }
731 prefixed.push_str(segment);
732 count_visual_rows_for_text(&prefixed, effective_width, gutter_width, hanging_indent)
733}
734
735/// Count visual rows for a single line's text under the renderer's
736/// wrap algorithm. Pure function of (text, geometry).
737///
738/// Behaves exactly like the renderer's per-logical-line wrap count:
739/// runs `apply_wrapping_transform` on a single-`Text`-token input and
740/// tallies non-empty rows. A trailing `Break` emitted when the last
741/// chunk exactly fills the effective width is followed by nothing
742/// meaningful and does not count as a row.
743pub fn count_visual_rows_for_text(
744 line_text: &str,
745 effective_width: usize,
746 gutter_width: usize,
747 hanging_indent: bool,
748) -> u32 {
749 use crate::view::ui::split_rendering::transforms::apply_wrapping_transform;
750 use fresh_core::api::ViewTokenWire;
751
752 let tokens = vec![ViewTokenWire {
753 source_offset: Some(0),
754 kind: ViewTokenWireKind::Text(line_text.to_string()),
755 style: None,
756 }];
757 let wrapped = apply_wrapping_transform(tokens, effective_width, gutter_width, hanging_indent);
758 let mut rows: u32 = 0;
759 let mut row_has_content = false;
760 for t in &wrapped {
761 match &t.kind {
762 ViewTokenWireKind::Newline => break,
763 ViewTokenWireKind::Break => {
764 if row_has_content {
765 rows += 1;
766 }
767 row_has_content = false;
768 }
769 ViewTokenWireKind::Text(s) => {
770 if !s.is_empty() {
771 row_has_content = true;
772 }
773 }
774 ViewTokenWireKind::Space | ViewTokenWireKind::BinaryByte(_) => {
775 row_has_content = true;
776 }
777 }
778 }
779 if row_has_content {
780 rows += 1;
781 }
782 rows.max(1)
783}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788 use crate::view::ui::view_pipeline::LineStart;
789
790 fn key(line_start: usize, version: u64) -> LineWrapKey {
791 LineWrapKey {
792 pipeline_inputs_version: version,
793 view_mode: CacheViewMode::Source,
794 line_start,
795 effective_width: 80,
796 gutter_width: 6,
797 wrap_column: None,
798 hanging_indent: false,
799 line_wrap_enabled: true,
800 }
801 }
802
803 /// Build a dummy `Vec<ViewLine>` of length `n` for primitive tests
804 /// that only care about how the cache stores / evicts values, not
805 /// about the actual pipeline output. Each `ViewLine` is empty
806 /// apart from its row identity.
807 fn dummy_lines(n: u32) -> Vec<ViewLine> {
808 (0..n)
809 .map(|_| ViewLine {
810 text: String::new(),
811 source_start_byte: Some(0),
812 char_source_bytes: Vec::new(),
813 char_styles: Vec::new(),
814 char_visual_cols: Vec::new(),
815 visual_to_char: Vec::new(),
816 tab_starts: std::collections::HashSet::new(),
817 line_start: LineStart::Beginning,
818 ends_with_newline: false,
819 virtual_gutter_glyph: None,
820 virtual_line_style: None,
821 })
822 .collect()
823 }
824
825 /// Roomy byte budget for tests that shouldn't evict.
826 const ROOMY: usize = 1024 * 1024;
827 /// Tight byte budget that evicts after a handful of empty lines.
828 /// Each empty `ViewLine` is ~96 bytes plus 48 Vec/Arc overhead, so
829 /// this budget holds roughly 3 entries.
830 const TIGHT: usize = 500;
831
832 #[test]
833 fn empty_cache_is_empty() {
834 let cache = LineWrapCache::default();
835 assert!(cache.is_empty());
836 assert_eq!(cache.len(), 0);
837 assert_eq!(cache.current_bytes(), 0);
838 }
839
840 #[test]
841 fn get_or_insert_caches_on_miss() {
842 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
843 let mut compute_calls = 0;
844 let v = cache.get_or_insert_with(key(100, 1), || {
845 compute_calls += 1;
846 dummy_lines(7)
847 });
848 assert_eq!(v.len(), 7);
849 assert_eq!(compute_calls, 1);
850 assert_eq!(cache.len(), 1);
851 }
852
853 #[test]
854 fn repeat_lookup_is_a_hit() {
855 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
856 let mut compute_calls = 0;
857 cache.get_or_insert_with(key(100, 1), || {
858 compute_calls += 1;
859 dummy_lines(7)
860 });
861 let v = cache.get_or_insert_with(key(100, 1), || {
862 compute_calls += 1;
863 dummy_lines(99) // wrong value, should not be invoked
864 });
865 assert_eq!(v.len(), 7);
866 assert_eq!(compute_calls, 1, "second lookup should be a hit");
867 }
868
869 #[test]
870 fn different_versions_are_separate_entries() {
871 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
872 cache.get_or_insert_with(key(100, 1), || dummy_lines(3));
873 cache.get_or_insert_with(key(100, 2), || dummy_lines(5));
874 assert_eq!(cache.get(&key(100, 1)).map(|v| v.len()), Some(3));
875 assert_eq!(cache.get(&key(100, 2)).map(|v| v.len()), Some(5));
876 assert_eq!(cache.len(), 2);
877 }
878
879 #[test]
880 fn evicts_oldest_when_byte_budget_reached() {
881 let mut cache = LineWrapCache::with_byte_budget(TIGHT);
882 cache.get_or_insert_with(key(100, 1), || dummy_lines(1));
883 cache.get_or_insert_with(key(200, 1), || dummy_lines(1));
884 cache.get_or_insert_with(key(300, 1), || dummy_lines(1));
885 // Adding a fourth tiny entry should evict at least the oldest
886 // (line_start=100) to stay within the budget.
887 cache.get_or_insert_with(key(400, 1), || dummy_lines(1));
888 assert!(cache.current_bytes() <= TIGHT);
889 assert_eq!(cache.get(&key(100, 1)).is_none(), true, "oldest evicted");
890 // Later entries still reachable.
891 assert!(cache.get(&key(400, 1)).is_some());
892 }
893
894 #[test]
895 fn structural_invariant_holds_under_many_inserts() {
896 let mut cache = LineWrapCache::with_byte_budget(TIGHT);
897 for i in 0..200u64 {
898 cache.get_or_insert_with(key(i as usize, i), || dummy_lines(1));
899 assert_eq!(cache.len(), cache.map.len());
900 assert_eq!(cache.len(), cache.order.len());
901 assert_eq!(cache.current_bytes <= cache.byte_budget, true);
902 }
903 }
904
905 #[test]
906 fn put_overwrites_existing_value_without_reordering() {
907 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
908 cache.get_or_insert_with(key(100, 1), || dummy_lines(1));
909 cache.get_or_insert_with(key(200, 1), || dummy_lines(1));
910 cache.get_or_insert_with(key(300, 1), || dummy_lines(1));
911 // Overwrite middle with a different-sized value.
912 cache.put(key(200, 1), Arc::new(dummy_lines(42)));
913 assert_eq!(cache.get(&key(200, 1)).map(|v| v.len()), Some(42));
914 // key=100 is still the oldest in the FIFO.
915 cache.get_or_insert_with(key(400, 1), || dummy_lines(1));
916 // With ROOMY budget nothing's evicted yet; all present.
917 for k in [100usize, 200, 300, 400] {
918 assert!(cache.get(&key(k, 1)).is_some(), "k={k} should be present");
919 }
920 }
921
922 #[test]
923 fn clear_empties_cache() {
924 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
925 cache.get_or_insert_with(key(100, 1), || dummy_lines(1));
926 cache.get_or_insert_with(key(200, 1), || dummy_lines(1));
927 cache.clear();
928 assert!(cache.is_empty());
929 assert_eq!(cache.current_bytes(), 0);
930 assert!(cache.get(&key(100, 1)).is_none());
931 }
932
933 #[test]
934 fn pipeline_inputs_version_changes_when_any_source_changes() {
935 let a = pipeline_inputs_version(100, 5, 3, 7);
936 assert_ne!(
937 a,
938 pipeline_inputs_version(101, 5, 3, 7),
939 "buffer bump changes version"
940 );
941 assert_ne!(
942 a,
943 pipeline_inputs_version(100, 6, 3, 7),
944 "soft-break bump changes version"
945 );
946 assert_ne!(
947 a,
948 pipeline_inputs_version(100, 5, 4, 7),
949 "conceal bump changes version"
950 );
951 assert_ne!(
952 a,
953 pipeline_inputs_version(100, 5, 3, 8),
954 "virtual-text bump changes version"
955 );
956 }
957
958 #[test]
959 #[should_panic]
960 fn zero_byte_budget_rejected() {
961 LineWrapCache::with_byte_budget(0);
962 }
963
964 /// Even if a single new entry's estimated size exceeds the budget,
965 /// the cache accepts it rather than silently dropping data the
966 /// caller just paid to compute. Later inserts will still evict it
967 /// like any other FIFO entry.
968 #[test]
969 fn oversize_entry_is_accepted_then_agable() {
970 let mut cache = LineWrapCache::with_byte_budget(TIGHT);
971 // dummy_lines(50) is ~7 KB per line × 50 = ~350 KB... no, empty
972 // ViewLines are ~96 bytes each, so 50 × 96 ≈ 5 KB. That
973 // exceeds TIGHT (500 bytes).
974 cache.get_or_insert_with(key(1, 1), || dummy_lines(50));
975 assert!(cache.get(&key(1, 1)).is_some());
976 // Inserting a second entry evicts the oversize one.
977 cache.get_or_insert_with(key(2, 1), || dummy_lines(1));
978 assert!(cache.get(&key(1, 1)).is_none());
979 assert!(cache.get(&key(2, 1)).is_some());
980 }
981
982 // -------------------------------------------------------------------
983 // Layer 4: wrap-function invariants.
984 //
985 // These hold for any correct wrap regardless of cache state. A cache
986 // bug that corrupts a stored value would eventually violate one of
987 // them via the cache-backed path (e.g. width-monotonicity).
988 // -------------------------------------------------------------------
989
990 /// An empty line wraps to exactly one visual row.
991 #[test]
992 fn empty_line_is_one_row() {
993 for width in [5usize, 10, 42, 80, 120] {
994 assert_eq!(count_visual_rows_for_text("", width, 0, false), 1);
995 assert_eq!(count_visual_rows_for_text("", width, 6, false), 1);
996 }
997 }
998
999 /// A line whose visual width fits inside the available width wraps to
1000 /// exactly one row. Tests a few short ASCII strings at a few widths.
1001 #[test]
1002 fn line_that_fits_is_one_row() {
1003 // "hello world" = 11 chars; at effective_width=80, gutter=6 →
1004 // available width = 74 > 11, must be 1 row.
1005 for text in ["hello", "hello world", "a b c d"] {
1006 assert_eq!(count_visual_rows_for_text(text, 80, 6, false), 1);
1007 }
1008 }
1009
1010 /// Width monotonicity: widening `effective_width` never *increases*
1011 /// the row count.
1012 ///
1013 /// For a fixed text, any correct wrap satisfies
1014 /// w1 <= w2 → rows(w1) >= rows(w2).
1015 #[test]
1016 fn width_monotonicity() {
1017 let texts = [
1018 "",
1019 "short",
1020 "a b c d e f g h i j k l m n o",
1021 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
1022 "word00 word01 word02 word03 word04 word05 word06 word07",
1023 ];
1024 let gutter = 2usize;
1025 for text in &texts {
1026 let mut prev_rows: Option<u32> = None;
1027 // effective_width must be > gutter to leave any available
1028 // width; start well above.
1029 for w in [10usize, 15, 20, 30, 50, 80, 120, 200] {
1030 let rows = count_visual_rows_for_text(text, w, gutter, false);
1031 if let Some(prev) = prev_rows {
1032 assert!(
1033 rows <= prev,
1034 "width monotonicity violated: rows({} chars, w={}) = {} > rows at prev w = {}. \
1035 text={:?}",
1036 text.len(),
1037 w,
1038 rows,
1039 prev,
1040 text,
1041 );
1042 }
1043 prev_rows = Some(rows);
1044 }
1045 }
1046 }
1047
1048 /// No row count is ever zero — even pathologically narrow widths or
1049 /// unusual inputs return at least 1.
1050 #[test]
1051 fn row_count_is_always_at_least_one() {
1052 let cases = [
1053 ("", 80usize),
1054 ("x", 80),
1055 ("", 2), // near-minimum width
1056 ("abc", 3),
1057 (
1058 "a very long line with lots of words that will definitely wrap",
1059 20,
1060 ),
1061 ];
1062 for (text, w) in cases {
1063 assert!(
1064 count_visual_rows_for_text(text, w, 0, false) >= 1,
1065 "row count < 1 for text={:?}, width={}",
1066 text,
1067 w,
1068 );
1069 }
1070 }
1071
1072 /// Adding characters never *decreases* the row count at a fixed width.
1073 ///
1074 /// Subset-superset property: if `a` is a prefix of `b`, `rows(a) <=
1075 /// rows(b)`. A cache that returned a stale value for a shortened
1076 /// line would fail this.
1077 #[test]
1078 fn prefix_never_has_more_rows() {
1079 let base = "aaaaaaaaaa bbbbbbbbbb cccccccccc dddddddddd eeeeeeeeee";
1080 let width = 20usize;
1081 let gutter = 2usize;
1082 let mut prev_rows: u32 = 0;
1083 for len in (0..=base.len()).step_by(5) {
1084 let prefix = &base[..len];
1085 let rows = count_visual_rows_for_text(prefix, width, gutter, false);
1086 assert!(
1087 rows >= prev_rows,
1088 "prefix property violated: len={}, rows={}, prev_rows={}",
1089 len,
1090 rows,
1091 prev_rows,
1092 );
1093 prev_rows = rows;
1094 }
1095 }
1096
1097 /// Deterministic: same input → same output, always.
1098 #[test]
1099 fn count_is_deterministic() {
1100 let text = "word00 word01 word02 word03 word04 word05 word06 word07 word08 word09";
1101 let w = 30usize;
1102 let g = 4usize;
1103 let r1 = count_visual_rows_for_text(text, w, g, false);
1104 for _ in 0..16 {
1105 let r = count_visual_rows_for_text(text, w, g, false);
1106 assert_eq!(r, r1, "non-deterministic row count");
1107 }
1108 }
1109
1110 /// A soft-break offset that lands inside a multi-byte char must be
1111 /// skipped like the other malformed break positions, not panic the
1112 /// slice. This happens when the break list is stale: the plugin
1113 /// recomputes breaks asynchronously, so an insert earlier in the
1114 /// line shifts the text under positions computed against the old
1115 /// content.
1116 #[test]
1117 fn stale_soft_break_inside_multibyte_char_does_not_panic() {
1118 // "decorative wave " is 16 bytes, so '—' occupies bytes
1119 // 16..19 of the line; a break at rel=17 is mid-char — exactly
1120 // what a one-byte-stale break list yields after inserting one
1121 // byte before the break.
1122 let text = "decorative wave \u{2014} a rising sea of glyphs";
1123 let line_start = 1043usize;
1124 let breaks = [(line_start + 17, 0u16)];
1125 let rows =
1126 count_visual_rows_for_text_with_soft_breaks(text, line_start, &breaks, 80, 6, false);
1127 assert!(rows >= 1);
1128 }
1129
1130 /// Property test for `count_visual_rows_for_text_with_soft_breaks`:
1131 /// deterministic fuzz over multi-byte / grapheme-cluster texts,
1132 /// geometries, and adversarial break lists (mid-char, mid-cluster,
1133 /// out-of-range, unsorted, duplicates). Deterministic LCG, so
1134 /// reproducible without a proptest dep.
1135 ///
1136 /// Properties:
1137 /// 1. never panics, result >= 1, and is deterministic;
1138 /// 2. rows are bounded above by chars + indents + breaks + 1
1139 /// (each counted row contains at least one char);
1140 /// 3. with a *well-formed* break list (sorted, in-range, on char
1141 /// boundaries), rows >= breaks + 1 — every segment occupies
1142 /// at least one row.
1143 #[test]
1144 fn soft_break_row_count_properties() {
1145 let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
1146 let mut next = move || {
1147 state = state
1148 .wrapping_mul(6364136223846793005)
1149 .wrapping_add(1442695040888963407);
1150 (state >> 33) as usize
1151 };
1152 // Building blocks: wrap-relevant ASCII, 1..4-byte scalars, and
1153 // multi-scalar grapheme clusters (combining marks, ZWJ
1154 // sequences, regional-indicator flags, variation selectors),
1155 // plus zero-width and RTL scalars. Adjacent picks can also
1156 // merge into larger clusters (e.g. emoji + skin tone).
1157 let palette: &[&str] = &[
1158 "a",
1159 "b",
1160 "c",
1161 " ",
1162 " ",
1163 "\t",
1164 "-",
1165 "\u{e9}", // é, 2-byte
1166 "\u{5d0}", // א, RTL Hebrew
1167 "\u{2014}", // —, 3-byte
1168 "\u{4e16}", // 世, wide CJK
1169 "\u{1f680}", // 🚀, 4-byte
1170 "e\u{301}", // e + combining acute
1171 "\u{928}\u{93f}", // Devanagari नि
1172 "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}", // ZWJ family
1173 "\u{1f1ee}\u{1f1f1}", // regional-indicator flag
1174 "\u{1f44d}\u{1f3fb}", // thumbs-up + skin tone
1175 "\u{2764}\u{fe0f}", // heart + VS16
1176 "\u{200d}", // lone zero-width joiner
1177 "\u{200b}", // zero-width space
1178 ];
1179
1180 for _iter in 0..2000 {
1181 let n_pieces = next() % 60;
1182 let text: String = (0..n_pieces)
1183 .map(|_| palette[next() % palette.len()])
1184 .collect();
1185 let line_start = next() % 5000;
1186 let width = 2 + next() % 119;
1187 let gutter = next() % 11;
1188 let hanging = next() % 2 == 0;
1189
1190 // Adversarial breaks: positions roam past both ends of the
1191 // line, indents are occasionally huge, order is unsorted.
1192 let n_breaks = next() % 8;
1193 let breaks: Vec<(usize, u16)> = (0..n_breaks)
1194 .map(|_| {
1195 let pos = (line_start + next() % (text.len() + 10)).saturating_sub(5);
1196 let indent = if next() % 10 == 0 {
1197 500
1198 } else {
1199 (next() % 12) as u16
1200 };
1201 (pos, indent)
1202 })
1203 .collect();
1204
1205 let rows = count_visual_rows_for_text_with_soft_breaks(
1206 &text, line_start, &breaks, width, gutter, hanging,
1207 );
1208 let again = count_visual_rows_for_text_with_soft_breaks(
1209 &text, line_start, &breaks, width, gutter, hanging,
1210 );
1211 assert_eq!(
1212 rows, again,
1213 "non-deterministic: text={text:?} breaks={breaks:?}"
1214 );
1215 assert!(rows >= 1, "zero rows: text={text:?} breaks={breaks:?}");
1216 let indent_sum: usize = breaks.iter().map(|&(_, i)| i as usize).sum();
1217 let bound = (text.chars().count() + indent_sum + breaks.len() + 1) as u32;
1218 assert!(
1219 rows <= bound,
1220 "rows={rows} > bound={bound}: text={text:?} breaks={breaks:?} \
1221 width={width} gutter={gutter} hanging={hanging}",
1222 );
1223
1224 // Well-formed list: distinct sorted char boundaries inside
1225 // the line. Lower bound: each segment is >= 1 row.
1226 let mut good: Vec<(usize, u16)> = Vec::new();
1227 for (b, _) in text.char_indices() {
1228 if b > 0 && next() % 4 == 0 {
1229 good.push((line_start + b, (next() % 8) as u16));
1230 }
1231 }
1232 good.sort_unstable();
1233 let rows = count_visual_rows_for_text_with_soft_breaks(
1234 &text, line_start, &good, width, gutter, hanging,
1235 );
1236 assert!(
1237 rows as usize >= good.len() + 1,
1238 "rows={rows} < segments={}: text={text:?} breaks={good:?} \
1239 width={width} gutter={gutter} hanging={hanging}",
1240 good.len() + 1,
1241 );
1242 }
1243 }
1244
1245 // -------------------------------------------------------------------
1246 // Layer 3 (partial): shadow-model property test.
1247 //
1248 // A "shadow" cache always recomputes from the pure `count_visual_rows
1249 // _for_text` function; the "real" cache uses `LineWrapCache`. A
1250 // mutation-free op stream with random (text, width) probes must
1251 // always agree between real and shadow — otherwise the cache is
1252 // returning a value inconsistent with fresh computation. Covers the
1253 // insert / hit / evict surfaces on the cache primitive without
1254 // running the full editor pipeline.
1255 //
1256 // Full plugin-state shadow (buffer edits, soft-break injection,
1257 // conceals, view-mode toggles) lives in an e2e-level test — this
1258 // layer is the pure-primitive check.
1259 // -------------------------------------------------------------------
1260
1261 #[test]
1262 fn shadow_agreement_pure_primitive() {
1263 // Deterministic "random" inputs from simple counters, so this is
1264 // reproducible without a proptest dep.
1265 let texts: Vec<String> = (0..30)
1266 .map(|i| {
1267 let n = (i * 7 + 3) % 120 + 5;
1268 let seed = [b'a', b'b', b'c', b' ', b'd', b'e', b'f', b' ', b'1', b'2'];
1269 (0..n).map(|k| seed[k % seed.len()] as char).collect()
1270 })
1271 .collect();
1272 let widths: [usize; 5] = [12, 20, 42, 80, 120];
1273
1274 // Cache stores Vec<ViewLine>, so the shadow compares the LENGTH
1275 // (row count) the cache would expose with a fresh recompute.
1276 // The full-pipeline shadow (ViewLine coordinates agreeing with
1277 // the renderer) lives in e2e tests; this primitive-level shadow
1278 // checks that the FIFO / byte-budget machinery doesn't corrupt
1279 // stored values across inserts and evictions.
1280 //
1281 // Real cache values are built from `dummy_lines(shadow_count)`
1282 // so the cache value's length equals the shadow row count.
1283 let mut real = LineWrapCache::with_byte_budget(TIGHT);
1284 for step in 0..400usize {
1285 let t_idx = (step * 37 + 11) % texts.len();
1286 let w_idx = (step * 5 + 3) % widths.len();
1287 let text = &texts[t_idx];
1288 let width = widths[w_idx];
1289
1290 let shadow_rows = count_visual_rows_for_text(text, width, 2, false);
1291
1292 let key = LineWrapKey {
1293 pipeline_inputs_version: 0,
1294 view_mode: CacheViewMode::Source,
1295 line_start: t_idx, // stand-in for byte; distinct per text
1296 effective_width: width as u32,
1297 gutter_width: 2,
1298 wrap_column: None,
1299 hanging_indent: false,
1300 line_wrap_enabled: true,
1301 };
1302 let real_val = real.get_or_insert_with(key, || dummy_lines(shadow_rows));
1303 assert_eq!(
1304 real_val.len() as u32,
1305 shadow_rows,
1306 "shadow disagreement at step {step}: text_idx={t_idx}, width={width}, \
1307 real={}, shadow={shadow_rows}",
1308 real_val.len(),
1309 );
1310 assert!(
1311 real.current_bytes() <= real.byte_budget(),
1312 "cache exceeded byte budget"
1313 );
1314 }
1315 }
1316
1317 /// Version-bump invalidation: entries stored under version V are
1318 /// NEVER returned when a lookup is built at version V+1. The
1319 /// old entry sits in memory until FIFO evicts it, but no caller
1320 /// should ever get the stale value.
1321 #[test]
1322 fn version_bump_makes_old_entry_unreachable() {
1323 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
1324 let key_v0 = LineWrapKey {
1325 pipeline_inputs_version: 100,
1326 view_mode: CacheViewMode::Source,
1327 line_start: 42,
1328 effective_width: 80,
1329 gutter_width: 6,
1330 wrap_column: None,
1331 hanging_indent: false,
1332 line_wrap_enabled: true,
1333 };
1334 cache.get_or_insert_with(key_v0, || dummy_lines(5));
1335 assert_eq!(cache.get(&key_v0).map(|v| v.len()), Some(5));
1336
1337 let key_v1 = LineWrapKey {
1338 pipeline_inputs_version: 101,
1339 ..key_v0
1340 };
1341 assert!(
1342 cache.get(&key_v1).is_none(),
1343 "v1 lookup must miss even though v0 entry is still present"
1344 );
1345
1346 // Miss path stores under v1; v0 remains in the map, untouched.
1347 let mut miss_called = 0;
1348 let v = cache.get_or_insert_with(key_v1, || {
1349 miss_called += 1;
1350 dummy_lines(7)
1351 });
1352 assert_eq!(v.len(), 7);
1353 assert_eq!(miss_called, 1);
1354 assert_eq!(cache.get(&key_v1).map(|v| v.len()), Some(7));
1355 assert_eq!(
1356 cache.get(&key_v0).map(|v| v.len()),
1357 Some(5),
1358 "v0 entry preserved until evicted"
1359 );
1360 }
1361
1362 /// All geometry dimensions in the key are distinct — changing any one
1363 /// produces a miss.
1364 #[test]
1365 fn every_key_dimension_separates_entries() {
1366 let base = LineWrapKey {
1367 pipeline_inputs_version: 1,
1368 view_mode: CacheViewMode::Source,
1369 line_start: 10,
1370 effective_width: 80,
1371 gutter_width: 6,
1372 wrap_column: None,
1373 hanging_indent: false,
1374 line_wrap_enabled: true,
1375 };
1376
1377 // Vary each field in turn; each variation must be a distinct key.
1378 let variations: [LineWrapKey; 8] = [
1379 LineWrapKey {
1380 pipeline_inputs_version: 2,
1381 ..base
1382 },
1383 LineWrapKey {
1384 view_mode: CacheViewMode::Compose,
1385 ..base
1386 },
1387 LineWrapKey {
1388 line_start: 11,
1389 ..base
1390 },
1391 LineWrapKey {
1392 effective_width: 81,
1393 ..base
1394 },
1395 LineWrapKey {
1396 gutter_width: 7,
1397 ..base
1398 },
1399 LineWrapKey {
1400 wrap_column: Some(70),
1401 ..base
1402 },
1403 LineWrapKey {
1404 hanging_indent: true,
1405 ..base
1406 },
1407 LineWrapKey {
1408 line_wrap_enabled: false,
1409 ..base
1410 },
1411 ];
1412
1413 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
1414 cache.get_or_insert_with(base, || dummy_lines(1));
1415 for (i, v) in variations.iter().enumerate() {
1416 assert_ne!(*v, base, "variation {i} shouldn't equal base");
1417 assert!(
1418 cache.get(v).is_none(),
1419 "variation {i} unexpectedly hit base entry"
1420 );
1421 cache.get_or_insert_with(*v, || dummy_lines(2 + i as u32));
1422 }
1423 // Base entry is still reachable.
1424 assert_eq!(cache.get(&base).map(|v| v.len()), Some(1));
1425 // Each variation stored its own value (distinguished by length).
1426 for (i, v) in variations.iter().enumerate() {
1427 assert_eq!(cache.get(v).map(|v| v.len()), Some(2 + i));
1428 }
1429 }
1430}