Skip to main content

smart_markdown/
highlight.rs

1//! Syntax highlighting for fenced code blocks.
2//!
3//! This module provides syntect-based syntax highlighting, theme management,
4//! and terminal background detection. It is gated behind the `syntax-highlight`
5//! feature (enabled by default).
6//!
7//! # Built-in themes
8//!
9//! Seven themes are bundled with the crate:
10//!
11//! - `base16-eighties.dark` (default for dark mode)
12//! - `base16-ocean.dark`
13//! - `base16-mocha.dark`
14//! - `base16-ocean.light`
15//! - `InspiredGitHub`
16//! - `Solarized (dark)`
17//! - `Solarized (light)` (default for light mode)
18
19use 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/// Controls which syntax highlighting theme is applied to code blocks.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ThemeMode {
29    /// Dark background. Uses "Base16 Eighties Dark".
30    Dark,
31    /// Light background. Uses "Solarized (light)".
32    Light,
33    /// Detects terminal background color at render time using `terminal-colorsaurus`.
34    Auto,
35}
36
37impl ThemeMode {
38    /// Detect the terminal background color using `terminal-colorsaurus`.
39    ///
40    /// Falls back to `ThemeMode::Dark` if detection fails.
41    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
51/// Returns the global syntax set (lazy-initialized).
52///
53/// Contains syntax definitions for all languages supported by syntect.
54pub fn syntax_set() -> &'static SyntaxSet {
55    SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
56}
57
58/// Returns the global theme set (lazy-initialized).
59///
60/// Contains the seven bundled color themes.
61pub fn themes() -> &'static ThemeSet {
62    THEME_SET.get_or_init(ThemeSet::load_defaults)
63}
64
65/// Lists the names of all available syntax highlighting themes.
66///
67/// ```rust
68/// # #[cfg(feature = "syntax-highlight")] {
69/// let themes = smart_markdown::highlight::list_themes();
70/// assert!(themes.contains(&"Solarized (dark)"));
71/// # }
72/// ```
73pub 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
94/// Highlight source code lines using syntect.
95///
96/// Returns `None` if the language is not recognized. On success, returns
97/// ANSI-escaped lines including a header bar and footer.
98///
99/// - `lang` — syntect language token (e.g. `"rust"`, `"python"`, `"bash"`)
100/// - `lines` — source lines to highlight
101/// - `theme_mode` — dark/light/auto theme selection
102/// - `custom_theme` — optional theme name override (see [`list_themes`])
103pub 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}