use std::ops::Range; use gpui::SharedString; use similar::{ChangeTag, TextDiff, udiff::UnifiedHunkHeader}; /// Whether a diff line stayed, came in, or went. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LineKind { Same, Added, Removed, } /// A diff line: kind, numbers from one, text, changed words. #[derive(Clone, Debug, PartialEq)] pub struct DiffLine { pub kind: LineKind, pub old: Option, pub new: Option, pub text: SharedString, pub words: Vec>, } /// A hunk of changes in context, or folded unchanged lines. #[derive(Clone, Debug, PartialEq)] pub enum Stretch { Hunk { /// `@@ -old +new @@`, from one. header: SharedString, lines: Vec, }, Folded(Vec), } fn same(text: &str, old: usize, new: usize) -> DiffLine { DiffLine { kind: LineKind::Same, old: Some(old + 1), new: Some(new + 1), text: text.trim_end_matches(['\n', '\r']).to_string().into(), words: Vec::new(), } } /// `old` against `new`, `context` lines per hunk, the rest folded. pub fn diff(old: &str, new: &str, context: usize) -> Vec { let diff = TextDiff::from_lines(old, new); let (olds, news): (Vec<&str>, Vec<&str>) = ( diff.iter_old_slices().collect(), diff.iter_new_slices().collect(), ); let mut out = Vec::new(); let mut at = (0, 0); for group in diff.grouped_ops(context) { let (first, last) = ( group.first().expect("a group holds ops"), group.last().expect("a group holds ops"), ); let (old_start, new_start) = (first.old_range().start, first.new_range().start); if old_start > at.0 { out.push(Stretch::Folded( (at.0..old_start) .zip(at.1..new_start) .map(|(old, new)| same(olds[old], old, new)) .collect(), )); } let (old_end, new_end) = (last.old_range().end, last.new_range().end); let mut lines = Vec::new(); for op in &group { for change in diff.iter_inline_changes(op) { let (mut text, mut words) = (String::new(), Vec::new()); for (emphasized, part) in change.iter_strings_lossy() { let part = part.trim_end_matches(['\n', '\r']); if emphasized && !part.is_empty() { words.push(text.len()..text.len() + part.len()); } text.push_str(part); } let kind = match change.tag() { ChangeTag::Equal => LineKind::Same, ChangeTag::Insert => LineKind::Added, ChangeTag::Delete => LineKind::Removed, }; lines.push(DiffLine { kind, old: change.old_index().map(|ix| ix + 1), new: change.new_index().map(|ix| ix + 1), text: text.into(), words: if kind == LineKind::Same { Vec::new() } else { words }, }); } } let header = UnifiedHunkHeader::new(&group).to_string(); out.push(Stretch::Hunk { header: header.into(), lines, }); at = (old_end, new_end); } if at.0 < olds.len() { out.push(Stretch::Folded( (at.0..olds.len()) .zip(at.1..news.len()) .map(|(old, new)| same(olds[old], old, new)) .collect(), )); } out } /// Lines added and removed from `old` to `new`. pub fn stat(old: &str, new: &str) -> (usize, usize) { let diff = TextDiff::from_lines(old, new); diff.iter_all_changes() .fold((0, 0), |(added, removed), change| match change.tag() { ChangeTag::Insert => (added + 1, removed), ChangeTag::Delete => (added, removed + 1), ChangeTag::Equal => (added, removed), }) } /// A hunk's rows side by side, as places in `lines`. pub(crate) fn pair_places(lines: &[DiffLine]) -> Vec<(Option, Option)> { let mut out = Vec::new(); let mut ix = 0; while ix < lines.len() { if lines[ix].kind == LineKind::Same { out.push((Some(ix), Some(ix))); ix += 1; continue; } let run = |from: usize, kind| { from..from + lines[from..] .iter() .take_while(|line| line.kind == kind) .count() }; let removed = run(ix, LineKind::Removed); let added = run(removed.end, LineKind::Added); ix = added.end; for row in 0..removed.len().max(added.len()) { let at = |range: &Range| (row < range.len()).then(|| range.start + row); out.push((at(&removed), at(&added))); } } out } /// A hunk's lines side by side: removals beside following additions. pub fn pairs(lines: &[DiffLine]) -> Vec<(Option<&DiffLine>, Option<&DiffLine>)> { pair_places(lines) .into_iter() .map(|(left, right)| (left.map(|ix| &lines[ix]), right.map(|ix| &lines[ix]))) .collect() } /// Marks words changed between each removal and its addition. pub fn mark_words(lines: &mut [DiffLine]) { for (left, right) in pair_places(lines) { let (Some(left), Some(right)) = (left, right) else { continue; }; if left == right { continue; } let (old, new) = (lines[left].text.clone(), lines[right].text.clone()); let diff = TextDiff::from_unicode_words(old.as_ref(), new.as_ref()); let (mut at, mut words, mut kept) = ((0, 0), (Vec::new(), Vec::new()), false); for change in diff.iter_all_changes() { let (value, len) = (change.value(), change.value().len()); let real = !value.trim().is_empty(); match change.tag() { ChangeTag::Equal => { kept |= real; at = (at.0 + len, at.1 + len); } ChangeTag::Delete => { words.0.extend(real.then(|| at.0..at.0 + len)); at.0 += len; } ChangeTag::Insert => { words.1.extend(real.then(|| at.1..at.1 + len)); at.1 += len; } } } // Lines with nothing in common mark no words. if kept { (lines[left].words, lines[right].words) = words; } } } /// A side-by-side row's numbers: old left, new right. pub(crate) fn side_numbers( left: Option<&DiffLine>, right: Option<&DiffLine>, ) -> (Option, Option) { ( left.and_then(|line| line.old), right.and_then(|line| line.new), ) } #[cfg(test)] mod tests { use super::*; #[test] fn a_change_shows_its_words_and_numbers() { let stretches = diff("a\nlet x = 1;\nc\n", "a\nlet x = 2;\nc\nd\n", 1); let [Stretch::Hunk { header, lines }] = stretches.as_slice() else { panic!("one hunk, not {stretches:?}"); }; assert_eq!(header.as_ref(), "@@ -1,3 +1,4 @@"); let kinds: Vec = lines.iter().map(|line| line.kind).collect(); use LineKind::*; assert_eq!(kinds, [Same, Removed, Added, Same, Added]); assert_eq!(&lines[1].text[lines[1].words[0].clone()], "1"); assert_eq!((lines[2].old, lines[2].new), (None, Some(2))); assert_eq!((lines[4].old, lines[4].new), (None, Some(4))); } #[test] fn unchanged_lines_between_hunks_fold() { let old: String = (1..=20).map(|n| format!("{n}\n")).collect(); let new = old .replacen("2\n", "two\n", 1) .replace("19\n", "nineteen\n"); let stretches = diff(&old, &new, 1); let shape: Vec<&str> = stretches .iter() .map(|stretch| match stretch { Stretch::Hunk { .. } => "hunk", Stretch::Folded(_) => "folded", }) .collect(); assert_eq!(shape, ["hunk", "folded", "hunk"]); let Stretch::Folded(hidden) = &stretches[1] else { unreachable!() }; assert_eq!((hidden.len(), hidden[0].old), (14, Some(4))); assert_eq!(stat(&old, &new), (2, 2)); } #[test] fn removals_sit_beside_the_additions_after_them() { let stretches = diff("a\nb\nc\n", "a\nB\nC\nD\n", 0); let lines = stretches .iter() .find_map(|stretch| match stretch { Stretch::Hunk { lines, .. } => Some(lines), Stretch::Folded(_) => None, }) .expect("a hunk"); let sides: Vec<(bool, bool)> = pairs(lines) .iter() .map(|(left, right)| (left.is_some(), right.is_some())) .collect(); assert_eq!(sides, [(true, true), (true, true), (false, true)]); } #[test] fn words_are_marked_on_lines_built_elsewhere() { let line = |kind, text: &str| DiffLine { kind, old: None, new: None, text: text.to_string().into(), words: Vec::new(), }; let mut lines = vec![ line(LineKind::Removed, "let x = 1;"), line(LineKind::Removed, "abc"), line(LineKind::Added, "let x = 2;"), line(LineKind::Added, "xyz"), ]; mark_words(&mut lines); assert_eq!(&lines[0].text[lines[0].words[0].clone()], "1"); assert_eq!(&lines[2].text[lines[2].words[0].clone()], "2"); assert!( lines[1].words.is_empty() && lines[3].words.is_empty(), "nothing shared" ); } #[test] fn each_side_numbers_its_own_file() { let stretches = diff("a\nb\n", "x\na\nb\n", 3); let [Stretch::Hunk { lines, .. }] = stretches.as_slice() else { panic!("one hunk, not {stretches:?}"); }; let numbers: Vec<_> = pairs(lines) .into_iter() .map(|(left, right)| side_numbers(left, right)) .collect(); assert_eq!( numbers, [(None, Some(1)), (Some(1), Some(2)), (Some(2), Some(3))] ); } #[test] fn an_empty_side_starts_before_its_range() { let stretches = diff("", "a\n", 3); let [Stretch::Hunk { header, .. }] = stretches.as_slice() else { panic!("one hunk, not {stretches:?}"); }; assert_eq!(header.as_ref(), "@@ -0,0 +1 @@"); } }