diff --git a/src/highlighter.rs b/src/highlighter.rs new file mode 100644 index 0000000..b189624 --- /dev/null +++ b/src/highlighter.rs @@ -0,0 +1,171 @@ +use std::sync::OnceLock; +use syntect::easy::HighlightLines; +use syntect::highlighting::ThemeSet; +use syntect::parsing::SyntaxSet; +use ratatui::text::{Line, Span}; +use ratatui::style::Style; + +// ── Module-level OnceLock statics ───────────────────────────────────────────── + +static SYNTAX_SET: OnceLock = OnceLock::new(); +static THEME_SET: OnceLock = OnceLock::new(); + +// ── Initialization ──────────────────────────────────────────────────────────── + +/// Initialize syntect's embedded syntax and theme sets. +/// +/// Must be called once before any call to `highlight_code()`. +/// Safe to call multiple times — subsequent calls are no-ops. +pub fn init_highlighter() { + SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines); + THEME_SET.get_or_init(ThemeSet::load_defaults); +} + +// ── Accessors ───────────────────────────────────────────────────────────────── + +/// Return a reference to the initialized `SyntaxSet`. +/// +/// # Panics +/// +/// Panics if `init_highlighter()` has not been called. +pub fn syntax_set() -> &'static SyntaxSet { + SYNTAX_SET.get().expect("init_highlighter() must be called before syntax_set()") +} + +/// Return a reference to the initialized `ThemeSet`. +/// +/// # Panics +/// +/// Panics if `init_highlighter()` has not been called. +pub fn theme_set() -> &'static ThemeSet { + THEME_SET.get().expect("init_highlighter() must be called before theme_set()") +} + +// ── CGA color mapping ───────────────────────────────────────────────────────── + +/// Map a syntect RGB color to the nearest CGA 16-color ratatui `Color`. +/// +/// Uses Euclidean distance in RGB space to find the closest match among +/// the classic 16-color CGA palette. +pub fn syntect_color_to_cga(c: syntect::highlighting::Color) -> ratatui::style::Color { + use ratatui::style::Color; + + // CGA 16-color palette: (r, g, b, ratatui Color variant) + const CGA: &[(u8, u8, u8, Color)] = &[ + (0, 0, 0, Color::Black), + (170, 0, 0, Color::Red), + (0, 170, 0, Color::Green), + (170, 170, 0, Color::Yellow), + (0, 0, 170, Color::Blue), + (170, 0, 170, Color::Magenta), + (0, 170, 170, Color::Cyan), + (170, 170, 170, Color::Gray), + (85, 85, 85, Color::DarkGray), + (255, 85, 85, Color::LightRed), + (85, 255, 85, Color::LightGreen), + (255, 255, 85, Color::LightYellow), + (85, 85, 255, Color::LightBlue), + (255, 85, 255, Color::LightMagenta), + (85, 255, 255, Color::LightCyan), + (255, 255, 255, Color::White), + ]; + + let mut best = Color::White; + let mut best_dist = u32::MAX; + + for &(r, g, b, color) in CGA { + let dr = (c.r as i32 - r as i32).pow(2) as u32; + let dg = (c.g as i32 - g as i32).pow(2) as u32; + let db = (c.b as i32 - b as i32).pow(2) as u32; + let dist = dr + dg + db; + if dist < best_dist { + best_dist = dist; + best = color; + } + } + + best +} + +// ── Syntax highlighting ─────────────────────────────────────────────────────── + +/// Highlight a code string into a list of styled ratatui lines. +/// +/// Looks up the syntax definition for `lang` (by token first, then by name), +/// falling back to plain text if no match is found. Applies the +/// `base16-ocean.dark` theme with colors quantized to the CGA 16-color palette. +/// +/// Returns `Vec>` — all content is owned, no lifetime leakage. +/// +/// # Panics +/// +/// Panics if `init_highlighter()` has not been called. +pub fn highlight_code(code: &str, lang: &str) -> Vec> { + let ss = syntax_set(); + let ts = theme_set(); + + let syntax = ss + .find_syntax_by_token(lang) + .or_else(|| ss.find_syntax_by_name(lang)) + .unwrap_or_else(|| ss.find_syntax_plain_text()); + + let mut highlighter = HighlightLines::new(syntax, &ts.themes["base16-ocean.dark"]); + + let mut result = Vec::new(); + for line in LinesWithNewlines::new(code) { + let ranges = highlighter + .highlight_line(line, ss) + .unwrap_or_default(); + + let spans: Vec> = ranges + .into_iter() + .map(|(style, text)| { + let fg = syntect_color_to_cga(style.foreground); + Span::styled(text.to_string(), Style::default().fg(fg)) + }) + .collect(); + + result.push(Line::from(spans)); + } + + result +} + +// ── LinesWithNewlines iterator ──────────────────────────────────────────────── + +/// Iterator that yields lines with their trailing newline preserved. +/// +/// syntect's `highlight_line()` expects lines to include the `\n` terminator +/// so that language grammars can match end-of-line anchors correctly. +struct LinesWithNewlines<'a> { + remaining: &'a str, +} + +impl<'a> LinesWithNewlines<'a> { + fn new(s: &'a str) -> Self { + Self { remaining: s } + } +} + +impl<'a> Iterator for LinesWithNewlines<'a> { + type Item = &'a str; + + fn next(&mut self) -> Option { + if self.remaining.is_empty() { + return None; + } + match self.remaining.find('\n') { + Some(pos) => { + let line = &self.remaining[..=pos]; // include the \n + self.remaining = &self.remaining[pos + 1..]; + Some(line) + } + None => { + // Last line without trailing newline + let line = self.remaining; + self.remaining = ""; + Some(line) + } + } + } +}