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: 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: LanguageRegistry::build(),
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.as_ref() 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]
335 fn python_keywords_get_distinct_color() {
336 let hl = Highlighter::default();
337 let lines = hl.highlight("a.py", "def f():\n return 1\n");
338 assert_eq!(lines.len(), 2);
339 let colors: std::collections::HashSet<(u8, u8, u8)> =
340 lines[0].iter().map(|r| r.fg).collect();
341 assert!(colors.len() > 1, "expected multiple colors, got {colors:?}");
342 }
343
344 #[test]
345 fn yaml_is_highlighted() {
346 let hl = Highlighter::default();
347 let lines = hl.highlight("ci.yml", "name: CI\non: push\njobs:\n lint: {}\n");
348 assert!(
349 lines.iter().any(|line| !line.is_empty()),
350 "expected styled ranges for a .yml file"
351 );
352 }
353
354 #[test]
355 fn sql_is_highlighted() {
356 let hl = Highlighter::default();
357 let lines = hl.highlight("q.sql", "SELECT id FROM users WHERE active = true;\n");
358 assert!(
359 lines.iter().any(|line| !line.is_empty()),
360 "expected styled ranges for a .sql file"
361 );
362 }
363
364 #[test]
365 fn sql_line_and_block_comments_style_alike() {
366 let hl = Highlighter::default();
367 let lines = hl.highlight("q.sql", "-- one\n/* two */\n");
368 let style = |line: &[StyledRange]| line.first().map(|r| (r.fg, r.italic));
369 assert_eq!(
370 style(&lines[0]),
371 style(&lines[1]),
372 "a -- comment styles like a block comment"
373 );
374 assert!(style(&lines[0]).is_some(), "comments are styled at all");
375 }
376
377 #[test]
378 fn sql_numbers_are_not_styled_as_strings() {
379 let hl = Highlighter::default();
380 let lines = hl.highlight("q.sql", "SELECT 42, 1.5, 'txt';\n");
381 let colors: Vec<(u8, u8, u8)> = lines[0].iter().map(|r| r.fg).collect();
382 let string_fg = (152, 195, 121);
383 let number_fg = (209, 154, 102);
384 assert!(colors.contains(&number_fg), "numbers get the number color");
385 assert_eq!(
386 colors.iter().filter(|c| **c == string_fg).count(),
387 1,
388 "only the quoted literal is a string: {colors:?}"
389 );
390 }
391
392 #[test]
393 fn ranges_cover_within_line_bounds() {
394 let hl = Highlighter::default();
395 let src = "fn main() { let x = \"hi\"; }\n";
396 let lines = hl.highlight("a.rs", src);
397 let visible = src.trim_end();
398 for r in &lines[0] {
399 assert!(r.range.end <= visible.len());
400 assert!(r.range.start < r.range.end);
401 }
402 }
403
404 #[test]
405 fn multiline_string_state_carries_across_lines() {
406 let hl = Highlighter::default();
407 let src = "s = \"\"\"first\nsecond\nthird\"\"\"\nx = 1\n";
408 let lines = hl.highlight("a.py", src);
409 let string_color = lines[0].iter().last().map(|r| r.fg).expect("line 0 styled");
410 assert!(
411 lines[1].iter().all(|r| r.fg == string_color),
412 "inside-string line must keep string color"
413 );
414 }
415
416 #[test]
417 fn markdown_highlights_headings_and_inline_code() {
418 let hl = Highlighter::default();
419 let src = "# Title\n\nSome `code` and **bold** text.\n";
420 let lines = hl.highlight("readme.md", src);
421 assert!(!lines[0].is_empty(), "heading line should be styled");
422 assert!(
424 lines[2].iter().any(|r| r.fg == (152, 195, 121)),
425 "inline `code` should get the string color"
426 );
427 }
428
429 #[test]
430 fn markdown_inline_code_offset_is_absolute_not_range_relative() {
431 let hl = Highlighter::default();
434 let src = "# A longer heading here\n\nintro line\n\nthen `code` appears.\n";
435 let lines = hl.highlight("readme.md", src);
436 let code_line = "then `code` appears.";
437 let styled: Vec<_> = lines[4]
438 .iter()
439 .filter(|r| r.fg == (152, 195, 121))
440 .collect();
441 assert!(!styled.is_empty(), "code span should be styled on line 4");
442 for r in styled {
443 assert!(
444 r.range.end <= code_line.len(),
445 "range {:?} escapes the line (offsets not absolute)",
446 r.range
447 );
448 assert_eq!(&code_line[r.range.clone()], "`code`");
449 }
450 }
451
452 #[test]
453 fn markdown_fenced_code_block_gets_language_highlight() {
454 let hl = Highlighter::default();
455 let src = "text\n\n```rust\nfn f() {}\n```\n";
456 let lines = hl.highlight("readme.md", src);
457 assert!(
459 lines[3].iter().any(|r| r.fg == (198, 120, 221)),
460 "fenced rust `fn` should get the keyword color"
461 );
462 }
463
464 #[test]
465 fn markdown_fence_tag_resolves_by_extension() {
466 let hl = Highlighter::default();
469 let src = "text\n\n```rs\nfn f() {}\n```\n";
470 let lines = hl.highlight("readme.md", src);
471 assert!(
472 lines[3].iter().any(|r| r.fg == (198, 120, 221)),
473 "an `rs` fence should resolve to rust via by_ext"
474 );
475 }
476
477 #[test]
478 fn unknown_extension_yields_plain_lines() {
479 let hl = Highlighter::default();
480 let lines = hl.highlight("file.zzz-unknown", "a\nb\n");
481 assert_eq!(lines, vec![Vec::new(), Vec::new()]);
482 }
483
484 #[test]
485 fn syntax_theme_changes_the_foreground_palette() {
486 let src = "fn main() { let x = 1; }\n";
487 let dark = Highlighter::new(SyntaxTheme::OneHalfDark).highlight("a.rs", src);
488 let light = Highlighter::new(SyntaxTheme::OneHalfLight).highlight("a.rs", src);
489 assert_ne!(dark, light, "a different syntax theme recolors the line");
490 }
491}