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
163/// The `name` slot of a theme file, if it has one. [`Theme::from_json`] ignores
164/// `name` — a theme's display name belongs to whoever is listing themes, which
165/// is [`ThemeManager`](super::ThemeManager).
166pub(super) fn json_name(source: &str) -> Option<String> {
167  parse_flat(source)
168    .ok()?
169    .into_iter()
170    .find(|(key, _)| key == "name")
171    .map(|(_, value)| value)
172    .filter(|name| !name.is_empty())
173}
174
175fn size_token(value: &str) -> Option<Size> {
176  match value {
177    "xs" => Some(Size::Xs),
178    "sm" => Some(Size::Sm),
179    "md" => Some(Size::Md),
180    "lg" => Some(Size::Lg),
181    "xl" => Some(Size::Xl),
182    _ => None,
183  }
184}
185
186impl Theme {
187  /// Build a theme from a JSON string (see the module docs for the format).
188  /// Starts from [`Theme::light`]/[`Theme::dark`] per the `scheme` key
189  /// (default dark) and applies each slot as an override.
190  pub fn from_json(source: &str) -> Result<Theme, ThemeJsonError> {
191    let pairs = parse_flat(source)?;
192
193    let scheme = match pairs.iter().find(|(k, _)| k == "scheme") {
194      None => ColorScheme::Dark,
195      Some((_, v)) => match v.as_str() {
196        "light" => ColorScheme::Light,
197        "dark" => ColorScheme::Dark,
198        other => return Err(ThemeJsonError::BadValue("scheme".into(), other.into())),
199      },
200    };
201    let mut theme = match scheme {
202      ColorScheme::Light => Theme::light(),
203      ColorScheme::Dark => Theme::dark(),
204    };
205
206    for (key, value) in pairs {
207      let color = || css(&value).map_err(|_| ThemeJsonError::BadValue(key.clone(), value.clone()));
208      theme = match key.as_str() {
209        "name" | "$schema" | "scheme" => theme,
210        "primary" => theme.with_primary(color()?),
211        "body" => theme.with_body(color()?),
212        "surface" => theme.with_surface(color()?),
213        "surfacehover" => theme.with_surface_hover(color()?),
214        "text" => theme.with_text(color()?),
215        "dimmed" => theme.with_dimmed(color()?),
216        "border" => theme.with_border(color()?),
217        "success" => theme.with_success(color()?),
218        "warning" => theme.with_warning(color()?),
219        "danger" => theme.with_danger(color()?),
220        "info" => theme.with_info(color()?),
221        "fontfamily" => {
222          theme.font_family = value.clone().into();
223          theme
224        }
225        "radius" => {
226          theme.default_radius = size_token(&value)
227            .ok_or_else(|| ThemeJsonError::BadValue(key.clone(), value.clone()))?;
228          theme
229        }
230        _ => return Err(ThemeJsonError::UnknownKey(key)),
231      };
232    }
233    Ok(theme)
234  }
235}
236
237#[cfg(test)]
238mod tests {
239  use super::*;
240
241  const SAMPLE: &str = r##"{
242        "name": "midnight",
243        "scheme": "dark",
244        "primary": "#7aa2f7",
245        "body": "#1a1b26",
246        "surfacehover": "rgb(41, 46, 66)",
247        "danger": "hsl(349, 89%, 72%)",
248        "fontfamily": "Inter",
249        "radius": "lg"
250    }"##;
251
252  #[test]
253  fn parses_a_full_theme() {
254    let theme = Theme::from_json(SAMPLE).unwrap();
255    assert!(theme.scheme.is_dark());
256    assert_eq!(theme.font_family.as_ref(), "Inter");
257    assert_eq!(theme.default_radius, Size::Lg);
258    assert!(theme.overrides.primary.is_some());
259    assert!(theme.overrides.body.is_some());
260    assert!(theme.overrides.surface_hover.is_some());
261    assert!(theme.overrides.danger.is_some());
262    // Unset slots stay on scheme defaults.
263    assert!(theme.overrides.text.is_none());
264    assert_ne!(theme.primary(), Theme::dark().primary());
265  }
266
267  #[test]
268  fn scheme_defaults_to_dark_and_light_works() {
269    assert!(Theme::from_json("{}").unwrap().scheme.is_dark());
270    let light = Theme::from_json(r#"{"scheme": "light"}"#).unwrap();
271    assert!(!light.scheme.is_dark());
272  }
273
274  #[test]
275  fn unknown_keys_are_rejected() {
276    let err = Theme::from_json(r##"{"primry": "#fff"}"##).unwrap_err();
277    assert_eq!(err, ThemeJsonError::UnknownKey("primry".into()));
278  }
279
280  #[test]
281  fn bad_values_name_the_key() {
282    let err = Theme::from_json(r#"{"primary": "not-a-color"}"#).unwrap_err();
283    assert_eq!(
284      err,
285      ThemeJsonError::BadValue("primary".into(), "not-a-color".into())
286    );
287    let err = Theme::from_json(r#"{"radius": "huge"}"#).unwrap_err();
288    assert_eq!(
289      err,
290      ThemeJsonError::BadValue("radius".into(), "huge".into())
291    );
292    let err = Theme::from_json(r#"{"scheme": "sepia"}"#).unwrap_err();
293    assert_eq!(
294      err,
295      ThemeJsonError::BadValue("scheme".into(), "sepia".into())
296    );
297  }
298
299  #[test]
300  fn syntax_errors_carry_a_reason() {
301    assert!(matches!(
302      Theme::from_json("[]"),
303      Err(ThemeJsonError::Syntax(_, "expected '{'"))
304    ));
305    assert!(matches!(
306      Theme::from_json(r#"{"a": 1}"#),
307      Err(ThemeJsonError::Syntax(_, "expected a string"))
308    ));
309    assert!(matches!(
310      Theme::from_json(r#"{"a": "b"} extra"#),
311      Err(ThemeJsonError::Syntax(_, "trailing content"))
312    ));
313    assert!(matches!(
314      Theme::from_json(r#"{"a": "b" "c": "d"}"#),
315      Err(ThemeJsonError::Syntax(_, "expected ',' or '}'"))
316    ));
317  }
318
319  #[test]
320  fn string_escapes_round_trip() {
321    let theme = Theme::from_json(r#"{"fontfamily": "JetBrains \"Mono\"!"}"#).unwrap();
322    assert_eq!(theme.font_family.as_ref(), "JetBrains \"Mono\"!");
323  }
324}