1use 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: &'static LanguageRegistry,
14 theme: SyntaxTheme,
15}
16
17#[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#[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 pub fn new(syntax: SyntaxTheme) -> Self {
52 Self {
53 registry: &crate::syntax::registry::REGISTRY,
54 theme: syntax,
55 }
56 }
57
58 pub fn highlight(&self, path: &str, content: &str) -> Vec<Vec<StyledRange>> {
62 self.highlight_entry(self.registry.for_path(path), content)
63 }
64
65 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() 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 pub fn scope_index(&self, path: &str, content: &str) -> crate::syntax::ScopeIndex {
131 self.registry.scope_index(path, content)
132 }
133
134 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
166fn 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
181fn palette_category(category: &str) -> &str {
185 match category {
186 "boolean" => "constant",
187 "conditional" | "storageclass" => "keyword",
188 "field" => "property",
189 "parameter" => "variable",
190 "spell" => "comment",
192 other => other,
193 }
194}
195
196impl SyntaxTheme {
197 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 "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]
338 fn every_language_colours_a_sample() {
339 let samples: &[(&str, &str)] = &[
340 ("a.rs", "fn main() { let x = 1; }\n"),
341 ("a.py", "def f():\n return 1\n"),
342 ("a.js", "const x = 1;\n"),
343 ("a.ts", "const x: number = 1;\n"),
344 ("a.tsx", "const A = () => <div />;\n"),
345 ("a.go", "package main\nfunc main() {}\n"),
346 ("a.c", "int main(void) { return 0; }\n"),
347 ("a.cpp", "int main() { return 0; }\n"),
348 ("A.java", "class A { void f() {} }\n"),
349 ("a.cs", "class A { void F() {} }\n"),
350 ("a.rb", "def f\n 1\nend\n"),
351 ("a.php", "<?php function f() { return 1; }\n"),
352 ("a.sh", "set -e\necho hi\n"),
353 ("a.json", "{\"a\": 1}\n"),
354 ("a.html", "<p>hi</p>\n"),
355 ("a.css", "a { color: red; }\n"),
356 ("a.yml", "name: CI\non: push\n"),
357 ("a.sql", "SELECT id FROM users;\n"),
358 ("a.md", "# Title\n\ntext\n"),
359 ("a.toml", "[package]\nname = \"x\"\n"),
360 (
361 "main.tf",
362 "resource \"aws_s3_bucket\" \"b\" {\n bucket = var.name\n}\n",
363 ),
364 ("Dockerfile", "FROM alpine:3\nRUN echo hi\n"),
365 ("a.mk", "all:\n\techo hi\n"),
366 ("a.lua", "local function f() return 1 end\n"),
367 ("a.nix", "{ pkgs }: pkgs.hello\n"),
368 ("a.xml", "<root><a b=\"c\"/></root>\n"),
369 ("a.swift", "func f() -> Int { return 1 }\n"),
370 ("a.scala", "object A { def f = 1 }\n"),
371 ("a.ex", "defmodule A do\n def f, do: 1\nend\n"),
372 ("a.zig", "pub fn main() void {}\n"),
373 ("a.hs", "main :: IO ()\nmain = return ()\n"),
374 ("a.dart", "void main() { var x = 1; }\n"),
375 ("a.ps1", "function Get-Thing { param($x) $x }\n"),
376 ("a.svelte", "<script>let x = 1;</script>\n<p>{x}</p>\n"),
377 ];
378 let hl = Highlighter::default();
379 let plain: Vec<&str> = samples
380 .iter()
381 .filter(|(path, content)| hl.highlight(path, content).iter().all(Vec::is_empty))
382 .map(|(path, _)| *path)
383 .collect();
384 assert!(plain.is_empty(), "rendered plain: {plain:?}");
385 }
386
387 #[test]
388 fn a_build_tool_file_resolves_by_its_name() {
389 let hl = Highlighter::default();
390 for path in ["Makefile", "GNUmakefile", "Dockerfile", "Containerfile"] {
391 let lines = hl.highlight(path, "FROM alpine\nall:\n\techo hi\n");
392 assert!(
393 lines.iter().any(|line| !line.is_empty()),
394 "{path} rendered plain"
395 );
396 }
397 }
398
399 #[test]
400 fn terraform_fences_and_extensions_reach_the_hcl_grammar() {
401 let hl = Highlighter::default();
402 let source = "variable \"name\" {\n type = string\n}\n";
403 for path in ["main.tf", "vars.tfvars", "config.hcl"] {
404 assert!(
405 hl.highlight(path, source)
406 .iter()
407 .any(|line| !line.is_empty()),
408 "{path} rendered plain"
409 );
410 }
411 for token in ["terraform", "hcl", "tf"] {
412 assert!(
413 hl.highlight_lang(token, source)
414 .iter()
415 .any(|line| !line.is_empty()),
416 "fence {token} rendered plain"
417 );
418 }
419 }
420
421 #[test]
422 fn python_keywords_get_distinct_color() {
423 let hl = Highlighter::default();
424 let lines = hl.highlight("a.py", "def f():\n return 1\n");
425 assert_eq!(lines.len(), 2);
426 let colors: std::collections::HashSet<(u8, u8, u8)> =
427 lines[0].iter().map(|r| r.fg).collect();
428 assert!(colors.len() > 1, "expected multiple colors, got {colors:?}");
429 }
430
431 #[test]
432 fn yaml_is_highlighted() {
433 let hl = Highlighter::default();
434 let lines = hl.highlight("ci.yml", "name: CI\non: push\njobs:\n lint: {}\n");
435 assert!(
436 lines.iter().any(|line| !line.is_empty()),
437 "expected styled ranges for a .yml file"
438 );
439 }
440
441 #[test]
442 fn sql_is_highlighted() {
443 let hl = Highlighter::default();
444 let lines = hl.highlight("q.sql", "SELECT id FROM users WHERE active = true;\n");
445 assert!(
446 lines.iter().any(|line| !line.is_empty()),
447 "expected styled ranges for a .sql file"
448 );
449 }
450
451 #[test]
452 fn sql_line_and_block_comments_style_alike() {
453 let hl = Highlighter::default();
454 let lines = hl.highlight("q.sql", "-- one\n/* two */\n");
455 let style = |line: &[StyledRange]| line.first().map(|r| (r.fg, r.italic));
456 assert_eq!(
457 style(&lines[0]),
458 style(&lines[1]),
459 "a -- comment styles like a block comment"
460 );
461 assert!(style(&lines[0]).is_some(), "comments are styled at all");
462 }
463
464 #[test]
465 fn sql_numbers_are_not_styled_as_strings() {
466 let hl = Highlighter::default();
467 let lines = hl.highlight("q.sql", "SELECT 42, 1.5, 'txt';\n");
468 let colors: Vec<(u8, u8, u8)> = lines[0].iter().map(|r| r.fg).collect();
469 let string_fg = (152, 195, 121);
470 let number_fg = (209, 154, 102);
471 assert!(colors.contains(&number_fg), "numbers get the number color");
472 assert_eq!(
473 colors.iter().filter(|c| **c == string_fg).count(),
474 1,
475 "only the quoted literal is a string: {colors:?}"
476 );
477 }
478
479 #[test]
480 fn ranges_cover_within_line_bounds() {
481 let hl = Highlighter::default();
482 let src = "fn main() { let x = \"hi\"; }\n";
483 let lines = hl.highlight("a.rs", src);
484 let visible = src.trim_end();
485 for r in &lines[0] {
486 assert!(r.range.end <= visible.len());
487 assert!(r.range.start < r.range.end);
488 }
489 }
490
491 #[test]
492 fn multiline_string_state_carries_across_lines() {
493 let hl = Highlighter::default();
494 let src = "s = \"\"\"first\nsecond\nthird\"\"\"\nx = 1\n";
495 let lines = hl.highlight("a.py", src);
496 let string_color = lines[0].iter().last().map(|r| r.fg).expect("line 0 styled");
497 assert!(
498 lines[1].iter().all(|r| r.fg == string_color),
499 "inside-string line must keep string color"
500 );
501 }
502
503 #[test]
504 fn markdown_highlights_headings_and_inline_code() {
505 let hl = Highlighter::default();
506 let src = "# Title\n\nSome `code` and **bold** text.\n";
507 let lines = hl.highlight("readme.md", src);
508 assert!(!lines[0].is_empty(), "heading line should be styled");
509 assert!(
511 lines[2].iter().any(|r| r.fg == (152, 195, 121)),
512 "inline `code` should get the string color"
513 );
514 }
515
516 #[test]
517 fn markdown_inline_code_offset_is_absolute_not_range_relative() {
518 let hl = Highlighter::default();
521 let src = "# A longer heading here\n\nintro line\n\nthen `code` appears.\n";
522 let lines = hl.highlight("readme.md", src);
523 let code_line = "then `code` appears.";
524 let styled: Vec<_> = lines[4]
525 .iter()
526 .filter(|r| r.fg == (152, 195, 121))
527 .collect();
528 assert!(!styled.is_empty(), "code span should be styled on line 4");
529 for r in styled {
530 assert!(
531 r.range.end <= code_line.len(),
532 "range {:?} escapes the line (offsets not absolute)",
533 r.range
534 );
535 assert_eq!(&code_line[r.range.clone()], "`code`");
536 }
537 }
538
539 #[test]
540 fn markdown_fenced_code_block_gets_language_highlight() {
541 let hl = Highlighter::default();
542 let src = "text\n\n```rust\nfn f() {}\n```\n";
543 let lines = hl.highlight("readme.md", src);
544 assert!(
546 lines[3].iter().any(|r| r.fg == (198, 120, 221)),
547 "fenced rust `fn` should get the keyword color"
548 );
549 }
550
551 #[test]
552 fn markdown_fence_tag_resolves_by_extension() {
553 let hl = Highlighter::default();
556 let src = "text\n\n```rs\nfn f() {}\n```\n";
557 let lines = hl.highlight("readme.md", src);
558 assert!(
559 lines[3].iter().any(|r| r.fg == (198, 120, 221)),
560 "an `rs` fence should resolve to rust via by_ext"
561 );
562 }
563
564 #[test]
565 fn unknown_extension_yields_plain_lines() {
566 let hl = Highlighter::default();
567 let lines = hl.highlight("file.zzz-unknown", "a\nb\n");
568 assert_eq!(lines, vec![Vec::new(), Vec::new()]);
569 }
570
571 #[test]
572 fn syntax_theme_changes_the_foreground_palette() {
573 let src = "fn main() { let x = 1; }\n";
574 let dark = Highlighter::new(SyntaxTheme::OneHalfDark).highlight("a.rs", src);
575 let light = Highlighter::new(SyntaxTheme::OneHalfLight).highlight("a.rs", src);
576 assert_ne!(dark, light, "a different syntax theme recolors the line");
577 }
578}