Skip to main content

ogeom_io/
json.rs

1//! Just enough JSON to read a glTF document.
2//!
3//! glTF's structure is JSON and its payload is binary, so reading one needs a
4//! parser. This is that parser and nothing more: the grammar as the standard
5//! for JSON states it (objects, arrays, strings with their escapes, numbers,
6//! the three literals) with no schema, no derive, and no dependency. It is
7//! here rather than in a general place because the only thing that needs it is
8//! the format that carries its own structure this way.
9//!
10//! Two deliberate limits, both stated rather than discovered. Numbers are read
11//! as `f64`, which is what every quantity in a glTF document is used as, and a
12//! caller wanting an index asks for one and is told if the value is not a
13//! whole number. And duplicate object keys keep the last, which is what a
14//! reader has to do with a document that says one thing twice.
15
16use ogeom_core::FastMap;
17use ogeom_core::{OgeomResult, ogeom_bail};
18
19/// A parsed JSON value.
20#[derive(Debug, Clone, PartialEq)]
21pub enum Json {
22    /// `null`.
23    Null,
24    /// `true` or `false`.
25    Bool(bool),
26    /// Any number, as the double every JSON number fits.
27    Number(f64),
28    /// A string, escapes resolved.
29    Text(String),
30    /// An array.
31    Array(Vec<Json>),
32    /// An object.
33    Object(FastMap<String, Json>),
34}
35
36impl Json {
37    /// The member of an object, or `None` for anything else.
38    #[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    /// The elements of an array, or an empty slice.
47    #[must_use]
48    pub fn items(&self) -> &[Self] {
49        match self {
50            Self::Array(items) => items,
51            _ => &[],
52        }
53    }
54
55    /// The number, or `None`.
56    #[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    /// The string, or `None`.
65    #[must_use]
66    pub fn text(&self) -> Option<&str> {
67        match self {
68            Self::Text(s) => Some(s),
69            _ => None,
70        }
71    }
72
73    /// A non-negative whole number as an index.
74    ///
75    /// `None` where the value is absent, not a number, negative, or not
76    /// whole; an index of `2.5` is a broken document, not a rounding.
77    #[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    /// A member read as an index.
92    #[must_use]
93    pub fn index_at(&self, key: &str) -> Option<usize> {
94        self.get(key)?.index()
95    }
96}
97
98/// Parse a JSON document.
99///
100/// # Errors
101///
102/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) with the byte
103/// offset where the document stopped making sense.
104pub 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
121/// How deep objects and arrays may nest. A glTF document nests a handful
122/// of levels; a document nesting thousands is hostile or broken, and
123/// reading it by recursion would run the stack out.
124const 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            // Anything else is copied through as UTF-8, which the document
245            // already is: the slice is walked byte by byte, so a multi-byte
246            // character arrives one continuation at a time and rebuilds here.
247            _ => {
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
263/// How many bytes a UTF-8 character starting with this byte occupies.
264const 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
273/// A `\uXXXX` escape, surrogate pairs included: a character outside the
274/// basic plane is written as two of them, and one alone is not a character.
275fn 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}