1use std::collections::HashMap;
5use std::sync::LazyLock;
6
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::Span;
9use tree_sitter::Language;
10use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};
11
12use crate::theme::SyntaxTheme;
13
14const CAPTURE_NAMES: &[&str] = &[
15 "attribute",
16 "boolean",
17 "comment",
18 "constant",
19 "constant.builtin",
20 "constructor",
21 "escape",
22 "function",
23 "function.builtin",
24 "function.call",
25 "function.method",
26 "keyword",
27 "keyword.operator",
28 "label",
29 "number",
30 "operator",
31 "property",
32 "punctuation",
33 "punctuation.bracket",
34 "punctuation.delimiter",
35 "punctuation.special",
36 "storageclass",
37 "string",
38 "string.escape",
39 "string.special",
40 "tag",
41 "text.literal",
42 "text.reference",
43 "text.title",
44 "text.uri",
45 "type",
46 "type.builtin",
47 "type.qualifier",
48 "variable",
49 "variable.builtin",
50 "variable.parameter",
51];
52
53const BASH_HIGHLIGHTS_QUERY: &str = r#"
56[(string) (raw_string) (heredoc_body) (heredoc_start)] @string
57(command_name) @function
58(variable_name) @property
59["case" "do" "done" "elif" "else" "esac" "export" "fi" "for" "function" "if" "in" "select" "then" "unset" "until" "while"] @keyword
60(comment) @comment
61(function_definition name: (word) @function)
62(file_descriptor) @number
63["$" "&&" ">" ">>" "<" "|"] @operator
64((command (_) @constant) (#match? @constant "^-"))
65"#;
66
67static LANG_ALIASES: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
68 HashMap::from([
69 ("rs", "rust"),
70 ("py", "python"),
71 ("js", "javascript"),
72 ("sh", "bash"),
73 ("shell", "bash"),
74 ("ts", "typescript"),
75 ("tsx", "typescript"),
76 ("mts", "typescript"),
77 ("cts", "typescript"),
78 ("golang", "go"),
79 ("yml", "yaml"),
80 ("md", "markdown"),
81 ("mysql", "sql"),
82 ("psql", "sql"),
83 ("postgres", "sql"),
84 ("sequel", "sql"),
85 ])
86});
87
88pub static SYNTAX_HIGHLIGHTER: LazyLock<SyntaxHighlighter> = LazyLock::new(SyntaxHighlighter::new);
104
105pub struct SyntaxHighlighter {
151 configs: HashMap<&'static str, HighlightConfiguration>,
152}
153
154impl SyntaxHighlighter {
155 #[allow(clippy::too_many_lines)]
156 fn new() -> Self {
157 let mut configs = HashMap::new();
158
159 let mut register = |name: &'static str,
160 language: Language,
161 lang_name: &str,
162 highlights_query: &str,
163 injections_query: &str| {
164 let Ok(mut config) = HighlightConfiguration::new(
165 language,
166 lang_name.to_string(),
167 highlights_query,
168 injections_query,
169 "",
170 ) else {
171 return;
172 };
173 config.configure(CAPTURE_NAMES);
174 configs.insert(name, config);
175 };
176
177 register(
178 "rust",
179 tree_sitter_rust::LANGUAGE.into(),
180 "rust",
181 tree_sitter_rust::HIGHLIGHTS_QUERY,
182 tree_sitter_rust::INJECTIONS_QUERY,
183 );
184
185 register(
186 "python",
187 tree_sitter_python::LANGUAGE.into(),
188 "python",
189 tree_sitter_python::HIGHLIGHTS_QUERY,
190 "",
191 );
192
193 register(
194 "javascript",
195 tree_sitter_javascript::LANGUAGE.into(),
196 "javascript",
197 tree_sitter_javascript::HIGHLIGHT_QUERY,
198 tree_sitter_javascript::INJECTIONS_QUERY,
199 );
200
201 let ts_query = [
204 tree_sitter_javascript::HIGHLIGHT_QUERY,
205 "\n",
206 tree_sitter_typescript::HIGHLIGHTS_QUERY,
207 ]
208 .concat();
209 register(
210 "typescript",
211 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
212 "typescript",
213 &ts_query,
214 "",
215 );
216
217 register(
218 "go",
219 tree_sitter_go::LANGUAGE.into(),
220 "go",
221 tree_sitter_go::HIGHLIGHTS_QUERY,
222 "",
223 );
224
225 register(
226 "json",
227 tree_sitter_json::LANGUAGE.into(),
228 "json",
229 tree_sitter_json::HIGHLIGHTS_QUERY,
230 "",
231 );
232
233 register(
234 "toml",
235 tree_sitter_toml_ng::LANGUAGE.into(),
236 "toml",
237 tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
238 "",
239 );
240
241 register(
242 "yaml",
243 tree_sitter_yaml::LANGUAGE.into(),
244 "yaml",
245 tree_sitter_yaml::HIGHLIGHTS_QUERY,
246 "",
247 );
248
249 register(
250 "sql",
251 tree_sitter_sequel::LANGUAGE.into(),
252 "sql",
253 tree_sitter_sequel::HIGHLIGHTS_QUERY,
254 "",
255 );
256
257 register(
258 "bash",
259 tree_sitter_bash::LANGUAGE.into(),
260 "bash",
261 BASH_HIGHLIGHTS_QUERY,
262 "",
263 );
264
265 register(
268 "markdown",
269 tree_sitter_md::LANGUAGE.into(),
270 "markdown",
271 tree_sitter_md::HIGHLIGHT_QUERY_BLOCK,
272 tree_sitter_md::INJECTION_QUERY_BLOCK,
273 );
274
275 Self { configs }
276 }
277
278 pub fn highlight(
302 &self,
303 lang: &str,
304 code: &str,
305 theme: &SyntaxTheme,
306 ) -> Option<Vec<Span<'static>>> {
307 let lang_lower = lang.to_lowercase();
308 let canonical = LANG_ALIASES
309 .get(lang_lower.as_str())
310 .copied()
311 .unwrap_or(lang_lower.as_str());
312 let config = self.configs.get(canonical)?;
313
314 let mut highlighter = Highlighter::new();
315 let events = highlighter
316 .highlight(config, code.as_bytes(), None, |_| None)
317 .ok()?;
318
319 let mut spans = Vec::new();
320 let mut style_stack: Vec<Style> = Vec::new();
321
322 for event in events {
323 match event.ok()? {
324 HighlightEvent::Source { start, end } => {
325 let text = code.get(start..end).unwrap_or_default();
326 let style = style_stack.last().copied().unwrap_or(theme.default);
327 spans.push(Span::styled(text.to_string(), style));
328 }
329 HighlightEvent::HighlightStart(highlight) => {
330 let style = capture_to_style(highlight.0, theme);
331 style_stack.push(style);
332 }
333 HighlightEvent::HighlightEnd => {
334 style_stack.pop();
335 }
336 }
337 }
338
339 Some(spans)
340 }
341}
342
343fn capture_to_style(index: usize, theme: &SyntaxTheme) -> Style {
344 match CAPTURE_NAMES.get(index).copied().unwrap_or_default() {
345 "attribute" | "storageclass" => theme.attribute,
346 "boolean" | "constant" | "constant.builtin" => theme.constant,
347 "comment" => theme.comment,
348 "constructor" | "type" | "type.builtin" | "type.qualifier" | "tag" => theme.r#type,
349 "escape" | "string" | "string.escape" | "string.special" | "text.literal" => theme.string,
350 "function" | "function.builtin" | "function.call" | "function.method"
351 | "text.reference" | "text.uri" => theme.function,
352 "keyword" | "keyword.operator" | "text.title" => theme.keyword,
353 "label" | "property" | "variable" | "variable.builtin" | "variable.parameter" => {
354 theme.variable
355 }
356 "number" => theme.number,
357 "operator" => theme.operator,
358 "punctuation" | "punctuation.bracket" | "punctuation.delimiter" | "punctuation.special" => {
359 theme.punctuation
360 }
361 _ => theme.default,
362 }
363}
364
365impl Default for SyntaxTheme {
366 fn default() -> Self {
367 Self {
368 keyword: Style::default()
369 .fg(Color::Rgb(198, 120, 221))
370 .add_modifier(Modifier::BOLD),
371 string: Style::default().fg(Color::Rgb(152, 195, 121)),
372 comment: Style::default()
373 .fg(Color::Rgb(92, 99, 112))
374 .add_modifier(Modifier::ITALIC),
375 function: Style::default().fg(Color::Rgb(97, 175, 239)),
376 r#type: Style::default().fg(Color::Rgb(229, 192, 123)),
377 number: Style::default().fg(Color::Rgb(209, 154, 102)),
378 operator: Style::default().fg(Color::Rgb(171, 178, 191)),
379 variable: Style::default().fg(Color::Rgb(224, 108, 117)),
380 attribute: Style::default().fg(Color::Rgb(229, 192, 123)),
381 punctuation: Style::default().fg(Color::Rgb(171, 178, 191)),
382 constant: Style::default().fg(Color::Rgb(209, 154, 102)),
383 default: Style::default().fg(Color::Rgb(190, 175, 145)),
384 }
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use std::collections::HashSet;
391
392 use super::*;
393
394 #[test]
397 fn highlight_rust_code() {
398 let hl = &*SYNTAX_HIGHLIGHTER;
399 let theme = SyntaxTheme::default();
400 let spans = hl.highlight("rust", "let x = 42;", &theme);
401 assert!(spans.is_some());
402 let spans = spans.unwrap();
403 assert!(!spans.is_empty());
404 let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
405 assert_eq!(text, "let x = 42;");
406 }
407
408 #[test]
409 fn highlight_python_code() {
410 let hl = &*SYNTAX_HIGHLIGHTER;
411 let theme = SyntaxTheme::default();
412 let spans = hl.highlight("python", "def foo():\n pass", &theme);
413 assert!(spans.is_some());
414 }
415
416 #[test]
417 fn highlight_unknown_lang_returns_none() {
418 let hl = &*SYNTAX_HIGHLIGHTER;
419 let theme = SyntaxTheme::default();
420 assert!(hl.highlight("brainfuck", "+++", &theme).is_none());
421 }
422
423 #[test]
424 fn highlight_json_code() {
425 let hl = &*SYNTAX_HIGHLIGHTER;
426 let theme = SyntaxTheme::default();
427 let spans = hl.highlight("json", r#"{"key": "value"}"#, &theme);
428 assert!(spans.is_some());
429 }
430
431 #[test]
432 fn highlight_js_code() {
433 let hl = &*SYNTAX_HIGHLIGHTER;
434 let theme = SyntaxTheme::default();
435 let spans = hl.highlight("js", "const x = 1;", &theme);
436 assert!(spans.is_some());
437 }
438
439 #[test]
440 fn highlight_alias_rs() {
441 let hl = &*SYNTAX_HIGHLIGHTER;
442 let theme = SyntaxTheme::default();
443 assert!(hl.highlight("rs", "fn main() {}", &theme).is_some());
444 }
445
446 #[test]
447 fn highlight_empty_string() {
448 let hl = &*SYNTAX_HIGHLIGHTER;
449 let theme = SyntaxTheme::default();
450 let spans = hl.highlight("rust", "", &theme);
451 assert!(spans.is_some());
452 assert!(spans.unwrap().is_empty());
453 }
454
455 #[test]
456 fn highlight_malformed_code_no_panic() {
457 let hl = &*SYNTAX_HIGHLIGHTER;
458 let theme = SyntaxTheme::default();
459 let spans = hl.highlight("rust", "fn {{{{ let !!!", &theme);
461 assert!(spans.is_some());
462 }
463
464 #[test]
465 fn highlight_toml_code() {
466 let hl = &*SYNTAX_HIGHLIGHTER;
467 let theme = SyntaxTheme::default();
468 let spans = hl.highlight("toml", "[package]\nname = \"foo\"", &theme);
469 assert!(spans.is_some());
470 }
471
472 #[test]
473 fn highlight_bash_code() {
474 let hl = &*SYNTAX_HIGHLIGHTER;
475 let theme = SyntaxTheme::default();
476 let spans = hl.highlight("bash", "echo \"hello\"", &theme);
477 assert!(spans.is_some());
478 }
479
480 #[test]
481 fn rust_keywords_get_keyword_style() {
482 let hl = &*SYNTAX_HIGHLIGHTER;
483 let theme = SyntaxTheme::default();
484 let spans = hl.highlight("rust", "let x = 1;", &theme).unwrap();
485 let let_span = spans.iter().find(|s| s.content.as_ref() == "let").unwrap();
486 assert_eq!(let_span.style, theme.keyword);
487 }
488
489 fn assert_multi_style(lang: &str, code: &str) {
492 let hl = &*SYNTAX_HIGHLIGHTER;
493 let theme = SyntaxTheme::default();
494 let spans = hl.highlight(lang, code, &theme).unwrap_or_else(|| {
495 panic!("highlight returned None for language '{lang}'");
496 });
497 let styles: HashSet<_> = spans.iter().map(|s| s.style).collect();
498 assert!(
499 styles.len() > 1,
500 "expected >1 distinct style for '{lang}', got {}: {:?}",
501 styles.len(),
502 spans.iter().map(|s| s.content.as_ref()).collect::<Vec<_>>()
503 );
504 }
505
506 #[test]
507 fn highlight_typescript_multi_style() {
508 assert_multi_style(
509 "typescript",
510 "const greet = (name: string): void => {\n console.log(name);\n};",
511 );
512 }
513
514 #[test]
515 fn highlight_typescript_alias_ts() {
516 let hl = &*SYNTAX_HIGHLIGHTER;
517 let theme = SyntaxTheme::default();
518 assert!(hl.highlight("ts", "let x: number = 1;", &theme).is_some());
519 }
520
521 #[test]
522 fn highlight_typescript_alias_tsx() {
523 let hl = &*SYNTAX_HIGHLIGHTER;
524 let theme = SyntaxTheme::default();
525 assert!(
526 hl.highlight("tsx", "const x: string = 'hello';", &theme)
527 .is_some()
528 );
529 }
530
531 #[test]
532 fn highlight_go_multi_style() {
533 assert_multi_style(
534 "go",
535 "package main\n\nimport \"fmt\"\n\nfunc main() {\n fmt.Println(\"hello\")\n}",
536 );
537 }
538
539 #[test]
540 fn highlight_go_alias_golang() {
541 let hl = &*SYNTAX_HIGHLIGHTER;
542 let theme = SyntaxTheme::default();
543 assert!(hl.highlight("golang", "func f() {}", &theme).is_some());
544 }
545
546 #[test]
547 fn highlight_yaml_multi_style() {
548 assert_multi_style("yaml", "name: foo\nversion: \"1.0\"\nenabled: true\n");
549 }
550
551 #[test]
552 fn highlight_yaml_alias_yml() {
553 let hl = &*SYNTAX_HIGHLIGHTER;
554 let theme = SyntaxTheme::default();
555 assert!(
556 hl.highlight("yml", "key: value\nlist:\n - a\n - b", &theme)
557 .is_some()
558 );
559 }
560
561 #[test]
562 fn highlight_sql_multi_style() {
563 assert_multi_style("sql", "SELECT id, name FROM users WHERE active = true;");
564 }
565
566 #[test]
567 fn highlight_sql_alias_mysql() {
568 let hl = &*SYNTAX_HIGHLIGHTER;
569 let theme = SyntaxTheme::default();
570 assert!(hl.highlight("mysql", "SELECT * FROM t;", &theme).is_some());
571 }
572
573 #[test]
574 fn highlight_markdown_headings_and_links() {
575 assert_multi_style(
576 "markdown",
577 "# Hello World\n\n[link text](https://example.com)\n",
578 );
579 }
580
581 #[test]
582 fn highlight_markdown_alias_md() {
583 let hl = &*SYNTAX_HIGHLIGHTER;
584 let theme = SyntaxTheme::default();
585 assert!(hl.highlight("md", "# Title\nSome text.", &theme).is_some());
586 }
587
588 #[test]
589 fn highlight_unknown_still_none_after_new_langs() {
590 let hl = &*SYNTAX_HIGHLIGHTER;
591 let theme = SyntaxTheme::default();
592 assert!(hl.highlight("brainfuck", "+++", &theme).is_none());
593 assert!(hl.highlight("cobol", "DISPLAY 'hi'", &theme).is_none());
594 }
595}