use crate::diff::core::is_func_line;
use crate::userdiff::FuncnameMatcher;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineEnding {
Lf,
CrLf,
Missing,
}
#[derive(Clone, Debug)]
pub struct SourceLine {
pub body: String,
pub ending: LineEnding,
}
#[derive(Clone, Debug)]
pub struct LineSide {
pub lines: Vec<SourceLine>,
}
impl LineSide {
#[must_use]
pub fn from_text(text: &str) -> Self {
if text.is_empty() {
return Self { lines: Vec::new() };
}
let mut lines = Vec::new();
let mut start = 0usize;
for (i, b) in text.bytes().enumerate() {
if b == b'\n' {
lines.push(parse_line_segment(&text[start..i], true));
start = i + 1;
}
}
if start < text.len() {
lines.push(parse_line_segment(&text[start..], false));
}
Self { lines }
}
#[must_use]
pub fn len(&self) -> usize {
self.lines.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.lines.is_empty()
}
pub fn line(&self, index: usize) -> &SourceLine {
&self.lines[index]
}
#[must_use]
pub fn incomplete_last_line_index(&self) -> Option<usize> {
if self.lines.is_empty() {
return None;
}
let last = self.lines.len() - 1;
(self.lines[last].ending == LineEnding::Missing).then_some(last)
}
}
#[must_use]
pub(crate) fn line_diff_token(line: &SourceLine) -> String {
match line.ending {
LineEnding::Missing => line.body.clone(),
LineEnding::Lf | LineEnding::CrLf => {
let mut s = String::with_capacity(line.body.len() + 1);
s.push_str(&line.body);
s.push('\n');
s
}
}
}
fn parse_line_segment(raw: &str, had_line_break: bool) -> SourceLine {
if had_line_break {
let ending = if raw.ends_with('\r') {
LineEnding::CrLf
} else {
LineEnding::Lf
};
SourceLine {
body: raw.to_string(),
ending,
}
} else {
SourceLine {
body: raw.to_string(),
ending: LineEnding::Missing,
}
}
}
#[must_use]
pub fn line_ending_only_edits(before: &LineSide, after: &LineSide) -> Vec<EditSpan> {
if before.len() != after.len() {
return Vec::new();
}
let mut edits = Vec::new();
for i in 0..before.len() {
let bl = before.line(i);
let al = after.line(i);
if bl.body != al.body {
return Vec::new();
}
if bl.ending != al.ending {
edits.push(EditSpan {
before_start: i as i64,
before_len: 1,
after_start: i as i64,
after_len: 1,
skip_in_output: false,
});
}
}
edits
}
#[derive(Clone, Debug)]
pub struct EditSpan {
pub before_start: i64,
pub before_len: i64,
pub after_start: i64,
pub after_len: i64,
pub skip_in_output: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct UnifiedEmitOptions {
pub context_lines: usize,
pub inter_hunk_context: usize,
pub function_context: bool,
}
pub fn write_patch_line(out: &mut String, prefix: char, line: &SourceLine) {
out.push(prefix);
out.push_str(&line.body);
out.push('\n');
if line.ending == LineEnding::Missing {
out.push_str("\\ No newline at end of file\n");
}
}
fn format_hunk_side(start0: i64, count: i64) -> String {
let shown_start = if count == 0 { start0 } else { start0 + 1 };
if count == 1 {
format!("{shown_start}")
} else {
format!("{shown_start},{count}")
}
}
#[allow(clippy::too_many_arguments)]
pub fn emit_unified_body_from_edits(
before: &LineSide,
after: &LineSide,
edits: &[EditSpan],
options: UnifiedEmitOptions,
funcname_matcher: Option<&FuncnameMatcher>,
) -> String {
use std::fmt::Write as _;
if edits.is_empty() {
return String::new();
}
let before_len = before.len() as i64;
let after_len = after.len() as i64;
let ctxlen = options.context_lines as i64;
let interhunk = options.inter_hunk_context as i64;
let max_common = ctxlen.saturating_add(ctxlen).saturating_add(interhunk);
let max_ignorable = ctxlen;
let before_body = |i: i64| -> &str {
if i < 0 || i as usize >= before.lines.len() {
return "";
}
&before.lines[i as usize].body
};
let next_hunk = |start: usize| -> Option<(usize, usize)> {
let mut scr = start;
let mut xchp = start;
while xchp < edits.len() && edits[xchp].skip_in_output {
let next = xchp + 1;
if next >= edits.len()
|| edits[next].before_start - (edits[xchp].before_start + edits[xchp].before_len)
>= max_ignorable
{
scr = next;
}
xchp = next;
}
if scr >= edits.len() {
return None;
}
let mut lx = scr;
let mut ignored_after: i64 = 0;
let mut prev = scr;
let mut idx = scr + 1;
while idx < edits.len() {
let cur = &edits[idx];
let prev_e = &edits[prev];
let distance = cur.before_start - (prev_e.before_start + prev_e.before_len);
if distance > max_common {
break;
}
if distance < max_ignorable && (!cur.skip_in_output || lx == prev) {
lx = idx;
ignored_after = 0;
} else if distance < max_ignorable && cur.skip_in_output {
ignored_after += cur.after_len;
} else if lx != prev
&& cur.before_start + ignored_after
- (edits[lx].before_start + edits[lx].before_len)
> max_common
{
break;
} else if !cur.skip_in_output {
lx = idx;
ignored_after = 0;
} else {
ignored_after += cur.after_len;
}
prev = idx;
idx += 1;
}
Some((scr, lx))
};
let mut out = String::new();
let mut cursor = 0usize;
while cursor < edits.len() {
let Some((first_idx, last_idx)) = next_hunk(cursor) else {
break;
};
let first = &edits[first_idx];
let last = &edits[last_idx];
let mut b_start = (first.before_start - ctxlen).max(0);
let mut a_start = (first.after_start - ctxlen).max(0);
let mut post = ctxlen;
post = post.min(before_len - (last.before_start + last.before_len));
post = post.min(after_len - (last.after_start + last.after_len));
let mut b_end = last.before_start + last.before_len + post;
let mut a_end = last.after_start + last.after_len + post;
if options.function_context {
let mut func_start = first.before_start;
while func_start >= 0 && !is_func_line(before_body(func_start), funcname_matcher) {
func_start -= 1;
}
while func_start > 0
&& !before_body(func_start - 1).trim().is_empty()
&& !is_func_line(before_body(func_start - 1), funcname_matcher)
{
func_start -= 1;
}
if func_start < 0 {
func_start = 0;
}
if func_start < b_start {
a_start = (a_start - (b_start - func_start)).max(0);
b_start = func_start;
}
let end_before = last.before_start + last.before_len;
let mut func_end = end_before;
while func_end < before_len && !is_func_line(before_body(func_end), funcname_matcher) {
func_end += 1;
}
while func_end > 0 && before_body(func_end - 1).trim().is_empty() {
func_end -= 1;
}
if func_end > before_len {
func_end = before_len;
}
if func_end > b_end {
a_end = (a_end + (func_end - b_end)).min(after_len);
b_end = func_end;
}
}
let _ = writeln!(
out,
"@@ -{} +{} @@",
format_hunk_side(b_start, b_end - b_start),
format_hunk_side(a_start, a_end - a_start)
);
let mut a_cur = a_start;
while a_cur < first.after_start {
write_patch_line(&mut out, ' ', after.line(a_cur as usize));
a_cur += 1;
}
let mut b_cur = first.before_start;
let mut a_cur = first.after_start;
let mut k = first_idx;
loop {
let e = &edits[k];
while b_cur < e.before_start && a_cur < e.after_start {
write_patch_line(&mut out, ' ', after.line(a_cur as usize));
b_cur += 1;
a_cur += 1;
}
let mut r = e.before_start;
while r < e.before_start + e.before_len {
write_patch_line(&mut out, '-', before.line(r as usize));
r += 1;
}
let mut a = e.after_start;
while a < e.after_start + e.after_len {
write_patch_line(&mut out, '+', after.line(a as usize));
a += 1;
}
if k == last_idx {
break;
}
b_cur = e.before_start + e.before_len;
a_cur = e.after_start + e.after_len;
k += 1;
}
let mut a_tail = last.after_start + last.after_len;
while a_tail < a_end {
write_patch_line(&mut out, ' ', after.line(a_tail as usize));
a_tail += 1;
}
cursor = last_idx + 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_preserves_crlf_in_body() {
let side = LineSide::from_text("a\r\nb");
assert_eq!(side.lines.len(), 2);
assert_eq!(side.lines[0].body, "a\r");
assert_eq!(side.lines[0].ending, LineEnding::CrLf);
assert_eq!(side.lines[1].body, "b");
assert_eq!(side.lines[1].ending, LineEnding::Missing);
}
#[test]
fn patch_line_marks_missing_newline() {
let line = SourceLine {
body: "x".into(),
ending: LineEnding::Missing,
};
let mut out = String::new();
write_patch_line(&mut out, '-', &line);
assert!(out.contains("\\ No newline at end of file"));
}
#[test]
fn eof_newline_only_change_yields_replace_hunk() {
let before = LineSide::from_text("one");
let after = LineSide::from_text("one\n");
let edits = line_ending_only_edits(&before, &after);
assert_eq!(edits.len(), 1);
let body = emit_unified_body_from_edits(
&before,
&after,
&edits,
UnifiedEmitOptions {
context_lines: 3,
inter_hunk_context: 0,
function_context: false,
},
None,
);
assert!(body.contains("-one\n"));
assert!(body.contains("+one\n"));
assert!(body.contains("\\ No newline at end of file"));
}
#[test]
fn line_diff_token_distinguishes_eof_from_terminated() {
let no_nl = LineSide::from_text("a\nb");
let with_nl = LineSide::from_text("a\nb\n");
assert_ne!(
line_diff_token(no_nl.line(1)),
line_diff_token(with_nl.line(1))
);
}
}