1#![forbid(unsafe_code)]
2mod algo;
5mod block;
6mod inline;
7mod noise;
8mod unified;
9
10use std::cell::OnceCell;
11use std::collections::{HashMap, VecDeque};
12
13use serde::{Deserialize, Serialize};
14
15pub use block::{Block, BlockKind, normalize, split_blocks};
16pub use inline::{InlineOp, InlineSegment, inline_diff};
17pub use noise::NoiseCategory;
18pub use unified::{DiffLine, Hunk, LineKind, UnifiedDiff, unified_diff};
19
20use algo::Edit;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub enum ChangeType {
25 Added,
26 Removed,
27 Modified,
28 Moved,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub enum Significance {
34 Noise,
35 Substantive,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase", default)]
40pub struct DiffOptions {
41 pub noise_categories: Vec<NoiseCategory>,
43 pub moved_is_noise: bool,
45 pub similarity_threshold: f64,
47}
48
49impl Default for DiffOptions {
50 fn default() -> Self {
51 Self {
52 noise_categories: vec![
53 NoiseCategory::Date,
54 NoiseCategory::RelativeTime,
55 NoiseCategory::Price,
56 NoiseCategory::Count,
57 NoiseCategory::Number,
58 ],
59 moved_is_noise: false,
60 similarity_threshold: 0.5,
61 }
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct BlockChange {
68 #[serde(rename = "type")]
69 pub change: ChangeType,
70 pub block_type: BlockKind,
71 pub significance: Significance,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub noise_categories: Vec<NoiseCategory>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub old_index: Option<usize>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub new_index: Option<usize>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub old_text: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub new_text: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub old_hash: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub new_hash: Option<String>,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub inline: Vec<InlineSegment>,
88}
89
90#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct DiffStats {
93 pub old_blocks: usize,
94 pub new_blocks: usize,
95 pub unchanged: usize,
96 pub added: usize,
97 pub removed: usize,
98 pub modified: usize,
99 pub moved: usize,
100 pub noise: usize,
101 pub substantive: usize,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase")]
106pub struct DiffReport {
107 pub changed: bool,
108 pub substantive: bool,
109 pub blocks: Vec<BlockChange>,
110 pub stats: DiffStats,
111}
112
113fn hex(h: &[u8; 32]) -> String {
114 const D: &[u8; 16] = b"0123456789abcdef";
115 let mut s = String::with_capacity(64);
116 for b in h {
117 s.push(D[(b >> 4) as usize] as char);
118 s.push(D[(b & 15) as usize] as char);
119 }
120 s
121}
122
123fn signature(norm: &str) -> Vec<u64> {
125 use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher};
126 let hasher = BuildHasherDefault::<DefaultHasher>::default();
127 let mut v: Vec<u64> = inline::tokenize(norm)
128 .into_iter()
129 .filter(|t| !t.trim().is_empty())
130 .map(|t| hasher.hash_one(t))
131 .collect();
132 v.sort_unstable();
133 v
134}
135
136fn dice(a: &[u64], b: &[u64]) -> f64 {
137 if a.len() + b.len() == 0 {
138 return 1.0;
139 }
140 let (mut i, mut j, mut common) = (0, 0, 0usize);
141 while i < a.len() && j < b.len() {
142 match a[i].cmp(&b[j]) {
143 std::cmp::Ordering::Less => i += 1,
144 std::cmp::Ordering::Greater => j += 1,
145 std::cmp::Ordering::Equal => {
146 common += 1;
147 i += 1;
148 j += 1;
149 }
150 }
151 }
152 2.0 * common as f64 / (a.len() + b.len()) as f64
153}
154
155const PAIR_WINDOW: usize = 8;
156
157fn intern<'a>(bs: &'a [Block], ids: &mut HashMap<(BlockKind, &'a str), u32>) -> Vec<u32> {
158 bs.iter()
159 .map(|b| {
160 let n = ids.len() as u32;
161 *ids.entry((b.kind, b.norm.as_str())).or_insert(n)
162 })
163 .collect()
164}
165
166pub fn diff(old: &str, new: &str) -> DiffReport {
167 diff_with(old, new, &DiffOptions::default())
168}
169
170pub fn diff_with(old: &str, new: &str, opts: &DiffOptions) -> DiffReport {
171 let ob = split_blocks(old);
172 let nb = split_blocks(new);
173 let mut ids = HashMap::new();
174 let oi = intern(&ob, &mut ids);
175 let ni = intern(&nb, &mut ids);
176 let edits = algo::diff(&oi, &ni);
177
178 let mut stats = DiffStats {
179 old_blocks: ob.len(),
180 new_blocks: nb.len(),
181 ..Default::default()
182 };
183 let mut events: Vec<(usize, BlockChange)> = Vec::new();
184
185 let mut consumed = vec![false; edits.len()];
187 let mut deleted: HashMap<u32, VecDeque<(usize, usize)>> = HashMap::new();
188 for (p, e) in edits.iter().enumerate() {
189 if let Edit::Delete(i) = *e {
190 deleted.entry(oi[i]).or_default().push_back((p, i));
191 }
192 }
193 for (p, e) in edits.iter().enumerate() {
194 if let Edit::Insert(j) = *e
195 && let Some((dp, i)) = deleted.get_mut(&ni[j]).and_then(VecDeque::pop_front)
196 {
197 consumed[dp] = true;
198 consumed[p] = true;
199 events.push((
200 p,
201 BlockChange {
202 change: ChangeType::Moved,
203 block_type: nb[j].kind,
204 significance: if opts.moved_is_noise {
205 Significance::Noise
206 } else {
207 Significance::Substantive
208 },
209 noise_categories: vec![],
210 old_index: Some(i),
211 new_index: Some(j),
212 old_text: Some(ob[i].text.clone()),
213 new_text: Some(nb[j].text.clone()),
214 old_hash: Some(hex(&ob[i].hash())),
215 new_hash: Some(hex(&nb[j].hash())),
216 inline: vec![],
217 },
218 ));
219 }
220 }
221
222 let added = |p: usize, j: usize| {
223 (
224 p,
225 BlockChange {
226 change: ChangeType::Added,
227 block_type: nb[j].kind,
228 significance: Significance::Substantive,
229 noise_categories: vec![],
230 old_index: None,
231 new_index: Some(j),
232 old_text: None,
233 new_text: Some(nb[j].text.clone()),
234 old_hash: None,
235 new_hash: None,
236 inline: vec![],
237 },
238 )
239 };
240 let removed = |p: usize, i: usize| {
241 (
242 p,
243 BlockChange {
244 change: ChangeType::Removed,
245 block_type: ob[i].kind,
246 significance: Significance::Substantive,
247 noise_categories: vec![],
248 old_index: Some(i),
249 new_index: None,
250 old_text: Some(ob[i].text.clone()),
251 new_text: None,
252 old_hash: None,
253 new_hash: None,
254 inline: vec![],
255 },
256 )
257 };
258
259 let osig: Vec<OnceCell<Vec<u64>>> = (0..ob.len()).map(|_| OnceCell::new()).collect();
260 let nsig: Vec<OnceCell<Vec<u64>>> = (0..nb.len()).map(|_| OnceCell::new()).collect();
261 let mut dels: Vec<(usize, usize)> = Vec::new();
262 let mut inss: Vec<(usize, usize)> = Vec::new();
263 let flush = |dels: &mut Vec<(usize, usize)>,
264 inss: &mut Vec<(usize, usize)>,
265 events: &mut Vec<(usize, BlockChange)>| {
266 let mut j = 0;
267 for &(dp, i) in dels.iter() {
268 let hi = (j + PAIR_WINDOW).min(inss.len());
269 let mut best: Option<(usize, f64)> = None;
271 for k in j..hi {
272 if ob[i].kind != nb[inss[k].1].kind {
273 continue;
274 }
275 let so = osig[i].get_or_init(|| signature(&ob[i].norm));
276 let sn = nsig[inss[k].1].get_or_init(|| signature(&nb[inss[k].1].norm));
277 let sim = dice(so, sn);
278 if sim >= opts.similarity_threshold && best.is_none_or(|(_, s)| sim > s) {
279 best = Some((k, sim));
280 if sim >= 0.8 {
281 break;
282 }
283 }
284 }
285 match best {
286 Some((k, _)) => {
287 for &(ip, jj) in &inss[j..k] {
288 events.push(added(ip, jj));
289 }
290 let (ip, jj) = inss[k];
291 j = k + 1;
292 let (o, n) = (&ob[i], &nb[jj]);
293 let (significance, noise_categories) =
294 match noise::classify(&o.norm, &n.norm, &opts.noise_categories) {
295 Some(c) => (Significance::Noise, c),
296 None => (Significance::Substantive, vec![]),
297 };
298 events.push((
299 dp.min(ip),
300 BlockChange {
301 change: ChangeType::Modified,
302 block_type: n.kind,
303 significance,
304 noise_categories,
305 old_index: Some(i),
306 new_index: Some(jj),
307 old_text: Some(o.text.clone()),
308 new_text: Some(n.text.clone()),
309 old_hash: Some(hex(&o.hash())),
310 new_hash: Some(hex(&n.hash())),
311 inline: inline_diff(&o.norm, &n.norm),
312 },
313 ));
314 }
315 None => events.push(removed(dp, i)),
316 }
317 }
318 for &(ip, jj) in &inss[j..] {
319 events.push(added(ip, jj));
320 }
321 dels.clear();
322 inss.clear();
323 };
324
325 for (p, e) in edits.iter().enumerate() {
326 match *e {
327 Edit::Equal(..) => {
328 stats.unchanged += 1;
329 flush(&mut dels, &mut inss, &mut events);
330 }
331 _ if consumed[p] => {}
332 Edit::Delete(i) => dels.push((p, i)),
333 Edit::Insert(j) => inss.push((p, j)),
334 }
335 }
336 flush(&mut dels, &mut inss, &mut events);
337
338 events.sort_by_key(|e| e.0);
339 let blocks: Vec<BlockChange> = events.into_iter().map(|e| e.1).collect();
340 for b in &blocks {
341 match b.change {
342 ChangeType::Added => stats.added += 1,
343 ChangeType::Removed => stats.removed += 1,
344 ChangeType::Modified => stats.modified += 1,
345 ChangeType::Moved => stats.moved += 1,
346 }
347 match b.significance {
348 Significance::Noise => stats.noise += 1,
349 Significance::Substantive => stats.substantive += 1,
350 }
351 }
352 DiffReport {
353 changed: !blocks.is_empty(),
354 substantive: stats.substantive > 0,
355 blocks,
356 stats,
357 }
358}