Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 81 additions & 9 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@
use std::ops::Range;
use std::slice;

use crate::util::{strip_common_postfix, strip_common_prefix};
use crate::{
sources::words,
util::{strip_common_postfix, strip_common_prefix},
};

pub use crate::slider_heuristic::{
IndentHeuristic, IndentLevel, NoSliderHeuristic, SliderHeuristic,
Expand All @@ -160,13 +163,12 @@ mod myers;
mod postprocess;
mod slider_heuristic;
pub mod sources;
#[cfg(test)]
mod tests;
#[cfg(feature = "unified_diff")]
mod unified_diff;
mod util;

#[cfg(test)]
mod tests;

/// `imara-diff` supports multiple different algorithms
/// for computing an edit sequence.
/// These algorithms have different performance and all produce different output.
Expand Down Expand Up @@ -225,11 +227,6 @@ pub enum Algorithm {
MyersMinimal,
}

impl Algorithm {
#[cfg(test)]
const ALL: [Self; 2] = [Algorithm::Histogram, Algorithm::Myers];
}

/// Represents the difference between two sequences of tokens.
///
/// A `Diff` stores which tokens were removed from the first sequence and which tokens were added to the second sequence.
Expand Down Expand Up @@ -416,6 +413,81 @@ impl Hunk {
pub fn is_pure_removal(&self) -> bool {
self.after.is_empty()
}

/// Performs a word-diff on this hunk.
///
/// This requires passing the original [`input`](InternedInput) in order to look up
/// the tokens of the current hunk, which typically are lines.
/// Each token is split into words using the built-in [`words`] tokenizer.
/// The resulting word tokens are stored in a second [`diff_input`](InternedInput),
/// and a [`diff`](Diff) is computed on them, with basic post-processing applied.
///
/// For performance reasons, this second [`diff_input`](InternedInput) as well as
/// the computed [`diff`](Diff) need to be passed as parameters so that they can be
/// re-used when iterating over hunks. Note that word tokens are always
/// added but never removed from the interner. Consider clearing it if you expect
/// your input to have a large vocabulary.
///
/// # Examples
///
/// ```
/// # use imara_diff::{InternedInput, Diff, Algorithm};
/// // Compute diff normally
/// let before = "before text";
/// let after = "after text";
/// let mut lines = InternedInput::new(before, after);
/// let mut diff = Diff::compute(Algorithm::Histogram, &lines);
/// diff.postprocess_lines(&lines);
///
/// // Compute word-diff per hunk, reusing allocations across iterations
/// let mut hunk_diff_input = InternedInput::default();
/// let mut hunk_diff = Diff::default();
/// for hunk in diff.hunks() {
/// hunk.latin_word_diff(&lines, &mut hunk_diff_input, &mut hunk_diff);
/// let added = hunk_diff.count_additions();
/// let removed = hunk_diff.count_removals();
/// println!("word-diff of this hunk has {added} additions and {removed} removals");
/// // optionally, clear the interner:
/// hunk_diff_input.clear();
/// }
/// ```
pub fn latin_word_diff<'a>(
&self,
input: &InternedInput<&'a str>,
word_tokens: &mut InternedInput<&'a str>,
diff: &mut Diff,
) {
let Hunk { before, after } = self.clone();
Comment thread
Byron marked this conversation as resolved.
word_tokens.update_before(
before
.map(|index| input.before[index as usize])
.map(|token| input.interner[token])
.flat_map(|line| words(line)),
);
word_tokens.update_after(
after
.map(|index| input.after[index as usize])
.map(|token| input.interner[token])
.flat_map(|line| words(line)),
);
diff.removed.clear();
diff.removed.resize(word_tokens.before.len(), false);
diff.added.clear();
diff.added.resize(word_tokens.after.len(), false);
if self.is_pure_removal() {
diff.removed.fill(true);
} else if self.is_pure_insertion() {
diff.added.fill(true);
} else {
diff.compute_with(
Algorithm::Myers,
Comment thread
KnorpelSenf marked this conversation as resolved.
&word_tokens.before,
&word_tokens.after,
word_tokens.interner.num_tokens(),
);
diff.postprocess_no_heuristic(word_tokens);
}
}
}

/// Yields all [`Hunk`]s in a file in monotonically increasing order.
Expand Down
55 changes: 55 additions & 0 deletions src/sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ pub fn lines(data: &str) -> Lines<'_> {
Lines(ByteLines(data.as_bytes()))
}

