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    /// 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}