1use crate::theme::Theme;
2use ratatui::style::{Color, Modifier, Style};
3use ratatui::text::{Line, Span};
4use std::collections::hash_map::DefaultHasher;
5use std::collections::{HashMap, VecDeque};
6use std::hash::{Hash, Hasher};
7use std::rc::Rc;
8use syntect::easy::HighlightLines;
9use syntect::highlighting::{FontStyle, HighlightState};
10use syntect::parsing::{ParseState, SyntaxReference, SyntaxSet};
11
12const MAX_CACHE_ENTRIES: usize = 512;
13
14#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
19pub struct HighlightStats {
20 pub calls: u64,
21 pub cache_misses: u64,
22 pub bytes_highlighted: u64,
23}
24
25type CacheKey = (u64, u64);
29
30pub type HighlightedLines = Rc<[Line<'static>]>;
33
34#[derive(Debug, Clone)]
38pub struct CodeBlockState {
39 highlight: HighlightState,
40 parse: ParseState,
41}
42
43#[derive(Clone)]
44struct CacheEntry {
45 lines: HighlightedLines,
46 state: Option<CodeBlockState>,
47}
48
49pub struct SyntaxHighlighter {
50 syntax_set: SyntaxSet,
51 cache: HashMap<CacheKey, CacheEntry>,
52 insertion_order: VecDeque<CacheKey>,
56 stats: HighlightStats,
57}
58
59impl SyntaxHighlighter {
60 pub fn new() -> Self {
61 Self {
62 syntax_set: two_face::syntax::extra_newlines(),
63 cache: HashMap::new(),
64 insertion_order: VecDeque::new(),
65 stats: HighlightStats::default(),
66 }
67 }
68
69 pub fn take_stats(&mut self) -> HighlightStats {
70 std::mem::take(&mut self.stats)
71 }
72
73 pub fn highlight(&mut self, code: &str, language: &str, theme: &Theme) -> HighlightedLines {
74 self.highlight_seeded(code, language, theme, None).0
75 }
76
77 pub fn highlight_seeded(
85 &mut self,
86 code: &str,
87 language: &str,
88 theme: &Theme,
89 seed: Option<CodeBlockState>,
90 ) -> (HighlightedLines, Option<CodeBlockState>) {
91 self.stats.calls += 1;
92 if let Some(seed) = seed {
93 return self.highlight_uncached(code, language, theme, Some(seed));
94 }
95 let key = {
96 let mut code_hasher = DefaultHasher::new();
97 code.hash(&mut code_hasher);
98 let mut language_hasher = DefaultHasher::new();
99 language.hash(&mut language_hasher);
100 (code_hasher.finish(), language_hasher.finish())
101 };
102 if let Some(entry) = self.cache.get(&key) {
103 return (Rc::clone(&entry.lines), entry.state.clone());
104 }
105 let (lines, state) = self.highlight_uncached(code, language, theme, None);
106 if self.cache.len() >= MAX_CACHE_ENTRIES
107 && let Some(oldest) = self.insertion_order.pop_front()
108 {
109 self.cache.remove(&oldest);
110 }
111 self.insertion_order.push_back(key);
112 self.cache.insert(key, CacheEntry { lines: Rc::clone(&lines), state: state.clone() });
113 (lines, state)
114 }
115
116 fn highlight_uncached(
117 &mut self,
118 code: &str,
119 language: &str,
120 theme: &Theme,
121 seed: Option<CodeBlockState>,
122 ) -> (HighlightedLines, Option<CodeBlockState>) {
123 self.stats.cache_misses += 1;
124 self.stats.bytes_highlighted += code.len() as u64;
125 let Some(syntax) = find_syntax(&self.syntax_set, language) else {
126 let lines: HighlightedLines = Rc::from(
127 code.split('\n')
128 .map(|line| Line::styled(line.to_string(), Style::new().fg(theme.code_fg).bg(theme.code_bg)))
129 .collect::<Vec<_>>(),
130 );
131 return (lines, None);
132 };
133 let mut highlighter = match seed {
134 Some(state) => HighlightLines::from_state(theme.syntect(), state.highlight, state.parse),
135 None => HighlightLines::new(syntax, theme.syntect()),
136 };
137 let lines = highlight_lines(&mut highlighter, code, &self.syntax_set, theme);
138 let (highlight, parse) = highlighter.state();
139 (Rc::from(lines), Some(CodeBlockState { highlight, parse }))
140 }
141
142 pub fn clear(&mut self) {
143 self.cache.clear();
144 self.insertion_order.clear();
145 }
146}
147
148fn highlight_lines(
149 highlighter: &mut HighlightLines<'_>,
150 code: &str,
151 syntax_set: &SyntaxSet,
152 theme: &Theme,
153) -> Vec<Line<'static>> {
154 code.split('\n')
155 .map(|source_line| {
156 let line_with_ending = format!("{source_line}\n");
157 match highlighter.highlight_line(&line_with_ending, syntax_set) {
158 Ok(ranges) => Line::from(
159 ranges
160 .into_iter()
161 .filter_map(|(style, text)| {
162 let text = text.strip_suffix('\n').unwrap_or(text);
163 (!text.is_empty()).then(|| Span::styled(text.to_string(), style_from_syntect(style)))
164 })
165 .collect::<Vec<_>>(),
166 ),
167 Err(_) => Line::styled(source_line.to_string(), Style::new().fg(theme.code_fg)),
168 }
169 })
170 .collect()
171}
172
173impl Default for SyntaxHighlighter {
174 fn default() -> Self {
175 Self::new()
176 }
177}
178
179fn find_syntax<'a>(syntax_set: &'a SyntaxSet, hint: &str) -> Option<&'a SyntaxReference> {
180 let language = hint
181 .split(|character: char| character.is_whitespace() || character == ',')
182 .find(|part| !part.is_empty())
183 .unwrap_or_default()
184 .to_ascii_lowercase();
185 let normalized = match language.as_str() {
186 "typescript" => "ts",
187 "typescriptreact" => "tsx",
188 "javascript" | "jsx" => "js",
189 "python" => "py",
190 "rust" => "rs",
191 "c99" | "c11" => "c",
192 "c++" | "cxx" | "cc" => "cpp",
193 "c#" | "csharp" => "cs",
194 "ruby" => "rb",
195 "kotlin" | "kts" => "kt",
196 "shell" | "bash" | "zsh" => "sh",
197 "yml" => "yaml",
198 "markdown" => "md",
199 language => language,
200 };
201 if normalized.is_empty() {
202 return None;
203 }
204 syntax_set.find_syntax_by_extension(normalized).or_else(|| syntax_set.find_syntax_by_token(normalized))
205}
206
207fn style_from_syntect(style: syntect::highlighting::Style) -> Style {
208 let mut modifiers = Modifier::empty();
209 if style.font_style.contains(FontStyle::BOLD) {
210 modifiers.insert(Modifier::BOLD);
211 }
212 if style.font_style.contains(FontStyle::ITALIC) {
213 modifiers.insert(Modifier::ITALIC);
214 }
215 if style.font_style.contains(FontStyle::UNDERLINE) {
216 modifiers.insert(Modifier::UNDERLINED);
217 }
218 Style::new().fg(Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b)).add_modifier(modifiers)
219}