Skip to main content

gpui_component/diff/
presentation.rs

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