Skip to main content

smart_markdown/
highlight.rs

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