Skip to main content

oxframe_text/
diff.rs

1//! Diff of two code strings for a token tween.
2//!
3//! Lines are matched first, then the highlight tokens of a changed line.
4//! The result is a pure edit script: drawing and time live elsewhere.
5
6use crate::{Lang, Token, highlight};
7
8/// One highlight token, split at whitespace so a renamed word does not
9/// take the spaces around it with it. The color is still the highlighter's.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Piece {
12    /// Source text of this piece.
13    pub text: String,
14    /// Highlight kind, shared by every character of `text`.
15    pub token: Token,
16}
17
18/// What happened to one piece.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum PieceEdit {
21    /// Present in both lines, in the same order.
22    Equal(Piece),
23    /// Only in the old line.
24    Delete(Piece),
25    /// Only in the new line.
26    Insert(Piece),
27}
28
29/// What happened to one line. Indexes are 0-based.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum LineEdit {
32    /// The line text is unchanged.
33    Equal {
34        /// Index in the old source.
35        old: usize,
36        /// Index in the new source.
37        new: usize,
38        /// The line, without its newline.
39        line: String,
40    },
41    /// The line exists only in the old source.
42    Delete {
43        /// Index in the old source.
44        old: usize,
45        /// The line, without its newline.
46        line: String,
47    },
48    /// The line exists only in the new source.
49    Insert {
50        /// Index in the new source.
51        new: usize,
52        /// The line, without its newline.
53        line: String,
54    },
55    /// Both sides have a line here, and their text differs.
56    Change {
57        /// Index in the old source.
58        old: usize,
59        /// Index in the new source.
60        new: usize,
61        /// Token edits inside the line.
62        pieces: Vec<PieceEdit>,
63    },
64}
65
66/// Edit script from one source to another.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct CodeDiff {
69    /// Lines in visual order: equal, deleted and inserted lines interleaved
70    /// so a deleted line sits where it left and an inserted line where it arrives.
71    pub lines: Vec<LineEdit>,
72}
73
74/// A place in a source. Lines are 1-based, columns are 0-based characters.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct Loc {
77    /// Line, starting at 1.
78    pub line: usize,
79    /// Character column, starting at 0.
80    pub col: usize,
81}
82
83/// Why a source edit could not be applied.
84#[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
93/// Lines of `src`, without their newlines. A trailing newline does not add
94/// an empty line; a blank line in the middle does.
95pub 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
106/// Diff `from` into `to`. Highlight tokens come from `lang`.
107pub 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
127/// Where the focused old lines land. `(0, 0)` stays "everything".
128/// `None` when every focused line was deleted.
129pub 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
152/// Inserts `text` at `line` (1-based) and `col` (0-based characters).
153///
154/// # Errors
155///
156/// When the line or column is outside the source. The line just after the
157/// last one, column 0, means the end of the source.
158pub 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
162/// Removes the half-open range `start..end`.
163///
164/// # Errors
165///
166/// When a point is outside the source, or `end` is before `start`.
167pub fn remove_range(src: &str, start: Loc, end: Loc) -> Result<String, EditError> {
168    replace_range(src, start, end, "")
169}
170
171/// Replaces the half-open range `start..end` with `text`.
172///
173/// # Errors
174///
175/// When a point is outside the source, or `end` is before `start`.
176pub 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
217/// Highlight pieces of one line, split at whitespace.
218pub 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        // One character, even though "й" is two bytes.
522        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}