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}