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
163pub(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 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 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}