1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
14#[non_exhaustive]
15pub enum DiffInlineUnit {
16 #[default]
19 Word,
20 Character,
22}
23
24const MIN_INLINE_SIMILARITY: f32 = 0.25;
27
28#[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
36pub(crate) type SyntaxHighlighters = HashMap<SharedString, SyntaxHighlighter>;
39
40#[derive(Default)]
42pub(crate) struct FilePresentation {
43 names: Vec<SharedString>,
44 syntax: [LineSpans<u16>; 2],
45 inline: [LineSpans<()>; 2],
46}
47
48struct LineSpans<T> {
50 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 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 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 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
150fn 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
207fn 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
243pub(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
278fn 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
290pub(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 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 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}