use std::collections::HashSet;
use crate::diff::anchor::{Commentable, HunkRange, LinePos, Side};
use crate::diff::blocks::{self, ChangeBlock};
use crate::diff::highlight::{Language, MAX_HIGHLIGHT_BYTES, Span, highlight, parse};
use crate::diff::hunks::{Algorithm, DiffLine, Whitespace, align};
use crate::diff::intraline::{IntraLine, intraline};
use crate::diff::scope::{self, Scope};
use crate::diff::structure::{self, Fallback};
use crate::diff::text::{Text, is_binary};
pub const CONTEXT: u32 = 3;
pub const MAX_DIFF_BYTES: usize = 16 << 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Content {
Text(Box<TextDiff>),
Binary {
old_size: Option<usize>,
new_size: Option<usize>,
},
TooLarge {
old_size: Option<usize>,
new_size: Option<usize>,
},
Submodule {
old: Option<String>,
new: Option<String>,
},
Error(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Alignment {
pub lines: Vec<DiffLine>,
pub intraline: IntraLine,
pub blocks: Vec<ChangeBlock>,
pub pairs: Vec<(u32, u32)>,
pub kept: HashSet<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextDiff {
old: Text,
new: Text,
old_spans: Vec<Vec<Span>>,
new_spans: Vec<Vec<Span>>,
exact: Alignment,
ignoring_whitespace: Alignment,
structural: Option<Result<Box<[Alignment; 2]>, Fallback>>,
pub local_ranges: Vec<HunkRange>,
pub scopes: Vec<Scope>,
}
impl TextDiff {
pub fn alignment(&self, whitespace: Whitespace) -> &Alignment {
match whitespace {
Whitespace::Exact => &self.exact,
Whitespace::Ignore => &self.ignoring_whitespace,
}
}
pub fn view(&self, whitespace: Whitespace, structural: bool) -> &Alignment {
match &self.structural {
Some(Ok(both)) if structural => {
let [exact, ignoring] = &**both;
match whitespace {
Whitespace::Exact => exact,
Whitespace::Ignore => ignoring,
}
}
_ => self.alignment(whitespace),
}
}
pub fn fallback(&self) -> Option<Fallback> {
self.structural.as_ref()?.as_ref().err().copied()
}
pub(crate) fn side_text(&self, side: Side) -> &Text {
self.side(side).0
}
pub fn scope(&self, line: u32) -> Vec<&str> {
scope::path(&self.scopes, line)
}
fn side(&self, side: Side) -> (&Text, &[Vec<Span>]) {
match side {
Side::Left => (&self.old, &self.old_spans),
Side::Right => (&self.new, &self.new_spans),
}
}
pub fn line(&self, pos: LinePos) -> &str {
self.side(pos.side).0.line_no(pos.line)
}
pub fn spans(&self, pos: LinePos) -> &[Span] {
nth(self.side(pos.side).1, pos.line).map_or(&[], Vec::as_slice)
}
pub fn crlf(&self, pos: LinePos) -> bool {
nth(&self.side(pos.side).0.crlf, pos.line) == Some(&true)
}
pub fn is_last(&self, pos: LinePos) -> bool {
pos.line as usize == self.side(pos.side).0.len()
}
pub fn ends_without_newline(&self, pos: LinePos) -> bool {
self.is_last(pos) && self.side(pos.side).0.missing_final_newline
}
pub fn final_newline_changed(&self) -> bool {
self.old.missing_final_newline != self.new.missing_final_newline
}
pub fn max_lines(&self) -> usize {
self.old.len().max(self.new.len())
}
}
fn nth<T>(items: &[T], line: u32) -> Option<&T> {
items.get(line.checked_sub(1)? as usize)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileDiff {
pub content: Content,
pub additions: u32,
pub deletions: u32,
}
pub fn counts(lines: &[DiffLine]) -> (u32, u32) {
lines.iter().fold((0, 0), |(a, d), l| match l {
DiffLine::Added(_) => (a + 1, d),
DiffLine::Removed(_) => (a, d + 1),
DiffLine::Context { .. } => (a, d),
})
}
impl FileDiff {
fn empty(content: Content) -> Self {
Self {
content,
additions: 0,
deletions: 0,
}
}
pub fn submodule(old: Option<String>, new: Option<String>) -> Self {
Self::empty(Content::Submodule { old, new })
}
pub fn error(message: impl Into<String>) -> Self {
Self::empty(Content::Error(message.into()))
}
pub fn compute(path: &str, old: Option<&[u8]>, new: Option<&[u8]>) -> Self {
Self::compute_with(path, old, new, true)
}
pub fn compute_plain(path: &str, old: Option<&[u8]>, new: Option<&[u8]>) -> Self {
Self::compute_with(path, old, new, false)
}
fn compute_with(path: &str, old: Option<&[u8]>, new: Option<&[u8]>, rich: bool) -> Self {
let (old_size, new_size) = (old.map(<[u8]>::len), new.map(<[u8]>::len));
if old_size.max(new_size) > Some(MAX_DIFF_BYTES) {
return Self::empty(Content::TooLarge { old_size, new_size });
}
if old.is_some_and(is_binary) || new.is_some_and(is_binary) {
return Self::empty(Content::Binary { old_size, new_size });
}
let old = Text::new(old.unwrap_or_default());
let new = Text::new(new.unwrap_or_default());
let lines = align(&old, &new, Algorithm::Histogram, Whitespace::Exact);
let (additions, deletions) = counts(&lines);
let changed = additions + deletions > 0;
let ignoring = changed.then(|| align(&old, &new, Algorithm::Histogram, Whitespace::Ignore));
let lang = Language::from_path(path).filter(|_| changed && rich);
let trees = lang.map(|lang| (parse(lang, &old), parse(lang, &new)));
let (local_ranges, old_spans, new_spans, scopes) = if changed && rich {
let ranges = Commentable::local(&old, &new).ranges;
let new_tree = trees.as_ref().and_then(|t| t.1.as_ref());
let scopes = scope::scopes_in(lang, new_tree, &new);
(ranges, highlight(lang, &old), highlight(lang, &new), scopes)
} else {
Default::default()
};
let matching = (changed && rich).then(|| match &trees {
None => Err(Fallback::NoGrammar),
Some((Some(a), Some(b))) => structure::compare((a, &old), (b, &new)),
Some(_) if old.source.len().max(new.source.len()) > MAX_HIGHLIGHT_BYTES => {
Err(Fallback::TooBig)
}
Some(_) => Err(Fallback::Unparsed),
});
let mut text = TextDiff {
old,
new,
old_spans,
new_spans,
exact: Alignment::default(),
ignoring_whitespace: Alignment::default(),
structural: None,
local_ranges,
scopes,
};
let alignment = |lines: Vec<DiffLine>| Alignment {
intraline: intraline(&text, if rich { lines.as_slice() } else { &[] }),
blocks: blocks::change_blocks(path, &text, &lines),
lines,
pairs: Vec::new(),
kept: HashSet::new(),
};
let exact = alignment(lines);
let mut ignoring = ignoring.map_or_else(|| exact.clone(), alignment);
blocks::name_after(&exact, &mut ignoring);
text.structural = matching
.map(|m| m.map(|m| Box::new([m.apply(&text, &exact), m.apply(&text, &ignoring)])));
(text.exact, text.ignoring_whitespace) = (exact, ignoring);
Self {
content: Content::Text(Box::new(text)),
additions,
deletions,
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) fn text_diff(path: &str, old: &str, new: &str) -> Box<TextDiff> {
match FileDiff::compute(path, Some(old.as_bytes()), Some(new.as_bytes())).content {
Content::Text(text) => text,
other => panic!("{other:?}"),
}
}
#[test]
fn text_diff_counts_and_highlights() {
let diff = FileDiff::compute(
"a.rs",
Some(b"fn a() {}\n"),
Some(b"fn b() {}\nfn c() {}\n"),
);
assert_eq!((diff.additions, diff.deletions), (2, 1));
let Content::Text(text) = &diff.content else {
panic!("{diff:?}")
};
assert!(!text.alignment(Whitespace::Exact).blocks.is_empty());
let new = |line| LinePos {
side: Side::Right,
line,
};
assert!(!text.spans(new(1)).is_empty());
assert!(text.spans(new(99)).is_empty());
}
#[test]
fn added_deleted_binary_and_large() {
let added = FileDiff::compute("x.txt", None, Some(b"a\nb\n"));
assert_eq!((added.additions, added.deletions), (2, 0));
let deleted = FileDiff::compute("x.txt", Some(b"a\n"), None);
assert_eq!((deleted.additions, deleted.deletions), (0, 1));
let binary = FileDiff::compute("x.png", Some(b"\0\x01"), Some(b"\0\x02\x03"));
assert_eq!(
binary.content,
Content::Binary {
old_size: Some(2),
new_size: Some(3)
}
);
let big = vec![b'a'; MAX_DIFF_BYTES + 1];
assert!(matches!(
FileDiff::compute("x.txt", None, Some(&big)).content,
Content::TooLarge { .. }
));
}
}