Skip to main content

sveltui_code/
highlight.rs

1use 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}