1use ogeom_core::FastMap;
17use ogeom_core::{OgeomResult, ogeom_bail};
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(FastMap<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, 0)?;
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
121const DEPTH: usize = 256;
125
126fn parse_value(bytes: &[u8], at: &mut usize, depth: usize) -> OgeomResult<Json> {
127 skip_space(bytes, at);
128 let Some(&byte) = bytes.get(*at) else {
129 ogeom_bail!(Construction, "the document ends where a value was expected");
130 };
131 if matches!(byte, b'{' | b'[') && depth >= DEPTH {
132 ogeom_bail!(
133 Construction,
134 "the document nests deeper than {DEPTH} levels, at byte {at}",
135 at = *at
136 );
137 }
138 match byte {
139 b'{' => parse_object(bytes, at, depth + 1),
140 b'[' => parse_array(bytes, at, depth + 1),
141 b'"' => Ok(Json::Text(parse_string(bytes, at)?)),
142 b't' => literal(bytes, at, "true", Json::Bool(true)),
143 b'f' => literal(bytes, at, "false", Json::Bool(false)),
144 b'n' => literal(bytes, at, "null", Json::Null),
145 _ => parse_number(bytes, at),
146 }
147}
148
149fn literal(bytes: &[u8], at: &mut usize, word: &str, value: Json) -> OgeomResult<Json> {
150 if bytes[*at..].starts_with(word.as_bytes()) {
151 *at += word.len();
152 return Ok(value);
153 }
154 ogeom_bail!(Construction, "expected `{word}` at byte {at}", at = *at);
155}
156
157fn parse_object(bytes: &[u8], at: &mut usize, depth: usize) -> OgeomResult<Json> {
158 *at += 1;
159 let mut map = FastMap::default();
160 skip_space(bytes, at);
161 if bytes.get(*at) == Some(&b'}') {
162 *at += 1;
163 return Ok(Json::Object(map));
164 }
165 loop {
166 skip_space(bytes, at);
167 if bytes.get(*at) != Some(&b'"') {
168 ogeom_bail!(
169 Construction,
170 "an object's key is a string, at byte {at}",
171 at = *at
172 );
173 }
174 let key = parse_string(bytes, at)?;
175 skip_space(bytes, at);
176 if bytes.get(*at) != Some(&b':') {
177 ogeom_bail!(Construction, "expected `:` at byte {at}", at = *at);
178 }
179 *at += 1;
180 let value = parse_value(bytes, at, depth)?;
181 map.insert(key, value);
182 skip_space(bytes, at);
183 match bytes.get(*at) {
184 Some(&b',') => *at += 1,
185 Some(&b'}') => {
186 *at += 1;
187 return Ok(Json::Object(map));
188 }
189 _ => ogeom_bail!(Construction, "expected `,` or `}}` at byte {at}", at = *at),
190 }
191 }
192}
193
194fn parse_array(bytes: &[u8], at: &mut usize, depth: usize) -> OgeomResult<Json> {
195 *at += 1;
196 let mut items = Vec::new();
197 skip_space(bytes, at);
198 if bytes.get(*at) == Some(&b']') {
199 *at += 1;
200 return Ok(Json::Array(items));
201 }
202 loop {
203 items.push(parse_value(bytes, at, depth)?);
204 skip_space(bytes, at);
205 match bytes.get(*at) {
206 Some(&b',') => *at += 1,
207 Some(&b']') => {
208 *at += 1;
209 return Ok(Json::Array(items));
210 }
211 _ => ogeom_bail!(Construction, "expected `,` or `]` at byte {at}", at = *at),
212 }
213 }
214}
215
216fn parse_string(bytes: &[u8], at: &mut usize) -> OgeomResult<String> {
217 *at += 1;
218 let mut out = String::new();
219 loop {
220 let Some(&byte) = bytes.get(*at) else {
221 ogeom_bail!(Construction, "a string runs off the end of the document");
222 };
223 *at += 1;
224 match byte {
225 b'"' => return Ok(out),
226 b'\\' => {
227 let Some(&escape) = bytes.get(*at) else {
228 ogeom_bail!(Construction, "an escape runs off the end of the document");
229 };
230 *at += 1;
231 match escape {
232 b'"' => out.push('"'),
233 b'\\' => out.push('\\'),
234 b'/' => out.push('/'),
235 b'b' => out.push('\u{8}'),
236 b'f' => out.push('\u{c}'),
237 b'n' => out.push('\n'),
238 b'r' => out.push('\r'),
239 b't' => out.push('\t'),
240 b'u' => out.push(parse_escape(bytes, at)?),
241 other => ogeom_bail!(Construction, "unknown escape `\\{}`", other as char),
242 }
243 }
244 _ => {
248 let start = *at - 1;
249 let width = utf8_width(byte);
250 if start + width > bytes.len() {
251 ogeom_bail!(Construction, "a character runs off the end of the document");
252 }
253 let Ok(text) = core::str::from_utf8(&bytes[start..start + width]) else {
254 ogeom_bail!(Construction, "the document is not UTF-8 at byte {start}");
255 };
256 out.push_str(text);
257 *at = start + width;
258 }
259 }
260 }
261}
262
263const fn utf8_width(lead: u8) -> usize {
265 match lead {
266 0x00..=0x7F => 1,
267 0xC0..=0xDF => 2,
268 0xE0..=0xEF => 3,
269 _ => 4,
270 }
271}
272
273fn parse_escape(bytes: &[u8], at: &mut usize) -> OgeomResult<char> {
276 let first = hex4(bytes, at)?;
277 if (0xD800..0xDC00).contains(&first) {
278 if bytes.get(*at) != Some(&b'\\') || bytes.get(*at + 1) != Some(&b'u') {
279 ogeom_bail!(Construction, "a leading surrogate with no trailing one");
280 }
281 *at += 2;
282 let second = hex4(bytes, at)?;
283 if !(0xDC00..0xE000).contains(&second) {
284 ogeom_bail!(Construction, "a leading surrogate followed by {second:#x}");
285 }
286 let combined = 0x1_0000 + ((first - 0xD800) << 10) + (second - 0xDC00);
287 let Some(c) = char::from_u32(combined) else {
288 ogeom_bail!(Construction, "the surrogate pair names no character");
289 };
290 return Ok(c);
291 }
292 let Some(c) = char::from_u32(first) else {
293 ogeom_bail!(Construction, "the escape {first:#x} names no character");
294 };
295 Ok(c)
296}
297
298fn hex4(bytes: &[u8], at: &mut usize) -> OgeomResult<u32> {
299 let Some(slice) = bytes.get(*at..*at + 4) else {
300 ogeom_bail!(Construction, "a `\\u` escape wants four hex digits");
301 };
302 let Ok(text) = core::str::from_utf8(slice) else {
303 ogeom_bail!(Construction, "a `\\u` escape wants four hex digits");
304 };
305 let Ok(value) = u32::from_str_radix(text, 16) else {
306 ogeom_bail!(Construction, "`{text}` is not four hex digits");
307 };
308 *at += 4;
309 Ok(value)
310}
311
312fn parse_number(bytes: &[u8], at: &mut usize) -> OgeomResult<Json> {
313 let start = *at;
314 if bytes.get(*at) == Some(&b'-') {
315 *at += 1;
316 }
317 while matches!(bytes.get(*at), Some(b'0'..=b'9')) {
318 *at += 1;
319 }
320 if bytes.get(*at) == Some(&b'.') {
321 *at += 1;
322 while matches!(bytes.get(*at), Some(b'0'..=b'9')) {
323 *at += 1;
324 }
325 }
326 if matches!(bytes.get(*at), Some(b'e' | b'E')) {
327 *at += 1;
328 if matches!(bytes.get(*at), Some(b'+' | b'-')) {
329 *at += 1;
330 }
331 while matches!(bytes.get(*at), Some(b'0'..=b'9')) {
332 *at += 1;
333 }
334 }
335 let Ok(text) = core::str::from_utf8(&bytes[start..*at]) else {
336 ogeom_bail!(Construction, "a number is not UTF-8 at byte {start}");
337 };
338 let Ok(value) = text.parse::<f64>() else {
339 ogeom_bail!(Construction, "`{text}` is not a number, at byte {start}");
340 };
341 Ok(Json::Number(value))
342}
343
344#[cfg(test)]
345#[allow(clippy::unwrap_used, clippy::expect_used)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn the_grammar_round_trips_the_shapes_a_gltf_document_uses() {
351 let document = r#"
352 {
353 "asset": {"version": "2.0", "generator": "something \"quoted\""},
354 "scene": 0,
355 "accessors": [
356 {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
357 "min": [-1, -2.5, 0], "max": [1e1, 2.5E-1, 0]},
358 {"componentType": 5121, "count": 0, "type": "SCALAR", "normalized": true}
359 ],
360 "nothing": null,
361 "empty": {},
362 "none": []
363 }"#;
364 let json = parse(document).unwrap();
365 assert_eq!(json.index_at("scene"), Some(0));
366 assert_eq!(
367 json.get("asset").unwrap().get("generator").unwrap().text(),
368 Some("something \"quoted\"")
369 );
370 let accessors = json.get("accessors").unwrap().items();
371 assert_eq!(accessors.len(), 2);
372 assert_eq!(accessors[0].index_at("componentType"), Some(5126));
373 assert_eq!(
374 accessors[0].get("max").unwrap().items()[0].number(),
375 Some(10.0)
376 );
377 assert_eq!(
378 accessors[0].get("min").unwrap().items()[1].number(),
379 Some(-2.5)
380 );
381 assert_eq!(accessors[1].get("normalized"), Some(&Json::Bool(true)));
382 assert_eq!(json.get("nothing"), Some(&Json::Null));
383 assert!(json.get("none").unwrap().items().is_empty());
384 }
385
386 #[test]
387 fn escapes_and_characters_outside_the_basic_plane_survive() {
388 let json = parse(r#"{"n":"aé\n\t😀b","x":"café"}"#).unwrap();
389 assert_eq!(json.get("n").unwrap().text(), Some("aé\n\t😀b"));
390 assert_eq!(json.get("x").unwrap().text(), Some("café"));
391 }
392
393 #[test]
394 fn an_index_that_is_not_a_whole_number_is_not_an_index() {
395 let json = parse(r#"{"a": 2.5, "b": -1, "c": 7}"#).unwrap();
396 assert_eq!(json.index_at("a"), None);
397 assert_eq!(json.index_at("b"), None);
398 assert_eq!(json.index_at("c"), Some(7));
399 }
400
401 #[test]
402 fn broken_documents_are_refused_rather_than_guessed_at() {
403 for broken in [
404 "{",
405 "{\"a\"}",
406 "{\"a\":}",
407 "[1,]",
408 "[1 2]",
409 "{\"a\":1} trailing",
410 "\"unterminated",
411 r#""\q""#,
412 r#""\ud83d""#,
413 ] {
414 assert!(parse(broken).is_err(), "`{broken}` should not parse");
415 }
416 }
417}