Skip to main content

theme_engine/
lib.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6pub const BUILTIN_THEME_NAMES: [&str; 6] = [
7    "tokyonight-dark",
8    "tokyonight-moon",
9    "tokyonight-light",
10    "tokyonight-day",
11    "solarized-dark",
12    "solarized-light",
13];
14
15#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
16pub enum BuiltinTheme {
17    TokyoNightDark,
18    TokyoNightMoon,
19    TokyoNightLight,
20    TokyoNightDay,
21    SolarizedDark,
22    SolarizedLight,
23}
24
25impl BuiltinTheme {
26    /// Returns the canonical name used in configuration and CLI arguments.
27    #[must_use]
28    pub const fn name(self) -> &'static str {
29        match self {
30            Self::TokyoNightDark => "tokyonight-dark",
31            Self::TokyoNightMoon => "tokyonight-moon",
32            Self::TokyoNightLight => "tokyonight-light",
33            Self::TokyoNightDay => "tokyonight-day",
34            Self::SolarizedDark => "solarized-dark",
35            Self::SolarizedLight => "solarized-light",
36        }
37    }
38
39    /// Parses a built-in theme name or alias.
40    ///
41    /// Accepted aliases include `"tokyo-night"`, `"tokyo-day"`, and
42    /// `"tokyonight-moon"`.
43    #[must_use]
44    pub fn from_name(name: &str) -> Option<Self> {
45        match name.trim().to_ascii_lowercase().as_str() {
46            "tokyonight-dark" | "tokyo-night" => Some(Self::TokyoNightDark),
47            "tokyonight-moon" => Some(Self::TokyoNightMoon),
48            "tokyonight-light" | "tokyo-day" => Some(Self::TokyoNightLight),
49            "tokyonight-day" => Some(Self::TokyoNightDay),
50            "solarized-dark" => Some(Self::SolarizedDark),
51            "solarized-light" => Some(Self::SolarizedLight),
52            _ => None,
53        }
54    }
55
56    /// Returns the embedded JSON source for this built-in theme.
57    const fn source(self) -> &'static str {
58        match self {
59            Self::TokyoNightDark => include_str!("../themes/tokyonight-dark.json"),
60            Self::TokyoNightMoon => include_str!("../themes/tokyonight-moon.json"),
61            Self::TokyoNightLight => include_str!("../themes/tokyonight-light.json"),
62            Self::TokyoNightDay => include_str!("../themes/tokyonight-day.json"),
63            Self::SolarizedDark => include_str!("../themes/solarized-dark.json"),
64            Self::SolarizedLight => include_str!("../themes/solarized-light.json"),
65        }
66    }
67}
68
69#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
70pub struct Rgb {
71    pub r: u8,
72    pub g: u8,
73    pub b: u8,
74}
75
76impl Rgb {
77    /// Creates an RGB triplet.
78    #[must_use]
79    pub const fn new(r: u8, g: u8, b: u8) -> Self {
80        Self { r, g, b }
81    }
82}
83
84#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, Serialize, Deserialize)]
85pub struct Style {
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub fg: Option<Rgb>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub bg: Option<Rgb>,
90    #[serde(default)]
91    pub bold: bool,
92    #[serde(default)]
93    pub italic: bool,
94    #[serde(default)]
95    pub underline: bool,
96}
97
98#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
99pub enum UiRole {
100    DefaultFg,
101    DefaultBg,
102    Statusline,
103    StatuslineInactive,
104    TabActive,
105    TabInactive,
106    Selection,
107    Cursorline,
108}
109
110impl UiRole {
111    /// Returns the canonical role key used in theme documents.
112    #[must_use]
113    pub const fn key(self) -> &'static str {
114        match self {
115            Self::DefaultFg => "default_fg",
116            Self::DefaultBg => "default_bg",
117            Self::Statusline => "statusline",
118            Self::StatuslineInactive => "statusline_inactive",
119            Self::TabActive => "tab_active",
120            Self::TabInactive => "tab_inactive",
121            Self::Selection => "selection",
122            Self::Cursorline => "cursorline",
123        }
124    }
125
126    /// Parses a UI role from a key or alias.
127    #[must_use]
128    pub fn from_name(name: &str) -> Option<Self> {
129        match normalize_capture_name(name).as_str() {
130            "default_fg" | "defaultfg" | "terminal_fg" | "terminalfg" => Some(Self::DefaultFg),
131            "default_bg" | "defaultbg" | "terminal_bg" | "terminalbg" => Some(Self::DefaultBg),
132            "statusline" | "status_line" => Some(Self::Statusline),
133            "statusline_inactive" | "status_line_inactive" | "statuslineinactive" => {
134                Some(Self::StatuslineInactive)
135            }
136            "tab_active" | "tabactive" | "tab" => Some(Self::TabActive),
137            "tab_inactive" | "tabinactive" => Some(Self::TabInactive),
138            "selection" => Some(Self::Selection),
139            "cursorline" | "cursor_line" => Some(Self::Cursorline),
140            _ => None,
141        }
142    }
143}
144
145#[derive(Debug, Clone, Default, Eq, PartialEq)]
146pub struct Theme {
147    styles: BTreeMap<String, Style>,
148    ui: BTreeMap<String, Style>,
149}
150
151impl Theme {
152    /// Creates an empty theme.
153    #[must_use]
154    pub fn new() -> Self {
155        Self::default()
156    }
157
158    /// Creates a theme from a style map after normalizing capture names.
159    #[must_use]
160    pub fn from_styles(styles: BTreeMap<String, Style>) -> Self {
161        Self::from_parts(styles, BTreeMap::new())
162    }
163
164    /// Creates a theme from syntax-style and UI-role maps after normalization.
165    #[must_use]
166    pub fn from_parts(styles: BTreeMap<String, Style>, ui: BTreeMap<String, Style>) -> Self {
167        let mut theme = Self::new();
168        for (name, style) in styles {
169            let _ = theme.insert(name, style);
170        }
171        for (name, style) in ui {
172            let _ = theme.insert_ui(name, style);
173        }
174        theme
175    }
176
177    /// Inserts or replaces a style for a capture name.
178    ///
179    /// Capture names are normalized (trimmed, lowercased, optional `@` removed).
180    /// Returns the previously associated style, if any.
181    pub fn insert(&mut self, capture_name: impl AsRef<str>, style: Style) -> Option<Style> {
182        self.styles
183            .insert(normalize_capture_name(capture_name.as_ref()), style)
184    }
185
186    /// Returns the internal normalized style map.
187    #[must_use]
188    pub fn styles(&self) -> &BTreeMap<String, Style> {
189        &self.styles
190    }
191
192    /// Inserts or replaces a UI role style.
193    ///
194    /// Role names are normalized like capture names.
195    /// Returns the previously associated style, if any.
196    pub fn insert_ui(&mut self, role_name: impl AsRef<str>, style: Style) -> Option<Style> {
197        self.ui
198            .insert(normalize_capture_name(role_name.as_ref()), style)
199    }
200
201    /// Returns the internal normalized UI role map.
202    #[must_use]
203    pub fn ui_styles(&self) -> &BTreeMap<String, Style> {
204        &self.ui
205    }
206
207    /// Returns the exact style for a capture after normalization.
208    #[must_use]
209    pub fn get_exact(&self, capture_name: &str) -> Option<&Style> {
210        self.styles.get(&normalize_capture_name(capture_name))
211    }
212
213    /// Returns the exact UI role style after normalization.
214    #[must_use]
215    pub fn get_ui_exact(&self, role_name: &str) -> Option<&Style> {
216        self.ui.get(&normalize_capture_name(role_name))
217    }
218
219    /// Resolves a style using dotted-name fallback and finally `normal`.
220    ///
221    /// For example, `comment.documentation` falls back to `comment` before
222    /// attempting `normal`.
223    #[must_use]
224    pub fn resolve(&self, capture_name: &str) -> Option<&Style> {
225        let mut key = normalize_capture_name(capture_name);
226
227        loop {
228            if let Some(style) = self.styles.get(&key) {
229                return Some(style);
230            }
231
232            let Some(index) = key.rfind('.') else {
233                break;
234            };
235            key.truncate(index);
236        }
237
238        self.styles.get("normal")
239    }
240
241    /// Resolves a UI role from explicit UI map entries with compatibility fallbacks.
242    ///
243    /// This method first checks the dedicated `ui` map, then falls back to legacy
244    /// entries in `styles` for compatibility with older themes.
245    #[must_use]
246    pub fn resolve_ui(&self, role_name: &str) -> Option<Style> {
247        let normalized = normalize_capture_name(role_name);
248        if let Some(style) = self.ui.get(&normalized).copied() {
249            return Some(style);
250        }
251        if let Some(style) = self.styles.get(&normalized).copied() {
252            return Some(style);
253        }
254
255        if let Some(role) = UiRole::from_name(&normalized) {
256            return self.resolve_ui_role(role);
257        }
258
259        None
260    }
261
262    /// Resolves a typed UI role from explicit UI entries and fallbacks.
263    #[must_use]
264    pub fn resolve_ui_role(&self, role: UiRole) -> Option<Style> {
265        let key = role.key();
266        if let Some(style) = self.ui.get(key).copied() {
267            return Some(style);
268        }
269        if let Some(style) = self.styles.get(key).copied() {
270            return Some(style);
271        }
272
273        match role {
274            UiRole::DefaultFg => self.styles.get("normal").and_then(|normal| {
275                normal.fg.map(|fg| Style {
276                    fg: Some(fg),
277                    ..Style::default()
278                })
279            }),
280            UiRole::DefaultBg => self.styles.get("normal").and_then(|normal| {
281                normal.bg.map(|bg| Style {
282                    bg: Some(bg),
283                    ..Style::default()
284                })
285            }),
286            UiRole::Statusline => self.styles.get("statusline").copied(),
287            UiRole::StatuslineInactive => self
288                .ui
289                .get("statusline_inactive")
290                .copied()
291                .or_else(|| self.styles.get("statusline_inactive").copied())
292                .or_else(|| self.styles.get("ignore").copied())
293                .or_else(|| self.styles.get("statusline").copied()),
294            UiRole::TabActive => self
295                .ui
296                .get("tab_active")
297                .copied()
298                .or_else(|| self.styles.get("tab_active").copied())
299                .or_else(|| self.styles.get("statusline").copied()),
300            UiRole::TabInactive => self
301                .ui
302                .get("tab_inactive")
303                .copied()
304                .or_else(|| self.styles.get("tab_inactive").copied())
305                .or_else(|| self.styles.get("ignore").copied())
306                .or_else(|| self.styles.get("statusline").copied()),
307            UiRole::Selection => self
308                .ui
309                .get("selection")
310                .copied()
311                .or_else(|| self.styles.get("selection").copied()),
312            UiRole::Cursorline => self
313                .ui
314                .get("cursorline")
315                .copied()
316                .or_else(|| self.styles.get("cursorline").copied())
317                .or_else(|| self.styles.get("selection").copied()),
318        }
319    }
320
321    /// Returns the theme default terminal foreground/background colors.
322    ///
323    /// Values are resolved from UI roles first (`default_fg`, `default_bg`), then
324    /// from `styles.normal`.
325    #[must_use]
326    pub fn default_terminal_colors(&self) -> (Option<Rgb>, Option<Rgb>) {
327        let fg = self
328            .resolve_ui_role(UiRole::DefaultFg)
329            .and_then(|style| style.fg)
330            .or_else(|| self.styles.get("normal").and_then(|style| style.fg));
331        let bg = self
332            .resolve_ui_role(UiRole::DefaultBg)
333            .and_then(|style| style.bg)
334            .or_else(|| self.styles.get("normal").and_then(|style| style.bg));
335        (fg, bg)
336    }
337
338    /// Parses a theme from JSON.
339    ///
340    /// Both wrapped (`{ "styles": { ... } }`) and flat style documents are accepted.
341    ///
342    /// # Errors
343    ///
344    /// Returns an error if the JSON cannot be parsed.
345    pub fn from_json_str(input: &str) -> Result<Self, ThemeError> {
346        let parsed = serde_json::from_str::<ThemeDocument>(input)?;
347        Ok(parsed.into_theme())
348    }
349
350    /// Parses a theme from TOML.
351    ///
352    /// # Errors
353    ///
354    /// Returns an error if the TOML cannot be parsed.
355    pub fn from_toml_str(input: &str) -> Result<Self, ThemeError> {
356        let parsed = toml::from_str::<ThemeDocument>(input)?;
357        Ok(parsed.into_theme())
358    }
359
360    /// Loads a built-in theme from embedded JSON.
361    ///
362    /// # Errors
363    ///
364    /// Returns an error if embedded theme JSON fails to parse.
365    pub fn from_builtin(theme: BuiltinTheme) -> Result<Self, ThemeError> {
366        Self::from_json_str(theme.source())
367    }
368
369    /// Loads a built-in theme from a name or alias.
370    ///
371    /// # Errors
372    ///
373    /// Returns [`ThemeError::UnknownBuiltinTheme`] for unknown names.
374    pub fn from_builtin_name(name: &str) -> Result<Self, ThemeError> {
375        let theme = BuiltinTheme::from_name(name)
376            .ok_or_else(|| ThemeError::UnknownBuiltinTheme(name.trim().to_string()))?;
377        Self::from_builtin(theme)
378    }
379}
380
381/// Returns canonical names of built-in themes.
382#[must_use]
383pub const fn available_themes() -> &'static [&'static str] {
384    &BUILTIN_THEME_NAMES
385}
386
387/// Loads a built-in theme by name or alias.
388///
389/// # Errors
390///
391/// Returns an error for unknown theme names or malformed embedded theme data.
392pub fn load_theme(name: &str) -> Result<Theme, ThemeError> {
393    Theme::from_builtin_name(name)
394}
395
396#[derive(Debug, Error)]
397pub enum ThemeError {
398    #[error("failed to parse theme JSON: {0}")]
399    Json(#[from] serde_json::Error),
400    #[error("failed to parse theme TOML: {0}")]
401    Toml(#[from] toml::de::Error),
402    #[error(
403        "unknown built-in theme '{0}', available: tokyonight-dark, tokyonight-moon, tokyonight-light, tokyonight-day, solarized-dark, solarized-light"
404    )]
405    UnknownBuiltinTheme(String),
406}
407
408#[derive(Debug, Deserialize)]
409#[serde(deny_unknown_fields)]
410struct WrappedThemeDocument {
411    #[serde(default)]
412    styles: BTreeMap<String, Style>,
413    #[serde(default)]
414    ui: BTreeMap<String, Style>,
415}
416
417#[derive(Debug, Deserialize)]
418#[serde(untagged)]
419enum ThemeDocument {
420    Wrapped(WrappedThemeDocument),
421    Flat(BTreeMap<String, Style>),
422}
423
424impl ThemeDocument {
425    /// Converts a parsed document to a normalized [`Theme`].
426    fn into_theme(self) -> Theme {
427        match self {
428            ThemeDocument::Wrapped(doc) => Theme::from_parts(doc.styles, doc.ui),
429            ThemeDocument::Flat(styles) => Theme::from_styles(styles),
430        }
431    }
432}
433
434/// Normalizes a theme capture name for lookup.
435///
436/// The normalization trims whitespace, removes an optional `@` prefix, and lowercases.
437#[must_use]
438pub fn normalize_capture_name(capture_name: &str) -> String {
439    let trimmed = capture_name.trim();
440    let without_prefix = trimmed.strip_prefix('@').unwrap_or(trimmed);
441    without_prefix.to_ascii_lowercase()
442}
443
444#[cfg(test)]
445mod tests {
446    use super::{
447        available_themes, load_theme, normalize_capture_name, BuiltinTheme, Rgb, Style, Theme,
448        ThemeError, UiRole,
449    };
450
451    #[test]
452    /// Verifies capture name normalization behavior.
453    fn normalizes_capture_names() {
454        assert_eq!(normalize_capture_name("@Comment.Doc"), "comment.doc");
455        assert_eq!(normalize_capture_name(" keyword "), "keyword");
456    }
457
458    #[test]
459    /// Verifies dotted fallback and `normal` fallback resolution.
460    fn resolves_dot_fallback_then_normal() {
461        let mut theme = Theme::new();
462        let _ = theme.insert(
463            "comment",
464            Style {
465                fg: Some(Rgb::new(1, 2, 3)),
466                ..Style::default()
467            },
468        );
469        let _ = theme.insert(
470            "normal",
471            Style {
472                fg: Some(Rgb::new(9, 9, 9)),
473                ..Style::default()
474            },
475        );
476
477        let comment = theme
478            .resolve("@comment.documentation")
479            .expect("missing comment");
480        assert_eq!(comment.fg, Some(Rgb::new(1, 2, 3)));
481
482        let unknown = theme.resolve("@does.not.exist").expect("missing normal");
483        assert_eq!(unknown.fg, Some(Rgb::new(9, 9, 9)));
484    }
485
486    #[test]
487    /// Verifies wrapped JSON theme documents parse correctly.
488    fn parses_json_theme_document() {
489        let input = r#"
490{
491  "styles": {
492    "@keyword": { "fg": { "r": 255, "g": 0, "b": 0 }, "bold": true },
493    "normal": { "fg": { "r": 200, "g": 200, "b": 200 } }
494  }
495}
496"#;
497
498        let theme = Theme::from_json_str(input).expect("failed to parse json");
499        let style = theme.resolve("keyword").expect("keyword style missing");
500        assert_eq!(style.fg, Some(Rgb::new(255, 0, 0)));
501        assert!(style.bold);
502    }
503
504    #[test]
505    /// Verifies flat TOML theme documents parse correctly.
506    fn parses_toml_flat_theme_document() {
507        let input = r#"
508[normal]
509fg = { r = 40, g = 41, b = 42 }
510
511["@string"]
512fg = { r = 120, g = 121, b = 122 }
513italic = true
514"#;
515
516        let theme = Theme::from_toml_str(input).expect("failed to parse toml");
517        let style = theme.resolve("string").expect("string style missing");
518        assert_eq!(style.fg, Some(Rgb::new(120, 121, 122)));
519        assert!(style.italic);
520    }
521
522    #[test]
523    /// Verifies all built-ins load and contain a `normal` style.
524    fn loads_all_built_in_themes() {
525        for name in available_themes() {
526            let theme = load_theme(name).expect("failed to load built-in theme");
527            assert!(
528                theme.get_exact("normal").is_some(),
529                "missing normal style in {name}"
530            );
531        }
532    }
533
534    #[test]
535    /// Verifies built-in enum loading works for a known theme.
536    fn loads_built_in_theme_by_enum() {
537        let theme = Theme::from_builtin(BuiltinTheme::TokyoNightDark)
538            .expect("failed to load tokyonight-dark");
539        assert!(theme.resolve("keyword").is_some());
540    }
541
542    #[test]
543    /// Verifies unknown built-in names return the expected error.
544    fn rejects_unknown_built_in_theme_name() {
545        let err = load_theme("unknown-theme").expect_err("expected unknown-theme to fail");
546        assert!(matches!(err, ThemeError::UnknownBuiltinTheme(_)));
547    }
548
549    #[test]
550    /// Verifies theme aliases are accepted.
551    fn supports_theme_aliases() {
552        assert!(load_theme("tokyo-night").is_ok());
553        assert!(load_theme("tokyo-day").is_ok());
554        assert!(load_theme("tokyonight-moon").is_ok());
555        assert!(load_theme("tokyonight-day").is_ok());
556    }
557
558    #[test]
559    /// Verifies moon/day variants are distinct built-ins, not aliases.
560    fn loads_distinct_tokyonight_variants() {
561        let moon = load_theme("tokyonight-moon").expect("failed to load moon");
562        let dark = load_theme("tokyonight-dark").expect("failed to load dark");
563        let day = load_theme("tokyonight-day").expect("failed to load day");
564        let light = load_theme("tokyonight-light").expect("failed to load light");
565
566        assert_ne!(moon, dark, "moon should differ from dark");
567        assert_ne!(day, light, "day should differ from light");
568    }
569
570    #[test]
571    /// Verifies built-in themes expose XML-relevant capture styles.
572    fn builtins_include_xml_capture_styles() {
573        for name in available_themes() {
574            let theme = load_theme(name).expect("failed to load built-in theme");
575            assert!(
576                theme.get_exact("tag").is_some(),
577                "missing XML tag style in {name}"
578            );
579            assert!(
580                theme.get_exact("property").is_some(),
581                "missing XML property style in {name}"
582            );
583        }
584    }
585
586    #[test]
587    /// Verifies wrapped documents can carry dedicated UI-role styles.
588    fn parses_ui_roles_from_wrapped_document() {
589        let input = r#"
590{
591  "styles": {
592    "normal": { "fg": { "r": 10, "g": 11, "b": 12 }, "bg": { "r": 13, "g": 14, "b": 15 } }
593  },
594  "ui": {
595    "default_fg": { "fg": { "r": 1, "g": 2, "b": 3 } },
596    "tab_active": { "fg": { "r": 20, "g": 21, "b": 22 }, "bg": { "r": 30, "g": 31, "b": 32 } }
597  }
598}
599"#;
600
601        let theme = Theme::from_json_str(input).expect("failed to parse json");
602        let default_fg = theme
603            .resolve_ui_role(UiRole::DefaultFg)
604            .expect("missing default fg");
605        assert_eq!(default_fg.fg, Some(Rgb::new(1, 2, 3)));
606
607        let tab = theme
608            .resolve_ui("tab_active")
609            .expect("missing tab_active role");
610        assert_eq!(tab.bg, Some(Rgb::new(30, 31, 32)));
611    }
612
613    #[test]
614    /// Verifies default terminal colors fall back to `normal` when UI roles are absent.
615    fn default_terminal_colors_fallback_to_normal() {
616        let theme = Theme::from_json_str(
617            r#"{
618  "styles": {
619    "normal": { "fg": { "r": 100, "g": 101, "b": 102 }, "bg": { "r": 110, "g": 111, "b": 112 } }
620  }
621}"#,
622        )
623        .expect("failed to parse json");
624
625        let (fg, bg) = theme.default_terminal_colors();
626        assert_eq!(fg, Some(Rgb::new(100, 101, 102)));
627        assert_eq!(bg, Some(Rgb::new(110, 111, 112)));
628    }
629
630    #[test]
631    /// Verifies UI role compatibility fallback uses legacy `styles` keys.
632    fn ui_role_falls_back_to_legacy_style_keys() {
633        let mut theme = Theme::new();
634        let _ = theme.insert(
635            "statusline",
636            Style {
637                fg: Some(Rgb::new(1, 1, 1)),
638                bg: Some(Rgb::new(2, 2, 2)),
639                ..Style::default()
640            },
641        );
642        let _ = theme.insert(
643            "ignore",
644            Style {
645                fg: Some(Rgb::new(3, 3, 3)),
646                bg: Some(Rgb::new(4, 4, 4)),
647                ..Style::default()
648            },
649        );
650
651        let active = theme
652            .resolve_ui_role(UiRole::TabActive)
653            .expect("missing active tab");
654        assert_eq!(active.bg, Some(Rgb::new(2, 2, 2)));
655
656        let inactive = theme
657            .resolve_ui_role(UiRole::TabInactive)
658            .expect("missing inactive tab");
659        assert_eq!(inactive.bg, Some(Rgb::new(4, 4, 4)));
660    }
661}