1use crate::{Lang, Token, highlight};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Piece {
12 pub text: String,
14 pub token: Token,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum PieceEdit {
21 Equal(Piece),
23 Delete(Piece),
25 Insert(Piece),
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum LineEdit {
32 Equal {
34 old: usize,
36 new: usize,
38 line: String,
40 },
41 Delete {
43 old: usize,
45 line: String,
47 },
48 Insert {
50 new: usize,
52 line: String,
54 },
55 Change {
57 old: usize,
59 new: usize,
61 pieces: Vec<PieceEdit>,
63 },
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct CodeDiff {
69 pub lines: Vec<LineEdit>,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct Loc {
77 pub line: usize,
79 pub col: usize,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct EditError(pub String);
86
87impl std::fmt::Display for EditError {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.write_str(&self.0)
90 }
91}
92
93pub fn code_lines(src: &str) -> Vec<&str> {
96 if src.is_empty() {
97 return Vec::new();
98 }
99 let mut lines: Vec<&str> = src.split('\n').collect();
100 if src.ends_with('\n') {
101 lines.pop();
102 }
103 lines
104}
105
106pub fn diff_code(from: &str, to: &str, lang: Lang) -> CodeDiff {
108 let a = code_lines(from);
109 let b = code_lines(to);
110 let matches = lcs(&a, &b);
111 let mut lines = Vec::new();
112 let (mut i, mut j) = (0, 0);
113 for (mi, mj) in matches {
114 push_hunk(&mut lines, &a, &b, [i, j, mi, mj], lang);
115 lines.push(LineEdit::Equal {
116 old: mi,
117 new: mj,
118 line: a[mi].to_owned(),
119 });
120 i = mi + 1;
121 j = mj + 1;
122 }
123 push_hunk(&mut lines, &a, &b, [i, j, a.len(), b.len()], lang);
124 CodeDiff { lines }
125}
126
127pub fn map_focus(diff: &CodeDiff, first: usize, last: usize) -> Option<(usize, usize)> {
130 if first == 0 && last == 0 {
131 return Some((0, 0));
132 }
133 let (lo, hi) = if first <= last {
134 (first, last)
135 } else {
136 (last, first)
137 };
138 let mut news = Vec::new();
139 for line in &diff.lines {
140 let (old, new) = match line {
141 LineEdit::Equal { old, new, .. } | LineEdit::Change { old, new, .. } => (*old, *new),
142 LineEdit::Delete { .. } | LineEdit::Insert { .. } => continue,
143 };
144 let n = old + 1;
145 if n >= lo && n <= hi {
146 news.push(new + 1);
147 }
148 }
149 Some((*news.iter().min()?, *news.iter().max()?))
150}
151
152pub fn insert_at(src: &str, line: usize, col: usize, text: &str) -> Result<String, EditError> {
159 replace_range(src, Loc { line, col }, Loc { line, col }, text)
160}
161
162pub fn remove_range(src: &str, start: Loc, end: Loc) -> Result<String, EditError> {
168 replace_range(src, start, end, "")
169}
170
171pub fn replace_range(src: &str, start: Loc, end: Loc, text: &str) -> Result<String, EditError> {
177 let a = point(src, start)?;
178 let b = point(src, end)?;
179 if b < a {
180 return Err(EditError(format!(
181 "the range ends at line {} column {} before it starts at line {} column {}",
182 end.line, end.col, start.line, start.col
183 )));
184 }
185 let mut out = String::with_capacity(src.len() - (b - a) + text.len());
186 out.push_str(&src[..a]);
187 out.push_str(text);
188 out.push_str(&src[b..]);
189 Ok(out)
190}
191
192fn push_hunk(out: &mut Vec<LineEdit>, old: &[&str], new: &[&str], at: [usize; 4], lang: Lang) {
193 let [i, j, mi, mj] = at;
194 let (del, ins) = (mi - i, mj - j);
195 let paired = del.min(ins);
196 for k in 0..paired {
197 out.push(LineEdit::Change {
198 old: i + k,
199 new: j + k,
200 pieces: piece_diff(old[i + k], new[j + k], lang),
201 });
202 }
203 for k in paired..del {
204 out.push(LineEdit::Delete {
205 old: i + k,
206 line: old[i + k].to_owned(),
207 });
208 }
209 for k in paired..ins {
210 out.push(LineEdit::Insert {
211 new: j + k,
212 line: new[j + k].to_owned(),
213 });
214 }
215}
216
217pub fn line_pieces(line: &str, lang: Lang) -> Vec<Piece> {
219 pieces(line, lang)
220}
221
222fn piece_diff(old: &str, new: &str, lang: Lang) -> Vec<PieceEdit> {
223 let a = pieces(old, lang);
224 let b = pieces(new, lang);
225 let matches = lcs(&a, &b);
226 let mut out = Vec::new();
227 let (mut i, mut j) = (0, 0);
228 for (mi, mj) in matches {
229 for p in a.iter().take(mi).skip(i) {
230 out.push(PieceEdit::Delete(p.clone()));
231 }
232 for p in b.iter().take(mj).skip(j) {
233 out.push(PieceEdit::Insert(p.clone()));
234 }
235 out.push(PieceEdit::Equal(a[mi].clone()));
236 i = mi + 1;
237 j = mj + 1;
238 }
239 for p in a.iter().skip(i) {
240 out.push(PieceEdit::Delete(p.clone()));
241 }
242 for p in b.iter().skip(j) {
243 out.push(PieceEdit::Insert(p.clone()));
244 }
245 out
246}
247
248fn pieces(line: &str, lang: Lang) -> Vec<Piece> {
249 let mut out = Vec::new();
250 for (range, token) in highlight(line, lang) {
251 let mut rest = &line[range];
252 while !rest.is_empty() {
253 let ws = rest.chars().next().is_some_and(char::is_whitespace);
254 let mut end = 0;
255 for c in rest.chars() {
256 if c.is_whitespace() != ws {
257 break;
258 }
259 end += c.len_utf8();
260 }
261 out.push(Piece {
262 text: rest[..end].to_owned(),
263 token,
264 });
265 rest = &rest[end..];
266 }
267 }
268 out
269}
270
271fn lcs<T: PartialEq>(a: &[T], b: &[T]) -> Vec<(usize, usize)> {
272 let (n, m) = (a.len(), b.len());
273 let mut dp = vec![vec![0u32; m + 1]; n + 1];
274 for i in (0..n).rev() {
275 for j in (0..m).rev() {
276 dp[i][j] = if a[i] == b[j] {
277 dp[i + 1][j + 1] + 1
278 } else {
279 dp[i + 1][j].max(dp[i][j + 1])
280 };
281 }
282 }
283 let mut pairs = Vec::new();
284 let (mut i, mut j) = (0, 0);
285 while i < n && j < m {
286 if a[i] == b[j] {
287 pairs.push((i, j));
288 i += 1;
289 j += 1;
290 } else if dp[i + 1][j] >= dp[i][j + 1] {
291 i += 1;
292 } else {
293 j += 1;
294 }
295 }
296 pairs
297}
298
299fn point(src: &str, at: Loc) -> Result<usize, EditError> {
300 if at.line == 0 {
301 return Err(EditError(
302 "line 0 is not a line: lines start at 1, columns start at 0".into(),
303 ));
304 }
305 let lines = code_lines(src);
306 if at.line == lines.len() + 1 {
307 if at.col == 0 {
308 return Ok(src.len());
309 }
310 return Err(EditError(format!(
311 "line {} is past the end of the code, which has {} lines",
312 at.line,
313 lines.len()
314 )));
315 }
316 if at.line > lines.len() {
317 return Err(EditError(format!(
318 "line {} is past the end of the code, which has {} lines",
319 at.line,
320 lines.len()
321 )));
322 }
323 let row = lines[at.line - 1];
324 let chars = row.chars().count();
325 if at.col > chars {
326 return Err(EditError(format!(
327 "column {} is past the end of line {}, which has {chars} characters",
328 at.col, at.line
329 )));
330 }
331 let mut byte = 0;
332 for (i, line) in lines.iter().enumerate() {
333 if i + 1 == at.line {
334 byte += line.chars().take(at.col).map(char::len_utf8).sum::<usize>();
335 return Ok(byte);
336 }
337 byte += line.len() + 1;
338 }
339 Ok(byte)
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 fn equals(diff: &CodeDiff) -> Vec<&str> {
347 diff.lines
348 .iter()
349 .filter_map(|l| match l {
350 LineEdit::Equal { line, .. } => Some(line.as_str()),
351 _ => None,
352 })
353 .collect()
354 }
355
356 fn inserted(diff: &CodeDiff) -> Vec<&str> {
357 diff.lines
358 .iter()
359 .filter_map(|l| match l {
360 LineEdit::Insert { line, .. } => Some(line.as_str()),
361 _ => None,
362 })
363 .collect()
364 }
365
366 fn deleted(diff: &CodeDiff) -> Vec<&str> {
367 diff.lines
368 .iter()
369 .filter_map(|l| match l {
370 LineEdit::Delete { line, .. } => Some(line.as_str()),
371 _ => None,
372 })
373 .collect()
374 }
375
376 #[test]
377 fn edit_at_start_middle_and_end() {
378 let start = diff_code("b\nc\n", "a\nb\nc\n", Lang::Rust);
379 assert_eq!(inserted(&start), ["a"]);
380 assert_eq!(equals(&start), ["b", "c"]);
381
382 let middle = diff_code("a\nb\nc\n", "a\nB\nc\n", Lang::Rust);
383 assert_eq!(equals(&middle), ["a", "c"]);
384 assert!(matches!(
385 &middle.lines[1],
386 LineEdit::Change { old: 1, new: 1, .. }
387 ));
388
389 let end = diff_code("a\nb\n", "a\nb\nc\n", Lang::Rust);
390 assert_eq!(inserted(&end), ["c"]);
391 assert_eq!(equals(&end), ["a", "b"]);
392 }
393
394 #[test]
395 fn empty_line_is_its_own_row() {
396 let diff = diff_code("a\nb\n", "a\n\nb\n", Lang::Rust);
397 assert_eq!(inserted(&diff), [""]);
398 assert_eq!(equals(&diff), ["a", "b"]);
399 assert!(deleted(&diff).is_empty());
400 }
401
402 #[test]
403 fn moving_a_block_deletes_it_and_inserts_it() {
404 let from = "A\nB\nC\nX\nY\nZ\n";
405 let to = "X\nY\nZ\nA\nB\nC\n";
406 for lang in [
407 Lang::Rust,
408 Lang::Js,
409 Lang::Python,
410 Lang::Json,
411 Lang::Toml,
412 Lang::Shell,
413 ] {
414 let diff = diff_code(from, to, lang);
415 let gone = deleted(&diff);
416 let arrived = inserted(&diff);
417 assert_eq!(gone, arrived, "{lang:?}");
418 assert_eq!(gone.len(), 3, "{lang:?}");
419 assert_eq!(equals(&diff).len(), 3, "{lang:?}");
420 }
421 }
422
423 #[test]
424 fn a_renamed_word_leaves_its_keyword() {
425 let cases = [
426 (
427 Lang::Rust,
428 "fn main() {\n let x = 1;\n}\n",
429 "fn main() {\n let y = 1;\n}\n",
430 "let",
431 ),
432 (
433 Lang::Js,
434 "function f() {\n const x = 1;\n}\n",
435 "function f() {\n const y = 1;\n}\n",
436 "const",
437 ),
438 (
439 Lang::Python,
440 "def f():\n return x\n",
441 "def f():\n return y\n",
442 "return",
443 ),
444 (
445 Lang::Json,
446 "{\n \"a\": 1\n}\n",
447 "{\n \"a\": 2\n}\n",
448 "\"a\"",
449 ),
450 (Lang::Toml, "name = \"a\"\n", "name = \"b\"\n", "name"),
451 (Lang::Shell, "echo hello\n", "echo world\n", "echo"),
452 ];
453 for (lang, from, to, word) in cases {
454 let diff = diff_code(from, to, lang);
455 let kept: Vec<&str> = diff
456 .lines
457 .iter()
458 .filter_map(|l| match l {
459 LineEdit::Change { pieces, .. } => Some(pieces),
460 _ => None,
461 })
462 .flatten()
463 .filter_map(|p| match p {
464 PieceEdit::Equal(p) => Some(p.text.as_str()),
465 _ => None,
466 })
467 .collect();
468 assert!(
469 kept.contains(&word),
470 "{lang:?} dropped {word:?} from {kept:?}"
471 );
472 assert!(!kept.contains(&"x") || lang == Lang::Toml, "{lang:?}");
473 }
474 }
475
476 #[test]
477 fn rust_ident_change_does_not_redraw_the_spaces() {
478 let diff = diff_code("let x = 1;", "let y = 1;", Lang::Rust);
479 let LineEdit::Change { pieces, .. } = &diff.lines[0] else {
480 panic!("expected a change, got {diff:?}");
481 };
482 let texts: Vec<_> = pieces
483 .iter()
484 .map(|p| match p {
485 PieceEdit::Equal(p) => ("=", p.text.as_str()),
486 PieceEdit::Delete(p) => ("-", p.text.as_str()),
487 PieceEdit::Insert(p) => ("+", p.text.as_str()),
488 })
489 .collect();
490 assert!(texts.contains(&("=", "let")), "{texts:?}");
491 assert!(texts.contains(&("-", "x")), "{texts:?}");
492 assert!(texts.contains(&("+", "y")), "{texts:?}");
493 assert!(texts.contains(&("=", " ")), "{texts:?}");
494 assert!(texts.contains(&("=", "1")), "{texts:?}");
495 }
496
497 #[test]
498 fn focus_follows_lines_that_shift() {
499 let diff = diff_code("a\nb\nc\n", "NEW\na\nb\nc\n", Lang::Rust);
500 assert_eq!(map_focus(&diff, 2, 2), Some((3, 3)));
501 assert_eq!(map_focus(&diff, 0, 0), Some((0, 0)));
502 let gone = diff_code("a\nb\n", "b\n", Lang::Rust);
503 assert_eq!(map_focus(&gone, 1, 1), None);
504 }
505
506 #[test]
507 fn insert_remove_and_replace_use_lines_and_columns() {
508 let src = "aaaa\nbbbb\ncccc\n";
509 assert_eq!(
510 insert_at(src, 2, 0, "X\n").unwrap(),
511 "aaaa\nX\nbbbb\ncccc\n"
512 );
513 assert_eq!(
514 remove_range(src, Loc { line: 2, col: 0 }, Loc { line: 3, col: 0 }).unwrap(),
515 "aaaa\ncccc\n"
516 );
517 assert_eq!(
518 replace_range(src, Loc { line: 2, col: 1 }, Loc { line: 2, col: 3 }, "YZ").unwrap(),
519 "aaaa\nbYZb\ncccc\n"
520 );
521 assert_eq!(insert_at("й\n", 1, 1, "!").unwrap(), "й!\n");
523 let err = insert_at(src, 0, 0, "z").unwrap_err();
524 assert!(err.0.contains("start at 1"), "{err}");
525 let err = remove_range(src, Loc { line: 9, col: 0 }, Loc { line: 9, col: 0 }).unwrap_err();
526 assert!(err.0.contains("3 lines"), "{err}");
527 }
528}