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, Default, Eq, PartialEq)]
99pub struct Theme {
100    styles: BTreeMap<String, Style>,
101}
102
103impl Theme {
104    /// Creates an empty theme.
105    #[must_use]
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    /// Creates a theme from a style map after normalizing capture names.
111    #[must_use]
112    pub fn from_styles(styles: BTreeMap<String, Style>) -> Self {
113        let mut theme = Self::new();
114        for (name, style) in styles {
115            let _ = theme.insert(name, style);
116        }
117        theme
118    }
119
120    /// Inserts or replaces a style for a capture name.
121    ///
122    /// Capture names are normalized (trimmed, lowercased, optional `@` removed).
123    /// Returns the previously associated style, if any.
124    pub fn insert(&mut self, capture_name: impl AsRef<str>, style: Style) -> Option<Style> {
125        self.styles
126            .insert(normalize_capture_name(capture_name.as_ref()), style)
127    }
128
129    /// Returns the internal normalized style map.
130    #[must_use]
131    pub fn styles(&self) -> &BTreeMap<String, Style> {
132        &self.styles
133    }
134
135    /// Returns the exact style for a capture after normalization.
136    #[must_use]
137    pub fn get_exact(&self, capture_name: &str) -> Option<&Style> {
138        self.styles.get(&normalize_capture_name(capture_name))
139    }
140
141    /// Resolves a style using dotted-name fallback and finally `normal`.
142    ///
143    /// For example, `comment.documentation` falls back to `comment` before
144    /// attempting `normal`.
145    #[must_use]
146    pub fn resolve(&self, capture_name: &str) -> Option<&Style> {
147        let mut key = normalize_capture_name(capture_name);
148
149        loop {
150            if let Some(style) = self.styles.get(&key) {
151                return Some(style);
152            }
153
154            let Some(index) = key.rfind('.') else {
155                break;
156            };
157            key.truncate(index);
158        }
159
160        self.styles.get("normal")
161    }
162
163    /// Parses a theme from JSON.
164    ///
165    /// Both wrapped (`{ "styles": { ... } }`) and flat style documents are accepted.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if the JSON cannot be parsed.
170    pub fn from_json_str(input: &str) -> Result<Self, ThemeError> {
171        let parsed = serde_json::from_str::<ThemeDocument>(input)?;
172        Ok(Self::from_styles(parsed.into_styles()))
173    }
174
175    /// Parses a theme from TOML.
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if the TOML cannot be parsed.
180    pub fn from_toml_str(input: &str) -> Result<Self, ThemeError> {
181        let parsed = toml::from_str::<ThemeDocument>(input)?;
182        Ok(Self::from_styles(parsed.into_styles()))
183    }
184
185    /// Loads a built-in theme from embedded JSON.
186    ///
187    /// # Errors
188    ///
189    /// Returns an error if embedded theme JSON fails to parse.
190    pub fn from_builtin(theme: BuiltinTheme) -> Result<Self, ThemeError> {
191        Self::from_json_str(theme.source())
192    }
193
194    /// Loads a built-in theme from a name or alias.
195    ///
196    /// # Errors
197    ///
198    /// Returns [`ThemeError::UnknownBuiltinTheme`] for unknown names.
199    pub fn from_builtin_name(name: &str) -> Result<Self, ThemeError> {
200        let theme = BuiltinTheme::from_name(name)
201            .ok_or_else(|| ThemeError::UnknownBuiltinTheme(name.trim().to_string()))?;
202        Self::from_builtin(theme)
203    }
204}
205
206/// Returns canonical names of built-in themes.
207#[must_use]
208pub const fn available_themes() -> &'static [&'static str] {
209    &BUILTIN_THEME_NAMES
210}
211
212/// Loads a built-in theme by name or alias.
213///
214/// # Errors
215///
216/// Returns an error for unknown theme names or malformed embedded theme data.
217pub fn load_theme(name: &str) -> Result<Theme, ThemeError> {
218    Theme::from_builtin_name(name)
219}
220
221#[derive(Debug, Error)]
222pub enum ThemeError {
223    #[error("failed to parse theme JSON: {0}")]
224    Json(#[from] serde_json::Error),
225    #[error("failed to parse theme TOML: {0}")]
226    Toml(#[from] toml::de::Error),
227    #[error(
228        "unknown built-in theme '{0}', available: tokyonight-dark, tokyonight-moon, tokyonight-light, tokyonight-day, solarized-dark, solarized-light"
229    )]
230    UnknownBuiltinTheme(String),
231}
232
233#[derive(Debug, Deserialize)]
234#[serde(untagged)]
235enum ThemeDocument {
236    Wrapped { styles: BTreeMap<String, Style> },
237    Flat(BTreeMap<String, Style>),
238}
239
240impl ThemeDocument {
241    /// Converts a parsed document to its style map representation.
242    fn into_styles(self) -> BTreeMap<String, Style> {
243        match self {
244            ThemeDocument::Wrapped { styles } => styles,
245            ThemeDocument::Flat(styles) => styles,
246        }
247    }
248}
249
250/// Normalizes a theme capture name for lookup.
251///
252/// The normalization trims whitespace, removes an optional `@` prefix, and lowercases.
253#[must_use]
254pub fn normalize_capture_name(capture_name: &str) -> String {
255    let trimmed = capture_name.trim();
256    let without_prefix = trimmed.strip_prefix('@').unwrap_or(trimmed);
257    without_prefix.to_ascii_lowercase()
258}
259
260#[cfg(test)]
261mod tests {
262    use super::{
263        available_themes, load_theme, normalize_capture_name, BuiltinTheme, Rgb, Style, Theme,
264        ThemeError,
265    };
266
267    #[test]
268    /// Verifies capture name normalization behavior.
269    fn normalizes_capture_names() {
270        assert_eq!(normalize_capture_name("@Comment.Doc"), "comment.doc");
271        assert_eq!(normalize_capture_name(" keyword "), "keyword");
272    }
273
274    #[test]
275    /// Verifies dotted fallback and `normal` fallback resolution.
276    fn resolves_dot_fallback_then_normal() {
277        let mut theme = Theme::new();
278        let _ = theme.insert(
279            "comment",
280            Style {
281                fg: Some(Rgb::new(1, 2, 3)),
282                ..Style::default()
283            },
284        );
285        let _ = theme.insert(
286            "normal",
287            Style {
288                fg: Some(Rgb::new(9, 9, 9)),
289                ..Style::default()
290            },
291        );
292
293        let comment = theme
294            .resolve("@comment.documentation")
295            .expect("missing comment");
296        assert_eq!(comment.fg, Some(Rgb::new(1, 2, 3)));
297
298        let unknown = theme.resolve("@does.not.exist").expect("missing normal");
299        assert_eq!(unknown.fg, Some(Rgb::new(9, 9, 9)));
300    }
301
302    #[test]
303    /// Verifies wrapped JSON theme documents parse correctly.
304    fn parses_json_theme_document() {
305        let input = r#"
306{
307  "styles": {
308    "@keyword": { "fg": { "r": 255, "g": 0, "b": 0 }, "bold": true },
309    "normal": { "fg": { "r": 200, "g": 200, "b": 200 } }
310  }
311}
312"#;
313
314        let theme = Theme::from_json_str(input).expect("failed to parse json");
315        let style = theme.resolve("keyword").expect("keyword style missing");
316        assert_eq!(style.fg, Some(Rgb::new(255, 0, 0)));
317        assert!(style.bold);
318    }
319
320    #[test]
321    /// Verifies flat TOML theme documents parse correctly.
322    fn parses_toml_flat_theme_document() {
323        let input = r#"
324[normal]
325fg = { r = 40, g = 41, b = 42 }
326
327["@string"]
328fg = { r = 120, g = 121, b = 122 }
329italic = true
330"#;
331
332        let theme = Theme::from_toml_str(input).expect("failed to parse toml");
333        let style = theme.resolve("string").expect("string style missing");
334        assert_eq!(style.fg, Some(Rgb::new(120, 121, 122)));
335        assert!(style.italic);
336    }
337
338    #[test]
339    /// Verifies all built-ins load and contain a `normal` style.
340    fn loads_all_built_in_themes() {
341        for name in available_themes() {
342            let theme = load_theme(name).expect("failed to load built-in theme");
343            assert!(
344                theme.get_exact("normal").is_some(),
345                "missing normal style in {name}"
346            );
347        }
348    }
349
350    #[test]
351    /// Verifies built-in enum loading works for a known theme.
352    fn loads_built_in_theme_by_enum() {
353        let theme = Theme::from_builtin(BuiltinTheme::TokyoNightDark)
354            .expect("failed to load tokyonight-dark");
355        assert!(theme.resolve("keyword").is_some());
356    }
357
358    #[test]
359    /// Verifies unknown built-in names return the expected error.
360    fn rejects_unknown_built_in_theme_name() {
361        let err = load_theme("unknown-theme").expect_err("expected unknown-theme to fail");
362        assert!(matches!(err, ThemeError::UnknownBuiltinTheme(_)));
363    }
364
365    #[test]
366    /// Verifies theme aliases are accepted.
367    fn supports_theme_aliases() {
368        assert!(load_theme("tokyo-night").is_ok());
369        assert!(load_theme("tokyo-day").is_ok());
370        assert!(load_theme("tokyonight-moon").is_ok());
371        assert!(load_theme("tokyonight-day").is_ok());
372    }
373
374    #[test]
375    /// Verifies moon/day variants are distinct built-ins, not aliases.
376    fn loads_distinct_tokyonight_variants() {
377        let moon = load_theme("tokyonight-moon").expect("failed to load moon");
378        let dark = load_theme("tokyonight-dark").expect("failed to load dark");
379        let day = load_theme("tokyonight-day").expect("failed to load day");
380        let light = load_theme("tokyonight-light").expect("failed to load light");
381
382        assert_ne!(moon, dark, "moon should differ from dark");
383        assert_ne!(day, light, "day should differ from light");
384    }
385}