Skip to main content

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}