#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LineKind {
Context,
Added,
Deleted,
}
#[derive(Debug, Clone)]
pub struct DiffHunk {
old_start: u32,
new_start: u32,
lines: Vec<HunkLine>,
}
#[derive(Debug, Clone)]
struct HunkLine {
kind: LineKind,
content: String,
}
struct IndexedLine {
line_num: u32,
content: String,
}
#[derive(Debug, Clone, Default)]
pub struct ResolveTarget {
pub snippet: Option<String>,
pub claimed_line: Option<i32>,
}
pub fn parse_hunks(diff_section: &str) -> Vec<DiffHunk> {
let mut hunks: Vec<DiffHunk> = Vec::new();
let mut current: Option<DiffHunk> = None;
for raw in diff_section.lines() {
let line = raw.strip_suffix('\r').unwrap_or(raw);
if line.starts_with("@@") {
if let Some(h) = current.take() {
hunks.push(h);
}
current = parse_hunk_header(line);
continue;
}
let Some(h) = current.as_mut() else {
continue;
};
if line.starts_with('\\') {
continue;
}
match line.as_bytes().first() {
Some(b'+') => h.lines.push(HunkLine {
kind: LineKind::Added,
content: line[1..].to_owned(),
}),
Some(b'-') => h.lines.push(HunkLine {
kind: LineKind::Deleted,
content: line[1..].to_owned(),
}),
Some(b' ') => h.lines.push(HunkLine {
kind: LineKind::Context,
content: line[1..].to_owned(),
}),
None => h.lines.push(HunkLine {
kind: LineKind::Context,
content: String::new(),
}),
Some(_) => {}
}
}
if let Some(h) = current.take() {
hunks.push(h);
}
hunks
}
fn parse_hunk_header(header: &str) -> Option<DiffHunk> {
let inner = header.strip_prefix("@@")?;
let end = inner.find("@@")?;
let spec = inner[..end].trim();
let mut parts = spec.split_whitespace();
let old = parts.next()?.strip_prefix('-')?;
let new = parts.next()?.strip_prefix('+')?;
let old_start = old.split(',').next()?.parse::<u32>().ok()?;
let new_start = new.split(',').next()?.parse::<u32>().ok()?;
Some(DiffHunk {
old_start,
new_start,
lines: Vec::new(),
})
}
pub fn resolve_issue_lines(target: &ResolveTarget, hunks: &[DiffHunk]) -> Option<(i32, i32)> {
if hunks.is_empty() {
return None;
}
if let Some(snippet) = target.snippet.as_deref() {
let targets = split_and_normalize(snippet);
if !targets.is_empty() {
let prefer = target
.claimed_line
.filter(|n| *n > 0)
.and_then(|n| u32::try_from(n).ok());
for new_side in [true, false] {
let mut best: Option<(u32, u32)> = None;
for hunk in hunks {
let side = extract_side_lines(hunk, new_side);
let Some(cand) = match_consecutive(&side, &targets, prefer) else {
continue;
};
match prefer {
None => return Some((to_i32(cand.0), to_i32(cand.1))),
Some(claimed) => {
best = Some(best.map_or(cand, |prev| {
if cand.0.abs_diff(claimed) < prev.0.abs_diff(claimed) {
cand
} else {
prev
}
}));
}
}
}
if let Some((s, e)) = best {
return Some((to_i32(s), to_i32(e)));
}
}
}
}
if let Some(claimed) = target.claimed_line.filter(|n| *n > 0) {
if let Some(line) = snap_claimed_line(claimed, hunks) {
return Some((line, line));
}
}
None
}
fn snap_claimed_line(claimed: i32, hunks: &[DiffHunk]) -> Option<i32> {
let claimed = u32::try_from(claimed).ok()?;
let mut best: Option<(u32, u32)> = None; for hunk in hunks {
let side = extract_side_lines(hunk, true);
if side.is_empty() {
continue;
}
let lo = side.first().map_or(0, |l| l.line_num);
let hi = side.last().map_or(0, |l| l.line_num);
if claimed + 2 < lo || claimed > hi + 2 {
continue;
}
for l in &side {
let dist = l.line_num.abs_diff(claimed);
if best.is_none_or(|(bd, _)| dist < bd) {
best = Some((dist, l.line_num));
}
}
}
best.map(|(_, line)| to_i32(line))
}
fn extract_side_lines(hunk: &DiffHunk, new_side: bool) -> Vec<IndexedLine> {
let mut out = Vec::new();
let mut old_line = hunk.old_start;
let mut new_line = hunk.new_start;
for l in &hunk.lines {
match l.kind {
LineKind::Context => {
let n = if new_side { new_line } else { old_line };
out.push(IndexedLine {
line_num: n,
content: normalize_line(&l.content),
});
old_line += 1;
new_line += 1;
}
LineKind::Added => {
if new_side {
out.push(IndexedLine {
line_num: new_line,
content: normalize_line(&l.content),
});
}
new_line += 1;
}
LineKind::Deleted => {
if !new_side {
out.push(IndexedLine {
line_num: old_line,
content: normalize_line(&l.content),
});
}
old_line += 1;
}
}
}
out
}
fn match_consecutive(
side: &[IndexedLine],
targets: &[String],
prefer_near: Option<u32>,
) -> Option<(u32, u32)> {
if targets.is_empty() || side.len() < targets.len() {
return None;
}
let last = side.len() - targets.len();
let mut best: Option<(u32, u32)> = None;
for i in 0..=last {
if side[i..]
.iter()
.zip(targets.iter())
.all(|(s, t)| &s.content == t)
{
let cand = (side[i].line_num, side[i + targets.len() - 1].line_num);
match prefer_near {
None => return Some(cand),
Some(claimed) => {
best = Some(best.map_or(cand, |prev| {
if cand.0.abs_diff(claimed) < prev.0.abs_diff(claimed) {
cand
} else {
prev
}
}));
}
}
}
}
best
}
fn split_and_normalize(code: &str) -> Vec<String> {
code.lines()
.map(normalize_line)
.filter(|s| !s.is_empty())
.collect()
}
fn normalize_line(s: &str) -> String {
let t = s.trim();
let t = t
.strip_prefix('+')
.or_else(|| t.strip_prefix('-'))
.unwrap_or(t);
t.trim().to_owned()
}
fn to_i32(n: u32) -> i32 {
i32::try_from(n).unwrap_or(i32::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
@@ -10,6 +10,7 @@ fn handler() {
let cfg = load();
let db = connect();
- let token = req.token;
+ let token = req.token.clone();
+ validate(&token)?;
run(token);
}
";
const DUP_SNIPPET: &str = "\
@@ -1,3 +1,5 @@
fn a() {
+ log(x);
}
fn b() {
+ log(x);
}
";
fn parsed() -> Vec<DiffHunk> {
parse_hunks(SAMPLE)
}
#[test]
fn parses_header_start_lines() {
let h = parsed();
assert_eq!(h.len(), 1);
assert_eq!(h[0].old_start, 10);
assert_eq!(h[0].new_start, 10);
}
#[test]
fn matches_added_line_to_new_file_number() {
let target = ResolveTarget {
snippet: Some("validate(&token)?;".to_owned()),
claimed_line: None,
};
assert_eq!(resolve_issue_lines(&target, &parsed()), Some((13, 13)));
}
#[test]
fn matches_context_line_after_additions() {
let target = ResolveTarget {
snippet: Some("run(token);".to_owned()),
claimed_line: None,
};
assert_eq!(resolve_issue_lines(&target, &parsed()), Some((14, 14)));
}
#[test]
fn matches_multi_line_consecutive_run() {
let target = ResolveTarget {
snippet: Some("let token = req.token.clone();\nvalidate(&token)?;".to_owned()),
claimed_line: None,
};
assert_eq!(resolve_issue_lines(&target, &parsed()), Some((12, 13)));
}
#[test]
fn falls_back_to_old_side_for_deleted_code() {
let target = ResolveTarget {
snippet: Some("let token = req.token;".to_owned()),
claimed_line: None,
};
assert_eq!(resolve_issue_lines(&target, &parsed()), Some((12, 12)));
}
#[test]
fn normalizes_diff_markers_and_whitespace_in_snippet() {
let target = ResolveTarget {
snippet: Some("+ validate(&token)?;".to_owned()),
claimed_line: None,
};
assert_eq!(resolve_issue_lines(&target, &parsed()), Some((13, 13)));
}
#[test]
fn no_match_returns_none() {
let target = ResolveTarget {
snippet: Some("this text is not in the diff at all".to_owned()),
claimed_line: None,
};
assert_eq!(resolve_issue_lines(&target, &parsed()), None);
}
#[test]
fn empty_hunks_returns_none() {
let target = ResolveTarget {
snippet: Some("anything".to_owned()),
claimed_line: Some(5),
};
assert_eq!(resolve_issue_lines(&target, &[]), None);
}
#[test]
fn snaps_claimed_line_when_no_snippet() {
let target = ResolveTarget {
snippet: None,
claimed_line: Some(13),
};
assert_eq!(resolve_issue_lines(&target, &parsed()), Some((13, 13)));
}
#[test]
fn snaps_slightly_off_claimed_line_to_nearest_changed_line() {
let target = ResolveTarget {
snippet: None,
claimed_line: Some(16),
};
assert_eq!(resolve_issue_lines(&target, &parsed()), Some((15, 15)));
}
#[test]
fn claimed_line_far_outside_any_hunk_returns_none() {
let target = ResolveTarget {
snippet: None,
claimed_line: Some(900),
};
assert_eq!(resolve_issue_lines(&target, &parsed()), None);
}
#[test]
fn snippet_wins_over_claimed_line() {
let target = ResolveTarget {
snippet: Some("run(token);".to_owned()),
claimed_line: Some(12),
};
assert_eq!(resolve_issue_lines(&target, &parsed()), Some((14, 14)));
}
#[test]
fn ambiguous_snippet_disambiguates_to_claimed_line() {
let hunks = parse_hunks(DUP_SNIPPET);
let near5 = ResolveTarget {
snippet: Some("log(x);".to_owned()),
claimed_line: Some(5),
};
assert_eq!(resolve_issue_lines(&near5, &hunks), Some((5, 5)));
let near2 = ResolveTarget {
snippet: Some("log(x);".to_owned()),
claimed_line: Some(2),
};
assert_eq!(resolve_issue_lines(&near2, &hunks), Some((2, 2)));
let no_claim = ResolveTarget {
snippet: Some("log(x);".to_owned()),
claimed_line: None,
};
assert_eq!(resolve_issue_lines(&no_claim, &hunks), Some((2, 2)));
}
#[test]
fn parses_multiple_hunks_independently() {
let multi = "\
@@ -1,2 +1,3 @@
alpha
+beta
gamma
@@ -40,2 +41,3 @@
delta
+epsilon
zeta
";
let hunks = parse_hunks(multi);
assert_eq!(hunks.len(), 2);
let target = ResolveTarget {
snippet: Some("epsilon".to_owned()),
claimed_line: None,
};
assert_eq!(resolve_issue_lines(&target, &hunks), Some((42, 42)));
let target2 = ResolveTarget {
snippet: Some("beta".to_owned()),
claimed_line: None,
};
assert_eq!(resolve_issue_lines(&target2, &hunks), Some((2, 2)));
}
#[test]
fn skips_file_headers_before_first_hunk() {
let with_headers = "\
diff --git a/src/x.rs b/src/x.rs
index e69de29..4b825dc 100644
--- a/src/x.rs
+++ b/src/x.rs
@@ -1,1 +1,2 @@
keep
+added
";
let hunks = parse_hunks(with_headers);
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].new_start, 1);
let target = ResolveTarget {
snippet: Some("added".to_owned()),
claimed_line: None,
};
assert_eq!(resolve_issue_lines(&target, &hunks), Some((2, 2)));
}
}