use crate::theme::Theme;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, VecDeque};
use std::hash::{Hash, Hasher};
use std::rc::Rc;
use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle, HighlightState};
use syntect::parsing::{ParseState, SyntaxReference, SyntaxSet};
const MAX_CACHE_ENTRIES: usize = 512;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct HighlightStats {
pub calls: u64,
pub cache_misses: u64,
pub bytes_highlighted: u64,
}
type CacheKey = (u64, u64);
pub type HighlightedLines = Rc<[Line<'static>]>;
#[derive(Debug, Clone)]
pub struct CodeBlockState {
highlight: HighlightState,
parse: ParseState,
}
#[derive(Clone)]
struct CacheEntry {
lines: HighlightedLines,
state: Option<CodeBlockState>,
}
pub struct SyntaxHighlighter {
syntax_set: SyntaxSet,
cache: HashMap<CacheKey, CacheEntry>,
insertion_order: VecDeque<CacheKey>,
stats: HighlightStats,
}
impl SyntaxHighlighter {
pub fn new() -> Self {
Self {
syntax_set: two_face::syntax::extra_newlines(),
cache: HashMap::new(),
insertion_order: VecDeque::new(),
stats: HighlightStats::default(),
}
}
pub fn take_stats(&mut self) -> HighlightStats {
std::mem::take(&mut self.stats)
}
pub fn highlight(&mut self, code: &str, language: &str, theme: &Theme) -> HighlightedLines {
self.highlight_seeded(code, language, theme, None).0
}
pub fn highlight_seeded(
&mut self,
code: &str,
language: &str,
theme: &Theme,
seed: Option<CodeBlockState>,
) -> (HighlightedLines, Option<CodeBlockState>) {
self.stats.calls += 1;
if let Some(seed) = seed {
return self.highlight_uncached(code, language, theme, Some(seed));
}
let key = {
let mut code_hasher = DefaultHasher::new();
code.hash(&mut code_hasher);
let mut language_hasher = DefaultHasher::new();
language.hash(&mut language_hasher);
(code_hasher.finish(), language_hasher.finish())
};
if let Some(entry) = self.cache.get(&key) {
return (Rc::clone(&entry.lines), entry.state.clone());
}
let (lines, state) = self.highlight_uncached(code, language, theme, None);
if self.cache.len() >= MAX_CACHE_ENTRIES
&& let Some(oldest) = self.insertion_order.pop_front()
{
self.cache.remove(&oldest);
}
self.insertion_order.push_back(key);
self.cache.insert(key, CacheEntry { lines: Rc::clone(&lines), state: state.clone() });
(lines, state)
}
fn highlight_uncached(
&mut self,
code: &str,
language: &str,
theme: &Theme,
seed: Option<CodeBlockState>,
) -> (HighlightedLines, Option<CodeBlockState>) {
self.stats.cache_misses += 1;
self.stats.bytes_highlighted += code.len() as u64;
let Some(syntax) = find_syntax(&self.syntax_set, language) else {
let lines: HighlightedLines = Rc::from(
code.split('\n')
.map(|line| Line::styled(line.to_string(), Style::new().fg(theme.code_fg).bg(theme.code_bg)))
.collect::<Vec<_>>(),
);
return (lines, None);
};
let mut highlighter = match seed {
Some(state) => HighlightLines::from_state(theme.syntect(), state.highlight, state.parse),
None => HighlightLines::new(syntax, theme.syntect()),
};
let lines = highlight_lines(&mut highlighter, code, &self.syntax_set, theme);
let (highlight, parse) = highlighter.state();
(Rc::from(lines), Some(CodeBlockState { highlight, parse }))
}
pub fn clear(&mut self) {
self.cache.clear();
self.insertion_order.clear();
}
}
fn highlight_lines(
highlighter: &mut HighlightLines<'_>,
code: &str,
syntax_set: &SyntaxSet,
theme: &Theme,
) -> Vec<Line<'static>> {
code.split('\n')
.map(|source_line| {
let line_with_ending = format!("{source_line}\n");
match highlighter.highlight_line(&line_with_ending, syntax_set) {
Ok(ranges) => Line::from(
ranges
.into_iter()
.filter_map(|(style, text)| {
let text = text.strip_suffix('\n').unwrap_or(text);
(!text.is_empty()).then(|| Span::styled(text.to_string(), style_from_syntect(style)))
})
.collect::<Vec<_>>(),
),
Err(_) => Line::styled(source_line.to_string(), Style::new().fg(theme.code_fg)),
}
})
.collect()
}
impl Default for SyntaxHighlighter {
fn default() -> Self {
Self::new()
}
}
fn find_syntax<'a>(syntax_set: &'a SyntaxSet, hint: &str) -> Option<&'a SyntaxReference> {
let language = hint
.split(|character: char| character.is_whitespace() || character == ',')
.find(|part| !part.is_empty())
.unwrap_or_default()
.to_ascii_lowercase();
let normalized = match language.as_str() {
"typescript" => "ts",
"typescriptreact" => "tsx",
"javascript" | "jsx" => "js",
"python" => "py",
"rust" => "rs",
"c99" | "c11" => "c",
"c++" | "cxx" | "cc" => "cpp",
"c#" | "csharp" => "cs",
"ruby" => "rb",
"kotlin" | "kts" => "kt",
"shell" | "bash" | "zsh" => "sh",
"yml" => "yaml",
"markdown" => "md",
language => language,
};
if normalized.is_empty() {
return None;
}
syntax_set.find_syntax_by_extension(normalized).or_else(|| syntax_set.find_syntax_by_token(normalized))
}
fn style_from_syntect(style: syntect::highlighting::Style) -> Style {
let mut modifiers = Modifier::empty();
if style.font_style.contains(FontStyle::BOLD) {
modifiers.insert(Modifier::BOLD);
}
if style.font_style.contains(FontStyle::ITALIC) {
modifiers.insert(Modifier::ITALIC);
}
if style.font_style.contains(FontStyle::UNDERLINE) {
modifiers.insert(Modifier::UNDERLINED);
}
Style::new().fg(Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b)).add_modifier(modifiers)
}