#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeRun {
pub added_lines: Vec<usize>,
pub lines: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Segment {
Context(String),
Run(ChangeRun),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Hunk {
header: String,
segments: Vec<Segment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileDiff {
preamble: Vec<String>,
hunks: Vec<Hunk>,
}
fn parse_new_start(header: &str) -> Option<usize> {
let plus = header.split('+').nth(1)?;
let num: String = plus.chars().take_while(|c| c.is_ascii_digit()).collect();
num.parse().ok()
}
impl FileDiff {
pub fn parse(diff: &str) -> FileDiff {
let mut preamble = Vec::new();
let mut hunks: Vec<Hunk> = Vec::new();
let mut new_line = 0usize;
let mut current_run: Option<ChangeRun> = None;
fn flush(current_run: &mut Option<ChangeRun>, hunk: Option<&mut Hunk>) {
if let (Some(run), Some(hunk)) = (current_run.take(), hunk) {
hunk.segments.push(Segment::Run(run));
}
}
for raw in diff.split_inclusive('\n') {
let line = raw.strip_suffix('\n').unwrap_or(raw);
if line.starts_with("@@") {
flush(&mut current_run, hunks.last_mut());
new_line = parse_new_start(line).unwrap_or(1);
hunks.push(Hunk {
header: raw.to_string(),
segments: Vec::new(),
});
} else if hunks.is_empty() {
preamble.push(raw.to_string());
} else if let Some(rest) = line.strip_prefix('+') {
let _ = rest;
let run = current_run.get_or_insert_with(|| ChangeRun {
added_lines: Vec::new(),
lines: Vec::new(),
});
run.added_lines.push(new_line);
run.lines.push(raw.to_string());
new_line += 1;
} else if line.starts_with('-') {
let run = current_run.get_or_insert_with(|| ChangeRun {
added_lines: Vec::new(),
lines: Vec::new(),
});
run.lines.push(raw.to_string());
} else if line.starts_with('\\') {
if let Some(run) = current_run.as_mut() {
run.lines.push(raw.to_string());
} else if let Some(hunk) = hunks.last_mut() {
hunk.segments.push(Segment::Context(raw.to_string()));
}
} else {
flush(&mut current_run, hunks.last_mut());
if let Some(hunk) = hunks.last_mut() {
hunk.segments.push(Segment::Context(raw.to_string()));
}
new_line += 1;
}
}
flush(&mut current_run, hunks.last_mut());
FileDiff { preamble, hunks }
}
pub fn render_filtered(&self, omit: impl Fn(&ChangeRun) -> bool) -> String {
let mut out = String::new();
for line in &self.preamble {
out.push_str(line);
}
for hunk in &self.hunks {
out.push_str(&hunk.header);
for seg in &hunk.segments {
match seg {
Segment::Context(l) => out.push_str(l),
Segment::Run(run) if omit(run) => {
out.push_str(&omission_marker(run));
}
Segment::Run(run) => {
for l in &run.lines {
out.push_str(l);
}
}
}
}
}
out
}
}
fn omission_marker(run: &ChangeRun) -> String {
let span = match (run.added_lines.first(), run.added_lines.last()) {
(Some(a), Some(b)) if a == b => format!("line {a}"),
(Some(a), Some(b)) => format!("lines {a}\u{2013}{b}"),
_ => "changed lines".to_string(),
};
let n = run.added_lines.len();
format!("[llmlint: {n} changed {span} omitted — ignored for all applicable rules]\n")
}
#[cfg(test)]
mod tests {
use super::*;
const DIFF: &str = "\
diff --git a/src/lib.rs b/src/lib.rs
index e69de29..1c2d3e4 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,6 @@
fn a() {}
+// TODO one
+// TODO two
fn b() {}
-fn old() {}
+fn new() {}
";
#[test]
fn parse_splits_runs_at_context_and_numbers_added_lines() {
let d = FileDiff::parse(DIFF);
assert_eq!(d.preamble.len(), 4, "four preamble lines before the hunk");
assert_eq!(d.hunks.len(), 1);
let runs: Vec<&ChangeRun> = d.hunks[0]
.segments
.iter()
.filter_map(|s| match s {
Segment::Run(r) => Some(r),
Segment::Context(_) => None,
})
.collect();
assert_eq!(runs.len(), 2);
assert_eq!(runs[0].added_lines, vec![2, 3]);
assert_eq!(runs[1].added_lines, vec![5]);
}
#[test]
fn render_with_no_omission_is_byte_identical() {
let d = FileDiff::parse(DIFF);
assert_eq!(d.render_filtered(|_| false), DIFF);
}
#[test]
fn omitting_a_run_replaces_it_with_a_marker_and_keeps_the_rest() {
let d = FileDiff::parse(DIFF);
let out = d.render_filtered(|r| r.added_lines == vec![2, 3]);
assert!(out.contains("2 changed lines 2\u{2013}3 omitted"), "{out}");
assert!(!out.contains("// TODO one"), "{out}");
assert!(out.contains("+fn new() {}"), "{out}");
assert!(out.contains(" fn a() {}"), "{out}");
assert!(out.contains("@@ -1,4 +1,6 @@"), "{out}");
}
#[test]
fn a_single_omitted_line_reads_as_line_n() {
let d = FileDiff::parse(DIFF);
let out = d.render_filtered(|r| r.added_lines == vec![5]);
assert!(out.contains("1 changed line 5 omitted"), "{out}");
}
#[test]
fn a_pure_deletion_run_has_no_added_lines() {
let diff = "\
@@ -1,3 +1,2 @@
keep
-gone
tail
";
let d = FileDiff::parse(diff);
let run = d.hunks[0]
.segments
.iter()
.find_map(|s| match s {
Segment::Run(r) => Some(r),
Segment::Context(_) => None,
})
.unwrap();
assert!(
run.added_lines.is_empty(),
"pure deletion has no new-file line"
);
assert!(omission_marker(run).contains("changed lines omitted"));
}
#[test]
fn no_newline_marker_attaches_to_its_run_or_to_context() {
let attached = "@@ -1 +1 @@\n-old\n+new\n\\ No newline at end of file\n";
let d = FileDiff::parse(attached);
let run = d.hunks[0]
.segments
.iter()
.find_map(|s| match s {
Segment::Run(r) => Some(r),
Segment::Context(_) => None,
})
.unwrap();
assert!(run.lines.iter().any(|l| l.contains("No newline")));
assert_eq!(d.render_filtered(|_| false), attached);
let ctx = "@@ -1 +1 @@\n keep\n\\ No newline at end of file\n";
let d2 = FileDiff::parse(ctx);
assert_eq!(d2.render_filtered(|_| false), ctx);
}
#[test]
fn an_unparseable_header_still_passes_through() {
let diff = "@@ garbage @@\n context\n+added\n";
let d = FileDiff::parse(diff);
assert_eq!(d.hunks.len(), 1);
assert_eq!(d.render_filtered(|_| false), diff);
}
}