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