1use 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
44const DEFAULT_THEME_NAME: &str = "base16-ocean.dark";
46
47const MAX_INPUT_SIZE_BYTES: usize = 512 * 1024;
49
50const MAX_INPUT_LINES: usize = 10_000;
52
53const ANSI_ALPHA_INDEX: u8 = 0x00;
56const ANSI_ALPHA_DEFAULT: u8 = 0x01;
57const OPAQUE_ALPHA: u8 = u8::MAX;
58
59static SHARED_SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
61
62static 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
71static THEME_CACHE: Lazy<std::sync::RwLock<std::collections::HashMap<String, Theme>>> =
74 Lazy::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
75
76#[inline]
78pub fn syntax_set() -> &'static SyntaxSet {
79 &SHARED_SYNTAX_SET
80}
81
82#[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#[inline]
92pub fn find_syntax_by_name(name: &str) -> Option<&'static SyntaxReference> {
93 SHARED_SYNTAX_SET.find_syntax_by_name(name)
94}
95
96#[inline]
98pub fn find_syntax_by_extension(ext: &str) -> Option<&'static SyntaxReference> {
99 SHARED_SYNTAX_SET.find_syntax_by_extension(ext)
100}
101
102#[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
128pub fn load_theme(theme_name: &str, cache: bool) -> Theme {
137 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 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 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#[inline]
163pub fn default_theme_name() -> String {
164 DEFAULT_THEME_NAME.to_string()
165}
166
167pub fn available_themes() -> Vec<String> {
169 SHARED_THEME_SET.themes.keys().cloned().collect()
170}
171
172#[inline]
174pub fn should_highlight(code: &str) -> bool {
175 code.len() <= MAX_INPUT_SIZE_BYTES && code.lines().count() <= MAX_INPUT_LINES
176}
177
178#[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#[inline]
189pub fn get_syntax_theme(theme: &str) -> &'static str {
190 get_syntax_theme_for_ui_theme(theme)
191}
192
193#[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
203pub(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 ANSI_ALPHA_INDEX => Some(ansi_palette_color(color.r)),
243 ANSI_ALPHA_DEFAULT => None,
245 OPAQUE_ALPHA => Some(RgbColor(color.r, color.g, color.b).into()),
247 _ => 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
275pub 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
343pub 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
354pub 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
366pub 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
390pub 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
401pub 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}