use ratatui::style::{Color as RColor, Modifier, Style as RStyle};
use ratatui::text::{Line, Span};
use syntect::easy::HighlightLines;
use syntect::highlighting::{
Color as SColor, FontStyle, ScopeSelectors, StyleModifier, Theme as STheme, ThemeItem,
ThemeSettings,
};
use syntect::parsing::SyntaxSet;
use crate::theme::Theme;
pub struct Highlighter {
syntaxes: SyntaxSet,
theme: STheme,
}
fn scolor(c: RColor) -> SColor {
match c {
RColor::Rgb(r, g, b) => SColor { r, g, b, a: 255 },
_ => SColor {
r: 255,
g: 255,
b: 255,
a: 255,
},
}
}
fn syntect_theme(theme: &Theme) -> STheme {
let syn = &theme.syntax;
let text = scolor(theme.semantic.text);
let rule = |scope: &str, fg: RColor| ThemeItem {
scope: scope.parse::<ScopeSelectors>().expect("scope selector"),
style: StyleModifier {
foreground: Some(scolor(fg)),
background: None,
font_style: None,
},
};
STheme {
name: Some("rootle-palette".into()),
author: Some("rootle".into()),
settings: ThemeSettings {
foreground: Some(text),
..Default::default()
},
scopes: vec![
rule("keyword, storage", syn.keyword),
rule("string, string.quoted", syn.string),
rule("comment, punctuation.definition.comment", syn.comment),
rule(
"entity.name.function, support.function, meta.function-call",
syn.function,
),
rule(
"entity.name.type, support.type, entity.name.struct, entity.name.enum",
syn.type_,
),
rule("constant.numeric, constant.language", syn.constant),
rule("entity.name.tag, markup.heading", syn.tag),
rule("variable.parameter, variable.other", theme.semantic.text),
rule("entity.name.namespace, meta.path", syn.namespace),
rule("markup.bold, punctuation.definition.bold", syn.constant),
rule("markup.italic", syn.keyword),
rule("markup.raw, markup.fenced_code", syn.string),
rule("invalid, invalid.illegal", syn.invalid),
],
}
}
impl Default for Highlighter {
fn default() -> Self {
Self::new(&Theme::catppuccin_mocha())
}
}
impl Highlighter {
pub fn new(theme: &Theme) -> Self {
Highlighter {
syntaxes: SyntaxSet::load_defaults_newlines(),
theme: syntect_theme(theme),
}
}
pub fn set_theme(&mut self, theme: &Theme) {
self.theme = syntect_theme(theme);
}
pub fn language(&self, filename: &str) -> String {
self.syntaxes
.find_syntax_for_file(filename)
.ok()
.flatten()
.map(|s| s.name.to_lowercase())
.unwrap_or_else(|| "text".into())
}
pub fn highlight(&self, filename: &str, text: &str) -> Vec<Line<'static>> {
let syntax = self
.syntaxes
.find_syntax_for_file(filename)
.ok()
.flatten()
.unwrap_or_else(|| self.syntaxes.find_syntax_plain_text());
let mut highlighter = HighlightLines::new(syntax, &self.theme);
text.lines()
.map(|line| {
let regions = match highlighter.highlight_line(line, &self.syntaxes) {
Ok(r) => r,
Err(_) => {
return Line::from(Span::raw(line.replace('\t', " ")));
}
};
Line::from(
regions
.into_iter()
.map(|(style, text)| {
let fg = style.foreground;
let mut rstyle =
RStyle::default().fg(ratatui::style::Color::Rgb(fg.r, fg.g, fg.b));
if style.font_style.contains(FontStyle::BOLD) {
rstyle = rstyle.add_modifier(Modifier::BOLD);
}
Span::styled(text.replace('\t', " "), rstyle)
})
.collect::<Vec<_>>(),
)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rust_keywords_get_mauve() {
let h = Highlighter::default();
let lines = h.highlight("lib.rs", "fn main() {}\n");
assert_eq!(lines.len(), 1);
let first = &lines[0].spans[0];
assert_eq!(
first.style.fg,
Some(ratatui::style::Color::Rgb(203, 166, 247)),
"fn keyword should be mauve, got {:?}",
first.style.fg
);
}
#[test]
fn theme_switch_recolors_keywords() {
let dracula = Highlighter::new(&Theme::embedded("dracula").unwrap());
let lines = dracula.highlight("lib.rs", "fn main() {}\n");
assert_eq!(
lines[0].spans[0].style.fg,
Some(ratatui::style::Color::Rgb(255, 121, 198)),
"fn keyword should follow the dracula palette, got {:?}",
lines[0].spans[0].style.fg
);
}
#[test]
fn tabs_expand_inside_spans() {
let h = Highlighter::default();
let lines = h.highlight("main.rs", "\tfn x() {}\n");
let joined: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(joined, " fn x() {}");
assert!(!joined.contains('\t'));
}
#[test]
fn language_label_follows_extension() {
let h = Highlighter::default();
assert_eq!(h.language("lib.rs"), "rust");
assert_eq!(h.language("Cargo.toml"), "text");
assert_eq!(h.language("data.xyz123"), "text");
}
#[test]
fn unknown_extension_renders_plain() {
let h = Highlighter::default();
let lines = h.highlight("data.xyz123", "just text\n");
assert_eq!(lines.len(), 1);
}
}