1use std::sync::OnceLock;
20use syntect::highlighting::{Theme, ThemeSet};
21use syntect::parsing::SyntaxSet;
22
23static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
24static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ThemeMode {
29 Dark,
31 Light,
33 Auto,
35}
36
37impl ThemeMode {
38 pub fn detect() -> Self {
42 use terminal_colorsaurus::{QueryOptions, theme_mode};
43 match theme_mode(QueryOptions::default()) {
44 Ok(terminal_colorsaurus::ThemeMode::Dark) => ThemeMode::Dark,
45 Ok(terminal_colorsaurus::ThemeMode::Light) => ThemeMode::Light,
46 Err(_) => ThemeMode::Dark,
47 }
48 }
49}
50
51pub fn syntax_set() -> &'static SyntaxSet {
55 SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
56}
57
58pub fn themes() -> &'static ThemeSet {
62 THEME_SET.get_or_init(ThemeSet::load_defaults)
63}
64
65pub fn list_themes() -> Vec<&'static str> {
74 themes().themes.keys().map(|k| k.as_str()).collect()
75}
76
77fn resolve_theme(theme_mode: ThemeMode, custom: Option<&str>) -> &Theme {
78 if let Some(name) = custom
79 && let Some(theme) = themes().themes.get(name)
80 {
81 return theme;
82 }
83 let resolved = match theme_mode {
84 ThemeMode::Auto => ThemeMode::detect(),
85 other => other,
86 };
87 match resolved {
88 ThemeMode::Dark => &themes().themes["base16-eighties.dark"],
89 ThemeMode::Light => &themes().themes["Solarized (light)"],
90 ThemeMode::Auto => unreachable!(),
91 }
92}
93
94pub fn highlight_lines(
104 lang: &str,
105 lines: &[String],
106 theme_mode: ThemeMode,
107 custom_theme: Option<&str>,
108) -> Option<Vec<String>> {
109 use syntect::easy::HighlightLines;
110 use syntect::highlighting::FontStyle;
111
112 let syntax = syntax_set().find_syntax_by_token(lang)?;
113 let theme = resolve_theme(theme_mode, custom_theme);
114 let mut highlighter = HighlightLines::new(syntax, theme);
115
116 let mut out = Vec::new();
117 out.push(format!("\x1b[1m┌ \x1b[34m{lang}\x1b[0m"));
118
119 for line in lines {
120 let ranges = highlighter.highlight_line(line, syntax_set()).ok()?;
121 let mut rendered = String::new();
122 for (style, text) in &ranges {
123 let mut codes: Vec<String> = Vec::new();
124
125 if style.font_style.contains(FontStyle::BOLD) {
126 codes.push("1".into());
127 }
128 if style.font_style.contains(FontStyle::ITALIC) {
129 codes.push("3".into());
130 }
131 if style.font_style.contains(FontStyle::UNDERLINE) {
132 codes.push("4".into());
133 }
134
135 let color = style.foreground;
136 let (r, g, b) = boost_rgb(color.r, color.g, color.b);
137 codes.push(format!("38;2;{r};{g};{b}"));
138
139 if !codes.is_empty() {
140 let escape = codes.join(";");
141 rendered.push_str(&format!("\x1b[{escape}m"));
142 }
143 rendered.push_str(text);
144 }
145 rendered.push_str("\x1b[0m");
146 out.push(format!("\x1b[1m│\x1b[0m {rendered}"));
147 }
148
149 out.push("\x1b[1m└─\x1b[0m".to_string());
150 out.push("\x1b[0m".to_string());
151 Some(out)
152}
153
154fn boost_rgb(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
155 let max_ch = r.max(g).max(b);
156 if max_ch < 10 {
157 return (r, g, b);
158 }
159 (
160 ((r as u16 * 255 + max_ch as u16 / 2) / max_ch as u16).min(255) as u8,
161 ((g as u16 * 255 + max_ch as u16 / 2) / max_ch as u16).min(255) as u8,
162 ((b as u16 * 255 + max_ch as u16 / 2) / max_ch as u16).min(255) as u8,
163 )
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn boost_rgb_full_saturation() {
172 let (_, _, b) = boost_rgb(100, 50, 200);
173 assert_eq!(b, 255, "brightest channel should be 255");
174 }
175
176 #[test]
177 fn boost_rgb_preserves_ratio() {
178 let (r, g, b) = boost_rgb(50, 100, 0);
179 assert_eq!(g, 255);
180 assert!((126..=129).contains(&r), "red should be ~127, got {r}");
181 assert_eq!(b, 0);
182 }
183
184 #[test]
185 fn boost_rgb_near_black_unchanged() {
186 assert_eq!(boost_rgb(5, 5, 5), (5, 5, 5));
187 }
188
189 #[test]
190 fn boost_rgb_already_bright() {
191 assert_eq!(boost_rgb(255, 128, 64), (255, 128, 64));
192 }
193
194 #[test]
195 fn syntax_set_initializes() {
196 let ss = syntax_set();
197 assert!(ss.find_syntax_by_token("rust").is_some());
198 }
199
200 #[test]
201 fn themes_loads_all_seven() {
202 let names = list_themes();
203 assert_eq!(names.len(), 7, "expected 7 bundled themes");
204 }
205
206 #[test]
207 fn themes_have_expected_keys() {
208 let names = list_themes();
209 assert!(names.contains(&"base16-eighties.dark"));
210 assert!(names.contains(&"base16-ocean.dark"));
211 assert!(names.contains(&"base16-mocha.dark"));
212 assert!(names.contains(&"base16-ocean.light"));
213 assert!(names.contains(&"InspiredGitHub"));
214 assert!(names.contains(&"Solarized (dark)"));
215 assert!(names.contains(&"Solarized (light)"));
216 }
217
218 #[test]
219 fn resolve_theme_dark_explicit() {
220 let theme = resolve_theme(ThemeMode::Dark, None);
221 assert_eq!(theme.name.as_deref(), Some("Base16 Eighties Dark"));
222 }
223
224 #[test]
225 fn resolve_theme_light_explicit() {
226 let theme = resolve_theme(ThemeMode::Light, None);
227 assert_eq!(theme.name.as_deref(), Some("Solarized (light)"));
228 }
229
230 #[test]
231 fn resolve_theme_custom_name() {
232 let theme = resolve_theme(ThemeMode::Dark, Some("InspiredGitHub"));
233 assert_eq!(theme.name.as_deref(), Some("GitHub"));
234 }
235
236 #[test]
237 fn resolve_theme_custom_invalid_falls_back() {
238 let theme = resolve_theme(ThemeMode::Dark, Some("doesnotexist"));
239 assert_eq!(theme.name.as_deref(), Some("Base16 Eighties Dark"));
240 }
241
242 #[test]
243 fn highlight_lines_rust() {
244 let lines: Vec<String> = vec!["fn main() {".into(), " let x = 42;".into(), "}".into()];
245 let result = highlight_lines("rust", &lines, ThemeMode::Dark, None).unwrap();
246 assert!(result.len() > 3);
247 assert!(result[0].contains("rust"));
248 assert!(result[1].contains("fn"));
249 assert!(result.iter().any(|l| l.contains("38;2;")));
250 }
251
252 #[test]
253 fn highlight_lines_python() {
254 let lines: Vec<String> = vec!["def hello():".into(), " return 1".into()];
255 let result = highlight_lines("python", &lines, ThemeMode::Dark, None).unwrap();
256 assert!(result[0].contains("python"));
257 assert!(result.iter().any(|l| l.contains("def")));
258 }
259
260 #[test]
261 fn highlight_lines_unknown_lang() {
262 let lines: Vec<String> = vec!["some code".into()];
263 assert!(highlight_lines("zzz", &lines, ThemeMode::Dark, None).is_none());
264 }
265
266 #[test]
267 fn highlight_lines_custom_theme() {
268 let lines: Vec<String> = vec!["let x = 1;".into()];
269 let result =
270 highlight_lines("rust", &lines, ThemeMode::Dark, Some("base16-ocean.dark")).unwrap();
271 assert!(!result.is_empty());
272 }
273
274 #[test]
275 fn highlight_lines_empty_code() {
276 let lines: Vec<String> = vec![];
277 let result = highlight_lines("rust", &lines, ThemeMode::Dark, None).unwrap();
278 assert!(result[0].contains("rust"));
279 assert!(result.contains(&"\x1b[1m└─\x1b[0m".to_string()));
280 }
281
282 #[test]
283 fn theme_mode_detect_returns_dark_or_light() {
284 let mode = ThemeMode::detect();
285 assert!(matches!(mode, ThemeMode::Dark | ThemeMode::Light));
286 }
287}