Skip to main content

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}