Skip to main content

vtcode_ui/tui/ui/
syntax_highlight.rs

1//! Syntax Highlighting Engine
2//!
3//! Global syntax highlighting using `syntect` with TextMate themes.
4//! Follows the architecture from OpenAI Codex PRs #11447 and #12581.
5//!
6//! # Architecture
7//!
8//! - **SyntaxSet**: Process-global singleton (~250 grammars, loaded once)
9//! - **ThemeSet**: Process-global singleton loaded once
10//! - **Highlighting**: Guardrails skip large inputs (>512KB or >10K lines)
11//!
12//! # Usage
13//!
14//! ```rust
15//! use vtcode_ui::tui::ui::syntax_highlight::{
16//!     get_active_syntax_theme, highlight_code_to_segments,
17//! };
18//!
19//! // Auto-resolve syntax theme from current UI theme
20//! let syntax_theme = get_active_syntax_theme();
21//!
22//! // Highlight code with proper theme
23//! let code = "fn main() { println!(\"hi\"); }";
24//! let segments = highlight_code_to_segments(code, Some("rust"), syntax_theme);
25//! assert!(!segments.is_empty());
26//! ```
27//!
28//! # Performance
29//!
30//! - Single SyntaxSet load (~1MB, ~50ms)
31//! - Single ThemeSet load shared by all highlighters
32//! - Input guardrails prevent highlighting huge files
33//! - Parser state preserved across multiline constructs
34
35use crate::tui::ui::theme::get_syntax_theme_for_ui_theme;
36use anstyle::{Ansi256Color, AnsiColor, Effects, RgbColor, Style as AnstyleStyle};
37use once_cell::sync::Lazy;
38use syntect::highlighting::{FontStyle, Highlighter, Theme, ThemeSet};
39use syntect::parsing::{Scope, SyntaxReference, SyntaxSet};
40use syntect::util::LinesWithEndings;
41use tracing::warn;
42use vtcode_commons::ansi_codes::RESET;
43
44/// Default syntax highlighting theme
45const DEFAULT_THEME_NAME: &str = "base16-ocean.dark";
46
47/// Input size guardrail - skip highlighting for files > 512 KB
48const MAX_INPUT_SIZE_BYTES: usize = 512 * 1024;
49
50/// Input line guardrail - skip highlighting for files > 10K lines
51const MAX_INPUT_LINES: usize = 10_000;
52
53// Syntect/bat encode ANSI palette semantics in alpha:
54// `a=0` => ANSI palette index stored in `r`, `a=1` => terminal default.
55const ANSI_ALPHA_INDEX: u8 = 0x00;
56const ANSI_ALPHA_DEFAULT: u8 = 0x01;
57const OPAQUE_ALPHA: u8 = u8::MAX;
58
59/// Global SyntaxSet singleton (~250 grammars)
60static SHARED_SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
61
62/// Global ThemeSet singleton.
63static SHARED_THEME_SET: Lazy<ThemeSet> = Lazy::new(|| match ThemeSet::load_defaults() {
64    defaults if !defaults.themes.is_empty() => defaults,
65    _ => {
66        warn!("Failed to load default syntax highlighting themes");
67        ThemeSet { themes: Default::default() }
68    }
69});
70
71/// Cache for loaded themes to avoid repeated cloning from the ThemeSet.
72/// Key is theme name, value is the cloned theme.
73static THEME_CACHE: Lazy<std::sync::RwLock<std::collections::HashMap<String, Theme>>> =
74    Lazy::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
75
76/// Get the global SyntaxSet reference
77#[inline]
78pub fn syntax_set() -> &'static SyntaxSet {
79    &SHARED_SYNTAX_SET
80}
81
82/// Find syntax by language token (e.g., "rust", "python")
83#[inline]
84pub fn find_syntax_by_token(token: &str) -> &'static SyntaxReference {
85    SHARED_SYNTAX_SET
86        .find_syntax_by_token(token)
87        .unwrap_or_else(|| SHARED_SYNTAX_SET.find_syntax_plain_text())
88}
89
90/// Find syntax by exact name
91#[inline]
92pub fn find_syntax_by_name(name: &str) -> Option<&'static SyntaxReference> {
93    SHARED_SYNTAX_SET.find_syntax_by_name(name)
94}
95
96/// Find syntax by file extension
97#[inline]
98pub fn find_syntax_by_extension(ext: &str) -> Option<&'static SyntaxReference> {
99    SHARED_SYNTAX_SET.find_syntax_by_extension(ext)
100}
101
102/// Get plain text syntax fallback
103#[inline]
104pub fn find_syntax_plain_text() -> &'static SyntaxReference {
105    SHARED_SYNTAX_SET.find_syntax_plain_text()
106}
107
108fn fallback_theme() -> Theme {
109    SHARED_THEME_SET.themes.values().next().cloned().unwrap_or_default()
110}
111
112fn plain_text_line_segments(code: &str) -> Vec<Vec<(syntect::highlighting::Style, String)>> {
113    let mut result = Vec::with_capacity(code.lines().count() + 1);
114    let mut ends_with_newline = false;
115    for line in LinesWithEndings::from(code) {
116        ends_with_newline = line.ends_with('\n');
117        let trimmed = line.trim_end_matches('\n');
118        result.push(vec![(syntect::highlighting::Style::default(), trimmed.to_string())]);
119    }
120
121    if ends_with_newline {
122        result.push(Vec::new());
123    }
124
125    result
126}
127
128/// Load a theme from the process-global theme set with caching.
129///
130/// # Arguments
131/// * `theme_name` - Theme identifier (TextMate theme name)
132/// * `cache` - If true, cache the loaded theme for reuse. If false, always clone from ThemeSet.
133///
134/// # Returns
135/// Cloned theme instance (safe for multi-threaded use)
136pub fn load_theme(theme_name: &str, cache: bool) -> Theme {
137    // Try cache first if caching is enabled
138    if cache
139        && let Ok(cache) = THEME_CACHE.read()
140        && let Some(theme) = cache.get(theme_name)
141    {
142        return theme.clone();
143    }
144
145    // Load from ThemeSet
146    let theme = if let Some(theme) = SHARED_THEME_SET.themes.get(theme_name) {
147        theme.clone()
148    } else {
149        warn!(theme = theme_name, "Unknown syntax highlighting theme, falling back to default");
150        fallback_theme()
151    };
152
153    // Cache the theme if caching is enabled
154    if cache && let Ok(mut cache) = THEME_CACHE.write() {
155        cache.entry(theme_name.to_string()).or_insert_with(|| theme.clone());
156    }
157
158    theme
159}
160
161/// Get the default syntax theme name
162#[inline]
163pub fn default_theme_name() -> String {
164    DEFAULT_THEME_NAME.to_string()
165}
166
167/// Get all available theme names
168pub fn available_themes() -> Vec<String> {
169    SHARED_THEME_SET.themes.keys().cloned().collect()
170}
171
172/// Check if input should be highlighted (guardrails)
173#[inline]
174pub fn should_highlight(code: &str) -> bool {
175    code.len() <= MAX_INPUT_SIZE_BYTES && code.lines().count() <= MAX_INPUT_LINES
176}
177
178/// Get the recommended syntax theme for the current UI theme
179///
180/// This ensures syntax highlighting colors complement the UI theme background.
181/// Based on OpenAI Codex PRs #11447 and #12581.
182#[inline]
183pub fn get_active_syntax_theme() -> &'static str {
184    get_syntax_theme_for_ui_theme(&crate::tui::ui::theme::active_theme_id())
185}
186
187/// Get the recommended syntax theme for a specific UI theme
188#[inline]
189pub fn get_syntax_theme(theme: &str) -> &'static str {
190    get_syntax_theme_for_ui_theme(theme)
191}
192
193/// Raw RGB diff backgrounds extracted from syntax theme scopes.
194///
195/// Prefers `markup.inserted` / `markup.deleted` and falls back to
196/// `diff.inserted` / `diff.deleted`.
197#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
198pub struct DiffScopeBackgroundRgbs {
199    pub(crate) inserted: Option<(u8, u8, u8)>,
200    pub(crate) deleted: Option<(u8, u8, u8)>,
201}
202
203/// Resolve diff-scope background colors from the currently active syntax theme.
204pub(crate) fn diff_scope_background_rgbs() -> DiffScopeBackgroundRgbs {
205    let theme_name = get_active_syntax_theme();
206    let theme = load_theme(theme_name, true);
207    diff_scope_background_rgbs_for_theme(&theme)
208}
209
210fn diff_scope_background_rgbs_for_theme(theme: &Theme) -> DiffScopeBackgroundRgbs {
211    let highlighter = Highlighter::new(theme);
212    let inserted = scope_background_rgb(&highlighter, "markup.inserted")
213        .or_else(|| scope_background_rgb(&highlighter, "diff.inserted"));
214    let deleted = scope_background_rgb(&highlighter, "markup.deleted")
215        .or_else(|| scope_background_rgb(&highlighter, "diff.deleted"));
216    DiffScopeBackgroundRgbs { inserted, deleted }
217}
218
219fn scope_background_rgb(highlighter: &Highlighter<'_>, scope_name: &str) -> Option<(u8, u8, u8)> {
220    let scope = Scope::new(scope_name).ok()?;
221    let background = highlighter.style_mod_for_stack(&[scope]).background?;
222    Some((background.r, background.g, background.b))
223}
224
225fn ansi_palette_color(index: u8) -> anstyle::Color {
226    match index {
227        0x00 => AnsiColor::Black.into(),
228        0x01 => AnsiColor::Red.into(),
229        0x02 => AnsiColor::Green.into(),
230        0x03 => AnsiColor::Yellow.into(),
231        0x04 => AnsiColor::Blue.into(),
232        0x05 => AnsiColor::Magenta.into(),
233        0x06 => AnsiColor::Cyan.into(),
234        0x07 => AnsiColor::White.into(),
235        index => Ansi256Color(index).into(),
236    }
237}
238
239fn convert_syntect_color(color: syntect::highlighting::Color) -> Option<anstyle::Color> {
240    match color.a {
241        // Bat-compatible encoding for ANSI-family themes.
242        ANSI_ALPHA_INDEX => Some(ansi_palette_color(color.r)),
243        // Preserve terminal defaults rather than forcing black.
244        ANSI_ALPHA_DEFAULT => None,
245        // Standard syntect themes use opaque RGB values.
246        OPAQUE_ALPHA => Some(RgbColor(color.r, color.g, color.b).into()),
247        // Some theme dumps use other alpha values; keep them readable as RGB.
248        _ => Some(RgbColor(color.r, color.g, color.b).into()),
249    }
250}
251
252fn convert_syntect_style(style: syntect::highlighting::Style) -> AnstyleStyle {
253    let mut effects = Effects::new();
254    if style.font_style.contains(FontStyle::BOLD) {
255        effects |= Effects::BOLD;
256    }
257    if style.font_style.contains(FontStyle::ITALIC) {
258        effects |= Effects::ITALIC;
259    }
260    if style.font_style.contains(FontStyle::UNDERLINE) {
261        effects |= Effects::UNDERLINE;
262    }
263
264    AnstyleStyle::new()
265        .fg_color(convert_syntect_color(style.foreground))
266        .bg_color(convert_syntect_color(style.background))
267        .effects(effects)
268}
269
270#[inline]
271fn select_syntax(language: Option<&str>) -> &'static SyntaxReference {
272    language.map(find_syntax_by_token).unwrap_or_else(find_syntax_plain_text)
273}
274
275/// Highlight code and return styled segments per line.
276///
277/// Uses `LinesWithEndings` semantics by preserving an empty trailing line
278/// when the input ends with `\n`.
279pub fn highlight_code_to_line_segments(
280    code: &str,
281    language: Option<&str>,
282    theme_name: &str,
283) -> Vec<Vec<(syntect::highlighting::Style, String)>> {
284    let theme = load_theme(theme_name, true);
285    highlight_code_to_line_segments_with_theme(code, language, &theme)
286}
287
288fn highlight_code_to_line_segments_with_theme(
289    code: &str,
290    language: Option<&str>,
291    theme: &Theme,
292) -> Vec<Vec<(syntect::highlighting::Style, String)>> {
293    if !should_highlight(code) {
294        return plain_text_line_segments(code);
295    }
296
297    let syntax = select_syntax(language);
298    let mut highlighter = syntect::easy::HighlightLines::new(syntax, theme);
299    let mut result = Vec::with_capacity(code.lines().count() + 1);
300    let mut ends_with_newline = false;
301
302    for line in LinesWithEndings::from(code) {
303        ends_with_newline = line.ends_with('\n');
304        let trimmed = line.trim_end_matches('\n');
305        let segments = match highlighter.highlight_line(trimmed, syntax_set()) {
306            Ok(ranges) => ranges.into_iter().map(|(style, text)| (style, text.to_string())).collect(),
307            Err(_) => vec![(syntect::highlighting::Style::default(), trimmed.to_string())],
308        };
309        result.push(segments);
310    }
311
312    if ends_with_newline {
313        result.push(Vec::new());
314    }
315
316    result
317}
318
319fn highlight_code_to_anstyle_line_segments_with_theme(
320    code: &str,
321    language: Option<&str>,
322    theme: &Theme,
323    strip_background: bool,
324) -> Vec<Vec<(AnstyleStyle, String)>> {
325    highlight_code_to_line_segments_with_theme(code, language, theme)
326        .into_iter()
327        .map(|ranges| {
328            ranges
329                .into_iter()
330                .filter(|(_, text)| !text.is_empty())
331                .map(|(style, text)| {
332                    let mut anstyle = convert_syntect_style(style);
333                    if strip_background {
334                        anstyle = anstyle.bg_color(None);
335                    }
336                    (anstyle, text)
337                })
338                .collect()
339        })
340        .collect()
341}
342
343/// Highlight code and convert to `anstyle` segments with optional bg stripping.
344pub fn highlight_code_to_anstyle_line_segments(
345    code: &str,
346    language: Option<&str>,
347    theme_name: &str,
348    strip_background: bool,
349) -> Vec<Vec<(AnstyleStyle, String)>> {
350    let theme = load_theme(theme_name, true);
351    highlight_code_to_anstyle_line_segments_with_theme(code, language, &theme, strip_background)
352}
353
354/// Highlight one line and convert to `anstyle` segments with optional bg stripping.
355pub fn highlight_line_to_anstyle_segments(
356    line: &str,
357    language: Option<&str>,
358    theme_name: &str,
359    strip_background: bool,
360) -> Option<Vec<(AnstyleStyle, String)>> {
361    highlight_code_to_anstyle_line_segments(line, language, theme_name, strip_background)
362        .into_iter()
363        .next()
364}
365
366/// Highlight code and return styled segments
367///
368/// # Arguments
369/// * `code` - Source code to highlight
370/// * `language` - Optional language hint (auto-detected if None)
371/// * `theme_name` - Syntax theme name (use `get_active_syntax_theme()` for UI theme sync)
372///
373/// # Returns
374/// Vector of (Style, String) tuples for rendering
375///
376/// # Performance
377/// - Returns None early if input exceeds guardrails
378/// - Uses cached theme when available
379pub fn highlight_code_to_segments(
380    code: &str,
381    language: Option<&str>,
382    theme_name: &str,
383) -> Vec<(syntect::highlighting::Style, String)> {
384    highlight_code_to_line_segments(code, language, theme_name)
385        .into_iter()
386        .flatten()
387        .collect()
388}
389
390/// Highlight a single line (for diff rendering)
391///
392/// Preserves parser state for multiline constructs
393pub fn highlight_line_for_diff(
394    line: &str,
395    language: Option<&str>,
396    theme_name: &str,
397) -> Option<Vec<(syntect::highlighting::Style, String)>> {
398    highlight_code_to_line_segments(line, language, theme_name).into_iter().next()
399}
400
401/// Convert code to ANSI escape sequences
402pub fn highlight_code_to_ansi(code: &str, language: Option<&str>, theme_name: &str) -> String {
403    let segments = highlight_code_to_anstyle_line_segments(code, language, theme_name, false);
404    let mut output = String::with_capacity(code.len() + segments.len() * 10);
405
406    for (ansi_style, text) in segments.into_iter().flatten() {
407        output.push_str(&ansi_style.to_string());
408        output.push_str(&text);
409        output.push_str(RESET);
410    }
411
412    output
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use std::str::FromStr;
419    use syntect::highlighting::Color as SyntectColor;
420    use syntect::highlighting::ScopeSelectors;
421    use syntect::highlighting::StyleModifier;
422    use syntect::highlighting::ThemeItem;
423    use syntect::highlighting::ThemeSettings;
424
425    fn theme_item(scope: &str, background: Option<(u8, u8, u8)>) -> ThemeItem {
426        ThemeItem {
427            scope: ScopeSelectors::from_str(scope).expect("scope selector should parse"),
428            style: StyleModifier {
429                background: background.map(|(r, g, b)| SyntectColor { r, g, b, a: 255 }),
430                ..StyleModifier::default()
431            },
432        }
433    }
434
435    #[test]
436    fn test_syntax_set_loaded() {
437        let ss = syntax_set();
438        assert!(!ss.syntaxes().is_empty());
439    }
440
441    #[test]
442    fn test_find_syntax_by_token() {
443        let rust = find_syntax_by_token("rust");
444        assert!(rust.name.contains("Rust"));
445    }
446
447    #[test]
448    fn test_should_highlight_guardrails() {
449        assert!(should_highlight("fn main() {}"));
450        assert!(!should_highlight(&"x".repeat(MAX_INPUT_SIZE_BYTES + 1)));
451    }
452
453    #[test]
454    fn test_get_active_syntax_theme() {
455        let theme = get_active_syntax_theme();
456        assert!(!theme.is_empty());
457    }
458
459    #[test]
460    fn test_highlight_code_to_segments() {
461        let segments = highlight_code_to_segments("fn main() {}", Some("rust"), "base16-ocean.dark");
462        assert!(!segments.is_empty());
463    }
464
465    #[test]
466    fn test_theme_loading_stable() {
467        let theme1 = load_theme("base16-ocean.dark", true);
468        let theme2 = load_theme("base16-ocean.dark", true);
469        assert_eq!(theme1.name, theme2.name);
470    }
471
472    #[test]
473    fn convert_syntect_style_uses_named_ansi_for_low_palette_indexes() {
474        let style = convert_syntect_style(syntect::highlighting::Style {
475            foreground: SyntectColor { r: 0x02, g: 0, b: 0, a: ANSI_ALPHA_INDEX },
476            background: SyntectColor { r: 0, g: 0, b: 0, a: OPAQUE_ALPHA },
477            font_style: FontStyle::empty(),
478        });
479
480        assert_eq!(style.get_fg_color(), Some(AnsiColor::Green.into()));
481    }
482
483    #[test]
484    fn convert_syntect_style_uses_ansi256_for_high_palette_indexes() {
485        let style = convert_syntect_style(syntect::highlighting::Style {
486            foreground: SyntectColor { r: 0x9a, g: 0, b: 0, a: ANSI_ALPHA_INDEX },
487            background: SyntectColor { r: 0, g: 0, b: 0, a: OPAQUE_ALPHA },
488            font_style: FontStyle::empty(),
489        });
490
491        assert_eq!(style.get_fg_color(), Some(Ansi256Color(0x9a).into()));
492    }
493
494    #[test]
495    fn convert_syntect_style_uses_terminal_default_for_alpha_one() {
496        let style = convert_syntect_style(syntect::highlighting::Style {
497            foreground: SyntectColor { r: 0, g: 0, b: 0, a: ANSI_ALPHA_DEFAULT },
498            background: SyntectColor { r: 0, g: 0, b: 0, a: OPAQUE_ALPHA },
499            font_style: FontStyle::empty(),
500        });
501
502        assert_eq!(style.get_fg_color(), None);
503    }
504
505    #[test]
506    fn convert_syntect_style_falls_back_to_rgb_for_unexpected_alpha() {
507        let style = convert_syntect_style(syntect::highlighting::Style {
508            foreground: SyntectColor { r: 10, g: 20, b: 30, a: 0x80 },
509            background: SyntectColor { r: 0, g: 0, b: 0, a: OPAQUE_ALPHA },
510            font_style: FontStyle::empty(),
511        });
512
513        assert_eq!(style.get_fg_color(), Some(RgbColor(10, 20, 30).into()));
514    }
515
516    #[test]
517    fn convert_syntect_style_preserves_effects() {
518        let style = convert_syntect_style(syntect::highlighting::Style {
519            foreground: SyntectColor { r: 10, g: 20, b: 30, a: OPAQUE_ALPHA },
520            background: SyntectColor { r: 0, g: 0, b: 0, a: OPAQUE_ALPHA },
521            font_style: FontStyle::BOLD | FontStyle::ITALIC | FontStyle::UNDERLINE,
522        });
523
524        let effects = style.get_effects();
525        assert!(effects.contains(Effects::BOLD));
526        assert!(effects.contains(Effects::ITALIC));
527        assert!(effects.contains(Effects::UNDERLINE));
528    }
529
530    #[test]
531    fn highlight_pipeline_decodes_alpha_encoded_theme_colors() {
532        let theme = Theme {
533            settings: ThemeSettings {
534                foreground: Some(SyntectColor { r: 0x02, g: 0, b: 0, a: ANSI_ALPHA_INDEX }),
535                background: Some(SyntectColor { r: 0, g: 0, b: 0, a: ANSI_ALPHA_DEFAULT }),
536                ..ThemeSettings::default()
537            },
538            ..Theme::default()
539        };
540
541        let segments = highlight_code_to_anstyle_line_segments_with_theme("plain text", None, &theme, false);
542        assert_eq!(segments.len(), 1);
543        assert_eq!(segments[0].len(), 1);
544        assert_eq!(segments[0][0].0.get_fg_color(), Some(AnsiColor::Green.into()));
545        assert_eq!(segments[0][0].0.get_bg_color(), None);
546        assert_eq!(segments[0][0].1, "plain text");
547    }
548
549    #[test]
550    fn diff_scope_backgrounds_prefer_markup_scope_then_diff_fallback() {
551        let theme = Theme {
552            settings: ThemeSettings::default(),
553            scopes: vec![
554                theme_item("markup.inserted", Some((10, 20, 30))),
555                theme_item("diff.deleted", Some((40, 50, 60))),
556            ],
557            ..Theme::default()
558        };
559
560        let rgbs = diff_scope_background_rgbs_for_theme(&theme);
561        assert_eq!(
562            rgbs,
563            DiffScopeBackgroundRgbs {
564                inserted: Some((10, 20, 30)),
565                deleted: Some((40, 50, 60)),
566            }
567        );
568    }
569
570    #[test]
571    fn diff_scope_backgrounds_return_none_when_scopes_do_not_match() {
572        let theme = Theme {
573            settings: ThemeSettings::default(),
574            scopes: vec![theme_item("constant.numeric", Some((1, 2, 3)))],
575            ..Theme::default()
576        };
577
578        let rgbs = diff_scope_background_rgbs_for_theme(&theme);
579        assert_eq!(rgbs, DiffScopeBackgroundRgbs { inserted: None, deleted: None });
580    }
581
582    #[test]
583    fn diff_scope_backgrounds_fall_back_to_diff_scopes() {
584        let theme = Theme {
585            settings: ThemeSettings::default(),
586            scopes: vec![
587                theme_item("diff.inserted", Some((16, 32, 48))),
588                theme_item("diff.deleted", Some((64, 80, 96))),
589            ],
590            ..Theme::default()
591        };
592
593        let rgbs = diff_scope_background_rgbs_for_theme(&theme);
594        assert_eq!(
595            rgbs,
596            DiffScopeBackgroundRgbs {
597                inserted: Some((16, 32, 48)),
598                deleted: Some((64, 80, 96)),
599            }
600        );
601    }
602}