use crate::{DiffLine, DiffOp, myers_diff_lines, split_lines};
pub const DEFAULT_CONTEXT: usize = 3;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum LineKind {
Context,
Delete,
Insert,
}
#[derive(Clone, Copy)]
pub struct TaggedLine<'a> {
pub kind: LineKind,
pub content: &'a [u8],
pub old_index: usize,
pub new_index: usize,
}
#[derive(Clone, Copy)]
pub struct RenderColors<'a> {
pub frag: &'a str,
pub func: &'a str,
pub old: &'a str,
pub new: &'a str,
pub context: &'a str,
pub reset: &'a str,
}
pub type HeadingFn<'a> = dyn FnMut(&[u8]) -> Option<Vec<u8>> + 'a;
pub trait HunkWordDiff {
fn push_minus(&mut self, content: &[u8]);
fn push_plus(&mut self, content: &[u8]);
fn flush(&mut self, out: &mut Vec<u8>);
fn emit_context_line(&mut self, out: &mut Vec<u8>, content: &[u8]);
}
pub struct HunkRenderOptions<'a, 'h> {
pub context: usize,
pub interhunk: usize,
pub heading: Option<&'a mut HeadingFn<'h>>,
pub colors: Option<RenderColors<'a>>,
pub word_diff: Option<&'a mut dyn HunkWordDiff>,
}
impl Default for HunkRenderOptions<'_, '_> {
fn default() -> Self {
Self {
context: DEFAULT_CONTEXT,
interhunk: 0,
heading: None,
colors: None,
word_diff: None,
}
}
}
pub fn render_hunks(
out: &mut Vec<u8>,
old_content: Option<&[u8]>,
new_content: Option<&[u8]>,
options: &mut HunkRenderOptions<'_, '_>,
) {
let old = split_lines(old_content.unwrap_or_default());
let new = split_lines(new_content.unwrap_or_default());
let ops = myers_diff_lines(&old, &new);
let mut tagged: Vec<TaggedLine<'_>> = Vec::new();
let mut old_idx = 0usize;
let mut new_idx = 0usize;
for op in ops {
match op {
DiffOp::Equal(n) => {
for _ in 0..n {
tagged.push(TaggedLine {
kind: LineKind::Context,
content: old[old_idx].content,
old_index: old_idx,
new_index: new_idx,
});
old_idx += 1;
new_idx += 1;
}
}
DiffOp::Delete(n) => {
for _ in 0..n {
tagged.push(TaggedLine {
kind: LineKind::Delete,
content: old[old_idx].content,
old_index: old_idx,
new_index: new_idx,
});
old_idx += 1;
}
}
DiffOp::Insert(n) => {
for _ in 0..n {
tagged.push(TaggedLine {
kind: LineKind::Insert,
content: new[new_idx].content,
old_index: old_idx,
new_index: new_idx,
});
new_idx += 1;
}
}
}
}
let change_positions: Vec<usize> = tagged
.iter()
.enumerate()
.filter(|(_, line)| line.kind != LineKind::Context)
.map(|(idx, _)| idx)
.collect();
if change_positions.is_empty() {
return;
}
let mut groups: Vec<(usize, usize)> = Vec::new();
let mut group_start = change_positions[0];
let mut group_end = change_positions[0];
for &pos in &change_positions[1..] {
if pos - group_end <= 2 * options.context + options.interhunk + 1 {
group_end = pos;
} else {
groups.push((group_start, group_end));
group_start = pos;
group_end = pos;
}
}
groups.push((group_start, group_end));
for (first_change, last_change) in groups {
let hunk_start = first_change.saturating_sub(options.context);
let hunk_end = (last_change + options.context + 1).min(tagged.len());
render_one_hunk(out, &tagged, &old, hunk_start, hunk_end, options);
}
}
fn render_one_hunk(
out: &mut Vec<u8>,
tagged: &[TaggedLine<'_>],
old_lines: &[DiffLine<'_>],
start: usize,
end: usize,
options: &mut HunkRenderOptions<'_, '_>,
) {
let slice = &tagged[start..end];
let mut old_count = 0usize;
let mut new_count = 0usize;
for line in slice {
match line.kind {
LineKind::Context => {
old_count += 1;
new_count += 1;
}
LineKind::Delete => old_count += 1,
LineKind::Insert => new_count += 1,
}
}
let old_start = if old_count == 0 {
slice.first().map(|line| line.old_index).unwrap_or(0)
} else {
slice
.iter()
.find(|line| line.kind != LineKind::Insert)
.map(|line| line.old_index + 1)
.unwrap_or(1)
};
let new_start = if new_count == 0 {
slice.first().map(|line| line.new_index).unwrap_or(0)
} else {
slice
.iter()
.find(|line| line.kind != LineKind::Delete)
.map(|line| line.new_index + 1)
.unwrap_or(1)
};
let heading = hunk_section_heading(
old_lines,
slice.first().map(|line| line.old_index),
options.heading.as_deref_mut(),
);
let frag = format!(
"@@ -{} +{} @@",
format_hunk_range(old_start, old_count),
format_hunk_range(new_start, new_count)
);
match options.colors {
Some(colors) => {
out.extend_from_slice(colors.frag.as_bytes());
out.extend_from_slice(frag.as_bytes());
out.extend_from_slice(colors.reset.as_bytes());
if let Some(heading) = &heading {
out.extend_from_slice(colors.context.as_bytes());
out.push(b' ');
out.extend_from_slice(colors.reset.as_bytes());
out.extend_from_slice(colors.func.as_bytes());
out.extend_from_slice(heading);
out.extend_from_slice(colors.reset.as_bytes());
}
out.push(b'\n');
}
None => {
out.extend_from_slice(frag.as_bytes());
if let Some(heading) = &heading {
out.push(b' ');
out.extend_from_slice(heading);
}
out.push(b'\n');
}
}
if let Some(word_diff) = options.word_diff.as_deref_mut() {
for line in slice {
match line.kind {
LineKind::Delete => word_diff.push_minus(line.content),
LineKind::Insert => word_diff.push_plus(line.content),
LineKind::Context => {
word_diff.flush(out);
word_diff.emit_context_line(out, line.content);
}
}
}
word_diff.flush(out);
return;
}
for line in slice {
let prefix = match line.kind {
LineKind::Context => b' ',
LineKind::Delete => b'-',
LineKind::Insert => b'+',
};
match options.colors {
Some(colors) => write_patch_line_colored(out, prefix, line.content, colors),
None => write_patch_line(out, prefix, line.content),
}
}
}
fn format_hunk_range(start: usize, count: usize) -> String {
if count == 1 {
start.to_string()
} else {
format!("{start},{count}")
}
}
fn hunk_section_heading(
old_lines: &[DiffLine<'_>],
first_old_index: Option<usize>,
mut heading: Option<&mut HeadingFn<'_>>,
) -> Option<Vec<u8>> {
let first = first_old_index?;
let classifier = heading.as_mut()?;
for idx in (0..first).rev() {
if let Some(found) = classifier(old_lines[idx].content) {
return Some(found);
}
}
None
}
fn write_patch_line(out: &mut Vec<u8>, prefix: u8, line: &[u8]) {
out.push(prefix);
out.extend_from_slice(line);
if !line.ends_with(b"\n") {
out.extend_from_slice(b"\n\\ No newline at end of file\n");
}
}
fn write_patch_line_colored(out: &mut Vec<u8>, prefix: u8, line: &[u8], colors: RenderColors<'_>) {
let (body, terminated) = match line.split_last() {
Some((b'\n', body)) => (body, true),
_ => (line, false),
};
let color = match prefix {
b'-' => colors.old,
b'+' => colors.new,
_ => colors.context,
};
if prefix == b'+' {
out.extend_from_slice(color.as_bytes());
out.push(prefix);
out.extend_from_slice(colors.reset.as_bytes());
if !body.is_empty() {
out.extend_from_slice(color.as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(colors.reset.as_bytes());
}
} else {
out.extend_from_slice(color.as_bytes());
out.push(prefix);
out.extend_from_slice(body);
out.extend_from_slice(colors.reset.as_bytes());
}
out.push(b'\n');
if !terminated {
out.extend_from_slice(colors.context.as_bytes());
out.extend_from_slice(b"\\ No newline at end of file");
out.extend_from_slice(colors.reset.as_bytes());
out.push(b'\n');
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render_plain(old: Option<&[u8]>, new: Option<&[u8]>) -> Vec<u8> {
let mut out = Vec::new();
let mut options = HunkRenderOptions::default();
render_hunks(&mut out, old, new, &mut options);
out
}
#[test]
fn identical_content_renders_nothing() {
assert!(render_plain(Some(b"a\nb\n"), Some(b"a\nb\n")).is_empty());
}
#[test]
fn single_line_change_basic_hunk() {
let out = render_plain(Some(b"alpha\nbeta\ngamma\n"), Some(b"alpha\nBETA\ngamma\n"));
assert_eq!(
out,
b"@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n".to_vec(),
);
}
#[test]
fn count_omitted_when_one() {
let out = render_plain(Some(b"old\n"), Some(b"new\n"));
assert_eq!(out, b"@@ -1 +1 @@\n-old\n+new\n".to_vec());
}
#[test]
fn no_newline_marker_on_old_side() {
let out = render_plain(Some(b"only line no newline"), None);
assert_eq!(
out,
b"@@ -1 +0,0 @@\n-only line no newline\n\\ No newline at end of file\n".to_vec(),
);
}
#[test]
fn no_newline_marker_on_new_side() {
let out = render_plain(Some(b"beta\n"), Some(b"beta-notail"));
assert_eq!(
out,
b"@@ -1 +1 @@\n-beta\n+beta-notail\n\\ No newline at end of file\n".to_vec(),
);
}
#[test]
fn pure_insertion_into_empty() {
let out = render_plain(None, Some(b"x\ny\n"));
assert_eq!(out, b"@@ -0,0 +1,2 @@\n+x\n+y\n".to_vec());
}
#[test]
fn distant_changes_split_into_two_hunks() {
let old: &[u8] = b"a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
let new: &[u8] = b"A\nb\nc\nd\ne\nf\ng\nh\ni\nJ\n";
let out = render_plain(Some(old), Some(new));
let text = String::from_utf8(out).expect("rendered output is valid UTF-8");
assert_eq!(text.matches("@@ ").count(), 2, "expected two hunks: {text}");
}
#[test]
fn heading_callback_supplies_section() {
let old: &[u8] =
b"fn foo() {\n a\n b\n c\n d\n e\n f\n g\n}\n";
let new: &[u8] =
b"fn foo() {\n a\n b\n c\n d\n CHANGED\n f\n g\n}\n";
let mut out = Vec::new();
let mut heading_fn = |line: &[u8]| -> Option<Vec<u8>> {
if line.first().is_some_and(u8::is_ascii_alphabetic) {
Some(line.strip_suffix(b"\n").unwrap_or(line).to_vec())
} else {
None
}
};
let mut options = HunkRenderOptions {
heading: Some(&mut heading_fn),
..Default::default()
};
render_hunks(&mut out, Some(old), Some(new), &mut options);
let text = String::from_utf8(out).expect("rendered output is valid UTF-8");
assert!(
text.starts_with("@@ -3,7 +3,7 @@ fn foo() {\n"),
"expected funcname heading: {text}",
);
}
}