use similar::{Algorithm, ChangeTag, TextDiff};
const ALGORITHM: Algorithm = Algorithm::Histogram;
pub(crate) const DEFAULT_CONTEXT: usize = 3;
pub struct LineDiff {
pub body: String,
pub lines_added: usize,
pub lines_removed: usize,
pub old_lines: usize,
pub new_lines: usize,
pub changes: Vec<LineChange>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineChangeKind {
Equal,
Delete,
Insert,
}
impl LineChangeKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::Equal => "equal",
Self::Delete => "delete",
Self::Insert => "insert",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LineChange {
pub kind: LineChangeKind,
pub line: String,
pub old_line: usize,
pub new_line: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct LineDiffOptions {
pub context: usize,
pub include_body: bool,
pub include_changes: bool,
}
impl Default for LineDiffOptions {
fn default() -> Self {
Self {
context: DEFAULT_CONTEXT,
include_body: true,
include_changes: false,
}
}
}
pub fn render_line_diff(before: &str, after: &str) -> LineDiff {
compute_line_diff(before, after, LineDiffOptions::default())
}
pub fn compute_line_diff(before: &str, after: &str, options: LineDiffOptions) -> LineDiff {
let diff = TextDiff::configure()
.algorithm(ALGORITHM)
.diff_lines(before, after);
let body = if options.include_body {
diff.unified_diff()
.context_radius(options.context)
.to_string()
} else {
String::new()
};
let mut lines_added = 0;
let mut lines_removed = 0;
let mut old_line = 1;
let mut new_line = 1;
let mut changes = if options.include_changes {
Vec::with_capacity(diff.old_len().max(diff.new_len()))
} else {
Vec::new()
};
for change in diff.iter_all_changes() {
let kind = match change.tag() {
ChangeTag::Insert => {
lines_added += 1;
LineChangeKind::Insert
}
ChangeTag::Delete => {
lines_removed += 1;
LineChangeKind::Delete
}
ChangeTag::Equal => LineChangeKind::Equal,
};
if options.include_changes {
changes.push(LineChange {
kind,
line: line_without_terminator(change.value()),
old_line,
new_line,
});
}
match kind {
LineChangeKind::Equal => {
old_line += 1;
new_line += 1;
}
LineChangeKind::Delete => old_line += 1,
LineChangeKind::Insert => new_line += 1,
}
}
LineDiff {
body,
lines_added,
lines_removed,
old_lines: diff.old_len(),
new_lines: diff.new_len(),
changes,
}
}
fn line_without_terminator(value: &str) -> String {
let value = value.strip_suffix('\n').unwrap_or(value);
value.strip_suffix('\r').unwrap_or(value).to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identical_inputs_yield_empty_body() {
let diff = render_line_diff("a\nb\nc\n", "a\nb\nc\n");
assert_eq!(diff.body, "");
assert_eq!(diff.lines_added, 0);
assert_eq!(diff.lines_removed, 0);
}
#[test]
fn single_change_emits_bounded_hunk() {
let diff = render_line_diff("a\nb\nc\n", "a\nB\nc\n");
assert!(diff.body.starts_with("@@ -"));
assert!(diff.body.contains("-b\n"));
assert!(diff.body.contains("+B\n"));
assert_eq!(diff.lines_added, 1);
assert_eq!(diff.lines_removed, 1);
}
#[test]
fn expanded_changes_keep_one_based_coordinates() {
let diff = compute_line_diff(
"a\nb\nc\n",
"a\nB\nc\n",
LineDiffOptions {
include_body: false,
include_changes: true,
..LineDiffOptions::default()
},
);
assert_eq!(diff.old_lines, 3);
assert_eq!(diff.new_lines, 3);
assert_eq!(diff.changes[1].kind, LineChangeKind::Delete);
assert_eq!(diff.changes[1].line, "b");
assert_eq!((diff.changes[1].old_line, diff.changes[1].new_line), (2, 2));
assert_eq!(diff.changes[2].kind, LineChangeKind::Insert);
assert_eq!(diff.changes[2].line, "B");
assert_eq!((diff.changes[2].old_line, diff.changes[2].new_line), (3, 2));
}
#[test]
fn context_stays_bounded_on_large_inputs() {
let before: String = (0..1000).map(|i| format!("line {i}\n")).collect();
let mut after_lines: Vec<String> = (0..1000).map(|i| format!("line {i}")).collect();
after_lines[500] = "CHANGED".to_string();
let after = after_lines
.iter()
.map(|l| format!("{l}\n"))
.collect::<String>();
let diff = render_line_diff(&before, &after);
assert_eq!(diff.body.matches("@@ -").count(), 1);
assert!(diff.body.lines().count() < 12);
assert!(!diff.body.contains("line 100\n"));
}
#[test]
fn trailing_newline_change_is_not_collapsed() {
let diff = render_line_diff("a\nb", "a\nb\n");
assert!(diff.body.contains("\\ No newline at end of file"));
}
}