Skip to main content

wisp/view/
syntax.rs

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/// Work the highlighter did since the last [`SyntaxHighlighter::take_stats`].
15/// Byte counters measure input re-processed rather than output produced: a
16/// caller that re-highlights a growing block every frame shows up as the whole
17/// block's size again and again, whatever the output looks like.
18#[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
25/// Hash of the `(language, code)` pair a cache entry was built from. Hashing
26/// rather than owning the strings means a lookup costs nothing: the hot path
27/// runs once per source line per rendered patch.
28type CacheKey = (u64, u64);
29
30/// Highlighted lines, shared rather than copied out of the cache: a hit on a
31/// long code block would otherwise clone every line of it.
32pub type HighlightedLines = Rc<[Line<'static>]>;
33
34/// Where highlighting of a code block got to — enough to continue it later with
35/// byte-identical output, so a streaming block can highlight only its new lines
36/// instead of the whole block again.
37#[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, for evicting the oldest entry when the cache is full.
53    /// Entries are not promoted on a hit — callers that re-render the same lines
54    /// every frame cache the finished result themselves.
55    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    /// Highlights `code`, optionally continuing from [`CodeBlockState`] a
78    /// previous call returned, and reports the state it ended in.
79    ///
80    /// A continuation is transient — its content changes again with the next
81    /// chunk — so it bypasses the cache rather than churning it. A fresh block
82    /// is cacheable like any finished render, with its end state stored so a
83    /// later identical block can continue from the hit.
84    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}