pub use imara_diff::Algorithm;
use imara_diff::{Diff, InternedInput};
use crate::diff::anchor::{LinePos, Side};
use crate::diff::sat_u32;
use crate::diff::text::Text;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DiffLine {
Context { old: u32, new: u32 },
Removed(u32),
Added(u32),
}
impl DiffLine {
pub fn left(self) -> Option<LinePos> {
self.on(Side::Left)
}
pub fn right(self) -> Option<LinePos> {
self.on(Side::Right)
}
pub fn on(self, side: Side) -> Option<LinePos> {
let line = match (self, side) {
(Self::Context { old, .. } | Self::Removed(old), Side::Left) => old,
(Self::Context { new, .. } | Self::Added(new), Side::Right) => new,
_ => return None,
};
Some(LinePos { side, line })
}
pub fn lines(self) -> impl Iterator<Item = LinePos> {
self.left().into_iter().chain(self.right())
}
pub fn shown(self) -> LinePos {
let (side, line) = match self {
Self::Removed(line) => (Side::Left, line),
Self::Context { new: line, .. } | Self::Added(line) => (Side::Right, line),
};
LinePos { side, line }
}
pub fn is_change(self) -> bool {
!matches!(self, Self::Context { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hunk {
pub old_start: u32,
pub old_len: u32,
pub new_start: u32,
pub new_len: u32,
pub lines: Vec<DiffLine>,
}
impl Hunk {
pub fn header(&self) -> String {
format!(
"@@ -{},{} +{},{} @@",
self.old_start, self.old_len, self.new_start, self.new_len
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Whitespace {
#[default]
Exact,
Ignore,
}
pub fn align(
old: &Text,
new: &Text,
algorithm: Algorithm,
whitespace: Whitespace,
) -> Vec<DiffLine> {
let key = |text: &Text, i: usize| -> String {
match whitespace {
Whitespace::Exact => text.token(i),
Whitespace::Ignore => text
.line(i)
.chars()
.filter(|c| !c.is_whitespace())
.collect(),
}
};
let before: Vec<String> = (0..old.len()).map(|i| key(old, i)).collect();
let after: Vec<String> = (0..new.len()).map(|i| key(new, i)).collect();
let mut input = InternedInput::default();
input.update_before(before.iter().map(String::as_str));
input.update_after(after.iter().map(String::as_str));
let mut diff = Diff::compute(algorithm, &input);
diff.postprocess_lines(&input);
let mut lines = Vec::with_capacity(old.len().max(new.len()));
let (mut o, mut n) = (0, 0);
let (old_end, new_end) = (sat_u32(old.len()), sat_u32(new.len()));
let end = (old_end..old_end, new_end..new_end);
for (before, after) in diff.hunks().map(|h| (h.before, h.after)).chain([end]) {
let context = (o..before.start).zip(n..);
lines.extend(context.map(|(o, n)| DiffLine::Context {
old: o + 1,
new: n + 1,
}));
lines.extend(before.clone().map(|o| DiffLine::Removed(o + 1)));
lines.extend(after.clone().map(|n| DiffLine::Added(n + 1)));
(o, n) = (before.end, after.end);
}
lines
}
pub fn segments(
lines: &[DiffLine],
context: u32,
windows: &[std::ops::Range<u32>],
full: bool,
) -> Vec<std::ops::Range<usize>> {
segments_by(
lines.len(),
|i| lines.get(i).is_some_and(|l| l.is_change()),
context,
windows,
full,
)
}
#[expect(clippy::single_range_in_vec_init, reason = "a list of one range")]
pub fn segments_by(
len: usize,
is_change: impl Fn(usize) -> bool,
context: u32,
windows: &[std::ops::Range<u32>],
full: bool,
) -> Vec<std::ops::Range<usize>> {
if len == 0 {
return Vec::new();
}
if full {
return vec![0..len];
}
let context = context as usize;
let mut visible = vec![false; len];
let mut show = |range: std::ops::Range<usize>| {
if let Some(lines) = visible.get_mut(range.start..range.end.min(len)) {
lines.fill(true);
}
};
let mut shown = 0;
for i in (0..len).filter(|&i| is_change(i)) {
let end = i.saturating_add(context).saturating_add(1);
show(i.saturating_sub(context).max(shown)..end);
shown = end;
}
for window in windows {
show(window.start as usize..window.end as usize);
}
let mut end = 0;
visible
.chunk_by(|a, b| a == b)
.filter_map(|run| {
let start = end;
end += run.len();
(run.first() == Some(&true)).then_some(start..end)
})
.collect()
}
pub fn side_counts(lines: &[DiffLine]) -> (u32, u32) {
lines.iter().fold((0, 0), |(o, n), l| {
(
o + u32::from(l.left().is_some()),
n + u32::from(l.right().is_some()),
)
})
}
fn hunk(lines: &[DiffLine], range: std::ops::Range<usize>, before: (u32, u32)) -> Hunk {
let lines = lines.get(range).unwrap_or_default().to_vec();
let (old_len, new_len) = side_counts(&lines);
let start = |seen: u32, len: u32| if len > 0 { seen + 1 } else { seen };
Hunk {
old_start: start(before.0, old_len),
old_len,
new_start: start(before.1, new_len),
new_len,
lines,
}
}
pub fn hunks(lines: &[DiffLine], segs: &[std::ops::Range<usize>]) -> Vec<Hunk> {
let (mut seen, mut before) = (0, (0, 0));
let hunk_at = |seg: &std::ops::Range<usize>| {
let (o, n) = side_counts(lines.get(seen..seg.start).unwrap_or_default());
(seen, before) = (seg.start, (before.0 + o, before.1 + n));
hunk(lines, seg.clone(), before)
};
segs.iter().map(hunk_at).collect()
}
pub fn diff_lines(old: &Text, new: &Text, algorithm: Algorithm, context: u32) -> Vec<Hunk> {
let lines = align(old, new, algorithm, Whitespace::Exact);
hunks(&lines, &segments(&lines, context, &[], false))
}
#[cfg(test)]
#[expect(clippy::single_range_in_vec_init, reason = "lists of ranges")]
mod tests {
use super::*;
fn hunks(old: &str, new: &str, algorithm: Algorithm) -> Vec<Hunk> {
diff_lines(
&Text::new(old.as_bytes()),
&Text::new(new.as_bytes()),
algorithm,
3,
)
}
fn render(old: &str, new: &str) -> String {
let (old, new) = (Text::new(old.as_bytes()), Text::new(new.as_bytes()));
let mut out = String::new();
for hunk in diff_lines(&old, &new, Algorithm::Histogram, 3) {
out.push_str(&hunk.header());
out.push('\n');
for line in &hunk.lines {
let (sign, text) = match *line {
DiffLine::Context { new: n, .. } => (' ', new.line_no(n)),
DiffLine::Added(n) => ('+', new.line_no(n)),
DiffLine::Removed(o) => ('-', old.line_no(o)),
};
out.push(sign);
out.push_str(text);
out.push('\n');
}
}
out
}
fn numbered(n: u32) -> String {
(1..=n).map(|i| format!("line {i}\n")).collect()
}
#[test]
fn single_change_with_context() {
let old = numbered(10);
let new = old.replace("line 5\n", "line five\n");
assert_eq!(
render(&old, &new),
"@@ -2,7 +2,7 @@\n line 2\n line 3\n line 4\n-line 5\n+line five\n line 6\n line 7\n line 8\n"
);
}
#[test]
fn close_changes_merge_far_ones_split() {
let old = numbered(30);
let near = old.replace("line 5\n", "x\n").replace("line 9\n", "y\n");
assert_eq!(hunks(&old, &near, Algorithm::Histogram).len(), 1);
let far = old.replace("line 5\n", "x\n").replace("line 25\n", "y\n");
assert_eq!(hunks(&old, &far, Algorithm::Histogram).len(), 2);
}
#[test]
fn added_and_deleted_files() {
assert_eq!(render("", "a\nb\n"), "@@ -0,0 +1,2 @@\n+a\n+b\n");
assert_eq!(render("a\nb\n", ""), "@@ -1,2 +0,0 @@\n-a\n-b\n");
assert_eq!(render("same\n", "same\n"), "");
}
#[test]
fn insertion_at_start_and_end() {
assert_eq!(
render("a\nb\n", "z\na\nb\n"),
"@@ -1,2 +1,3 @@\n+z\n a\n b\n"
);
assert_eq!(
render("a\nb\n", "a\nb\nc\n"),
"@@ -1,2 +1,3 @@\n a\n b\n+c\n"
);
}
#[test]
fn missing_final_newline_is_a_change() {
assert_eq!(render("a\nb\n", "a\nb"), "@@ -1,2 +1,2 @@\n a\n-b\n+b\n");
}
#[test]
fn crlf_change_is_a_change() {
assert_eq!(
render("a\nb\n", "a\r\nb\n"),
"@@ -1,2 +1,2 @@\n-a\n+a\n b\n"
);
}
#[test]
fn hunk_headers_match_git() {
let base = numbered(40);
let cases = [
(base.clone(), base.replace("line 2\n", "")),
(
base.clone(),
base.replace("line 10\n", "line 10\nnew a\nnew b\n"),
),
(
base.clone(),
base.replace("line 1\n", "first\n")
.replace("line 40\n", "last\n"),
),
(
base.clone(),
base.replace("line 5\n", "x\n")
.replace("line 12\n", "y\n")
.replace("line 30\n", ""),
),
(base.clone(), format!("{base}tail\n")),
(format!("head\n{base}"), base.clone()),
];
let dir = std::env::temp_dir().join(format!("ghtui-hunks-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (i, (old, new)) in cases.iter().enumerate() {
let (a, b) = (dir.join(format!("{i}.old")), dir.join(format!("{i}.new")));
std::fs::write(&a, old).unwrap();
std::fs::write(&b, new).unwrap();
let out = std::process::Command::new("git")
.args(["diff", "--no-index", "-U3", "--diff-algorithm=myers"])
.arg(&a)
.arg(&b)
.output()
.unwrap();
let expected: Vec<String> = String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| l.starts_with("@@"))
.map(|l| {
let inner = l.trim_start_matches("@@ ").split(" @@").next().unwrap();
let parts: Vec<String> = inner
.split(' ')
.map(|p| {
if p.contains(',') {
p.to_owned()
} else {
format!("{p},1")
}
})
.collect();
format!("@@ {} @@", parts.join(" "))
})
.collect();
let actual: Vec<String> = hunks(old, new, Algorithm::Myers)
.iter()
.map(Hunk::header)
.collect();
assert_eq!(actual, expected, "case {i}");
}
std::fs::remove_dir_all(&dir).unwrap();
}
fn kinds(lines: &[DiffLine]) -> String {
lines
.iter()
.map(|l| match l {
DiffLine::Context { .. } => ' ',
DiffLine::Removed(_) => '-',
DiffLine::Added(_) => '+',
})
.collect()
}
#[test]
fn alignment_covers_every_line() {
let old = Text::new(b"a\nb\nc\n");
let new = Text::new(b"a\nB\nc\nd\n");
let lines = align(&old, &new, Algorithm::Histogram, Whitespace::Exact);
assert_eq!(kinds(&lines), " -+ +");
assert_eq!(side_counts(&lines), (3, 4));
}
#[test]
fn ignoring_whitespace() {
let old = Text::new(b"fn f() {\n x();\n}\nend\n");
let new = Text::new(b"fn f() {\n\tx( );\r\n}\nEND\n");
assert_eq!(
kinds(&align(&old, &new, Algorithm::Histogram, Whitespace::Exact)),
" -+ -+"
);
assert_eq!(
kinds(&align(&old, &new, Algorithm::Histogram, Whitespace::Ignore)),
" -+"
);
}
#[test]
fn segments_with_windows_and_full() {
let old = numbered(40);
let new = old.replace("line 20\n", "x\n");
let lines = align(
&Text::new(old.as_bytes()),
&Text::new(new.as_bytes()),
Algorithm::Histogram,
Whitespace::Exact,
);
assert_eq!(segments(&lines, 3, &[], false), [16..24]);
assert_eq!(segments(&lines, 3, &[0..2], false), [0..2, 16..24]);
assert_eq!(segments(&lines, 3, &[10..16], false), [10..24]);
assert_eq!(segments(&lines, 3, &[], true), [0..41]);
assert!(segments(&[], 3, &[], false).is_empty());
}
}