Skip to main content

guise/theme/
json.rs

1//! JSON theme files: `Theme::from_json(source)`.
2//!
3//! The format is a **flat JSON object of string values** — every key is a
4//! theme slot, every value a CSS color (any form `css()` accepts) or token.
5//! Flat-on-purpose: it keeps the parser dependency-free and the files
6//! diff-friendly.
7//!
8//! ```json
9//! {
10//!   "name": "midnight",
11//!   "scheme": "dark",
12//!   "primary": "#7aa2f7",
13//!   "body": "#1a1b26",
14//!   "surface": "#16161e",
15//!   "surfacehover": "#292e42",
16//!   "text": "#c0caf5",
17//!   "dimmed": "#565f89",
18//!   "border": "#3b4261",
19//!   "success": "rgb(158, 206, 106)",
20//!   "warning": "#e0af68",
21//!   "danger": "#f7768e",
22//!   "info": "#7dcfff",
23//!   "fontfamily": "Inter",
24//!   "radius": "md"
25//! }
26//! ```
27//!
28//! Every key is optional except that unknown keys are rejected (they're
29//! almost always typos). `scheme` defaults to `dark`.
30
31use std::fmt;
32
33use super::css::css;
34use super::{ColorScheme, Size, Theme};
35
36/// Why a theme file failed to load.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ThemeJsonError {
39    /// Malformed JSON, with a byte offset and a short reason.
40    Syntax(usize, &'static str),
41    /// A key that isn't a theme slot (probably a typo).
42    UnknownKey(String),
43    /// A value that didn't parse for its key.
44    BadValue(String, String),
45}
46
47impl fmt::Display for ThemeJsonError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            ThemeJsonError::Syntax(at, why) => write!(f, "theme json: {why} at byte {at}"),
51            ThemeJsonError::UnknownKey(key) => write!(f, "theme json: unknown key {key:?}"),
52            ThemeJsonError::BadValue(key, value) => {
53                write!(f, "theme json: bad value {value:?} for {key:?}")
54            }
55        }
56    }
57}
58
59impl std::error::Error for ThemeJsonError {}
60
61/// Parse a flat `{"string": "string", ...}` object. Nested values, arrays,
62/// numbers, and booleans are syntax errors — the format is deliberately flat.
63fn parse_flat(src: &str) -> Result<Vec<(String, String)>, ThemeJsonError> {
64    let bytes = src.as_bytes();
65    let mut i = 0;
66    let mut pairs = Vec::new();
67
68    let skip_ws = |i: &mut usize| {
69        while *i < bytes.len() && bytes[*i].is_ascii_whitespace() {
70            *i += 1;
71        }
72    };
73
74    fn parse_string(bytes: &[u8], i: &mut usize) -> Result<String, ThemeJsonError> {
75        if bytes.get(*i) != Some(&b'"') {
76            return Err(ThemeJsonError::Syntax(*i, "expected a string"));
77        }
78        *i += 1;
79        let mut out = String::new();
80        loop {
81            match bytes.get(*i) {
82                None => return Err(ThemeJsonError::Syntax(*i, "unterminated string")),
83                Some(b'"') => {
84                    *i += 1;
85                    return Ok(out);
86                }
87                Some(b'\\') => {
88                    *i += 1;
89                    match bytes.get(*i) {
90                        Some(b'"') => out.push('"'),
91                        Some(b'\\') => out.push('\\'),
92                        Some(b'/') => out.push('/'),
93                        Some(b'n') => out.push('\n'),
94                        Some(b't') => out.push('\t'),
95                        Some(b'r') => out.push('\r'),
96                        Some(b'u') => {
97                            let hex = bytes
98                                .get(*i + 1..*i + 5)
99                                .and_then(|h| std::str::from_utf8(h).ok())
100                                .and_then(|h| u32::from_str_radix(h, 16).ok())
101                                .and_then(char::from_u32)
102                                .ok_or(ThemeJsonError::Syntax(*i, "bad \\u escape"))?;
103                            out.push(hex);
104                            *i += 4;
105                        }
106                        _ => return Err(ThemeJsonError::Syntax(*i, "bad escape")),
107                    }
108                    *i += 1;
109                }
110                Some(_) => {
111                    // Push the full UTF-8 character, not just one byte.
112                    let rest = &src_from(bytes, *i);
113                    let ch = rest.chars().next().unwrap_or('\u{fffd}');
114                    out.push(ch);
115                    *i += ch.len_utf8();
116                }
117            }
118        }
119    }
120
121    fn src_from(bytes: &[u8], i: usize) -> &str {
122        std::str::from_utf8(&bytes[i..]).unwrap_or("")
123    }
124
125    skip_ws(&mut i);
126    if bytes.get(i) != Some(&b'{') {
127        return Err(ThemeJsonError::Syntax(i, "expected '{'"));
128    }
129    i += 1;
130    skip_ws(&mut i);
131    if bytes.get(i) == Some(&b'}') {
132        i += 1;
133    } else {
134        loop {
135            skip_ws(&mut i);
136            let key = parse_string(bytes, &mut i)?;
137            skip_ws(&mut i);
138            if bytes.get(i) != Some(&b':') {
139                return Err(ThemeJsonError::Syntax(i, "expected ':'"));
140            }
141            i += 1;
142            skip_ws(&mut i);
143            let value = parse_string(bytes, &mut i)?;
144            pairs.push((key, value));
145            skip_ws(&mut i);
146            match bytes.get(i) {
147                Some(b',') => i += 1,
148                Some(b'}') => {
149                    i += 1;
150                    break;
151                }
152                _ => return Err(ThemeJsonError::Syntax(i, "expected ',' or '}'")),
153            }
154        }
155    }
156    skip_ws(&mut i);
157    if i != bytes.len() {
158        return Err(ThemeJsonError::Syntax(i, "trailing content"));
159    }
160    Ok(pairs)
161}
162
163fn size_token(value: &str) -> Option<Size> {
164    match value {
165        "xs" => Some(Size::Xs),
166        "sm" => Some(Size::Sm),
167        "md" => Some(Size::Md),
168        "lg" => Some(Size::Lg),
169        "xl" => Some(Size::Xl),
170        _ => None,
171    }
172}
173
174impl Theme {
175    /// Build a theme from a JSON string (see the module docs for the format).
176    /// Starts from [`Theme::light`]/[`Theme::dark`] per the `scheme` key
177    /// (default dark) and applies each slot as an override.
178    pub fn from_json(source: &str) -> Result<Theme, ThemeJsonError> {
179        let pairs = parse_flat(source)?;
180
181        let scheme = match pairs.iter().find(|(k, _)| k == "scheme") {
182            None => ColorScheme::Dark,
183            Some((_, v)) => match v.as_str() {
184                "light" => ColorScheme::Light,
185                "dark" => ColorScheme::Dark,
186                other => return Err(ThemeJsonError::BadValue("scheme".into(), other.into())),
187            },
188        };
189        let mut theme = match scheme {
190            ColorScheme::Light => Theme::light(),
191            ColorScheme::Dark => Theme::dark(),
192        };
193
194        for (key, value) in pairs {
195            let color =
196                || css(&value).map_err(|_| ThemeJsonError::BadValue(key.clone(), value.clone()));
197            theme = match key.as_str() {
198                "name" | "$schema" | "scheme" => theme,
199                "primary" => theme.with_primary(color()?),
200                "body" => theme.with_body(color()?),
201                "surface" => theme.with_surface(color()?),
202                "surfacehover" => theme.with_surface_hover(color()?),
203                "text" => theme.with_text(color()?),
204                "dimmed" => theme.with_dimmed(color()?),
205                "border" => theme.with_border(color()?),
206                "success" => theme.with_success(color()?),
207                "warning" => theme.with_warning(color()?),
208                "danger" => theme.with_danger(color()?),
209                "info" => theme.with_info(color()?),
210                "fontfamily" => {
211                    theme.font_family = value.clone().into();
212                    theme
213                }
214                "radius" => {
215                    theme.default_radius = size_token(&value)
216                        .ok_or_else(|| ThemeJsonError::BadValue(key.clone(), value.clone()))?;
217                    theme
218                }
219                _ => return Err(ThemeJsonError::UnknownKey(key)),
220            };
221        }
222        Ok(theme)
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    const SAMPLE: &str = r##"{
231        "name": "midnight",
232        "scheme": "dark",
233        "primary": "#7aa2f7",
234        "body": "#1a1b26",
235        "surfacehover": "rgb(41, 46, 66)",
236        "danger": "hsl(349, 89%, 72%)",
237        "fontfamily": "Inter",
238        "radius": "lg"
239    }"##;
240
241    #[test]
242    fn parses_a_full_theme() {
243        let theme = Theme::from_json(SAMPLE).unwrap();
244        assert!(theme.scheme.is_dark());
245        assert_eq!(theme.font_family.as_ref(), "Inter");
246        assert_eq!(theme.default_radius, Size::Lg);
247        assert!(theme.overrides.primary.is_some());
248        assert!(theme.overrides.body.is_some());
249        assert!(theme.overrides.surface_hover.is_some());
250        assert!(theme.overrides.danger.is_some());
251        // Unset slots stay on scheme defaults.
252        assert!(theme.overrides.text.is_none());
253        assert_ne!(theme.primary(), Theme::dark().primary());
254    }
255
256    #[test]
257    fn scheme_defaults_to_dark_and_light_works() {
258        assert!(Theme::from_json("{}").unwrap().scheme.is_dark());
259        let light = Theme::from_json(r#"{"scheme": "light"}"#).unwrap();
260        assert!(!light.scheme.is_dark());
261    }
262
263    #[test]
264    fn unknown_keys_are_rejected() {
265        let err = Theme::from_json(r##"{"primry": "#fff"}"##).unwrap_err();
266        assert_eq!(err, ThemeJsonError::UnknownKey("primry".into()));
267    }
268
269    #[test]
270    fn bad_values_name_the_key() {
271        let err = Theme::from_json(r#"{"primary": "not-a-color"}"#).unwrap_err();
272        assert_eq!(
273            err,
274            ThemeJsonError::BadValue("primary".into(), "not-a-color".into())
275        );
276        let err = Theme::from_json(r#"{"radius": "huge"}"#).unwrap_err();
277        assert_eq!(
278            err,
279            ThemeJsonError::BadValue("radius".into(), "huge".into())
280        );
281        let err = Theme::from_json(r#"{"scheme": "sepia"}"#).unwrap_err();
282        assert_eq!(
283            err,
284            ThemeJsonError::BadValue("scheme".into(), "sepia".into())
285        );
286    }
287
288    #[test]
289    fn syntax_errors_carry_a_reason() {
290        assert!(matches!(
291            Theme::from_json("[]"),
292            Err(ThemeJsonError::Syntax(_, "expected '{'"))
293        ));
294        assert!(matches!(
295            Theme::from_json(r#"{"a": 1}"#),
296            Err(ThemeJsonError::Syntax(_, "expected a string"))
297        ));
298        assert!(matches!(
299            Theme::from_json(r#"{"a": "b"} extra"#),
300            Err(ThemeJsonError::Syntax(_, "trailing content"))
301        ));
302        assert!(matches!(
303            Theme::from_json(r#"{"a": "b" "c": "d"}"#),
304            Err(ThemeJsonError::Syntax(_, "expected ',' or '}'"))
305        ));
306    }
307
308    #[test]
309    fn string_escapes_round_trip() {
310        let theme = Theme::from_json(r#"{"fontfamily": "JetBrains \"Mono\"!"}"#).unwrap();
311        assert_eq!(theme.font_family.as_ref(), "JetBrains \"Mono\"!");
312    }
313}