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