Skip to main content

gpui_component/diff/
presentation.rs

1//! Presentation prepared on a background thread: theme-independent syntax
2//! captures and inline changes between paired lines.
3use std::{collections::HashMap, ops::Range};
4
5use gpui::{HighlightStyle, SharedString};
6use gpui_base::input::HighlightStyleResolver;
7use similar::{ChangeTag, TextDiff};
8
9use super::{DiffFile, DiffSide, document::SourceLine};
10use crate::{Rope, highlighter::SyntaxHighlighter};
11
12/// The unit inline changes are compared and emphasized in.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
14#[non_exhaustive]
15pub enum DiffInlineUnit {
16    /// Words and punctuation; changed words separated only by whitespace are
17    /// emphasized as one run.
18    #[default]
19    Word,
20    /// Grapheme clusters.
21    Character,
22}
23
24/// Paired lines sharing less than this proportion of content are emphasized as
25/// whole lines only; marking nearly every character adds noise, not signal.
26const MIN_INLINE_SIMILARITY: f32 = 0.25;
27
28/// Settings that determine prepared presentation.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub(crate) struct PresentationOptions {
31    pub inline_unit: Option<DiffInlineUnit>,
32    pub inline_max_line_length: usize,
33    pub syntax_max_line_length: usize,
34}
35
36/// Highlighters by language, shared by the files of one preparation pass
37/// because building one compiles its language's queries.
38pub(crate) type SyntaxHighlighters = HashMap<SharedString, SyntaxHighlighter>;
39
40/// Prepared presentation for one file.
41#[derive(Default)]
42pub(crate) struct FilePresentation {
43    names: Vec<SharedString>,
44    syntax: [LineSpans<u16>; 2],
45    inline: [LineSpans<()>; 2],
46}
47
48/// Byte ranges relative to each line's source start, with a payload.
49struct LineSpans<T> {
50    /// `starts[ix]..starts[ix + 1]` index the spans of line `ix`.
51    starts: Vec<u32>,
52    spans: Vec<(Range<u32>, T)>,
53}
54
55impl<T> Default for LineSpans<T> {
56    fn default() -> Self {
57        Self {
58            starts: Vec::new(),
59            spans: Vec::new(),
60        }
61    }
62}
63
64impl<T> LineSpans<T> {
65    fn from_lines(per_line: Vec<Vec<(Range<u32>, T)>>) -> Self {
66        let mut spans = Self::default();
67        spans.starts.reserve(per_line.len() + 1);
68        spans.starts.push(0);
69        for line in per_line {
70            spans.spans.extend(line);
71            spans.starts.push(spans.spans.len() as u32);
72        }
73        spans
74    }
75
76    fn line(&self, ix: usize) -> &[(Range<u32>, T)] {
77        match (self.starts.get(ix), self.starts.get(ix + 1)) {
78            (Some(start), Some(end)) => &self.spans[*start as usize..*end as usize],
79            _ => &[],
80        }
81    }
82}
83
84impl FilePresentation {
85    /// Blocking; run it on a background thread.
86    pub(crate) fn prepare(
87        file: &DiffFile,
88        options: PresentationOptions,
89        highlighters: &mut SyntaxHighlighters,
90    ) -> Self {
91        let mut presentation = Self::default();
92        let mut name_ixs = HashMap::<SharedString, u16>::new();
93        for side in [DiffSide::Original, DiffSide::Modified] {
94            let per_line = syntax_spans(
95                file,
96                side,
97                options.syntax_max_line_length,
98                highlighters,
99                &mut |name| {
100                    let next = name_ixs.len().min(u16::MAX as usize) as u16;
101                    *name_ixs.entry(name.clone()).or_insert_with(|| {
102                        presentation.names.push(name.clone());
103                        next
104                    })
105                },
106            );
107            presentation.syntax[side as usize] = LineSpans::from_lines(per_line);
108        }
109        if let Some(unit) = options.inline_unit {
110            presentation.inline = inline_changes(file, unit, options.inline_max_line_length);
111        }
112        presentation
113    }
114
115    /// Theme styles for one line's syntax, as display ranges.
116    pub(crate) fn syntax_highlights(
117        &self,
118        line: &SourceLine,
119        side: DiffSide,
120        ix: usize,
121        theme: &dyn HighlightStyleResolver,
122    ) -> Vec<(Range<usize>, HighlightStyle)> {
123        self.syntax[side as usize]
124            .line(ix)
125            .iter()
126            .filter_map(|(range, name)| {
127                let style = theme.style(&self.names[*name as usize])?;
128                Some((
129                    line.display_range(range.start as usize..range.end as usize),
130                    style,
131                ))
132            })
133            .collect()
134    }
135
136    /// Changed runs within one line, as display ranges.
137    pub(crate) fn inline_changes<'a>(
138        &'a self,
139        line: &'a SourceLine,
140        side: DiffSide,
141        ix: usize,
142    ) -> impl Iterator<Item = Range<usize>> + 'a {
143        self.inline[side as usize]
144            .line(ix)
145            .iter()
146            .map(|(range, _)| line.display_range(range.start as usize..range.end as usize))
147    }
148}
149
150/// Parses each hunk of one side independently, so an unterminated comment or
151/// string in one hunk cannot affect the next.
152fn syntax_spans(
153    file: &DiffFile,
154    side: DiffSide,
155    max_line_length: usize,
156    highlighters: &mut SyntaxHighlighters,
157    name_ix: &mut dyn FnMut(&SharedString) -> u16,
158) -> Vec<Vec<(Range<u32>, u16)>> {
159    let lines = file.lines(side);
160    let mut per_line: Vec<Vec<(Range<u32>, u16)>> = (0..lines.len()).map(|_| Vec::new()).collect();
161    let language = match (file.language_override(), file.side_path(side)) {
162        (Some(language), _) => language.clone(),
163        (None, Some(path)) => detected_language(path),
164        (None, None) => return per_line,
165    };
166    if language.as_str() == "text" || lines.is_empty() {
167        return per_line;
168    }
169    let highlighter = highlighters
170        .entry(language.clone())
171        .or_insert_with(|| SyntaxHighlighter::new(&language));
172    let ranges = if file.hunks().is_empty() {
173        vec![0..lines.len()]
174    } else {
175        file.hunks().iter().map(|hunk| hunk.lines(side)).collect()
176    };
177    let source = file.source(side);
178    for range in ranges.into_iter().filter(|range| !range.is_empty()) {
179        let base = lines[range.start].source().start;
180        let text = &source[base..lines[range.end - 1].source().end];
181        let hunk_lines = &lines[range.clone()];
182        for (capture, name) in highlighter.capture_names(&Rope::from_str(text)) {
183            let capture = capture.start + base..capture.end + base;
184            let first = hunk_lines.partition_point(|line| line.source().end <= capture.start);
185            for (offset, line) in hunk_lines[first..].iter().enumerate() {
186                let line_start = line.source().start;
187                if line_start >= capture.end {
188                    break;
189                }
190                if line.content_end() - line_start > max_line_length {
191                    continue;
192                }
193                let start = capture.start.max(line_start);
194                let end = capture.end.min(line.content_end());
195                if start < end {
196                    per_line[range.start + first + offset].push((
197                        (start - line_start) as u32..(end - line_start) as u32,
198                        name_ix(&name),
199                    ));
200                }
201            }
202        }
203    }
204    per_line
205}
206
207/// Compares each deleted line with the added line paired with it.
208fn inline_changes(
209    file: &DiffFile,
210    unit: DiffInlineUnit,
211    max_line_length: usize,
212) -> [LineSpans<()>; 2] {
213    let mut sides: [Vec<Vec<(Range<u32>, ())>>; 2] = [
214        (0..file.lines_count(DiffSide::Original))
215            .map(|_| Vec::new())
216            .collect(),
217        (0..file.lines_count(DiffSide::Modified))
218            .map(|_| Vec::new())
219            .collect(),
220    ];
221    for pair in file.pairs() {
222        let (true, Some(old_ix), Some(new_ix)) =
223            (pair.is_changed(), pair.original(), pair.modified())
224        else {
225            continue;
226        };
227        let old = file.lines(DiffSide::Original)[old_ix].text();
228        let new = file.lines(DiffSide::Modified)[new_ix].text();
229        if old.len() > max_line_length || new.len() > max_line_length || old == new {
230            continue;
231        }
232        let (old_runs, new_runs) = changed_runs(old, new, unit);
233        sides[0][old_ix] = old_runs.into_iter().map(|range| (range, ())).collect();
234        sides[1][new_ix] = new_runs.into_iter().map(|range| (range, ())).collect();
235    }
236    let [original, modified] = sides;
237    [
238        LineSpans::from_lines(original),
239        LineSpans::from_lines(modified),
240    ]
241}
242
243/// Byte ranges that differ between two lines, on each side.
244pub(crate) fn changed_runs(
245    old: &str,
246    new: &str,
247    unit: DiffInlineUnit,
248) -> (Vec<Range<u32>>, Vec<Range<u32>>) {
249    let diff = match unit {
250        DiffInlineUnit::Word => TextDiff::from_unicode_words(old, new),
251        DiffInlineUnit::Character => TextDiff::from_graphemes(old, new),
252    };
253    if diff.ratio() < MIN_INLINE_SIMILARITY {
254        return (Vec::new(), Vec::new());
255    }
256    let (mut old_runs, mut new_runs) = (Vec::new(), Vec::new());
257    let (mut old_offset, mut new_offset) = (0usize, 0usize);
258    for change in diff.iter_all_changes() {
259        let len = change.value().len();
260        match change.tag() {
261            ChangeTag::Equal => {
262                old_offset += len;
263                new_offset += len;
264            }
265            ChangeTag::Delete => {
266                push_run(&mut old_runs, old_offset..old_offset + len, old, unit);
267                old_offset += len;
268            }
269            ChangeTag::Insert => {
270                push_run(&mut new_runs, new_offset..new_offset + len, new, unit);
271                new_offset += len;
272            }
273        }
274    }
275    (old_runs, new_runs)
276}
277
278/// Extends the previous run when only whitespace separates it from `range`.
279fn push_run(runs: &mut Vec<Range<u32>>, range: Range<usize>, text: &str, unit: DiffInlineUnit) {
280    if let Some(last) = runs.last_mut() {
281        let gap = &text[last.end as usize..range.start];
282        if gap.is_empty() || unit == DiffInlineUnit::Word && gap.trim().is_empty() {
283            last.end = range.end as u32;
284            return;
285        }
286    }
287    runs.push(range.start as u32..range.end as u32);
288}
289
290/// File paths may come from another platform. Only the final component
291/// determines the language; dots in a directory are not extensions.
292pub(crate) fn detected_language(path: &str) -> SharedString {
293    let name = path.rsplit(['/', '\\']).next().unwrap_or("").to_lowercase();
294    let language = match name.as_str() {
295        "makefile" | "gnumakefile" => "make",
296        "cmakelists.txt" => "cmake",
297        _ => match name.rsplit_once('.').map(|(_, extension)| extension) {
298            Some("jsx" | "mjs" | "cjs") => "javascript",
299            Some("mts" | "cts") => "typescript",
300            Some("cc" | "cxx" | "hpp" | "hxx") => "cpp",
301            Some("h") => "c",
302            Some("htm") => "html",
303            Some("gql") => "graphql",
304            Some("exs") => "elixir",
305            Some(
306                extension @ ("astro" | "sh" | "bash" | "c" | "cmake" | "cs" | "cpp" | "css"
307                | "scss" | "diff" | "ejs" | "ex" | "erb" | "go" | "graphql" | "html"
308                | "java" | "js" | "json" | "jsonc" | "kt" | "kts" | "lua" | "md"
309                | "mdx" | "php" | "php3" | "php4" | "php5" | "phtml" | "proto" | "py"
310                | "pyi" | "rb" | "rs" | "scala" | "sql" | "svelte" | "swift" | "toml"
311                | "tsx" | "ts" | "yaml" | "yml" | "zig"),
312            ) => extension,
313            _ => "text",
314        },
315    };
316    crate::highlighter::language_name(language)
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    const OPTIONS: PresentationOptions = PresentationOptions {
324        inline_unit: Some(DiffInlineUnit::Word),
325        inline_max_line_length: 1000,
326        syntax_max_line_length: 1000,
327    };
328
329    #[test]
330    fn language_detection_uses_filename() {
331        for (name, language) in [
332            ("src/main.RS", "rust"),
333            ("src/components/App.jsx", "javascript"),
334            ("C:\\src\\lib.hpp", "cpp"),
335            ("build/CMakeLists.txt", "cmake"),
336            ("build/Makefile", "make"),
337            ("directory.rs/README", "text"),
338            ("ts", "text"),
339            ("notes.unknown", "text"),
340        ] {
341            assert_eq!(detected_language(name).as_str(), language);
342        }
343        let file = DiffFile::parse("--- a/notes\n+++ b/notes\n@@ -1 +1 @@\n-a\n+b\n")
344            .unwrap()
345            .remove(0);
346        assert_eq!(file.language().as_str(), "text");
347        assert_eq!(file.with_language("rust").language().as_str(), "rust");
348    }
349
350    #[test]
351    fn inline_changes_merge_words_across_whitespace_and_skip_unrelated_lines() {
352        let (old, new) = changed_runs(
353            "let total = price * count;",
354            "let total = cost * quantity;",
355            DiffInlineUnit::Word,
356        );
357        assert_eq!(old, [12..17, 20..25]);
358        assert_eq!(new, [12..16, 19..27]);
359        let (old, new) = changed_runs("old value here", "new value here", DiffInlineUnit::Word);
360        assert_eq!((old, new), (vec![0..3], vec![0..3]));
361        let (old, new) = changed_runs("a b c", "a x y c", DiffInlineUnit::Word);
362        assert_eq!(new, [2..5]);
363        assert!(old.is_empty() || old == [2..3]);
364        let (old, new) = changed_runs("colour", "color", DiffInlineUnit::Character);
365        assert_eq!((old, new), (vec![4..5], vec![]));
366        let (old, new) = changed_runs("alpha", "zzzzzzzzz", DiffInlineUnit::Word);
367        assert!(old.is_empty() && new.is_empty());
368    }
369
370    #[test]
371    fn inline_changes_cover_paired_lines_only() {
372        let file = DiffFile::parse(
373            "--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,3 @@\n-let total = price * count;\n same\n+let total = cost * count;\n+added\n",
374        )
375        .unwrap()
376        .remove(0);
377        let presentation =
378            FilePresentation::prepare(&file, OPTIONS, &mut SyntaxHighlighters::default());
379        let ranges = |side, ix| {
380            let line = &file.lines(side)[ix];
381            presentation
382                .inline_changes(line, side, ix)
383                .collect::<Vec<_>>()
384        };
385        // The deletion and the first addition are in different change runs,
386        // so they are not paired.
387        assert!(ranges(DiffSide::Original, 0).is_empty());
388        let file = DiffFile::parse(
389            "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1,2 @@\n-let total = price * count;\n+let total = cost * count;\n+added\n",
390        )
391        .unwrap()
392        .remove(0);
393        let presentation =
394            FilePresentation::prepare(&file, OPTIONS, &mut SyntaxHighlighters::default());
395        let ranges = |side, ix| {
396            let line = &file.lines(side)[ix];
397            presentation
398                .inline_changes(line, side, ix)
399                .collect::<Vec<_>>()
400        };
401        assert_eq!(ranges(DiffSide::Original, 0), [12..17]);
402        assert_eq!(ranges(DiffSide::Modified, 0), [12..16]);
403        assert!(ranges(DiffSide::Modified, 1).is_empty());
404        let off = PresentationOptions {
405            inline_unit: None,
406            ..OPTIONS
407        };
408        let presentation =
409            FilePresentation::prepare(&file, off, &mut SyntaxHighlighters::default());
410        assert_eq!(
411            presentation
412                .inline_changes(&file.lines(DiffSide::Original)[0], DiffSide::Original, 0)
413                .count(),
414            0
415        );
416    }
417
418    #[cfg(feature = "tree-sitter-rust")]
419    #[test]
420    fn syntax_is_parsed_per_hunk_and_respects_language_and_line_limit() {
421        // Hunks are separated by unavailable source. Parsing their fragments
422        // together would join `/* open` and `close */` into one comment.
423        let file = DiffFile::parse(
424            "--- a/a.rs\n+++ b/a.rs\n@@ -1,2 +1,2 @@\n-let a = 1;\n+let a = 2;\n /* open\n@@ -10,2 +10,2 @@\n close */\n-fn old() {}\n+fn new() {}\n",
425        )
426        .unwrap()
427        .remove(0);
428        let presentation =
429            FilePresentation::prepare(&file, OPTIONS, &mut SyntaxHighlighters::default());
430        let names = |presentation: &FilePresentation, ix: usize| {
431            presentation.syntax[DiffSide::Modified as usize]
432                .line(ix)
433                .iter()
434                .map(|(_, name)| presentation.names[*name as usize].to_string())
435                .collect::<Vec<_>>()
436        };
437        assert!(names(&presentation, 0).iter().any(|name| name == "keyword"));
438        assert!(
439            !names(&presentation, 2)
440                .iter()
441                .any(|name| name.starts_with("comment"))
442        );
443        assert!(
444            names(&presentation, 3)
445                .iter()
446                .any(|name| name == "function")
447        );
448
449        let short = PresentationOptions {
450            syntax_max_line_length: 5,
451            ..OPTIONS
452        };
453        let presentation =
454            FilePresentation::prepare(&file, short, &mut SyntaxHighlighters::default());
455        assert!(names(&presentation, 0).is_empty());
456
457        let as_text = file.clone().with_language("text");
458        let presentation =
459            FilePresentation::prepare(&as_text, OPTIONS, &mut SyntaxHighlighters::default());
460        assert!(names(&presentation, 0).is_empty());
461    }
462}