feat(02-01): add syntax highlighter module with CGA color mapping

- Create src/highlighter.rs with OnceLock-based one-time syntect initialization
- Implement init_highlighter(), syntax_set(), theme_set() accessors
- Implement syntect_color_to_cga() with Euclidean RGB-to-CGA 16-color quantization
- Implement highlight_code() returning Vec<Line<'static>> using base16-ocean.dark theme
- LinesWithNewlines iterator preserves newlines for syntect grammar correctness
- Module verified to compile cleanly (tested via temporary mod registration)
This commit is contained in:
2026-02-28 22:12:53 +01:00
parent 7622d41496
commit 8d45a7c263
+171
View File
@@ -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<SyntaxSet> = OnceLock::new();
static THEME_SET: OnceLock<ThemeSet> = 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<Line<'static>>` — 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<Line<'static>> {
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<Span<'static>> = 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<Self::Item> {
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)
}
}
}
}