//! Syntax highlighting engine for the TUI. //! //! Wraps [syntect] with the [two_face] grammar and theme bundles to provide //! ~250-language syntax highlighting and 32 bundled color themes. The module //! owns four process-global singletons: //! //! | Singleton | Type | Purpose | //! |---|---|---| //! | `SYNTAX_SET` | `OnceLock` | Grammar database, immutable after init | //! | `THEME` | `OnceLock>` | Active color theme, swappable at runtime | //! | `THEME_OVERRIDE` | `OnceLock>` | Persisted user preference (write-once) | //! | `CODEX_HOME` | `OnceLock>` | Root for custom `.tmTheme` discovery | //! //! **Lifecycle:** call [`set_theme_override`] once at startup (after the final //! config is resolved) to persist the user preference and seed the `THEME` //! lock. After that, [`set_syntax_theme`] and [`current_syntax_theme`] can //! swap/snapshot the theme for live preview. All highlighting functions read //! the theme via `theme_lock()`. //! //! **Guardrails:** inputs exceeding 512 KB or 10 000 lines are rejected early //! (returns `None`) to prevent pathological CPU/memory usage. Callers must //! fall back to plain unstyled text. use ratatui::style::Color as RtColor; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::text::Line; use ratatui::text::Span; use std::path::Path; use std::path::PathBuf; use std::sync::OnceLock; use std::sync::RwLock; use syntect::easy::HighlightLines; use syntect::highlighting::Color as SyntectColor; use syntect::highlighting::FontStyle; use syntect::highlighting::Highlighter; use syntect::highlighting::Style as SyntectStyle; use syntect::highlighting::Theme; use syntect::highlighting::ThemeSet; use syntect::parsing::Scope; use syntect::parsing::SyntaxReference; use syntect::parsing::SyntaxSet; use syntect::util::LinesWithEndings; use two_face::theme::EmbeddedThemeName; // -- Global singletons ------------------------------------------------------- static SYNTAX_SET: OnceLock = OnceLock::new(); static THEME: OnceLock> = OnceLock::new(); static THEME_OVERRIDE: OnceLock> = OnceLock::new(); static CODEX_HOME: OnceLock> = OnceLock::new(); // Syntect/bat encode ANSI palette semantics in alpha: // `a=0` => indexed ANSI palette via RGB payload, `a=1` => terminal default. const ANSI_ALPHA_INDEX: u8 = 0x00; const ANSI_ALPHA_DEFAULT: u8 = 0x01; const OPAQUE_ALPHA: u8 = 0xFF; fn syntax_set() -> &'static SyntaxSet { SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines) } // NOTE: We intentionally do NOT emit a runtime diagnostic when an ANSI-family // theme (ansi, base16, base16-256) lacks the expected alpha-channel marker // encoding. If the upstream two_face/syntect theme format changes, the // `ansi_themes_use_only_ansi_palette_colors` test will catch it at build // time — long before it reaches users. A runtime warning would be // unactionable noise since users can't fix upstream themes. /// Set the user-configured syntax theme override and codex home path. /// /// Call this with the **final resolved config** (after onboarding, resume, and /// fork reloads complete). The first call persists `name` and `codex_home` in /// `OnceLock`s used by startup/default theme resolution. /// /// Subsequent calls cannot change the persisted `OnceLock` values, but they /// still update the runtime theme immediately for live preview flows. /// /// Returns user-facing warnings for actionable configuration issues, such as /// unknown/invalid theme names or duplicate override persistence. pub(crate) fn set_theme_override( name: Option, codex_home: Option, ) -> Option { let warning = validate_theme_name(name.as_deref(), codex_home.as_deref()); let override_set_ok = THEME_OVERRIDE.set(name.clone()).is_ok(); let codex_home_set_ok = CODEX_HOME.set(codex_home.clone()).is_ok(); if THEME.get().is_some() { set_syntax_theme(resolve_theme_with_override( name.as_deref(), codex_home.as_deref(), )); } if !override_set_ok || !codex_home_set_ok { // This should never happen in practice — set_theme_override is only // called once at startup. Keep as a debug breadcrumb in case a second // call site is added in the future. tracing::debug!("set_theme_override called more than once; OnceLock values unchanged"); } warning } /// Check whether a theme name resolves to a bundled theme or a custom /// `.tmTheme` file. Returns a user-facing warning when it does not. pub(crate) fn validate_theme_name(name: Option<&str>, codex_home: Option<&Path>) -> Option { let name = name?; let custom_theme_path_display = codex_home .map(|home| custom_theme_path(name, home).display().to_string()) .unwrap_or_else(|| format!("$CODEX_HOME/themes/{name}.tmTheme")); // Bundled themes always resolve. if parse_theme_name(name).is_some() { return None; } // Custom themes must parse successfully; an unreadable/invalid file should // still surface a startup warning so users can diagnose configuration issues. if let Some(home) = codex_home { let custom_path = custom_theme_path(name, home); if custom_path.is_file() { if load_custom_theme(name, home).is_some() { return None; } return Some(format!( "Custom theme \"{name}\" at {custom_theme_path_display} could not \ be loaded (invalid .tmTheme format). Falling back to the default theme." )); } } Some(format!( "Theme \"{name}\" not found. Using the default theme. \ To use a custom theme, place a .tmTheme file at \ {custom_theme_path_display}." )) } /// Map a kebab-case theme name to the corresponding `EmbeddedThemeName`. fn parse_theme_name(name: &str) -> Option { match name { "ansi" => Some(EmbeddedThemeName::Ansi), "base16" => Some(EmbeddedThemeName::Base16), "base16-eighties-dark" => Some(EmbeddedThemeName::Base16EightiesDark), "base16-mocha-dark" => Some(EmbeddedThemeName::Base16MochaDark), "base16-ocean-dark" => Some(EmbeddedThemeName::Base16OceanDark), "base16-ocean-light" => Some(EmbeddedThemeName::Base16OceanLight), "base16-256" => Some(EmbeddedThemeName::Base16_256), "catppuccin-frappe" => Some(EmbeddedThemeName::CatppuccinFrappe), "catppuccin-latte" => Some(EmbeddedThemeName::CatppuccinLatte), "catppuccin-macchiato" => Some(EmbeddedThemeName::CatppuccinMacchiato), "catppuccin-mocha" => Some(EmbeddedThemeName::CatppuccinMocha), "coldark-cold" => Some(EmbeddedThemeName::ColdarkCold), "coldark-dark" => Some(EmbeddedThemeName::ColdarkDark), "dark-neon" => Some(EmbeddedThemeName::DarkNeon), "dracula" => Some(EmbeddedThemeName::Dracula), "github" => Some(EmbeddedThemeName::Github), "gruvbox-dark" => Some(EmbeddedThemeName::GruvboxDark), "gruvbox-light" => Some(EmbeddedThemeName::GruvboxLight), "inspired-github" => Some(EmbeddedThemeName::InspiredGithub), "1337" => Some(EmbeddedThemeName::Leet), "monokai-extended" => Some(EmbeddedThemeName::MonokaiExtended), "monokai-extended-bright" => Some(EmbeddedThemeName::MonokaiExtendedBright), "monokai-extended-light" => Some(EmbeddedThemeName::MonokaiExtendedLight), "monokai-extended-origin" => Some(EmbeddedThemeName::MonokaiExtendedOrigin), "nord" => Some(EmbeddedThemeName::Nord), "one-half-dark" => Some(EmbeddedThemeName::OneHalfDark), "one-half-light" => Some(EmbeddedThemeName::OneHalfLight), "solarized-dark" => Some(EmbeddedThemeName::SolarizedDark), "solarized-light" => Some(EmbeddedThemeName::SolarizedLight), "sublime-snazzy" => Some(EmbeddedThemeName::SublimeSnazzy), "two-dark" => Some(EmbeddedThemeName::TwoDark), "zenburn" => Some(EmbeddedThemeName::Zenburn), _ => None, } } /// Build the expected path for a custom theme file. fn custom_theme_path(name: &str, codex_home: &Path) -> PathBuf { codex_home.join("themes").join(format!("{name}.tmTheme")) } /// Try to load a custom `.tmTheme` file from `{codex_home}/themes/{name}.tmTheme`. fn load_custom_theme(name: &str, codex_home: &Path) -> Option { ThemeSet::get_theme(custom_theme_path(name, codex_home)).ok() } fn adaptive_default_theme_selection() -> (EmbeddedThemeName, &'static str) { match crate::terminal_palette::default_bg() { Some(bg) if crate::color::is_light(bg) => { (EmbeddedThemeName::CatppuccinLatte, "catppuccin-latte") } _ => (EmbeddedThemeName::CatppuccinMocha, "catppuccin-mocha"), } } fn adaptive_default_embedded_theme_name() -> EmbeddedThemeName { adaptive_default_theme_selection().0 } /// Return the kebab-case name of the adaptive default syntax theme selected /// from terminal background lightness. pub(crate) fn adaptive_default_theme_name() -> &'static str { adaptive_default_theme_selection().1 } /// Build the theme from current override/default-theme settings. /// Extracted from the old `theme()` init closure so it can be reused. fn resolve_theme_with_override(name: Option<&str>, codex_home: Option<&Path>) -> Theme { let ts = two_face::theme::extra(); // Honor user-configured theme if valid. if let Some(name) = name { // 1. Try bundled theme by kebab-case name. if let Some(theme_name) = parse_theme_name(name) { return ts.get(theme_name).clone(); } // 2. Try loading {CODEX_HOME}/themes/{name}.tmTheme from disk. if let Some(home) = codex_home && let Some(theme) = load_custom_theme(name, home) { return theme; } tracing::debug!("Theme \"{name}\" not recognized; using default theme"); } ts.get(adaptive_default_embedded_theme_name()).clone() } /// Build the theme from current override/default-theme settings. /// Extracted from the old `theme()` init closure so it can be reused. fn build_default_theme() -> Theme { let name = THEME_OVERRIDE.get().and_then(|name| name.as_deref()); let codex_home = CODEX_HOME .get() .and_then(|codex_home| codex_home.as_deref()); resolve_theme_with_override(name, codex_home) } fn theme_lock() -> &'static RwLock { THEME.get_or_init(|| RwLock::new(build_default_theme())) } /// Swap the active syntax theme at runtime (for live preview). pub(crate) fn set_syntax_theme(theme: Theme) { let mut guard = match theme_lock().write() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; *guard = theme; } /// Clone the current syntax theme (e.g. to save for cancel-restore). pub(crate) fn current_syntax_theme() -> Theme { match theme_lock().read() { Ok(theme) => theme.clone(), Err(poisoned) => poisoned.into_inner().clone(), } } /// Raw RGB background colors extracted from syntax theme diff/markup scopes. /// /// These are theme-provided colors, not yet adapted for any particular color /// depth. [`diff_render`](crate::diff_render) converts them to ratatui /// `Color` values via `color_from_rgb_for_level` after deciding whether to /// emit truecolor or quantized ANSI-256. /// /// Both fields are `None` when the active theme defines no relevant scope /// backgrounds, in which case the diff renderer falls back to its hardcoded /// palette. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) struct DiffScopeBackgroundRgbs { pub inserted: Option<(u8, u8, u8)>, pub deleted: Option<(u8, u8, u8)>, } /// Query the active syntax theme for diff-scope background colors. /// /// Prefers `markup.inserted` / `markup.deleted` (the TextMate convention used /// by most VS Code themes) and falls back to `diff.inserted` / `diff.deleted` /// (used by some older `.tmTheme` files). pub(crate) fn diff_scope_background_rgbs() -> DiffScopeBackgroundRgbs { let theme = current_syntax_theme(); diff_scope_background_rgbs_for_theme(&theme) } /// Pure extraction helper, separated from the global theme singleton so tests /// can pass arbitrary themes. fn diff_scope_background_rgbs_for_theme(theme: &Theme) -> DiffScopeBackgroundRgbs { let highlighter = Highlighter::new(theme); let inserted = scope_background_rgb(&highlighter, "markup.inserted") .or_else(|| scope_background_rgb(&highlighter, "diff.inserted")); let deleted = scope_background_rgb(&highlighter, "markup.deleted") .or_else(|| scope_background_rgb(&highlighter, "diff.deleted")); DiffScopeBackgroundRgbs { inserted, deleted } } /// Extract the background color for a single TextMate scope, if defined. fn scope_background_rgb(highlighter: &Highlighter<'_>, scope_name: &str) -> Option<(u8, u8, u8)> { let scope = Scope::new(scope_name).ok()?; let bg = highlighter.style_mod_for_stack(&[scope]).background?; Some((bg.r, bg.g, bg.b)) } /// Query the active syntax theme for the first foreground style provided by the /// supplied TextMate scopes. pub(crate) fn foreground_style_for_scopes(scope_names: &[&str]) -> Option