1use std::ops::Range;
8
9use syndiff::{SyntaxDiffOptions, build_tree, diff_trees};
10
11use crate::model::{FileDiff, Hunk, LineKind};
12use crate::syntax::registry::LanguageRegistry;
13use crate::syntax::{MAX_PARSE_BYTES, line_bounds, parse, split_range_by_line};
14
15type LineEmphasis = Vec<Vec<Range<usize>>>;
17
18const GRAPH_LIMIT: usize = 250_000;
22
23impl LanguageRegistry {
24 fn line_emphasis(
29 &self,
30 path: &str,
31 old_src: &str,
32 new_src: &str,
33 ) -> Option<(LineEmphasis, LineEmphasis)> {
34 if old_src.len() > MAX_PARSE_BYTES || new_src.len() > MAX_PARSE_BYTES {
35 return None;
36 }
37 let entry = self.for_path(path)?;
38 let old_ts = parse(entry, old_src)?;
39 let new_ts = parse(entry, new_src)?;
40 let old_tree = build_tree(old_ts.walk(), old_src);
41 let new_tree = build_tree(new_ts.walk(), new_src);
42 let options = SyntaxDiffOptions {
43 graph_limit: GRAPH_LIMIT,
44 };
45 let (old_ranges, new_ranges) = diff_trees(&old_tree, &new_tree, None, None, Some(options))?;
46 Some((
47 per_line_emphasis(old_src, &old_ranges),
48 per_line_emphasis(new_src, &new_ranges),
49 ))
50 }
51
52 pub fn syntactic_emphasis(&self, file: &mut FileDiff) -> bool {
56 let emphasis = match (file.old_text.as_deref(), file.new_text.as_deref()) {
57 (Some(old), Some(new)) => self.line_emphasis(&file.path, old, new),
58 _ => None,
59 };
60 let Some((old_emph, new_emph)) = emphasis else {
61 return false;
62 };
63 for hunk in &mut file.hunks {
64 for line in &mut hunk.lines {
65 let ranges = match (line.new_no, line.old_no) {
66 (Some(n), _) => new_emph.get(n as usize - 1),
67 (None, Some(o)) => old_emph.get(o as usize - 1),
68 _ => None,
69 };
70 line.emphasis =
71 classify_line(line.kind, &line.text, ranges.map_or(&[], Vec::as_slice));
72 }
73 refine_partial_changes(hunk);
74 }
75 true
76 }
77}
78
79fn refine_partial_changes(hunk: &mut Hunk) {
88 let pairs = crate::pairing::paired_run_indices(&hunk.lines);
89 let paired: std::collections::HashSet<usize> =
90 pairs.iter().flat_map(|&(d, a)| [d, a]).collect();
91 for (index, line) in hunk.lines.iter_mut().enumerate() {
92 if matches!(line.kind, LineKind::Deleted | LineKind::Added) && !paired.contains(&index) {
93 line.emphasis = Vec::new();
94 }
95 }
96 for (del_idx, add_idx) in pairs {
97 let partial = hunk
98 .lines
99 .get(del_idx)
100 .is_some_and(|l| !l.emphasis.is_empty())
101 || hunk
102 .lines
103 .get(add_idx)
104 .is_some_and(|l| !l.emphasis.is_empty());
105 if !partial {
106 continue;
107 }
108 let (Some(old), Some(new)) = (
109 hunk.lines.get(del_idx).map(|l| l.text.clone()),
110 hunk.lines.get(add_idx).map(|l| l.text.clone()),
111 ) else {
112 continue;
113 };
114 let (old_emph, new_emph) = crate::pairing::gated_pair_emphasis(&old, &new);
117 if let Some(line) = hunk.lines.get_mut(del_idx) {
118 line.emphasis = old_emph;
119 }
120 if let Some(line) = hunk.lines.get_mut(add_idx) {
121 line.emphasis = new_emph;
122 }
123 }
124}
125
126fn per_line_emphasis(src: &str, ranges: &[Range<usize>]) -> LineEmphasis {
128 let bounds = line_bounds(src);
129 let starts: Vec<usize> = bounds.iter().map(|&(s, _)| s).collect();
130 let mut out = vec![Vec::new(); bounds.len()];
131 for r in ranges {
132 split_range_by_line(&bounds, &starts, r, |li, rr| {
133 if let Some(v) = out.get_mut(li) {
134 v.push(rr);
135 }
136 });
137 }
138 out
139}
140
141fn classify_line(kind: LineKind, text: &str, ranges: &[Range<usize>]) -> Vec<Range<usize>> {
146 let _ = kind;
147 let ranges = clamp(ranges, text.len());
148 if ranges.is_empty() || !crate::pairing::emphasis_is_punctual(text, &ranges) {
149 return Vec::new();
150 }
151 ranges
152}
153
154fn clamp(ranges: &[Range<usize>], len: usize) -> Vec<Range<usize>> {
156 ranges
157 .iter()
158 .filter_map(|r| {
159 let end = r.end.min(len);
160 (r.start < end).then_some(r.start..end)
161 })
162 .collect()
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 fn line_with(src: &str, needle: &str) -> usize {
170 src.lines()
171 .position(|l| l.contains(needle))
172 .unwrap_or_else(|| panic!("no line with {needle:?}"))
173 }
174
175 #[test]
176 fn pure_reindent_is_not_emphasized() {
177 let reg = LanguageRegistry::build();
178 let old = "fn f() {\n let x = compute();\n use_it(x);\n}\n";
179 let new = "fn f() {\n let x = compute();\n use_it(x);\n}\n";
180 let (_, new_e) = reg.line_emphasis("a.rs", old, new).expect("rust parses");
181 assert!(
182 new_e.iter().all(Vec::is_empty),
183 "reindentation must produce no emphasis, got {new_e:?}"
184 );
185 }
186
187 #[test]
188 fn a_real_token_change_is_emphasized() {
189 let reg = LanguageRegistry::build();
190 let old = "fn f() {\n let x = 1;\n}\n";
191 let new = "fn f() {\n let x = 2;\n}\n";
192 let (_, new_e) = reg.line_emphasis("a.rs", old, new).expect("rust parses");
193 let changed = line_with(new, "let x = 2");
194 let signature = line_with(new, "fn f()");
195 assert!(!new_e[changed].is_empty(), "the changed line is emphasized");
196 assert!(
197 new_e[signature].is_empty(),
198 "the unchanged signature line is not"
199 );
200 }
201
202 #[test]
203 fn in_string_edit_is_char_precise_not_whole_token() {
204 use crate::model::{DiffLine, FileDiff, FileStatus, Hunk, HunkId, LineKind};
205 let old_line = "fn f() { let s = \"foo/bar\"; }";
206 let new_line = "fn f() { let s = \"foo/EXTRA/bar\"; }";
207 let mut file = FileDiff {
208 path: "a.rs".into(),
209 old_path: None,
210 status: FileStatus::Modified,
211 binary: false,
212 old_text: Some(format!("{old_line}\n")),
213 new_text: Some(format!("{new_line}\n")),
214 hunks: vec![Hunk {
215 id: HunkId("h".into()),
216 old_start: 1,
217 old_lines: 1,
218 new_start: 1,
219 new_lines: 1,
220 context: String::new(),
221 lines: vec![
222 DiffLine::new(LineKind::Deleted, Some(1), None, old_line.to_owned()),
223 DiffLine::new(LineKind::Added, None, Some(1), new_line.to_owned()),
224 ],
225 }],
226 hashes: crate::model::HashCache::default(),
227 };
228 assert!(LanguageRegistry::build().syntactic_emphasis(&mut file));
229 let added = &file.hunks[0].lines[1];
230 assert!(!added.emphasis.is_empty(), "the changed line is emphasized");
231 let covered: String = added
232 .emphasis
233 .iter()
234 .filter_map(|r| new_line.get(r.clone()))
235 .collect();
236 assert!(
238 covered.contains("EXTRA"),
239 "covers the insertion: {covered:?}"
240 );
241 assert!(
242 !covered.contains("foo"),
243 "the unchanged prefix is not emphasized: {covered:?}"
244 );
245 }
246
247 #[test]
251 fn wholly_new_code_never_carries_fragment_emphasis() {
252 use crate::model::{DiffLine, FileDiff, FileStatus, Hunk, HunkId, LineKind};
253 let old_src = "function keep(path: string): string {\n return path;\n}\n";
254 let added = [
255 "function fresh(path: string): string {",
256 " if (!path) {",
257 " return \"missing\";",
258 " }",
259 " return path;",
260 "}",
261 ];
262 let new_src = format!("{old_src}\n{}\n", added.join("\n"));
263 let lines = added
264 .iter()
265 .enumerate()
266 .map(|(i, text)| {
267 DiffLine::new(
268 LineKind::Added,
269 None,
270 Some(5 + i as u32),
271 (*text).to_owned(),
272 )
273 })
274 .collect();
275 let mut file = FileDiff {
276 path: "a.ts".into(),
277 old_path: None,
278 status: FileStatus::Modified,
279 binary: false,
280 old_text: Some(old_src.to_owned()),
281 new_text: Some(new_src),
282 hunks: vec![Hunk {
283 id: HunkId("h".into()),
284 old_start: 3,
285 old_lines: 0,
286 new_start: 5,
287 new_lines: 6,
288 context: String::new(),
289 lines,
290 }],
291 hashes: crate::model::HashCache::default(),
292 };
293 assert!(LanguageRegistry::build().syntactic_emphasis(&mut file));
294 for line in &file.hunks[0].lines {
295 assert!(
296 line.emphasis.is_empty(),
297 "no pair, no emphasis — {:?} got {:?}",
298 line.text,
299 line.emphasis
300 );
301 }
302 }
303
304 #[test]
305 fn tsx_wrap_and_reindent_marks_only_real_changes() {
306 let reg = LanguageRegistry::build();
307 let old = "<Form>\n <Button onClick={onApply}>Apply</Button>\n</Form>\n";
308 let new = "{(values) => (\n <Form>\n <Button onClick={() => apply(values)}>Apply</Button>\n </Form>\n)}\n";
309 let (_, new_e) = reg.line_emphasis("a.tsx", old, new).expect("tsx parses");
310 let reindented = line_with(new, "<Form>");
311 let changed = line_with(new, "apply(values)");
312 assert!(
313 new_e[reindented].is_empty(),
314 "a reindented-but-identical line is not emphasized, got {:?}",
315 new_e[reindented]
316 );
317 assert!(
318 !new_e[changed].is_empty(),
319 "the structurally changed line is emphasized"
320 );
321 }
322
323 #[test]
324 fn unsupported_language_returns_none() {
325 let reg = LanguageRegistry::build();
326 assert!(reg.line_emphasis("a.zzz", "a\n", "b\n").is_none());
327 }
328
329 #[test]
330 fn classify_unchanged_line_gets_no_emphasis() {
331 let emph = classify_line(LineKind::Added, " <Form>", &[]);
333 assert!(emph.is_empty());
334 }
335
336 #[test]
337 fn classify_whole_line_change_keeps_background_without_emphasis() {
338 let text = " let entirely_new = compute();";
340 let ranges = [4..7, 8..20, 21..22, 23..text.len()];
341 let emph = classify_line(LineKind::Added, text, &ranges);
342 assert!(
343 emph.is_empty(),
344 "no char emphasis when the whole line changed"
345 );
346 }
347
348 #[test]
349 fn classify_mostly_changed_line_drops_emphasis() {
350 let text = " let entirely_new = compute();";
352 let ranges = [4..7, 8..20, 23..30];
353 let emph = classify_line(LineKind::Added, text, &ranges);
354 assert!(emph.is_empty(), "{emph:?}");
355 }
356
357 #[test]
358 fn classify_partial_change_keeps_emphasis() {
359 let text = " let x = 2;";
361 let changed = 12..13;
362 let emph = classify_line(LineKind::Added, text, std::slice::from_ref(&changed));
363 assert_eq!(emph.len(), 1);
364 assert_eq!(emph[0], changed);
365 }
366}