Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

xtask/src/json.rs

//! A JSON reader, in `std` alone.
//!
//! # Why this exists rather than `serde_json`
//!
//! `xtask/Cargo.toml` declares zero dependencies and that is the point of it —
//! it is the crate that runs the dependency guard, and a guard with a
//! dependency tree becomes the thing it guards. So the one place xtask has to
//! read structured data — `nornir bench history-show <repo> --json`, which is
//! how [`crate::mashup`] gets its numbers out of the iceberg `bench_runs`
//! table — is parsed here.
//!
//! # What it deliberately does not do
//!
//! No serialisation, no borrowing tricks, no `\u` surrogate pairing beyond the
//! basic-multilingual-plane case, no arbitrary-precision numbers. Every value
//! this parser will ever see is a nornir bench metric: a number, a short ASCII
//! string, or a nesting of the two. It rejects what it cannot represent instead
//! of guessing, because a silently-wrong number here becomes a bar on a chart.

use std::collections::BTreeMap;

/// A parsed JSON value.
#[derive(Debug, Clone, PartialEq)]
pub enum Json {
    Null,
    Bool(bool),
    Num(f64),
    Str(String),
    Arr(Vec<Json>),
    /// Object members, keyed. Duplicate keys keep the last, which is what every
    /// mainstream parser does and what nornir's writer cannot emit anyway.
    Obj(BTreeMap<String, Json>),
}

impl Json {
    /// Parse a whole document. Trailing non-whitespace is an error, so a
    /// truncated read cannot pass as a short document.
    pub fn parse(src: &str) -> Result<Json, String> {
        let b: Vec<char> = src.chars().collect();
        let mut p = Parser { b, i: 0 };
        p.ws();
        let v = p.value()?;
        p.ws();
        if p.i != p.b.len() {
            return Err(format!("trailing input at char {}", p.i));
        }
        Ok(v)
    }

    /// `obj["key"]`, or `None` for a non-object or a missing key.
    pub fn get(&self, key: &str) -> Option<&Json> {
        match self {
            Json::Obj(m) => m.get(key),
            _ => None,
        }
    }

    /// The members of an array, or an empty slice for anything else.
    pub fn arr(&self) -> &[Json] {
        match self {
            Json::Arr(v) => v,
            _ => &[],
        }
    }

    /// The members of an object, or an empty map for anything else.
    pub fn obj(&self) -> Option<&BTreeMap<String, Json>> {
        match self {
            Json::Obj(m) => Some(m),
            _ => None,
        }
    }

    /// The value as a number. A JSON string that happens to hold digits is
    /// **not** accepted: nornir writes metrics as numbers, and accepting both
    /// would hide the day a metric turns into a formatted string.
    pub fn num(&self) -> Option<f64> {
        match self {
            Json::Num(n) => Some(*n),
            _ => None,
        }
    }

    /// The value as a string.
    pub fn str(&self) -> Option<&str> {
        match self {
            Json::Str(s) => Some(s.as_str()),
            _ => None,
        }
    }
}

struct Parser {
    b: Vec<char>,
    i: usize,
}

impl Parser {
    fn ws(&mut self) {
        while self.i < self.b.len() && matches!(self.b[self.i], ' ' | '\t' | '\n' | '\r') {
            self.i += 1;
        }
    }

    fn peek(&self) -> Option<char> {
        self.b.get(self.i).copied()
    }

    fn eat(&mut self, c: char) -> Result<(), String> {
        match self.peek() {
            Some(got) if got == c => {
                self.i += 1;
                Ok(())
            }
            // A document that stops early and a document with the wrong
            // character in it are different faults and get different messages:
            // truncation is what a half-written file looks like.
            None => Err(format!(
                "unexpected end of input, expected {c:?} at char {}",
                self.i
            )),
            Some(got) => Err(format!("expected {c:?} at char {}, found {got:?}", self.i)),
        }
    }

    fn lit(&mut self, word: &str, v: Json) -> Result<Json, String> {
        if self.b[self.i..].starts_with(&word.chars().collect::<Vec<_>>()[..]) {
            self.i += word.chars().count();
            Ok(v)
        } else {
            Err(format!("expected `{word}` at char {}", self.i))
        }
    }

