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 pub 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 let (moved, emphasis) =
71 classify_line(line.kind, &line.text, ranges.map_or(&[], Vec::as_slice));
72 line.moved = moved;
73 line.emphasis = emphasis;
74 }
75 refine_partial_changes(hunk);
76 }
77 true
78 }
79}
80
81fn refine_partial_changes(hunk: &mut Hunk) {
87 let kinds: Vec<Option<LineKind>> = hunk.lines.iter().map(|l| Some(l.kind)).collect();
88 let kind_at = |i: usize| kinds.get(i).copied().flatten();
89 let mut i = 0;
90 while i < hunk.lines.len() {
91 if kind_at(i) != Some(LineKind::Deleted) {
92 i += 1;
93 continue;
94 }
95 let del_start = i;
96 while kind_at(i) == Some(LineKind::Deleted) {
97 i += 1;
98 }
99 let add_start = i;
100 while kind_at(i) == Some(LineKind::Added) {
101 i += 1;
102 }
103 for p in 0..(add_start - del_start).min(i - add_start) {
104 let (del_idx, add_idx) = (del_start + p, add_start + p);
105 let partial = hunk
106 .lines
107 .get(del_idx)
108 .is_some_and(|l| !l.emphasis.is_empty())
109 || hunk
110 .lines
111 .get(add_idx)
112 .is_some_and(|l| !l.emphasis.is_empty());
113 let (Some(old), Some(new)) = (
114 hunk.lines.get(del_idx).map(|l| l.text.clone()),
115 hunk.lines.get(add_idx).map(|l| l.text.clone()),
116 ) else {
117 continue;
118 };
119 if !partial {
120 continue;
121 }
122 let (old_emph, new_emph) = crate::diff::intraline(&old, &new);
123 if let Some(line) = hunk.lines.get_mut(del_idx) {
124 line.emphasis = old_emph;
125 }
126 if let Some(line) = hunk.lines.get_mut(add_idx) {
127 line.emphasis = new_emph;
128 }
129 }
130 }
131}
132
133fn per_line_emphasis(src: &str, ranges: &[Range<usize>]) -> LineEmphasis {
135 let bounds = line_bounds(src);
136 let starts: Vec<usize> = bounds.iter().map(|&(s, _)| s).collect();
137 let mut out = vec![Vec::new(); bounds.len()];
138 for r in ranges {
139 split_range_by_line(&bounds, &starts, r, |li, rr| {
140 if let Some(v) = out.get_mut(li) {
141 v.push(rr);
142 }
143 });
144 }
145 out
146}
147
148fn classify_line(kind: LineKind, text: &str, ranges: &[Range<usize>]) -> (bool, Vec<Range<usize>>) {
153 let ranges = clamp(ranges, text.len());
154 let changed = matches!(kind, LineKind::Added | LineKind::Deleted);
155 if ranges.is_empty() {
156 return (changed, Vec::new());
157 }
158 if whole_line_changed(text, &ranges) {
159 return (false, Vec::new());
160 }
161 (false, ranges)
162}
163
164fn whole_line_changed(text: &str, ranges: &[Range<usize>]) -> bool {
167 let mut any_content = false;
168 for (i, &b) in text.as_bytes().iter().enumerate() {
169 if b == b' ' || b == b'\t' {
170 continue;
171 }
172 any_content = true;
173 if !ranges.iter().any(|r| r.start <= i && i < r.end) {
174 return false;
175 }
176 }
177 any_content
178}
179
180fn clamp(ranges: &[Range<usize>], len: usize) -> Vec<Range<usize>> {
182 ranges
183 .iter()
184 .filter_map(|r| {
185 let end = r.end.min(len);
186 (r.start < end).then_some(r.start..end)
187 })
188 .collect()
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 fn line_with(src: &str, needle: &str) -> usize {
196 src.lines()
197 .position(|l| l.contains(needle))
198 .unwrap_or_else(|| panic!("no line with {needle:?}"))
199 }
200
201 #[test]
202 fn pure_reindent_is_not_emphasized() {
203 let reg = LanguageRegistry::build();
204 let old = "fn f() {\n let x = compute();\n use_it(x);\n}\n";
205 let new = "fn f() {\n let x = compute();\n use_it(x);\n}\n";
206 let (_, new_e) = reg.line_emphasis("a.rs", old, new).expect("rust parses");
207 assert!(
208 new_e.iter().all(Vec::is_empty),
209 "reindentation must produce no emphasis, got {new_e:?}"
210 );
211 }
212
213 #[test]
214 fn a_real_token_change_is_emphasized() {
215 let reg = LanguageRegistry::build();
216 let old = "fn f() {\n let x = 1;\n}\n";
217 let new = "fn f() {\n let x = 2;\n}\n";
218 let (_, new_e) = reg.line_emphasis("a.rs", old, new).expect("rust parses");
219 let changed = line_with(new, "let x = 2");
220 let signature = line_with(new, "fn f()");
221 assert!(!new_e[changed].is_empty(), "the changed line is emphasized");
222 assert!(
223 new_e[signature].is_empty(),
224 "the unchanged signature line is not"
225 );
226 }
227
228 #[test]
229 fn in_string_edit_is_char_precise_not_whole_token() {
230 use crate::model::{DiffLine, FileDiff, FileStatus, Hunk, HunkId, LineKind};
231 let old_line = "fn f() { let s = \"foo/bar\"; }";
232 let new_line = "fn f() { let s = \"foo/EXTRA/bar\"; }";
233 let mut file = FileDiff {
234 path: "a.rs".into(),
235 old_path: None,
236 status: FileStatus::Modified,
237 binary: false,
238 old_text: Some(format!("{old_line}\n")),
239 new_text: Some(format!("{new_line}\n")),
240 hunks: vec![Hunk {
241 id: HunkId("h".into()),
242 old_start: 1,
243 old_lines: 1,
244 new_start: 1,
245 new_lines: 1,
246 context: String::new(),
247 lines: vec![
248 DiffLine::new(LineKind::Deleted, Some(1), None, old_line.to_owned()),
249 DiffLine::new(LineKind::Added, None, Some(1), new_line.to_owned()),
250 ],
251 }],
252 };
253 assert!(LanguageRegistry::build().syntactic_emphasis(&mut file));
254 let added = &file.hunks[0].lines[1];
255 assert!(!added.emphasis.is_empty(), "the changed line is emphasized");
256 let covered: String = added
257 .emphasis
258 .iter()
259 .filter_map(|r| new_line.get(r.clone()))
260 .collect();
261 assert!(
263 covered.contains("EXTRA"),
264 "covers the insertion: {covered:?}"
265 );
266 assert!(
267 !covered.contains("foo"),
268 "the unchanged prefix is not emphasized: {covered:?}"
269 );
270 }
271
272 #[test]
273 fn tsx_wrap_and_reindent_marks_only_real_changes() {
274 let reg = LanguageRegistry::build();
275 let old = "<Form>\n <Button onClick={onApply}>Apply</Button>\n</Form>\n";
276 let new = "{(values) => (\n <Form>\n <Button onClick={() => apply(values)}>Apply</Button>\n </Form>\n)}\n";
277 let (_, new_e) = reg.line_emphasis("a.tsx", old, new).expect("tsx parses");
278 let reindented = line_with(new, "<Form>");
279 let changed = line_with(new, "apply(values)");
280 assert!(
281 new_e[reindented].is_empty(),
282 "a reindented-but-identical line is not emphasized, got {:?}",
283 new_e[reindented]
284 );
285 assert!(
286 !new_e[changed].is_empty(),
287 "the structurally changed line is emphasized"
288 );
289 }
290
291 #[test]
292 fn unsupported_language_returns_none() {
293 let reg = LanguageRegistry::build();
294 assert!(reg.line_emphasis("a.zzz", "a\n", "b\n").is_none());
295 }
296
297 #[test]
298 fn classify_reindented_line_is_a_move() {
299 let (moved, emph) = classify_line(LineKind::Added, " <Form>", &[]);
301 assert!(moved, "reindent/move is flagged");
302 assert!(emph.is_empty());
303 }
304
305 #[test]
306 fn classify_whole_line_change_keeps_background_without_emphasis() {
307 let text = " let entirely_new = compute();";
309 let ranges = [4..7, 8..20, 21..22, 23..text.len()];
310 let (moved, emph) = classify_line(LineKind::Added, text, &ranges);
311 assert!(!moved, "a wholly-changed line is a real change, not a move");
312 assert!(
313 emph.is_empty(),
314 "no char emphasis when the whole line changed"
315 );
316 }
317
318 #[test]
319 fn classify_partial_change_keeps_emphasis() {
320 let text = " let x = 2;";
322 let changed = 12..13;
323 let (moved, emph) = classify_line(LineKind::Added, text, std::slice::from_ref(&changed));
324 assert!(!moved);
325 assert_eq!(emph.len(), 1);
326 assert_eq!(emph[0], changed);
327 }
328
329 #[test]
330 fn classify_context_line_is_never_moved() {
331 let (moved, emph) = classify_line(LineKind::Context, " unchanged", &[]);
332 assert!(!moved);
333 assert!(emph.is_empty());
334 }
335}