use crate::{
DiffAlgorithm, DiffLine, DiffOp, WsIgnore, line_is_blank, myers_diff_lines_ws,
patience_diff_lines_anchored, split_lines,
};
use std::collections::HashMap;
pub const DEFAULT_CONTEXT: usize = 3;
const FUNCTION_CONTEXT_FLAG: usize = 1usize << (usize::BITS - 1);
const CONTEXT_VALUE_MASK: usize = !FUNCTION_CONTEXT_FLAG;
pub fn enable_function_context(context: usize) -> usize {
(context & CONTEXT_VALUE_MASK) | FUNCTION_CONTEXT_FLAG
}
fn decode_context(context: usize) -> (usize, bool) {
(
context & CONTEXT_VALUE_MASK,
context & FUNCTION_CONTEXT_FLAG != 0,
)
}
fn replace_context_value(encoded: usize, context: usize) -> usize {
(encoded & !CONTEXT_VALUE_MASK) | (context & CONTEXT_VALUE_MASK)
}
#[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 old_moved: &'a str,
pub old_moved_alt: &'a str,
pub old_moved_dim: &'a str,
pub old_moved_alt_dim: &'a str,
pub new_moved: &'a str,
pub new_moved_alt: &'a str,
pub new_moved_dim: &'a str,
pub new_moved_alt_dim: &'a str,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColorMovedMode {
Plain,
Blocks,
Zebra,
DimmedZebra,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ColorMovedWs {
pub ignore: WsIgnore,
pub allow_indentation_change: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ColorMoved {
pub mode: ColorMovedMode,
pub ws: ColorMovedWs,
}
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 indent_heuristic: bool,
pub change_ignore: Option<&'a ChangeIgnore<'a>>,
pub line_ranges: Option<&'a [LineRange]>,
pub color_moved: Option<ColorMoved>,
pub anchors: &'a [Vec<u8>],
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LineRange {
pub start: i64,
pub end: i64,
}
pub type ChangeIgnoreRegex<'a> = &'a dyn Fn(&[u8]) -> bool;
pub struct ChangeIgnore<'a> {
pub ignore_blank_lines: bool,
pub regex_match: Option<ChangeIgnoreRegex<'a>>,
}
#[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,
indent_heuristic: true,
change_ignore: None,
line_ranges: None,
color_moved: None,
anchors: &[],
}
}
}
pub fn render_hunks(
out: &mut Vec<u8>,
old_content: Option<&[u8]>,
new_content: Option<&[u8]>,
options: &mut HunkRenderOptions<'_, '_>,
) {
let (context, function_context) = decode_context(options.context);
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 = replace_context_value(saved_context, 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 mut ops = if options.algorithm == DiffAlgorithm::Patience
&& !options.anchors.is_empty()
&& options.ws_ignore.is_empty()
{
patience_diff_lines_anchored(&old, &new, options.anchors)
} else {
myers_diff_lines_ws(&old, &new, options.ws_ignore, options.algorithm)
};
change_compact(
&mut ops,
&old,
&new,
options.ws_ignore,
options.indent_heuristic,
);
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: new[new_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 mut groups = group_changes_into_hunks(&changes, context, options.interhunk);
if function_context {
groups = expand_hunks_to_function_context(
&groups,
&tagged,
&old,
&new,
options.heading.as_deref_mut(),
);
}
let moved_styles = options
.color_moved
.filter(|_| options.colors.is_some() && options.word_diff.is_none())
.map(|color_moved| mark_color_as_moved(&tagged, color_moved));
for (first_change, last_change) in groups {
let (hunk_start, hunk_end) = if function_context {
(first_change, (last_change + 1).min(tagged.len()))
} else {
(
first_change.saturating_sub(context),
(last_change + context + 1).min(tagged.len()),
)
};
render_one_hunk(
out,
&tagged,
moved_styles.as_deref(),
&old,
hunk_start,
hunk_end,
options,
);
}
}
const MAX_INDENT: i32 = 200;
const MAX_BLANKS: i32 = 20;
const START_OF_FILE_PENALTY: i32 = 1;
const END_OF_FILE_PENALTY: i32 = 21;
const TOTAL_BLANK_WEIGHT: i32 = -30;
const POST_BLANK_WEIGHT: i32 = 6;
const RELATIVE_INDENT_PENALTY: i32 = -4;
const RELATIVE_INDENT_WITH_BLANK_PENALTY: i32 = 10;
const RELATIVE_OUTDENT_PENALTY: i32 = 24;
const RELATIVE_OUTDENT_WITH_BLANK_PENALTY: i32 = 17;
const RELATIVE_DEDENT_PENALTY: i32 = 23;
const RELATIVE_DEDENT_WITH_BLANK_PENALTY: i32 = 17;
const INDENT_WEIGHT: i32 = 60;
const INDENT_HEURISTIC_MAX_SLIDING: i64 = 100;
struct CompactFile {
recs: Vec<Vec<u8>>,
changed: Vec<bool>,
}
impl CompactFile {
fn nrec(&self) -> i64 {
self.recs.len() as i64
}
fn changed(&self, i: i64) -> bool {
if i < 0 || i >= self.nrec() {
false
} else {
self.changed[i as usize]
}
}
fn set_changed(&mut self, i: i64, v: bool) {
self.changed[i as usize] = v;
}
}
fn get_indent(rec: &[u8]) -> i32 {
let mut ret: i32 = 0;
for &c in rec {
if !xdl_isspace(c) {
return ret;
} else if c == b' ' {
ret += 1;
} else if c == b'\t' {
ret += 8 - ret % 8;
}
if ret >= MAX_INDENT {
return MAX_INDENT;
}
}
-1
}
fn xdl_isspace(c: u8) -> bool {
matches!(c, b' ' | b'\t' | b'\n' | 0x0b | 0x0c | b'\r')
}
#[derive(Default)]
struct SplitMeasurement {
end_of_file: bool,
indent: i32,
pre_blank: i32,
pre_indent: i32,
post_blank: i32,
post_indent: i32,
}
#[derive(Default, Clone, Copy)]
struct SplitScore {
effective_indent: i32,
penalty: i32,
}
fn measure_split(xdf: &CompactFile, split: i64) -> SplitMeasurement {
let mut m = SplitMeasurement::default();
if split >= xdf.nrec() {
m.end_of_file = true;
m.indent = -1;
} else {
m.end_of_file = false;
m.indent = get_indent(&xdf.recs[split as usize]);
}
m.pre_blank = 0;
m.pre_indent = -1;
let mut i = split - 1;
while i >= 0 {
m.pre_indent = get_indent(&xdf.recs[i as usize]);
if m.pre_indent != -1 {
break;
}
m.pre_blank += 1;
if m.pre_blank == MAX_BLANKS {
m.pre_indent = 0;
break;
}
i -= 1;
}
m.post_blank = 0;
m.post_indent = -1;
let mut i = split + 1;
while i < xdf.nrec() {
m.post_indent = get_indent(&xdf.recs[i as usize]);
if m.post_indent != -1 {
break;
}
m.post_blank += 1;
if m.post_blank == MAX_BLANKS {
m.post_indent = 0;
break;
}
i += 1;
}
m
}
fn score_add_split(m: &SplitMeasurement, s: &mut SplitScore) {
if m.pre_indent == -1 && m.pre_blank == 0 {
s.penalty += START_OF_FILE_PENALTY;
}
if m.end_of_file {
s.penalty += END_OF_FILE_PENALTY;
}
let post_blank = if m.indent == -1 { 1 + m.post_blank } else { 0 };
let total_blank = m.pre_blank + post_blank;
s.penalty += TOTAL_BLANK_WEIGHT * total_blank;
s.penalty += POST_BLANK_WEIGHT * post_blank;
let indent = if m.indent != -1 {
m.indent
} else {
m.post_indent
};
let any_blanks = total_blank != 0;
s.effective_indent += indent;
if indent == -1 || m.pre_indent == -1 {
} else if indent > m.pre_indent {
s.penalty += if any_blanks {
RELATIVE_INDENT_WITH_BLANK_PENALTY
} else {
RELATIVE_INDENT_PENALTY
};
} else if indent == m.pre_indent {
} else if m.post_indent != -1 && m.post_indent > indent {
s.penalty += if any_blanks {
RELATIVE_OUTDENT_WITH_BLANK_PENALTY
} else {
RELATIVE_OUTDENT_PENALTY
};
} else {
s.penalty += if any_blanks {
RELATIVE_DEDENT_WITH_BLANK_PENALTY
} else {
RELATIVE_DEDENT_PENALTY
};
}
}
fn score_cmp(s1: &SplitScore, s2: &SplitScore) -> i32 {
let cmp_indents = (s1.effective_indent > s2.effective_indent) as i32
- (s1.effective_indent < s2.effective_indent) as i32;
INDENT_WEIGHT * cmp_indents + (s1.penalty - s2.penalty)
}
struct XdlGroup {
start: i64,
end: i64,
}
fn recs_match(xdf: &CompactFile, a: i64, b: i64) -> bool {
xdf.recs[a as usize] == xdf.recs[b as usize]
}
fn group_init(xdf: &CompactFile) -> XdlGroup {
let mut end = 0i64;
while xdf.changed(end) {
end += 1;
}
XdlGroup { start: 0, end }
}
fn group_next(xdf: &CompactFile, g: &mut XdlGroup) -> bool {
if g.end == xdf.nrec() {
return false;
}
g.start = g.end + 1;
g.end = g.start;
while xdf.changed(g.end) {
g.end += 1;
}
true
}
fn group_previous(xdf: &CompactFile, g: &mut XdlGroup) -> bool {
if g.start == 0 {
return false;
}
g.end = g.start - 1;
g.start = g.end;
while xdf.changed(g.start - 1) {
g.start -= 1;
}
true
}
fn group_slide_down(xdf: &mut CompactFile, g: &mut XdlGroup) -> bool {
if g.end < xdf.nrec() && recs_match(xdf, g.start, g.end) {
xdf.set_changed(g.start, false);
xdf.set_changed(g.end, true);
g.start += 1;
g.end += 1;
while xdf.changed(g.end) {
g.end += 1;
}
true
} else {
false
}
}
fn group_slide_up(xdf: &mut CompactFile, g: &mut XdlGroup) -> bool {
if g.start > 0 && recs_match(xdf, g.start - 1, g.end - 1) {
g.start -= 1;
g.end -= 1;
xdf.set_changed(g.start, true);
xdf.set_changed(g.end, false);
while xdf.changed(g.start - 1) {
g.start -= 1;
}
true
} else {
false
}
}
fn compact_one(xdf: &mut CompactFile, xdfo: &mut CompactFile, indent_heuristic: bool) {
let mut g = group_init(xdf);
let mut go = group_init(xdfo);
loop {
if g.end == g.start {
if !group_next(xdf, &mut g) {
break;
}
if !group_next(xdfo, &mut go) {
break;
}
continue;
}
let mut groupsize;
let mut earliest_end;
let mut end_matching_other;
loop {
groupsize = g.end - g.start;
end_matching_other = -1i64;
while group_slide_up(xdf, &mut g) {
let ok = group_previous(xdfo, &mut go);
debug_assert!(ok, "group sync broken sliding up");
}
earliest_end = g.end;
if go.end > go.start {
end_matching_other = g.end;
}
loop {
if !group_slide_down(xdf, &mut g) {
break;
}
let ok = group_next(xdfo, &mut go);
debug_assert!(ok, "group sync broken sliding down");
if go.end > go.start {
end_matching_other = g.end;
}
}
if groupsize == g.end - g.start {
break;
}
}
if g.end == earliest_end {
} else if end_matching_other != -1 {
while go.end == go.start {
let ok = group_slide_up(xdf, &mut g);
debug_assert!(ok, "match disappeared");
let ok = group_previous(xdfo, &mut go);
debug_assert!(ok, "group sync broken sliding to match");
}
} else if indent_heuristic {
let mut best_shift = -1i64;
let mut best_score = SplitScore::default();
let mut shift = earliest_end;
if g.end - groupsize - 1 > shift {
shift = g.end - groupsize - 1;
}
if g.end - INDENT_HEURISTIC_MAX_SLIDING > shift {
shift = g.end - INDENT_HEURISTIC_MAX_SLIDING;
}
while shift <= g.end {
let mut score = SplitScore::default();
let m = measure_split(xdf, shift);
score_add_split(&m, &mut score);
let m = measure_split(xdf, shift - groupsize);
score_add_split(&m, &mut score);
if best_shift == -1 || score_cmp(&score, &best_score) <= 0 {
best_score = score;
best_shift = shift;
}
shift += 1;
}
while g.end > best_shift {
let ok = group_slide_up(xdf, &mut g);
debug_assert!(ok, "best shift unreached");
let ok = group_previous(xdfo, &mut go);
debug_assert!(ok, "group sync broken sliding to blank line");
}
}
if !group_next(xdf, &mut g) {
break;
}
if !group_next(xdfo, &mut go) {
break;
}
}
}
fn change_compact(
ops: &mut Vec<DiffOp>,
old: &[DiffLine<'_>],
new: &[DiffLine<'_>],
ws_ignore: WsIgnore,
indent_heuristic: bool,
) {
if ops.iter().all(|op| matches!(op, DiffOp::Equal(_))) {
return;
}
let canon = |lines: &[DiffLine<'_>]| -> Vec<Vec<u8>> {
if ws_ignore.is_empty() {
lines.iter().map(|l| l.content.to_vec()).collect()
} else {
lines
.iter()
.map(|l| crate::canonicalize_line_for_match(l.content, ws_ignore))
.collect()
}
};
let mut xdf1 = CompactFile {
recs: canon(old),
changed: vec![false; old.len()],
};
let mut xdf2 = CompactFile {
recs: canon(new),
changed: vec![false; new.len()],
};
let mut oi = 0usize;
let mut ni = 0usize;
for op in ops.iter() {
match *op {
DiffOp::Equal(n) => {
oi += n;
ni += n;
}
DiffOp::Delete(n) => {
for _ in 0..n {
xdf1.changed[oi] = true;
oi += 1;
}
}
DiffOp::Insert(n) => {
for _ in 0..n {
xdf2.changed[ni] = true;
ni += 1;
}
}
}
}
compact_one(&mut xdf1, &mut xdf2, indent_heuristic);
compact_one(&mut xdf2, &mut xdf1, indent_heuristic);
let n_old = xdf1.changed.len();
let n_new = xdf2.changed.len();
let mut rebuilt: Vec<DiffOp> = Vec::with_capacity(ops.len());
let mut i = 0usize; let mut j = 0usize; while i < n_old || j < n_new {
let del = i < n_old && xdf1.changed[i];
let ins = j < n_new && xdf2.changed[j];
if del {
let mut run = 0usize;
while i < n_old && xdf1.changed[i] {
run += 1;
i += 1;
}
push_op(&mut rebuilt, DiffOp::Delete(run));
} else if ins {
let mut run = 0usize;
while j < n_new && xdf2.changed[j] {
run += 1;
j += 1;
}
push_op(&mut rebuilt, DiffOp::Insert(run));
} else {
let mut run = 0usize;
while i < n_old && j < n_new && !xdf1.changed[i] && !xdf2.changed[j] {
run += 1;
i += 1;
j += 1;
}
debug_assert!(run > 0, "change_compact stalled rebuilding script");
push_op(&mut rebuilt, DiffOp::Equal(run));
}
}
*ops = rebuilt;
}
fn push_op(out: &mut Vec<DiffOp>, op: DiffOp) {
match (out.last_mut(), op) {
(Some(DiffOp::Equal(prev)), DiffOp::Equal(n)) => *prev += n,
(Some(DiffOp::Delete(prev)), DiffOp::Delete(n)) => *prev += n,
(Some(DiffOp::Insert(prev)), DiffOp::Insert(n)) => *prev += n,
_ => out.push(op),
}
}
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
&& 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 expand_hunks_to_function_context(
groups: &[(usize, usize)],
tagged: &[TaggedLine<'_>],
old: &[DiffLine<'_>],
new: &[DiffLine<'_>],
mut heading: Option<&mut HeadingFn<'_>>,
) -> Vec<(usize, usize)> {
let Some(classifier) = heading.as_mut() else {
return groups.to_vec();
};
let mut expanded = Vec::with_capacity(groups.len());
for &(start, end) in groups {
let first = tagged[start];
let last = tagged[end];
let old_changed = tagged[start..=end]
.iter()
.any(|line| line.kind == LineKind::Delete);
let (side, range) = if old_changed {
(
FunctionSide::Old,
function_context_range(old, first.old_index, false, classifier),
)
} else {
(
FunctionSide::New,
function_context_range(new, first.new_index, true, classifier),
)
};
let Some((range_start, range_end)) = range else {
expanded.push((start, end));
continue;
};
let mut hunk_start = expand_tag_start(tagged, start, side, range_start);
let mut hunk_end = expand_tag_end(tagged, end, side, range_end);
if old_changed {
if last.old_index >= range_end {
hunk_end = end;
}
} else if last.new_index >= range_end {
hunk_end = end;
}
if hunk_start > start {
hunk_start = start;
}
if hunk_end < end {
hunk_end = end;
}
if let Some(prev) = expanded.last_mut()
&& hunk_start <= prev.1 + 1
{
prev.1 = prev.1.max(hunk_end);
continue;
}
expanded.push((hunk_start, hunk_end));
}
expanded
}
#[derive(Clone, Copy)]
enum FunctionSide {
Old,
New,
}
fn function_context_range(
lines: &[DiffLine<'_>],
anchor: usize,
prefer_forward: bool,
heading: &mut HeadingFn<'_>,
) -> Option<(usize, usize)> {
if lines.is_empty() {
return None;
}
let anchor = anchor.min(lines.len() - 1);
let mut heading_idx = None;
for idx in (0..=anchor).rev() {
if heading(lines[idx].content).is_some() {
heading_idx = Some(idx);
break;
}
}
if heading_idx.is_none() && prefer_forward {
for (idx, line) in lines.iter().enumerate().skip(anchor) {
if heading(line.content).is_some() {
heading_idx = Some(idx);
break;
}
}
}
let (mut start, mut end) = if let Some(idx) = heading_idx {
let mut start = idx;
while start > 0 && !line_is_blank(lines[start - 1].content, WsIgnore::default()) {
start -= 1;
}
let mut end = lines.len();
for (next, line) in lines.iter().enumerate().skip(idx + 1) {
if heading(line.content).is_some() {
end = next;
break;
}
}
(start, end)
} else {
(0, lines.len())
};
while start < end && line_is_blank(lines[start].content, WsIgnore::default()) {
start += 1;
}
while end > start && line_is_blank(lines[end - 1].content, WsIgnore::default()) {
end -= 1;
}
(start < end).then_some((start, end))
}
fn expand_tag_start(
tagged: &[TaggedLine<'_>],
current: usize,
side: FunctionSide,
range_start: usize,
) -> usize {
let mut start = current;
while start > 0 {
let prev = tagged[start - 1];
let line_index = match side {
FunctionSide::Old => prev.old_index,
FunctionSide::New => prev.new_index,
};
if line_index < range_start {
break;
}
start -= 1;
}
start
}
fn expand_tag_end(
tagged: &[TaggedLine<'_>],
current: usize,
side: FunctionSide,
range_end: usize,
) -> usize {
let mut end = current;
while end + 1 < tagged.len() {
let next = tagged[end + 1];
let line_index = match side {
FunctionSide::Old => next.old_index,
FunctionSide::New => next.new_index,
};
if line_index >= range_end {
break;
}
end += 1;
}
end
}
const COLOR_MOVED_MIN_ALNUM_COUNT: usize = 20;
const INDENT_BLANKLINE: i32 = i32::MIN;
#[derive(Clone, Copy, Default)]
struct MovedStyle {
moved: bool,
alt: bool,
uninteresting: bool,
}
#[derive(Clone)]
struct MovedEntry {
tag_idx: usize,
next_line: Option<usize>,
next_match: Option<usize>,
id: usize,
indent_width: i32,
}
#[derive(Clone, Copy, Default)]
struct MovedEntryList {
add: Option<usize>,
del: Option<usize>,
}
#[derive(Clone, Copy)]
struct MovedBlock {
match_entry: usize,
wsd: i32,
}
fn mark_color_as_moved(tagged: &[TaggedLine<'_>], color_moved: ColorMoved) -> Vec<MovedStyle> {
let (entries, entry_for_tag, entry_list) = add_lines_to_move_detection(tagged, color_moved.ws);
let mut styles = vec![MovedStyle::default(); tagged.len()];
let mut pmb: Vec<MovedBlock> = Vec::new();
let mut n = 0usize;
let mut flipped_block = false;
let mut block_length = 0usize;
let mut moved_symbol: Option<LineKind> = None;
while n < tagged.len() {
let line = tagged[n];
let line_entry = entry_for_tag[n];
let mut line_match = line_entry.and_then(|entry_idx| {
let id = entries[entry_idx].id;
match line.kind {
LineKind::Insert => entry_list.get(id).and_then(|list| list.del),
LineKind::Delete => entry_list.get(id).and_then(|list| list.add),
LineKind::Context => None,
}
});
if line.kind == LineKind::Context {
flipped_block = false;
}
if !pmb.is_empty() && (line_match.is_none() || Some(line.kind) != moved_symbol) {
if !adjust_last_block(&mut styles, tagged, color_moved.mode, n, block_length)
&& block_length > 1
{
line_match = None;
n -= block_length;
}
pmb.clear();
block_length = 0;
flipped_block = false;
}
let Some(line_match) = line_match else {
moved_symbol = None;
n += 1;
continue;
};
if color_moved.mode == ColorMovedMode::Plain {
styles[n].moved = true;
n += 1;
continue;
}
pmb_advance_or_null(
&mut pmb,
&entries,
tagged,
line_entry.expect("plus/minus line has move-detection entry"),
color_moved.ws,
);
if pmb.is_empty() {
let contiguous =
adjust_last_block(&mut styles, tagged, color_moved.mode, n, block_length);
if !contiguous && block_length > 1 {
n -= block_length;
} else {
fill_potential_moved_blocks(
line_match,
&entries,
tagged,
n,
color_moved.ws,
&mut pmb,
);
}
if contiguous && !pmb.is_empty() && moved_symbol == Some(line.kind) {
flipped_block = !flipped_block;
} else {
flipped_block = false;
}
moved_symbol = (!pmb.is_empty()).then_some(line.kind);
block_length = 0;
}
if !pmb.is_empty() {
block_length += 1;
styles[n].moved = true;
if flipped_block && color_moved.mode != ColorMovedMode::Blocks {
styles[n].alt = true;
}
}
n += 1;
}
adjust_last_block(&mut styles, tagged, color_moved.mode, n, block_length);
if color_moved.mode == ColorMovedMode::DimmedZebra {
dim_moved_lines(&mut styles, tagged);
}
styles
}
fn add_lines_to_move_detection(
tagged: &[TaggedLine<'_>],
ws: ColorMovedWs,
) -> (Vec<MovedEntry>, Vec<Option<usize>>, Vec<MovedEntryList>) {
let mut entries: Vec<MovedEntry> = Vec::new();
let mut entry_for_tag = vec![None; tagged.len()];
let mut entry_list = Vec::<MovedEntryList>::new();
let mut interned = HashMap::<Vec<u8>, usize>::new();
let mut prev_line: Option<usize> = None;
for (tag_idx, line) in tagged.iter().enumerate() {
if line.kind != LineKind::Insert && line.kind != LineKind::Delete {
prev_line = None;
continue;
}
let indent = if ws.allow_indentation_change {
moved_indent_data(line.content)
} else {
(0, 0)
};
let key = moved_line_key(line.content, indent.0, ws.ignore);
let id = match interned.get(&key) {
Some(id) => *id,
None => {
let id = interned.len();
interned.insert(key, id);
entry_list.push(MovedEntryList::default());
id
}
};
let entry_idx = entries.len();
if let Some(prev) = prev_line
&& tagged[entries[prev].tag_idx].kind == line.kind
{
entries[prev].next_line = Some(entry_idx);
}
let next_match = match line.kind {
LineKind::Insert => entry_list[id].add,
LineKind::Delete => entry_list[id].del,
LineKind::Context => None,
};
entries.push(MovedEntry {
tag_idx,
next_line: None,
next_match,
id,
indent_width: indent.1,
});
match line.kind {
LineKind::Insert => entry_list[id].add = Some(entry_idx),
LineKind::Delete => entry_list[id].del = Some(entry_idx),
LineKind::Context => {}
}
entry_for_tag[tag_idx] = Some(entry_idx);
prev_line = Some(entry_idx);
}
(entries, entry_for_tag, entry_list)
}
fn moved_line_key(line: &[u8], indent_off: usize, ignore: WsIgnore) -> Vec<u8> {
let bytes = line.get(indent_off..).unwrap_or_default();
if ignore.is_empty() {
bytes.to_vec()
} else {
canonicalize_moved_line(bytes, ignore)
}
}
fn canonicalize_moved_line(line: &[u8], ignore: WsIgnore) -> Vec<u8> {
let is_ws = |b: u8| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c);
if ignore.all_space {
return line.iter().copied().filter(|b| !is_ws(*b)).collect();
}
if ignore.space_change {
let mut out = Vec::with_capacity(line.len());
let mut i = 0usize;
while i < line.len() {
if is_ws(line[i]) {
out.push(b' ');
while i < line.len() && is_ws(line[i]) {
i += 1;
}
} else {
out.push(line[i]);
i += 1;
}
}
while out.last() == Some(&b' ') {
out.pop();
}
return out;
}
if ignore.space_at_eol {
let mut end = line.len();
if end > 0 && line[end - 1] == b'\n' {
end -= 1;
}
while end > 0 && matches!(line[end - 1], b' ' | b'\t') {
end -= 1;
}
let mut out = line[..end].to_vec();
if line.ends_with(b"\n") {
out.push(b'\n');
}
return out;
}
if ignore.cr_at_eol {
let mut out = line.to_vec();
if out.ends_with(b"\r\n") {
let len = out.len();
out.remove(len - 2);
}
return out;
}
line.to_vec()
}
fn moved_indent_data(line: &[u8]) -> (usize, i32) {
let mut off = 0usize;
let mut width = 0i32;
while off < line.len() && matches!(line[off], b'\x0c' | b'\x0b' | b'\r') {
off += 1;
}
while off < line.len() {
match line[off] {
b' ' => {
width += 1;
off += 1;
}
b'\t' => {
width += 8 - (width % 8);
off += 1;
while off < line.len() && line[off] == b'\t' {
width += 8;
off += 1;
}
}
_ => break,
}
}
if line[off..].iter().all(|b| b.is_ascii_whitespace()) {
(line.len(), INDENT_BLANKLINE)
} else {
(off, width)
}
}
fn pmb_advance_or_null(
pmb: &mut Vec<MovedBlock>,
entries: &[MovedEntry],
tagged: &[TaggedLine<'_>],
line_entry: usize,
ws: ColorMovedWs,
) {
let mut kept = Vec::with_capacity(pmb.len());
for mut block in pmb.iter().copied() {
let Some(cur) = entries[block.match_entry].next_line else {
continue;
};
let matched = if ws.allow_indentation_change {
!cmp_in_block_with_wsd(entries, cur, tagged, line_entry, &mut block)
} else {
entries[cur].id == entries[line_entry].id
};
if matched {
block.match_entry = cur;
kept.push(block);
}
}
*pmb = kept;
}
fn fill_potential_moved_blocks(
mut line_match: usize,
entries: &[MovedEntry],
tagged: &[TaggedLine<'_>],
tag_idx: usize,
ws: ColorMovedWs,
pmb: &mut Vec<MovedBlock>,
) {
loop {
let entry = &entries[line_match];
let wsd = if ws.allow_indentation_change {
compute_ws_delta(tagged[tag_idx].content, entry.indent_width)
} else {
0
};
pmb.push(MovedBlock {
match_entry: line_match,
wsd,
});
match entry.next_match {
Some(next) => line_match = next,
None => break,
}
}
}
fn compute_ws_delta(line: &[u8], match_indent_width: i32) -> i32 {
let (_, width) = moved_indent_data(line);
if width == INDENT_BLANKLINE && match_indent_width == INDENT_BLANKLINE {
INDENT_BLANKLINE
} else {
width - match_indent_width
}
}
fn cmp_in_block_with_wsd(
entries: &[MovedEntry],
cur: usize,
tagged: &[TaggedLine<'_>],
line_entry: usize,
block: &mut MovedBlock,
) -> bool {
let cur_entry = &entries[cur];
if cur_entry.id != entries[line_entry].id {
return true;
}
if cur_entry.indent_width == INDENT_BLANKLINE {
return false;
}
let (_, line_width) = moved_indent_data(tagged[entries[line_entry].tag_idx].content);
let delta = line_width - cur_entry.indent_width;
if block.wsd == INDENT_BLANKLINE {
block.wsd = delta;
}
delta != block.wsd
}
fn adjust_last_block(
styles: &mut [MovedStyle],
tagged: &[TaggedLine<'_>],
mode: ColorMovedMode,
n: usize,
block_length: usize,
) -> bool {
if mode == ColorMovedMode::Plain {
return block_length != 0;
}
let mut alnum_count = 0usize;
for i in 1..=block_length {
for byte in tagged[n - i].content {
if byte.is_ascii_alphanumeric() {
alnum_count += 1;
if alnum_count >= COLOR_MOVED_MIN_ALNUM_COUNT {
return true;
}
}
}
}
for i in 1..=block_length {
styles[n - i] = MovedStyle::default();
}
false
}
fn dim_moved_lines(styles: &mut [MovedStyle], tagged: &[TaggedLine<'_>]) {
for n in 0..tagged.len() {
if tagged[n].kind != LineKind::Insert && tagged[n].kind != LineKind::Delete {
continue;
}
if !styles[n].moved {
continue;
}
let prev = (n > 0
&& (tagged[n - 1].kind == LineKind::Insert || tagged[n - 1].kind == LineKind::Delete))
.then_some(n - 1);
let next = (n + 1 < tagged.len()
&& (tagged[n + 1].kind == LineKind::Insert || tagged[n + 1].kind == LineKind::Delete))
.then_some(n + 1);
if prev.is_some_and(|i| moved_zebra_mask(styles[i]) == moved_zebra_mask(styles[n]))
&& next.is_some_and(|i| moved_zebra_mask(styles[i]) == moved_zebra_mask(styles[n]))
{
styles[n].uninteresting = true;
continue;
}
if prev.is_some_and(|i| styles[i].moved && styles[i].alt != styles[n].alt) {
continue;
}
if next.is_some_and(|i| styles[i].moved && styles[i].alt != styles[n].alt) {
continue;
}
styles[n].uninteresting = true;
}
}
fn moved_zebra_mask(style: MovedStyle) -> (bool, bool) {
(style.moved, style.alt)
}
fn render_one_hunk(
out: &mut Vec<u8>,
tagged: &[TaggedLine<'_>],
moved_styles: Option<&[MovedStyle]>,
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 (offset, line) in slice.iter().enumerate() {
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)
});
let moved = moved_styles
.and_then(|styles| styles.get(start + offset))
.copied()
.filter(|style| style.moved);
write_patch_line_colored(out, prefix, line.content, colors, ws_rule, moved);
}
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>,
moved: Option<MovedStyle>,
) {
let (body, terminated) = match line.split_last() {
Some((b'\n', body)) => (body, true),
_ => (line, false),
};
let color = match (prefix, moved) {
(b'-', Some(style)) if style.uninteresting && style.alt => colors.old_moved_alt_dim,
(b'-', Some(style)) if style.uninteresting => colors.old_moved_dim,
(b'-', Some(style)) if style.alt => colors.old_moved_alt,
(b'-', Some(_)) => colors.old_moved,
(b'+', Some(style)) if style.uninteresting && style.alt => colors.new_moved_alt_dim,
(b'+', Some(style)) if style.uninteresting => colors.new_moved_dim,
(b'+', Some(style)) if style.alt => colors.new_moved_alt,
(b'+', Some(_)) => colors.new_moved,
(b'-', _) => colors.old,
(b'+', _) => colors.new,
_ => colors.context,
};
if let Some(rule) = ws_rule {
if rule == 0 {
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');
}
return;
}
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 {
let marker_color = if rule & crate::ws::WS_INCOMPLETE_LINE != 0 {
colors.whitespace
} else {
colors.context
};
out.extend_from_slice(marker_color.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');
}
}
struct CdLine {
bol: Vec<u8>,
lost: Vec<CdLost>,
plost: Vec<Vec<u8>>,
flag: u64,
p_lno: Vec<u64>,
}
struct CdLost {
line: Vec<u8>,
parent_map: u64,
}
pub struct CombinedRenderOptions {
pub dense: bool,
pub context: usize,
pub algorithm: DiffAlgorithm,
pub ws_ignore: WsIgnore,
}
impl Default for CombinedRenderOptions {
fn default() -> Self {
Self {
dense: true,
context: DEFAULT_CONTEXT,
algorithm: DiffAlgorithm::Myers,
ws_ignore: WsIgnore::default(),
}
}
}
pub fn render_combined(out: &mut Vec<u8>, result: &[u8], parents: &[&[u8]]) -> bool {
render_combined_with(out, result, parents, &CombinedRenderOptions::default())
}
pub fn render_combined_with(
out: &mut Vec<u8>,
result: &[u8],
parents: &[&[u8]],
options: &CombinedRenderOptions,
) -> bool {
let num_parent = parents.len();
debug_assert!(num_parent >= 1);
let result_lines = split_lines(result);
let cnt = result_lines.len();
let mut sline: Vec<CdLine> = Vec::with_capacity(cnt + 2);
for line in &result_lines {
sline.push(CdLine {
bol: line.bytes_without_newline().to_vec(),
lost: Vec::new(),
plost: Vec::new(),
flag: 0,
p_lno: vec![0; num_parent],
});
}
for _ in 0..2 {
sline.push(CdLine {
bol: Vec::new(),
lost: Vec::new(),
plost: Vec::new(),
flag: 0,
p_lno: vec![0; num_parent],
});
}
for n in 0..num_parent {
let mut reused = None;
for j in 0..n {
if parents[j] == parents[n] {
reused = Some(j);
break;
}
}
match reused {
Some(j) => reuse_combine_diff(&mut sline, cnt, n, j),
None => combine_one_parent(&mut sline, &result_lines, parents[n], n, options),
}
}
let show_hunks = make_hunks(&mut sline, cnt, num_parent, options.dense, options.context);
if show_hunks {
dump_sline(out, &sline, cnt, num_parent, options.context);
}
show_hunks
}
fn combine_one_parent(
sline: &mut [CdLine],
result_lines: &[DiffLine<'_>],
parent: &[u8],
n: usize,
options: &CombinedRenderOptions,
) {
let cnt = result_lines.len();
let nmask = 1u64 << n;
let parent_lines = split_lines(parent);
let ops = myers_diff_lines_ws(
&parent_lines,
result_lines,
options.ws_ignore,
options.algorithm,
);
let mut old_idx: usize = 0; let mut new_idx: usize = 0; let mut i = 0;
while i < ops.len() {
match ops[i] {
DiffOp::Equal(k) => {
old_idx += k;
new_idx += k;
i += 1;
}
_ => {
let hunk_old_start = old_idx; let hunk_new_start = new_idx; let mut dels: Vec<&[u8]> = Vec::new();
while i < ops.len() {
match ops[i] {
DiffOp::Delete(k) => {
for _ in 0..k {
dels.push(parent_lines[old_idx].bytes_without_newline());
old_idx += 1;
}
i += 1;
}
DiffOp::Insert(k) => {
new_idx += k;
i += 1;
}
DiffOp::Equal(_) => break,
}
}
let _ = hunk_old_start;
for d in &dels {
sline[hunk_new_start].plost.push(d.to_vec());
}
for line in sline.iter_mut().take(new_idx.min(cnt)).skip(hunk_new_start) {
line.flag |= nmask;
}
}
}
}
let mut p_lno: u64 = 1;
for (lno, line) in sline.iter_mut().enumerate().take(cnt + 1) {
line.p_lno[n] = p_lno;
if !line.plost.is_empty() {
let plost = std::mem::take(&mut line.plost);
coalesce_lost(&mut line.lost, plost, n, options);
}
for ll in &line.lost {
if ll.parent_map & nmask != 0 {
p_lno += 1; }
}
if lno < cnt && (line.flag & nmask) == 0 {
p_lno += 1; }
}
sline[cnt + 1].p_lno[n] = p_lno; }
fn coalesce_lost(
base: &mut Vec<CdLost>,
newlines: Vec<Vec<u8>>,
n: usize,
options: &CombinedRenderOptions,
) {
let pmask = 1u64 << n;
if newlines.is_empty() {
return;
}
if base.is_empty() {
for line in newlines {
base.push(CdLost {
line,
parent_map: pmask,
});
}
return;
}
let m = base.len();
let k = newlines.len();
let mut lcs = vec![vec![0i32; k + 1]; m + 1];
for i in 1..=m {
for j in 1..=k {
if combined_lines_match(&base[i - 1].line, &newlines[j - 1], options.ws_ignore) {
lcs[i][j] = lcs[i - 1][j - 1] + 1;
} else if lcs[i][j - 1] >= lcs[i - 1][j] {
lcs[i][j] = lcs[i][j - 1];
} else {
lcs[i][j] = lcs[i - 1][j];
}
}
}
let mut merged: Vec<CdLost> = Vec::with_capacity(m + k);
let mut i = m;
let mut j = k;
while i > 0 || j > 0 {
if i > 0
&& j > 0
&& combined_lines_match(&base[i - 1].line, &newlines[j - 1], options.ws_ignore)
{
let mut entry = std::mem::replace(
&mut base[i - 1],
CdLost {
line: Vec::new(),
parent_map: 0,
},
);
entry.parent_map |= pmask;
merged.push(entry);
i -= 1;
j -= 1;
} else if j > 0 && (i == 0 || lcs[i][j - 1] >= lcs[i - 1][j]) {
merged.push(CdLost {
line: newlines[j - 1].clone(),
parent_map: pmask,
});
j -= 1;
} else {
let entry = std::mem::replace(
&mut base[i - 1],
CdLost {
line: Vec::new(),
parent_map: 0,
},
);
merged.push(entry);
i -= 1;
}
}
merged.reverse();
*base = merged;
}
fn combined_lines_match(a: &[u8], b: &[u8], ws: WsIgnore) -> bool {
if ws.all_space || ws.space_change || ws.space_at_eol {
let at = strip_trailing_ws(a);
let bt = strip_trailing_ws(b);
if !ws.all_space && !ws.space_change {
return at == bt;
}
return ws_squash_eq(at, bt, ws.space_change);
}
a == b
}
fn strip_trailing_ws(s: &[u8]) -> &[u8] {
let mut end = s.len();
while end > 0 && (s[end - 1] == b' ' || s[end - 1] == b'\t') {
end -= 1;
}
&s[..end]
}
fn ws_squash_eq(a: &[u8], b: &[u8], change_only: bool) -> bool {
let is_ws = |c: u8| c == b' ' || c == b'\t';
let (mut ia, mut ib) = (0usize, 0usize);
while ia < a.len() && ib < b.len() {
let (ca, cb) = (a[ia], b[ib]);
if is_ws(ca) || is_ws(cb) {
if change_only && (!is_ws(ca) || !is_ws(cb)) {
return false;
}
if change_only {
while ia < a.len() && is_ws(a[ia]) {
ia += 1;
}
while ib < b.len() && is_ws(b[ib]) {
ib += 1;
}
continue;
} else {
if is_ws(ca) {
ia += 1;
continue;
}
if is_ws(cb) {
ib += 1;
continue;
}
}
}
if ca != cb {
return false;
}
ia += 1;
ib += 1;
}
while ia < a.len() && is_ws(a[ia]) {
ia += 1;
}
while ib < b.len() && is_ws(b[ib]) {
ib += 1;
}
ia == a.len() && ib == b.len()
}
fn reuse_combine_diff(sline: &mut [CdLine], cnt: usize, i: usize, j: usize) {
let imask = 1u64 << i;
let jmask = 1u64 << j;
for line in sline.iter_mut().take(cnt + 1) {
line.p_lno[i] = line.p_lno[j];
for ll in &mut line.lost {
if ll.parent_map & jmask != 0 {
ll.parent_map |= imask;
}
}
if line.flag & jmask != 0 {
line.flag |= imask;
}
}
sline[cnt + 1].p_lno[i] = sline[cnt + 1].p_lno[j];
}
fn cd_interesting(sline: &CdLine, all_mask: u64) -> bool {
(sline.flag & all_mask) != 0 || !sline.lost.is_empty()
}
fn adjust_hunk_tail(sline: &[CdLine], all_mask: u64, hunk_begin: usize, mut i: usize) -> usize {
if hunk_begin < i && (sline[i - 1].flag & all_mask) == 0 {
i -= 1;
}
i
}
fn find_next(
sline: &[CdLine],
mark: u64,
mut i: usize,
cnt: usize,
look_for_uninteresting: bool,
) -> usize {
while i <= cnt {
let marked = (sline[i].flag & mark) != 0;
if look_for_uninteresting {
if !marked {
return i;
}
} else if marked {
return i;
}
i += 1;
}
i
}
fn give_context(sline: &mut [CdLine], cnt: usize, num_parent: usize, context: usize) -> bool {
let all_mask = (1u64 << num_parent) - 1;
let mark = 1u64 << num_parent;
let no_pre_delete = 2u64 << num_parent;
let mut i = find_next(sline, mark, 0, cnt, false);
if cnt < i {
return false;
}
while i <= cnt {
let mut j = i.saturating_sub(context);
while j < i {
if (sline[j].flag & mark) == 0 {
sline[j].flag |= no_pre_delete;
}
sline[j].flag |= mark;
j += 1;
}
loop {
j = find_next(sline, mark, i, cnt, true);
if cnt < j {
return true;
}
let k = find_next(sline, mark, j, cnt, false);
let j2 = adjust_hunk_tail(sline, all_mask, i, j);
if k < j2 + context {
let mut jj = j2;
while jj < k {
sline[jj].flag |= mark;
jj += 1;
}
i = k;
continue;
}
i = k;
let kk = if j2 + context < cnt + 1 {
j2 + context
} else {
cnt + 1
};
let mut jj = j2;
while jj < kk {
sline[jj].flag |= mark;
jj += 1;
}
break;
}
}
true
}
fn make_hunks(
sline: &mut [CdLine],
cnt: usize,
num_parent: usize,
dense: bool,
context: usize,
) -> bool {
let all_mask = (1u64 << num_parent) - 1;
let mark = 1u64 << num_parent;
for line in sline.iter_mut().take(cnt + 1) {
if cd_interesting(line, all_mask) {
line.flag |= mark;
} else {
line.flag &= !mark;
}
}
if !dense {
return give_context(sline, cnt, num_parent, context);
}
let mut i = 0;
while i <= cnt {
while i <= cnt && (sline[i].flag & mark) == 0 {
i += 1;
}
if cnt < i {
break;
}
let hunk_begin = i;
let mut j = i + 1;
while j <= cnt {
if (sline[j].flag & mark) == 0 {
let mut la = adjust_hunk_tail(sline, all_mask, hunk_begin, j);
la = if la + context < cnt + 1 {
la + context
} else {
cnt + 1
};
let mut contin = false;
while la > 0 && j < la {
la -= 1;
if (sline[la].flag & mark) != 0 {
contin = true;
break;
}
}
if !contin {
break;
}
j = la;
}
j += 1;
}
let hunk_end = j;
let mut same_diff: u64 = 0;
let mut has_interesting = false;
let mut jj = i;
while jj < hunk_end && !has_interesting {
let this_diff = sline[jj].flag & all_mask;
if this_diff != 0 {
if same_diff == 0 {
same_diff = this_diff;
} else if same_diff != this_diff {
has_interesting = true;
break;
}
}
for ll in &sline[jj].lost {
if has_interesting {
break;
}
let td = ll.parent_map;
if same_diff == 0 {
same_diff = td;
} else if same_diff != td {
has_interesting = true;
}
}
jj += 1;
}
if !has_interesting && same_diff != all_mask {
for line in sline.iter_mut().take(hunk_end).skip(hunk_begin) {
line.flag &= !mark;
}
}
i = hunk_end;
}
give_context(sline, cnt, num_parent, context)
}
fn show_parent_lno(
out: &mut Vec<u8>,
sline: &[CdLine],
l0: usize,
l1: usize,
n: usize,
null_context: u64,
) {
let a = sline[l0].p_lno[n];
let b = sline[l1].p_lno[n];
out.extend_from_slice(format!(" -{},{}", a, b - a - null_context).as_bytes());
}
fn hunk_comment_line(bol: &[u8]) -> bool {
if bol.is_empty() {
return false;
}
let ch = bol[0];
ch.is_ascii_alphabetic() || ch == b'_' || ch == b'$'
}
fn show_line_to_eol(out: &mut Vec<u8>, line: &[u8]) {
let saw_cr = line.last() == Some(&b'\r');
if saw_cr {
out.extend_from_slice(&line[..line.len() - 1]);
out.push(b'\r');
} else {
out.extend_from_slice(line);
}
out.push(b'\n');
}
fn dump_sline(out: &mut Vec<u8>, sline: &[CdLine], cnt: usize, num_parent: usize, context: usize) {
let mark = 1u64 << num_parent;
let no_pre_delete = 2u64 << num_parent;
let mut lno: usize = 0;
loop {
let mut hunk_comment: Option<&[u8]> = None;
while lno <= cnt && (sline[lno].flag & mark) == 0 {
if hunk_comment_line(&sline[lno].bol) {
hunk_comment = Some(&sline[lno].bol);
}
lno += 1;
}
if cnt < lno {
break;
}
let mut hunk_end = lno + 1;
while hunk_end <= cnt {
if (sline[hunk_end].flag & mark) == 0 {
break;
}
hunk_end += 1;
}
let mut rlines = (hunk_end - lno) as u64;
if cnt < hunk_end {
rlines -= 1; }
let mut null_context: u64 = 0;
if context == 0 {
for sl in sline.iter().take(hunk_end).skip(lno) {
if (sl.flag & (mark - 1)) == 0 {
null_context += 1;
}
}
rlines -= null_context;
}
for _ in 0..=num_parent {
out.push(b'@');
}
for i in 0..num_parent {
show_parent_lno(out, sline, lno, hunk_end, i, null_context);
}
out.extend_from_slice(format!(" +{},{} ", lno + 1, rlines).as_bytes());
for _ in 0..=num_parent {
out.push(b'@');
}
if let Some(comment) = hunk_comment {
let mut comment_end = 0;
for (idx, &ch) in comment.iter().take(40).enumerate() {
if ch == b'\n' {
break;
}
if !ch.is_ascii_whitespace() {
comment_end = idx + 1;
}
}
if comment_end != 0 {
out.push(b' ');
out.extend_from_slice(&comment[..comment_end]);
}
}
out.push(b'\n');
while lno < hunk_end {
let sl = &sline[lno];
lno += 1;
if (sl.flag & no_pre_delete) == 0 {
for ll in &sl.lost {
for j in 0..num_parent {
if ll.parent_map & (1u64 << j) != 0 {
out.push(b'-');
} else {
out.push(b' ');
}
}
show_line_to_eol(out, &ll.line);
}
}
if cnt < lno {
break;
}
if (sl.flag & (mark - 1)) == 0 {
if context == 0 {
continue;
}
}
let mut p_mask = 1u64;
for _ in 0..num_parent {
if p_mask & sl.flag != 0 {
out.push(b'+');
} else {
out.push(b' ');
}
p_mask <<= 1;
}
show_line_to_eol(out, &sl.bol);
}
}
}
#[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}",
);
}
fn render_cc(result: &[u8], parents: &[&[u8]], dense: bool) -> String {
let mut out = Vec::new();
let opts = CombinedRenderOptions {
dense,
..Default::default()
};
render_combined_with(&mut out, result, parents, &opts);
String::from_utf8(out).expect("combined output is valid UTF-8")
}
#[test]
fn combined_two_parent_dense_header_and_columns() {
let p0 = b"A\nB\nC\nD\nE\nF\n";
let p1 = b"A\nB\n1\n2\n";
let result = b"A\nB\nC\nD\nE\nF\n1\n2\n";
let text = render_cc(result, &[p0, p1], true);
assert_eq!(
text, "@@@ -1,6 -1,4 +1,8 @@@\n A\n B\n +C\n +D\n +E\n +F\n+ 1\n+ 2\n",
"combined dense output:\n{text}",
);
}
#[test]
fn combined_identical_to_one_parent_dense_drops_hunk() {
let p0 = b"x\ny\n";
let p1 = b"x\nCHANGED\n";
let result = b"x\ny\n"; assert_eq!(render_cc(result, &[p0, p1], true), "");
assert!(render_cc(result, &[p0, p1], false).starts_with("@@@"));
}
#[test]
fn combined_reuse_identical_parents() {
let parent = b"a\nb\n";
let result = b"a\nb\nc\n";
let text = render_cc(result, &[parent, parent], true);
assert_eq!(
text, "@@@ -1,2 -1,2 +1,3 @@@\n a\n b\n++c\n",
"reuse output:\n{text}",
);
}
}