1use std::ops::Range;
5
6use syndiff::{SyntaxDiffOptions, build_tree, diff_trees};
7
8use crate::model::{FileDiff, Hunk, LineKind};
9use crate::syntax::registry::{LangEntry, LanguageRegistry};
10use crate::syntax::{MAX_PARSE_BYTES, line_bounds, parse, split_range_by_line};
11
12type LineEmphasis = Vec<Vec<Range<usize>>>;
13
14const GRAPH_LIMIT: usize = 250_000;
17
18impl LanguageRegistry {
19 fn line_emphasis(
22 entry: &LangEntry,
23 old_src: &str,
24 new_src: &str,
25 ) -> Option<(LineEmphasis, LineEmphasis)> {
26 if old_src.len() > MAX_PARSE_BYTES || new_src.len() > MAX_PARSE_BYTES {
27 return None;
28 }
29 if entry.name == "markdown" {
32 return None;
33 }
34 let old_ts = parse(entry, old_src)?;
35 let new_ts = parse(entry, new_src)?;
36 let old_tree = build_tree(old_ts.walk(), old_src);
37 let new_tree = build_tree(new_ts.walk(), new_src);
38 let options = SyntaxDiffOptions {
39 graph_limit: GRAPH_LIMIT,
40 };
41 let (old_ranges, new_ranges) = diff_trees(&old_tree, &new_tree, None, None, Some(options))?;
42 Some((
43 per_line_emphasis(old_src, &old_ranges),
44 per_line_emphasis(new_src, &new_ranges),
45 ))
46 }
47
48 pub fn syntactic_emphasis(
53 entry: Option<&LangEntry>,
54 file: &mut FileDiff,
55 mark_reformat_only: bool,
56 ) -> bool {
57 let Some(entry) = entry else {
58 return false;
59 };
60 let emphasis = match (file.old_text.as_deref(), file.new_text.as_deref()) {
61 (Some(old), Some(new)) => Self::line_emphasis(entry, old, new),
62 _ => None,
63 };
64 let Some((old_emph, new_emph)) = emphasis else {
65 return false;
66 };
67 let mark_reformat_only = mark_reformat_only && !entry.layout_significant;
68 for hunk in &mut file.hunks {
69 for line in &mut hunk.lines {
70 let ranges = match (line.new_no, line.old_no) {
71 (Some(n), _) => new_emph.get(n as usize - 1),
72 (None, Some(o)) => old_emph.get(o as usize - 1),
73 _ => None,
74 };
75 line.emphasis =
76 classify_line(line.kind, &line.text, ranges.map_or(&[], Vec::as_slice));
77 }
78 refine_partial_changes(hunk);
79 if mark_reformat_only {
80 mark_reformat_pairs(hunk, &old_emph, &new_emph);
81 }
82 }
83 true
84 }
85}
86
87fn mark_reformat_pairs(hunk: &mut Hunk, old_emph: &LineEmphasis, new_emph: &LineEmphasis) {
91 let unchanged = |emph: &LineEmphasis, number: Option<u32>| {
92 number
93 .and_then(|n| n.checked_sub(1))
94 .and_then(|i| emph.get(i as usize))
95 .is_some_and(Vec::is_empty)
96 };
97 let squeezed = |text: &str| text.split_whitespace().collect::<String>();
98 for (del_idx, add_idx) in crate::pairing::paired_run_indices(&hunk.lines) {
99 let (Some(del), Some(add)) = (hunk.lines.get(del_idx), hunk.lines.get(add_idx)) else {
100 continue;
101 };
102 if unchanged(old_emph, del.old_no)
103 && unchanged(new_emph, add.new_no)
104 && squeezed(&del.text) == squeezed(&add.text)
105 {
106 if let Some(line) = hunk.lines.get_mut(del_idx) {
107 line.reformat_only = true;
108 }
109 if let Some(line) = hunk.lines.get_mut(add_idx) {
110 line.reformat_only = true;
111 }
112 }
113 }
114}
115
116fn refine_partial_changes(hunk: &mut Hunk) {
121 let pairs = crate::pairing::paired_run_indices(&hunk.lines);
122 let paired: std::collections::HashSet<usize> =
123 pairs.iter().flat_map(|&(d, a)| [d, a]).collect();
124 for (index, line) in hunk.lines.iter_mut().enumerate() {
125 if matches!(line.kind, LineKind::Deleted | LineKind::Added) && !paired.contains(&index) {
126 line.emphasis = Vec::new();
127 }
128 }
129 for (del_idx, add_idx) in pairs {
130 let partial = hunk
131 .lines
132 .get(del_idx)
133 .is_some_and(|l| !l.emphasis.is_empty())
134 || hunk
135 .lines
136 .get(add_idx)
137 .is_some_and(|l| !l.emphasis.is_empty());
138 if !partial {
139 continue;
140 }
141 let (Some(old), Some(new)) = (
142 hunk.lines.get(del_idx).map(|l| l.text.clone()),
143 hunk.lines.get(add_idx).map(|l| l.text.clone()),
144 ) else {
145 continue;
146 };
147 let (old_emph, new_emph) = crate::pairing::gated_pair_emphasis(&old, &new);
148 if let Some(line) = hunk.lines.get_mut(del_idx) {
149 line.emphasis = old_emph;
150 }
151 if let Some(line) = hunk.lines.get_mut(add_idx) {
152 line.emphasis = new_emph;
153 }
154 }
155}
156
157fn per_line_emphasis(src: &str, ranges: &[Range<usize>]) -> LineEmphasis {
158 let bounds = line_bounds(src);
159 let starts: Vec<usize> = bounds.iter().map(|&(s, _)| s).collect();
160 let mut out = vec![Vec::new(); bounds.len()];
161 for r in ranges {
162 split_range_by_line(&bounds, &starts, r, |li, rr| {
163 if let Some(v) = out.get_mut(li) {
164 v.push(rr);
165 }
166 });
167 }
168 out
169}
170
171fn classify_line(kind: LineKind, text: &str, ranges: &[Range<usize>]) -> Vec<Range<usize>> {
174 let _ = kind;
175 let ranges = clamp(ranges, text.len());
176 if ranges.is_empty() || !crate::pairing::emphasis_is_punctual(text, &ranges) {
177 return Vec::new();
178 }
179 ranges
180}
181
182fn clamp(ranges: &[Range<usize>], len: usize) -> Vec<Range<usize>> {
183 ranges
184 .iter()
185 .filter_map(|r| {
186 let end = r.end.min(len);
187 (r.start < end).then_some(r.start..end)
188 })
189 .collect()
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn line_with(src: &str, needle: &str) -> usize {
197 src.lines()
198 .position(|l| l.contains(needle))
199 .unwrap_or_else(|| panic!("no line with {needle:?}"))
200 }
201
202 #[test]
203 fn pure_reindent_is_not_emphasized() {
204 let reg = LanguageRegistry::build();
205 let old = "fn f() {\n let x = compute();\n use_it(x);\n}\n";
206 let new = "fn f() {\n let x = compute();\n use_it(x);\n}\n";
207 let (_, new_e) =
208 LanguageRegistry::line_emphasis(reg.for_path("a.rs").expect("bundled"), old, new)
209 .expect("rust parses");
210 assert!(
211 new_e.iter().all(Vec::is_empty),
212 "reindentation must produce no emphasis, got {new_e:?}"
213 );
214 }
215
216 #[test]
217 fn a_real_token_change_is_emphasized() {
218 let reg = LanguageRegistry::build();
219 let old = "fn f() {\n let x = 1;\n}\n";
220 let new = "fn f() {\n let x = 2;\n}\n";
221 let (_, new_e) =
222 LanguageRegistry::line_emphasis(reg.for_path("a.rs").expect("bundled"), old, new)
223 .expect("rust parses");
224 let changed = line_with(new, "let x = 2");
225 let signature = line_with(new, "fn f()");
226 assert!(!new_e[changed].is_empty(), "the changed line is emphasized");
227 assert!(
228 new_e[signature].is_empty(),
229 "the unchanged signature line is not"
230 );
231 }
232
233 #[test]
234 fn in_string_edit_is_char_precise_not_whole_token() {
235 use crate::model::{DiffLine, FileDiff, FileStatus, Hunk, HunkId, LineKind};
236 let old_line = "fn f() { let s = \"foo/bar\"; }";
237 let new_line = "fn f() { let s = \"foo/EXTRA/bar\"; }";
238 let mut file = FileDiff {
239 path: "a.rs".into(),
240 old_path: None,
241 status: FileStatus::Modified,
242 binary: false,
243 old_text: Some(format!("{old_line}\n")),
244 new_text: Some(format!("{new_line}\n")),
245 hunks: vec![Hunk {
246 id: HunkId("h".into()),
247 old_start: 1,
248 old_lines: 1,
249 new_start: 1,
250 new_lines: 1,
251 context: String::new(),
252 lines: vec![
253 DiffLine::new(LineKind::Deleted, Some(1), None, old_line.to_owned()),
254 DiffLine::new(LineKind::Added, None, Some(1), new_line.to_owned()),
255 ],
256 }],
257 hashes: crate::model::HashCache::default(),
258 blobs: crate::model::BlobIds::default(),
259 };
260 assert!(crate::highlight::Highlighter::default().syntactic_emphasis(&mut file, false));
261 let added = &file.hunks[0].lines[1];
262 assert!(!added.emphasis.is_empty(), "the changed line is emphasized");
263 let covered: String = added
264 .emphasis
265 .iter()
266 .filter_map(|r| new_line.get(r.clone()))
267 .collect();
268 assert!(
269 covered.contains("EXTRA"),
270 "covers the insertion: {covered:?}"
271 );
272 assert!(
273 !covered.contains("foo"),
274 "the unchanged prefix is not emphasized: {covered:?}"
275 );
276 }
277
278 fn reformat_flagged(path: &str, old: &str, new: &str) -> Vec<String> {
279 let mut file = crate::model::FileDiff {
280 path: path.into(),
281 old_path: None,
282 status: crate::model::FileStatus::Modified,
283 binary: false,
284 old_text: Some(old.into()),
285 new_text: Some(new.into()),
286 hunks: crate::diffalgo::histogram_hunks(old, new, path, 3, true),
287 hashes: crate::model::HashCache::default(),
288 blobs: crate::model::BlobIds::default(),
289 };
290 assert!(crate::highlight::Highlighter::default().syntactic_emphasis(&mut file, true));
291 file.hunks
292 .iter()
293 .flat_map(|h| &h.lines)
294 .filter(|l| l.reformat_only)
295 .map(|l| l.text.clone())
296 .collect()
297 }
298
299 #[test]
300 fn structural_mode_marks_a_pure_reindent_pair_reformat_only() {
301 let flagged = reformat_flagged(
302 "a.rs",
303 "fn f() {\n let x = compute();\n}\n",
304 "fn f() {\n let x = compute();\n}\n",
305 );
306 assert_eq!(
307 flagged,
308 [" let x = compute();", " let x = compute();"]
309 );
310 }
311
312 #[test]
313 fn structural_mode_leaves_a_real_change_unflagged() {
314 let flagged = reformat_flagged(
315 "a.rs",
316 "fn f() {\n let x = 1;\n}\n",
317 "fn f() {\n let x = 2;\n}\n",
318 );
319 assert!(flagged.is_empty(), "{flagged:?}");
320 }
321
322 #[test]
323 fn structural_mode_keeps_whitespace_inside_a_string_a_change() {
324 let flagged = reformat_flagged(
325 "a.rs",
326 "fn f() {\n let s = \"a b\";\n}\n",
327 "fn f() {\n let s = \"a b\";\n}\n",
328 );
329 assert!(flagged.is_empty(), "{flagged:?}");
330 }
331
332 #[test]
333 fn structural_mode_never_flags_a_reindent_where_layout_is_syntax() {
334 let python = reformat_flagged(
335 "a.py",
336 "x = 1\nif c:\n pass\ny = 2\n",
337 "x = 1\nif c:\n pass\n y = 2\n",
338 );
339 assert!(python.is_empty(), "moving into the block: {python:?}");
340 let yaml = reformat_flagged("a.yaml", "a:\n b: 1\nc: 2\n", "a:\n b: 1\n c: 2\n");
341 assert!(yaml.is_empty(), "nesting a key: {yaml:?}");
342 }
343
344 #[test]
345 fn wholly_new_code_never_carries_fragment_emphasis() {
346 use crate::model::{DiffLine, FileDiff, FileStatus, Hunk, HunkId, LineKind};
347 let old_src = "function keep(path: string): string {\n return path;\n}\n";
348 let added = [
349 "function fresh(path: string): string {",
350 " if (!path) {",
351 " return \"missing\";",
352 " }",
353 " return path;",
354 "}",
355 ];
356 let new_src = format!("{old_src}\n{}\n", added.join("\n"));
357 let lines = added
358 .iter()
359 .enumerate()
360 .map(|(i, text)| {
361 DiffLine::new(
362 LineKind::Added,
363 None,
364 Some(5 + i as u32),
365 (*text).to_owned(),
366 )
367 })
368 .collect();
369 let mut file = FileDiff {
370 path: "a.ts".into(),
371 old_path: None,
372 status: FileStatus::Modified,
373 binary: false,
374 old_text: Some(old_src.to_owned()),
375 new_text: Some(new_src),
376 hunks: vec![Hunk {
377 id: HunkId("h".into()),
378 old_start: 3,
379 old_lines: 0,
380 new_start: 5,
381 new_lines: 6,
382 context: String::new(),
383 lines,
384 }],
385 hashes: crate::model::HashCache::default(),
386 blobs: crate::model::BlobIds::default(),
387 };
388 assert!(crate::highlight::Highlighter::default().syntactic_emphasis(&mut file, false));
389 for line in &file.hunks[0].lines {
390 assert!(
391 line.emphasis.is_empty(),
392 "no pair, no emphasis: {:?} got {:?}",
393 line.text,
394 line.emphasis
395 );
396 }
397 }
398
399 #[test]
400 fn tsx_wrap_and_reindent_marks_only_real_changes() {
401 let reg = LanguageRegistry::build();
402 let old = "<Form>\n <Button onClick={onApply}>Apply</Button>\n</Form>\n";
403 let new = "{(values) => (\n <Form>\n <Button onClick={() => apply(values)}>Apply</Button>\n </Form>\n)}\n";
404 let (_, new_e) =
405 LanguageRegistry::line_emphasis(reg.for_path("a.tsx").expect("bundled"), old, new)
406 .expect("tsx parses");
407 let reindented = line_with(new, "<Form>");
408 let changed = line_with(new, "apply(values)");
409 assert!(
410 new_e[reindented].is_empty(),
411 "a reindented-but-identical line is not emphasized, got {:?}",
412 new_e[reindented]
413 );
414 assert!(
415 !new_e[changed].is_empty(),
416 "the structurally changed line is emphasized"
417 );
418 }
419
420 #[test]
421 fn classify_unchanged_line_gets_no_emphasis() {
422 let emph = classify_line(LineKind::Added, " <Form>", &[]);
423 assert!(emph.is_empty());
424 }
425
426 #[test]
427 fn classify_whole_line_change_keeps_background_without_emphasis() {
428 let text = " let entirely_new = compute();";
429 let ranges = [4..7, 8..20, 21..22, 23..text.len()];
430 let emph = classify_line(LineKind::Added, text, &ranges);
431 assert!(
432 emph.is_empty(),
433 "no char emphasis when the whole line changed"
434 );
435 }
436
437 #[test]
438 fn classify_mostly_changed_line_drops_emphasis() {
439 let text = " let entirely_new = compute();";
440 let ranges = [4..7, 8..20, 23..30];
441 let emph = classify_line(LineKind::Added, text, &ranges);
442 assert!(emph.is_empty(), "{emph:?}");
443 }
444
445 #[test]
446 fn classify_partial_change_keeps_emphasis() {
447 let text = " let x = 2;";
448 let changed = 12..13;
449 let emph = classify_line(LineKind::Added, text, std::slice::from_ref(&changed));
450 assert_eq!(emph.len(), 1);
451 assert_eq!(emph[0], changed);
452 }
453}