1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum DiffInlineUnit {
17 #[default]
20 Word,
21 Character,
23}
24
25const MIN_INLINE_SIMILARITY: f32 = 0.25;
28
29#[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
37pub(crate) type SyntaxHighlighters = HashMap<SharedString, SyntaxHighlighter>;
40
41pub(crate) type DiffSyntaxHighlighter =
44 Arc<dyn Fn(&str, &str) -> Vec<(Range<usize>, HighlightStyle)> + Send + Sync>;
45
46#[derive(Clone, Copy)]
48enum SyntaxPaint {
49 Name(u16),
51 Style(HighlightStyle),
53}
54
55#[derive(Default)]
57pub(crate) struct FilePresentation {
58 names: Vec<SharedString>,
59 syntax: [LineSpans<SyntaxPaint>; 2],
60 inline: [LineSpans<()>; 2],
61}
62
63struct LineSpans<T> {
65 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 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 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 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 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
187fn 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
241fn 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
277pub(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
312fn 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
324pub(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 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 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 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 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}