Skip to main content

lattice_cells/
edit_delta.rs

1//! Cell-grid-specific edit delta — the input contract for
2//! incremental matrix rebuilds (S2.4.b).
3//!
4//! `EditDelta` is the substrate's compact view of a single applied
5//! edit. The protocol's `lattice_protocol::edit::EditDelta` carries
6//! tree-sitter-shaped byte + position fields for incremental
7//! reparse; for cell-matrix incremental rebuild the worker only
8//! needs line-granular shift info.
9//!
10//! The cell-builder uses this to:
11//! - Identify which chunks intersect the edit's affected range.
12//! - Shift downstream chunks (lines past the edit) by
13//!   `lines_added - lines_removed` without rebuilding their cells.
14
15/// One applied edit's line-shift impact on the cell matrix.
16///
17/// All fields are in *logical source lines* (pre-fold). The edit
18/// removed `lines_removed` lines starting at `start_line` (in the
19/// pre-edit document) and inserted `lines_added` lines starting at
20/// `start_line` (in the post-edit document).
21///
22/// `start_line == 0`, `lines_removed == 0`, `lines_added == 0`
23/// represents the no-op identity; constructors don't filter
24/// trivial deltas — the cell-builder's eligibility check does.
25///
26/// `Copy` so the publisher can stamp it onto each
27/// `CellsRenderState` without an `Arc` bump.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub struct EditDelta {
30    /// First source line the edit touches in the pre-edit document
31    /// (also: first line in the post-edit document where the
32    /// insert content begins). 0-based.
33    pub start_line: u32,
34    /// Number of *full* source lines removed. A single-line edit
35    /// that doesn't cross a newline yields `0`.
36    pub lines_removed: u32,
37    /// Number of *full* source lines added. A single-line insert
38    /// without a newline yields `0`.
39    pub lines_added: u32,
40    /// Whether the edit's OLD (pre-edit) range ends at column
41    /// (byte) `0` of its last line — i.e. a genuine line boundary
42    /// (BOL of the line one past [`Self::pre_edit_end_line`]) —
43    /// rather than partway into (or at the very end of) that line.
44    ///
45    /// This is the bit `lines_removed`/`lines_added` cannot carry:
46    /// they are a tree-sitter-shaped *row delta* (`old_end_position.line
47    /// - start_line`), which counts the same for "range ends at BOL of
48    /// the following line" (the line at `pre_edit_end_line` is
49    /// genuinely untouched) and "range ends at EOL of its own last
50    /// line" (that line's content WAS the edit). Only this flag
51    /// disambiguates the two. See [`Self::suffix_start_line`].
52    ///
53    /// Defaults to `false` (the conservative reading: treat the
54    /// boundary line as edited rather than risk reusing a wrongly
55    /// stale row) so construction sites that don't care about this
56    /// axis — most tests, most benches — can use
57    /// `..Default::default()` without silently opting into the
58    /// unsafe reuse.
59    pub old_end_at_bol: bool,
60}
61
62impl EditDelta {
63    /// Net line shift the edit causes for downstream lines.
64    /// `lines_added - lines_removed` as an `i32` — can be
65    /// negative (deletion shrinks the document).
66    pub fn net_delta(&self) -> i32 {
67        self.lines_added as i32 - self.lines_removed as i32
68    }
69
70    /// First source line past the edit's pre-edit affected range,
71    /// counting only *full* lines removed
72    /// (`start_line + lines_removed`).
73    ///
74    /// This does NOT by itself mean "lines `>=` this value were
75    /// untouched" — that claim only holds when
76    /// [`Self::old_end_at_bol`] is `true` (the old range ended
77    /// exactly at BOL of this line). When it's `false`, the edit's
78    /// old range ended partway into (or at the very end of) THIS
79    /// line, so this line's content was itself part of the edit.
80    /// Callers that need the actual safe shift boundary want
81    /// [`Self::suffix_start_line`], not this method directly.
82    pub fn pre_edit_end_line(&self) -> u32 {
83        self.start_line.saturating_add(self.lines_removed)
84    }
85
86    /// First source line past the edit's post-edit affected range
87    /// (exclusive). Lines `>=` this value in the post-edit
88    /// document map to lines `>= pre_edit_end_line` in the
89    /// pre-edit document.
90    pub fn post_edit_end_line(&self) -> u32 {
91        self.start_line.saturating_add(self.lines_added)
92    }
93
94    /// First source line that is safe to treat as an untouched
95    /// SUFFIX of the edit — i.e. every line `>=` this value can be
96    /// reused verbatim from the pre-edit cache and shifted
97    /// wholesale by [`Self::net_delta`]. This is the value
98    /// `pre_edit_end_line`'s doc comment used to (incorrectly)
99    /// claim for itself; this method is the one that actually
100    /// honours that contract.
101    ///
102    /// Three shapes, in order:
103    ///
104    /// - **Pure insert** (`lines_removed == 0`): `pre_edit_end_line()
105    ///   == start_line`, which would misclassify the very line the
106    ///   edit lands on (typed into, or split by a newline) as an
107    ///   untouched suffix. The safe boundary is one line later,
108    ///   `start_line + 1`.
109    /// - **Boundary line partially or wholly replaced**
110    ///   (`lines_removed > 0` and `old_end_at_bol == false`):
111    ///   `pre_edit_end_line()` lands ON the line the edit's old
112    ///   range actually ends inside — that line's content changed,
113    ///   so it is NOT a safe suffix start either. The safe boundary
114    ///   is one line later, `pre_edit_end_line() + 1`. Table-mode's
115    ///   `rewrite()` and org's `replace_lines` both build edits
116    ///   shaped exactly this way (range end = EOL of the last
117    ///   affected line, never BOL of the line after) on every
118    ///   invocation.
119    /// - **Clean line-boundary replace** (`lines_removed > 0` and
120    ///   `old_end_at_bol == true`): the old range ended exactly at
121    ///   BOL of `pre_edit_end_line()`, so that line is genuinely
122    ///   untouched and safe to shift wholesale — `pre_edit_end_line()`
123    ///   itself is the answer.
124    pub fn suffix_start_line(&self) -> u32 {
125        if self.lines_removed == 0 {
126            self.start_line.saturating_add(1)
127        } else if self.old_end_at_bol {
128            self.pre_edit_end_line()
129        } else {
130            self.pre_edit_end_line().saturating_add(1)
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn net_delta_signs() {
141        let insert = EditDelta {
142            start_line: 5,
143            lines_removed: 0,
144            lines_added: 3,
145            ..Default::default()
146        };
147        assert_eq!(insert.net_delta(), 3);
148        assert_eq!(insert.pre_edit_end_line(), 5);
149        assert_eq!(insert.post_edit_end_line(), 8);
150
151        let delete = EditDelta {
152            start_line: 10,
153            lines_removed: 4,
154            lines_added: 0,
155            ..Default::default()
156        };
157        assert_eq!(delete.net_delta(), -4);
158        assert_eq!(delete.pre_edit_end_line(), 14);
159        assert_eq!(delete.post_edit_end_line(), 10);
160
161        let replace = EditDelta {
162            start_line: 2,
163            lines_removed: 2,
164            lines_added: 5,
165            ..Default::default()
166        };
167        assert_eq!(replace.net_delta(), 3);
168        assert_eq!(replace.pre_edit_end_line(), 4);
169        assert_eq!(replace.post_edit_end_line(), 7);
170    }
171
172    /// `saturating_add` guards the worst-case
173    /// (`start_line` near `u32::MAX`) so the eligibility check
174    /// doesn't wrap. Production callers will never hit this; it
175    /// exists for defensive correctness.
176    #[test]
177    fn end_lines_saturate_at_u32_max() {
178        let e = EditDelta {
179            start_line: u32::MAX - 1,
180            lines_removed: 10,
181            lines_added: 0,
182            ..Default::default()
183        };
184        assert_eq!(e.pre_edit_end_line(), u32::MAX);
185    }
186
187    #[test]
188    fn default_old_end_at_bol_is_conservative_false() {
189        // The conservative default causes one EXTRA row to be
190        // rebuilt (safe, just wasted work) rather than one row to
191        // be WRONGLY reused (the table-align bug this field fixes).
192        assert!(!EditDelta::default().old_end_at_bol);
193    }
194
195    #[test]
196    fn suffix_start_line_pure_insert_skips_the_edited_line() {
197        // `lines_removed == 0`: the start line itself was typed
198        // into or split, regardless of `old_end_at_bol` — the safe
199        // suffix boundary is one line later.
200        let e = EditDelta {
201            start_line: 3,
202            lines_removed: 0,
203            lines_added: 1,
204            old_end_at_bol: true,
205        };
206        assert_eq!(e.suffix_start_line(), 4);
207    }
208
209    #[test]
210    fn suffix_start_line_clean_line_boundary_reuses_pre_edit_end_line() {
211        // Old range ends exactly at BOL of the following line (e.g.
212        // `dd`-style whole-line deletion including trailing
213        // newlines) — that line is genuinely untouched.
214        let e = EditDelta {
215            start_line: 3,
216            lines_removed: 2,
217            lines_added: 0,
218            old_end_at_bol: true,
219        };
220        assert_eq!(e.suffix_start_line(), e.pre_edit_end_line());
221        assert_eq!(e.suffix_start_line(), 5);
222    }
223
224    #[test]
225    fn suffix_start_line_mid_line_boundary_extends_by_one() {
226        // Old range ends partway into (or at the EOL of) its last
227        // line — table-mode's `rewrite()` / org's `replace_lines`
228        // shape. `pre_edit_end_line()` lands ON the changed line, so
229        // the safe suffix boundary is one line past it.
230        let e = EditDelta {
231            start_line: 3,
232            lines_removed: 2,
233            lines_added: 2,
234            old_end_at_bol: false,
235        };
236        assert_eq!(e.suffix_start_line(), e.pre_edit_end_line() + 1);
237        assert_eq!(e.suffix_start_line(), 6);
238    }
239}