Skip to main content

vtcode_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 crate::ui::syntax_highlight::{
16//!     highlight_code_to_segments, get_active_syntax_theme
17//! };
18//! use crate::ui::theme::active_theme_id;
19//!
20//! // Auto-resolve syntax theme from current UI theme
21//! let syntax_theme = get_active_syntax_theme();
22//!
23//! // Highlight code with proper theme
24//! let segments = highlight_code_to_segments(code, Some("rust"), syntax_theme);
25//! ```
26//!
27//! # Performance
28//!
29//! - Single SyntaxSet load (~1MB, ~50ms)
30//! - Single ThemeSet load shared by all highlighters
31//! - Input guardrails prevent highlighting huge files
32//! - Parser state preserved across multiline constructs
33
34use crate::ui::theme::get_syntax_theme_for_ui_theme;
35use anstyle::Style as AnstyleStyle;
36use anstyle_syntect::to_anstyle;
37use once_cell::sync::Lazy;
38use syntect::highlighting::{Highlighter, Theme, ThemeSet};
39use syntect::parsing::{Scope, SyntaxReference, SyntaxSet};
40use syntect::util::LinesWithEndings;
41use tracing::warn;
42
43/// Default syntax highlighting theme
44const DEFAULT_THEME_NAME: &str = "base16-ocean.dark";
45
46/// Input size guardrail - skip highlighting for files > 512 KB
47const MAX_INPUT_SIZE_BYTES: usize = 512 * 1024;
48
49/// Input line guardrail - skip highlighting for files > 10K lines
50const MAX_INPUT_LINES: usize = 10_000;
51
52/// Global SyntaxSet singleton (~250 grammars)
53static SHARED_SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
54
55/// Global ThemeSet singleton.
56static SHARED_THEME_SET: Lazy<ThemeSet> = Lazy::new(|| match ThemeSet::load_defaults() {
57    defaults if !defaults.themes.is_empty() => defaults,
58    _ => {
59        warn!("Failed to load default syntax highlighting themes");
60        ThemeSet {
61            themes: Default::default(),
62        }
63    }
64});
65
66/// Get the global SyntaxSet reference
67#[inline]
68pub fn syntax_set() -> &'static SyntaxSet {
69    &SHARED_SYNTAX_SET
70}
71
72/// Find syntax by language token (e.g., "rust", "python")
73#[inline]
74pub fn find_syntax_by_token(token: &str) -> &'static SyntaxReference {
75    SHARED_SYNTAX_SET
76        .find_syntax_by_token(token)
77        .unwrap_or_else(|| SHARED_SYNTAX_SET.find_syntax_plain_text())
78}
79
80/// Find syntax by exact name
81#[inline]
82pub fn find_syntax_by_name(name: &str) -> Option<&'static SyntaxReference> {
83    SHARED_SYNTAX_SET.find_syntax_by_name(name)
84}
85
86/// Find syntax by file extension
87#[inline]
88pub fn find_syntax_by_extension(ext: &str) -> Option<&'static SyntaxReference> {
89    SHARED_SYNTAX_SET.find_syntax_by_extension(ext)
90}
91
92/// Get plain text syntax fallback
93#[inline]
94pub fn find_syntax_plain_text() -> &'static SyntaxReference {
95    SHARED_SYNTAX_SET.find_syntax_plain_text()
96}
97
98fn fallback_theme() -> Theme {
99    SHARED_THEME_SET
100        .themes
101        .values()
102        .next()
103        .cloned()
104        .unwrap_or_default()
105}
106
107fn plain_text_line_segments(code: &str) -> Vec<Vec<(syntect::highlighting::Style, String)>> {
108    let mut result = Vec::new();
109    let mut ends_with_newline = false;
110    for line in LinesWithEndings::from(code) {
111        ends_with_newline = line.ends_with('\n');
112        let trimmed = line.trim_end_matches('\n');
113        result.push(vec![(
114            syntect::highlighting::Style::default(),
115            trimmed.to_string(),
116        )]);
117    }
118
119    if ends_with_newline {
120        result.push(Vec::new());
121    }
122
123    result
124}
125
126/// Load a theme from the process-global theme set.
127///
128/// # Arguments
129/// * `theme_name` - Theme identifier (TextMate theme name)
130/// * `cache` - Ignored. Kept for API compatibility.
131///
132/// # Returns
133/// Cloned theme instance (safe for multi-threaded use)
134pub fn load_theme(theme_name: &str, _cache: bool) -> Theme {
135    if let Some(theme) = SHARED_THEME_SET.themes.get(theme_name) {
136        theme.clone()
137    } else {
138        warn!(
139            theme = theme_name,
140            "Unknown syntax highlighting theme, falling back to default"
141        );
142        fallback_theme()
143    }
144}
145
146/// Get the default syntax theme name
147#[inline]
148pub fn default_theme_name() -> String {
149    DEFAULT_THEME_NAME.to_string()
150}
151
152/// Get all available theme names
153pub fn available_themes() -> Vec<String> {
154    SHARED_THEME_SET.themes.keys().cloned().collect()
155}
156
157/// Check if input should be highlighted (guardrails)
158#[inline]
159pub fn should_highlight(code: &str) -> bool {
160    code.len() <= MAX_INPUT_SIZE_BYTES && code.lines().count() <= MAX_INPUT_LINES
161}
162
163/// Get the recommended syntax theme for the current UI theme
164///
165/// This ensures syntax highlighting colors complement the UI theme background.
166/// Based on OpenAI Codex PRs #11447 and #12581.
167#[inline]
168pub fn get_active_syntax_theme() -> &'static str {
169    get_syntax_theme_for_ui_theme(&crate::ui::theme::active_theme_id())
170}
171
172/// Get the recommended syntax theme for a specific UI theme
173#[inline]
174pub fn get_syntax_theme(theme: &str) -> &'static str {
175    get_syntax_theme_for_ui_theme(theme)
176}
177
178/// Raw RGB diff backgrounds extracted from syntax theme scopes.
179///
180/// Prefers `markup.inserted` / `markup.deleted` and falls back to
181/// `diff.inserted` / `diff.deleted`.
182#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
183pub struct DiffScopeBackgroundRgbs {
184    pub inserted: Option<(u8, u8, u8)>,
185    pub deleted: Option<(u8, u8, u8)>,
186}
187
188/// Resolve diff-scope background colors from the currently active syntax theme.
189pub fn diff_scope_background_rgbs() -> DiffScopeBackgroundRgbs {
190    let theme_name = get_active_syntax_theme();
191    let theme = load_theme(theme_name, true);
192    diff_scope_background_rgbs_for_theme(&theme)
193}
194
195fn diff_scope_background_rgbs_for_theme(theme: &Theme) -> DiffScopeBackgroundRgbs {
196    let highlighter = Highlighter::new(theme);
197    let inserted = scope_background_rgb(&highlighter, "markup.inserted")
198        .or_else(|| scope_background_rgb(&highlighter, "diff.inserted"));
199    let deleted = scope_background_rgb(&highlighter, "markup.deleted")
200        .or_else(|| scope_background_rgb(&highlighter, "diff.deleted"));
201    DiffScopeBackgroundRgbs { inserted, deleted }
202}
203
204fn scope_background_rgb(highlighter: &Highlighter<'_>, scope_name: &str) -> Option<(u8, u8, u8)> {
205    let scope = Scope::new(scope_name).ok()?;
206    let background = highlighter.style_mod_for_stack(&[scope]).background?;
207    Some((background.r, background.g, background.b))
208}
209
210#[inline]
211fn select_syntax(language: Option<&str>) -> &'static SyntaxReference {
212    language
213        .map(find_syntax_by_token)
214        .unwrap_or_else(find_syntax_plain_text)
215}
216
217/// Highlight code and return styled segments per line.
218///
219/// Uses `LinesWithEndings` semantics by preserving an empty trailing line
220/// when the input ends with `\n`.
221pub fn highlight_code_to_line_segments(
222    code: &str,
223    language: Option<&str>,
224    theme_name: &str,
225) -> Vec<Vec<(syntect::highlighting::Style, String)>> {
226    if !should_highlight(code) {
227        return plain_text_line_segments(code);
228    }
229
230    let syntax = select_syntax(language);
231    let theme = load_theme(theme_name, true);
232    let mut highlighter = syntect::easy::HighlightLines::new(syntax, &theme);
233    let mut result = Vec::new();
234    let mut ends_with_newline = false;
235
236    for line in LinesWithEndings::from(code) {
237        ends_with_newline = line.ends_with('\n');
238        let trimmed = line.trim_end_matches('\n');
239        let segments = match highlighter.highlight_line(trimmed, syntax_set()) {
240            Ok(ranges) => ranges
241                .into_iter()
242                .map(|(style, text)| (style, text.to_string()))
243                .collect(),
244            Err(_) => vec![(syntect::highlighting::Style::default(), trimmed.to_string())],
245        };
246        result.push(segments);
247    }
248
249    if ends_with_newline {
250        result.push(Vec::new());
251    }
252
253    result
254}
255
256/// Highlight code and convert to `anstyle` segments with optional bg stripping.
257pub fn highlight_code_to_anstyle_line_segments(
258    code: &str,
259    language: Option<&str>,
260    theme_name: &str,
261    strip_background: bool,
262) -> Vec<Vec<(AnstyleStyle, String)>> {
263    highlight_code_to_line_segments(code, language, theme_name)
264        .into_iter()
265        .map(|ranges| {
266            ranges
267                .into_iter()
268                .filter(|(_, text)| !text.is_empty())
269                .map(|(style, text)| {
270                    let mut anstyle = to_anstyle(style);
271                    if strip_background {
272                        anstyle = anstyle.bg_color(None);
273                    }
274                    (anstyle, text)
275                })
276                .collect()
277        })
278        .collect()
279}
280
281/// Highlight one line and convert to `anstyle` segments with optional bg stripping.
282pub fn highlight_line_to_anstyle_segments(
283    line: &str,
284    language: Option<&str>,
285    theme_name: &str,
286    strip_background: bool,
287) -> Option<Vec<(AnstyleStyle, String)>> {
288    highlight_code_to_anstyle_line_segments(line, language, theme_name, strip_background)
289        .into_iter()
290        .next()
291}
292
293/// Highlight code and return styled segments
294///
295/// # Arguments
296/// * `code` - Source code to highlight
297/// * `language` - Optional language hint (auto-detected if None)
298/// * `theme_name` - Syntax theme name (use `get_active_syntax_theme()` for UI theme sync)
299///
300/// # Returns
301/// Vector of (Style, String) tuples for rendering
302///
303/// # Performance
304/// - Returns None early if input exceeds guardrails
305/// - Uses cached theme when available
306pub fn highlight_code_to_segments(
307    code: &str,
308    language: Option<&str>,
309    theme_name: &str,
310) -> Vec<(syntect::highlighting::Style, String)> {
311    highlight_code_to_line_segments(code, language, theme_name)
312        .into_iter()
313        .flatten()
314        .collect()
315}
316
317/// Highlight a single line (for diff rendering)
318///
319/// Preserves parser state for multiline constructs
320pub fn highlight_line_for_diff(
321    line: &str,
322    language: Option<&str>,
323    theme_name: &str,
324) -> Option<Vec<(syntect::highlighting::Style, String)>> {
325    highlight_code_to_line_segments(line, language, theme_name)
326        .into_iter()
327        .next()
328}
329
330/// Convert code to ANSI escape sequences
331pub fn highlight_code_to_ansi(code: &str, language: Option<&str>, theme_name: &str) -> String {
332    let segments = highlight_code_to_segments(code, language, theme_name);
333    let mut output = String::with_capacity(code.len() + segments.len() * 10);
334
335    for (style, text) in segments {
336        let ansi_style = to_anstyle(style);
337        output.push_str(&ansi_style.to_string());
338        output.push_str(&text);
339        output.push_str("\x1b[0m"); // Reset
340    }
341
342    output
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use std::str::FromStr;
349    use syntect::highlighting::Color as SyntectColor;
350    use syntect::highlighting::ScopeSelectors;
351    use syntect::highlighting::StyleModifier;
352    use syntect::highlighting::ThemeItem;
353    use syntect::highlighting::ThemeSettings;
354
355    fn theme_item(scope: &str, background: Option<(u8, u8, u8)>) -> ThemeItem {
356        ThemeItem {
357            scope: ScopeSelectors::from_str(scope).expect("scope selector should parse"),
358            style: StyleModifier {
359                background: background.map(|(r, g, b)| SyntectColor { r, g, b, a: 255 }),
360                ..StyleModifier::default()
361            },
362        }
363    }
364
365    #[test]
366    fn test_syntax_set_loaded() {
367        let ss = syntax_set();
368        assert!(!ss.syntaxes().is_empty());
369    }
370
371    #[test]
372    fn test_find_syntax_by_token() {
373        let rust = find_syntax_by_token("rust");
374        assert!(rust.name.contains("Rust"));
375    }
376
377    #[test]
378    fn test_should_highlight_guardrails() {
379        assert!(should_highlight("fn main() {}"));
380        assert!(!should_highlight(&"x".repeat(MAX_INPUT_SIZE_BYTES + 1)));
381    }
382
383    #[test]
384    fn test_get_active_syntax_theme() {
385        let theme = get_active_syntax_theme();
386        assert!(!theme.is_empty());
387    }
388
389    #[test]
390    fn test_highlight_code_to_segments() {
391        let segments =
392            highlight_code_to_segments("fn main() {}", Some("rust"), "base16-ocean.dark");
393        assert!(!segments.is_empty());
394    }
395
396    #[test]
397    fn test_theme_loading_stable() {
398        let theme1 = load_theme("base16-ocean.dark", true);
399        let theme2 = load_theme("base16-ocean.dark", true);
400        assert_eq!(theme1.name, theme2.name);
401    }
402
403    #[test]
404    fn diff_scope_backgrounds_prefer_markup_scope_then_diff_fallback() {
405        let theme = Theme {
406            settings: ThemeSettings::default(),
407            scopes: vec![
408                theme_item("markup.inserted", Some((10, 20, 30))),
409                theme_item("diff.deleted", Some((40, 50, 60))),
410            ],
411            ..Theme::default()
412        };
413
414        let rgbs = diff_scope_background_rgbs_for_theme(&theme);
415        assert_eq!(
416            rgbs,
417            DiffScopeBackgroundRgbs {
418                inserted: Some((10, 20, 30)),
419                deleted: Some((40, 50, 60)),
420            }
421        );
422    }
423
424    #[test]
425    fn diff_scope_backgrounds_return_none_when_scopes_do_not_match() {
426        let theme = Theme {
427            settings: ThemeSettings::default(),
428            scopes: vec![theme_item("constant.numeric", Some((1, 2, 3)))],
429            ..Theme::default()
430        };
431
432        let rgbs = diff_scope_background_rgbs_for_theme(&theme);
433        assert_eq!(
434            rgbs,
435            DiffScopeBackgroundRgbs {
436                inserted: None,
437                deleted: None,
438            }
439        );
440    }
441
442    #[test]
443    fn diff_scope_backgrounds_fall_back_to_diff_scopes() {
444        let theme = Theme {
445            settings: ThemeSettings::default(),
446            scopes: vec![
447                theme_item("diff.inserted", Some((16, 32, 48))),
448                theme_item("diff.deleted", Some((64, 80, 96))),
449            ],
450            ..Theme::default()
451        };
452
453        let rgbs = diff_scope_background_rgbs_for_theme(&theme);
454        assert_eq!(
455            rgbs,
456            DiffScopeBackgroundRgbs {
457                inserted: Some((16, 32, 48)),
458                deleted: Some((64, 80, 96)),
459            }
460        );
461    }
462}