Skip to main content

diffler_core/syntax/
intraline.rs

1//! Intra-line emphasis from an AST diff (syndiff). [`crate::pairing`] holds
2//! the textual engine we fall back to.
3
4use 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
14/// We bound the AST-diff graph search so a huge rewrite cannot stall the
15/// render thread; past it the caller falls back to the textual engine.
16const GRAPH_LIMIT: usize = 250_000;
17
18impl LanguageRegistry {
19    /// `None` when the file cannot be parsed or the diff exceeds its graph
20    /// budget.
21    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        // markdown's block tree makes a paragraph one opaque node, so the
30        // textual word diff serves prose better
31        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    /// Set emphasis on `file`'s diff lines from an AST diff of both sides.
49    /// `mark_reformat_only` also flags paired lines that differ in layout
50    /// alone. Returns `false` when the file cannot be diffed this way, so the
51    /// caller falls back to the textual engine.
52    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
87/// Flag a paired line `reformat_only` when the two differ in whitespace alone
88/// and the AST diff found no token changed. We need both checks so a
89/// whitespace edit inside a string literal stays a real change.
90fn 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
116/// Replace the AST diff's coarse token ranges on a partly changed pair with a
117/// word diff of the two lines, so an edit inside a string does not light up
118/// the whole string. Unpaired lines render plain, since the AST diff leaves
119/// stray fragments on them where it matched new code against old.
120fn 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
171/// A line that changed mostly or entirely gets no emphasis, since
172/// highlighting almost everything highlights nothing.
173fn 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}