use std::sync::OnceLock;
use syntect::highlighting::{Theme, ThemeSet};
use syntect::parsing::SyntaxSet;
static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemeMode {
Dark,
Light,
Auto,
}
impl ThemeMode {
pub fn detect() -> Self {
use terminal_colorsaurus::{theme_mode, QueryOptions};
match theme_mode(QueryOptions::default()) {
Ok(terminal_colorsaurus::ThemeMode::Dark) => ThemeMode::Dark,
Ok(terminal_colorsaurus::ThemeMode::Light) => ThemeMode::Light,
Err(_) => ThemeMode::Dark,
}
}
}
pub fn syntax_set() -> &'static SyntaxSet {
SYNTAX_SET.get_or_init(|| SyntaxSet::load_defaults_newlines())
}
pub fn themes() -> &'static ThemeSet {
THEME_SET.get_or_init(|| ThemeSet::load_defaults())
}
pub fn list_themes() -> Vec<&'static str> {
themes().themes.keys().map(|k| k.as_str()).collect()
}
fn resolve_theme(theme_mode: ThemeMode, custom: Option<&str>) -> &Theme {
if let Some(name) = custom {
if let Some(theme) = themes().themes.get(name) {
return theme;
}
}
let resolved = match theme_mode {
ThemeMode::Auto => ThemeMode::detect(),
other => other,
};
match resolved {
ThemeMode::Dark => &themes().themes["base16-eighties.dark"],
ThemeMode::Light => &themes().themes["Solarized (light)"],
ThemeMode::Auto => unreachable!(),
}
}
pub fn highlight_lines(
lang: &str,
lines: &[String],
theme_mode: ThemeMode,
custom_theme: Option<&str>,
) -> Option<Vec<String>> {
use syntect::easy::HighlightLines;
use syntect::highlighting::FontStyle;
let syntax = syntax_set().find_syntax_by_token(lang)?;
let theme = resolve_theme(theme_mode, custom_theme);
let mut highlighter = HighlightLines::new(syntax, theme);
let mut out = Vec::new();
out.push(format!("\x1b[1m┌ \x1b[34m{lang}\x1b[0m"));
for line in lines {
let ranges = highlighter.highlight_line(line, syntax_set()).ok()?;
let mut rendered = String::new();
for (style, text) in &ranges {
let mut codes: Vec<String> = Vec::new();
if style.font_style.contains(FontStyle::BOLD) {
codes.push("1".into());
}
if style.font_style.contains(FontStyle::ITALIC) {
codes.push("3".into());
}
if style.font_style.contains(FontStyle::UNDERLINE) {
codes.push("4".into());
}
let color = style.foreground;
let (r, g, b) = boost_rgb(color.r, color.g, color.b);
codes.push(format!("38;2;{r};{g};{b}"));
if !codes.is_empty() {
let escape = codes.join(";");
rendered.push_str(&format!("\x1b[{escape}m"));
}
rendered.push_str(text);
}
rendered.push_str("\x1b[0m");
out.push(format!("\x1b[1m│\x1b[0m {rendered}"));
}
out.push("\x1b[1m└─\x1b[0m".to_string());
out.push("\x1b[0m".to_string());
Some(out)
}
fn boost_rgb(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
let max_ch = r.max(g).max(b);
if max_ch < 10 {
return (r, g, b);
}
(
((r as u16 * 255 + max_ch as u16 / 2) / max_ch as u16).min(255) as u8,
((g as u16 * 255 + max_ch as u16 / 2) / max_ch as u16).min(255) as u8,
((b as u16 * 255 + max_ch as u16 / 2) / max_ch as u16).min(255) as u8,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn boost_rgb_full_saturation() {
let (_, _, b) = boost_rgb(100, 50, 200);
assert_eq!(b, 255, "brightest channel should be 255");
}
#[test]
fn boost_rgb_preserves_ratio() {
let (r, g, b) = boost_rgb(50, 100, 0);
assert_eq!(g, 255);
assert!(r >= 126 && r <= 129, "red should be ~127, got {r}");
assert_eq!(b, 0);
}
#[test]
fn boost_rgb_near_black_unchanged() {
assert_eq!(boost_rgb(5, 5, 5), (5, 5, 5));
}
#[test]
fn boost_rgb_already_bright() {
assert_eq!(boost_rgb(255, 128, 64), (255, 128, 64));
}
#[test]
fn syntax_set_initializes() {
let ss = syntax_set();
assert!(ss.find_syntax_by_token("rust").is_some());
}
#[test]
fn themes_loads_all_seven() {
let names = list_themes();
assert_eq!(names.len(), 7, "expected 7 bundled themes");
}
#[test]
fn themes_have_expected_keys() {
let names = list_themes();
assert!(names.contains(&"base16-eighties.dark"));
assert!(names.contains(&"base16-ocean.dark"));
assert!(names.contains(&"base16-mocha.dark"));
assert!(names.contains(&"base16-ocean.light"));
assert!(names.contains(&"InspiredGitHub"));
assert!(names.contains(&"Solarized (dark)"));
assert!(names.contains(&"Solarized (light)"));
}
#[test]
fn resolve_theme_dark_explicit() {
let theme = resolve_theme(ThemeMode::Dark, None);
assert_eq!(theme.name.as_deref(), Some("Base16 Eighties Dark"));
}
#[test]
fn resolve_theme_light_explicit() {
let theme = resolve_theme(ThemeMode::Light, None);
assert_eq!(theme.name.as_deref(), Some("Solarized (light)"));
}
#[test]
fn resolve_theme_custom_name() {
let theme = resolve_theme(ThemeMode::Dark, Some("InspiredGitHub"));
assert_eq!(theme.name.as_deref(), Some("GitHub"));
}
#[test]
fn resolve_theme_custom_invalid_falls_back() {
let theme = resolve_theme(ThemeMode::Dark, Some("doesnotexist"));
assert_eq!(theme.name.as_deref(), Some("Base16 Eighties Dark"));
}
#[test]
fn highlight_lines_rust() {
let lines: Vec<String> = vec!["fn main() {".into(), " let x = 42;".into(), "}".into()];
let result = highlight_lines("rust", &lines, ThemeMode::Dark, None).unwrap();
assert!(result.len() > 3);
assert!(result[0].contains("rust"));
assert!(result[1].contains("fn"));
assert!(result.iter().any(|l| l.contains("38;2;")));
}
#[test]
fn highlight_lines_python() {
let lines: Vec<String> = vec!["def hello():".into(), " return 1".into()];
let result = highlight_lines("python", &lines, ThemeMode::Dark, None).unwrap();
assert!(result[0].contains("python"));
assert!(result.iter().any(|l| l.contains("def")));
}
#[test]
fn highlight_lines_unknown_lang() {
let lines: Vec<String> = vec!["some code".into()];
assert!(highlight_lines("zzz", &lines, ThemeMode::Dark, None).is_none());
}
#[test]
fn highlight_lines_custom_theme() {
let lines: Vec<String> = vec!["let x = 1;".into()];
let result = highlight_lines("rust", &lines, ThemeMode::Dark, Some("base16-ocean.dark")).unwrap();
assert!(result.len() > 0);
}
#[test]
fn highlight_lines_empty_code() {
let lines: Vec<String> = vec![];
let result = highlight_lines("rust", &lines, ThemeMode::Dark, None).unwrap();
assert!(result[0].contains("rust"));
assert!(result.contains(&"\x1b[1m└─\x1b[0m".to_string()));
}
#[test]
fn theme_mode_detect_returns_dark_or_light() {
let mode = ThemeMode::detect();
assert!(matches!(mode, ThemeMode::Dark | ThemeMode::Light));
}
}