1use 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 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 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 pub fn highlight(&self, path: &str, content: &str) -> Vec<Vec<StyledRange>> {
74 self.highlight_entry(self.language(path, content), content)
75 }
76
77 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 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
180fn 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
194fn palette_category(category: &str) -> &str {
197 match category {
198 "boolean" => "constant",
199 "conditional" | "storageclass" => "keyword",
200 "field" => "property",
201 "parameter" => "variable",
202 "spell" => "comment",
204 other => other,
205 }
206}
207
208impl SyntaxTheme {
209 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 "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 #[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}