badness_parser/parser/edit.rs
1//! Byte-range text edits.
2//!
3//! [`Edit`] is the parser's one edit currency: a byte range in some old text plus
4//! the string that replaces it. Everything here is pure text manipulation with no
5//! parser content; [`super::reparse`] re-exports it so `parser::Edit` is the single
6//! path the parser layer uses. Converting LSP `didChange` content changes into
7//! these lives host-side (`crate::lsp` in the root crate), which keeps this crate
8//! free of protocol dependencies and wasm-clean.
9//!
10//! Edits reaching the reparse are **untrusted**. A language server can hand over a
11//! chain staged against a buffer that has since moved, and slicing on a stale range
12//! is a panic in an analysis query rather than a wrong answer. So every consumer
13//! validates before it slices: [`try_apply_edits`] is the apply-and-verify guard,
14//! and reconstructing the current buffer from an old snapshot plus a chain is what
15//! proves the chain is the exact transform between them.
16
17use std::ops::Range;
18
19/// A single contiguous text edit: replace `range` (a byte range in the *old* text)
20/// with `insert`.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Edit {
23 pub range: Range<usize>,
24 pub insert: String,
25}
26
27impl Edit {
28 /// The signed length change this edit applies to text after `range`.
29 ///
30 /// Diagnostic offsets at or after the edit shift by exactly this much, which is
31 /// what lets a tier keep the errors it did not regenerate.
32 pub fn delta(&self) -> isize {
33 self.insert.len() as isize - (self.range.end - self.range.start) as isize
34 }
35
36 /// Whether this edit fits `text`: in bounds, non-inverted, and both offsets on
37 /// char boundaries. Check before slicing — the edit is untrusted.
38 pub fn fits(&self, text: &str) -> bool {
39 self.range.start <= self.range.end
40 && self.range.end <= text.len()
41 && text.is_char_boundary(self.range.start)
42 && text.is_char_boundary(self.range.end)
43 }
44
45 /// Apply the edit to `old`, producing the new text.
46 ///
47 /// # Panics
48 ///
49 /// If the edit does not [fit](Self::fits) `old`.
50 pub fn apply(&self, old: &str) -> String {
51 let mut out =
52 String::with_capacity(old.len().saturating_sub(self.range.len()) + self.insert.len());
53 out.push_str(&old[..self.range.start]);
54 out.push_str(&self.insert);
55 out.push_str(&old[self.range.end..]);
56 out
57 }
58}
59
60/// Apply `edits` to `old` left-to-right, each expressed against the text its
61/// predecessors produced — the shape an LSP `didChange` batch arrives in.
62///
63/// # Panics
64///
65/// If any edit does not fit the text its predecessors produced. Use
66/// [`try_apply_edits`] for a chain of unproven provenance.
67pub fn apply_edits(old: &str, edits: &[Edit]) -> String {
68 try_apply_edits(old, edits).expect("apply_edits: edit chain does not fit the text")
69}
70
71/// [`apply_edits`] for an edit chain of unproven provenance: [`None`] when any edit
72/// does not fit the text its predecessors produced.
73///
74/// Folds in place, so peak memory is one text rather than one per edit.
75pub fn try_apply_edits(old: &str, edits: &[Edit]) -> Option<String> {
76 let mut text = old.to_string();
77 for e in edits {
78 if !e.fits(&text) {
79 return None;
80 }
81 text.replace_range(e.range.clone(), &e.insert);
82 }
83 Some(text)
84}
85
86/// Recover a single contiguous [`Edit`] from a pair of whole texts by stripping the
87/// common prefix and suffix.
88///
89/// This is the **fallback**, not the hot path. The language server knows the exact
90/// range it spliced and must hand it over; re-deriving it here costs more than the
91/// reparse it feeds (fatou measured ~200 us of a ~500 us keystroke at 1 MB). It
92/// stays for texts that changed by a route carrying no edits — a disk reload, a
93/// whole-buffer replacement, a chain that failed to verify.
94///
95/// Multiple disjoint edits collapse into one spanning edit. Still a correct
96/// transform, just coarser — and a coarse one spans everything between the changes,
97/// which is exactly the shape a cost guard declines.
98pub fn diff_edit(old: &str, new: &str) -> Edit {
99 let ob = old.as_bytes();
100 let nb = new.as_bytes();
101
102 let mut prefix = 0;
103 let max_prefix = ob.len().min(nb.len());
104 while prefix < max_prefix && ob[prefix] == nb[prefix] {
105 prefix += 1;
106 }
107 // Back off to a char boundary of *both* texts. They share these bytes, so one
108 // test would do; testing `old` alone is the convention and `new` agrees.
109 while prefix > 0 && !old.is_char_boundary(prefix) {
110 prefix -= 1;
111 }
112
113 let mut suffix = 0;
114 let max_suffix = (ob.len() - prefix).min(nb.len() - prefix);
115 while suffix < max_suffix && ob[ob.len() - 1 - suffix] == nb[nb.len() - 1 - suffix] {
116 suffix += 1;
117 }
118 // Here the two texts are at different offsets, so both need the test.
119 while suffix > 0
120 && (!old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix))
121 {
122 suffix -= 1;
123 }
124
125 Edit {
126 range: prefix..(old.len() - suffix),
127 insert: new[prefix..(new.len() - suffix)].to_string(),
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 fn edit(range: Range<usize>, insert: &str) -> Edit {
136 Edit {
137 range,
138 insert: insert.to_string(),
139 }
140 }
141
142 /// The property every `diff_edit` case below leans on, stated once: whatever it
143 /// returns must transform `old` into `new`.
144 fn assert_recovers(old: &str, new: &str) -> Edit {
145 let e = diff_edit(old, new);
146 assert!(e.fits(old), "{e:?} does not fit {old:?}");
147 assert_eq!(e.apply(old), new, "diff_edit({old:?}, {new:?}) = {e:?}");
148 e
149 }
150
151 #[test]
152 fn diff_edit_recovers_a_noop() {
153 assert_eq!(
154 assert_recovers("\\section{Hi}\n", "\\section{Hi}\n").insert,
155 ""
156 );
157 }
158
159 #[test]
160 fn diff_edit_recovers_an_insertion() {
161 assert_eq!(assert_recovers("ab\n", "axb\n"), edit(1..1, "x"));
162 }
163
164 #[test]
165 fn diff_edit_recovers_a_deletion() {
166 assert_eq!(assert_recovers("axb\n", "ab\n"), edit(1..2, ""));
167 }
168
169 /// The span reaches only as far as the shared suffix allows: `\alpha` and
170 /// `\gamma` share a trailing `a`, so the edit stops one char short of the end.
171 #[test]
172 fn diff_edit_recovers_a_replacement() {
173 assert_eq!(
174 assert_recovers("\\alpha\n", "\\gamma\n"),
175 edit(1..5, "gamm")
176 );
177 }
178
179 #[test]
180 fn diff_edit_collapses_disjoint_edits_into_one_span() {
181 // Two changes, one span: correct, and deliberately coarse.
182 let e = assert_recovers("a x b y c\n", "a X b Y c\n");
183 assert_eq!(e, edit(2..7, "X b Y"));
184 }
185
186 #[test]
187 fn diff_edit_handles_whole_replacement_and_empty_texts() {
188 assert_recovers("\\begin{a}\n", "\\end{b}\n");
189 assert_recovers("", "\\section{x}");
190 assert_recovers("\\section{x}", "");
191 assert_recovers("", "");
192 }
193
194 /// A prefix that lands mid-`α` would slice a multi-byte char in half. This is
195 /// not hypothetical: `\alpha` beside a literal `α` is ordinary LaTeX.
196 #[test]
197 fn diff_edit_clamps_to_char_boundaries() {
198 let e = assert_recovers("αβ\n", "αγ\n");
199 assert!("αβ\n".is_char_boundary(e.range.start));
200 assert!("αβ\n".is_char_boundary(e.range.end));
201 }
202
203 #[test]
204 fn diff_edit_clamps_a_shared_suffix_that_splits_a_char() {
205 assert_recovers("xα\n", "yα\n");
206 assert_recovers("α\n", "αα\n");
207 }
208
209 #[test]
210 fn apply_edits_chains_left_to_right() {
211 // The second range is expressed against the text the first produced.
212 let edits = [edit(0..0, "\\a"), edit(2..2, "{b}")];
213 assert_eq!(apply_edits("\n", &edits), "\\a{b}\n");
214 }
215
216 #[test]
217 fn try_apply_edits_rejects_an_out_of_bounds_range() {
218 assert_eq!(try_apply_edits("ab", &[edit(9..9, "x")]), None);
219 }
220
221 /// An inverted range is the shape a mis-ordered LSP batch produces, and it is
222 /// the one `Range` case that would slice-panic rather than bounds-panic.
223 #[test]
224 #[allow(
225 clippy::reversed_empty_ranges,
226 reason = "the inverted range is the input under test"
227 )]
228 fn try_apply_edits_rejects_an_inverted_range() {
229 assert_eq!(try_apply_edits("ab", &[edit(2..1, "x")]), None);
230 }
231
232 #[test]
233 fn try_apply_edits_rejects_an_offset_inside_a_char() {
234 // `α` is two bytes; offset 1 is inside it.
235 assert_eq!(try_apply_edits("α", &[edit(1..1, "x")]), None);
236 }
237
238 /// A chain can go stale *mid-fold*, so the check has to run against the text
239 /// each predecessor produced rather than the original — in both directions. The
240 /// first chain's second edit fits the original and not the text it lands on; the
241 /// second chain's fits the text it lands on and not the original.
242 #[test]
243 fn try_apply_edits_validates_each_step_against_its_predecessor() {
244 assert_eq!(
245 try_apply_edits("abc", &[edit(0..3, ""), edit(1..1, "x")]),
246 None
247 );
248 assert_eq!(
249 try_apply_edits("abc", &[edit(3..3, "de"), edit(4..5, "X")]).as_deref(),
250 Some("abcdX"),
251 );
252 }
253
254 #[test]
255 fn delta_is_the_shift_applied_to_later_offsets() {
256 assert_eq!(edit(0..0, "xy").delta(), 2);
257 assert_eq!(edit(0..2, "").delta(), -2);
258 assert_eq!(edit(0..2, "ab").delta(), 0);
259 }
260}