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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user