1use std::collections::HashMap;
8
9use imara_diff::{Algorithm, Diff, InternedInput};
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
12use crate::model::{DiffLine, Hunk, HunkId, LineKind, disambiguated_hunk_id};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum DiffAlgorithm {
16 #[default]
17 Myers,
18 Minimal,
19 Patience,
20 Histogram,
21 Structural,
25}
26
27impl DiffAlgorithm {
28 pub const ALL: [Self; 5] = [
29 Self::Myers,
30 Self::Minimal,
31 Self::Patience,
32 Self::Histogram,
33 Self::Structural,
34 ];
35
36 pub const fn as_str(self) -> &'static str {
39 match self {
40 Self::Myers => "myers",
41 Self::Minimal => "minimal",
42 Self::Patience => "patience",
43 Self::Histogram => "histogram",
44 Self::Structural => "structural",
45 }
46 }
47
48 pub fn parse(value: &str) -> Option<Self> {
49 Self::ALL.into_iter().find(|a| a.as_str() == value)
50 }
51
52 pub const fn is_imara(self) -> bool {
55 matches!(self, Self::Histogram | Self::Structural)
56 }
57}
58
59impl std::fmt::Display for DiffAlgorithm {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.write_str(self.as_str())
62 }
63}
64
65impl Serialize for DiffAlgorithm {
66 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
67 serializer.serialize_str(self.as_str())
68 }
69}
70
71impl<'de> Deserialize<'de> for DiffAlgorithm {
72 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
73 let name = String::deserialize(deserializer)?;
74 Self::parse(&name).ok_or_else(|| {
75 let names = Self::ALL.map(Self::as_str).join(", ");
76 serde::de::Error::custom(format!(
77 "unknown diff algorithm `{name}`, expected one of {names}"
78 ))
79 })
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct DiffSettings {
89 pub context_lines: u32,
90 pub algorithm: DiffAlgorithm,
91 pub indent_heuristic: bool,
92}
93
94impl Default for DiffSettings {
95 fn default() -> Self {
96 Self {
97 context_lines: crate::git::DEFAULT_CONTEXT_LINES,
98 algorithm: DiffAlgorithm::default(),
99 indent_heuristic: crate::git::DEFAULT_INDENT_HEURISTIC,
100 }
101 }
102}
103
104impl DiffSettings {
105 pub fn with_context(context_lines: u32) -> Self {
107 Self {
108 context_lines,
109 ..Self::default()
110 }
111 }
112}
113
114pub fn histogram_hunks(
122 old: &str,
123 new: &str,
124 file_path: &str,
125 context: u32,
126 indent_heuristic: bool,
127) -> Vec<Hunk> {
128 let input = InternedInput::new(old, new);
129 let mut diff = Diff::compute(Algorithm::Histogram, &input);
130 if indent_heuristic {
131 diff.postprocess_lines(&input);
132 } else {
133 diff.postprocess_no_heuristic(&input);
134 }
135
136 let before_len = input.before.len() as u32;
137 let raw: Vec<imara_diff::Hunk> = diff.hunks().collect();
138 if raw.is_empty() {
139 return Vec::new();
140 }
141
142 let two_context = context.saturating_mul(2);
143 let mut groups: Vec<Vec<imara_diff::Hunk>> = Vec::new();
144 let mut pos = 0u32;
145 for hunk in raw {
146 let starts_new = groups.is_empty() || hunk.before.start.saturating_sub(pos) > two_context;
147 if starts_new {
148 groups.push(Vec::new());
149 }
150 pos = hunk.before.end;
151 if let Some(group) = groups.last_mut() {
152 group.push(hunk);
153 }
154 }
155
156 let mut seen: HashMap<HunkId, usize> = HashMap::new();
157 let mut heading = FuncHeading::default();
158 groups
159 .into_iter()
160 .map(|group| {
161 build_hunk(
162 &group,
163 &input,
164 file_path,
165 context,
166 before_len,
167 &mut seen,
168 &mut heading,
169 )
170 })
171 .collect()
172}
173
174fn line_text(input: &InternedInput<&str>, token: imara_diff::Token) -> String {
175 input.interner[token]
176 .trim_end_matches(['\n', '\r'])
177 .to_owned()
178}
179
180#[derive(Default)]
186struct FuncHeading {
187 scanned: u32,
188 text: String,
189}
190
191impl FuncHeading {
192 const MAX_BYTES: usize = 80;
193
194 fn above(&mut self, input: &InternedInput<&str>, row: u32) -> String {
195 let found = (self.scanned..row)
196 .rev()
197 .filter_map(|idx| input.before.get(idx as usize))
198 .map(|&token| input.interner[token].trim_end())
199 .find(|line| {
200 line.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_' || c == '$')
201 });
202 if let Some(line) = found {
203 let mut end = line.len().min(Self::MAX_BYTES);
204 while !line.is_char_boundary(end) {
205 end -= 1;
206 }
207 line[..end].trim_end().clone_into(&mut self.text);
208 }
209 self.scanned = self.scanned.max(row);
210 self.text.clone()
211 }
212}
213
214fn push_context_lines(
217 lines: &mut Vec<DiffLine>,
218 input: &InternedInput<&str>,
219 old_from: u32,
220 old_to: u32,
221 new_from: u32,
222) {
223 for offset in 0..old_to.saturating_sub(old_from) {
224 let old_idx = old_from + offset;
225 let new_idx = new_from + offset;
226 let Some(&token) = input.before.get(old_idx as usize) else {
227 continue;
228 };
229 lines.push(DiffLine::new(
230 LineKind::Context,
231 Some(old_idx + 1),
232 Some(new_idx + 1),
233 line_text(input, token),
234 ));
235 }
236}
237
238fn build_hunk(
239 group: &[imara_diff::Hunk],
240 input: &InternedInput<&str>,
241 file_path: &str,
242 context: u32,
243 before_len: u32,
244 seen: &mut HashMap<HunkId, usize>,
245 heading: &mut FuncHeading,
246) -> Hunk {
247 let first = group.first().unwrap_or(&imara_diff::Hunk::NONE);
248 let last = group.last().unwrap_or(&imara_diff::Hunk::NONE);
249
250 let lead_start = first.before.start.saturating_sub(context);
251 let lead_len = first.before.start - lead_start;
252 let after_lead_start = first.after.start.saturating_sub(lead_len);
253 let tail_end = last.before.end.saturating_add(context).min(before_len);
254
255 let mut lines = Vec::new();
256 push_context_lines(
257 &mut lines,
258 input,
259 lead_start,
260 first.before.start,
261 after_lead_start,
262 );
263
264 for (index, hunk) in group.iter().enumerate() {
265 for old_idx in hunk.before.start..hunk.before.end {
266 let Some(&token) = input.before.get(old_idx as usize) else {
267 continue;
268 };
269 lines.push(DiffLine::new(
270 LineKind::Deleted,
271 Some(old_idx + 1),
272 None,
273 line_text(input, token),
274 ));
275 }
276 for new_idx in hunk.after.start..hunk.after.end {
277 let Some(&token) = input.after.get(new_idx as usize) else {
278 continue;
279 };
280 lines.push(DiffLine::new(
281 LineKind::Added,
282 None,
283 Some(new_idx + 1),
284 line_text(input, token),
285 ));
286 }
287 if let Some(next) = group.get(index + 1) {
288 push_context_lines(
289 &mut lines,
290 input,
291 hunk.before.end,
292 next.before.start,
293 hunk.after.end,
294 );
295 }
296 }
297 push_context_lines(&mut lines, input, last.before.end, tail_end, last.after.end);
298
299 let old_lines = lines.iter().filter(|l| l.kind != LineKind::Added).count() as u32;
300 let new_lines = lines.iter().filter(|l| l.kind != LineKind::Deleted).count() as u32;
301 let start = |index: u32, len: u32| if len == 0 { index } else { index + 1 };
303 let id = disambiguated_hunk_id(file_path, &lines, seen);
304 Hunk {
305 id,
306 old_start: start(lead_start, old_lines),
307 old_lines,
308 new_start: start(after_lead_start, new_lines),
309 new_lines,
310 context: heading.above(input, lead_start),
311 lines,
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn algorithm_names_round_trip() {
321 for algo in DiffAlgorithm::ALL {
322 assert_eq!(DiffAlgorithm::parse(algo.as_str()), Some(algo));
323 let json = serde_json::to_string(&algo).expect("serialize");
324 assert_eq!(json, format!("\"{}\"", algo.as_str()));
325 assert_eq!(
326 serde_json::from_str::<DiffAlgorithm>(&json).expect("deserialize"),
327 algo
328 );
329 }
330 assert_eq!(DiffAlgorithm::parse("bogus"), None);
331 assert!(serde_json::from_str::<DiffAlgorithm>("\"bogus\"").is_err());
332 }
333
334 #[test]
335 fn default_algorithm_is_myers() {
336 assert_eq!(DiffAlgorithm::default(), DiffAlgorithm::Myers);
337 }
338
339 #[test]
340 fn single_line_change_yields_one_hunk_with_context() {
341 let old = "a\nb\nc\nd\ne\n";
342 let new = "a\nb\nX\nd\ne\n";
343 let hunks = histogram_hunks(old, new, "f.txt", 1, true);
344 assert_eq!(hunks.len(), 1);
345 let h = &hunks[0];
346 assert_eq!(h.old_start, 2);
347 assert_eq!(h.old_lines, 3);
348 assert_eq!(h.new_start, 2);
349 assert_eq!(h.new_lines, 3);
350 let kinds: Vec<_> = h.lines.iter().map(|l| (l.kind, l.text.as_str())).collect();
351 assert_eq!(
352 kinds,
353 vec![
354 (LineKind::Context, "b"),
355 (LineKind::Deleted, "c"),
356 (LineKind::Added, "X"),
357 (LineKind::Context, "d"),
358 ]
359 );
360 }
361
362 #[test]
363 fn nearby_changes_merge_into_one_hunk() {
364 let old = "a\nb\nc\nd\ne\n";
366 let new = "A\nb\nc\nD\ne\n";
367 let hunks = histogram_hunks(old, new, "f.txt", 3, true);
368 assert_eq!(hunks.len(), 1, "the two edits merge: {hunks:?}");
369 assert_eq!(hunks[0].old_start, 1);
370 assert_eq!(hunks[0].old_lines, 5);
371 }
372
373 #[test]
374 fn distant_changes_stay_separate_hunks() {
375 let mut old = vec!["a".to_owned()];
376 old.extend((0..20).map(|i| format!("ctx{i}")));
377 old.push("z".to_owned());
378 let mut new = old.clone();
379 new[0] = "A".to_owned();
380 let last = new.len() - 1;
381 new[last] = "Z".to_owned();
382 let old_text = format!("{}\n", old.join("\n"));
383 let new_text = format!("{}\n", new.join("\n"));
384 let hunks = histogram_hunks(&old_text, &new_text, "f.txt", 3, true);
385 assert_eq!(hunks.len(), 2, "far-apart edits stay separate: {hunks:?}");
386 }
387
388 #[test]
389 fn leading_and_trailing_context_clip_to_file_bounds() {
390 let old = "a\nb\n";
391 let new = "A\nb\n";
392 let hunks = histogram_hunks(old, new, "f.txt", 3, true);
393 assert_eq!(hunks.len(), 1);
394 assert_eq!(hunks[0].old_start, 1);
395 assert_eq!(hunks[0].old_lines, 2);
396 }
397
398 #[test]
399 fn no_context_only_shows_changed_lines() {
400 let old = "a\nb\nc\n";
401 let new = "a\nB\nc\n";
402 let hunks = histogram_hunks(old, new, "f.txt", 0, true);
403 assert_eq!(hunks.len(), 1);
404 let kinds: Vec<_> = hunks[0].lines.iter().map(|l| l.kind).collect();
405 assert_eq!(kinds, vec![LineKind::Deleted, LineKind::Added]);
406 }
407
408 #[test]
409 fn an_empty_side_starts_at_the_line_before_like_git() {
410 let insert = histogram_hunks("a\nb\n", "a\nX\nb\n", "f.txt", 0, true);
411 assert_eq!(
412 (
413 insert[0].old_start,
414 insert[0].old_lines,
415 insert[0].new_start
416 ),
417 (1, 0, 2)
418 );
419 let delete = histogram_hunks("a\nX\nb\n", "a\nb\n", "f.txt", 0, true);
420 assert_eq!(
421 (
422 delete[0].old_start,
423 delete[0].new_start,
424 delete[0].new_lines
425 ),
426 (2, 1, 0)
427 );
428 }
429
430 #[test]
431 fn hunk_ids_are_stable_for_identical_input() {
432 let old = "a\nb\nc\n";
433 let new = "a\nB\nc\n";
434 let first = histogram_hunks(old, new, "f.txt", 1, true);
435 let second = histogram_hunks(old, new, "f.txt", 1, true);
436 assert_eq!(first[0].id, second[0].id);
437 }
438
439 #[test]
442 fn hunk_context_finds_the_enclosing_function() {
443 let old = "def parse_config():\n a = 1\n b = 2\n c = 3\n";
444 let new = "def parse_config():\n a = 1\n b = 20\n c = 3\n";
445 let hunks = histogram_hunks(old, new, "f.py", 0, true);
446 assert_eq!(hunks[0].context, "def parse_config():");
447 }
448
449 #[test]
450 fn hunk_context_skips_a_same_indent_sibling() {
451 let old = "def parse_config():\n a = 1\n b = 2\n";
454 let new = "def parse_config():\n a = 1\n b = 20\n";
455 let hunks = histogram_hunks(old, new, "f.py", 0, true);
456 assert_eq!(hunks[0].context, "def parse_config():");
457 }
458
459 #[test]
460 fn hunk_context_climbs_to_the_outermost_scope() {
461 let old = "class Foo:\n def bar():\n a = 1\n b = 2\n";
462 let new = "class Foo:\n def bar():\n a = 1\n b = 20\n";
463 let hunks = histogram_hunks(old, new, "f.py", 0, true);
464 assert_eq!(hunks[0].context, "class Foo:");
465 }
466
467 #[test]
468 fn hunk_context_prefers_the_nearest_top_level_definition() {
469 let old =
470 "def first():\n pass\n\ndef second():\n if true:\n x = 1\n y = 2\n";
471 let new = "def first():\n pass\n\ndef second():\n if true:\n x = 1\n y = 20\n";
472 let hunks = histogram_hunks(old, new, "f.py", 0, true);
473 assert_eq!(hunks[0].context, "def second():");
474 }
475
476 #[test]
477 fn hunk_context_for_an_insertion_reads_the_enclosing_function() {
478 let old = "def outer():\n a = 1\n b = 2\n";
479 let new = "def outer():\n a = 1\n newline = 99\n b = 2\n";
480 let hunks = histogram_hunks(old, new, "f.py", 0, true);
481 assert_eq!(hunks[0].context, "def outer():");
482 }
483
484 #[test]
485 fn hunk_context_for_a_top_level_change_names_the_definition_above() {
486 let old = "fn top() {\n 1;\n}\n\nfn next() {\n 2;\n}\n";
487 let new = "fn top() {\n 1;\n}\n\nfn renamed() {\n 2;\n}\n";
488 let hunks = histogram_hunks(old, new, "f.rs", 0, true);
489 assert_eq!(hunks[0].context, "fn top() {");
490 }
491
492 #[test]
493 fn hunk_context_reads_above_the_leading_context_lines() {
494 let old = "fn a() {\n 1;\n 2;\n}\n";
495 let new = "fn a() {\n 10;\n 2;\n}\n";
496 let hunks = histogram_hunks(old, new, "f.rs", 3, true);
497 assert_eq!(hunks[0].context, "", "the definition is a context line");
498 }
499
500 #[test]
501 fn a_later_hunk_keeps_the_heading_when_no_definition_lies_between() {
502 let mut old = String::from("fn only() {\n");
503 old.extend((0..20).map(|i| format!(" line{i};\n")));
504 old.push_str("}\n");
505 let new = old
506 .replace("line2;", "LINE2;")
507 .replace("line17;", "LINE17;");
508 let hunks = histogram_hunks(&old, &new, "f.rs", 1, true);
509 let contexts: Vec<_> = hunks.iter().map(|h| h.context.as_str()).collect();
510 assert_eq!(contexts, ["fn only() {", "fn only() {"]);
511 }
512
513 #[test]
514 fn hunk_context_is_cut_to_git_s_80_bytes_on_a_char_boundary() {
515 let name = "é".repeat(60);
516 let old = format!("fn {name}() {{\n a;\n b;\n}}\n");
517 let new = old.replace(" b;", " B;");
518 let hunks = histogram_hunks(&old, &new, "f.rs", 0, true);
519 assert_eq!(hunks[0].context, format!("fn {}", "é".repeat(38)));
520 }
521}