Files
Gotcha/crates/app/src/diff.rs
2026-07-30 16:12:22 +02:00

191 lines
5.8 KiB
Rust

#[derive(Debug, Eq, PartialEq)]
pub struct Line {
pub old_number: String,
pub new_number: String,
pub text: String,
pub kind: &'static str,
}
pub struct Parsed {
pub lines: Vec<Line>,
pub columns: usize,
}
pub fn parse_file(diff: &str, path: &str) -> Parsed {
let section = sections(diff)
.into_iter()
.find(|section| section_path(section).as_deref() == Some(path))
.unwrap_or("");
if section.is_empty() {
let text = format!("No diff data is available for {path}.");
return Parsed {
columns: text.chars().count(),
lines: vec![Line {
old_number: String::new(),
new_number: String::new(),
text,
kind: "header",
}],
};
}
let mut old = 0;
let mut new = 0;
let mut columns = 0;
let lines = section
.lines()
.map(|text| {
columns = columns.max(display_columns(text));
let (old_number, new_number, kind) = if text.starts_with("@@") {
if let Some((old_start, new_start)) = hunk_starts(text) {
old = old_start;
new = new_start;
}
(String::new(), String::new(), "hunk")
} else if text.starts_with('+') && !text.starts_with("+++") {
let number = new.to_string();
new += 1;
(String::new(), number, "addition")
} else if text.starts_with('-') && !text.starts_with("---") {
let number = old.to_string();
old += 1;
(number, String::new(), "removal")
} else if text.starts_with(' ') {
let numbers = (old.to_string(), new.to_string());
old += 1;
new += 1;
(numbers.0, numbers.1, "context")
} else {
(String::new(), String::new(), "header")
};
Line {
old_number,
new_number,
text: text.into(),
kind,
}
})
.collect();
Parsed { lines, columns }
}
fn sections(diff: &str) -> Vec<&str> {
let mut starts: Vec<_> = diff
.match_indices("diff --git ")
.map(|(index, _)| index)
.collect();
starts.push(diff.len());
starts
.windows(2)
.map(move |bounds| &diff[bounds[0]..bounds[1]])
.collect()
}
fn section_path(section: &str) -> Option<String> {
section
.lines()
.find_map(|line| line.strip_prefix("+++ "))
.filter(|path| *path != "/dev/null")
.or_else(|| {
section
.lines()
.find_map(|line| line.strip_prefix("--- "))
.filter(|path| *path != "/dev/null")
})
.map(|path| {
decode_path(path)
.trim_start_matches("a/")
.trim_start_matches("b/")
.into()
})
}
fn decode_path(path: &str) -> String {
let path = path.trim_matches('"');
let mut bytes = Vec::with_capacity(path.len());
let mut chars = path.bytes();
while let Some(byte) = chars.next() {
if byte != b'\\' {
bytes.push(byte);
continue;
}
let Some(escaped) = chars.next() else { break };
match escaped {
b'n' => bytes.push(b'\n'),
b'r' => bytes.push(b'\r'),
b't' => bytes.push(b'\t'),
b'\\' | b'"' => bytes.push(escaped),
b'0'..=b'7' => {
let mut value = escaped - b'0';
for _ in 0..2 {
let Some(next) = chars.next() else { break };
if !(b'0'..=b'7').contains(&next) {
bytes.push(value);
bytes.push(next);
value = 0;
break;
}
value = value * 8 + next - b'0';
}
if value != 0 {
bytes.push(value);
}
}
other => bytes.push(other),
}
}
String::from_utf8_lossy(&bytes).into_owned()
}
fn hunk_starts(line: &str) -> Option<(i32, i32)> {
let mut parts = line.split_whitespace();
(parts.next()? == "@@").then_some(())?;
Some((
range_start(parts.next()?, '-'),
range_start(parts.next()?, '+'),
))
}
fn range_start(value: &str, prefix: char) -> i32 {
value
.strip_prefix(prefix)
.and_then(|value| value.split(',').next())
.and_then(|value| value.parse().ok())
.unwrap_or_default()
}
fn display_columns(line: &str) -> usize {
line.chars().fold(0, |column, character| {
if character == '\t' {
(column / 8 + 1) * 8
} else {
column + 1
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selects_and_numbers_a_file_diff() {
let diff = "diff --git a/one b/one\n--- a/one\n+++ b/one\n@@ -1 +1 @@\n-old\n+new\n\
diff --git a/two b/two\n--- a/two\n+++ b/two\n@@ -4,2 +8,3 @@\n context\n-removed\n+added\n";
let parsed = parse_file(diff, "two");
assert_eq!(parsed.lines[4].old_number, "4");
assert_eq!(parsed.lines[4].new_number, "8");
assert_eq!(parsed.lines[5].kind, "removal");
assert_eq!(parsed.lines[6].kind, "addition");
assert!(parsed.lines.iter().all(|line| !line.text.contains("old")));
}
#[test]
fn decodes_git_quoted_paths() {
let diff = "diff --git \"a/caf\\303\\251\" \"b/caf\\303\\251\"\n--- \"a/caf\\303\\251\"\n+++ \"b/caf\\303\\251\"\n@@ -0,0 +1 @@\n+hello\n";
assert_eq!(
parse_file(diff, "café").lines.last().unwrap().kind,
"addition"
);
}
}