use ratatui::style::Color;
use std::path::Path;
use syntect::easy::HighlightLines;
use syntect::highlighting::{Style, ThemeSet};
use syntect::parsing::SyntaxSet;
pub struct Highlighter {
syntax_set: SyntaxSet,
theme_set: ThemeSet,
theme_name: String,
}
pub struct HlToken {
pub text: String,
pub fg: Color,
}
impl Highlighter {
pub fn new() -> Self {
Self {
syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(),
theme_name: "base16-eighties.dark".to_string(),
}
}
pub fn highlight_line(&self, line: &str, path: &Path) -> Vec<HlToken> {
let ext = path.extension().and_then(|e| e.to_str());
let syntax = ext
.and_then(|e| self.syntax_set.find_syntax_by_extension(e))
.or_else(|| {
let fallback = match ext {
Some("ts" | "mts" | "cts") => Some("js"),
Some("tsx") => Some("jsx"),
Some("vue" | "svelte") => Some("html"),
Some("jsonc") => Some("json"),
_ => None,
};
fallback.and_then(|f| self.syntax_set.find_syntax_by_extension(f))
})
.or_else(|| {
path.file_name()
.and_then(|n| n.to_str())
.and_then(|name| self.syntax_set.find_syntax_by_extension(name))
})
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
let theme = self
.theme_set
.themes
.get(&self.theme_name)
.unwrap_or_else(|| {
self.theme_set
.themes
.values()
.next()
.expect("at least one theme")
});
let mut hl = HighlightLines::new(syntax, theme);
match hl.highlight_line(line, &self.syntax_set) {
Ok(tokens) => tokens
.into_iter()
.map(|(style, text)| HlToken {
text: text.to_string(),
fg: syntect_to_ratatui_color(style),
})
.collect(),
Err(_) => vec![HlToken {
text: line.to_string(),
fg: Color::Reset,
}],
}
}
}
fn syntect_to_ratatui_color(style: Style) -> Color {
let c = style.foreground;
Color::Rgb(c.r, c.g, c.b)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn highlight_rust_code_produces_multiple_tokens() {
let hl = Highlighter::new();
let tokens = hl.highlight_line("fn main() {}", Path::new("test.rs"));
assert!(tokens.len() > 1, "Rust code should produce multiple tokens");
}
#[test]
fn highlight_typescript_code_produces_multiple_tokens() {
let hl = Highlighter::new();
let tokens = hl.highlight_line("const x: number = 42;", Path::new("app.ts"));
assert!(
tokens.len() > 1,
"TypeScript should produce multiple tokens, got {} — syntect may not recognise .ts",
tokens.len()
);
}
#[test]
fn ts_falls_back_to_js_highlighting() {
let hl = Highlighter::new();
let tokens = hl.highlight_line("const x: number = 42;", Path::new("app.ts"));
assert!(
tokens.len() > 1,
".ts should produce highlighted tokens via JS fallback, got {}",
tokens.len()
);
}
#[test]
fn highlight_unknown_extension_returns_single_token() {
let hl = Highlighter::new();
let tokens = hl.highlight_line("hello world", Path::new("file.xyzunknown"));
assert!(!tokens.is_empty());
}
#[test]
fn highlight_empty_line_does_not_panic() {
let hl = Highlighter::new();
let tokens = hl.highlight_line("", Path::new("a.rs"));
let _ = tokens;
}
}