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    if let Some(l) = listener {
131        l.diff_end();
132    }
133
134    script
135}
136
137#[derive(Clone, Copy)]
138struct MatchAnchor {
139    src_idx: usize,
140    tgt_idx: usize,
141    len: usize,
142}
143
144#[allow(clippy::too_many_arguments)]
145fn histogram_rec<T, F, L>(
146    source: &[T],
147    target: &[T],
148    mut src_start: usize,
149    mut src_end: usize,
150    mut tgt_start: usize,
151    mut tgt_end: usize,
152    equalizer: &F,
153    max_chain_length: usize,
154    script: &mut Vec<Change>,
155    mut listener: Option<&mut L>,
156    total_steps: usize,
157) where
158    T: PartialEq,
159    F: Fn(&T, &T) -> bool + ?Sized,
160    L: DiffAlgorithmListener + ?Sized,
161{
162    // Fast path: trim matching prefix
163    while src_start < src_end
164        && tgt_start < tgt_end
165        && equalizer(&source[src_start], &target[tgt_start])
166    {
167        src_start += 1;
168        tgt_start += 1;
169    }
170
171    // Fast path: trim matching suffix
172    while src_end > src_start
173        && tgt_end > tgt_start
174        && equalizer(&source[src_end - 1], &target[tgt_end - 1])
175    {
176        src_end -= 1;
177        tgt_end -= 1;
178    }
179
180    let src_len = src_end - src_start;
181    let tgt_len = tgt_end - tgt_start;
182
183    if src_len == 0 && tgt_len == 0 {
184        return;
185    }
186
187    if let Some(l) = listener.as_deref_mut() {
188        l.diff_step(src_start + tgt_start, total_steps);
189    }
190
191    // Base cases: purely insertion or deletion
192    if src_len == 0 {
193        push_change(
194            script,
195            DeltaType::Insert,
196            src_start,
197            src_start,
198            tgt_start,
199            tgt_end,
200        );
201        return;
202    }
203    if tgt_len == 0 {
204        push_change(
205            script,
206            DeltaType::Delete,
207            src_start,
208            src_end,
209            tgt_start,
210            tgt_start,
211        );
212        return;
213    }
214
215    // Try finding the lowest-occurrence anchor in the target range
216    if let Some(anchor) = find_best_anchor(
217        source,
218        target,
219        src_start,
220        src_end,
221        tgt_start,
222        tgt_end,
223        equalizer,
224        max_chain_length,
225    ) {
226        // Divide and conquer: Left subregion
227        histogram_rec(
228            source,
229            target,
230            src_start,
231            anchor.src_idx,
232            tgt_start,
233            anchor.tgt_idx,
234            equalizer,
235            max_chain_length,
236            script,
237            listener.as_deref_mut(),
238            total_steps,
239        );
240
241        // Right subregion (anchor region is skipped as it is equal)
242        histogram_rec(
243            source,
244            target,
245            anchor.src_idx + anchor.len,
246            src_end,
247            anchor.tgt_idx + anchor.len,
248            tgt_end,
249            equalizer,
250            max_chain_length,
251            script,
252            listener,
253            total_steps,
254        );
255    } else {
256        // Fallback to linear Myers on this subregion
257        let fallback_algo = MyersDiffWithLinearSpace::new();
258        let sub_source = &source[src_start..src_end];
259        let sub_target = &target[tgt_start..tgt_end];
260
261        let sub_changes = fallback_algo.diff_with_listener(
262            sub_source,
263            sub_target,
264            &mut crate::algorithm::diff_algorithm_listener::NoOpListener,
265        );
266
267        for c in sub_changes {
268            push_change(
269                script,
270                c.delta_type,
271                src_start + c.start_original,
272                src_start + c.end_original,
273                tgt_start + c.start_revised,
274                tgt_start + c.end_revised,
275            );
276        }
277    }
278}
279
280#[allow(clippy::too_many_arguments)]
281fn find_best_anchor<T, F>(
282    source: &[T],
283    target: &[T],
284    src_start: usize,
285    src_end: usize,
286    tgt_start: usize,
287    tgt_end: usize,
288    equalizer: &F,
289    max_chain_length: usize,
290) -> Option<MatchAnchor>
291where
292    T: PartialEq,
293    F: Fn(&T, &T) -> bool + ?Sized,
294{
295    let mut best_anchor: Option<MatchAnchor> = None;
296    let mut lowest_occurrence = max_chain_length + 1;
297
298    for i in src_start..src_end {
299        let mut count = 0;
300        let mut first_match_j = 0;
301
302        for j in tgt_start..tgt_end {
303            if equalizer(&source[i], &target[j]) {
304                count += 1;
305                if count == 1 {
306                    first_match_j = j;
307                }
308                if count >= lowest_occurrence {
309                    break;
310                }
311            }
312        }
313
314        if count > 0 && count < lowest_occurrence {
315            lowest_occurrence = count;
316
317            // Expand match length forward as far as possible
318            let mut len = 1;
319            while (i + len) < src_end
320                && (first_match_j + len) < tgt_end
321                && equalizer(&source[i + len], &target[first_match_j + len])
322            {
323                len += 1;
324            }
325
326            best_anchor = Some(MatchAnchor {
327                src_idx: i,
328                tgt_idx: first_match_j,
329                len,
330            });
331
332            // If unique match found (occurrence == 1), this is the ideal anchor
333            if count == 1 {
334                break;
335            }
336        }
337    }
338
339    best_anchor
340}
341
342fn push_change(
343    script: &mut Vec<Change>,
344    delta_type: DeltaType,
345    src_start: usize,
346    src_end: usize,
347    tgt_start: usize,
348    tgt_end: usize,
349) {
350    if let Some(last) = script.last_mut() {
351        if last.delta_type == delta_type {
352            match delta_type {
353                DeltaType::Delete if last.end_original == src_start => {
354                    last.end_original = src_end;
355                    return;
356                }
357                DeltaType::Insert if last.end_revised == tgt_start => {
358                    last.end_revised = tgt_end;
359                    return;
360                }
361                _ => {}
362            }
363        }
364    }
365
366    script.push(Change {
367        delta_type,
368        start_original: src_start,
369        end_original: src_end,
370        start_revised: tgt_start,
371        end_revised: tgt_end,
372    });
373}
374
375/// Factory for creating `HistogramDiff` algorithm instances.
376#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
377pub struct HistogramDiffFactory {
378    pub max_chain_length: usize,
379}
380
381impl HistogramDiffFactory {
382    pub fn new() -> Self {
383        Self {
384            max_chain_length: DEFAULT_MAX_CHAIN_LENGTH,
385        }
386    }
387
388    pub fn with_max_chain_length(max_chain_length: usize) -> Self {
389        Self { max_chain_length }
390    }
391}
392
393impl<T: PartialEq + 'static> DiffAlgorithmFactory<T> for HistogramDiffFactory {
394    fn create(&self) -> Box<dyn DiffAlgorithm<T>>
395    where
396        T: PartialEq + 'static,
397    {
398        Box::new(HistogramDiff::new().with_max_chain_length(self.max_chain_length))
399    }
400
401    fn create_with_equalizer(
402        &self,
403        equalizer: Box<dyn Fn(&T, &T) -> bool + 'static>,
404    ) -> Box<dyn DiffAlgorithm<T>> {
405        Box::new(
406            HistogramDiff::new()
407                .with_max_chain_length(self.max_chain_length)
408                .with_equalizer(move |a: &T, b: &T| equalizer(a, b)),
409        )
410    }
411}