Skip to main content

java_diff_utils_rs/algorithm/histogram/
histogram_diff.rs

1//! Histogram diff algorithm (Patience / low-occurrence anchor based diff).
2//!
3//! Ported to match the behavior of JGit / `java-diff-utils` `HistogramDiff`.
4//! This algorithm selects elements with low occurrence counts as anchors to split sequences
5//! recursively, falling back to Myers' algorithm when no low-occurrence anchors remain.
6
7use crate::algorithm::{
8    change::{Change, DeltaType},
9    diff_algorithm_factory::DiffAlgorithmFactory,
10    diff_algorithm_listener::DiffAlgorithmListener,
11    myers::myers_linear::MyersDiffWithLinearSpace,
12    DiffAlgorithm,
13};
14use std::collections::{HashMap, HashSet};
15use std::hash::Hash;
16
17/// Default maximum occurrence count for an element to be considered as a pivot anchor.
18pub const DEFAULT_MAX_CHAIN_LENGTH: usize = 64;
19
20/// Histogram diff algorithm implementation.
21pub struct HistogramDiff<T> {
22    max_chain_length: usize,
23    equalizer: Option<Box<dyn Fn(&T, &T) -> bool>>,
24}
25
26impl<T> Default for HistogramDiff<T> {
27    fn default() -> Self {
28        Self {
29            max_chain_length: DEFAULT_MAX_CHAIN_LENGTH,
30            equalizer: None,
31        }
32    }
33}
34
35impl<T> HistogramDiff<T> {
36    /// Creates a new `HistogramDiff` with default max chain length (64).
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// Sets a custom maximum chain length threshold.
42    #[must_use]
43    pub fn with_max_chain_length(mut self, max_chain_length: usize) -> Self {
44        self.max_chain_length = max_chain_length;
45        self
46    }
47
48    /// Sets a custom element equality predicate.
49    #[must_use]
50    pub fn with_equalizer<F>(mut self, equalizer: F) -> Self
51    where
52        F: Fn(&T, &T) -> bool + 'static,
53    {
54        self.equalizer = Some(Box::new(equalizer));
55        self
56    }
57}
58
59impl<T: Eq + Hash> DiffAlgorithm<T> for HistogramDiff<T> {
60    fn diff_with_listener(
61        &self,
62        source: &[T],
63        target: &[T],
64        listener: &mut dyn DiffAlgorithmListener,
65    ) -> Vec<Change> {
66        let eq: &dyn Fn(&T, &T) -> bool = match &self.equalizer {
67            Some(f) => f.as_ref(),
68            None => &|a, b| a == b,
69        };
70        compute_diff_full(source, target, eq, self.max_chain_length, Some(listener))
71    }
72}
73
74/// Computes the diff between two slices using HistogramDiff and default equality.
75pub fn compute_diff<T: Eq + Hash>(source: &[T], target: &[T]) -> Vec<Change> {
76    compute_diff_with(source, target, |a, b| a == b)
77}
78
79/// Computes the diff between two slices using HistogramDiff and a custom equalizer.
80pub fn compute_diff_with<T, F>(source: &[T], target: &[T], equalizer: F) -> Vec<Change>
81where
82    T: Eq + Hash,
83    F: Fn(&T, &T) -> bool,
84{
85    compute_diff_full(
86        source,
87        target,
88        &equalizer,
89        DEFAULT_MAX_CHAIN_LENGTH,
90        Option::<&mut dyn DiffAlgorithmListener>::None,
91    )
92}
93
94/// Full histogram diff entry point with workspace and listener support.
95pub fn compute_diff_full<T, F, L>(
96    source: &[T],
97    target: &[T],
98    equalizer: &F,
99    max_chain_length: usize,
100    mut listener: Option<&mut L>,
101) -> Vec<Change>
102where
103    T: Eq + Hash,
104    F: Fn(&T, &T) -> bool + ?Sized,
105    L: DiffAlgorithmListener + ?Sized,
106{
107    if source.is_empty() && target.is_empty() {
108        return Vec::new();
109    }
110
111    if let Some(l) = listener.as_deref_mut() {
112        l.diff_start();
113    }
114
115    let mut script = Vec::new();
116    let total_steps = source.len() + target.len();
117
118    histogram_rec(
119        source,
120        target,
121        0,
122        source.len(),
123        0,
124        target.len(),
125        equalizer,
126        max_chain_length,
127        &mut script,
128        listener.as_deref_mut(),
129        total_steps,
130    );
131
132    normalize_replacements(&mut script);
133
134    if let Some(l) = listener {
135        l.diff_end();
136    }
137
138    script
139}
140
141#[derive(Clone, Copy)]
142struct MatchAnchor {
143    src_idx: usize,
144    tgt_idx: usize,
145    len: usize,
146}
147
148#[allow(clippy::too_many_arguments)]
149fn histogram_rec<T, F, L>(
150    source: &[T],
151    target: &[T],
152    mut src_start: usize,
153    mut src_end: usize,
154    mut tgt_start: usize,
155    mut tgt_end: usize,
156    equalizer: &F,
157    max_chain_length: usize,
158    script: &mut Vec<Change>,
159    mut listener: Option<&mut L>,
160    total_steps: usize,
161) where
162    T: Eq + Hash,
163    F: Fn(&T, &T) -> bool + ?Sized,
164    L: DiffAlgorithmListener + ?Sized,
165{
166    // Fast path: trim matching prefix
167    while src_start < src_end
168        && tgt_start < tgt_end
169        && equalizer(&source[src_start], &target[tgt_start])
170    {
171        src_start += 1;
172        tgt_start += 1;
173    }
174
175    // Fast path: trim matching suffix
176    while src_end > src_start
177        && tgt_end > tgt_start
178        && equalizer(&source[src_end - 1], &target[tgt_end - 1])
179    {
180        src_end -= 1;
181        tgt_end -= 1;
182    }
183
184    let src_len = src_end - src_start;
185    let tgt_len = tgt_end - tgt_start;
186
187    if src_len == 0 && tgt_len == 0 {
188        return;
189    }
190
191    if let Some(l) = listener.as_deref_mut() {
192        l.diff_step(src_start + tgt_start, total_steps);
193    }
194
195    // Base cases: purely insertion or deletion
196    if src_len == 0 {
197        push_change(
198            script,
199            DeltaType::Insert,
200            src_start,
201            src_start,
202            tgt_start,
203            tgt_end,
204        );
205        return;
206    }
207    if tgt_len == 0 {
208        push_change(
209            script,
210            DeltaType::Delete,
211            src_start,
212            src_end,
213            tgt_start,
214            tgt_start,
215        );
216        return;
217    }
218
219    if src_len > 256
220        && tgt_len > 256
221        && ((looks_like_high_entropy(source, src_start, src_end)
222            && looks_like_high_entropy(target, tgt_start, tgt_end))
223            || (looks_like_low_entropy(source, src_start, src_end)
224                && looks_like_low_entropy(target, tgt_start, tgt_end)))
225    {
226        let fallback_algo = MyersDiffWithLinearSpace::new();
227        let sub_source = &source[src_start..src_end];
228        let sub_target = &target[tgt_start..tgt_end];
229        let sub_changes = fallback_algo.diff_with_listener(
230            sub_source,
231            sub_target,
232            &mut crate::algorithm::diff_algorithm_listener::NoOpListener,
233        );
234        for c in sub_changes {
235            push_change(
236                script,
237                c.delta_type,
238                src_start + c.start_original,
239                src_start + c.end_original,
240                tgt_start + c.start_revised,
241                tgt_start + c.end_revised,
242            );
243        }
244        return;
245    }
246
247    // Build both vector buckets and hash indexes in one pass per sequence.
248    let (src_buckets, _) = build_buckets(source, src_start, src_end, equalizer);
249    let (tgt_buckets, _) = build_buckets(target, tgt_start, tgt_end, equalizer);
250    let src_distinct = src_buckets.len();
251    let tgt_distinct = tgt_buckets.len();
252
253    // Check whether any value exceeds max_chain_length (high-frequency check).
254    // Re-use the already-built bucket lists rather than scanning again.
255    let src_has_high_freq = src_buckets
256        .iter()
257        .any(|b| b.positions.len() > max_chain_length);
258    let tgt_has_high_freq = tgt_buckets
259        .iter()
260        .any(|b| b.positions.len() > max_chain_length);
261
262    // Histogram's recursive bucket rebuilds are wasteful when almost every
263    // element is unique. Dispatch high-distinct, low-repetition regions to
264    // Myers, which is substantially cheaper for this shape.
265    let src_max_frequency = src_buckets
266        .iter()
267        .map(|bucket| bucket.positions.len())
268        .max()
269        .unwrap_or(0);
270    let tgt_max_frequency = tgt_buckets
271        .iter()
272        .map(|bucket| bucket.positions.len())
273        .max()
274        .unwrap_or(0);
275    let low_repetition = (src_distinct > 32 || tgt_distinct > 32)
276        && src_max_frequency <= 2
277        && tgt_max_frequency <= 2;
278
279    if src_has_high_freq || tgt_has_high_freq || low_repetition {
280        let fallback_algo = MyersDiffWithLinearSpace::new();
281        let sub_source = &source[src_start..src_end];
282        let sub_target = &target[tgt_start..tgt_end];
283
284        let sub_changes = fallback_algo.diff_with_listener(
285            sub_source,
286            sub_target,
287            &mut crate::algorithm::diff_algorithm_listener::NoOpListener,
288        );
289
290        for c in sub_changes {
291            push_change(
292                script,
293                c.delta_type,
294                src_start + c.start_original,
295                src_start + c.end_original,
296                tgt_start + c.start_revised,
297                tgt_start + c.end_revised,
298            );
299        }
300        return;
301    }
302    // Drop the bucket lists — find_best_anchor will rebuild them internally.
303    // (The alternative of passing them in would require threading through the
304    // recursive signature; the build cost is O(n) per slice, which is acceptable.)
305    drop(src_buckets);
306    drop(tgt_buckets);
307
308    // Try finding the lowest-occurrence anchor in the target range
309    if let Some(anchor) = find_best_anchor(
310        source,
311        target,
312        src_start,
313        src_end,
314        tgt_start,
315        tgt_end,
316        equalizer,
317        max_chain_length,
318    ) {
319        // Divide and conquer: Left subregion
320        histogram_rec(
321            source,
322            target,
323            src_start,
324            anchor.src_idx,
325            tgt_start,
326            anchor.tgt_idx,
327            equalizer,
328            max_chain_length,
329            script,
330            listener.as_deref_mut(),
331            total_steps,
332        );
333
334        // Right subregion (anchor region is skipped as it is equal)
335        histogram_rec(
336            source,
337            target,
338            anchor.src_idx + anchor.len,
339            src_end,
340            anchor.tgt_idx + anchor.len,
341            tgt_end,
342            equalizer,
343            max_chain_length,
344            script,
345            listener,
346            total_steps,
347        );
348    } else {
349        // Fallback to linear Myers on this subregion
350        let fallback_algo = MyersDiffWithLinearSpace::new();
351        let sub_source = &source[src_start..src_end];
352        let sub_target = &target[tgt_start..tgt_end];
353
354        let sub_changes = fallback_algo.diff_with_listener(
355            sub_source,
356            sub_target,
357            &mut crate::algorithm::diff_algorithm_listener::NoOpListener,
358        );
359
360        for c in sub_changes {
361            push_change(
362                script,
363                c.delta_type,
364                src_start + c.start_original,
365                src_start + c.end_original,
366                tgt_start + c.start_revised,
367                tgt_start + c.end_revised,
368            );
369        }
370    }
371}
372
373struct OccurrenceBucket<'a, T> {
374    value: &'a T,
375    positions: Vec<usize>,
376}
377
378/// Build occurrence buckets for `sequence[start..end]` in a single pass.
379///
380/// Returns the occurrence buckets and a hash index into them.
381fn build_buckets<'a, T, F>(
382    sequence: &'a [T],
383    start: usize,
384    end: usize,
385    equalizer: &F,
386) -> (Vec<OccurrenceBucket<'a, T>>, HashMap<&'a T, usize>)
387where
388    T: Eq + Hash,
389    F: Fn(&T, &T) -> bool + ?Sized,
390{
391    let mut buckets: Vec<OccurrenceBucket<'a, T>> = Vec::new();
392    let mut bucket_by_value: HashMap<&'a T, usize> = HashMap::new();
393
394    for i in start..end {
395        let value = &sequence[i];
396        if let Some(&bucket_index) = bucket_by_value.get(value) {
397            buckets[bucket_index].positions.push(i);
398        } else {
399            let bucket_index = buckets.len();
400            buckets.push(OccurrenceBucket {
401                value,
402                positions: vec![i],
403            });
404            bucket_by_value.insert(value, bucket_index);
405        }
406    }
407
408    let _ = equalizer;
409    (buckets, bucket_by_value)
410}
411
412fn looks_like_high_entropy<T: Eq + Hash>(sequence: &[T], start: usize, end: usize) -> bool {
413    let sample_end = (start + 64).min(end);
414    let mut sample = HashSet::with_capacity(sample_end - start);
415    for value in &sequence[start..sample_end] {
416        if !sample.insert(value) {
417            return false;
418        }
419    }
420    true
421}
422
423fn looks_like_low_entropy<T: Eq + Hash>(sequence: &[T], start: usize, end: usize) -> bool {
424    let sample_end = (start + 64).min(end);
425    let mut sample = HashSet::with_capacity(sample_end - start);
426    for value in &sequence[start..sample_end] {
427        sample.insert(value);
428    }
429    sample.len() <= 32
430}
431
432fn find_best_anchor<T, F>(
433    source: &[T],
434    target: &[T],
435    src_start: usize,
436    src_end: usize,
437    tgt_start: usize,
438    tgt_end: usize,
439    equalizer: &F,
440    max_chain_length: usize,
441) -> Option<MatchAnchor>
442where
443    T: Eq + Hash,
444    F: Fn(&T, &T) -> bool + ?Sized,
445{
446    // One pass each — bucket list doubles as the distinct-value index.
447    let (mut source_buckets, _) = build_buckets(source, src_start, src_end, equalizer);
448    let (target_buckets, target_by_value) = build_buckets(target, tgt_start, tgt_end, equalizer);
449
450    // Sort source buckets by ascending count so unique elements (count == 1)
451    // are visited first. This lets us hit the early-exit path as early as possible.
452    source_buckets.sort_unstable_by_key(|b| b.positions.len());
453
454    let mut best_anchor: Option<MatchAnchor> = None;
455    let mut lowest_occurrence = max_chain_length + 1;
456    let mut best_len = 0usize;
457
458    for source_bucket in &source_buckets {
459        let target_bucket_index = target_by_value
460            .get(source_bucket.value)
461            .copied()
462            .or_else(|| {
463                target_buckets
464                    .iter()
465                    .position(|bucket| equalizer(source_bucket.value, bucket.value))
466            });
467        let Some(target_bucket_index) = target_bucket_index else {
468            continue;
469        };
470        let target_bucket = &target_buckets[target_bucket_index];
471
472        let occurrence_count = source_bucket.positions.len().min(target_bucket.positions.len());
473        // occurrence_count == 0 is impossible because build_buckets initialises
474        // every entry with count = 1. Skip values that exceed the chain-length
475        // threshold — they are too common to make reliable anchors.
476        if occurrence_count > max_chain_length {
477            continue;
478        }
479
480        for &src_idx in &source_bucket.positions {
481            for &tgt_idx in &target_bucket.positions {
482                if !equalizer(&source[src_idx], &target[tgt_idx]) {
483                    continue;
484                }
485                let mut len = 1usize;
486                while (src_idx + len) < src_end
487                    && (tgt_idx + len) < tgt_end
488                    && equalizer(&source[src_idx + len], &target[tgt_idx + len])
489                {
490                    len += 1;
491                }
492
493                if occurrence_count < lowest_occurrence
494                    || (occurrence_count == lowest_occurrence && len > best_len)
495                {
496                    lowest_occurrence = occurrence_count;
497                    best_len = len;
498                    best_anchor = Some(MatchAnchor { src_idx, tgt_idx, len });
499
500                    // Unique element found — this is the best possible anchor; stop early.
501                    if occurrence_count == 1 {
502                        return best_anchor;
503                    }
504                }
505            }
506        }
507    }
508
509    best_anchor
510}
511
512fn push_change(
513    script: &mut Vec<Change>,
514    delta_type: DeltaType,
515    src_start: usize,
516    src_end: usize,
517    tgt_start: usize,
518    tgt_end: usize,
519) {
520    if let Some(last) = script.last_mut() {
521        if last.delta_type == delta_type {
522            match delta_type {
523                DeltaType::Delete if last.end_original == src_start => {
524                    last.end_original = src_end;
525                    return;
526                }
527                DeltaType::Insert if last.end_revised == tgt_start => {
528                    last.end_revised = tgt_end;
529                    return;
530                }
531                _ => {}
532            }
533        }
534    }
535
536    script.push(Change {
537        delta_type,
538        start_original: src_start,
539        end_original: src_end,
540        start_revised: tgt_start,
541        end_revised: tgt_end,
542    });
543}
544
545fn normalize_replacements(script: &mut Vec<Change>) {
546    let mut normalized: Vec<Change> = Vec::with_capacity(script.len());
547
548    for change in script.drain(..) {
549        if let Some(previous) = normalized.last_mut() {
550            let replacement = match (previous.delta_type, change.delta_type) {
551                (DeltaType::Insert, DeltaType::Delete)
552                    if previous.start_original == change.start_original
553                        && previous.end_revised == change.start_revised => Some(Change {
554                        delta_type: DeltaType::Change,
555                        start_original: change.start_original,
556                        end_original: change.end_original,
557                        start_revised: previous.start_revised,
558                        end_revised: change.end_revised,
559                    }),
560                (DeltaType::Delete, DeltaType::Insert)
561                    if previous.end_original == change.start_original
562                        && previous.end_revised == change.start_revised => Some(Change {
563                        delta_type: DeltaType::Change,
564                        start_original: previous.start_original,
565                        end_original: previous.end_original,
566                        start_revised: previous.start_revised,
567                        end_revised: change.end_revised,
568                    }),
569                _ => None,
570            };
571
572            if let Some(replacement) = replacement {
573                *previous = replacement;
574                continue;
575            }
576        }
577
578        normalized.push(change);
579    }
580
581    *script = normalized;
582}
583
584/// Factory for creating `HistogramDiff` algorithm instances.
585#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
586pub struct HistogramDiffFactory {
587    pub max_chain_length: usize,
588}
589
590impl HistogramDiffFactory {
591    pub fn new() -> Self {
592        Self {
593            max_chain_length: DEFAULT_MAX_CHAIN_LENGTH,
594        }
595    }
596
597    pub fn with_max_chain_length(max_chain_length: usize) -> Self {
598        Self { max_chain_length }
599    }
600}
601
602impl<T: Eq + Hash + 'static> DiffAlgorithmFactory<T> for HistogramDiffFactory {
603    fn create(&self) -> Box<dyn DiffAlgorithm<T>>
604    where
605        T: Eq + Hash + 'static,
606    {
607        Box::new(HistogramDiff::new().with_max_chain_length(self.max_chain_length))
608    }
609
610    fn create_with_equalizer(
611        &self,
612        equalizer: Box<dyn Fn(&T, &T) -> bool + 'static>,
613    ) -> Box<dyn DiffAlgorithm<T>> {
614        Box::new(
615            HistogramDiff::new()
616                .with_max_chain_length(self.max_chain_length)
617                .with_equalizer(move |a: &T, b: &T| equalizer(a, b)),
618        )
619    }
620}