use crate::{
DiffAlgorithm, DiffLine, DiffOp, WsIgnore, line_is_blank, myers_diff_lines_ws, 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 whitespace: &'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>,
pub ws_error: Option<WsErrorHighlight>,
pub ws_ignore: WsIgnore,
pub algorithm: DiffAlgorithm,
pub change_ignore: Option<&'a ChangeIgnore<'a>>,
pub line_ranges: Option<&'a [LineRange]>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LineRange {
pub start: i64,
pub end: i64,
}
pub struct ChangeIgnore<'a> {
pub ignore_blank_lines: bool,
pub regex_match: Option<&'a dyn Fn(&[u8]) -> bool>,
}
#[derive(Clone, Copy)]
pub struct WsErrorHighlight {
pub rule: crate::ws::WsRule,
pub old: bool,
pub new: bool,
pub context: bool,
}
impl Default for HunkRenderOptions<'_, '_> {
fn default() -> Self {
Self {
context: DEFAULT_CONTEXT,
interhunk: 0,
heading: None,
colors: None,
word_diff: None,
ws_error: None,
ws_ignore: WsIgnore::default(),
algorithm: DiffAlgorithm::Myers,
change_ignore: None,
line_ranges: None,
}
}
}
pub fn render_hunks(
out: &mut Vec<u8>,
old_content: Option<&[u8]>,
new_content: Option<&[u8]>,
options: &mut HunkRenderOptions<'_, '_>,
) {
if let Some(ranges) = options.line_ranges {
let max_span = ranges
.iter()
.map(|r| r.end - r.start)
.max()
.unwrap_or(0)
.max(0) as usize;
let saved_context = options.context;
options.context = saved_context.max(max_span);
options.line_ranges = None;
let mut full = Vec::new();
render_hunks(&mut full, old_content, new_content, options);
options.context = saved_context;
options.line_ranges = Some(ranges);
filter_hunks_to_ranges(out, &full, ranges);
return;
}
let old = split_lines(old_content.unwrap_or_default());
let new = split_lines(new_content.unwrap_or_default());
let ops = myers_diff_lines_ws(&old, &new, options.ws_ignore, options.algorithm);
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 changes = build_changes(&tagged);
if changes.is_empty() {
return;
}
let mut changes = changes;
if let Some(ci) = options.change_ignore {
mark_ignorable_changes(&mut changes, &old, &new, options.ws_ignore, ci);
}
let groups = group_changes_into_hunks(&changes, options.context, options.interhunk);
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);
}
}
struct RangeFilter<'r> {
ranges: &'r [LineRange],
cur_range: usize,
lno_post: i64,
lno_pre: i64,
func: Vec<u8>,
rhunk: Vec<u8>,
rhunk_old_begin: i64,
rhunk_old_count: i64,
rhunk_new_begin: i64,
rhunk_new_count: i64,
rhunk_active: bool,
rhunk_has_changes: bool,
pending_rm: Vec<u8>,
pending_rm_count: i64,
pending_rm_pre_begin: i64,
}
impl RangeFilter<'_> {
fn discard_pending_rm(&mut self) {
self.pending_rm.clear();
self.pending_rm_count = 0;
}
fn flush_rhunk(&mut self, out: &mut Vec<u8>) {
if !self.rhunk_active {
return;
}
if self.pending_rm_count != 0 {
self.rhunk.extend_from_slice(&self.pending_rm);
self.rhunk_old_count += self.pending_rm_count;
self.rhunk_has_changes = true;
self.discard_pending_rm();
}
if !self.rhunk_has_changes {
self.rhunk_active = false;
self.rhunk.clear();
return;
}
out.extend_from_slice(
format!(
"@@ -{},{} +{},{} @@",
self.rhunk_old_begin,
self.rhunk_old_count,
self.rhunk_new_begin,
self.rhunk_new_count
)
.as_bytes(),
);
if !self.func.is_empty() {
out.push(b' ');
out.extend_from_slice(&self.func);
}
out.push(b'\n');
out.extend_from_slice(&self.rhunk);
self.rhunk_active = false;
self.rhunk.clear();
}
fn body_line(&mut self, out: &mut Vec<u8>, marker: u8, line: &[u8]) {
if marker == b'-' {
if self.pending_rm_count == 0 {
self.pending_rm_pre_begin = self.lno_pre;
}
self.lno_pre += 1;
self.pending_rm.extend_from_slice(line);
self.pending_rm_count += 1;
return;
}
if marker == b'\\' {
if self.pending_rm_count != 0 {
self.pending_rm.extend_from_slice(line);
} else if self.rhunk_active {
self.rhunk.extend_from_slice(line);
}
return;
}
let lno_0 = self.lno_post - 1;
let cur_pre = self.lno_pre;
self.lno_post += 1;
if marker == b' ' {
self.lno_pre += 1;
}
while self.cur_range < self.ranges.len() && lno_0 >= self.ranges[self.cur_range].end {
if self.rhunk_active {
self.flush_rhunk(out);
}
self.discard_pending_rm();
self.cur_range += 1;
}
if self.cur_range >= self.ranges.len() {
self.discard_pending_rm();
return;
}
let cur = self.ranges[self.cur_range];
if lno_0 < cur.start {
self.discard_pending_rm();
return;
}
if !self.rhunk_active {
self.rhunk_active = true;
self.rhunk_has_changes = false;
self.rhunk_new_begin = lno_0 + 1;
self.rhunk_old_begin = if self.pending_rm_count != 0 {
self.pending_rm_pre_begin
} else {
cur_pre
};
self.rhunk_old_count = 0;
self.rhunk_new_count = 0;
self.rhunk.clear();
}
if self.pending_rm_count != 0 {
self.rhunk.extend_from_slice(&self.pending_rm);
self.rhunk_old_count += self.pending_rm_count;
self.rhunk_has_changes = true;
self.discard_pending_rm();
}
self.rhunk.extend_from_slice(line);
self.rhunk_new_count += 1;
if marker == b'+' {
self.rhunk_has_changes = true;
} else {
self.rhunk_old_count += 1;
}
}
}
fn filter_hunks_to_ranges(out: &mut Vec<u8>, full: &[u8], ranges: &[LineRange]) {
if ranges.is_empty() {
return;
}
let mut filter = RangeFilter {
ranges,
cur_range: 0,
lno_post: 0,
lno_pre: 0,
func: Vec::new(),
rhunk: Vec::new(),
rhunk_old_begin: 0,
rhunk_old_count: 0,
rhunk_new_begin: 0,
rhunk_new_count: 0,
rhunk_active: false,
rhunk_has_changes: false,
pending_rm: Vec::new(),
pending_rm_count: 0,
pending_rm_pre_begin: 0,
};
for line in split_keep_newline(full) {
if line.starts_with(b"@@ ") {
if let Some((old_begin, new_begin, func)) = parse_hunk_header(line) {
filter.lno_post = new_begin;
filter.lno_pre = old_begin;
filter.func = func;
}
continue;
}
let marker = line.first().copied().unwrap_or(b' ');
filter.body_line(out, marker, line);
}
filter.flush_rhunk(out);
}
fn split_keep_newline(buf: &[u8]) -> impl Iterator<Item = &[u8]> {
let mut start = 0usize;
std::iter::from_fn(move || {
if start >= buf.len() {
return None;
}
let rel = buf[start..].iter().position(|&b| b == b'\n');
let end = match rel {
Some(pos) => start + pos + 1,
None => buf.len(),
};
let line = &buf[start..end];
start = end;
Some(line)
})
}
fn parse_hunk_header(line: &[u8]) -> Option<(i64, i64, Vec<u8>)> {
let rest = line.strip_prefix(b"@@ -")?;
let plus = rest.iter().position(|&b| b == b'+')?;
let old_part = &rest[..plus];
let after_plus = &rest[plus + 1..];
let close = find_subslice(after_plus, b" @@")?;
let new_part = &after_plus[..close];
let old_begin = parse_range_begin(old_part.split(|&b| b == b' ').next().unwrap_or(old_part))?;
let new_begin = parse_range_begin(new_part)?;
let tail = &after_plus[close + 3..];
let func = if let Some(f) = tail.strip_prefix(b" ") {
let mut f = f.to_vec();
if f.last() == Some(&b'\n') {
f.pop();
}
f
} else {
Vec::new()
};
Some((old_begin, new_begin, func))
}
fn parse_range_begin(field: &[u8]) -> Option<i64> {
let begin = field.split(|&b| b == b',').next().unwrap_or(field);
std::str::from_utf8(begin).ok()?.trim().parse::<i64>().ok()
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
(0..=haystack.len() - needle.len()).find(|&i| &haystack[i..i + needle.len()] == needle)
}
#[derive(Clone, Copy)]
struct Change {
i1: usize,
chg1: usize,
i2: usize,
chg2: usize,
tag_first: usize,
tag_last: usize,
ignore: bool,
}
fn build_changes(tagged: &[TaggedLine<'_>]) -> Vec<Change> {
let mut changes: Vec<Change> = Vec::new();
let mut idx = 0usize;
while idx < tagged.len() {
if tagged[idx].kind == LineKind::Context {
idx += 1;
continue;
}
let tag_first = idx;
let i1 = tagged[idx].old_index;
let i2 = tagged[idx].new_index;
let mut chg1 = 0usize;
let mut chg2 = 0usize;
while idx < tagged.len() && tagged[idx].kind != LineKind::Context {
match tagged[idx].kind {
LineKind::Delete => chg1 += 1,
LineKind::Insert => chg2 += 1,
LineKind::Context => unreachable!(),
}
idx += 1;
}
changes.push(Change {
i1,
chg1,
i2,
chg2,
tag_first,
tag_last: idx - 1,
ignore: false,
});
}
changes
}
fn mark_ignorable_changes(
changes: &mut [Change],
old: &[DiffLine<'_>],
new: &[DiffLine<'_>],
ws_ignore: WsIgnore,
ci: &ChangeIgnore<'_>,
) {
for change in changes.iter_mut() {
if ci.ignore_blank_lines {
let blank = (change.i1..change.i1 + change.chg1)
.all(|i| line_is_blank(old[i].content, ws_ignore))
&& (change.i2..change.i2 + change.chg2)
.all(|i| line_is_blank(new[i].content, ws_ignore));
change.ignore = blank;
}
if !change.ignore {
if let Some(regex_match) = ci.regex_match {
let matched = (change.i1..change.i1 + change.chg1)
.all(|i| regex_match(old[i].content))
&& (change.i2..change.i2 + change.chg2).all(|i| regex_match(new[i].content));
change.ignore = matched;
}
}
}
}
fn group_changes_into_hunks(
changes: &[Change],
context: usize,
interhunk: usize,
) -> Vec<(usize, usize)> {
let max_common = context.saturating_add(context).saturating_add(interhunk);
let max_ignorable = context;
let mut hunks: Vec<(usize, usize)> = Vec::new();
let mut start = 0usize;
while start < changes.len() {
{
let mut xchp = start;
while xchp < changes.len() && changes[xchp].ignore {
let cur = &changes[xchp];
match changes.get(xchp + 1) {
None => {
start = changes.len();
}
Some(next) => {
if next.i1 - (cur.i1 + cur.chg1) >= max_ignorable {
start = xchp + 1;
}
}
}
xchp += 1;
}
}
if start >= changes.len() {
break;
}
let mut last = start;
let mut ignored = 0usize; let mut prev = start;
let mut idx = start + 1;
while idx < changes.len() {
let xch = &changes[idx];
let xchp = &changes[prev];
let distance = xch.i1 - (xchp.i1 + xchp.chg1);
if distance > max_common {
break;
}
if distance < max_ignorable && (!xch.ignore || last == prev) {
last = idx;
ignored = 0;
} else if distance < max_ignorable && xch.ignore {
ignored += xch.chg2;
} else if last != prev
&& xch.i1 + ignored - (changes[last].i1 + changes[last].chg1) > max_common
{
break;
} else if !xch.ignore {
last = idx;
ignored = 0;
} else {
ignored += xch.chg2;
}
prev = idx;
idx += 1;
}
let first_change = &changes[start];
let last_change = &changes[last];
hunks.push((first_change.tag_first, last_change.tag_last));
start = last + 1;
}
hunks
}
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) => {
let ws_rule = options.ws_error.and_then(|ws| {
let enabled = match line.kind {
LineKind::Context => ws.context,
LineKind::Delete => ws.old,
LineKind::Insert => ws.new,
};
enabled.then_some(ws.rule)
});
write_patch_line_colored(out, prefix, line.content, colors, ws_rule);
}
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<'_>,
ws_rule: Option<crate::ws::WsRule>,
) {
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 let Some(rule) = ws_rule {
out.extend_from_slice(color.as_bytes());
out.push(prefix);
out.extend_from_slice(colors.reset.as_bytes());
let emit_colors = crate::ws::WsEmitColors {
set: color,
reset: colors.reset,
ws: colors.whitespace,
};
crate::ws::ws_check_emit(body, rule, out, &emit_colors);
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');
}
return;
}
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}",
);
}
}