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