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 = || css(&value).map_err(|_| ThemeJsonError::BadValue(key.clone(), value.clone()));
196      theme = match key.as_str() {
197        "name" | "$schema" | "scheme" => theme,
198        "primary" => theme.with_primary(color()?),
199        "body" => theme.with_body(color()?),
200        "surface" => theme.with_surface(color()?),
201        "surfacehover" => theme.with_surface_hover(color()?),
202        "text" => theme.with_text(color()?),
203        "dimmed" => theme.with_dimmed(color()?),
204        "border" => theme.with_border(color()?),
205        "success" => theme.with_success(color()?),
206        "warning" => theme.with_warning(color()?),
207        "danger" => theme.with_danger(color()?),
208        "info" => theme.with_info(color()?),
209        "fontfamily" => {
210          theme.font_family = value.clone().into();
211          theme
212        }
213        "radius" => {
214          theme.default_radius = size_token(&value)
215            .ok_or_else(|| ThemeJsonError::BadValue(key.clone(), value.clone()))?;
216          theme
217        }
218        _ => return Err(ThemeJsonError::UnknownKey(key)),
219      };
220    }
221    Ok(theme)
222  }
223}
224
225#[cfg(test)]
226mod tests {
227  use super::*;
228
229  const SAMPLE: &str = r##"{
230        "name": "midnight",
231        "scheme": "dark",
232        "primary": "#7aa2f7",
233        "body": "#1a1b26",
234        "surfacehover": "rgb(41, 46, 66)",
235        "danger": "hsl(349, 89%, 72%)",
236        "fontfamily": "Inter",
237        "radius": "lg"
238    }"##;
239
240  #[test]
241  fn parses_a_full_theme() {
242    let theme = Theme::from_json(SAMPLE).unwrap();
243    assert!(theme.scheme.is_dark());
244    assert_eq!(theme.font_family.as_ref(), "Inter");
245    assert_eq!(theme.default_radius, Size::Lg);
246    assert!(theme.overrides.primary.is_some());
247    assert!(theme.overrides.body.is_some());
248    assert!(theme.overrides.surface_hover.is_some());
249    assert!(theme.overrides.danger.is_some());
250    // Unset slots stay on scheme defaults.
251    assert!(theme.overrides.text.is_none());
252    assert_ne!(theme.primary(), Theme::dark().primary());
253  }
254
255  #[test]
256  fn scheme_defaults_to_dark_and_light_works() {
257    assert!(Theme::from_json("{}").unwrap().scheme.is_dark());
258    let light = Theme::from_json(r#"{"scheme": "light"}"#).unwrap();
259    assert!(!light.scheme.is_dark());
260  }
261
262  #[test]
263  fn unknown_keys_are_rejected() {
264    let err = Theme::from_json(r##"{"primry": "#fff"}"##).unwrap_err();
265    assert_eq!(err, ThemeJsonError::UnknownKey("primry".into()));
266  }
267
268  #[test]
269  fn bad_values_name_the_key() {
270    let err = Theme::from_json(r#"{"primary": "not-a-color"}"#).unwrap_err();
271    assert_eq!(
272      err,
273      ThemeJsonError::BadValue("primary".into(), "not-a-color".into())
274    );
275    let err = Theme::from_json(r#"{"radius": "huge"}"#).unwrap_err();
276    assert_eq!(
277      err,
278      ThemeJsonError::BadValue("radius".into(), "huge".into())
279    );
280    let err = Theme::from_json(r#"{"scheme": "sepia"}"#).unwrap_err();
281    assert_eq!(
282      err,
283      ThemeJsonError::BadValue("scheme".into(), "sepia".into())
284    );
285  }
286
287  #[test]
288  fn syntax_errors_carry_a_reason() {
289    assert!(matches!(
290      Theme::from_json("[]"),
291      Err(ThemeJsonError::Syntax(_, "expected '{'"))
292    ));
293    assert!(matches!(
294      Theme::from_json(r#"{"a": 1}"#),
295      Err(ThemeJsonError::Syntax(_, "expected a string"))
296    ));
297    assert!(matches!(
298      Theme::from_json(r#"{"a": "b"} extra"#),
299      Err(ThemeJsonError::Syntax(_, "trailing content"))
300    ));
301    assert!(matches!(
302      Theme::from_json(r#"{"a": "b" "c": "d"}"#),
303      Err(ThemeJsonError::Syntax(_, "expected ',' or '}'"))
304    ));
305  }
306
307  #[test]
308  fn string_escapes_round_trip() {
309    let theme = Theme::from_json(r#"{"fontfamily": "JetBrains \"Mono\"!"}"#).unwrap();
310    assert_eq!(theme.font_family.as_ref(), "JetBrains \"Mono\"!");
311  }
312}