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    let src_distinct = distinct_count(source, src_start, src_end, equalizer);
242    let tgt_distinct = distinct_count(target, tgt_start, tgt_end, equalizer);
243
244    if (src_distinct > 32 || tgt_distinct > 32)
245        && (has_high_frequency_value(source, src_start, src_end, equalizer, max_chain_length)
246            || has_high_frequency_value(target, tgt_start, tgt_end, equalizer, max_chain_length))
247    {
248        let fallback_algo = MyersDiffWithLinearSpace::new();
249        let sub_source = &source[src_start..src_end];
250        let sub_target = &target[tgt_start..tgt_end];
251
252        let sub_changes = fallback_algo.diff_with_listener(
253            sub_source,
254            sub_target,
255            &mut crate::algorithm::diff_algorithm_listener::NoOpListener,
256        );
257
258        for c in sub_changes {
259            push_change(
260                script,
261                c.delta_type,
262                src_start + c.start_original,
263                src_start + c.end_original,
264                tgt_start + c.start_revised,
265                tgt_start + c.end_revised,
266            );
267        }
268        return;
269    }
270
271    // Try finding the lowest-occurrence anchor in the target range
272    if let Some(anchor) = find_best_anchor(
273        source,
274        target,
275        src_start,
276        src_end,
277        tgt_start,
278        tgt_end,
279        equalizer,
280        max_chain_length,
281    ) {
282        // Divide and conquer: Left subregion
283        histogram_rec(
284            source,
285            target,
286            src_start,
287            anchor.src_idx,
288            tgt_start,
289            anchor.tgt_idx,
290            equalizer,
291            max_chain_length,
292            script,
293            listener.as_deref_mut(),
294            total_steps,
295        );
296
297        // Right subregion (anchor region is skipped as it is equal)
298        histogram_rec(
299            source,
300            target,
301            anchor.src_idx + anchor.len,
302            src_end,
303            anchor.tgt_idx + anchor.len,
304            tgt_end,
305            equalizer,
306            max_chain_length,
307            script,
308            listener,
309            total_steps,
310        );
311    } else {
312        // Fallback to linear Myers on this subregion
313        let fallback_algo = MyersDiffWithLinearSpace::new();
314        let sub_source = &source[src_start..src_end];
315        let sub_target = &target[tgt_start..tgt_end];
316
317        let sub_changes = fallback_algo.diff_with_listener(
318            sub_source,
319            sub_target,
320            &mut crate::algorithm::diff_algorithm_listener::NoOpListener,
321        );
322
323        for c in sub_changes {
324            push_change(
325                script,
326                c.delta_type,
327                src_start + c.start_original,
328                src_start + c.end_original,
329                tgt_start + c.start_revised,
330                tgt_start + c.end_revised,
331            );
332        }
333    }
334}
335
336struct OccurrenceBucket<'a, T> {
337    value: &'a T,
338    count: usize,
339    first_index: usize,
340}
341
342#[allow(clippy::too_many_arguments)]
343fn should_fallback_to_myers<T, F>(
344    source: &[T],
345    src_start: usize,
346    src_end: usize,
347    target: &[T],
348    tgt_start: usize,
349    tgt_end: usize,
350    equalizer: &F,
351) -> bool
352where
353    T: PartialEq,
354    F: Fn(&T, &T) -> bool + ?Sized,
355{
356    let src_len = src_end - src_start;
357    let tgt_len = tgt_end - tgt_start;
358    if src_len == 0 || tgt_len == 0 {
359        return false;
360    }
361
362    let distinct_source = distinct_count(source, src_start, src_end, equalizer);
363    let distinct_target = distinct_count(target, tgt_start, tgt_end, equalizer);
364
365    (distinct_source > 32 || distinct_target > 32)
366        && (src_len > 0 && tgt_len > 0)
367        && (distinct_source >= 64 || distinct_target >= 64)
368}
369
370fn distinct_count<T, F>(sequence: &[T], start: usize, end: usize, equalizer: &F) -> usize
371where
372    T: PartialEq,
373    F: Fn(&T, &T) -> bool + ?Sized,
374{
375    let mut seen = Vec::new();
376    for i in start..end {
377        let value = &sequence[i];
378        let mut already_seen = false;
379        for prev in &seen {
380            if equalizer(value, *prev) {
381                already_seen = true;
382                break;
383            }
384        }
385        if !already_seen {
386            seen.push(value);
387        }
388    }
389    seen.len()
390}
391
392fn has_high_frequency_value<T, F>(
393    sequence: &[T],
394    start: usize,
395    end: usize,
396    equalizer: &F,
397    max_chain_length: usize,
398) -> bool
399where
400    T: PartialEq,
401    F: Fn(&T, &T) -> bool + ?Sized,
402{
403    let distinct = distinct_count(sequence, start, end, equalizer);
404    if distinct <= 32 {
405        return false;
406    }
407
408    let mut buckets: Vec<OccurrenceBucket<'_, T>> = Vec::new();
409
410    for i in start..end {
411        let value = &sequence[i];
412        let mut found = false;
413
414        for bucket in &mut buckets {
415            if equalizer(value, bucket.value) {
416                bucket.count += 1;
417                found = true;
418                break;
419            }
420        }
421
422        if !found {
423            buckets.push(OccurrenceBucket {
424                value,
425                count: 1,
426                first_index: i,
427            });
428        }
429    }
430
431    buckets.iter().any(|bucket| bucket.count > max_chain_length)
432}
433
434fn find_best_anchor<T, F>(
435    source: &[T],
436    target: &[T],
437    src_start: usize,
438    src_end: usize,
439    tgt_start: usize,
440    tgt_end: usize,
441    equalizer: &F,
442    max_chain_length: usize,
443) -> Option<MatchAnchor>
444where
445    T: PartialEq,
446    F: Fn(&T, &T) -> bool + ?Sized,
447{
448    let mut source_buckets: Vec<OccurrenceBucket<'_, T>> = Vec::new();
449    for i in src_start..src_end {
450        let value = &source[i];
451        let mut bucket_idx = None;
452        for (idx, bucket) in source_buckets.iter_mut().enumerate() {
453            if equalizer(value, bucket.value) {
454                bucket.count += 1;
455                bucket_idx = Some(idx);
456                break;
457            }
458        }
459
460        if bucket_idx.is_none() {
461            source_buckets.push(OccurrenceBucket {
462                value,
463                count: 1,
464                first_index: i,
465            });
466        }
467    }
468
469    let mut target_buckets: Vec<OccurrenceBucket<'_, T>> = Vec::new();
470    for j in tgt_start..tgt_end {
471        let value = &target[j];
472        let mut bucket_idx = None;
473        for (idx, bucket) in target_buckets.iter_mut().enumerate() {
474            if equalizer(value, bucket.value) {
475                bucket.count += 1;
476                bucket_idx = Some(idx);
477                break;
478            }
479        }
480
481        if bucket_idx.is_none() {
482            target_buckets.push(OccurrenceBucket {
483                value,
484                count: 1,
485                first_index: j,
486            });
487        }
488    }
489
490    let mut best_anchor: Option<MatchAnchor> = None;
491    let mut lowest_occurrence = max_chain_length + 1;
492    let mut best_len = 0usize;
493
494    for source_bucket in &source_buckets {
495        let target_bucket = target_buckets
496            .iter()
497            .find(|bucket| equalizer(source_bucket.value, bucket.value));
498
499        let Some(target_bucket) = target_bucket else {
500            continue;
501        };
502
503        let occurrence_count = source_bucket.count.min(target_bucket.count);
504        if occurrence_count == 0 || occurrence_count > max_chain_length {
505            // Large repeated alphabets are still valid anchors in a histogram split;
506            // JGit prefers the longest anchor run among the lowest count values instead
507            // of treating all repeated values as a no-op. We therefore keep these as
508            // candidates during anchor selection and only reject truly empty matches.
509            if occurrence_count == 0 {
510                continue;
511            }
512        }
513
514        if occurrence_count == 0 {
515            continue;
516        }
517
518        let src_idx = source_bucket.first_index;
519        let tgt_idx = target_bucket.first_index;
520        let mut len = 1usize;
521        while (src_idx + len) < src_end
522            && (tgt_idx + len) < tgt_end
523            && equalizer(&source[src_idx + len], &target[tgt_idx + len])
524        {
525            len += 1;
526        }
527
528        if occurrence_count < lowest_occurrence
529            || (occurrence_count == lowest_occurrence && len > best_len)
530        {
531            lowest_occurrence = occurrence_count;
532            best_len = len;
533            best_anchor = Some(MatchAnchor {
534                src_idx,
535                tgt_idx,
536                len,
537            });
538
539            if occurrence_count == 1 {
540                return best_anchor;
541            }
542        }
543    }
544
545    best_anchor
546}
547
548fn push_change(
549    script: &mut Vec<Change>,
550    delta_type: DeltaType,
551    src_start: usize,
552    src_end: usize,
553    tgt_start: usize,
554    tgt_end: usize,
555) {
556    if let Some(last) = script.last_mut() {
557        if last.delta_type == delta_type {
558            match delta_type {
559                DeltaType::Delete if last.end_original == src_start => {
560                    last.end_original = src_end;
561                    return;
562                }
563                DeltaType::Insert if last.end_revised == tgt_start => {
564                    last.end_revised = tgt_end;
565                    return;
566                }
567                _ => {}
568            }
569        }
570    }
571
572    script.push(Change {
573        delta_type,
574        start_original: src_start,
575        end_original: src_end,
576        start_revised: tgt_start,
577        end_revised: tgt_end,
578    });
579}
580
581fn normalize_replacements(script: &mut Vec<Change>) {
582    let mut normalized: Vec<Change> = Vec::with_capacity(script.len());
583
584    for change in script.drain(..) {
585        if let Some(previous) = normalized.last_mut() {
586            let replacement = match (previous.delta_type, change.delta_type) {
587                (DeltaType::Insert, DeltaType::Delete)
588                    if previous.start_original == change.start_original
589                        && previous.end_revised == change.start_revised => Some(Change {
590                        delta_type: DeltaType::Change,
591                        start_original: change.start_original,
592                        end_original: change.end_original,
593                        start_revised: previous.start_revised,
594                        end_revised: change.end_revised,
595                    }),
596                (DeltaType::Delete, DeltaType::Insert)
597                    if previous.end_original == change.start_original
598                        && previous.end_revised == change.start_revised => Some(Change {
599                        delta_type: DeltaType::Change,
600                        start_original: previous.start_original,
601                        end_original: previous.end_original,
602                        start_revised: previous.start_revised,
603                        end_revised: change.end_revised,
604                    }),
605                _ => None,
606            };
607
608            if let Some(replacement) = replacement {
609                *previous = replacement;
610                continue;
611            }
612        }
613
614        normalized.push(change);
615    }
616
617    *script = normalized;
618}
619
620/// Factory for creating `HistogramDiff` algorithm instances.
621#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
622pub struct HistogramDiffFactory {
623    pub max_chain_length: usize,
624}
625
626impl HistogramDiffFactory {
627    pub fn new() -> Self {
628        Self {
629            max_chain_length: DEFAULT_MAX_CHAIN_LENGTH,
630        }
631    }
632
633    pub fn with_max_chain_length(max_chain_length: usize) -> Self {
634        Self { max_chain_length }
635    }
636}
637
638impl<T: PartialEq + 'static> DiffAlgorithmFactory<T> for HistogramDiffFactory {
639    fn create(&self) -> Box<dyn DiffAlgorithm<T>>
640    where
641        T: PartialEq + 'static,
642    {
643        Box::new(HistogramDiff::new().with_max_chain_length(self.max_chain_length))
644    }
645
646    fn create_with_equalizer(
647        &self,
648        equalizer: Box<dyn Fn(&T, &T) -> bool + 'static>,
649    ) -> Box<dyn DiffAlgorithm<T>> {
650        Box::new(
651            HistogramDiff::new()
652                .with_max_chain_length(self.max_chain_length)
653                .with_equalizer(move |a: &T, b: &T| equalizer(a, b)),
654        )
655    }
656}