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