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::{OgeomResult, ogeom_bail};
17use std::collections::HashMap;
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(HashMap<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)?;
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            // Anything else is copied through as UTF-8, which the document
233            // already is: the slice is walked byte by byte, so a multi-byte
234            // character arrives one continuation at a time and rebuilds here.
235            _ => {
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
251/// How many bytes a UTF-8 character starting with this byte occupies.
252const 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
261/// A `\uXXXX` escape, surrogate pairs included: a character outside the
262/// basic plane is written as two of them, and one alone is not a character.
263fn 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}