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,
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 });
311 }
312 lines
313}
314
315/// Look up a line's layout in the cache, running the mini-pipeline to
316/// fill on miss. The primary read-path entry point for consumers that
317/// need full `ViewLine` layout (not just row count).
318///
319/// Guarantees that the value returned matches what the renderer would
320/// produce for the same line under the same pipeline inputs: same
321/// function chain is called either way, so cache hit and miss are
322/// indistinguishable to the caller.
323pub fn layout_for_line(
324 state: &mut EditorState,
325 line_start: usize,
326 line_end: usize,
327 geom: &WrapGeometry,
328) -> Arc<Vec<ViewLine>> {
329 let version = pipeline_inputs_version(
330 state.buffer.version(),
331 state.soft_breaks.version(),
332 state.conceals.version(),
333 state.virtual_texts.version(),
334 );
335 let key = geom.key(line_start, version);
336 if let Some(cached) = state.line_wrap_cache.get(&key) {
337 return cached;
338 }
339 let layout = compute_line_layout(state, line_start, line_end, geom);
340 let arc = Arc::new(layout);
341 state.line_wrap_cache.put(key, arc.clone());
342 arc
343}
344
345/// Given a logical line's layout and a character position within the
346/// LOGICAL line (not the ViewLine), return `(segment_idx,
347/// col_in_segment)` — the index of the `ViewLine` the character falls
348/// into, and the visual column within that `ViewLine`.
349///
350/// Replaces `primitives::line_wrapping::char_position_to_segment` for
351/// callers that have a cached `Vec<ViewLine>`.
352///
353/// The trick: continuation `ViewLine`s can carry hanging-indent
354/// characters at their start whose `source_offset` is `None` (they
355/// don't correspond to any source byte). Those chars must NOT count
356/// toward the source-character position we're walking past. So we
357/// sum *source* characters per row (char_source_bytes entries that
358/// are `Some(_)`) to find the row containing `char_pos_in_line`, and
359/// within that row we locate the specific char whose source_offset
360/// matches.
361///
362/// If `layout` is empty, returns `(0, 0)`. If the position is past
363/// the end of the last row, returns the last row with the last
364/// visual column of that row.
365pub fn char_position_in_layout(layout: &[ViewLine], char_pos_in_line: usize) -> (usize, usize) {
366 if layout.is_empty() {
367 return (0, 0);
368 }
369 let mut source_chars_consumed = 0usize;
370 for (i, line) in layout.iter().enumerate() {
371 let source_chars_in_row = line
372 .char_source_bytes
373 .iter()
374 .filter(|b| b.is_some())
375 .count();
376 if char_pos_in_line < source_chars_consumed + source_chars_in_row {
377 // The target source-char is in this row. Find the
378 // `char_idx` whose position-among-source-chars equals
379 // the within-row offset, then convert to visual column.
380 let within_row = char_pos_in_line - source_chars_consumed;
381 let mut source_count = 0usize;
382 for (char_idx, byte) in line.char_source_bytes.iter().enumerate() {
383 if byte.is_some() {
384 if source_count == within_row {
385 return (i, line.visual_col_at_char(char_idx));
386 }
387 source_count += 1;
388 }
389 }
390 // Fallback: shouldn't happen given the length check above,
391 // but don't return garbage if it does.
392 return (i, line.visual_width().saturating_sub(1));
393 }
394 source_chars_consumed += source_chars_in_row;
395 }
396 // Past the end: return the last row's last visual column. (A
397 // cursor one past the last source char on the last row lands
398 // here.)
399 let last_idx = layout.len() - 1;
400 let last = &layout[last_idx];
401 let last_col = last.visual_width().saturating_sub(1);
402 (last_idx, last_col)
403}
404
405/// Geometry + view config inputs to the wrap pipeline that aren't carried
406/// by `EditorState`. Bundled so the plumbing through call sites doesn't
407/// grow a laundry list of parameters.
408#[derive(Debug, Clone, Copy)]
409pub struct WrapGeometry {
410 pub effective_width: usize,
411 pub gutter_width: usize,
412 pub hanging_indent: bool,
413 pub wrap_column: Option<u32>,
414 pub line_wrap_enabled: bool,
415 pub view_mode: CacheViewMode,
416}
417
418impl WrapGeometry {
419 /// Build a cache key for a logical line at `line_start` under these
420 /// geometry and pipeline-input versions.
421 pub fn key(&self, line_start: usize, pipeline_inputs_version: u64) -> LineWrapKey {
422 LineWrapKey {
423 pipeline_inputs_version,
424 view_mode: self.view_mode,
425 line_start,
426 effective_width: self.effective_width as u32,
427 gutter_width: self.gutter_width as u16,
428 wrap_column: self.wrap_column,
429 hanging_indent: self.hanging_indent,
430 line_wrap_enabled: self.line_wrap_enabled,
431 }
432 }
433}
434
435/// Run the same pipeline the renderer runs, scoped to exactly one
436/// logical line starting at `line_start`, and return the rendered
437/// [`ViewLine`]s for that line. Used by the cache miss handler.
438///
439/// When `geom.line_wrap_enabled` is false, returns a single
440/// placeholder `ViewLine` — an unwrapped line always occupies exactly
441/// one visual row. (Callers that only need a count can read
442/// `.len()`; callers that need coordinate mappings would not query
443/// this path with wrapping off.)
444///
445/// The four pipeline steps mirror `view_data::build_view_data`:
446/// 1. `build_base_tokens(top_byte=line_start, count=1)`
447/// 2. `apply_soft_breaks` (Compose mode, when any soft breaks overlap)
448/// 3. `apply_conceal_ranges` (Compose mode, when any conceals overlap)
449/// 4. `apply_wrapping_transform`
450/// followed by `ViewLineIterator::collect()` to materialise the
451/// `Vec<ViewLine>`.
452///
453/// The result is what the renderer would produce for this single
454/// logical line — the single source of truth the cache exists to
455/// share.
456pub fn compute_line_layout(
457 state: &mut EditorState,
458 line_start: usize,
459 line_end: usize,
460 geom: &WrapGeometry,
461) -> Vec<ViewLine> {
462 let is_binary = state.buffer.is_binary();
463 let line_ending = state.buffer.line_ending();
464 let estimated_line_length = state.buffer.estimated_line_length();
465 let tab_size = state.buffer_settings.tab_size;
466
467 // Step 1: build tokens for just this one logical line.
468 let mut tokens = build_base_tokens(
469 &mut state.buffer,
470 line_start,
471 estimated_line_length,
472 1, // just this one logical line
473 is_binary,
474 line_ending,
475 &[], // no fold skip ranges — folds affect what's rendered, not per-line wrap count
476 );
477
478 let is_compose = matches!(geom.view_mode, CacheViewMode::Compose);
479
480 // Step 2: soft breaks (Compose mode only; same gating as the renderer).
481 if is_compose && !state.soft_breaks.is_empty() {
482 let sb = state
483 .soft_breaks
484 .query_viewport(line_start, line_end, &state.marker_list);
485 if !sb.is_empty() {
486 tokens = apply_soft_breaks(tokens, &sb);
487 }
488 }
489
490 // Step 3: conceal ranges (Compose mode only).
491 if is_compose && !state.conceals.is_empty() {
492 let cr = state
493 .conceals
494 .query_viewport(line_start, line_end, &state.marker_list);
495 if !cr.is_empty() {
496 tokens = apply_conceal_ranges(tokens, &cr);
497 }
498 }
499
500 // Step 4: wrap (only when line-wrap is actually enabled). When
501 // disabled, pass tokens through unchanged; ViewLineIterator will
502 // still yield one ViewLine per Newline boundary.
503 if geom.line_wrap_enabled {
504 tokens = apply_wrapping_transform(
505 tokens,
506 geom.effective_width,
507 geom.gutter_width,
508 geom.hanging_indent,
509 );
510 }
511
512 // Materialise the ViewLines. `build_base_tokens` may emit tokens
513 // for more than one logical line; collect only the first logical
514 // line's ViewLines (those up to and including the first Newline).
515 let all_lines: Vec<ViewLine> =
516 ViewLineIterator::new(&tokens, is_binary, !is_binary, tab_size, false).collect();
517
518 // The `ViewLineIterator` produces one `ViewLine` per visual row.
519 // The Newline tokens inside split the stream at logical-line
520 // boundaries: every `ViewLine` after the first whose `line_start`
521 // is `AfterSourceNewline` begins a NEW logical line, which we
522 // don't want. Keep only rows up to (but not including) the first
523 // such transition.
524 let mut result = Vec::with_capacity(all_lines.len().min(8));
525 for (i, line) in all_lines.into_iter().enumerate() {
526 use crate::view::ui::view_pipeline::LineStart;
527 if i > 0 && matches!(line.line_start, LineStart::AfterSourceNewline) {
528 break;
529 }
530 result.push(line);
531 }
532 if result.is_empty() {
533 // Defensive: even a completely empty logical line corresponds
534 // to exactly one visual row. The iterator should always
535 // produce at least one, but be safe.
536 result.push(ViewLine {
537 text: String::new(),
538 source_start_byte: Some(line_start),
539 char_source_bytes: Vec::new(),
540 char_styles: Vec::new(),
541 char_visual_cols: Vec::new(),
542 visual_to_char: Vec::new(),
543 tab_starts: std::collections::HashSet::new(),
544 line_start: crate::view::ui::view_pipeline::LineStart::Beginning,
545 ends_with_newline: false,
546 });
547 }
548 result
549}
550
551/// Row count only. Thin wrapper over [`compute_line_layout`] for
552/// callers that need just the visual-row count — scroll math,
553/// thumb-size math. Prefer calling through the cache
554/// (`get_or_insert_with(key, || compute_line_layout(...)).len()`).
555pub fn count_visual_rows_via_pipeline(
556 state: &mut EditorState,
557 line_start: usize,
558 line_end: usize,
559 geom: &WrapGeometry,
560) -> u32 {
561 compute_line_layout(state, line_start, line_end, geom).len() as u32
562}
563
564/// Combined version of all pipeline inputs on the given state. Fold into
565/// a `LineWrapKey` to make stale entries unreachable on any mutation.
566#[inline]
567pub fn state_pipeline_inputs_version(state: &EditorState) -> u64 {
568 pipeline_inputs_version(
569 state.buffer.version(),
570 state.soft_breaks.version(),
571 state.conceals.version(),
572 state.virtual_texts.version(),
573 )
574}
575
576/// Build a placeholder `Vec<ViewLine>` of a given row count for cache
577/// consumers that only need `.len()` (e.g. scroll math's count-only
578/// queries, or the per-viewport row-count memoization). The returned
579/// `ViewLine`s have empty char/visual mappings — they carry no real
580/// layout information.
581///
582/// This exists because the cache is typed on `Vec<ViewLine>` so the
583/// cross-consumer path can share real layout, but some call sites
584/// don't yet have access to `EditorState` (needed by
585/// [`compute_line_layout`]). When those sites are migrated to take
586/// `&mut EditorState`, this helper can go away.
587pub fn placeholder_layout_for_row_count(n: u32) -> Vec<ViewLine> {
588 use crate::view::ui::view_pipeline::LineStart;
589 (0..n)
590 .map(|_| ViewLine {
591 text: String::new(),
592 source_start_byte: None,
593 char_source_bytes: Vec::new(),
594 char_styles: Vec::new(),
595 char_visual_cols: Vec::new(),
596 visual_to_char: Vec::new(),
597 tab_starts: std::collections::HashSet::new(),
598 line_start: LineStart::Beginning,
599 ends_with_newline: false,
600 })
601 .collect()
602}
603
604/// Count visual rows for a single line's text after applying the
605/// plugin's soft breaks AND the renderer's word-wrap. Mirrors the
606/// renderer's full pipeline (`apply_soft_breaks` → `apply_wrapping_transform`)
607/// so the scroll math agrees row-for-row with the rendered output even
608/// when the plugin has injected breaks at narrower-than-viewport
609/// widths (e.g. markdown_compose's per-paragraph wrap).
610///
611/// `soft_breaks_in_line` is the slice of `(byte_position, indent)` pairs
612/// for breaks falling **inside** `[line_start, line_start + line_text.len())`.
613/// Callers should pre-filter from the buffer-wide list.
614///
615/// When `soft_breaks_in_line` is empty this is a thin wrapper over
616/// [`count_visual_rows_for_text`].
617pub fn count_visual_rows_for_text_with_soft_breaks(
618 line_text: &str,
619 line_start: usize,
620 soft_breaks_in_line: &[(usize, u16)],
621 effective_width: usize,
622 gutter_width: usize,
623 hanging_indent: bool,
624) -> u32 {
625 if soft_breaks_in_line.is_empty() {
626 return count_visual_rows_for_text(
627 line_text,
628 effective_width,
629 gutter_width,
630 hanging_indent,
631 );
632 }
633
634 let mut total: u32 = 0;
635 let mut prev_end: usize = 0; // byte offset within `line_text`
636 let mut prev_indent: u16 = 0;
637
638 for &(pos, indent) in soft_breaks_in_line {
639 // Defensive: callers pre-filter, but ignore anything out of
640 // range so a stale break list can't OOB-slice the line.
641 if pos < line_start {
642 continue;
643 }
644 let rel = pos - line_start;
645 if rel >= line_text.len() {
646 continue;
647 }
648 if rel < prev_end {
649 // Break list is sorted; this would only fire on a
650 // duplicate or a not-byte-aligned offset. Skip rather
651 // than panic.
652 continue;
653 }
654 let segment = &line_text[prev_end..rel];
655 total = total.saturating_add(count_segment_rows_with_indent(
656 segment,
657 prev_indent,
658 effective_width,
659 gutter_width,
660 hanging_indent,
661 ));
662 // The renderer's `apply_soft_breaks` consumes the Space token
663 // *at* the break position when one is present (see
664 // transforms.rs::apply_soft_breaks). Skip exactly one
665 // character at `rel` to mirror that — UTF-8 safe.
666 let consumed = line_text[rel..]
667 .chars()
668 .next()
669 .map(|c| c.len_utf8())
670 .unwrap_or(0);
671 prev_end = (rel + consumed).min(line_text.len());
672 prev_indent = indent;
673 }
674 let segment = &line_text[prev_end..];
675 total = total.saturating_add(count_segment_rows_with_indent(
676 segment,
677 prev_indent,
678 effective_width,
679 gutter_width,
680 hanging_indent,
681 ));
682 total.max(1)
683}
684
685/// Helper for [`count_visual_rows_for_text_with_soft_breaks`]:
686/// row count for one inter-break segment with `leading_indent`
687/// columns reserved at the front. An empty segment still occupies
688/// one visual row (matches the renderer, which emits a trailing
689/// `Break` for the broken position).
690fn count_segment_rows_with_indent(
691 segment: &str,
692 leading_indent: u16,
693 effective_width: usize,
694 gutter_width: usize,
695 hanging_indent: bool,
696) -> u32 {
697 if segment.is_empty() && leading_indent == 0 {
698 return 1;
699 }
700 if leading_indent == 0 {
701 return count_visual_rows_for_text(segment, effective_width, gutter_width, hanging_indent);
702 }
703 // Prepend the indent columns; this lets the renderer's word-wrap
704 // see the same `current_line_width` it would after
705 // `apply_soft_breaks` injected indent Spaces.
706 let mut prefixed = String::with_capacity(leading_indent as usize + segment.len());
707 for _ in 0..leading_indent {
708 prefixed.push(' ');
709 }
710 prefixed.push_str(segment);
711 count_visual_rows_for_text(&prefixed, effective_width, gutter_width, hanging_indent)
712}
713
714/// Count visual rows for a single line's text under the renderer's
715/// wrap algorithm. Pure function of (text, geometry).
716///
717/// Behaves exactly like the renderer's per-logical-line wrap count:
718/// runs `apply_wrapping_transform` on a single-`Text`-token input and
719/// tallies non-empty rows. A trailing `Break` emitted when the last
720/// chunk exactly fills the effective width is followed by nothing
721/// meaningful and does not count as a row.
722pub fn count_visual_rows_for_text(
723 line_text: &str,
724 effective_width: usize,
725 gutter_width: usize,
726 hanging_indent: bool,
727) -> u32 {
728 use crate::view::ui::split_rendering::transforms::apply_wrapping_transform;
729 use fresh_core::api::ViewTokenWire;
730
731 let tokens = vec![ViewTokenWire {
732 source_offset: Some(0),
733 kind: ViewTokenWireKind::Text(line_text.to_string()),
734 style: None,
735 }];
736 let wrapped = apply_wrapping_transform(tokens, effective_width, gutter_width, hanging_indent);
737 let mut rows: u32 = 0;
738 let mut row_has_content = false;
739 for t in &wrapped {
740 match &t.kind {
741 ViewTokenWireKind::Newline => break,
742 ViewTokenWireKind::Break => {
743 if row_has_content {
744 rows += 1;
745 }
746 row_has_content = false;
747 }
748 ViewTokenWireKind::Text(s) => {
749 if !s.is_empty() {
750 row_has_content = true;
751 }
752 }
753 ViewTokenWireKind::Space | ViewTokenWireKind::BinaryByte(_) => {
754 row_has_content = true;
755 }
756 }
757 }
758 if row_has_content {
759 rows += 1;
760 }
761 rows.max(1)
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::view::ui::view_pipeline::LineStart;
768
769 fn key(line_start: usize, version: u64) -> LineWrapKey {
770 LineWrapKey {
771 pipeline_inputs_version: version,
772 view_mode: CacheViewMode::Source,
773 line_start,
774 effective_width: 80,
775 gutter_width: 6,
776 wrap_column: None,
777 hanging_indent: false,
778 line_wrap_enabled: true,
779 }
780 }
781
782 /// Build a dummy `Vec<ViewLine>` of length `n` for primitive tests
783 /// that only care about how the cache stores / evicts values, not
784 /// about the actual pipeline output. Each `ViewLine` is empty
785 /// apart from its row identity.
786 fn dummy_lines(n: u32) -> Vec<ViewLine> {
787 (0..n)
788 .map(|_| ViewLine {
789 text: String::new(),
790 source_start_byte: Some(0),
791 char_source_bytes: Vec::new(),
792 char_styles: Vec::new(),
793 char_visual_cols: Vec::new(),
794 visual_to_char: Vec::new(),
795 tab_starts: std::collections::HashSet::new(),
796 line_start: LineStart::Beginning,
797 ends_with_newline: false,
798 })
799 .collect()
800 }
801
802 /// Roomy byte budget for tests that shouldn't evict.
803 const ROOMY: usize = 1024 * 1024;
804 /// Tight byte budget that evicts after a handful of empty lines.
805 /// Each empty `ViewLine` is ~96 bytes plus 48 Vec/Arc overhead, so
806 /// this budget holds roughly 3 entries.
807 const TIGHT: usize = 500;
808
809 #[test]
810 fn empty_cache_is_empty() {
811 let cache = LineWrapCache::default();
812 assert!(cache.is_empty());
813 assert_eq!(cache.len(), 0);
814 assert_eq!(cache.current_bytes(), 0);
815 }
816
817 #[test]
818 fn get_or_insert_caches_on_miss() {
819 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
820 let mut compute_calls = 0;
821 let v = cache.get_or_insert_with(key(100, 1), || {
822 compute_calls += 1;
823 dummy_lines(7)
824 });
825 assert_eq!(v.len(), 7);
826 assert_eq!(compute_calls, 1);
827 assert_eq!(cache.len(), 1);
828 }
829
830 #[test]
831 fn repeat_lookup_is_a_hit() {
832 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
833 let mut compute_calls = 0;
834 cache.get_or_insert_with(key(100, 1), || {
835 compute_calls += 1;
836 dummy_lines(7)
837 });
838 let v = cache.get_or_insert_with(key(100, 1), || {
839 compute_calls += 1;
840 dummy_lines(99) // wrong value, should not be invoked
841 });
842 assert_eq!(v.len(), 7);
843 assert_eq!(compute_calls, 1, "second lookup should be a hit");
844 }
845
846 #[test]
847 fn different_versions_are_separate_entries() {
848 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
849 cache.get_or_insert_with(key(100, 1), || dummy_lines(3));
850 cache.get_or_insert_with(key(100, 2), || dummy_lines(5));
851 assert_eq!(cache.get(&key(100, 1)).map(|v| v.len()), Some(3));
852 assert_eq!(cache.get(&key(100, 2)).map(|v| v.len()), Some(5));
853 assert_eq!(cache.len(), 2);
854 }
855
856 #[test]
857 fn evicts_oldest_when_byte_budget_reached() {
858 let mut cache = LineWrapCache::with_byte_budget(TIGHT);
859 cache.get_or_insert_with(key(100, 1), || dummy_lines(1));
860 cache.get_or_insert_with(key(200, 1), || dummy_lines(1));
861 cache.get_or_insert_with(key(300, 1), || dummy_lines(1));
862 // Adding a fourth tiny entry should evict at least the oldest
863 // (line_start=100) to stay within the budget.
864 cache.get_or_insert_with(key(400, 1), || dummy_lines(1));
865 assert!(cache.current_bytes() <= TIGHT);
866 assert_eq!(cache.get(&key(100, 1)).is_none(), true, "oldest evicted");
867 // Later entries still reachable.
868 assert!(cache.get(&key(400, 1)).is_some());
869 }
870
871 #[test]
872 fn structural_invariant_holds_under_many_inserts() {
873 let mut cache = LineWrapCache::with_byte_budget(TIGHT);
874 for i in 0..200u64 {
875 cache.get_or_insert_with(key(i as usize, i), || dummy_lines(1));
876 assert_eq!(cache.len(), cache.map.len());
877 assert_eq!(cache.len(), cache.order.len());
878 assert_eq!(cache.current_bytes <= cache.byte_budget, true);
879 }
880 }
881
882 #[test]
883 fn put_overwrites_existing_value_without_reordering() {
884 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
885 cache.get_or_insert_with(key(100, 1), || dummy_lines(1));
886 cache.get_or_insert_with(key(200, 1), || dummy_lines(1));
887 cache.get_or_insert_with(key(300, 1), || dummy_lines(1));
888 // Overwrite middle with a different-sized value.
889 cache.put(key(200, 1), Arc::new(dummy_lines(42)));
890 assert_eq!(cache.get(&key(200, 1)).map(|v| v.len()), Some(42));
891 // key=100 is still the oldest in the FIFO.
892 cache.get_or_insert_with(key(400, 1), || dummy_lines(1));
893 // With ROOMY budget nothing's evicted yet; all present.
894 for k in [100usize, 200, 300, 400] {
895 assert!(cache.get(&key(k, 1)).is_some(), "k={k} should be present");
896 }
897 }
898
899 #[test]
900 fn clear_empties_cache() {
901 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
902 cache.get_or_insert_with(key(100, 1), || dummy_lines(1));
903 cache.get_or_insert_with(key(200, 1), || dummy_lines(1));
904 cache.clear();
905 assert!(cache.is_empty());
906 assert_eq!(cache.current_bytes(), 0);
907 assert!(cache.get(&key(100, 1)).is_none());
908 }
909
910 #[test]
911 fn pipeline_inputs_version_changes_when_any_source_changes() {
912 let a = pipeline_inputs_version(100, 5, 3, 7);
913 assert_ne!(
914 a,
915 pipeline_inputs_version(101, 5, 3, 7),
916 "buffer bump changes version"
917 );
918 assert_ne!(
919 a,
920 pipeline_inputs_version(100, 6, 3, 7),
921 "soft-break bump changes version"
922 );
923 assert_ne!(
924 a,
925 pipeline_inputs_version(100, 5, 4, 7),
926 "conceal bump changes version"
927 );
928 assert_ne!(
929 a,
930 pipeline_inputs_version(100, 5, 3, 8),
931 "virtual-text bump changes version"
932 );
933 }
934
935 #[test]
936 #[should_panic]
937 fn zero_byte_budget_rejected() {
938 LineWrapCache::with_byte_budget(0);
939 }
940
941 /// Even if a single new entry's estimated size exceeds the budget,
942 /// the cache accepts it rather than silently dropping data the
943 /// caller just paid to compute. Later inserts will still evict it
944 /// like any other FIFO entry.
945 #[test]
946 fn oversize_entry_is_accepted_then_agable() {
947 let mut cache = LineWrapCache::with_byte_budget(TIGHT);
948 // dummy_lines(50) is ~7 KB per line × 50 = ~350 KB... no, empty
949 // ViewLines are ~96 bytes each, so 50 × 96 ≈ 5 KB. That
950 // exceeds TIGHT (500 bytes).
951 cache.get_or_insert_with(key(1, 1), || dummy_lines(50));
952 assert!(cache.get(&key(1, 1)).is_some());
953 // Inserting a second entry evicts the oversize one.
954 cache.get_or_insert_with(key(2, 1), || dummy_lines(1));
955 assert!(cache.get(&key(1, 1)).is_none());
956 assert!(cache.get(&key(2, 1)).is_some());
957 }
958
959 // -------------------------------------------------------------------
960 // Layer 4: wrap-function invariants.
961 //
962 // These hold for any correct wrap regardless of cache state. A cache
963 // bug that corrupts a stored value would eventually violate one of
964 // them via the cache-backed path (e.g. width-monotonicity).
965 // -------------------------------------------------------------------
966
967 /// An empty line wraps to exactly one visual row.
968 #[test]
969 fn empty_line_is_one_row() {
970 for width in [5usize, 10, 42, 80, 120] {
971 assert_eq!(count_visual_rows_for_text("", width, 0, false), 1);
972 assert_eq!(count_visual_rows_for_text("", width, 6, false), 1);
973 }
974 }
975
976 /// A line whose visual width fits inside the available width wraps to
977 /// exactly one row. Tests a few short ASCII strings at a few widths.
978 #[test]
979 fn line_that_fits_is_one_row() {
980 // "hello world" = 11 chars; at effective_width=80, gutter=6 →
981 // available width = 74 > 11, must be 1 row.
982 for text in ["hello", "hello world", "a b c d"] {
983 assert_eq!(count_visual_rows_for_text(text, 80, 6, false), 1);
984 }
985 }
986
987 /// Width monotonicity: widening `effective_width` never *increases*
988 /// the row count.
989 ///
990 /// For a fixed text, any correct wrap satisfies
991 /// w1 <= w2 → rows(w1) >= rows(w2).
992 #[test]
993 fn width_monotonicity() {
994 let texts = [
995 "",
996 "short",
997 "a b c d e f g h i j k l m n o",
998 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
999 "word00 word01 word02 word03 word04 word05 word06 word07",
1000 ];
1001 let gutter = 2usize;
1002 for text in &texts {
1003 let mut prev_rows: Option<u32> = None;
1004 // effective_width must be > gutter to leave any available
1005 // width; start well above.
1006 for w in [10usize, 15, 20, 30, 50, 80, 120, 200] {
1007 let rows = count_visual_rows_for_text(text, w, gutter, false);
1008 if let Some(prev) = prev_rows {
1009 assert!(
1010 rows <= prev,
1011 "width monotonicity violated: rows({} chars, w={}) = {} > rows at prev w = {}. \
1012 text={:?}",
1013 text.len(),
1014 w,
1015 rows,
1016 prev,
1017 text,
1018 );
1019 }
1020 prev_rows = Some(rows);
1021 }
1022 }
1023 }
1024
1025 /// No row count is ever zero — even pathologically narrow widths or
1026 /// unusual inputs return at least 1.
1027 #[test]
1028 fn row_count_is_always_at_least_one() {
1029 let cases = [
1030 ("", 80usize),
1031 ("x", 80),
1032 ("", 2), // near-minimum width
1033 ("abc", 3),
1034 (
1035 "a very long line with lots of words that will definitely wrap",
1036 20,
1037 ),
1038 ];
1039 for (text, w) in cases {
1040 assert!(
1041 count_visual_rows_for_text(text, w, 0, false) >= 1,
1042 "row count < 1 for text={:?}, width={}",
1043 text,
1044 w,
1045 );
1046 }
1047 }
1048
1049 /// Adding characters never *decreases* the row count at a fixed width.
1050 ///
1051 /// Subset-superset property: if `a` is a prefix of `b`, `rows(a) <=
1052 /// rows(b)`. A cache that returned a stale value for a shortened
1053 /// line would fail this.
1054 #[test]
1055 fn prefix_never_has_more_rows() {
1056 let base = "aaaaaaaaaa bbbbbbbbbb cccccccccc dddddddddd eeeeeeeeee";
1057 let width = 20usize;
1058 let gutter = 2usize;
1059 let mut prev_rows: u32 = 0;
1060 for len in (0..=base.len()).step_by(5) {
1061 let prefix = &base[..len];
1062 let rows = count_visual_rows_for_text(prefix, width, gutter, false);
1063 assert!(
1064 rows >= prev_rows,
1065 "prefix property violated: len={}, rows={}, prev_rows={}",
1066 len,
1067 rows,
1068 prev_rows,
1069 );
1070 prev_rows = rows;
1071 }
1072 }
1073
1074 /// Deterministic: same input → same output, always.
1075 #[test]
1076 fn count_is_deterministic() {
1077 let text = "word00 word01 word02 word03 word04 word05 word06 word07 word08 word09";
1078 let w = 30usize;
1079 let g = 4usize;
1080 let r1 = count_visual_rows_for_text(text, w, g, false);
1081 for _ in 0..16 {
1082 let r = count_visual_rows_for_text(text, w, g, false);
1083 assert_eq!(r, r1, "non-deterministic row count");
1084 }
1085 }
1086
1087 // -------------------------------------------------------------------
1088 // Layer 3 (partial): shadow-model property test.
1089 //
1090 // A "shadow" cache always recomputes from the pure `count_visual_rows
1091 // _for_text` function; the "real" cache uses `LineWrapCache`. A
1092 // mutation-free op stream with random (text, width) probes must
1093 // always agree between real and shadow — otherwise the cache is
1094 // returning a value inconsistent with fresh computation. Covers the
1095 // insert / hit / evict surfaces on the cache primitive without
1096 // running the full editor pipeline.
1097 //
1098 // Full plugin-state shadow (buffer edits, soft-break injection,
1099 // conceals, view-mode toggles) lives in an e2e-level test — this
1100 // layer is the pure-primitive check.
1101 // -------------------------------------------------------------------
1102
1103 #[test]
1104 fn shadow_agreement_pure_primitive() {
1105 // Deterministic "random" inputs from simple counters, so this is
1106 // reproducible without a proptest dep.
1107 let texts: Vec<String> = (0..30)
1108 .map(|i| {
1109 let n = (i * 7 + 3) % 120 + 5;
1110 let seed = [b'a', b'b', b'c', b' ', b'd', b'e', b'f', b' ', b'1', b'2'];
1111 (0..n).map(|k| seed[k % seed.len()] as char).collect()
1112 })
1113 .collect();
1114 let widths: [usize; 5] = [12, 20, 42, 80, 120];
1115
1116 // Cache stores Vec<ViewLine>, so the shadow compares the LENGTH
1117 // (row count) the cache would expose with a fresh recompute.
1118 // The full-pipeline shadow (ViewLine coordinates agreeing with
1119 // the renderer) lives in e2e tests; this primitive-level shadow
1120 // checks that the FIFO / byte-budget machinery doesn't corrupt
1121 // stored values across inserts and evictions.
1122 //
1123 // Real cache values are built from `dummy_lines(shadow_count)`
1124 // so the cache value's length equals the shadow row count.
1125 let mut real = LineWrapCache::with_byte_budget(TIGHT);
1126 for step in 0..400usize {
1127 let t_idx = (step * 37 + 11) % texts.len();
1128 let w_idx = (step * 5 + 3) % widths.len();
1129 let text = &texts[t_idx];
1130 let width = widths[w_idx];
1131
1132 let shadow_rows = count_visual_rows_for_text(text, width, 2, false);
1133
1134 let key = LineWrapKey {
1135 pipeline_inputs_version: 0,
1136 view_mode: CacheViewMode::Source,
1137 line_start: t_idx, // stand-in for byte; distinct per text
1138 effective_width: width as u32,
1139 gutter_width: 2,
1140 wrap_column: None,
1141 hanging_indent: false,
1142 line_wrap_enabled: true,
1143 };
1144 let real_val = real.get_or_insert_with(key, || dummy_lines(shadow_rows));
1145 assert_eq!(
1146 real_val.len() as u32,
1147 shadow_rows,
1148 "shadow disagreement at step {step}: text_idx={t_idx}, width={width}, \
1149 real={}, shadow={shadow_rows}",
1150 real_val.len(),
1151 );
1152 assert!(
1153 real.current_bytes() <= real.byte_budget(),
1154 "cache exceeded byte budget"
1155 );
1156 }
1157 }
1158
1159 /// Version-bump invalidation: entries stored under version V are
1160 /// NEVER returned when a lookup is built at version V+1. The
1161 /// old entry sits in memory until FIFO evicts it, but no caller
1162 /// should ever get the stale value.
1163 #[test]
1164 fn version_bump_makes_old_entry_unreachable() {
1165 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
1166 let key_v0 = LineWrapKey {
1167 pipeline_inputs_version: 100,
1168 view_mode: CacheViewMode::Source,
1169 line_start: 42,
1170 effective_width: 80,
1171 gutter_width: 6,
1172 wrap_column: None,
1173 hanging_indent: false,
1174 line_wrap_enabled: true,
1175 };
1176 cache.get_or_insert_with(key_v0, || dummy_lines(5));
1177 assert_eq!(cache.get(&key_v0).map(|v| v.len()), Some(5));
1178
1179 let key_v1 = LineWrapKey {
1180 pipeline_inputs_version: 101,
1181 ..key_v0
1182 };
1183 assert!(
1184 cache.get(&key_v1).is_none(),
1185 "v1 lookup must miss even though v0 entry is still present"
1186 );
1187
1188 // Miss path stores under v1; v0 remains in the map, untouched.
1189 let mut miss_called = 0;
1190 let v = cache.get_or_insert_with(key_v1, || {
1191 miss_called += 1;
1192 dummy_lines(7)
1193 });
1194 assert_eq!(v.len(), 7);
1195 assert_eq!(miss_called, 1);
1196 assert_eq!(cache.get(&key_v1).map(|v| v.len()), Some(7));
1197 assert_eq!(
1198 cache.get(&key_v0).map(|v| v.len()),
1199 Some(5),
1200 "v0 entry preserved until evicted"
1201 );
1202 }
1203
1204 /// All geometry dimensions in the key are distinct — changing any one
1205 /// produces a miss.
1206 #[test]
1207 fn every_key_dimension_separates_entries() {
1208 let base = LineWrapKey {
1209 pipeline_inputs_version: 1,
1210 view_mode: CacheViewMode::Source,
1211 line_start: 10,
1212 effective_width: 80,
1213 gutter_width: 6,
1214 wrap_column: None,
1215 hanging_indent: false,
1216 line_wrap_enabled: true,
1217 };
1218
1219 // Vary each field in turn; each variation must be a distinct key.
1220 let variations: [LineWrapKey; 8] = [
1221 LineWrapKey {
1222 pipeline_inputs_version: 2,
1223 ..base
1224 },
1225 LineWrapKey {
1226 view_mode: CacheViewMode::Compose,
1227 ..base
1228 },
1229 LineWrapKey {
1230 line_start: 11,
1231 ..base
1232 },
1233 LineWrapKey {
1234 effective_width: 81,
1235 ..base
1236 },
1237 LineWrapKey {
1238 gutter_width: 7,
1239 ..base
1240 },
1241 LineWrapKey {
1242 wrap_column: Some(70),
1243 ..base
1244 },
1245 LineWrapKey {
1246 hanging_indent: true,
1247 ..base
1248 },
1249 LineWrapKey {
1250 line_wrap_enabled: false,
1251 ..base
1252 },
1253 ];
1254
1255 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
1256 cache.get_or_insert_with(base, || dummy_lines(1));
1257 for (i, v) in variations.iter().enumerate() {
1258 assert_ne!(*v, base, "variation {i} shouldn't equal base");
1259 assert!(
1260 cache.get(v).is_none(),
1261 "variation {i} unexpectedly hit base entry"
1262 );
1263 cache.get_or_insert_with(*v, || dummy_lines(2 + i as u32));
1264 }
1265 // Base entry is still reachable.
1266 assert_eq!(cache.get(&base).map(|v| v.len()), Some(1));
1267 // Each variation stored its own value (distinguished by length).
1268 for (i, v) in variations.iter().enumerate() {
1269 assert_eq!(cache.get(v).map(|v| v.len()), Some(2 + i));
1270 }
1271 }
1272}