Skip to main content

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    fn assert_recovers(old: &str, new: &str) -> Edit {
143        let e = diff_edit(old, new);
144        assert!(e.fits(old), "{e:?} does not fit {old:?}");
145        assert_eq!(e.apply(old), new, "diff_edit({old:?}, {new:?}) = {e:?}");
146        e
147    }
148
149    #[test]
150    fn diff_edit_recovers_a_noop() {
151        assert_eq!(
152            assert_recovers("\\section{Hi}\n", "\\section{Hi}\n").insert,
153            ""
154        );
155    }
156
157    #[test]
158    fn diff_edit_recovers_an_insertion() {
159        assert_eq!(assert_recovers("ab\n", "axb\n"), edit(1..1, "x"));
160    }
161
162    #[test]
163    fn diff_edit_recovers_a_deletion() {
164        assert_eq!(assert_recovers("axb\n", "ab\n"), edit(1..2, ""));
165    }
166
167    #[test]
168    fn diff_edit_recovers_a_replacement() {
169        assert_eq!(
170            assert_recovers("\\alpha\n", "\\gamma\n"),
171            edit(1..5, "gamm")
172        );
173    }
174
175    #[test]
176    fn diff_edit_collapses_disjoint_edits_into_one_span() {
177        let e = assert_recovers("a x b y c\n", "a X b Y c\n");
178        assert_eq!(e, edit(2..7, "X b Y"));
179    }
180
181    #[test]
182    fn diff_edit_handles_whole_replacement_and_empty_texts() {
183        assert_recovers("\\begin{a}\n", "\\end{b}\n");
184        assert_recovers("", "\\section{x}");
185        assert_recovers("\\section{x}", "");
186        assert_recovers("", "");
187    }
188
189    #[test]
190    fn diff_edit_clamps_to_char_boundaries() {
191        let e = assert_recovers("αβ\n", "αγ\n");
192        assert!("αβ\n".is_char_boundary(e.range.start));
193        assert!("αβ\n".is_char_boundary(e.range.end));
194    }
195
196    #[test]
197    fn diff_edit_clamps_a_shared_suffix_that_splits_a_char() {
198        assert_recovers("xα\n", "yα\n");
199        assert_recovers("α\n", "αα\n");
200    }
201
202    #[test]
203    fn apply_edits_chains_left_to_right() {
204        let edits = [edit(0..0, "\\a"), edit(2..2, "{b}")];
205        assert_eq!(apply_edits("\n", &edits), "\\a{b}\n");
206    }
207
208    #[test]
209    fn try_apply_edits_rejects_an_out_of_bounds_range() {
210        assert_eq!(try_apply_edits("ab", &[edit(9..9, "x")]), None);
211    }
212
213    #[test]
214    #[allow(
215        clippy::reversed_empty_ranges,
216        reason = "the inverted range is the input under test"
217    )]
218    fn try_apply_edits_rejects_an_inverted_range() {
219        assert_eq!(try_apply_edits("ab", &[edit(2..1, "x")]), None);
220    }
221
222    #[test]
223    fn try_apply_edits_rejects_an_offset_inside_a_char() {
224        assert_eq!(try_apply_edits("α", &[edit(1..1, "x")]), None);
225    }
226
227    #[test]
228    fn try_apply_edits_validates_each_step_against_its_predecessor() {
229        assert_eq!(
230            try_apply_edits("abc", &[edit(0..3, ""), edit(1..1, "x")]),
231            None
232        );
233        assert_eq!(
234            try_apply_edits("abc", &[edit(3..3, "de"), edit(4..5, "X")]).as_deref(),
235            Some("abcdX"),
236        );
237    }
238
239    #[test]
240    fn delta_is_the_shift_applied_to_later_offsets() {
241        assert_eq!(edit(0..0, "xy").delta(), 2);
242        assert_eq!(edit(0..2, "").delta(), -2);
243        assert_eq!(edit(0..2, "ab").delta(), 0);
244    }
245}