Something went wrong. Try again.
⚠️ This is completely vibe coded and I don't trust it
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669//! In-process structural diff comparison.//!//! [`compare_file`] runs Difftastic's engine (never a subprocess) and//! maps its result into an application-owned, rendering-ready//! [`FileDiff`]: normalized per-side source lines, one-based line//! labels, zero-based alignment rows, novel-line sets, typed and//! escaped token segments, original byte metadata and an explicit//! display state.
use std::collections::BTreeMap;
use difftastic::{ AtomKind, DiffOptions, DisplayOptions, FileContent, FileFormat, MatchKind, MatchedPos, StringKind, TokenKind, aligned_lines, novel_lines, split_source_lines,};
use crate::model::FileInput;
/// How a file's content should be presented.#[derive(Debug, Clone, PartialEq, Eq)]pub enum DisplayState { /// The two sides differ syntactically. Structural, /// The raw bytes differ but the decoded syntax is unchanged, for /// example a whitespace-only or final-newline-only change. FormattingOnly, /// Difftastic fell back to a line-based text diff. The reason is /// preserved verbatim and must be shown to the user. TextFallback { reason: String }, /// Difftastic classified the input as binary. Binary, /// Content without a textual diff, such as a type or mode change. Special { description: String }, /// Both sides are byte-identical, for example a metadata-only /// change. Unchanged,}
/// Failure to compare a file.#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]pub enum DiffError { /// Both sides are absent, so there is nothing to compare. #[error("cannot diff {path}: file has no content on either side")] MissingSides { path: String },}
/// A rendering-ready structural diff for one file.#[derive(Debug, Clone, PartialEq, Eq)]pub struct FileDiff { /// Display path passed to the engine. pub path: String, /// How the content should be presented. pub state: DisplayState, /// Additional engine information, such as a rename note. pub extra_info: Option<String>, /// Whether the engine found syntactic changes. pub has_syntactic_changes: bool, /// Original byte lengths when the raw bytes differ. pub byte_changes: Option<(usize, usize)>, /// Old-side source. pub old: SourceSide, /// New-side source. pub new: SourceSide, /// One row per rendered display line, in display order. pub alignment: Vec<AlignedRow>, /// Zero-based old-side lines containing novel content. pub old_novel_lines: Vec<u32>, /// Zero-based new-side lines containing novel content. pub new_novel_lines: Vec<u32>,}
/// One side of a file's content.#[derive(Debug, Clone, PartialEq, Eq, Default)]pub struct SourceSide { /// Whether this side exists at all (false for added/removed sides). pub present: bool, /// Length of the original bytes for this side. pub byte_len: usize, /// Whether the original bytes lack a final newline. pub missing_final_newline: bool, /// Source lines in display order; an empty file has no lines. pub lines: Vec<DiffLine>,}
/// One source line.#[derive(Debug, Clone, PartialEq, Eq)]pub struct DiffLine { /// One-based line label used for display and review anchors. pub number: u32, /// Exact line text without the trailing newline. pub text: String, /// Escaped, typed segments in display order covering the whole line. pub segments: Vec<TextSegment>, /// Whether this line contains novel content. pub novel: bool,}
/// A contiguous, HTML-escaped piece of a source line.#[derive(Debug, Clone, PartialEq, Eq)]pub struct TextSegment { /// Byte offset where the segment starts in the line text. pub start: usize, /// Byte offset where the segment ends in the line text. pub end: usize, /// HTML-escaped text; safe to emit as element content. pub text: String, /// Syntax class of the segment. pub kind: SegmentKind, /// Whether the engine marked this segment as novel. pub novel: bool,}
/// Syntax class of a rendered segment.#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]pub enum SegmentKind { /// Unstyled text, including gaps between matched tokens. #[default] Normal, /// A delimiter such as `{` or `,`. Delimiter, /// A string literal. StringLiteral, /// Plain text content, such as HTML text nodes. Text, /// A type name. Type, /// A comment. Comment, /// A keyword. Keyword, /// A tree-sitter error node. TreeSitterError,}
/// One display row, referencing zero-based lines on either side.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub struct AlignedRow { /// Zero-based index into [`FileDiff::old`]'s lines. pub old: Option<u32>, /// Zero-based index into [`FileDiff::new`]'s lines. pub new: Option<u32>,}
/// Compare one file's bytes with Difftastic's structural engine.////// `display_path` is used for language detection and display; it is/// not opened. Each side of `input` is optional so added and removed/// files round-trip without inventing content.pub fn compare_file(display_path: &str, input: &FileInput) -> Result<FileDiff, DiffError> { if input.old.is_none() && input.new.is_none() { return Err(DiffError::MissingSides { path: display_path.to_owned(), }); }
Ok(compare_present_sides(display_path, input))}
fn compare_present_sides(display_path: &str, input: &FileInput) -> FileDiff { let old_bytes: &[u8] = input.old.as_deref().unwrap_or(&[]); let new_bytes: &[u8] = input.new.as_deref().unwrap_or(&[]);
let result = difftastic::diff_bytes( display_path, old_bytes, new_bytes, &DiffOptions::default(), &DisplayOptions::default(), );
let old_text = text_of(&result.lhs_src); let new_text = text_of(&result.rhs_src);
let (old_novel, new_novel) = novel_lines(&result);
let old_lines = build_side( old_text, &result.lhs_positions, &old_novel, input.old.as_deref(), ); let new_lines = build_side( new_text, &result.rhs_positions, &new_novel, input.new.as_deref(), );
let old_count = old_lines.lines.len() as u32; let new_count = new_lines.lines.len() as u32;
let alignment = aligned_lines(&result) .into_iter() .filter_map(|(old, new)| { let row = AlignedRow { old: old.filter(|line| *line < old_count), new: new.filter(|line| *line < new_count), };
(row.old.is_some() || row.new.is_some()).then_some(row) }) .collect();
let state = display_state(&result, input);
FileDiff { path: display_path.to_owned(), state, extra_info: result.extra_info.clone(), has_syntactic_changes: result.has_syntactic_changes, byte_changes: result.has_byte_changes, old: old_lines, new: new_lines, alignment, old_novel_lines: old_novel, new_novel_lines: new_novel, }}
fn text_of(content: &FileContent) -> Option<&str> { match content { FileContent::Text(text) => Some(text), FileContent::Binary => None, }}
fn display_state(result: &difftastic::DiffResult, input: &FileInput) -> DisplayState { if matches!(&result.file_format, FileFormat::Binary) { return DisplayState::Binary; }
if let FileFormat::TextFallback { reason } = &result.file_format { return DisplayState::TextFallback { reason: reason.clone(), }; }
// Added and removed files stay visible even when their text has no // syntactic changes. if input.old.is_none() || input.new.is_none() { return DisplayState::Structural; }
if result.has_byte_changes.is_none() { return DisplayState::Unchanged; }
if !result.has_syntactic_changes { return DisplayState::FormattingOnly; }
DisplayState::Structural}
fn build_side( text: Option<&str>, positions: &[MatchedPos], novel: &[u32], raw: Option<&[u8]>,) -> SourceSide { let Some(text) = text else { return SourceSide { present: raw.is_some(), byte_len: raw.map_or(0, <[u8]>::len), missing_final_newline: raw.is_some_and(missing_final_newline), lines: Vec::new(), }; };
let mut spans_by_line: BTreeMap<u32, Vec<&MatchedPos>> = BTreeMap::new(); for position in positions { spans_by_line .entry(position.pos.line.0) .or_default() .push(position); }
let lines = split_source_lines(text) .into_iter() .enumerate() .map(|(index, line)| { let line_number = index as u32;
DiffLine { number: line_number + 1, text: line.to_owned(), segments: build_segments(line, spans_by_line.get(&line_number).map(Vec::as_slice)), novel: novel.contains(&line_number), } }) .collect();
SourceSide { present: raw.is_some(), byte_len: raw.map_or(0, <[u8]>::len), missing_final_newline: raw.is_some_and(missing_final_newline), lines, }}
fn missing_final_newline(bytes: &[u8]) -> bool { !bytes.is_empty() && !bytes.ends_with(b"\n")}
fn build_segments(text: &str, spans: Option<&[&MatchedPos]>) -> Vec<TextSegment> { let mut novel_spans: Vec<(usize, usize, SegmentKind)> = Vec::new();
for position in spans.into_iter().flatten() { if !position.kind.is_novel() { continue; }
let start = clamp_to_char_boundary(text, position.pos.start_col as usize); let end = clamp_to_char_boundary(text, position.pos.end_col as usize);
if start < end { novel_spans.push((start, end, segment_kind(&position.kind))); } }
novel_spans.sort_unstable_by_key(|(start, end, _)| (*start, *end));
let mut segments = Vec::new(); let mut cursor = 0;
for (start, end, kind) in novel_spans { if start < cursor { // Overlapping spans are already covered; difftastic emits // atomic positions, so this is defensive only. continue; }
if start > cursor { segments.push(plain_segment(&text[cursor..start], cursor)); }
segments.push(TextSegment { start, end, text: escape_html(&text[start..end]), kind, novel: true, }); cursor = end; }
if cursor < text.len() { segments.push(plain_segment(&text[cursor..], cursor)); }
segments}
fn plain_segment(text: &str, start: usize) -> TextSegment { TextSegment { start, end: start + text.len(), text: escape_html(text), kind: SegmentKind::Normal, novel: false, }}
fn clamp_to_char_boundary(text: &str, index: usize) -> usize { let mut index = index.min(text.len()); while index > 0 && !text.is_char_boundary(index) { index -= 1; } index}
fn segment_kind(kind: &MatchKind) -> SegmentKind { let highlight = match kind { MatchKind::UnchangedToken { highlight, .. } | MatchKind::UnchangedPartOfNovelItem { highlight, .. } => highlight, MatchKind::Novel { highlight } | MatchKind::NovelWord { highlight } | MatchKind::Ignored { highlight } => highlight, };
match highlight { TokenKind::Delimiter => SegmentKind::Delimiter, TokenKind::Atom(atom) => match atom { AtomKind::String(StringKind::StringLiteral) => SegmentKind::StringLiteral, AtomKind::String(StringKind::Text) => SegmentKind::Text, AtomKind::Keyword => SegmentKind::Keyword, AtomKind::Comment => SegmentKind::Comment, AtomKind::Type => SegmentKind::Type, AtomKind::TreeSitterError => SegmentKind::TreeSitterError, AtomKind::Normal | AtomKind::CanIgnore => SegmentKind::Normal, }, }}
/// Escape text for safe use as HTML element content or a quoted/// attribute value.fn escape_html(text: &str) -> String { let mut escaped = String::with_capacity(text.len());
for ch in text.chars() { match ch { '&' => escaped.push_str("&"), '<' => escaped.push_str("<"), '>' => escaped.push_str(">"), '"' => escaped.push_str("""), '\'' => escaped.push_str("'"), _ => escaped.push(ch), } }
escaped}
#[cfg(test)]mod tests { use super::*;
fn input(old: Option<&[u8]>, new: Option<&[u8]>) -> FileInput { FileInput { old: old.map(<[u8]>::to_vec), new: new.map(<[u8]>::to_vec), } }
fn diff(path: &str, old: Option<&[u8]>, new: Option<&[u8]>) -> FileDiff { compare_file(path, &input(old, new)).expect("comparison should succeed") }
fn plain(diff: &FileDiff, new: bool) -> String { let side = if new { &diff.new } else { &diff.old }; side.lines .iter() .flat_map(|line| line.segments.iter().map(|segment| segment.text.as_str())) .collect() }
#[test] fn identical_inputs_keep_sources_and_alignment() { let source = b"fn answer() -> u8 {\n 41\n}\n"; let result = diff("answer.rs", Some(source), Some(source));
assert_eq!(result.state, DisplayState::Unchanged); assert_eq!(result.byte_changes, None); assert_eq!( result.alignment, vec![ AlignedRow { old: Some(0), new: Some(0) }, AlignedRow { old: Some(1), new: Some(1) }, AlignedRow { old: Some(2), new: Some(2) }, ] ); assert_eq!(result.old.lines.len(), 3); assert_eq!(result.old.lines[0].number, 1); assert_eq!(result.old.lines[0].text, "fn answer() -> u8 {"); assert_eq!(result.old.lines[2].text, "}"); }
#[test] fn final_newline_only_change_is_formatting_and_keeps_newline_metadata() { let without = b"fn answer() {}\n"; let result = diff("answer.rs", Some(b"fn answer() {}"), Some(without));
assert_eq!(result.state, DisplayState::FormattingOnly); assert_eq!( result.byte_changes, Some((b"fn answer() {}".len(), b"fn answer() {}\n".len())) ); assert!(result.old.missing_final_newline); assert!(!result.new.missing_final_newline); assert_eq!(result.old.lines.len(), 1); assert_eq!(result.old.lines[0].text, "fn answer() {}"); assert_eq!( result.alignment, vec![AlignedRow { old: Some(0), new: Some(0) }] ); }
#[test] fn whitespace_only_change_is_formatting_only() { let old = b"fn a() {\n 1;\n}\n"; let new = b"fn a() {\n 1;\n}\n"; let result = diff("a.rs", Some(old), Some(new));
assert_eq!(result.state, DisplayState::FormattingOnly); assert!(result.byte_changes.is_some()); assert_eq!(result.alignment.len(), 3); }
#[test] fn changed_literal_has_novel_lines_and_spans() { let old = b"fn answer() -> u8 {\n 41\n}\n"; let new = b"fn answer() -> u8 {\n 42\n}\n"; let result = diff("answer.rs", Some(old), Some(new));
assert_eq!(result.state, DisplayState::Structural); assert!(result.old_novel_lines.contains(&1)); assert!(result.new_novel_lines.contains(&1)); assert!(result.new.lines[1].novel);
let novel: Vec<&TextSegment> = result.new.lines[1] .segments .iter() .filter(|segment| segment.novel) .collect(); assert!(!novel.is_empty()); assert!(novel.iter().any(|segment| segment.text.contains("42"))); assert!(plain(&result, false).contains("41")); assert!(plain(&result, true).contains("42")); }
#[test] fn added_file_aligns_only_the_new_side() { let result = diff("notes.txt", None, Some(b"hello\nworld\n"));
assert_eq!(result.state, DisplayState::Structural); assert!(!result.old.present); assert!(result.new.present); assert!(result.old.lines.is_empty()); assert_eq!(result.new.lines.len(), 2); assert_eq!(result.new.lines[1].number, 2); assert_eq!( result.alignment, vec![ AlignedRow { old: None, new: Some(0) }, AlignedRow { old: None, new: Some(1) }, ] ); }
#[test] fn removed_file_aligns_only_the_old_side() { let result = diff("notes.txt", Some(b"hello\n"), None);
assert!(result.old.present); assert!(!result.new.present); assert_eq!(result.new.lines.len(), 0); assert_eq!( result.alignment, vec![AlignedRow { old: Some(0), new: None }] ); }
#[test] fn both_sides_absent_is_an_error() { let error = compare_file("missing.txt", &input(None, None)).unwrap_err(); assert_eq!( error, DiffError::MissingSides { path: "missing.txt".to_owned() } ); }
#[test] fn binary_inputs_are_classified_with_byte_metadata() { let old = b"\xff\x00\x00"; let new = b"\xff\x00\x00\x01"; let result = diff("data.bin", Some(old), Some(new));
assert_eq!(result.state, DisplayState::Binary); assert_eq!(result.byte_changes, Some((old.len(), new.len()))); assert!(result.alignment.is_empty()); assert!(result.old.lines.is_empty()); assert!(result.new.lines.is_empty()); }
#[test] fn over_limit_input_reports_text_fallback_reason() { let mut old = "a".repeat(1_000_001); old.push('\n'); let mut new = old.clone(); new.push_str("b\n");
let result = diff("huge.rs", Some(old.as_bytes()), Some(new.as_bytes()));
match result.state { DisplayState::TextFallback { reason } => { assert!(reason.contains("DFT_BYTE_LIMIT"), "reason was {reason}"); } other => panic!("expected text fallback, got {other:?}"), } }
#[test] fn non_ascii_spans_stay_on_char_boundaries_and_are_escaped() { let old = b"let s = \"h\xc3\xa9llo\";\n"; let new = b"let s = \"h\xc3\xa9llp\";\n"; let result = diff("s.rs", Some(old), Some(new));
let line = &result.new.lines[0]; assert_eq!(line.text, "let s = \"héllp\";");
let joined: String = line.segments.iter().map(|s| s.text.as_str()).collect(); assert_eq!(joined, escape_html(&line.text)); assert!(joined.contains("h\u{e9}ll")); }
#[test] fn tabs_are_preserved_in_source_lines() { let old = b"fn a() {\n\t1;\n}\n"; let new = b"fn a() {\n\t2;\n}\n"; let result = diff("a.rs", Some(old), Some(new));
assert_eq!(result.old.lines[1].text, "\t1;"); assert_eq!(result.new.lines[1].text, "\t2;"); assert!(plain(&result, true).contains('\t')); }
#[test] fn source_markup_is_escaped_in_segments() { let old = b"<script>alert(1)</script>\n"; let new = b"<script>alert(2)</script>\n"; let result = diff("page.html", Some(old), Some(new));
for segment in result.new.lines[0].segments.iter() { assert!(!segment.text.contains("<script>")); }
let joined: String = result.new.lines[0] .segments .iter() .map(|segment| segment.text.as_str()) .collect(); assert!(joined.contains("<script>")); assert!(!joined.contains("</script>")); }
#[test] fn line_numbers_are_one_based_and_alignment_zero_based() { let result = diff("multi.txt", Some(b"a\nb\nc\n"), Some(b"a\nB\nc\n"));
assert_eq!(result.new.lines[1].number, 2); assert_eq!( result.alignment[1], AlignedRow { old: Some(1), new: Some(1) } ); }
#[test] fn plain_comparisons_carry_no_extra_info() { let result = diff("same.txt", Some(b"x\n"), Some(b"x\n")); assert_eq!(result.extra_info, None); }}