Skip to main content

diffler_core/
highlight.rs

1//! Whole-file syntax highlighting via tree-sitter, sliced into per-line styled
2//! ranges. Highlighting whole files (not hunks) keeps multi-line constructs
3//! like strings correct across hunk boundaries. Unknown languages and parse
4//! failures degrade to plain (empty) ranges so rendering never breaks.
5
6use std::ops::Range;
7
8use tree_sitter_highlight::{HighlightEvent, Highlighter as TsHighlighter};
9
10use crate::syntax::{HIGHLIGHT_NAMES, LanguageRegistry};
11
12pub struct Highlighter {
13    registry: LanguageRegistry,
14    theme: SyntaxTheme,
15}
16
17/// Syntax-highlight palette, paired with a UI theme so foreground colors stay
18/// legible against the diff backgrounds (a dark UI needs dark-theme syntax, a
19/// light UI light-theme syntax).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
21pub enum SyntaxTheme {
22    #[default]
23    OneHalfDark,
24    OneHalfLight,
25    Dracula,
26    CatppuccinMocha,
27    TokyoNight,
28    GruvboxDark,
29    Nord,
30    RosePine,
31    Kanagawa,
32}
33
34/// Foreground color + style for a byte range of one line.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct StyledRange {
37    pub range: Range<usize>,
38    pub fg: (u8, u8, u8),
39    pub bold: bool,
40    pub italic: bool,
41}
42
43impl Default for Highlighter {
44    fn default() -> Self {
45        Self::new(SyntaxTheme::default())
46    }
47}
48
49impl Highlighter {
50    /// Build a highlighter whose foregrounds come from `syntax`.
51    pub fn new(syntax: SyntaxTheme) -> Self {
52        Self {
53            registry: LanguageRegistry::build(),
54            theme: syntax,
55        }
56    }
57
58    /// Highlight `content` as the language guessed from `path`'s extension.
59    /// Returns one `Vec<StyledRange>` per line (without trailing newlines).
60    /// Unknown languages produce empty ranges per line (plain rendering).
61    pub fn highlight(&self, path: &str, content: &str) -> Vec<Vec<StyledRange>> {
62        self.highlight_entry(self.registry.for_path(path), content)
63    }
64
65    /// Highlight `content` as a markdown fence token (`rust`, `py`, ...).
66    pub fn highlight_lang(&self, token: &str, content: &str) -> Vec<Vec<StyledRange>> {
67        self.highlight_entry(self.registry.for_token(token), content)
68    }
69
70    fn highlight_entry(
71        &self,
72        entry: Option<&crate::syntax::registry::LangEntry>,
73        content: &str,
74    ) -> Vec<Vec<StyledRange>> {
75        let bounds = crate::syntax::line_bounds(content);
76        let mut out: Vec<Vec<StyledRange>> = vec![Vec::new(); bounds.len()];
77
78        if content.len() > crate::syntax::MAX_PARSE_BYTES {
79            return out;
80        }
81        let Some(entry) = entry else {
82            return out;
83        };
84        let Some(config) = entry.config.as_ref() else {
85            return out;
86        };
87
88        let mut ts = TsHighlighter::new();
89        let registry = &self.registry;
90        let Ok(events) = ts.highlight(config, content.as_bytes(), None, move |lang| {
91            registry.config_for_injection(lang)
92        }) else {
93            return out;
94        };
95
96        let starts: Vec<usize> = bounds.iter().map(|&(s, _)| s).collect();
97        let mut stack: Vec<usize> = Vec::new();
98        for event in events {
99            let Ok(event) = event else {
100                return out;
101            };
102            match event {
103                HighlightEvent::HighlightStart(h) => stack.push(h.0),
104                HighlightEvent::HighlightEnd => {
105                    stack.pop();
106                }
107                HighlightEvent::Source { start, end } => {
108                    if let Some(&name_idx) = stack.last()
109                        && let Some(name) = HIGHLIGHT_NAMES.get(name_idx)
110                        && let Some(style) = self.theme.style(name)
111                    {
112                        push_styled(&mut out, &bounds, &starts, &(start..end), &style);
113                    }
114                }
115            }
116        }
117
118        if entry.name == "markdown" {
119            for (range, name) in self.registry.markdown_inline_spans(content) {
120                if let Some(style) = self.theme.style(name) {
121                    push_styled(&mut out, &bounds, &starts, &range, &style);
122                }
123            }
124        }
125        out
126    }
127
128    /// Definition breadcrumb index for `content`, computed via the same grammar
129    /// registry used for highlighting. Empty for unsupported languages.
130    pub fn scope_index(&self, path: &str, content: &str) -> crate::syntax::ScopeIndex {
131        self.registry.scope_index(path, content)
132    }
133
134    /// Set AST-diff char-precise emphasis on `file`. Returns `false` (caller
135    /// should fall back to the textual engine) when unavailable.
136    pub fn syntactic_emphasis(&self, file: &mut crate::model::FileDiff) -> bool {
137        self.registry.syntactic_emphasis(file)
138    }
139}
140
141struct StyleSpec {
142    fg: (u8, u8, u8),
143    bold: bool,
144    italic: bool,
145}
146
147fn push_styled(
148    out: &mut [Vec<StyledRange>],
149    bounds: &[(usize, usize)],
150    starts: &[usize],
151    range: &Range<usize>,
152    style: &StyleSpec,
153) {
154    crate::syntax::split_range_by_line(bounds, starts, range, |li, r| {
155        if let Some(line) = out.get_mut(li) {
156            line.push(StyledRange {
157                range: r,
158                fg: style.fg,
159                bold: style.bold,
160                italic: style.italic,
161            });
162        }
163    });
164}
165
166/// Palette category and face for markdown `text.*` captures, reusing the general
167/// syntax colors (headings as functions, code spans as strings, links as
168/// properties) so every theme styles markdown with no extra color tables.
169fn markdown_face(name: &str) -> Option<(&'static str, bool, bool)> {
170    let face = match name {
171        "text.title" => ("function", true, false),
172        "text.strong" => ("variable", true, false),
173        "text.emphasis" => ("variable", false, true),
174        "text.literal" => ("string", false, false),
175        "text.uri" | "text.reference" => ("property", false, false),
176        _ => return None,
177    };
178    Some(face)
179}
180
181/// Fold a grammar's own capture category onto the palette's, so every theme
182/// styles it without carrying an entry for it. Applied before the face is
183/// chosen, so a folded comment stays italic like a native one.
184fn palette_category(category: &str) -> &str {
185    match category {
186        "boolean" => "constant",
187        "conditional" | "storageclass" => "keyword",
188        "field" => "property",
189        "parameter" => "variable",
190        // SQL tags comments `@comment @spell`, and the last capture wins
191        "spell" => "comment",
192        other => other,
193    }
194}
195
196impl SyntaxTheme {
197    /// Style for a tree-sitter capture name, matched by its leading category
198    /// (`function.method` -> `function`). `None` leaves the span at default fg.
199    fn style(self, name: &str) -> Option<StyleSpec> {
200        if let Some((category, bold, italic)) = markdown_face(name) {
201            return Some(StyleSpec {
202                fg: self.color(category)?,
203                bold,
204                italic,
205            });
206        }
207        let category = palette_category(name.split('.').next().unwrap_or(name));
208        let italic = category == "comment";
209        let fg = self.color(category)?;
210        Some(StyleSpec {
211            fg,
212            bold: false,
213            italic,
214        })
215    }
216
217    #[allow(clippy::too_many_lines)]
218    fn color(self, category: &str) -> Option<(u8, u8, u8)> {
219        let c = match self {
220            SyntaxTheme::OneHalfDark => match category {
221                "keyword" | "label" => (198, 120, 221),
222                "function" => (97, 175, 239),
223                "type" | "constructor" => (229, 192, 123),
224                "string" => (152, 195, 121),
225                // brighter than One Dark's default so comments stay legible on
226                // the added/removed diff backgrounds, not just the editor bg
227                "comment" => (126, 134, 145),
228                "constant" | "number" | "attribute" => (209, 154, 102),
229                "operator" | "escape" => (86, 182, 194),
230                "property" | "tag" => (224, 108, 117),
231                "variable" | "punctuation" => (171, 178, 191),
232                _ => return None,
233            },
234            SyntaxTheme::OneHalfLight => match category {
235                "keyword" | "label" => (166, 38, 164),
236                "function" => (64, 120, 242),
237                "type" | "constructor" => (193, 132, 1),
238                "string" => (80, 161, 79),
239                "comment" => (160, 161, 167),
240                "constant" | "number" | "attribute" => (152, 104, 1),
241                "operator" | "escape" => (1, 132, 188),
242                "property" | "tag" => (228, 86, 73),
243                "variable" | "punctuation" => (56, 58, 66),
244                _ => return None,
245            },
246            SyntaxTheme::Dracula => match category {
247                "keyword" | "label" | "tag" | "operator" => (255, 121, 198),
248                "function" | "property" => (80, 250, 123),
249                "type" | "constructor" => (139, 233, 253),
250                "string" => (241, 250, 140),
251                "comment" => (98, 114, 164),
252                "constant" | "number" => (189, 147, 249),
253                "escape" | "attribute" => (255, 184, 108),
254                "variable" | "punctuation" => (248, 248, 242),
255                _ => return None,
256            },
257            SyntaxTheme::CatppuccinMocha => match category {
258                "keyword" | "label" => (203, 166, 247),
259                "function" => (137, 180, 250),
260                "type" | "constructor" => (249, 226, 175),
261                "string" => (166, 227, 161),
262                "comment" => (127, 132, 156),
263                "constant" | "number" | "attribute" => (250, 179, 135),
264                "operator" | "escape" => (137, 220, 235),
265                "property" | "tag" => (243, 139, 168),
266                "variable" | "punctuation" => (205, 214, 244),
267                _ => return None,
268            },
269            SyntaxTheme::TokyoNight => match category {
270                "keyword" | "label" => (187, 154, 247),
271                "function" => (122, 162, 247),
272                "type" | "constructor" => (42, 195, 222),
273                "string" => (158, 206, 106),
274                "comment" => (99, 109, 150),
275                "constant" | "number" | "attribute" => (255, 158, 100),
276                "operator" | "escape" => (137, 221, 255),
277                "property" | "tag" => (247, 118, 142),
278                "variable" | "punctuation" => (192, 202, 245),
279                _ => return None,
280            },
281            SyntaxTheme::GruvboxDark => match category {
282                "keyword" | "label" => (251, 73, 52),
283                "function" => (184, 187, 38),
284                "type" | "constructor" => (250, 189, 47),
285                "string" => (142, 192, 124),
286                "comment" => (146, 131, 116),
287                "constant" | "number" => (211, 134, 155),
288                "operator" | "escape" | "attribute" => (254, 128, 25),
289                "property" | "tag" => (131, 165, 152),
290                "variable" | "punctuation" => (235, 219, 178),
291                _ => return None,
292            },
293            SyntaxTheme::Nord => match category {
294                "keyword" | "label" | "operator" | "escape" => (129, 161, 193),
295                "function" => (136, 192, 208),
296                "type" | "constructor" | "property" | "tag" => (143, 188, 187),
297                "string" => (163, 190, 140),
298                "comment" => (123, 136, 161),
299                "constant" | "number" | "attribute" => (180, 142, 173),
300                "variable" | "punctuation" => (216, 222, 233),
301                _ => return None,
302            },
303            SyntaxTheme::RosePine => match category {
304                "keyword" | "label" | "operator" | "escape" => (49, 116, 143),
305                "function" => (235, 188, 186),
306                "type" | "constructor" | "property" | "tag" => (156, 207, 216),
307                "string" => (246, 193, 119),
308                "comment" => (129, 124, 153),
309                "constant" | "number" | "attribute" => (196, 167, 231),
310                "variable" | "punctuation" => (224, 222, 244),
311                _ => return None,
312            },
313            SyntaxTheme::Kanagawa => match category {
314                "keyword" | "label" => (149, 127, 184),
315                "function" => (126, 156, 216),
316                "type" | "constructor" => (122, 168, 159),
317                "string" => (152, 187, 108),
318                "comment" => (144, 140, 128),
319                "constant" | "number" | "attribute" => (210, 126, 153),
320                "operator" | "escape" => (127, 180, 202),
321                "property" | "tag" => (106, 149, 137),
322                "variable" | "punctuation" => (220, 215, 186),
323                _ => return None,
324            },
325        };
326        Some(c)
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn python_keywords_get_distinct_color() {
336        let hl = Highlighter::default();
337        let lines = hl.highlight("a.py", "def f():\n    return 1\n");
338        assert_eq!(lines.len(), 2);
339        let colors: std::collections::HashSet<(u8, u8, u8)> =
340            lines[0].iter().map(|r| r.fg).collect();
341        assert!(colors.len() > 1, "expected multiple colors, got {colors:?}");
342    }
343
344    #[test]
345    fn yaml_is_highlighted() {
346        let hl = Highlighter::default();
347        let lines = hl.highlight("ci.yml", "name: CI\non: push\njobs:\n  lint: {}\n");
348        assert!(
349            lines.iter().any(|line| !line.is_empty()),
350            "expected styled ranges for a .yml file"
351        );
352    }
353
354    #[test]
355    fn sql_is_highlighted() {
356        let hl = Highlighter::default();
357        let lines = hl.highlight("q.sql", "SELECT id FROM users WHERE active = true;\n");
358        assert!(
359            lines.iter().any(|line| !line.is_empty()),
360            "expected styled ranges for a .sql file"
361        );
362    }
363
364    #[test]
365    fn sql_line_and_block_comments_style_alike() {
366        let hl = Highlighter::default();
367        let lines = hl.highlight("q.sql", "-- one\n/* two */\n");
368        let style = |line: &[StyledRange]| line.first().map(|r| (r.fg, r.italic));
369        assert_eq!(
370            style(&lines[0]),
371            style(&lines[1]),
372            "a -- comment styles like a block comment"
373        );
374        assert!(style(&lines[0]).is_some(), "comments are styled at all");
375    }
376
377    #[test]
378    fn sql_numbers_are_not_styled_as_strings() {
379        let hl = Highlighter::default();
380        let lines = hl.highlight("q.sql", "SELECT 42, 1.5, 'txt';\n");
381        let colors: Vec<(u8, u8, u8)> = lines[0].iter().map(|r| r.fg).collect();
382        let string_fg = (152, 195, 121);
383        let number_fg = (209, 154, 102);
384        assert!(colors.contains(&number_fg), "numbers get the number color");
385        assert_eq!(
386            colors.iter().filter(|c| **c == string_fg).count(),
387            1,
388            "only the quoted literal is a string: {colors:?}"
389        );
390    }
391
392    #[test]
393    fn ranges_cover_within_line_bounds() {
394        let hl = Highlighter::default();
395        let src = "fn main() { let x = \"hi\"; }\n";
396        let lines = hl.highlight("a.rs", src);
397        let visible = src.trim_end();
398        for r in &lines[0] {
399            assert!(r.range.end <= visible.len());
400            assert!(r.range.start < r.range.end);
401        }
402    }
403
404    #[test]
405    fn multiline_string_state_carries_across_lines() {
406        let hl = Highlighter::default();
407        let src = "s = \"\"\"first\nsecond\nthird\"\"\"\nx = 1\n";
408        let lines = hl.highlight("a.py", src);
409        let string_color = lines[0].iter().last().map(|r| r.fg).expect("line 0 styled");
410        assert!(
411            lines[1].iter().all(|r| r.fg == string_color),
412            "inside-string line must keep string color"
413        );
414    }
415
416    #[test]
417    fn markdown_highlights_headings_and_inline_code() {
418        let hl = Highlighter::default();
419        let src = "# Title\n\nSome `code` and **bold** text.\n";
420        let lines = hl.highlight("readme.md", src);
421        assert!(!lines[0].is_empty(), "heading line should be styled");
422        // `code` is styled by the by-hand inline pass over the block (inline) node
423        assert!(
424            lines[2].iter().any(|r| r.fg == (152, 195, 121)),
425            "inline `code` should get the string color"
426        );
427    }
428
429    #[test]
430    fn markdown_inline_code_offset_is_absolute_not_range_relative() {
431        // inline content starts well past byte 0 (after a heading and blank
432        // lines); the code span must still land on its own line
433        let hl = Highlighter::default();
434        let src = "# A longer heading here\n\nintro line\n\nthen `code` appears.\n";
435        let lines = hl.highlight("readme.md", src);
436        let code_line = "then `code` appears.";
437        let styled: Vec<_> = lines[4]
438            .iter()
439            .filter(|r| r.fg == (152, 195, 121))
440            .collect();
441        assert!(!styled.is_empty(), "code span should be styled on line 4");
442        for r in styled {
443            assert!(
444                r.range.end <= code_line.len(),
445                "range {:?} escapes the line (offsets not absolute)",
446                r.range
447            );
448            assert_eq!(&code_line[r.range.clone()], "`code`");
449        }
450    }
451
452    #[test]
453    fn markdown_fenced_code_block_gets_language_highlight() {
454        let hl = Highlighter::default();
455        let src = "text\n\n```rust\nfn f() {}\n```\n";
456        let lines = hl.highlight("readme.md", src);
457        // `fn` keyword inside the fence is highlighted by the injected rust grammar
458        assert!(
459            lines[3].iter().any(|r| r.fg == (198, 120, 221)),
460            "fenced rust `fn` should get the keyword color"
461        );
462    }
463
464    #[test]
465    fn markdown_fence_tag_resolves_by_extension() {
466        // an `rs` fence tag is a file extension, not a grammar name; it resolves
467        // to rust through the extension table
468        let hl = Highlighter::default();
469        let src = "text\n\n```rs\nfn f() {}\n```\n";
470        let lines = hl.highlight("readme.md", src);
471        assert!(
472            lines[3].iter().any(|r| r.fg == (198, 120, 221)),
473            "an `rs` fence should resolve to rust via by_ext"
474        );
475    }
476
477    #[test]
478    fn unknown_extension_yields_plain_lines() {
479        let hl = Highlighter::default();
480        let lines = hl.highlight("file.zzz-unknown", "a\nb\n");
481        assert_eq!(lines, vec![Vec::new(), Vec::new()]);
482    }
483
484    #[test]
485    fn syntax_theme_changes_the_foreground_palette() {
486        let src = "fn main() { let x = 1; }\n";
487        let dark = Highlighter::new(SyntaxTheme::OneHalfDark).highlight("a.rs", src);
488        let light = Highlighter::new(SyntaxTheme::OneHalfLight).highlight("a.rs", src);
489        assert_ne!(dark, light, "a different syntax theme recolors the line");
490    }
491}