Skip to main content

diffler_core/
highlight.rs

1//! Tree-sitter highlighting sliced into per-line styled ranges. We highlight
2//! whole files so multi-line strings stay correct across hunk boundaries.
3
4use std::ops::Range;
5
6use tree_sitter_highlight::{HighlightEvent, Highlighter as TsHighlighter};
7
8use crate::syntax::registry::LangEntry;
9use crate::syntax::{HIGHLIGHT_NAMES, LanguageRegistry};
10
11pub struct Highlighter {
12    registry: &'static LanguageRegistry,
13    theme: SyntaxTheme,
14    /// The reader's `glob = language` rules, tried in order before the
15    /// registry's own inference.
16    rules: Vec<(String, String)>,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
20pub enum SyntaxTheme {
21    #[default]
22    OneHalfDark,
23    OneHalfLight,
24    Dracula,
25    CatppuccinMocha,
26    TokyoNight,
27    GruvboxDark,
28    Nord,
29    RosePine,
30    Kanagawa,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct StyledRange {
35    pub range: Range<usize>,
36    pub fg: (u8, u8, u8),
37    pub bold: bool,
38    pub italic: bool,
39}
40
41impl Default for Highlighter {
42    fn default() -> Self {
43        Self::new(SyntaxTheme::default())
44    }
45}
46
47impl Highlighter {
48    pub fn new(syntax: SyntaxTheme) -> Self {
49        Self {
50            registry: &crate::syntax::registry::REGISTRY,
51            theme: syntax,
52            rules: Vec::new(),
53        }
54    }
55
56    #[must_use]
57    pub fn with_rules(mut self, rules: Vec<(String, String)>) -> Self {
58        self.rules = rules;
59        self
60    }
61
62    /// The first matching rule that names a bundled grammar, else the
63    /// registry's inference.
64    pub fn language(&self, path: &str, content: &str) -> Option<&'static LangEntry> {
65        self.rules
66            .iter()
67            .filter(|(glob, _)| crate::classify::glob_match(glob, path))
68            .find_map(|(_, name)| self.registry.by_name(name))
69            .or_else(|| self.registry.for_file(path, content))
70    }
71
72    /// One `Vec<StyledRange>` per line, empty for an unknown language.
73    pub fn highlight(&self, path: &str, content: &str) -> Vec<Vec<StyledRange>> {
74        self.highlight_entry(self.language(path, content), content)
75    }
76
77    /// Highlight `content` as a markdown fence token.
78    pub fn highlight_lang(&self, token: &str, content: &str) -> Vec<Vec<StyledRange>> {
79        self.highlight_entry(self.registry.for_token(token), content)
80    }
81
82    fn highlight_entry(
83        &self,
84        entry: Option<&crate::syntax::registry::LangEntry>,
85        content: &str,
86    ) -> Vec<Vec<StyledRange>> {
87        let bounds = crate::syntax::line_bounds(content);
88        let mut out: Vec<Vec<StyledRange>> = vec![Vec::new(); bounds.len()];
89
90        if content.len() > crate::syntax::MAX_PARSE_BYTES {
91            return out;
92        }
93        let Some(entry) = entry else {
94            return out;
95        };
96        let Some(config) = entry.config() else {
97            return out;
98        };
99
100        let mut ts = TsHighlighter::new();
101        let registry = self.registry;
102        let Ok(events) = ts.highlight(config, content.as_bytes(), None, move |lang| {
103            registry.config_for_injection(lang)
104        }) else {
105            return out;
106        };
107
108        let starts: Vec<usize> = bounds.iter().map(|&(s, _)| s).collect();
109        let mut stack: Vec<usize> = Vec::new();
110        for event in events {
111            let Ok(event) = event else {
112                return out;
113            };
114            match event {
115                HighlightEvent::HighlightStart(h) => stack.push(h.0),
116                HighlightEvent::HighlightEnd => {
117                    stack.pop();
118                }
119                HighlightEvent::Source { start, end } => {
120                    if let Some(&name_idx) = stack.last()
121                        && let Some(name) = HIGHLIGHT_NAMES.get(name_idx)
122                        && let Some(style) = self.theme.style(name)
123                    {
124                        push_styled(&mut out, &bounds, &starts, &(start..end), &style);
125                    }
126                }
127            }
128        }
129
130        if entry.name == "markdown" {
131            for (range, name) in self.registry.markdown_inline_spans(content) {
132                if let Some(style) = self.theme.style(name) {
133                    push_styled(&mut out, &bounds, &starts, &range, &style);
134                }
135            }
136        }
137        out
138    }
139
140    pub fn scope_index(&self, path: &str, content: &str) -> crate::syntax::ScopeIndex {
141        LanguageRegistry::scope_index_as(self.language(path, content), content)
142    }
143
144    /// See [`LanguageRegistry::syntactic_emphasis`].
145    pub fn syntactic_emphasis(
146        &self,
147        file: &mut crate::model::FileDiff,
148        mark_reformat_only: bool,
149    ) -> bool {
150        let entry = self.language(&file.path, file.new_text.as_deref().unwrap_or_default());
151        LanguageRegistry::syntactic_emphasis(entry, file, mark_reformat_only)
152    }
153}
154
155struct StyleSpec {
156    fg: (u8, u8, u8),
157    bold: bool,
158    italic: bool,
159}
160
161fn push_styled(
162    out: &mut [Vec<StyledRange>],
163    bounds: &[(usize, usize)],
164    starts: &[usize],
165    range: &Range<usize>,
166    style: &StyleSpec,
167) {
168    crate::syntax::split_range_by_line(bounds, starts, range, |li, r| {
169        if let Some(line) = out.get_mut(li) {
170            line.push(StyledRange {
171                range: r,
172                fg: style.fg,
173                bold: style.bold,
174                italic: style.italic,
175            });
176        }
177    });
178}
179
180/// Palette category and face for markdown `text.*` captures, so themes need
181/// no markdown colors of their own.
182fn markdown_face(name: &str) -> Option<(&'static str, bool, bool)> {
183    let face = match name {
184        "text.title" => ("function", true, false),
185        "text.strong" => ("variable", true, false),
186        "text.emphasis" => ("variable", false, true),
187        "text.literal" => ("string", false, false),
188        "text.uri" | "text.reference" => ("property", false, false),
189        _ => return None,
190    };
191    Some(face)
192}
193
194/// Fold a grammar's own capture category onto the palette's. We apply it
195/// before picking the face so a folded comment stays italic.
196fn palette_category(category: &str) -> &str {
197    match category {
198        "boolean" => "constant",
199        "conditional" | "storageclass" => "keyword",
200        "field" => "property",
201        "parameter" => "variable",
202        // SQL tags comments `@comment @spell`, and the last capture wins
203        "spell" => "comment",
204        other => other,
205    }
206}
207
208impl SyntaxTheme {
209    /// Matched by the capture's leading category. `None` leaves the default fg.
210    fn style(self, name: &str) -> Option<StyleSpec> {
211        if let Some((category, bold, italic)) = markdown_face(name) {
212            return Some(StyleSpec {
213                fg: self.color(category)?,
214                bold,
215                italic,
216            });
217        }
218        let category = palette_category(name.split('.').next().unwrap_or(name));
219        let italic = category == "comment";
220        let fg = self.color(category)?;
221        Some(StyleSpec {
222            fg,
223            bold: false,
224            italic,
225        })
226    }
227
228    #[allow(clippy::too_many_lines)]
229    fn color(self, category: &str) -> Option<(u8, u8, u8)> {
230        let c = match self {
231            SyntaxTheme::OneHalfDark => match category {
232                "keyword" | "label" => (198, 120, 221),
233                "function" => (97, 175, 239),
234                "type" | "constructor" => (229, 192, 123),
235                "string" => (152, 195, 121),
236                // brighter than One Dark's default so comments stay legible on
237                // the diff backgrounds
238                "comment" => (126, 134, 145),
239                "constant" | "number" | "attribute" => (209, 154, 102),
240                "operator" | "escape" => (86, 182, 194),
241                "property" | "tag" => (224, 108, 117),
242                "variable" | "punctuation" => (171, 178, 191),
243                _ => return None,
244            },
245            SyntaxTheme::OneHalfLight => match category {
246                "keyword" | "label" => (166, 38, 164),
247                "function" => (64, 120, 242),
248                "type" | "constructor" => (193, 132, 1),
249                "string" => (80, 161, 79),
250                "comment" => (160, 161, 167),
251                "constant" | "number" | "attribute" => (152, 104, 1),
252                "operator" | "escape" => (1, 132, 188),
253                "property" | "tag" => (228, 86, 73),
254                "variable" | "punctuation" => (56, 58, 66),
255                _ => return None,
256            },
257            SyntaxTheme::Dracula => match category {
258                "keyword" | "label" | "tag" | "operator" => (255, 121, 198),
259                "function" | "property" => (80, 250, 123),
260                "type" | "constructor" => (139, 233, 253),
261                "string" => (241, 250, 140),
262                "comment" => (98, 114, 164),
263                "constant" | "number" => (189, 147, 249),
264                "escape" | "attribute" => (255, 184, 108),
265                "variable" | "punctuation" => (248, 248, 242),
266                _ => return None,
267            },
268            SyntaxTheme::CatppuccinMocha => match category {
269                "keyword" | "label" => (203, 166, 247),
270                "function" => (137, 180, 250),
271                "type" | "constructor" => (249, 226, 175),
272                "string" => (166, 227, 161),
273                "comment" => (127, 132, 156),
274                "constant" | "number" | "attribute" => (250, 179, 135),
275                "operator" | "escape" => (137, 220, 235),
276                "property" | "tag" => (243, 139, 168),
277                "variable" | "punctuation" => (205, 214, 244),
278                _ => return None,
279            },
280            SyntaxTheme::TokyoNight => match category {
281                "keyword" | "label" => (187, 154, 247),
282                "function" => (122, 162, 247),
283                "type" | "constructor" => (42, 195, 222),
284                "string" => (158, 206, 106),
285                "comment" => (99, 109, 150),
286                "constant" | "number" | "attribute" => (255, 158, 100),
287                "operator" | "escape" => (137, 221, 255),
288                "property" | "tag" => (247, 118, 142),
289                "variable" | "punctuation" => (192, 202, 245),
290                _ => return None,
291            },
292            SyntaxTheme::GruvboxDark => match category {
293                "keyword" | "label" => (251, 73, 52),
294                "function" => (184, 187, 38),
295                "type" | "constructor" => (250, 189, 47),
296                "string" => (142, 192, 124),
297                "comment" => (146, 131, 116),
298                "constant" | "number" => (211, 134, 155),
299                "operator" | "escape" | "attribute" => (254, 128, 25),
300                "property" | "tag" => (131, 165, 152),
301                "variable" | "punctuation" => (235, 219, 178),
302                _ => return None,
303            },
304            SyntaxTheme::Nord => match category {
305                "keyword" | "label" | "operator" | "escape" => (129, 161, 193),
306                "function" => (136, 192, 208),
307                "type" | "constructor" | "property" | "tag" => (143, 188, 187),
308                "string" => (163, 190, 140),
309                "comment" => (123, 136, 161),
310                "constant" | "number" | "attribute" => (180, 142, 173),
311                "variable" | "punctuation" => (216, 222, 233),
312                _ => return None,
313            },
314            SyntaxTheme::RosePine => match category {
315                "keyword" | "label" | "operator" | "escape" => (49, 116, 143),
316                "function" => (235, 188, 186),
317                "type" | "constructor" | "property" | "tag" => (156, 207, 216),
318                "string" => (246, 193, 119),
319                "comment" => (129, 124, 153),
320                "constant" | "number" | "attribute" => (196, 167, 231),
321                "variable" | "punctuation" => (224, 222, 244),
322                _ => return None,
323            },
324            SyntaxTheme::Kanagawa => match category {
325                "keyword" | "label" => (149, 127, 184),
326                "function" => (126, 156, 216),
327                "type" | "constructor" => (122, 168, 159),
328                "string" => (152, 187, 108),
329                "comment" => (144, 140, 128),
330                "constant" | "number" | "attribute" => (210, 126, 153),
331                "operator" | "escape" => (127, 180, 202),
332                "property" | "tag" => (106, 149, 137),
333                "variable" | "punctuation" => (220, 215, 186),
334                _ => return None,
335            },
336        };
337        Some(c)
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn the_readers_rules_outrank_what_the_registry_infers() {
347        let rules = |rules: &[(&str, &str)]| {
348            Highlighter::default().with_rules(
349                rules
350                    .iter()
351                    .map(|&(glob, lang)| (glob.to_owned(), lang.to_owned()))
352                    .collect(),
353            )
354        };
355        let name = |hl: &Highlighter, path| hl.language(path, "").map(|entry| entry.name);
356        let hl = rules(&[("/main.rs", "toml"), ("*.tpl", "nope"), ("*.tpl", "yaml")]);
357        assert_eq!(name(&hl, "main.rs"), Some("toml"));
358        assert_eq!(
359            name(&hl, "src/main.rs"),
360            Some("rust"),
361            "a leading / anchors"
362        );
363        assert_eq!(
364            name(&hl, "chart/values.tpl"),
365            Some("yaml"),
366            "an unknown name is skipped"
367        );
368    }
369
370    /// A grammar crate with a broken highlight query links fine and renders
371    /// plain, so we check each one colours a sample.
372    #[test]
373    fn every_language_colours_a_sample() {
374        let samples: &[(&str, &str)] = &[
375            ("a.rs", "fn main() { let x = 1; }\n"),
376            ("a.py", "def f():\n    return 1\n"),
377            ("a.js", "const x = 1;\n"),
378            ("a.ts", "const x: number = 1;\n"),
379            ("a.tsx", "const A = () => <div />;\n"),
380            ("a.go", "package main\nfunc main() {}\n"),
381            ("a.c", "int main(void) { return 0; }\n"),
382            ("a.cpp", "int main() { return 0; }\n"),
383            ("A.java", "class A { void f() {} }\n"),
384            ("a.cs", "class A { void F() {} }\n"),
385            ("a.rb", "def f\n  1\nend\n"),
386            ("a.php", "<?php function f() { return 1; }\n"),
387            ("a.sh", "set -e\necho hi\n"),
388            ("a.json", "{\"a\": 1}\n"),
389            ("a.html", "<p>hi</p>\n"),
390            ("a.css", "a { color: red; }\n"),
391            ("a.yml", "name: CI\non: push\n"),
392            ("a.sql", "SELECT id FROM users;\n"),
393            ("a.md", "# Title\n\ntext\n"),
394            ("a.toml", "[package]\nname = \"x\"\n"),
395            (
396                "main.tf",
397                "resource \"aws_s3_bucket\" \"b\" {\n  bucket = var.name\n}\n",
398            ),
399            ("Dockerfile", "FROM alpine:3\nRUN echo hi\n"),
400            ("a.mk", "all:\n\techo hi\n"),
401            ("a.lua", "local function f() return 1 end\n"),
402            ("a.nix", "{ pkgs }: pkgs.hello\n"),
403            ("a.xml", "<root><a b=\"c\"/></root>\n"),
404            ("a.swift", "func f() -> Int { return 1 }\n"),
405            ("a.scala", "object A { def f = 1 }\n"),
406            ("a.ex", "defmodule A do\n  def f, do: 1\nend\n"),
407            ("a.zig", "pub fn main() void {}\n"),
408            ("a.hs", "main :: IO ()\nmain = return ()\n"),
409            ("a.dart", "void main() { var x = 1; }\n"),
410            ("a.ps1", "function Get-Thing { param($x) $x }\n"),
411            ("a.svelte", "<script>let x = 1;</script>\n<p>{x}</p>\n"),
412        ];
413        let hl = Highlighter::default();
414        let plain: Vec<&str> = samples
415            .iter()
416            .filter(|(path, content)| hl.highlight(path, content).iter().all(Vec::is_empty))
417            .map(|(path, _)| *path)
418            .collect();
419        assert!(plain.is_empty(), "rendered plain: {plain:?}");
420    }
421
422    #[test]
423    fn a_build_tool_file_resolves_by_its_name() {
424        let hl = Highlighter::default();
425        for path in ["Makefile", "GNUmakefile", "Dockerfile", "Containerfile"] {
426            let lines = hl.highlight(path, "FROM alpine\nall:\n\techo hi\n");
427            assert!(
428                lines.iter().any(|line| !line.is_empty()),
429                "{path} rendered plain"
430            );
431        }
432    }
433
434    #[test]
435    fn terraform_fences_and_extensions_reach_the_hcl_grammar() {
436        let hl = Highlighter::default();
437        let source = "variable \"name\" {\n  type = string\n}\n";
438        for path in ["main.tf", "vars.tfvars", "config.hcl"] {
439            assert!(
440                hl.highlight(path, source)
441                    .iter()
442                    .any(|line| !line.is_empty()),
443                "{path} rendered plain"
444            );
445        }
446        for token in ["terraform", "hcl", "tf"] {
447            assert!(
448                hl.highlight_lang(token, source)
449                    .iter()
450                    .any(|line| !line.is_empty()),
451                "fence {token} rendered plain"
452            );
453        }
454    }
455
456    #[test]
457    fn python_keywords_get_distinct_color() {
458        let hl = Highlighter::default();
459        let lines = hl.highlight("a.py", "def f():\n    return 1\n");
460        assert_eq!(lines.len(), 2);
461        let colors: std::collections::HashSet<(u8, u8, u8)> =
462            lines[0].iter().map(|r| r.fg).collect();
463        assert!(colors.len() > 1, "expected multiple colors, got {colors:?}");
464    }
465
466    #[test]
467    fn yaml_is_highlighted() {
468        let hl = Highlighter::default();
469        let lines = hl.highlight("ci.yml", "name: CI\non: push\njobs:\n  lint: {}\n");
470        assert!(
471            lines.iter().any(|line| !line.is_empty()),
472            "expected styled ranges for a .yml file"
473        );
474    }
475
476    #[test]
477    fn sql_is_highlighted() {
478        let hl = Highlighter::default();
479        let lines = hl.highlight("q.sql", "SELECT id FROM users WHERE active = true;\n");
480        assert!(
481            lines.iter().any(|line| !line.is_empty()),
482            "expected styled ranges for a .sql file"
483        );
484    }
485
486    #[test]
487    fn sql_line_and_block_comments_style_alike() {
488        let hl = Highlighter::default();
489        let lines = hl.highlight("q.sql", "-- one\n/* two */\n");
490        let style = |line: &[StyledRange]| line.first().map(|r| (r.fg, r.italic));
491        assert_eq!(
492            style(&lines[0]),
493            style(&lines[1]),
494            "a -- comment styles like a block comment"
495        );
496        assert!(style(&lines[0]).is_some(), "comments are styled at all");
497    }
498
499    #[test]
500    fn sql_numbers_are_not_styled_as_strings() {
501        let hl = Highlighter::default();
502        let lines = hl.highlight("q.sql", "SELECT 42, 1.5, 'txt';\n");
503        let colors: Vec<(u8, u8, u8)> = lines[0].iter().map(|r| r.fg).collect();
504        let string_fg = (152, 195, 121);
505        let number_fg = (209, 154, 102);
506        assert!(colors.contains(&number_fg), "numbers get the number color");
507        assert_eq!(
508            colors.iter().filter(|c| **c == string_fg).count(),
509            1,
510            "only the quoted literal is a string: {colors:?}"
511        );
512    }
513
514    #[test]
515    fn ranges_cover_within_line_bounds() {
516        let hl = Highlighter::default();
517        let src = "fn main() { let x = \"hi\"; }\n";
518        let lines = hl.highlight("a.rs", src);
519        let visible = src.trim_end();
520        for r in &lines[0] {
521            assert!(r.range.end <= visible.len());
522            assert!(r.range.start < r.range.end);
523        }
524    }
525
526    #[test]
527    fn multiline_string_state_carries_across_lines() {
528        let hl = Highlighter::default();
529        let src = "s = \"\"\"first\nsecond\nthird\"\"\"\nx = 1\n";
530        let lines = hl.highlight("a.py", src);
531        let string_color = lines[0].iter().last().map(|r| r.fg).expect("line 0 styled");
532        assert!(
533            lines[1].iter().all(|r| r.fg == string_color),
534            "inside-string line must keep string color"
535        );
536    }
537
538    #[test]
539    fn markdown_highlights_headings_and_inline_code() {
540        let hl = Highlighter::default();
541        let src = "# Title\n\nSome `code` and **bold** text.\n";
542        let lines = hl.highlight("readme.md", src);
543        assert!(!lines[0].is_empty(), "heading line should be styled");
544        assert!(
545            lines[2].iter().any(|r| r.fg == (152, 195, 121)),
546            "inline `code` should get the string color"
547        );
548    }
549
550    #[test]
551    fn markdown_inline_code_offset_is_absolute_not_range_relative() {
552        let hl = Highlighter::default();
553        let src = "# A longer heading here\n\nintro line\n\nthen `code` appears.\n";
554        let lines = hl.highlight("readme.md", src);
555        let code_line = "then `code` appears.";
556        let styled: Vec<_> = lines[4]
557            .iter()
558            .filter(|r| r.fg == (152, 195, 121))
559            .collect();
560        assert!(!styled.is_empty(), "code span should be styled on line 4");
561        for r in styled {
562            assert!(
563                r.range.end <= code_line.len(),
564                "range {:?} escapes the line (offsets not absolute)",
565                r.range
566            );
567            assert_eq!(&code_line[r.range.clone()], "`code`");
568        }
569    }
570
571    #[test]
572    fn markdown_fenced_code_block_gets_language_highlight() {
573        let hl = Highlighter::default();
574        let src = "text\n\n```rust\nfn f() {}\n```\n";
575        let lines = hl.highlight("readme.md", src);
576        assert!(
577            lines[3].iter().any(|r| r.fg == (198, 120, 221)),
578            "fenced rust `fn` should get the keyword color"
579        );
580    }
581
582    #[test]
583    fn markdown_fence_tag_resolves_by_extension() {
584        let hl = Highlighter::default();
585        let src = "text\n\n```rs\nfn f() {}\n```\n";
586        let lines = hl.highlight("readme.md", src);
587        assert!(
588            lines[3].iter().any(|r| r.fg == (198, 120, 221)),
589            "an `rs` fence should resolve to rust via by_ext"
590        );
591    }
592
593    #[test]
594    fn unknown_extension_yields_plain_lines() {
595        let hl = Highlighter::default();
596        let lines = hl.highlight("file.zzz-unknown", "a\nb\n");
597        assert_eq!(lines, vec![Vec::new(), Vec::new()]);
598    }
599
600    #[test]
601    fn syntax_theme_changes_the_foreground_palette() {
602        let src = "fn main() { let x = 1; }\n";
603        let dark = Highlighter::new(SyntaxTheme::OneHalfDark).highlight("a.rs", src);
604        let light = Highlighter::new(SyntaxTheme::OneHalfLight).highlight("a.rs", src);
605        assert_ne!(dark, light, "a different syntax theme recolors the line");
606    }
607}