thndrs_lib/cli/renderer/
highlight.rs1use 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
11static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
13
14static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
16
17fn syntax_set() -> &'static SyntaxSet {
19 SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
20}
21
22fn theme_set() -> &'static ThemeSet {
24 THEME_SET.get_or_init(ThemeSet::load_defaults)
25}
26
27fn theme() -> &'static syntect::highlighting::Theme {
29 theme_set().themes.get("base16-ocean.dark").expect("default theme")
30}
31
32pub 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
40pub 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
64fn 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
82fn syntect_color_to_renderer(color: syntect::highlighting::Color) -> Color {
84 Color::Rgb { r: color.r, g: color.g, b: color.b }
85}
86
87pub 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
110pub 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}