1use std::fmt;
32
33use super::css::css;
34use super::{ColorScheme, Size, Theme};
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ThemeJsonError {
39 Syntax(usize, &'static str),
41 UnknownKey(String),
43 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
61fn 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 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 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 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}