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}