Skip to main content

thndrs_lib/cli/renderer/
highlight.rs

1//! Renderer-native syntax highlighting using `syntect`.
2
3use std::sync::OnceLock;
4
5use syntect::easy::HighlightLines;
6use syntect::highlighting::{FontStyle, ThemeSet};
7use syntect::parsing::SyntaxSet;
8
9use crate::renderer::style::{CellStyle, Color, Span};
10
11/// Cached syntax set for parsing code by file extension/language.
12static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
13
14/// Cached theme set for syntax highlighting colors.
15static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
16
17/// Get the cached syntax set (built-in defaults).
18fn syntax_set() -> &'static SyntaxSet {
19    SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
20}
21
22/// Get the cached theme set (built-in defaults).
23fn theme_set() -> &'static ThemeSet {
24    THEME_SET.get_or_init(ThemeSet::load_defaults)
25}
26
27/// Get the default highlighting theme.
28fn theme() -> &'static syntect::highlighting::Theme {
29    theme_set().themes.get("base16-ocean.dark").expect("default theme")
30}
31
32/// Guess a syntax definition from a file extension or language name.
33pub fn syntax_for(lang_or_ext: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
34    let syn_set = syntax_set();
35    syn_set
36        .find_syntax_by_extension(lang_or_ext)
37        .or_else(|| syn_set.find_syntax_by_token(lang_or_ext))
38}
39
40/// Highlight a code string into rows of renderer spans.
41///
42/// If `lang` is provided and matches a known syntax, the code is highlighted
43/// with colors from the default theme. If no match is found or highlighting
44/// fails, the code is returned as plain unstyled spans.
45pub fn highlight_lines(code: &str, lang: Option<&str>) -> Vec<Vec<Span>> {
46    let syntax = lang.and_then(syntax_for);
47
48    let Some(syntax) = syntax else {
49        return code.lines().map(|l| vec![Span::plain(l.to_string())]).collect();
50    };
51
52    let mut highlighter = HighlightLines::new(syntax, theme());
53    code.lines()
54        .map(|line| match highlighter.highlight_line(line, syntax_set()) {
55            Ok(regions) => regions
56                .into_iter()
57                .map(|(style, text)| Span::styled(text.to_string(), syntect_style_to_renderer(&style)))
58                .collect(),
59            Err(_) => vec![Span::plain(line.to_string())],
60        })
61        .collect()
62}
63
64/// Convert a syntect `Style` into a renderer [`CellStyle`].
65fn syntect_style_to_renderer(style: &syntect::highlighting::Style) -> CellStyle {
66    let fg = syntect_color_to_renderer(style.foreground);
67    let mut cell = CellStyle::new().fg(fg);
68
69    if style.font_style.contains(FontStyle::BOLD) {
70        cell = cell.bold();
71    }
72    if style.font_style.contains(FontStyle::ITALIC) {
73        cell = cell.italic();
74    }
75    if style.font_style.contains(FontStyle::UNDERLINE) {
76        cell = cell.underlined();
77    }
78
79    cell
80}
81
82/// Convert a syntect `Color` into a renderer [`Color`].
83fn syntect_color_to_renderer(color: syntect::highlighting::Color) -> Color {
84    Color::Rgb { r: color.r, g: color.g, b: color.b }
85}
86
87/// Map a file path extension to a syntect language token.
88pub fn path_extension_language(path: &str) -> Option<&'static str> {
89    let ext = path.rsplit('.').next()?;
90    match ext {
91        "rs" => Some("rs"),
92        "py" => Some("py"),
93        "js" | "jsx" => Some("js"),
94        "ts" | "tsx" => Some("ts"),
95        "json" => Some("json"),
96        "toml" => Some("toml"),
97        "yaml" | "yml" => Some("yaml"),
98        "sh" | "bash" => Some("bash"),
99        "go" => Some("go"),
100        "c" | "h" => Some("c"),
101        "cpp" | "hpp" | "cc" => Some("cpp"),
102        "html" | "htm" => Some("html"),
103        "css" => Some("css"),
104        "md" => Some("md"),
105        "sql" => Some("sql"),
106        _ => None,
107    }
108}
109
110/// Determine the syntax highlighting language for a tool's output.
111pub fn tool_output_language(tool_name: &str, arguments: &str) -> Option<&'static str> {
112    match tool_name {
113        "read_file_range" | "create_file" | "replace_range" | "write_patch" => {
114            let v: serde_json::Value = serde_json::from_str(arguments).unwrap_or(serde_json::Value::Null);
115            let path = v.get("path").and_then(|p| p.as_str())?;
116            path_extension_language(path)
117        }
118        "run_shell" => Some("bash"),
119        _ => None,
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn syntax_for_rust_extension() {
129        assert!(syntax_for("rs").is_some());
130    }
131
132    #[test]
133    fn syntax_for_json_extension() {
134        assert!(syntax_for("json").is_some());
135    }
136
137    #[test]
138    fn syntax_for_unknown_returns_none() {
139        assert!(syntax_for("totallymadeup").is_none());
140    }
141
142    #[test]
143    fn highlight_rust_code_produces_spans() {
144        let code = "fn main() {\n    println!(\"hello\");\n}";
145        let rows = highlight_lines(code, Some("rs"));
146        assert_eq!(rows.len(), 3, "should produce one row per input line");
147        assert!(!rows[0].is_empty(), "each row should have spans");
148    }
149
150    #[test]
151    fn highlight_unknown_language_produces_plain_spans() {
152        let rows = highlight_lines("some unknown code", Some("totallymadeup"));
153        assert_eq!(rows.len(), 1);
154        assert_eq!(rows[0][0].style.fg, Color::Reset);
155    }
156
157    #[test]
158    fn highlight_no_language_produces_plain_spans() {
159        let rows = highlight_lines("plain text\nsecond line", None);
160        assert_eq!(rows.len(), 2);
161    }
162
163    #[test]
164    fn highlight_rust_spans_have_rgb_colors() {
165        let rows = highlight_lines("fn main() {}", Some("rs"));
166        let has_color = rows.iter().flatten().any(|s| s.style.fg != Color::Reset);
167        assert!(has_color, "highlighted code should have colored spans");
168    }
169
170    #[test]
171    fn path_extension_language_maps_common_types() {
172        assert_eq!(path_extension_language("main.rs"), Some("rs"));
173        assert_eq!(path_extension_language("config.json"), Some("json"));
174        assert_eq!(path_extension_language("script.sh"), Some("bash"));
175        assert_eq!(path_extension_language("unknown.xyz"), None);
176    }
177
178    #[test]
179    fn tool_output_language_detects_from_args() {
180        let args = r#"{"path": "main.rs"}"#;
181        assert_eq!(tool_output_language("read_file_range", args), Some("rs"));
182        assert_eq!(tool_output_language("run_shell", "{}"), Some("bash"));
183        assert_eq!(tool_output_language("search_text", "{}"), None);
184    }
185}