sveltui_code/
highlight.rs1use crossterm::style::Color;
2use std::cell::RefCell;
3use std::collections::hash_map::DefaultHasher;
4use std::hash::{Hash, Hasher};
5use std::sync::LazyLock;
6use syntect::easy::HighlightLines;
7use syntect::highlighting::{FontStyle, Theme, ThemeSet};
8use syntect::parsing::{SyntaxReference, SyntaxSet};
9use syntect::util::LinesWithEndings;
10
11static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
12static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
13
14thread_local! {
15 static HIGHLIGHT_CACHE: RefCell<Vec<(u64, Vec<Vec<HighlightedSpan>>)>> = const { RefCell::new(Vec::new()) };
16}
17
18fn cache_key(text: &str, language: Option<&str>, theme_name: Option<&str>) -> u64 {
19 let mut hasher = DefaultHasher::new();
20 text.hash(&mut hasher);
21 language.hash(&mut hasher);
22 theme_name.hash(&mut hasher);
23 hasher.finish()
24}
25
26pub const DEFAULT_THEME: &str = "base16-ocean.dark";
27
28pub fn syntax_set() -> &'static SyntaxSet {
29 &SYNTAX_SET
30}
31
32pub fn theme_set() -> &'static ThemeSet {
33 &THEME_SET
34}
35
36pub fn find_syntax(language: Option<&str>) -> &'static SyntaxReference {
37 let ps = syntax_set();
38 if let Some(lang) = language {
39 let clean = lang.trim();
40 if !clean.is_empty() {
41 if let Some(syntax) = ps.find_syntax_by_token(clean) {
42 return syntax;
43 }
44 if let Some(syntax) = ps.find_syntax_by_extension(clean) {
45 return syntax;
46 }
47 if let Some(syntax) = ps.find_syntax_by_name(clean) {
48 return syntax;
49 }
50 }
51 }
52 ps.find_syntax_plain_text()
53}
54
55pub fn find_theme(theme_name: Option<&str>) -> &'static Theme {
56 let ts = theme_set();
57 let name = theme_name.unwrap_or(DEFAULT_THEME);
58 if let Some(theme) = ts.themes.get(name) {
59 return theme;
60 }
61 if let Some(theme) = ts.themes.get(DEFAULT_THEME) {
62 return theme;
63 }
64 ts.themes
65 .values()
66 .next()
67 .expect("At least one default theme must exist in syntect")
68}
69
70pub fn syntect_color_to_crossterm(c: syntect::highlighting::Color) -> Color {
71 Color::Rgb {
72 r: c.r,
73 g: c.g,
74 b: c.b,
75 }
76}
77
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct HighlightedSpan {
80 pub text: String,
81 pub fg: Color,
82 pub bg: Option<Color>,
83 pub bold: bool,
84 pub italic: bool,
85 pub underline: bool,
86}
87
88impl HighlightedSpan {
89 pub fn plain(text: impl Into<String>) -> Self {
90 Self {
91 text: text.into(),
92 fg: Color::White,
93 bg: None,
94 bold: false,
95 italic: false,
96 underline: false,
97 }
98 }
99}
100
101pub fn highlight_text(
102 text: &str,
103 language: Option<&str>,
104 theme_name: Option<&str>,
105) -> Vec<Vec<HighlightedSpan>> {
106 if text.is_empty() {
107 return vec![Vec::new()];
108 }
109
110 let key = cache_key(text, language, theme_name);
111 let cached = HIGHLIGHT_CACHE.with(|c| {
112 let cache = c.borrow();
113 cache
114 .iter()
115 .find(|(k, _)| *k == key)
116 .map(|(_, v)| v.clone())
117 });
118 if let Some(res) = cached {
119 return res;
120 }
121
122 let ps = syntax_set();
123 let syntax = find_syntax(language);
124 let theme = find_theme(theme_name);
125 let mut highlighter = HighlightLines::new(syntax, theme);
126
127 let mut result = Vec::new();
128
129 for line in LinesWithEndings::from(text) {
130 let ranges = highlighter.highlight_line(line, ps).unwrap_or_default();
131 let mut spans = Vec::new();
132 for (style, segment) in ranges {
133 let clean_text = segment.trim_end_matches(['\r', '\n']);
134 if clean_text.is_empty() {
135 continue;
136 }
137 spans.push(HighlightedSpan {
138 text: clean_text.to_string(),
139 fg: syntect_color_to_crossterm(style.foreground),
140 bg: if style.background.a > 0 {
141 Some(syntect_color_to_crossterm(style.background))
142 } else {
143 None
144 },
145 bold: style.font_style.contains(FontStyle::BOLD),
146 italic: style.font_style.contains(FontStyle::ITALIC),
147 underline: style.font_style.contains(FontStyle::UNDERLINE),
148 });
149 }
150 result.push(spans);
151 }
152
153 if text.ends_with('\n') {
154 result.push(Vec::new());
155 }
156
157 HIGHLIGHT_CACHE.with(|c| {
158 let mut cache = c.borrow_mut();
159 if cache.len() >= 32 {
160 cache.remove(0);
161 }
162 cache.push((key, result.clone()));
163 });
164
165 result
166}
167
168pub fn clip_spans(
169 spans: &[HighlightedSpan],
170 scroll_col: usize,
171 max_width: usize,
172) -> Vec<HighlightedSpan> {
173 if max_width == 0 {
174 return Vec::new();
175 }
176 let end_col = scroll_col + max_width;
177 let mut current_col = 0;
178 let mut clipped = Vec::new();
179
180 for span in spans {
181 let span_chars = span.text.chars().count();
182 let span_start = current_col;
183 let span_end = current_col + span_chars;
184 current_col = span_end;
185
186 if span_end <= scroll_col || span_start >= end_col {
187 continue;
188 }
189
190 let slice_start = scroll_col.saturating_sub(span_start);
191 let slice_end = (end_col.saturating_sub(span_start)).min(span_chars);
192
193 if slice_start < slice_end {
194 let sliced_text: String = span
195 .text
196 .chars()
197 .skip(slice_start)
198 .take(slice_end - slice_start)
199 .collect();
200 if !sliced_text.is_empty() {
201 let mut new_span = span.clone();
202 new_span.text = sliced_text;
203 clipped.push(new_span);
204 }
205 }
206 }
207
208 clipped
209}