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; 4] = [
7    "tokyonight-dark",
8    "tokyonight-light",
9    "solarized-dark",
10    "solarized-light",
11];
12
13#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
14pub enum BuiltinTheme {
15    TokyoNightDark,
16    TokyoNightLight,
17    SolarizedDark,
18    SolarizedLight,
19}
20
21impl BuiltinTheme {
22    #[must_use]
23    pub const fn name(self) -> &'static str {
24        match self {
25            Self::TokyoNightDark => "tokyonight-dark",
26            Self::TokyoNightLight => "tokyonight-light",
27            Self::SolarizedDark => "solarized-dark",
28            Self::SolarizedLight => "solarized-light",
29        }
30    }
31
32    #[must_use]
33    pub fn from_name(name: &str) -> Option<Self> {
34        match name.trim().to_ascii_lowercase().as_str() {
35            "tokyonight-dark" | "tokyonight-moon" | "tokyo-night" => Some(Self::TokyoNightDark),
36            "tokyonight-light" | "tokyonight-day" | "tokyo-day" => Some(Self::TokyoNightLight),
37            "solarized-dark" => Some(Self::SolarizedDark),
38            "solarized-light" => Some(Self::SolarizedLight),
39            _ => None,
40        }
41    }
42
43    const fn source(self) -> &'static str {
44        match self {
45            Self::TokyoNightDark => include_str!("../themes/tokyonight-dark.json"),
46            Self::TokyoNightLight => include_str!("../themes/tokyonight-light.json"),
47            Self::SolarizedDark => include_str!("../themes/solarized-dark.json"),
48            Self::SolarizedLight => include_str!("../themes/solarized-light.json"),
49        }
50    }
51}
52
53#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
54pub struct Rgb {
55    pub r: u8,
56    pub g: u8,
57    pub b: u8,
58}
59
60impl Rgb {
61    #[must_use]
62    pub const fn new(r: u8, g: u8, b: u8) -> Self {
63        Self { r, g, b }
64    }
65}
66
67#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, Serialize, Deserialize)]
68pub struct Style {
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub fg: Option<Rgb>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub bg: Option<Rgb>,
73    #[serde(default)]
74    pub bold: bool,
75    #[serde(default)]
76    pub italic: bool,
77    #[serde(default)]
78    pub underline: bool,
79}
80
81#[derive(Debug, Clone, Default, Eq, PartialEq)]
82pub struct Theme {
83    styles: BTreeMap<String, Style>,
84}
85
86impl Theme {
87    #[must_use]
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    #[must_use]
93    pub fn from_styles(styles: BTreeMap<String, Style>) -> Self {
94        let mut theme = Self::new();
95        for (name, style) in styles {
96            let _ = theme.insert(name, style);
97        }
98        theme
99    }
100
101    pub fn insert(&mut self, capture_name: impl AsRef<str>, style: Style) -> Option<Style> {
102        self.styles
103            .insert(normalize_capture_name(capture_name.as_ref()), style)
104    }
105
106    #[must_use]
107    pub fn styles(&self) -> &BTreeMap<String, Style> {
108        &self.styles
109    }
110
111    #[must_use]
112    pub fn get_exact(&self, capture_name: &str) -> Option<&Style> {
113        self.styles.get(&normalize_capture_name(capture_name))
114    }
115
116    #[must_use]
117    pub fn resolve(&self, capture_name: &str) -> Option<&Style> {
118        let mut key = normalize_capture_name(capture_name);
119
120        loop {
121            if let Some(style) = self.styles.get(&key) {
122                return Some(style);
123            }
124
125            let Some(index) = key.rfind('.') else {
126                break;
127            };
128            key.truncate(index);
129        }
130
131        self.styles.get("normal")
132    }
133
134    pub fn from_json_str(input: &str) -> Result<Self, ThemeError> {
135        let parsed = serde_json::from_str::<ThemeDocument>(input)?;
136        Ok(Self::from_styles(parsed.into_styles()))
137    }
138
139    pub fn from_toml_str(input: &str) -> Result<Self, ThemeError> {
140        let parsed = toml::from_str::<ThemeDocument>(input)?;
141        Ok(Self::from_styles(parsed.into_styles()))
142    }
143
144    pub fn from_builtin(theme: BuiltinTheme) -> Result<Self, ThemeError> {
145        Self::from_json_str(theme.source())
146    }
147
148    pub fn from_builtin_name(name: &str) -> Result<Self, ThemeError> {
149        let theme = BuiltinTheme::from_name(name)
150            .ok_or_else(|| ThemeError::UnknownBuiltinTheme(name.trim().to_string()))?;
151        Self::from_builtin(theme)
152    }
153}
154
155#[must_use]
156pub const fn available_themes() -> &'static [&'static str] {
157    &BUILTIN_THEME_NAMES
158}
159
160pub fn load_theme(name: &str) -> Result<Theme, ThemeError> {
161    Theme::from_builtin_name(name)
162}
163
164#[derive(Debug, Error)]
165pub enum ThemeError {
166    #[error("failed to parse theme JSON: {0}")]
167    Json(#[from] serde_json::Error),
168    #[error("failed to parse theme TOML: {0}")]
169    Toml(#[from] toml::de::Error),
170    #[error(
171        "unknown built-in theme '{0}', available: tokyonight-dark, tokyonight-light, solarized-dark, solarized-light"
172    )]
173    UnknownBuiltinTheme(String),
174}
175
176#[derive(Debug, Deserialize)]
177#[serde(untagged)]
178enum ThemeDocument {
179    Wrapped { styles: BTreeMap<String, Style> },
180    Flat(BTreeMap<String, Style>),
181}
182
183impl ThemeDocument {
184    fn into_styles(self) -> BTreeMap<String, Style> {
185        match self {
186            ThemeDocument::Wrapped { styles } => styles,
187            ThemeDocument::Flat(styles) => styles,
188        }
189    }
190}
191
192#[must_use]
193pub fn normalize_capture_name(capture_name: &str) -> String {
194    let trimmed = capture_name.trim();
195    let without_prefix = trimmed.strip_prefix('@').unwrap_or(trimmed);
196    without_prefix.to_ascii_lowercase()
197}
198
199#[cfg(test)]
200mod tests {
201    use super::{
202        available_themes, load_theme, normalize_capture_name, BuiltinTheme, Rgb, Style, Theme,
203        ThemeError,
204    };
205
206    #[test]
207    fn normalizes_capture_names() {
208        assert_eq!(normalize_capture_name("@Comment.Doc"), "comment.doc");
209        assert_eq!(normalize_capture_name(" keyword "), "keyword");
210    }
211
212    #[test]
213    fn resolves_dot_fallback_then_normal() {
214        let mut theme = Theme::new();
215        let _ = theme.insert(
216            "comment",
217            Style {
218                fg: Some(Rgb::new(1, 2, 3)),
219                ..Style::default()
220            },
221        );
222        let _ = theme.insert(
223            "normal",
224            Style {
225                fg: Some(Rgb::new(9, 9, 9)),
226                ..Style::default()
227            },
228        );
229
230        let comment = theme
231            .resolve("@comment.documentation")
232            .expect("missing comment");
233        assert_eq!(comment.fg, Some(Rgb::new(1, 2, 3)));
234
235        let unknown = theme.resolve("@does.not.exist").expect("missing normal");
236        assert_eq!(unknown.fg, Some(Rgb::new(9, 9, 9)));
237    }
238
239    #[test]
240    fn parses_json_theme_document() {
241        let input = r#"
242{
243  "styles": {
244    "@keyword": { "fg": { "r": 255, "g": 0, "b": 0 }, "bold": true },
245    "normal": { "fg": { "r": 200, "g": 200, "b": 200 } }
246  }
247}
248"#;
249
250        let theme = Theme::from_json_str(input).expect("failed to parse json");
251        let style = theme.resolve("keyword").expect("keyword style missing");
252        assert_eq!(style.fg, Some(Rgb::new(255, 0, 0)));
253        assert!(style.bold);
254    }
255
256    #[test]
257    fn parses_toml_flat_theme_document() {
258        let input = r#"
259[normal]
260fg = { r = 40, g = 41, b = 42 }
261
262["@string"]
263fg = { r = 120, g = 121, b = 122 }
264italic = true
265"#;
266
267        let theme = Theme::from_toml_str(input).expect("failed to parse toml");
268        let style = theme.resolve("string").expect("string style missing");
269        assert_eq!(style.fg, Some(Rgb::new(120, 121, 122)));
270        assert!(style.italic);
271    }
272
273    #[test]
274    fn loads_all_built_in_themes() {
275        for name in available_themes() {
276            let theme = load_theme(name).expect("failed to load built-in theme");
277            assert!(
278                theme.get_exact("normal").is_some(),
279                "missing normal style in {name}"
280            );
281        }
282    }
283
284    #[test]
285    fn loads_built_in_theme_by_enum() {
286        let theme = Theme::from_builtin(BuiltinTheme::TokyoNightDark)
287            .expect("failed to load tokyonight-dark");
288        assert!(theme.resolve("keyword").is_some());
289    }
290
291    #[test]
292    fn rejects_unknown_built_in_theme_name() {
293        let err = load_theme("unknown-theme").expect_err("expected unknown-theme to fail");
294        assert!(matches!(err, ThemeError::UnknownBuiltinTheme(_)));
295    }
296
297    #[test]
298    fn supports_theme_aliases() {
299        assert!(load_theme("tokyo-night").is_ok());
300        assert!(load_theme("tokyo-day").is_ok());
301        assert!(load_theme("tokyonight-moon").is_ok());
302    }
303}