Skip to main content

diffler_core/syntax/
intraline.rs

1//! Char-precise intra-line change emphasis driven by an AST diff (syndiff).
2//! Unlike the textual engine in [`crate::pairing`], reindentation and block
3//! wrapping are not flagged — only the byte ranges that differ structurally
4//! are emphasized — so a reformatted or re-wrapped block highlights just the
5//! tokens that actually changed.
6
7use 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
15/// Emphasis byte ranges per line (one inner vec per source line).
16type LineEmphasis = Vec<Vec<Range<usize>>>;
17
18/// Bounds the AST-diff graph search so a huge, heavily rewritten file cannot
19/// stall the render thread; beyond it `diff_trees` returns `None` and the
20/// caller falls back to the textual engine. Well above any normal diff.
21const GRAPH_LIMIT: usize = 250_000;
22
23impl LanguageRegistry {
24    /// Per-line emphasis byte ranges for both sides, from an AST diff of the
25    /// full old/new content. `None` (caller falls back to the textual engine)
26    /// when the language is unsupported, content is too large, parsing fails,
27    /// or the diff exceeds its graph budget.
28    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    /// Set char-precise emphasis on `file`'s diff lines from the AST diff.
53    /// Returns `false` when the syntactic engine is unavailable, so the caller
54    /// can fall back to the textual engine.
55    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
81/// Where the AST diff flagged a *partial* line change (some token ranges, not
82/// the whole line and not a reformat), replace the coarse token ranges with a
83/// grapheme-level diff of the paired lines, so only the characters that actually
84/// changed are emphasized (an edit inside a string scalar shouldn't light up the
85/// whole scalar). Whole-line changes and reformat-only lines keep the AST result.
86fn 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
133/// Map whole-file changed byte ranges to the raw per-line, within-line ranges.
134fn 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
148/// Classify an added/deleted `line` from its raw changed byte `ranges`:
149/// - no change → a reindent/move: `(moved = true, no emphasis)`, a thin rail;
150/// - the whole content changed → `(false, no emphasis)`, a full +/- background;
151/// - some tokens changed → `(false, those ranges)`, background plus emphasis.
152fn 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
164/// True when every non-whitespace byte of the line is emphasized (gaps fall
165/// only on whitespace) — i.e. the entire content changed.
166fn 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
180/// Clip ranges to the line's length and drop any that become empty.
181fn 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        // only the inserted run is emphasized, not the whole "foo/EXTRA/bar" token
262        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        // an added/deleted line with no changed bytes only moved/reindented
300        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        // every non-whitespace byte changed -> full +/- bg, no char emphasis
308        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        // only `2` changed in `    let x = 2;`
321        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}