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 #[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 #[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 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 #[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 #[must_use]
106 pub fn new() -> Self {
107 Self::default()
108 }
109
110 #[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 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 #[must_use]
131 pub fn styles(&self) -> &BTreeMap<String, Style> {
132 &self.styles
133 }
134
135 #[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 #[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 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 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 pub fn from_builtin(theme: BuiltinTheme) -> Result<Self, ThemeError> {
191 Self::from_json_str(theme.source())
192 }
193
194 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#[must_use]
208pub const fn available_themes() -> &'static [&'static str] {
209 &BUILTIN_THEME_NAMES
210}
211
212pub 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 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#[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 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 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 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 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 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 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 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 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 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}