    fn value(&mut self) -> Result<Json, String> {
        match self.peek() {
            None => Err("unexpected end of input".to_string()),
            Some('{') => self.object(),
            Some('[') => self.array(),
            Some('"') => Ok(Json::Str(self.string()?)),
            Some('t') => self.lit("true", Json::Bool(true)),
            Some('f') => self.lit("false", Json::Bool(false)),
            Some('n') => self.lit("null", Json::Null),
            Some(c) if c == '-' || c.is_ascii_digit() => self.number(),
            Some(c) => Err(format!("unexpected {c:?} at char {}", self.i)),
        }
    }

    fn object(&mut self) -> Result<Json, String> {
        self.eat('{')?;
        let mut m = BTreeMap::new();
        self.ws();
        if self.peek() == Some('}') {
            self.i += 1;
            return Ok(Json::Obj(m));
        }
        loop {
            self.ws();
            let k = self.string()?;
            self.ws();
            self.eat(':')?;
            self.ws();
            let v = self.value()?;
            m.insert(k, v);
            self.ws();
            match self.peek() {
                Some(',') => self.i += 1,
                Some('}') => {
                    self.i += 1;
                    return Ok(Json::Obj(m));
                }
                other => {
                    return Err(format!(
                        "expected ',' or '}}' at char {}, found {other:?}",
                        self.i
                    ))
                }
            }
        }
    }

    fn array(&mut self) -> Result<Json, String> {
        self.eat('[')?;
        let mut v = Vec::new();
        self.ws();
        if self.peek() == Some(']') {
            self.i += 1;
            return Ok(Json::Arr(v));
        }
        loop {
            self.ws();
            v.push(self.value()?);
            self.ws();
            match self.peek() {
                Some(',') => self.i += 1,
                Some(']') => {
                    self.i += 1;
                    return Ok(Json::Arr(v));
                }
                other => {
                    return Err(format!(
                        "expected ',' or ']' at char {}, found {other:?}",
                        self.i
                    ))
                }
            }
        }
    }

    fn string(&mut self) -> Result<String, String> {
        self.eat('"')?;
        let mut s = String::new();
        loop {
            let c = self
                .peek()
                .ok_or_else(|| "unterminated string".to_string())?;
            self.i += 1;
            match c {
                '"' => return Ok(s),
                '\\' => {
                    let e = self
                        .peek()
                        .ok_or_else(|| "unterminated escape".to_string())?;
                    self.i += 1;
                    match e {
                        '"' => s.push('"'),
                        '\\' => s.push('\\'),
                        '/' => s.push('/'),
                        'b' => s.push('\u{8}'),
                        'f' => s.push('\u{c}'),
                        'n' => s.push('\n'),
                        'r' => s.push('\r'),
                        't' => s.push('\t'),
                        'u' => {
                            let mut n: u32 = 0;
                            for _ in 0..4 {
                                let h =
                                    self.peek().ok_or_else(|| "short \\u escape".to_string())?;
                                self.i += 1;
                                n = n * 16
                                    + h.to_digit(16)
                                        .ok_or_else(|| format!("bad hex {h:?} in \\u escape"))?;
                            }
                            // A lone surrogate is refused rather than replaced:
                            // nothing nornir emits contains one, so seeing one
                            // means the input is not what this parser is for.
                            s.push(
                                char::from_u32(n)
                                    .ok_or_else(|| format!("\\u{n:04x} is not a scalar value"))?,
                            );
                        }
                        other => return Err(format!("unknown escape \\{other}")),
                    }
                }
                c => s.push(c),
            }
        }
    }

