Files
Ely-GPUI-Components/src/git/diff.rs
T

318 lines
11 KiB
Rust

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<usize>,
pub new: Option<usize>,
pub text: SharedString,
pub words: Vec<Range<usize>>,
}
/// 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<DiffLine>,
},
Folded(Vec<DiffLine>),
}
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<Stretch> {
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<usize>, Option<usize>)> {
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<usize>| (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<usize>, Option<usize>) {
(
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<LineKind> = 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 @@");
}
}