use crate::buffer::RenderSnapshot;
use imara_diff::{Algorithm, Diff, InternedInput};
use std::ops::Range;
#[derive(Debug, Clone)]
pub struct InlineChange {
pub range: Range<usize>,
}
type LineInlineChanges = Vec<Vec<InlineChange>>;
#[derive(Debug, Clone)]
pub struct DiffHunk {
pub old_lines: Range<usize>,
pub new_lines: Range<usize>,
pub old_inline_changes: LineInlineChanges,
pub new_inline_changes: LineInlineChanges,
}
#[derive(Clone)]
pub struct DiffState {
pub old_snapshot: RenderSnapshot,
pub hunks: Vec<DiffHunk>,
}
impl DiffState {
pub fn compute(old_snapshot: RenderSnapshot, old_text: &str, new_text: &str) -> Self {
let hunks = compute_line_hunks(old_text, new_text);
Self {
old_snapshot,
hunks,
}
}
pub fn has_hunks(&self) -> bool {
!self.hunks.is_empty()
}
fn hunk_by_new_line(&self, new_line: usize) -> Option<&DiffHunk> {
let i = self.hunks.partition_point(|h| h.new_lines.end <= new_line);
self.hunks
.get(i)
.filter(|h| h.new_lines.contains(&new_line))
}
fn hunk_by_old_line(&self, old_line: usize) -> Option<&DiffHunk> {
let i = self.hunks.partition_point(|h| h.old_lines.end <= old_line);
self.hunks
.get(i)
.filter(|h| h.old_lines.contains(&old_line))
}
pub fn ghost_lines_before(&self, new_line: usize) -> Option<Range<usize>> {
let start = self.hunks.partition_point(|h| h.new_lines.start < new_line);
self.hunks[start..]
.iter()
.take_while(|h| h.new_lines.start == new_line)
.find(|h| !h.old_lines.is_empty())
.map(|h| h.old_lines.clone())
}
pub fn is_addition(&self, new_line: usize) -> bool {
self.hunk_by_new_line(new_line).is_some()
}
pub fn old_inline_changes(&self, old_line: usize) -> Option<&[InlineChange]> {
let hunk = self.hunk_by_old_line(old_line)?;
let line_offset = old_line - hunk.old_lines.start;
hunk.old_inline_changes
.get(line_offset)
.filter(|c| !c.is_empty())
.map(|c| c.as_slice())
}
pub fn new_inline_changes(&self, new_line: usize) -> Option<&[InlineChange]> {
let hunk = self.hunk_by_new_line(new_line)?;
let line_offset = new_line - hunk.new_lines.start;
hunk.new_inline_changes
.get(line_offset)
.filter(|c| !c.is_empty())
.map(|c| c.as_slice())
}
}
fn normalize_for_diff(text: &str) -> std::borrow::Cow<'_, str> {
if text.ends_with('\n') {
std::borrow::Cow::Borrowed(text)
} else {
std::borrow::Cow::Owned(format!("{text}\n"))
}
}
fn compute_line_hunks(old_text: &str, new_text: &str) -> Vec<DiffHunk> {
let old_normalized = normalize_for_diff(old_text);
let new_normalized = normalize_for_diff(new_text);
let old_lines: Vec<&str> = old_normalized.lines().collect();
let new_lines: Vec<&str> = new_normalized.lines().collect();
let input = InternedInput::new(&*old_normalized, &*new_normalized);
let mut diff = Diff::compute(Algorithm::Histogram, &input);
diff.postprocess_lines(&input);
diff.hunks()
.map(|hunk| {
let old_range = hunk.before.start as usize..hunk.before.end as usize;
let new_range = hunk.after.start as usize..hunk.after.end as usize;
let (old_inline, new_inline) = if !old_range.is_empty() && !new_range.is_empty() {
compute_word_changes(&old_lines[old_range.clone()], &new_lines[new_range.clone()])
} else {
(vec![], vec![])
};
DiffHunk {
old_lines: old_range,
new_lines: new_range,
old_inline_changes: old_inline,
new_inline_changes: new_inline,
}
})
.collect()
}
fn compute_word_changes(
old_lines: &[&str],
new_lines: &[&str],
) -> (LineInlineChanges, LineInlineChanges) {
let mut old_changes: LineInlineChanges = vec![Vec::new(); old_lines.len()];
let mut new_changes: LineInlineChanges = vec![Vec::new(); new_lines.len()];
for i in 0..old_lines.len().min(new_lines.len()) {
let (dels, adds) = word_diff_line(old_lines[i], new_lines[i]);
old_changes[i] = dels;
new_changes[i] = adds;
}
(old_changes, new_changes)
}
fn word_diff_line(old: &str, new: &str) -> (Vec<InlineChange>, Vec<InlineChange>) {
if old == new {
return (Vec::new(), Vec::new());
}
let old_tokens = tokenize_with_positions(old);
let new_tokens = tokenize_with_positions(new);
let old_words: Vec<&str> = old_tokens.iter().map(|(s, _)| *s).collect();
let new_words: Vec<&str> = new_tokens.iter().map(|(s, _)| *s).collect();
let old_joined = old_words.join("\n");
let new_joined = new_words.join("\n");
let input = InternedInput::new(old_joined.as_str(), new_joined.as_str());
let mut diff = Diff::compute(Algorithm::Histogram, &input);
diff.postprocess_lines(&input);
let mut dels = Vec::new();
let mut adds = Vec::new();
for hunk in diff.hunks() {
for idx in hunk.before.start as usize..hunk.before.end as usize {
if let Some((_, r)) = old_tokens.get(idx) {
dels.push(InlineChange { range: r.clone() });
}
}
for idx in hunk.after.start as usize..hunk.after.end as usize {
if let Some((_, r)) = new_tokens.get(idx) {
adds.push(InlineChange { range: r.clone() });
}
}
}
(dels, adds)
}
fn tokenize_with_positions(text: &str) -> Vec<(&str, Range<usize>)> {
#[derive(PartialEq)]
enum Class {
Word,
Space,
Punct,
}
fn classify(c: char) -> Class {
if c.is_alphanumeric() || c == '_' {
Class::Word
} else if c.is_whitespace() {
Class::Space
} else {
Class::Punct
}
}
let mut tokens = Vec::new();
let mut iter = text.char_indices().peekable();
while let Some(&(start, c)) = iter.peek() {
let class = classify(c);
let mut end = start;
while let Some(&(i, ch)) = iter.peek() {
if classify(ch) == class {
end = i + ch.len_utf8();
iter.next();
} else {
break;
}
}
tokens.push((&text[start..end], start..end));
}
tokens
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_line_hunks_addition() {
let old = "line1\nline2\n";
let new = "line1\ninserted\nline2\n";
let hunks = compute_line_hunks(old, new);
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].old_lines, 1..1); assert_eq!(hunks[0].new_lines, 1..2); }
#[test]
fn test_compute_line_hunks_deletion() {
let old = "line1\nline2\nline3\n";
let new = "line1\nline3\n";
let hunks = compute_line_hunks(old, new);
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].old_lines, 1..2); assert_eq!(hunks[0].new_lines, 1..1); }
#[test]
fn test_compute_line_hunks_modification() {
let old = "line1\nold content\nline3\n";
let new = "line1\nnew content\nline3\n";
let hunks = compute_line_hunks(old, new);
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].old_lines, 1..2); assert_eq!(hunks[0].new_lines, 1..2); }
#[test]
fn test_word_level_changes() {
let old = "The Histogram algorithm was originally ported from git and has since undergone significant optimization.";
let new =
"The Histogram algorithm, originally ported from git, has been extensively optimized.";
let hunks = compute_line_hunks(&format!("{}\n", old), &format!("{}\n", new));
assert_eq!(hunks.len(), 1);
assert!(
!hunks[0].old_inline_changes.is_empty(),
"Should have old inline changes"
);
assert!(
!hunks[0].new_inline_changes.is_empty(),
"Should have new inline changes"
);
eprintln!("Old inline changes: {:?}", hunks[0].old_inline_changes);
eprintln!("New inline changes: {:?}", hunks[0].new_inline_changes);
}
#[test]
fn test_identical_lines_no_hunks() {
let text = "line1\nline2\nline3\n";
let hunks = compute_line_hunks(text, text);
assert_eq!(hunks.len(), 0, "Identical text should produce no hunks");
}
#[test]
fn test_trailing_newline_difference() {
let old = "line1\nline2\nline3";
let new = "line1\nline2\nline3\n";
let hunks = compute_line_hunks(old, new);
assert_eq!(
hunks.len(),
0,
"Trailing newline difference should not produce hunks"
);
}
#[test]
fn test_trailing_whitespace_difference() {
let old = "line1\nline2 \nline3\n";
let new = "line1\nline2\nline3\n";
let hunks = compute_line_hunks(old, new);
assert_eq!(hunks.len(), 1, "Trailing whitespace is a real change");
assert_eq!(hunks[0].old_lines, 1..2);
assert_eq!(hunks[0].new_lines, 1..2);
}
}