    fn number(&mut self) -> Result<Json, String> {
        let start = self.i;
        if self.peek() == Some('-') {
            self.i += 1;
        }
        while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
            self.i += 1;
        }
        if self.peek() == Some('.') {
            self.i += 1;
            while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
                self.i += 1;
            }
        }
        if matches!(self.peek(), Some('e') | Some('E')) {
            self.i += 1;
            if matches!(self.peek(), Some('+') | Some('-')) {
                self.i += 1;
            }
            while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
                self.i += 1;
            }
        }
        let text: String = self.b[start..self.i].iter().collect();
        text.parse::<f64>()
            .map(Json::Num)
            .map_err(|e| format!("bad number {text:?} at char {start}: {e}"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_shapes_nornir_actually_emits_round_trip_into_values() {
        let v = Json::parse(
            r#"{"command":"bench history-show","ok":true,"data":{"runs":[
                 {"machine":"the desktop","cores":32,"results":[
                   {"name":"gunnar.vs_forge_clone","gunnar_large_pack_b":7625510.0,"load1":8.96}]}]}}"#,
        )
        .expect("parses");
        let runs = v.get("data").unwrap().get("runs").unwrap().arr();
        assert_eq!(runs.len(), 1);
        assert_eq!(runs[0].get("machine").unwrap().str(), Some("the desktop"));
        assert_eq!(runs[0].get("cores").unwrap().num(), Some(32.0));
        let res = &runs[0].get("results").unwrap().arr()[0];
        assert_eq!(
            res.get("name").unwrap().str(),
            Some("gunnar.vs_forge_clone")
        );
        assert_eq!(
            res.get("gunnar_large_pack_b").unwrap().num(),
            Some(7_625_510.0)
        );
    }

    /// Red first. A parser that returns `Ok` on damaged input is worse
    /// than none: the caller then draws a chart from whatever survived. Each
    /// rejection below is a distinct code path, and each is asserted to fire.
    #[test]
    fn every_way_the_input_can_be_damaged_is_refused() {
        for (bad, why) in [
            ("", "unexpected end"),
            ("{", "unexpected end"),
            ("{\"a\":}", "unexpected"),
            ("{\"a\" 1}", "expected ':'"),
            ("[1,2", "found None"),
            ("[1 2]", "expected ','"),
            ("\"unterminated", "unterminated string"),
            ("{\"a\":1} trailing", "trailing input"),
            ("tru", "expected `true`"),
            ("\"\\q\"", "unknown escape"),
            ("\"\\u00\"", "bad hex"),
            ("\"\\ud800\"", "not a scalar value"),
        ] {
            let err = Json::parse(bad).expect_err(&format!("{bad:?} must not parse"));
            assert!(
                err.contains(why),
                "{bad:?} was rejected for {err:?}, expected something about {why:?}"
            );
        }
    }

    /// A number read as a string, or a string read as a number, is the failure
    /// that would put a wrong bar on a chart without any error anywhere.
    #[test]
    fn accessors_do_not_coerce_between_kinds() {
        let v = Json::parse(r#"{"n":1.5,"s":"1.5","t":true,"z":null,"a":[1],"o":{}}"#).unwrap();
        assert_eq!(v.get("n").unwrap().num(), Some(1.5));
        assert_eq!(
            v.get("s").unwrap().num(),
            None,
            "a numeric string is not a number"
        );
        assert_eq!(v.get("n").unwrap().str(), None, "a number is not a string");
        assert_eq!(v.get("s").unwrap().str(), Some("1.5"));
        assert_eq!(v.get("t").unwrap(), &Json::Bool(true));
        assert_eq!(v.get("z").unwrap(), &Json::Null);
        assert_eq!(v.get("a").unwrap().arr().len(), 1);
        assert!(v.get("o").unwrap().obj().is_some());
        assert!(v.get("n").unwrap().obj().is_none());
        assert_eq!(v.get("missing"), None);
        assert_eq!(v.get("n").unwrap().get("anything"), None);
    }

    #[test]
    fn escapes_negatives_and_exponents_are_read_as_written() {
        let v = Json::parse(r#"{"e":-1.5e3,"x":"a\"b\\c\nd\u00e5"}"#).unwrap();
        assert_eq!(v.get("e").unwrap().num(), Some(-1500.0));
        assert_eq!(v.get("x").unwrap().str(), Some("a\"b\\c\nd\u{e5}"));
    }
}