badness_parser/parser/
edit.rs1use std::ops::Range;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Edit {
23 pub range: Range<usize>,
24 pub insert: String,
25}
26
27impl Edit {
28 pub fn delta(&self) -> isize {
33 self.insert.len() as isize - (self.range.end - self.range.start) as isize
34 }
35
36 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 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
60pub 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
71pub 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
86pub 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 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 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}