gpui_editor/
syntax_highlighter.rs

1use gpui::{Font, FontStyle, FontWeight, Hsla, SharedString, TextRun};
2use std::cell::RefCell;
3use std::collections::HashMap;
4use std::rc::Rc;
5use syntect::highlighting::{HighlightIterator, HighlightState, Highlighter, Style, ThemeSet};
6use syntect::parsing::{ParseState, ScopeStack, SyntaxSet};
7
8struct SyntaxHighlighterInner {
9    syntax_set: SyntaxSet,
10    theme_set: ThemeSet,
11    current_theme: String,
12    parse_states: HashMap<String, ParseState>,
13    highlight_states: HashMap<String, HighlightState>,
14}
15
16#[derive(Clone)]
17pub struct SyntaxHighlighter {
18    inner: Rc<RefCell<SyntaxHighlighterInner>>,
19}
20
21impl SyntaxHighlighter {
22    pub fn new() -> Self {
23        let syntax_set = SyntaxSet::load_defaults_newlines();
24        let theme_set = ThemeSet::load_defaults();
25
26        // Get the first available theme as default, or use a fallback
27        let current_theme = theme_set
28            .themes
29            .keys()
30            .next()
31            .cloned()
32            .unwrap_or_else(|| "Default".to_string());
33
34        Self {
35            inner: Rc::new(RefCell::new(SyntaxHighlighterInner {
36                syntax_set,
37                theme_set,
38                current_theme,
39                parse_states: HashMap::new(),
40                highlight_states: HashMap::new(),
41            })),
42        }
43    }
44
45    pub fn set_theme(&mut self, theme_name: &str) {
46        let mut inner = self.inner.borrow_mut();
47        if inner.theme_set.themes.contains_key(theme_name) {
48            inner.current_theme = theme_name.to_string();
49            inner.highlight_states.clear();
50        }
51    }
52
53    pub fn available_themes(&self) -> Vec<String> {
54        self.inner
55            .borrow()
56            .theme_set
57            .themes
58            .keys()
59            .cloned()
60            .collect()
61    }
62
63    pub fn detect_language(&self, text: &str, file_extension: Option<&str>) -> Option<String> {
64        let inner = self.inner.borrow();
65        if let Some(ext) = file_extension {
66            if let Some(syntax) = inner.syntax_set.find_syntax_by_extension(ext) {
67                return Some(syntax.name.clone());
68            }
69        }
70
71        inner
72            .syntax_set
73            .find_syntax_by_first_line(text)
74            .map(|s| s.name.clone())
75    }
76
77    /// Clear cached highlighting state from a specific line onward.
78    /// This is useful for incremental re-highlighting when text changes.
79    pub fn clear_state_from_line(&mut self, line_number: usize, language: &str) {
80        let mut inner = self.inner.borrow_mut();
81
82        // Clear parse states for this language from this line onward
83        // Since we don't track line numbers in parse_states directly,
84        // we need to clear it entirely for now
85        // TODO: Improve this to track line-specific states
86        if line_number == 0 {
87            inner.parse_states.remove(language);
88        }
89
90        // Clear highlight states that might be affected
91        let cache_key = format!("{}-{}", language, inner.current_theme);
92        if line_number == 0 {
93            inner.highlight_states.remove(&cache_key);
94        }
95    }
96
97    /// Reset all cached highlighting state.
98    /// Call this when the buffer content has significantly changed.
99    pub fn reset_state(&mut self) {
100        let mut inner = self.inner.borrow_mut();
101        inner.parse_states.clear();
102        inner.highlight_states.clear();
103    }
104
105    pub fn highlight_line(
106        &mut self,
107        line: &str,
108        language: &str,
109        line_number: usize,
110        font_family: SharedString,
111        _font_size: f32,
112    ) -> Vec<TextRun> {
113        let mut inner = self.inner.borrow_mut();
114
115        // First, check if we have the syntax
116        let has_syntax = inner.syntax_set.find_syntax_by_name(language).is_some();
117        if !has_syntax {
118            // Fallback to plain text
119            return vec![TextRun {
120                len: line.len(),
121                font: Font {
122                    family: font_family,
123                    features: Default::default(),
124                    weight: FontWeight::NORMAL,
125                    style: FontStyle::Normal,
126                    fallbacks: Default::default(),
127                },
128                color: gpui::rgb(0xcccccc).into(),
129                background_color: None,
130                underline: None,
131                strikethrough: None,
132            }];
133        }
134
135        let cache_key = format!("{}-{}", language, inner.current_theme);
136        let parse_state_key = language.to_string();
137
138        // Clear states if starting fresh
139        if line_number == 0 {
140            inner.parse_states.remove(&parse_state_key);
141            inner.highlight_states.remove(&cache_key);
142        }
143
144        // Get or create parse state - we already checked syntax exists above
145        let syntax = inner
146            .syntax_set
147            .find_syntax_by_name(language)
148            .expect("syntax should exist after check above");
149
150        let mut parse_state = if line_number == 0 {
151            ParseState::new(syntax)
152        } else if let Some(state) = inner.parse_states.get(&parse_state_key) {
153            state.clone()
154        } else {
155            ParseState::new(syntax)
156        };
157
158        // Get the theme, with fallback to default colors if theme not found
159        let theme = inner
160            .theme_set
161            .themes
162            .get(&inner.current_theme)
163            .or_else(|| inner.theme_set.themes.values().next());
164
165        if theme.is_none() {
166            // No themes available at all, return plain text
167            return vec![TextRun {
168                len: line.len(),
169                font: Font {
170                    family: font_family,
171                    features: Default::default(),
172                    weight: FontWeight::NORMAL,
173                    style: FontStyle::Normal,
174                    fallbacks: Default::default(),
175                },
176                color: gpui::rgb(0xcccccc).into(),
177                background_color: None,
178                underline: None,
179                strikethrough: None,
180            }];
181        }
182
183        let theme = theme.expect("theme should exist after check above");
184        let highlighter = Highlighter::new(theme);
185
186        let ops = parse_state
187            .parse_line(line, &inner.syntax_set)
188            .unwrap_or_default();
189
190        let mut highlight_state = if line_number == 0 {
191            HighlightState::new(&highlighter, ScopeStack::new())
192        } else if let Some(state) = inner.highlight_states.get(&cache_key) {
193            state.clone()
194        } else {
195            HighlightState::new(&highlighter, ScopeStack::new())
196        };
197
198        let mut text_runs = Vec::new();
199        let mut current_pos = 0;
200
201        let ranges: Vec<(Style, usize, usize)> =
202            HighlightIterator::new(&mut highlight_state, &ops, line, &highlighter)
203                .map(|(style, text)| {
204                    let start = current_pos;
205                    let end = current_pos + text.len();
206                    current_pos = end;
207                    (style, start, end)
208                })
209                .collect();
210
211        for (style, start, end) in ranges {
212            let len = end - start;
213            if len == 0 {
214                continue;
215            }
216
217            let color = style_to_hsla(style);
218            let (weight, font_style) = get_font_style(style);
219
220            text_runs.push(TextRun {
221                len,
222                font: Font {
223                    family: font_family.clone(),
224                    features: Default::default(),
225                    weight,
226                    style: font_style,
227                    fallbacks: Default::default(),
228                },
229                color,
230                background_color: if style.background != style.foreground {
231                    Some(style_color_to_hsla(style.background))
232                } else {
233                    None
234                },
235                underline: if style
236                    .font_style
237                    .contains(syntect::highlighting::FontStyle::UNDERLINE)
238                {
239                    Some(Default::default())
240                } else {
241                    None
242                },
243                strikethrough: None,
244            });
245        }
246
247        if text_runs.is_empty() {
248            text_runs.push(TextRun {
249                len: line.len(),
250                font: Font {
251                    family: font_family,
252                    features: Default::default(),
253                    weight: FontWeight::NORMAL,
254                    style: FontStyle::Normal,
255                    fallbacks: Default::default(),
256                },
257                color: gpui::rgb(0xcccccc).into(),
258                background_color: None,
259                underline: None,
260                strikethrough: None,
261            });
262        }
263
264        // Store parse state for next line
265        let new_parse_state = parse_state
266            .parse_line(line, &inner.syntax_set)
267            .map(|_| parse_state.clone())
268            .unwrap_or_else(|_| ParseState::new(syntax));
269        inner.parse_states.insert(parse_state_key, new_parse_state);
270
271        // Store highlight state for next line - it was already mutated by the iterator
272        inner.highlight_states.insert(cache_key, highlight_state);
273
274        text_runs
275    }
276
277    pub fn get_theme_background(&self) -> Hsla {
278        let inner = self.inner.borrow();
279        inner
280            .theme_set
281            .themes
282            .get(&inner.current_theme)
283            .and_then(|theme| theme.settings.background)
284            .map(style_color_to_hsla)
285            .unwrap_or_else(|| gpui::rgb(0x1e1e1e).into())
286    }
287
288    pub fn get_theme_foreground(&self) -> Hsla {
289        let inner = self.inner.borrow();
290        inner
291            .theme_set
292            .themes
293            .get(&inner.current_theme)
294            .and_then(|theme| theme.settings.foreground)
295            .map(style_color_to_hsla)
296            .unwrap_or_else(|| gpui::rgb(0xcccccc).into())
297    }
298
299    pub fn get_theme_gutter_background(&self) -> Hsla {
300        let inner = self.inner.borrow();
301        inner
302            .theme_set
303            .themes
304            .get(&inner.current_theme)
305            .and_then(|theme| {
306                theme.settings.gutter.map(style_color_to_hsla).or_else(|| {
307                    theme.settings.background.map(|bg| {
308                        // Darken background slightly for gutter
309                        let mut hsla: Hsla = style_color_to_hsla(bg);
310                        hsla.l = (hsla.l * 0.95).max(0.0);
311                        hsla
312                    })
313                })
314            })
315            .unwrap_or_else(|| gpui::rgb(0x252525).into())
316    }
317
318    pub fn get_theme_line_highlight(&self) -> Hsla {
319        let inner = self.inner.borrow();
320        inner
321            .theme_set
322            .themes
323            .get(&inner.current_theme)
324            .and_then(|theme| theme.settings.line_highlight)
325            .map(|color| {
326                let mut hsla = style_color_to_hsla(color);
327                hsla.a = hsla.a.min(0.3); // Make semi-transparent
328                hsla
329            })
330            .unwrap_or_else(|| gpui::rgba(0x2a2a2aff).into())
331    }
332
333    pub fn get_theme_selection(&self) -> Hsla {
334        let inner = self.inner.borrow();
335        inner
336            .theme_set
337            .themes
338            .get(&inner.current_theme)
339            .and_then(|theme| theme.settings.selection)
340            .map(|color| {
341                let mut hsla = style_color_to_hsla(color);
342                hsla.a = hsla.a.min(0.5); // Make semi-transparent
343                hsla
344            })
345            .unwrap_or_else(|| gpui::rgba(0x3e4451aa).into())
346    }
347
348    // Load custom themes from a directory
349    // Example: highlighter.load_theme_from_file("./themes/my-theme.tmTheme")
350    #[allow(dead_code)]
351    pub fn load_theme_from_file(&mut self, path: &str) -> Result<(), String> {
352        use std::fs::File;
353        use std::io::BufReader;
354
355        let file = File::open(path).map_err(|e| format!("Failed to open theme file: {}", e))?;
356        let reader = BufReader::new(file);
357
358        let theme = syntect::highlighting::ThemeSet::load_from_reader(&mut BufReader::new(reader))
359            .map_err(|e| format!("Failed to parse theme: {}", e))?;
360
361        let theme_name = std::path::Path::new(path)
362            .file_stem()
363            .and_then(|s| s.to_str())
364            .unwrap_or("custom")
365            .to_string();
366
367        let mut inner = self.inner.borrow_mut();
368        inner.theme_set.themes.insert(theme_name.clone(), theme);
369        inner.current_theme = theme_name;
370
371        Ok(())
372    }
373
374    // Load custom syntax definitions
375    // Example: highlighter.load_syntax_from_file("./syntaxes/mylang.sublime-syntax")
376    #[allow(dead_code)]
377    pub fn load_syntax_from_file(&mut self, path: &str) -> Result<(), String> {
378        let mut inner = self.inner.borrow_mut();
379        let mut builder = syntect::parsing::SyntaxSetBuilder::new();
380        builder
381            .add_from_folder(path, true)
382            .map_err(|e| format!("Failed to load syntax: {}", e))?;
383
384        // Merge with existing syntaxes
385        for _syntax in inner.syntax_set.syntaxes() {
386            builder.add_plain_text_syntax();
387        }
388
389        inner.syntax_set = builder.build();
390        inner.parse_states.clear();
391        inner.highlight_states.clear();
392
393        Ok(())
394    }
395}
396
397fn style_color_to_hsla(color: syntect::highlighting::Color) -> Hsla {
398    gpui::rgba(
399        ((color.r as u32) << 24)
400            | ((color.g as u32) << 16)
401            | ((color.b as u32) << 8)
402            | (color.a as u32),
403    )
404    .into()
405}
406
407fn style_to_hsla(style: Style) -> Hsla {
408    style_color_to_hsla(style.foreground)
409}
410
411fn get_font_style(style: Style) -> (FontWeight, FontStyle) {
412    let weight = if style
413        .font_style
414        .contains(syntect::highlighting::FontStyle::BOLD)
415    {
416        FontWeight::BOLD
417    } else {
418        FontWeight::NORMAL
419    };
420
421    let font_style = if style
422        .font_style
423        .contains(syntect::highlighting::FontStyle::ITALIC)
424    {
425        FontStyle::Italic
426    } else {
427        FontStyle::Normal
428    };
429
430    (weight, font_style)
431}
432
433impl Default for SyntaxHighlighter {
434    fn default() -> Self {
435        Self::new()
436    }
437}
438
439// HOW TO ADD CUSTOM GRAMMARS AND THEMES:
440//
441// 1. THEMES:
442//    Themes use the TextMate .tmTheme format (XML plist files).
443//    You can get themes from:
444//    - https://github.com/textmate/themes
445//    - VSCode themes (extract from .vsix)
446//    - Sublime Text packages
447//
448//    To use a custom theme:
449//    highlighter.load_theme_from_file("./my-theme.tmTheme").ok();
450//
451// 2. SYNTAX DEFINITIONS:
452//    Syntaxes use Sublime Text's .sublime-syntax format (YAML).
453//    You can get syntax definitions from:
454//    - https://github.com/sublimehq/Packages
455//    - Convert TextMate grammars (.tmLanguage) to Sublime syntax
456//
457//    To use custom syntax:
458//    highlighter.load_syntax_from_file("./syntaxes/").ok();
459//
460// 3. BUNDLED SYNTAXES:
461//    Syntect includes these by default:
462//    - Rust, Python, JavaScript, TypeScript, Java, C, C++, C#
463//    - Go, Ruby, PHP, Swift, Kotlin, Scala, Haskell
464//    - HTML, CSS, JSON, XML, YAML, Markdown
465//    - Shell scripts, Dockerfile, SQL, and many more
466//
467// 4. BUNDLED THEMES:
468//    Default themes from syntect include:
469//    - base16-ocean.dark, base16-ocean.light
470//    - base16-mocha.dark, base16-eighties.dark
471//    - InspiredGitHub, Solarized (dark), Solarized (light)