use std::sync::LazyLock;
use ratatui::style::{Color, Modifier, Style};
use syntect::{
easy::HighlightLines,
highlighting::{FontStyle, ThemeSet},
parsing::SyntaxSet,
util::LinesWithEndings,
};
pub static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
pub static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
pub type TokenLine = Vec<(String, Style)>;
pub fn highlight_code(
source: &str,
lang_token: Option<&str>,
theme_name: &str,
fallback_fg: Color,
bg: Color,
) -> Vec<TokenLine> {
let syntax_set = &*SYNTAX_SET;
let theme_set = &*THEME_SET;
let syntax = lang_token
.and_then(|t| syntax_set.find_syntax_by_token(t))
.unwrap_or_else(|| syntax_set.find_syntax_plain_text());
let Some(theme) = theme_set.themes.get(theme_name) else {
return plain_text_lines(source, fallback_fg, bg);
};
let mut highlighter = HighlightLines::new(syntax, theme);
let mut result = Vec::new();
for raw_line in LinesWithEndings::from(source) {
let line_text = raw_line.trim_end_matches('\n');
match highlighter.highlight_line(raw_line, syntax_set) {
Ok(tokens) => {
let token_line: TokenLine = tokens
.iter()
.map(|(style, fragment)| {
let text = fragment.trim_end_matches('\n').to_string();
(text, syntect_to_ratatui(*style, bg))
})
.filter(|(text, _)| !text.is_empty())
.collect();
if token_line.is_empty() && !line_text.is_empty() {
result.push(vec![(
line_text.to_string(),
Style::default().fg(fallback_fg).bg(bg),
)]);
} else {
result.push(token_line);
}
}
Err(_) => {
result.push(vec![(
line_text.to_string(),
Style::default().fg(fallback_fg).bg(bg),
)]);
}
}
}
result
}
fn plain_text_lines(source: &str, fallback_fg: Color, bg: Color) -> Vec<TokenLine> {
let style = Style::default().fg(fallback_fg).bg(bg);
source
.split('\n')
.filter(|line| !line.is_empty() || source.trim_end_matches('\n') != source.trim())
.map(|line| vec![(line.to_string(), style)])
.collect()
}
pub fn syntect_to_ratatui(style: syntect::highlighting::Style, bg: Color) -> Style {
let fg = Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b);
let mut ratatui_style = Style::default().fg(fg).bg(bg);
if style.font_style.contains(FontStyle::BOLD) {
ratatui_style = ratatui_style.add_modifier(Modifier::BOLD);
}
if style.font_style.contains(FontStyle::ITALIC) {
ratatui_style = ratatui_style.add_modifier(Modifier::ITALIC);
}
if style.font_style.contains(FontStyle::UNDERLINE) {
ratatui_style = ratatui_style.add_modifier(Modifier::UNDERLINED);
}
ratatui_style
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Theme;
#[test]
fn all_theme_variants_resolve_syntect_theme() {
let theme_set = &*THEME_SET;
for theme in Theme::ALL {
let name = theme.syntax_theme_name();
assert!(
theme_set.themes.contains_key(name),
"Theme::{theme:?} maps to '{name}' which is not in the bundled ThemeSet",
);
}
}
#[test]
fn rust_code_block_has_multiple_distinct_colors() {
let source = "let x: i32 = 42;\n";
let lines = highlight_code(
source,
Some("rust"),
"base16-ocean.dark",
Color::White,
Color::Black,
);
assert!(!lines.is_empty(), "expected at least one line");
let all_tokens: Vec<&(String, Style)> = lines.iter().flatten().collect();
assert!(
all_tokens.len() > 1,
"expected multiple tokens, got {}",
all_tokens.len(),
);
let colors: std::collections::HashSet<Color> =
all_tokens.iter().filter_map(|(_, s)| s.fg).collect();
assert!(
colors.len() > 1,
"expected multiple distinct foreground colors, got {colors:?}",
);
}
#[test]
fn no_language_fallback_is_single_color() {
let source = "hello world\nsome code\n";
let lines = highlight_code(
source,
None,
"base16-ocean.dark",
Color::White,
Color::Black,
);
let colors: std::collections::HashSet<Color> =
lines.iter().flatten().filter_map(|(_, s)| s.fg).collect();
assert_eq!(
colors.len(),
1,
"expected one foreground color for plain-text fallback, got {colors:?}",
);
}
#[test]
fn unknown_language_falls_back_without_panic() {
let source = "some unknown code\n";
let lines = highlight_code(
source,
Some("notalang"),
"base16-ocean.dark",
Color::White,
Color::Black,
);
assert!(!lines.is_empty(), "expected output for unknown language");
for line in &lines {
assert_eq!(
line.len(),
1,
"expected single token per line for unknown language fallback",
);
}
}
}