lattice_cells/version.rs
1//! Version vector for the cell matrix.
2//!
3//! Each axis tracks one source of cell-content invalidation. The
4//! cell-builder worker (S2, lives in `lattice-host`) compares the
5//! chunk's captured `MatrixVersion` against `RenderState`'s current
6//! version via [`MatrixVersion::differs_from`]; any field that
7//! changed → that chunk is stale and needs rebuild.
8//!
9//! ## Semantics: monotonic + hash-style fields coexist
10//!
11//! Some axes are monotonically-increasing counters (`text` is
12//! `document.text_version()`, bumped on every rope edit). Others
13//! are content hashes (`inlay_hints`, `folds`) — they don't
14//! compare with `<`/`>`, only with `==`/`!=`. The version vector
15//! treats every axis identically: "differs" is the only check
16//! that matters for the rebuild decision, and `differs_from` is
17//! correct for both kinds.
18//!
19//! See `docs/dev/architecture/cell-grid-renderer.md` § Invalidation
20//! for the complete trigger table.
21
22/// Per-source version stamps that drive matrix rebuild decisions.
23///
24/// Decoration kinds that *don't* change cell content (cursor,
25/// selection, hlsearch, diagnostics, doc highlights, search matches)
26/// deliberately don't appear here — they live in `OverlayState`
27/// (defined in `lattice-host` in S2) and are read by the paint loop
28/// each frame without triggering matrix work.
29///
30/// Fields are `u64` to match the source counters/hashes in
31/// `lattice-host` (`document.text_version()`,
32/// `compute_fold_hash`, `inlay_hints_version`, etc.) — no lossy
33/// cast at the boundary.
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
35pub struct MatrixVersion {
36 /// Source of truth: `document.text_version()`. Monotonic.
37 pub text: u64,
38 /// Source of truth: content stamp of the syntax span set
39 /// covering the buffer. Hash-style (not monotonic).
40 pub syntax: u64,
41 /// Source of truth: `inlay_hints_version(&inlays)`. Hash-style.
42 pub inlay_hints: u64,
43 /// Source of truth: `compute_fold_hash(&folds)`. Hash-style.
44 pub folds: u64,
45 /// Source of truth: theme palette revision. Monotonic in
46 /// practice (theme replacements are discrete events).
47 pub theme: u64,
48 /// 2026-05-27: `display.whitespace.*` snapshot hash. Bumps
49 /// when `display.show_whitespace` toggles or any of the
50 /// `display.whitespace.*` glyphs change. The cell-builder
51 /// substitutes whitespace bytes with marker glyphs + the
52 /// `WS_MARKER` flag at emission time, so the glyph ends up
53 /// in `Cell.ch` — folding the config into this axis
54 /// invalidates the cached matrix when the user re-configures.
55 pub whitespace: u64,
56 /// IG.2 (2026-08-16): `shiftwidth` + the `display.indent-guides.*`
57 /// snapshot hash. Indentation guides are built beside the display
58 /// matrix from the same snapshot, so they need an axis that makes
59 /// the worker rebuild when their inputs change.
60 ///
61 /// Deliberately NOT folded into [`Self::whitespace`], even though
62 /// both are display-config axes: renderers gate *painting* on the
63 /// whitespace axis (a mismatch drops the viewport to raw text for a
64 /// frame), and toggling guides must not cost a whole-viewport
65 /// fallback when the display text is not affected at all. This axis
66 /// gates the rebuild only — like `syntax`, `folds` and `theme`.
67 pub indent: u64,
68 /// H.3 (2026-08-29): the conceal inputs — the buffer language's
69 /// rule set plus whether the modal state reveals (H.4). Bumps when
70 /// either moves. See `docs/dev/architecture/conceal.md`.
71 ///
72 /// **Painting-class, like `whitespace` and unlike `indent`.** The
73 /// distinction is on `indent` above: conceal changes the display
74 /// *text*, so a renderer painting a matrix stamped with a stale
75 /// conceal axis would show the wrong characters, not merely a
76 /// missing decoration. A mismatch therefore drops the viewport to
77 /// raw text for one frame — which is the correct degradation, and
78 /// is the same frame the mode transition was going to repaint.
79 ///
80 /// **Constant for every buffer whose language declares no rules.**
81 /// That is what keeps `i` in a Rust file from costing a viewport
82 /// rebuild: the axis cannot move if there is nothing to conceal.
83 pub conceal: u64,
84}
85
86impl MatrixVersion {
87 /// All-zero version stamp. Equivalent to `Default::default()`.
88 pub const ZERO: Self = Self {
89 text: 0,
90 syntax: 0,
91 inlay_hints: 0,
92 folds: 0,
93 theme: 0,
94 whitespace: 0,
95 indent: 0,
96 conceal: 0,
97 };
98
99 /// `true` when any component differs from `other`. Used by the
100 /// cell-builder worker to decide if a cached chunk's version
101 /// is stale relative to the current RenderState version. Works
102 /// uniformly across monotonic and hash-style axes.
103 pub fn differs_from(&self, other: &Self) -> bool {
104 self != other
105 }
106
107 /// The names of the axes that differ, for logging.
108 ///
109 /// A stale-render report is always the same question — the matrix did not
110 /// rebuild, so WHICH invalidation axis failed to move? Answering it from
111 /// two opaque version structs means eyeballing seven `u64`s in a log line;
112 /// answering it from `["syntax"]` is immediate. Allocates, so it is only
113 /// ever called from a `debug!` argument that a disabled level never
114 /// evaluates.
115 pub fn differing_axes(&self, other: &Self) -> Vec<&'static str> {
116 let mut out = Vec::new();
117 for (name, a, b) in [
118 ("text", self.text, other.text),
119 ("syntax", self.syntax, other.syntax),
120 ("inlay_hints", self.inlay_hints, other.inlay_hints),
121 ("folds", self.folds, other.folds),
122 ("theme", self.theme, other.theme),
123 ("whitespace", self.whitespace, other.whitespace),
124 ] {
125 if a != b {
126 out.push(name);
127 }
128 }
129 out
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn default_is_all_zero() {
139 let v = MatrixVersion::default();
140 assert_eq!(v, MatrixVersion::ZERO);
141 }
142
143 #[test]
144 fn differs_from_detects_each_axis() {
145 let base = MatrixVersion::ZERO;
146 let bumps = [
147 MatrixVersion { text: 1, ..base },
148 MatrixVersion { syntax: 1, ..base },
149 MatrixVersion {
150 inlay_hints: 1,
151 ..base
152 },
153 MatrixVersion { folds: 1, ..base },
154 MatrixVersion { theme: 1, ..base },
155 MatrixVersion { indent: 1, ..base },
156 MatrixVersion { conceal: 1, ..base },
157 ];
158 for v in bumps {
159 assert!(v.differs_from(&base), "expected differs: {v:?}");
160 assert!(base.differs_from(&v), "differs is symmetric: {v:?}");
161 }
162 }
163
164 #[test]
165 fn differs_false_on_equal() {
166 let v = MatrixVersion {
167 text: 5,
168 syntax: 2,
169 inlay_hints: 7,
170 folds: 0,
171 theme: 1,
172 whitespace: 0,
173 indent: 0,
174 conceal: 0,
175 };
176 assert!(!v.differs_from(&v));
177 }
178
179 #[test]
180 fn differs_catches_whitespace_axis() {
181 let base = MatrixVersion::ZERO;
182 let v = MatrixVersion {
183 whitespace: 1,
184 ..base
185 };
186 assert!(v.differs_from(&base));
187 assert!(base.differs_from(&v));
188 }
189
190 /// Hash-style axes can DECREASE (a hash of fewer inlays might
191 /// be numerically smaller than the previous hash). `differs_from`
192 /// must catch that too, where the old `any_newer_than` did not.
193 #[test]
194 fn differs_catches_decreasing_hash_field() {
195 let a = MatrixVersion {
196 inlay_hints: 1_000_000,
197 ..MatrixVersion::ZERO
198 };
199 let b = MatrixVersion {
200 inlay_hints: 42,
201 ..MatrixVersion::ZERO
202 };
203 assert!(a.differs_from(&b));
204 assert!(b.differs_from(&a));
205 }
206}