-
Notifications
You must be signed in to change notification settings - Fork 18
feat: word diffs #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: word diffs #33
Changes from 14 commits
969dde8
a625abc
1bf3ccf
3dba63f
2682999
a175626
fbff98b
ae627f5
acdad54
337bbdb
236ae42
4c14911
83fe6bd
b24e52c
9290066
30d2f41
936bbcf
0194b50
d042243
cd20497
d193772
7724909
e92b3c6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -141,7 +141,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, | ||
|
|
@@ -389,6 +392,45 @@ impl Hunk { | |
| pub fn is_pure_removal(&self) -> bool { | ||
| self.after.is_empty() | ||
| } | ||
|
|
||
| /// Performs a word-diff of the hunk | ||
| pub fn word_diff<'a>( | ||
| &self, | ||
| input: &InternedInput<&'a str>, | ||
| diff_input: &mut InternedInput<&'a str>, | ||
| diff: &mut Diff, | ||
| ) { | ||
| let Hunk { before, after } = self.clone(); | ||
|
Byron marked this conversation as resolved.
|
||
| diff_input.update_before( | ||
| before | ||
| .map(|index| input.before[index as usize]) | ||
| .map(|token| input.interner[token]) | ||
| .flat_map(|line| words(line)), | ||
| ); | ||
| diff_input.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(diff_input.before.len(), false); | ||
| diff.added.clear(); | ||
| diff.added.resize(diff_input.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, | ||
|
KnorpelSenf marked this conversation as resolved.
|
||
| &diff_input.before, | ||
| &diff_input.after, | ||
| diff_input.interner.num_tokens(), | ||
| ); | ||
| diff.postprocess_no_heuristic(diff_input); | ||
| } | ||
| } | ||
|
||
| } | ||
|
|
||
| /// Yields all [`Hunk`]s in a file in monotonically increasing order. | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -12,6 +12,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
|
||||||||||||||||||||||
| /// `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
AI
Dec 20, 2025
There was a problem hiding this comment.
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.
| /// 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
AI
Dec 20, 2025
There was a problem hiding this comment.
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:
- For typical English text, average word length is closer to 4-5 characters when including spaces and punctuation
- For code with long identifiers, the average could be much higher
- 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.
| (self.0.len() / 3) as u32 | |
| (self.0.len() / 5) as u32 |
There was a problem hiding this comment.
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.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,9 +7,20 @@ use expect_test::{expect, expect_file}; | |
| // use git_repository as git; | ||
|
|
||
| use crate::intern::InternedInput; | ||
| use crate::sources::words; | ||
| use crate::unified_diff::BasicLineDiffPrinter; | ||
| use crate::{Algorithm, Diff, UnifiedDiffConfig}; | ||
|
|
||
| #[test] | ||
| fn words_tokenizer() { | ||
| let text = "Hello, imara!\n (foo-bar)"; | ||
| let tokens = words(text).collect::<Vec<_>>(); | ||
| assert_eq!( | ||
| tokens, | ||
| vec!["Hello", ",", " ", "imara", "!", "\n", " ", "(", "foo", "-", "bar", ")"] | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn postprocess() { | ||
| let before = r#" | ||
|
|
@@ -320,6 +331,156 @@ i | |
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn hunk_word_diff_pure() { | ||
| let before = r#"fn foo() -> Bar{ | ||
| let mut foo = 2.0; | ||
| foo *= 100 / 2; | ||
| }"#; | ||
| let after = r#"fn foo() -> Bar{ | ||
| let mut foo = 2.0; | ||
| foo *= 100 / 2; | ||
| println("hello world") | ||
| }"#; | ||
| let mut input = InternedInput::new(before, after); | ||
| for algorithm in Algorithm::ALL { | ||
| let mut diff_input = InternedInput::default(); | ||
| let mut d = Diff::default(); | ||
|
||
|
|
||
| println!("{algorithm:?}"); | ||
|
|
||
| let mut diff = Diff::compute(algorithm, &input); | ||
| diff.postprocess_lines(&input); | ||
|
|
||
| let mut hunks = diff.hunks(); | ||
| let hunk = hunks.next().expect("missing first hunk"); | ||
| hunk.word_diff(&input, &mut diff_input, &mut d); | ||
| let mut h = d.hunks(); | ||
| let first = h.next().expect("missing first inner hunk"); | ||
| assert!(first.is_pure_insertion()); | ||
| assert_eq!(first.before, 0..0); | ||
| assert_eq!( | ||
| first.after, | ||
| 0..words(" println(\"hello world\")\n").count() as u32 | ||
| ); | ||
| assert_eq!(h.next(), None); | ||
| assert_eq!(hunks.next(), None); | ||
|
|
||
| swap(&mut input.before, &mut input.after); | ||
|
|
||
| let mut diff = Diff::compute(algorithm, &input); | ||
| diff.postprocess_lines(&input); | ||
|
|
||
| let mut hunks = diff.hunks(); | ||
| let hunk = hunks.next().expect("missing first hunk"); | ||
| hunk.word_diff(&input, &mut diff_input, &mut d); | ||
| let mut h = d.hunks(); | ||
| let first = h.next().expect("missing first inner hunk"); | ||
| assert!(first.is_pure_removal()); | ||
| assert_eq!( | ||
| first.before, | ||
| 0..words(" println(\"hello world\")\n").count() as u32 | ||
| ); | ||
| assert_eq!(first.after, 0..0); | ||
| assert_eq!(h.next(), None); | ||
| assert_eq!(hunks.next(), None); | ||
|
|
||
| swap(&mut input.before, &mut input.after); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn hunk_word_diff_modify() { | ||
| let before = r#"fn foo() -> Bar { | ||
| let mut foo = 2.0; | ||
| foo *= 100 / 2; | ||
| }"#; | ||
| let after = r#"fn foo() -> Bar { | ||
| let mut foo = 3.0 * 2.0; | ||
| foo += 100 / 2; | ||
| }"#; | ||
| let mut input = InternedInput::new(before, after); | ||
| for algorithm in Algorithm::ALL { | ||
| let mut diff_input = InternedInput::default(); | ||
| let mut d = Diff::default(); | ||
|
||
|
|
||
| println!("{algorithm:?}"); | ||
|
|
||
| let mut diff = Diff::compute(algorithm, &input); | ||
| diff.postprocess_lines(&input); | ||
|
|
||
| let mut hunks = diff.hunks(); | ||
| let hunk = hunks.next().expect("missing first hunk"); | ||
| hunk.word_diff(&input, &mut diff_input, &mut d); | ||
| let mut h = d.hunks(); | ||
| let first = h.next().expect("missing first inner hunk"); | ||
| assert!(first.is_pure_insertion()); | ||
| let off = words(" let mut foo = ").count() as u32; | ||
| assert_eq!(first.before, off..off); | ||
| let ins = words("3.0 * ").count() as u32; | ||
| assert_eq!(first.after, off..ins + off); | ||
| let second = h.next().expect("missing second inner hunk"); | ||
| let off = words( | ||
| r#" let mut foo = 2.0; | ||
| foo "#, | ||
| ) | ||
| .count() as u32; | ||
| assert_eq!(second.before, off..1 + off); | ||
| assert_eq!(second.after, ins + off..1 + ins + off); | ||
| assert_eq!(h.next(), None); | ||
| assert_eq!(hunks.next(), None); | ||
|
|
||
| swap(&mut input.before, &mut input.after); | ||
|
|
||
| let mut diff = Diff::compute(algorithm, &input); | ||
| diff.postprocess_lines(&input); | ||
|
|
||
| let mut hunks = diff.hunks(); | ||
| let hunk = hunks.next().expect("missing first hunk"); | ||
| hunk.word_diff(&input, &mut diff_input, &mut d); | ||
| let mut h = d.hunks(); | ||
| let first = h.next().expect("missing first inner hunk"); | ||
| assert!(first.is_pure_removal()); | ||
| let off = words(" let mut foo = ").count() as u32; | ||
| let rem = words("3.0 * ").count() as u32; | ||
| assert_eq!(first.before, off..rem + off); | ||
| assert_eq!(first.after, off..off); | ||
| let second = h.next().expect("missing second inner hunk"); | ||
| let off = words( | ||
| r#" let mut foo = 2.0; | ||
| foo "#, | ||
| ) | ||
| .count() as u32; | ||
| assert_eq!(second.before, rem + off..1 + rem + off); | ||
| assert_eq!(second.after, off..1 + off); | ||
| assert_eq!(h.next(), None); | ||
| assert_eq!(hunks.next(), None); | ||
|
|
||
| swap(&mut input.before, &mut input.after); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn large_file() { | ||
| println!("reading files"); | ||
| let before = std::fs::read_to_string("/tmp/before.html").expect("bad file read"); | ||
| let after = std::fs::read_to_string("/tmp/after.html").expect("bad file read"); | ||
| println!("interning"); | ||
| let input = InternedInput::new(before.as_str(), after.as_str()); | ||
| println!("initial diff"); | ||
| let diff = Diff::compute(Algorithm::Myers, &input); | ||
|
|
||
| let mut word_input = InternedInput::default(); | ||
| let mut word_diff = Diff::default(); | ||
| for (i, hunk) in diff.hunks().enumerate() { | ||
| println!("+++ Hunk {i}"); | ||
| hunk.word_diff(&input, &mut word_input, &mut word_diff); | ||
| println!("word diff count {}", word_diff.hunks().count()); | ||
| println!("--- Hunk {i}"); | ||
| } | ||
| println!("done"); | ||
| } | ||
|
|
||
| pub fn project_root() -> PathBuf { | ||
| let dir = env!("CARGO_MANIFEST_DIR"); | ||
| let mut res = PathBuf::from(dir); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The word_diff API requires callers to pass mutable references to diff_input and diff that are populated by this function. This design is not intuitive and error-prone because:
Consider either:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While copilot is right in principle, I think compete documentation on parameters could explain why they need to be mutable.
Docs are definitely needed here.