use std::ops::Range;
#[non_exhaustive]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum DiffKind {
Added,
Removed,
HunkHeader,
FileHeader,
Meta,
Context,
}
const META_PREFIXES: &[&str] = &[
"diff ",
"index ",
"old mode",
"new mode",
"new file mode",
"deleted file mode",
"copy from ",
"copy to ",
"rename from ",
"rename to ",
"similarity index ",
"dissimilarity index ",
"Binary files ",
"GIT binary patch",
"Only in ",
];
#[derive(Copy, Clone, Debug, Default)]
pub struct DiffLexer;
impl DiffLexer {
pub fn new() -> DiffLexer {
DiffLexer
}
pub fn matches_lang(label: &str) -> bool {
let first = label.split_whitespace().next().unwrap_or("");
first.eq_ignore_ascii_case("diff")
|| first.eq_ignore_ascii_case("patch")
|| first.eq_ignore_ascii_case("udiff")
}
pub fn spans(&self, line: &str) -> Vec<(Range<usize>, DiffKind)> {
if line.is_empty() {
return Vec::new();
}
let whole = |kind: DiffKind| vec![(0..line.len(), kind)];
let b = line.as_bytes();
if line.starts_with("@@") {
match find_close(b) {
Some(end) if end < line.len() => {
return vec![
(0..end, DiffKind::HunkHeader),
(end..line.len(), DiffKind::Context),
];
}
_ => return whole(DiffKind::HunkHeader),
}
}
if line.starts_with("--- ") || line.starts_with("---\t") {
return whole(DiffKind::FileHeader);
}
if line.starts_with("+++ ") || line.starts_with("+++\t") {
return whole(DiffKind::FileHeader);
}
match b[0] {
b'+' => whole(DiffKind::Added),
b'-' => whole(DiffKind::Removed),
b' ' => whole(DiffKind::Context),
b'\\' => whole(DiffKind::Meta),
_ => {
if META_PREFIXES.iter().any(|p| line.starts_with(p)) {
whole(DiffKind::Meta)
} else {
whole(DiffKind::Context)
}
}
}
}
}
fn find_close(b: &[u8]) -> Option<usize> {
b.get(2..)?
.windows(2)
.position(|w| w == b"@@")
.map(|p| p + 4)
}
#[cfg(test)]
mod tests {
use super::*;
fn kind_of(line: &str) -> DiffKind {
let spans = DiffLexer::new().spans(line);
assert_eq!(
spans.len(),
1,
"expected one whole-line span for {line:?}: {spans:?}"
);
assert_eq!(spans[0].0, 0..line.len(), "whole line for {line:?}");
spans[0].1
}
#[test]
fn body_lines_classify_by_first_byte() {
assert_eq!(kind_of("+let x = 1;"), DiffKind::Added);
assert_eq!(kind_of("-let x = 0;"), DiffKind::Removed);
assert_eq!(kind_of(" unchanged"), DiffKind::Context);
assert_eq!(kind_of("+"), DiffKind::Added);
assert_eq!(kind_of("-"), DiffKind::Removed);
}
#[test]
fn headers_and_meta_lines() {
assert_eq!(kind_of("--- a/src/main.rs"), DiffKind::FileHeader);
assert_eq!(kind_of("+++ b/src/main.rs"), DiffKind::FileHeader);
assert_eq!(
kind_of("--- lao\t2002-02-21 23:30:39"),
DiffKind::FileHeader
);
assert_eq!(kind_of("diff --git a/x b/x"), DiffKind::Meta);
assert_eq!(kind_of("index 83db48f..bf3a1a5 100644"), DiffKind::Meta);
assert_eq!(kind_of("new file mode 100644"), DiffKind::Meta);
assert_eq!(kind_of("rename from old.rs"), DiffKind::Meta);
assert_eq!(
kind_of("Binary files a/i.png and b/i.png differ"),
DiffKind::Meta
);
assert_eq!(kind_of("Subject: [PATCH] fix the thing"), DiffKind::Context);
assert_eq!(kind_of("fix the thing"), DiffKind::Context);
}
#[test]
fn no_newline_at_eof_marker_is_meta() {
assert_eq!(kind_of("\\ No newline at end of file"), DiffKind::Meta);
}
#[test]
fn hunk_header_splits_trailing_function_context() {
let line = "@@ -1,3 +1,4 @@ fn main() {";
let spans = DiffLexer::new().spans(line);
assert_eq!(
spans,
vec![
(0..15, DiffKind::HunkHeader),
(15..line.len(), DiffKind::Context),
]
);
assert_eq!(&line[0..15], "@@ -1,3 +1,4 @@");
assert_eq!(kind_of("@@ -1,3 +1,4 @@"), DiffKind::HunkHeader);
assert_eq!(kind_of("@@ -1,3 +1,4"), DiffKind::HunkHeader);
}
#[test]
fn documented_ambiguities_resolve_header_first() {
assert_eq!(kind_of("--- struck-through prose"), DiffKind::FileHeader);
assert_eq!(kind_of("---"), DiffKind::Removed);
assert_eq!(kind_of("+++"), DiffKind::Added);
}
#[test]
fn lang_labels_route_diff_and_nothing_else() {
for yes in ["diff", "Diff", "DIFF", "patch", "udiff", "diff --git"] {
assert!(DiffLexer::matches_lang(yes), "{yes:?}");
}
for no in ["", "rust", "d", "diffs", "patchwork", "c"] {
assert!(!DiffLexer::matches_lang(no), "{no:?}");
}
}
#[test]
fn empty_line_carries_no_span() {
assert!(DiffLexer::new().spans("").is_empty());
}
#[test]
fn non_ascii_content_slices_on_char_boundaries() {
let lexer = DiffLexer::new();
for line in ["+héllo 世界", "-🎉", "@@ -1 +1 @@ 名前", "\\ 説明", "文脈"] {
for (r, _) in lexer.spans(line) {
let _ = &line[r]; }
}
}
#[test]
fn totality_mini_fuzz() {
let atoms = [
"+",
"-",
" ",
"@@",
"@",
"---",
"+++",
"\\",
"diff ",
"index ",
"@@ -1,2 +3,4 @@",
"é",
"世",
"\u{1b}",
"\u{0}",
"text",
"\t",
];
let lexer = DiffLexer::new();
let mut seed = 0x5EED_u32;
let mut rand = move || {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
(seed >> 16) as usize
};
for _ in 0..2_000 {
let n = 1 + rand() % 8;
let mut line = String::new();
for _ in 0..n {
line.push_str(atoms[rand() % atoms.len()]);
}
let mut prev_end = 0usize;
for (r, _) in lexer.spans(&line) {
assert!(r.start >= prev_end && r.end <= line.len() && r.start <= r.end);
assert!(line.is_char_boundary(r.start) && line.is_char_boundary(r.end));
prev_end = r.end;
}
}
}
}