1use ogeom_core::{OgeomResult, ogeom_bail};
17use std::collections::HashMap;
18
19#[derive(Debug, Clone, PartialEq)]
21pub enum Json {
22 Null,
24 Bool(bool),
26 Number(f64),
28 Text(String),
30 Array(Vec<Json>),
32 Object(HashMap<String, Json>),
34}
35
36impl Json {
37 #[must_use]
39 pub fn get(&self, key: &str) -> Option<&Self> {
40 match self {
41 Self::Object(map) => map.get(key),
42 _ => None,
43 }
44 }
45
46 #[must_use]
48 pub fn items(&self) -> &[Self] {
49 match self {
50 Self::Array(items) => items,
51 _ => &[],
52 }
53 }
54
55 #[must_use]
57 pub const fn number(&self) -> Option<f64> {
58 match self {
59 Self::Number(v) => Some(*v),
60 _ => None,
61 }
62 }
63
64 #[must_use]
66 pub fn text(&self) -> Option<&str> {
67 match self {
68 Self::Text(s) => Some(s),
69 _ => None,
70 }
71 }
72
73 #[must_use]
78 pub fn index(&self) -> Option<usize> {
79 let v = self.number()?;
80 if v < 0.0 || v.fract() != 0.0 || v > 9_007_199_254_740_992.0 {
81 return None;
82 }
83 #[expect(
84 clippy::cast_possible_truncation,
85 clippy::cast_sign_loss,
86 reason = "checked whole and non-negative just above"
87 )]
88 Some(v as usize)
89 }
90
91 #[must_use]
93 pub fn index_at(&self, key: &str) -> Option<usize> {
94 self.get(key)?.index()
95 }
96}
97
98pub fn parse(text: &str) -> OgeomResult<Json> {
105 let bytes = text.as_bytes();
106 let mut at = 0;
107 let value = parse_value(bytes, &mut at)?;
108 skip_space(bytes, &mut at);
109 if at != bytes.len() {
110 ogeom_bail!(Construction, "trailing content at byte {at}");
111 }
112 Ok(value)
113}
114
115fn skip_space(bytes: &[u8], at: &mut usize) {
116 while *at < bytes.len() && matches!(bytes[*at], b' ' | b'\t' | b'\n' | b'\r') {
117 *at += 1;
118 }
119}
120
121fn parse_value(bytes: &[u8], at: &mut usize) -> OgeomResult<Json> {
122 skip_space(bytes, at);
123 let Some(&byte) = bytes.get(*at) else {
124 ogeom_bail!(Construction, "the document ends where a value was expected");
125 };
126 match byte {
127 b'{' => parse_object(bytes, at),
128 b'[' => parse_array(bytes, at),
129 b'"' => Ok(Json::Text(parse_string(bytes, at)?)),
130 b't' => literal(bytes, at, "true", Json::Bool(true)),
131 b'f' => literal(bytes, at, "false", Json::Bool(false)),
132 b'n' => literal(bytes, at, "null", Json::Null),
133 _ => parse_number(bytes, at),
134 }
135}
136
137fn literal(bytes: &[u8], at: &mut usize, word: &str, value: Json) -> OgeomResult<Json> {
138 if bytes[*at..].starts_with(word.as_bytes()) {
139 *at += word.len();
140 return Ok(value);
141 }
142 ogeom_bail!(Construction, "expected `{word}` at byte {at}", at = *at);
143}
144
145fn parse_object(bytes: &[u8], at: &mut usize) -> OgeomResult<Json> {
146 *at += 1;
147 let mut map = HashMap::new();
148 skip_space(bytes, at);
149 if bytes.get(*at) == Some(&b'}') {
150 *at += 1;
151 return Ok(Json::Object(map));
152 }
153 loop {
154 skip_space(bytes, at);
155 if bytes.get(*at) != Some(&b'"') {
156 ogeom_bail!(
157 Construction,
158 "an object's key is a string, at byte {at}",
159 at = *at
160 );
161 }
162 let key = parse_string(bytes, at)?;
163 skip_space(bytes, at);
164 if bytes.get(*at) != Some(&b':') {
165 ogeom_bail!(Construction, "expected `:` at byte {at}", at = *at);
166 }
167 *at += 1;
168 let value = parse_value(bytes, at)?;
169 map.insert(key, value);
170 skip_space(bytes, at);
171 match bytes.get(*at) {
172 Some(&b',') => *at += 1,
173 Some(&b'}') => {
174 *at += 1;
175 return Ok(Json::Object(map));
176 }
177 _ => ogeom_bail!(Construction, "expected `,` or `}}` at byte {at}", at = *at),
178 }
179 }
180}
181
182fn parse_array(bytes: &[u8], at: &mut usize) -> OgeomResult<Json> {
183 *at += 1;
184 let mut items = Vec::new();
185 skip_space(bytes, at);
186 if bytes.get(*at) == Some(&b']') {
187 *at += 1;
188 return Ok(Json::Array(items));
189 }
190 loop {
191 items.push(parse_value(bytes, at)?);
192 skip_space(bytes, at);
193 match bytes.get(*at) {
194 Some(&b',') => *at += 1,
195 Some(&b']') => {
196 *at += 1;
197 return Ok(Json::Array(items));
198 }
199 _ => ogeom_bail!(Construction, "expected `,` or `]` at byte {at}", at = *at),
200 }
201 }
202}
203
204fn parse_string(bytes: &[u8], at: &mut usize) -> OgeomResult<String> {
205 *at += 1;
206 let mut out = String::new();
207 loop {
208 let Some(&byte) = bytes.get(*at) else {
209 ogeom_bail!(Construction, "a string runs off the end of the document");
210 };
211 *at += 1;
212 match byte {
213 b'"' => return Ok(out),
214 b'\\' => {
215 let Some(&escape) = bytes.get(*at) else {
216 ogeom_bail!(Construction, "an escape runs off the end of the document");
217 };
218 *at += 1;
219 match escape {
220 b'"' => out.push('"'),
221 b'\\' => out.push('\\'),
222 b'/' => out.push('/'),
223 b'b' => out.push('\u{8}'),
224 b'f' => out.push('\u{c}'),
225 b'n' => out.push('\n'),
226 b'r' => out.push('\r'),
227 b't' => out.push('\t'),
228 b'u' => out.push(parse_escape(bytes, at)?),
229 other => ogeom_bail!(Construction, "unknown escape `\\{}`", other as char),
230 }
231 }
232 _ => {
236 let start = *at - 1;
237 let width = utf8_width(byte);
238 if start + width > bytes.len() {
239 ogeom_bail!(Construction, "a character runs off the end of the document");
240 }
241 let Ok(text) = core::str::from_utf8(&bytes[start..start + width]) else {
242 ogeom_bail!(Construction, "the document is not UTF-8 at byte {start}");
243 };
244 out.push_str(text);
245 *at = start + width;
246 }
247 }
248 }
249}
250
251const fn utf8_width(lead: u8) -> usize {
253 match lead {
254 0x00..=0x7F => 1,
255 0xC0..=0xDF => 2,
256 0xE0..=0xEF => 3,
257 _ => 4,
258 }
259}
260
261fn parse_escape(bytes: &[u8], at: &mut usize) -> OgeomResult<char> {
264 let first = hex4(bytes, at)?;
265 if (0xD800..0xDC00).contains(&first) {
266 if bytes.get(*at) != Some(&b'\\') || bytes.get(*at + 1) != Some(&b'u') {
267 ogeom_bail!(Construction, "a leading surrogate with no trailing one");
268 }
269 *at += 2;
270 let second = hex4(bytes, at)?;
271 if !(0xDC00..0xE000).contains(&second) {
272 ogeom_bail!(Construction, "a leading surrogate followed by {second:#x}");
273 }
274 let combined = 0x1_0000 + ((first - 0xD800) << 10) + (second - 0xDC00);
275 let Some(c) = char::from_u32(combined) else {
276 ogeom_bail!(Construction, "the surrogate pair names no character");
277 };
278 return Ok(c);
279 }
280 let Some(c) = char::from_u32(first) else {
281 ogeom_bail!(Construction, "the escape {first:#x} names no character");
282 };
283 Ok(c)
284}
285
286fn hex4(bytes: &[u8], at: &mut usize) -> OgeomResult<u32> {
287 let Some(slice) = bytes.get(*at..*at + 4) else {
288 ogeom_bail!(Construction, "a `\\u` escape wants four hex digits");
289 };
290 let Ok(text) = core::str::from_utf8(slice) else {
291 ogeom_bail!(Construction, "a `\\u` escape wants four hex digits");
292 };
293 let Ok(value) = u32::from_str_radix(text, 16) else {
294 ogeom_bail!(Construction, "`{text}` is not four hex digits");
295 };
296 *at += 4;
297 Ok(value)
298}
299
300fn parse_number(bytes: &[u8], at: &mut usize) -> OgeomResult<Json> {
301 let start = *at;
302 if bytes.get(*at) == Some(&b'-') {
303 *at += 1;
304 }
305 while matches!(bytes.get(*at), Some(b'0'..=b'9')) {
306 *at += 1;
307 }
308 if bytes.get(*at) == Some(&b'.') {
309 *at += 1;
310 while matches!(bytes.get(*at), Some(b'0'..=b'9')) {
311 *at += 1;
312 }
313 }
314 if matches!(bytes.get(*at), Some(b'e' | b'E')) {
315 *at += 1;
316 if matches!(bytes.get(*at), Some(b'+' | b'-')) {
317 *at += 1;
318 }
319 while matches!(bytes.get(*at), Some(b'0'..=b'9')) {
320 *at += 1;
321 }
322 }
323 let Ok(text) = core::str::from_utf8(&bytes[start..*at]) else {
324 ogeom_bail!(Construction, "a number is not UTF-8 at byte {start}");
325 };
326 let Ok(value) = text.parse::<f64>() else {
327 ogeom_bail!(Construction, "`{text}` is not a number, at byte {start}");
328 };
329 Ok(Json::Number(value))
330}
331
332#[cfg(test)]
333#[allow(clippy::unwrap_used, clippy::expect_used)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn the_grammar_round_trips_the_shapes_a_gltf_document_uses() {
339 let document = r#"
340 {
341 "asset": {"version": "2.0", "generator": "something \"quoted\""},
342 "scene": 0,
343 "accessors": [
344 {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
345 "min": [-1, -2.5, 0], "max": [1e1, 2.5E-1, 0]},
346 {"componentType": 5121, "count": 0, "type": "SCALAR", "normalized": true}
347 ],
348 "nothing": null,
349 "empty": {},
350 "none": []
351 }"#;
352 let json = parse(document).unwrap();
353 assert_eq!(json.index_at("scene"), Some(0));
354 assert_eq!(
355 json.get("asset").unwrap().get("generator").unwrap().text(),
356 Some("something \"quoted\"")
357 );
358 let accessors = json.get("accessors").unwrap().items();
359 assert_eq!(accessors.len(), 2);
360 assert_eq!(accessors[0].index_at("componentType"), Some(5126));
361 assert_eq!(
362 accessors[0].get("max").unwrap().items()[0].number(),
363 Some(10.0)
364 );
365 assert_eq!(
366 accessors[0].get("min").unwrap().items()[1].number(),
367 Some(-2.5)
368 );
369 assert_eq!(accessors[1].get("normalized"), Some(&Json::Bool(true)));
370 assert_eq!(json.get("nothing"), Some(&Json::Null));
371 assert!(json.get("none").unwrap().items().is_empty());
372 }
373
374 #[test]
375 fn escapes_and_characters_outside_the_basic_plane_survive() {
376 let json = parse(r#"{"n":"aé\n\t😀b","x":"café"}"#).unwrap();
377 assert_eq!(json.get("n").unwrap().text(), Some("aé\n\t😀b"));
378 assert_eq!(json.get("x").unwrap().text(), Some("café"));
379 }
380
381 #[test]
382 fn an_index_that_is_not_a_whole_number_is_not_an_index() {
383 let json = parse(r#"{"a": 2.5, "b": -1, "c": 7}"#).unwrap();
384 assert_eq!(json.index_at("a"), None);
385 assert_eq!(json.index_at("b"), None);
386 assert_eq!(json.index_at("c"), Some(7));
387 }
388
389 #[test]
390 fn broken_documents_are_refused_rather_than_guessed_at() {
391 for broken in [
392 "{",
393 "{\"a\"}",
394 "{\"a\":}",
395 "[1,]",
396 "[1 2]",
397 "{\"a\":1} trailing",
398 "\"unterminated",
399 r#""\q""#,
400 r#""\ud83d""#,
401 ] {
402 assert!(parse(broken).is_err(), "`{broken}` should not parse");
403 }
404 }
405}