Skip to main content

cpd_core/
hash.rs

1// hash.rs
2
3use xxhash_rust::xxh3::xxh3_64;
4
5/// Fibonacci hashing constant for good bit distribution.
6pub const HASH_BASE: u64 = 0x9e3779b97f4a7c15;
7
8/// Compute a deterministic hash for a single token given its kind byte and value string.
9/// Uses xxh3_64 XORed with kind cast to u64.
10pub fn token_hash(kind: u8, value: &str) -> u64 {
11    xxh3_64(value.as_bytes()) ^ (kind as u64)
12}
13
14/// Hash a token with optional case folding.
15///
16/// `ignore_case = false` is equivalent to `token_hash(kind.discriminant(), value)`.
17#[inline]
18pub fn hash_token(kind_discriminant: u8, value: &str, ignore_case: bool) -> u64 {
19    if ignore_case {
20        xxh3_64(value.to_lowercase().as_bytes()) ^ (kind_discriminant as u64)
21    } else {
22        xxh3_64(value.as_bytes()) ^ (kind_discriminant as u64)
23    }
24}
25
26/// Hash a pair of duplicated snippets independent of fragment order, so the
27/// same clone pair yields the same hash regardless of which copy the detector
28/// labels as fragment A (file discovery order varies between runs).
29///
30/// Line endings are normalized (CR stripped) before hashing: fingerprints must
31/// be identical for CRLF and LF checkouts of the same content, or committed
32/// baselines break across platforms and --baseline-from-ref reports every
33/// clone as new on Windows, where git's autocrlf gives the temporary base-ref
34/// worktree CRLF content while the scanned tree has LF (or vice versa).
35pub fn snippet_pair_hash(a: &str, b: &str) -> u64 {
36    let a = strip_cr(a);
37    let b = strip_cr(b);
38    let (first, second) = if a <= b { (&a, &b) } else { (&b, &a) };
39    let mut buf = Vec::with_capacity(first.len() + second.len() + 1);
40    buf.extend_from_slice(first.as_bytes());
41    buf.push(0); // separator: keeps ("ab","c") distinct from ("a","bc")
42    buf.extend_from_slice(second.as_bytes());
43    xxh3_64(&buf)
44}
45
46fn strip_cr(s: &str) -> std::borrow::Cow<'_, str> {
47    if s.contains('\r') {
48        std::borrow::Cow::Owned(s.replace('\r', ""))
49    } else {
50        std::borrow::Cow::Borrowed(s)
51    }
52}
53
54/// Compute the initial polynomial hash of a window of token hashes.
55/// hash = h[0]*BASE^(n-1) + h[1]*BASE^(n-2) + ... + h[n-1]*BASE^0
56/// Uses wrapping arithmetic throughout.
57pub fn hash_window(hashes: &[u64]) -> u64 {
58    hashes
59        .iter()
60        .fold(0u64, |acc, &h| acc.wrapping_mul(HASH_BASE).wrapping_add(h))
61}
62
63/// Roll the hash one position: remove `outgoing` (the token leaving the window),
64/// add `incoming` (the token entering the window).
65///
66/// `window_power` must be precomputed by the caller as `base_pow(window_size - 1)`
67/// **once per format group** before the sliding-window loop — not on every call.
68/// This eliminates an O(window_size) loop from the hot path.
69///
70/// If per-language min_tokens is introduced in future, recompute `window_power`
71/// per `detect_in_group` invocation using that group's min_tokens value.
72///
73/// new_hash = (current - outgoing * window_power) * BASE + incoming
74/// All arithmetic is wrapping.
75pub fn roll(current: u64, outgoing: u64, incoming: u64, window_power: u64) -> u64 {
76    current
77        .wrapping_sub(outgoing.wrapping_mul(window_power))
78        .wrapping_mul(HASH_BASE)
79        .wrapping_add(incoming)
80}
81
82/// Compute HASH_BASE^n using wrapping multiplication.
83/// Call once per format group to obtain the `window_power` argument for `roll()`.
84pub fn base_pow(n: usize) -> u64 {
85    let mut result = 1u64;
86    for _ in 0..n {
87        result = result.wrapping_mul(HASH_BASE);
88    }
89    result
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn token_hash_is_deterministic() {
98        let h1 = token_hash(1, "function");
99        let h2 = token_hash(1, "function");
100        assert_eq!(h1, h2);
101    }
102
103    #[test]
104    fn token_hash_differs_by_kind() {
105        let h1 = token_hash(1, "x");
106        let h2 = token_hash(2, "x");
107        assert_ne!(h1, h2);
108    }
109
110    #[test]
111    fn snippet_pair_hash_is_order_insensitive() {
112        let h1 = snippet_pair_hash("fn a() {}", "fn b() {}");
113        let h2 = snippet_pair_hash("fn b() {}", "fn a() {}");
114        assert_eq!(h1, h2, "swapping fragments must not change the hash");
115    }
116
117    #[test]
118    fn snippet_pair_hash_is_line_ending_agnostic() {
119        let lf = snippet_pair_hash("fn a() {\n}\n", "fn b() {\n}\n");
120        let crlf = snippet_pair_hash("fn a() {\r\n}\r\n", "fn b() {\r\n}\r\n");
121        let mixed = snippet_pair_hash("fn a() {\r\n}\r\n", "fn b() {\n}\n");
122        assert_eq!(lf, crlf, "CRLF and LF content must fingerprint identically");
123        assert_eq!(lf, mixed, "mixed line endings must fingerprint identically");
124    }
125
126    #[test]
127    fn snippet_pair_hash_separator_prevents_boundary_collisions() {
128        let h1 = snippet_pair_hash("ab", "c");
129        let h2 = snippet_pair_hash("a", "bc");
130        assert_ne!(h1, h2, "concatenation boundary must be unambiguous");
131    }
132
133    #[test]
134    fn hash_window_single_element() {
135        let h = token_hash(0, "a");
136        // Window of 1: fold with initial 0 → 0 * BASE + h = h
137        assert_eq!(hash_window(&[h]), h);
138    }
139
140    #[test]
141    fn roll_matches_naive_recompute() {
142        let a = token_hash(0, "a");
143        let b = token_hash(0, "b");
144        let c = token_hash(0, "c");
145        let d = token_hash(0, "d");
146
147        let initial = hash_window(&[a, b, c]);
148        let wp = base_pow(3 - 1);
149        let rolled = roll(initial, a, d, wp);
150        let naive = hash_window(&[b, c, d]);
151        assert_eq!(rolled, naive, "rolled hash must match naive recomputation");
152    }
153
154    #[test]
155    fn roll_window_of_one() {
156        let a = token_hash(0, "hello");
157        let b = token_hash(0, "world");
158        let initial = hash_window(&[a]);
159        let wp = base_pow(1 - 1); // BASE^0 = 1
160        let rolled = roll(initial, a, b, wp);
161        let naive = hash_window(&[b]);
162        assert_eq!(rolled, naive);
163    }
164
165    #[test]
166    fn hash_window_empty_is_zero() {
167        assert_eq!(hash_window(&[]), 0u64);
168    }
169
170    #[test]
171    fn hash_token_case_insensitive_matches_different_case() {
172        let h1 = hash_token(1, "Function", true);
173        let h2 = hash_token(1, "function", true);
174        assert_eq!(h1, h2, "ignore_case=true must fold case before hashing");
175    }
176
177    #[test]
178    fn hash_token_case_sensitive_differs() {
179        let h1 = hash_token(1, "Function", false);
180        let h2 = hash_token(1, "function", false);
181        assert_ne!(h1, h2, "ignore_case=false must not fold case");
182    }
183
184    #[test]
185    fn hash_token_no_ignore_case_matches_token_hash() {
186        let h1 = hash_token(2, "hello", false);
187        let h2 = token_hash(2, "hello");
188        assert_eq!(
189            h1, h2,
190            "hash_token(ignore_case=false) must match token_hash"
191        );
192    }
193}