/// Returns a [`TokenSource`] that uses the words in `data` as Tokens. A word is
/// a sequence of alphanumeric characters as determined by
/// `char::is_alphanumeric`, or a sequence of just the space character ' '. Any
/// other characters are their own word.
Comment on lines +22 to +23

Copilot AI Dec 20, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states that "a sequence of just the space character ' '" is treated as a word, but this is incomplete. The implementation on line 104-108 treats any sequence of consecutive spaces as a single token. The documentation should clarify that multiple consecutive spaces are grouped together as one token.

Suggested change
/// `char::is_alphanumeric`, or a sequence of just the space character ' '. Any
/// other characters are their own word.
/// `char::is_alphanumeric`, or a sequence of one or more consecutive space
/// characters (' '). Any other characters are their own word.

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +23

Copilot AI Dec 20, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation mentions "a sequence of alphanumeric characters as determined by char::is_alphanumeric" but the implementation on lines 110-113 also includes underscores (_) as part of alphanumeric words. This is inconsistent with the documentation. Either update the documentation to mention that underscores are included in alphanumeric sequences, or remove the special handling of underscores if they should be treated as separate tokens.

Suggested change
/// a sequence of alphanumeric characters as determined by
/// `char::is_alphanumeric`, or a sequence of just the space character ' '. Any
/// other characters are their own word.
/// a sequence of "word" characters (those for which `char::is_alphanumeric`
/// returns `true`, plus the underscore character '_'), or a sequence of just
/// the space character ' '. Any other characters are their own word.

Copilot uses AI. Check for mistakes.
pub fn words(data: &str) -> Words<'_> {
Words(data)
}

/// Returns a [`TokenSource`] that uses the lines in `data` as Tokens. The newline
/// separator (`\r\n` or `\n`) is included in the emitted tokens. This means that changing
/// the newline separator from `\r\n` to `\n` (or omitting it fully on the last line) is
Expand Down Expand Up @@ -84,6 +92,53 @@ impl<'a> TokenSource for Lines<'a> {
}
}

/// A [`TokenSource`] that returns the words of a string as tokens. See
/// [`words`] for details.
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Words<'a>(&'a str);

impl<'a> Iterator for Words<'a> {
type Item = &'a str;

fn next(&mut self) -> Option<Self::Item> {
if self.0.is_empty() {
return None;
}

let initial = self.0.chars().next().unwrap();
let word_len = if initial == ' ' {
self.0
.char_indices()
.find(|(_, c)| *c != ' ')
.map_or(self.0.len(), |(index, _)| index)
} else if initial.is_alphanumeric() {
Comment thread
KnorpelSenf marked this conversation as resolved.
self.0
.char_indices()
.find(|(_, c)| !c.is_alphanumeric() && *c != '_')
.map_or(self.0.len(), |(index, _)| index)
} else {
initial.len_utf8()
};

let (word, rem) = self.0.split_at(word_len);
self.0 = rem;
Some(word)
}
}
impl<'a> TokenSource for Words<'a> {
type Token = &'a str;

type Tokenizer = Self;

fn tokenize(&self) -> Self::Tokenizer {
*self
}

fn estimate_tokens(&self) -> u32 {
(self.0.len() / 3) as u32

Copilot AI Dec 20, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The token estimation heuristic divides string length by 3 (assuming average word length of 3 characters). This could result in poor allocation sizing:

  1. For typical English text, average word length is closer to 4-5 characters when including spaces and punctuation
  2. For code with long identifiers, the average could be much higher
  3. The estimate doesn't account for the fact that each punctuation character becomes its own token

Consider using a more conservative estimate like (self.0.len() / 5) or implementing a sampling approach similar to ByteLines::estimate_tokens() that examines the first portion of the text to calculate the actual average.

Suggested change
(self.0.len() / 3) as u32
(self.0.len() / 5) as u32

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know about this one, but it seems that heuristics aren't always right and maybe there is a way to make it configurable?
Maybe this word-diff is also so tuned to Latin text that we might say it in the function, i.e. word_diff to latin_word_diff.

}
}

/// A [`TokenSource`] that returns the lines of a byte slice as tokens. See [`byte_lines`]
/// for details.
#[derive(Clone, Copy, PartialEq, Eq)]
Expand Down
Loading