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
181impl SyntaxTheme {
182 fn style(self, name: &str) -> Option<StyleSpec> {
185 if let Some((category, bold, italic)) = markdown_face(name) {
186 return Some(StyleSpec {
187 fg: self.color(category)?,
188 bold,
189 italic,
190 });
191 }
192 let category = name.split('.').next().unwrap_or(name);
193 let italic = category == "comment";
194 let fg = self.color(category)?;
195 Some(StyleSpec {
196 fg,
197 bold: false,
198 italic,
199 })
200 }
201
202 #[allow(clippy::too_many_lines)]
203 fn color(self, category: &str) -> Option<(u8, u8, u8)> {
204 let c = match self {
205 SyntaxTheme::OneHalfDark => match category {
206 "keyword" | "label" => (198, 120, 221),
207 "function" => (97, 175, 239),
208 "type" | "constructor" => (229, 192, 123),
209 "string" => (152, 195, 121),
210 "comment" => (126, 134, 145),
213 "constant" | "number" | "attribute" => (209, 154, 102),
214 "operator" | "escape" => (86, 182, 194),
215 "property" | "tag" => (224, 108, 117),
216 "variable" | "punctuation" => (171, 178, 191),
217 _ => return None,
218 },
219 SyntaxTheme::OneHalfLight => match category {
220 "keyword" | "label" => (166, 38, 164),
221 "function" => (64, 120, 242),
222 "type" | "constructor" => (193, 132, 1),
223 "string" => (80, 161, 79),
224 "comment" => (160, 161, 167),
225 "constant" | "number" | "attribute" => (152, 104, 1),
226 "operator" | "escape" => (1, 132, 188),
227 "property" | "tag" => (228, 86, 73),
228 "variable" | "punctuation" => (56, 58, 66),
229 _ => return None,
230 },
231 SyntaxTheme::Dracula => match category {
232 "keyword" | "label" | "tag" | "operator" => (255, 121, 198),
233 "function" | "property" => (80, 250, 123),
234 "type" | "constructor" => (139, 233, 253),
235 "string" => (241, 250, 140),
236 "comment" => (98, 114, 164),
237 "constant" | "number" => (189, 147, 249),
238 "escape" | "attribute" => (255, 184, 108),
239 "variable" | "punctuation" => (248, 248, 242),
240 _ => return None,
241 },
242 SyntaxTheme::CatppuccinMocha => match category {
243 "keyword" | "label" => (203, 166, 247),
244 "function" => (137, 180, 250),
245 "type" | "constructor" => (249, 226, 175),
246 "string" => (166, 227, 161),
247 "comment" => (127, 132, 156),
248 "constant" | "number" | "attribute" => (250, 179, 135),
249 "operator" | "escape" => (137, 220, 235),
250 "property" | "tag" => (243, 139, 168),
251 "variable" | "punctuation" => (205, 214, 244),
252 _ => return None,
253 },
254 SyntaxTheme::TokyoNight => match category {
255 "keyword" | "label" => (187, 154, 247),
256 "function" => (122, 162, 247),
257 "type" | "constructor" => (42, 195, 222),
258 "string" => (158, 206, 106),
259 "comment" => (99, 109, 150),
260 "constant" | "number" | "attribute" => (255, 158, 100),
261 "operator" | "escape" => (137, 221, 255),
262 "property" | "tag" => (247, 118, 142),
263 "variable" | "punctuation" => (192, 202, 245),
264 _ => return None,
265 },
266 SyntaxTheme::GruvboxDark => match category {
267 "keyword" | "label" => (251, 73, 52),
268 "function" => (184, 187, 38),
269 "type" | "constructor" => (250, 189, 47),
270 "string" => (142, 192, 124),
271 "comment" => (146, 131, 116),
272 "constant" | "number" => (211, 134, 155),
273 "operator" | "escape" | "attribute" => (254, 128, 25),
274 "property" | "tag" => (131, 165, 152),
275 "variable" | "punctuation" => (235, 219, 178),
276 _ => return None,
277 },
278 SyntaxTheme::Nord => match category {
279 "keyword" | "label" | "operator" | "escape" => (129, 161, 193),
280 "function" => (136, 192, 208),
281 "type" | "constructor" | "property" | "tag" => (143, 188, 187),
282 "string" => (163, 190, 140),
283 "comment" => (123, 136, 161),
284 "constant" | "number" | "attribute" => (180, 142, 173),
285 "variable" | "punctuation" => (216, 222, 233),
286 _ => return None,
287 },
288 SyntaxTheme::RosePine => match category {
289 "keyword" | "label" | "operator" | "escape" => (49, 116, 143),
290 "function" => (235, 188, 186),
291 "type" | "constructor" | "property" | "tag" => (156, 207, 216),
292 "string" => (246, 193, 119),
293 "comment" => (129, 124, 153),
294 "constant" | "number" | "attribute" => (196, 167, 231),
295 "variable" | "punctuation" => (224, 222, 244),
296 _ => return None,
297 },
298 SyntaxTheme::Kanagawa => match category {
299 "keyword" | "label" => (149, 127, 184),
300 "function" => (126, 156, 216),
301 "type" | "constructor" => (122, 168, 159),
302 "string" => (152, 187, 108),
303 "comment" => (144, 140, 128),
304 "constant" | "number" | "attribute" => (210, 126, 153),
305 "operator" | "escape" => (127, 180, 202),
306 "property" | "tag" => (106, 149, 137),
307 "variable" | "punctuation" => (220, 215, 186),
308 _ => return None,
309 },
310 };
311 Some(c)
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn python_keywords_get_distinct_color() {
321 let hl = Highlighter::default();
322 let lines = hl.highlight("a.py", "def f():\n return 1\n");
323 assert_eq!(lines.len(), 2);
324 let colors: std::collections::HashSet<(u8, u8, u8)> =
325 lines[0].iter().map(|r| r.fg).collect();
326 assert!(colors.len() > 1, "expected multiple colors, got {colors:?}");
327 }
328
329 #[test]
330 fn yaml_is_highlighted() {
331 let hl = Highlighter::default();
332 let lines = hl.highlight("ci.yml", "name: CI\non: push\njobs:\n lint: {}\n");
333 assert!(
334 lines.iter().any(|line| !line.is_empty()),
335 "expected styled ranges for a .yml file"
336 );
337 }
338
339 #[test]
340 fn ranges_cover_within_line_bounds() {
341 let hl = Highlighter::default();
342 let src = "fn main() { let x = \"hi\"; }\n";
343 let lines = hl.highlight("a.rs", src);
344 let visible = src.trim_end();
345 for r in &lines[0] {
346 assert!(r.range.end <= visible.len());
347 assert!(r.range.start < r.range.end);
348 }
349 }
350
351 #[test]
352 fn multiline_string_state_carries_across_lines() {
353 let hl = Highlighter::default();
354 let src = "s = \"\"\"first\nsecond\nthird\"\"\"\nx = 1\n";
355 let lines = hl.highlight("a.py", src);
356 let string_color = lines[0].iter().last().map(|r| r.fg).expect("line 0 styled");
357 assert!(
358 lines[1].iter().all(|r| r.fg == string_color),
359 "inside-string line must keep string color"
360 );
361 }
362
363 #[test]
364 fn markdown_highlights_headings_and_inline_code() {
365 let hl = Highlighter::default();
366 let src = "# Title\n\nSome `code` and **bold** text.\n";
367 let lines = hl.highlight("readme.md", src);
368 assert!(!lines[0].is_empty(), "heading line should be styled");
369 assert!(
371 lines[2].iter().any(|r| r.fg == (152, 195, 121)),
372 "inline `code` should get the string color"
373 );
374 }
375
376 #[test]
377 fn markdown_inline_code_offset_is_absolute_not_range_relative() {
378 let hl = Highlighter::default();
381 let src = "# A longer heading here\n\nintro line\n\nthen `code` appears.\n";
382 let lines = hl.highlight("readme.md", src);
383 let code_line = "then `code` appears.";
384 let styled: Vec<_> = lines[4]
385 .iter()
386 .filter(|r| r.fg == (152, 195, 121))
387 .collect();
388 assert!(!styled.is_empty(), "code span should be styled on line 4");
389 for r in styled {
390 assert!(
391 r.range.end <= code_line.len(),
392 "range {:?} escapes the line (offsets not absolute)",
393 r.range
394 );
395 assert_eq!(&code_line[r.range.clone()], "`code`");
396 }
397 }
398
399 #[test]
400 fn markdown_fenced_code_block_gets_language_highlight() {
401 let hl = Highlighter::default();
402 let src = "text\n\n```rust\nfn f() {}\n```\n";
403 let lines = hl.highlight("readme.md", src);
404 assert!(
406 lines[3].iter().any(|r| r.fg == (198, 120, 221)),
407 "fenced rust `fn` should get the keyword color"
408 );
409 }
410
411 #[test]
412 fn markdown_fence_tag_resolves_by_extension() {
413 let hl = Highlighter::default();
416 let src = "text\n\n```rs\nfn f() {}\n```\n";
417 let lines = hl.highlight("readme.md", src);
418 assert!(
419 lines[3].iter().any(|r| r.fg == (198, 120, 221)),
420 "an `rs` fence should resolve to rust via by_ext"
421 );
422 }
423
424 #[test]
425 fn unknown_extension_yields_plain_lines() {
426 let hl = Highlighter::default();
427 let lines = hl.highlight("file.zzz-unknown", "a\nb\n");
428 assert_eq!(lines, vec![Vec::new(), Vec::new()]);
429 }
430
431 #[test]
432 fn syntax_theme_changes_the_foreground_palette() {
433 let src = "fn main() { let x = 1; }\n";
434 let dark = Highlighter::new(SyntaxTheme::OneHalfDark).highlight("a.rs", src);
435 let light = Highlighter::new(SyntaxTheme::OneHalfLight).highlight("a.rs", src);
436 assert_ne!(dark, light, "a different syntax theme recolors the line");
437 }
438}