Skip to main content

zeph_tui/
highlight.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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
53// Custom bash query because tree-sitter-bash doesn't bundle a highlights.scm
54// compatible with tree-sitter-highlight's capture convention.
55const 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
88/// Process-wide singleton [`SyntaxHighlighter`], initialised on first access.
89///
90/// Use this instead of constructing a new highlighter to avoid redundant
91/// tree-sitter grammar compilation on each frame.
92///
93/// # Examples
94///
95/// ```rust
96/// use zeph_tui::highlight::SYNTAX_HIGHLIGHTER;
97/// use zeph_tui::theme::SyntaxTheme;
98///
99/// let theme = SyntaxTheme::default();
100/// let spans = SYNTAX_HIGHLIGHTER.highlight("rust", "let x = 1;", &theme);
101/// assert!(spans.is_some());
102/// ```
103pub static SYNTAX_HIGHLIGHTER: LazyLock<SyntaxHighlighter> = LazyLock::new(SyntaxHighlighter::new);
104
105/// Tree-sitter-based syntax highlighter for TUI code blocks.
106///
107/// Supports Rust, Python, JavaScript, TypeScript, Go, JSON, TOML, YAML, SQL,
108/// Markdown (block-level), and Bash out of the box. Language aliases
109/// (`"rs"` → `"rust"`, `"ts"` → `"typescript"`, `"yml"` → `"yaml"`, etc.)
110/// are resolved transparently.
111///
112/// Construct via the [`SYNTAX_HIGHLIGHTER`] static for process-level sharing,
113/// or call the private `new` method directly in tests.
114///
115/// # Supported languages
116///
117/// | Identifier   | Aliases                          |
118/// |--------------|----------------------------------|
119/// | `rust`       | `rs`                             |
120/// | `python`     | `py`                             |
121/// | `javascript` | `js`                             |
122/// | `typescript` | `ts`, `tsx`, `mts`, `cts`        |
123/// | `go`         | `golang`                         |
124/// | `bash`       | `sh`, `shell`                    |
125/// | `json`       | —                                |
126/// | `toml`       | —                                |
127/// | `yaml`       | `yml`                            |
128/// | `sql`        | `mysql`, `psql`, `postgres`, `sequel` |
129/// | `markdown`   | `md`                             |
130///
131/// # Examples
132///
133/// ```rust
134/// use zeph_tui::highlight::SYNTAX_HIGHLIGHTER;
135/// use zeph_tui::theme::SyntaxTheme;
136///
137/// let theme = SyntaxTheme::default();
138///
139/// // Known language → styled spans
140/// let spans = SYNTAX_HIGHLIGHTER.highlight("rust", "fn main() {}", &theme);
141/// assert!(spans.is_some());
142///
143/// // Alias works the same way
144/// let spans = SYNTAX_HIGHLIGHTER.highlight("rs", "let x = 1;", &theme);
145/// assert!(spans.is_some());
146///
147/// // Unknown language → None
148/// assert!(SYNTAX_HIGHLIGHTER.highlight("brainfuck", "+++", &theme).is_none());
149/// ```
150pub 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        // TypeScript grammar only ships a delta query over JS — register TS with
202        // the full JS query prepended so string/comment/function highlighting works.
203        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        // Markdown block-level highlighting only. The block grammar covers
266        // headings, markers, links, and code fence delimiters.
267        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    /// Highlight `code` for the given `lang` using `theme`.
279    ///
280    /// Returns `None` if the language is unsupported or if tree-sitter fails
281    /// to parse the input. The returned spans concatenate to the original
282    /// source text unchanged.
283    ///
284    /// # Arguments
285    ///
286    /// * `lang` — language identifier or alias (case-insensitive).
287    /// * `code` — source code to highlight.
288    /// * `theme` — style mapping for each token class.
289    ///
290    /// # Examples
291    ///
292    /// ```rust
293    /// use zeph_tui::highlight::SYNTAX_HIGHLIGHTER;
294    /// use zeph_tui::theme::SyntaxTheme;
295    ///
296    /// let theme = SyntaxTheme::default();
297    /// let spans = SYNTAX_HIGHLIGHTER.highlight("rust", "let x = 42;", &theme).unwrap();
298    /// let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
299    /// assert_eq!(text, "let x = 42;");
300    /// ```
301    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    // ---- existing tests ----
395
396    #[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        // Malformed Rust — should not panic, tree-sitter is error-tolerant
460        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    // ---- new language tests ----
490
491    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}