Gunnar

nordisk/gunnar-ui

uitest/src/json.rs

//! The smallest JSON that the DevTools Protocol needs: a value, a parser and a
//! string escaper. See the crate manifest for why this is not serde.

/// A JSON value. Objects keep insertion order in a `Vec` because CDP payloads
/// are small and a lookup that walks eight pairs is not worth a hash map.
#[derive(Debug, Clone, PartialEq)]
pub enum Json {
    Null,
    Bool(bool),
    Num(f64),
    Str(String),
    Arr(Vec<Json>),
    Obj(Vec<(String, Json)>),
}

impl Json {
    pub fn get(&self, key: &str) -> Option<&Json> {
        match self {
            Json::Obj(m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v),
            _ => None,
        }
    }

    /// Dotted lookup: `result.result.value`. Any missing segment is `None`,
    /// which is what makes the callers read as assertions rather than as
    /// pattern matches.
    pub fn path(&self, p: &str) -> Option<&Json> {
        let mut cur = self;
        for seg in p.split('.') {
            cur = cur.get(seg)?;
        }
        Some(cur)
    }

    pub fn arr(&self) -> &[Json] {
        match self {
            Json::Arr(v) => v,
            _ => &[],
        }
    }

    pub fn as_str(&self) -> Option<&str> {
        match self {
            Json::Str(s) => Some(s),
            _ => None,
        }
    }

    pub fn as_f64(&self) -> Option<f64> {
        match self {
            Json::Num(n) => Some(*n),
            _ => None,
        }
    }

    pub fn as_bool(&self) -> Option<bool> {
        match self {
            Json::Bool(b) => Some(*b),
            _ => None,
        }
    }

    /// `path` as a string, or the empty string. For rows and messages only —
    /// never for a decision, so that a missing key cannot read as a pass.
    pub fn text(&self, p: &str) -> String {
        self.path(p).and_then(|v| v.as_str()).unwrap_or("").to_owned()
    }

    /// Compact re-render, used when a situation has to quote what it actually
    /// received.
    pub fn dump(&self) -> String {
        let mut s = String::new();
        self.write(&mut s);
        s
    }

    fn write(&self, out: &mut String) {
        match self {
            Json::Null => out.push_str("null"),
            Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
            Json::Num(n) => {
                if n.fract() == 0.0 && n.abs() < 9e15 {
                    out.push_str(&format!("{}", *n as i64));
                } else {
                    out.push_str(&format!("{n}"));
                }
            }
            Json::Str(s) => out.push_str(&esc(s)),
            Json::Arr(v) => {
                out.push('[');
                for (i, e) in v.iter().enumerate() {
                    if i > 0 {
                        out.push(',');
                    }
                    e.write(out);
                }
                out.push(']');
            }
            Json::Obj(m) => {
                out.push('{');
                for (i, (k, v)) in m.iter().enumerate() {
                    if i > 0 {
                        out.push(',');
                    }
                    out.push_str(&esc(k));
                    out.push(':');
                    v.write(out);
                }
                out.push('}');
            }
        }
    }
}

/// A JSON string literal, quotes included.
pub fn esc(s: &str) -> String {
    let mut o = String::with_capacity(s.len() + 2);
    o.push('"');
    for c in s.chars() {
        match c {
            '"' => o.push_str("\\\""),
            '\\' => o.push_str("\\\\"),
            '\n' => o.push_str("\\n"),
            '\r' => o.push_str("\\r"),
            '\t' => o.push_str("\\t"),
            c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
            c => o.push(c),
        }
    }
    o.push('"');
    o
}

pub fn parse(s: &str) -> Result<Json, String> {
    let b = s.as_bytes();
    let mut i = 0usize;
    let v = value(b, &mut i)?;
    Ok(v)
}

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

fn value(b: &[u8], i: &mut usize) -> Result<Json, String> {
    ws(b, i);
    match b.get(*i) {
        None => Err("unexpected end of JSON".into()),
        Some(b'{') => object(b, i),
        Some(b'[') => array(b, i),
        Some(b'"') => Ok(Json::Str(string(b, i)?)),
        Some(b't') => lit(b, i, "true", Json::Bool(true)),
        Some(b'f') => lit(b, i, "false", Json::Bool(false)),
        Some(b'n') => lit(b, i, "null", Json::Null),
        Some(_) => number(b, i),
    }
}

fn lit(b: &[u8], i: &mut usize, want: &str, v: Json) -> Result<Json, String> {
    if b[*i..].starts_with(want.as_bytes()) {
        *i += want.len();
        Ok(v)
    } else {
        Err(format!("expected {want} at byte {i}", i = *i))
    }
}

fn number(b: &[u8], i: &mut usize) -> Result<Json, String> {
    let start = *i;
    while *i < b.len() && matches!(b[*i], b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9') {
        *i += 1;
    }
    std::str::from_utf8(&b[start..*i])
        .ok()
        .and_then(|s| s.parse::<f64>().ok())
        .map(Json::Num)
        .ok_or_else(|| format!("bad number at byte {start}"))
}

fn string(b: &[u8], i: &mut usize) -> Result<String, String> {
    if b.get(*i) != Some(&b'"') {
        return Err(format!("expected a string at byte {i}", i = *i));
    }
    *i += 1;
    let mut out = String::new();
    // Surrogate pairs arrive as two \u escapes and have to be joined here;
    // CDP sends them for anything outside the BMP, emoji in a page title
    // included.
    let mut pending_high: Option<u16> = None;
    while *i < b.len() {
        match b[*i] {
            b'"' => {
                *i += 1;
                return Ok(out);
            }
            b'\\' => {
                *i += 1;
                let e = *b.get(*i).ok_or("unterminated escape")?;
                *i += 1;
                let unit = match e {
                    b'"' => Some('"' as u16),
                    b'\\' => Some('\\' as u16),
                    b'/' => Some('/' as u16),
                    b'b' => Some(0x08),
                    b'f' => Some(0x0c),
                    b'n' => Some('\n' as u16),
                    b'r' => Some('\r' as u16),
                    b't' => Some('\t' as u16),
                    b'u' => {
                        let h = std::str::from_utf8(b.get(*i..*i + 4).ok_or("short \\u")?)
                            .map_err(|_| "bad \\u")?;
                        *i += 4;
                        Some(u16::from_str_radix(h, 16).map_err(|_| "bad \\u")?)
                    }
                    other => return Err(format!("bad escape \\{}", other as char)),
                };
                let unit = unit.unwrap();
                if let Some(high) = pending_high.take() {
                    if (0xdc00..0xe000).contains(&unit) {
                        let c = 0x10000 + (((high as u32) - 0xd800) << 10) + (unit as u32 - 0xdc00);
                        out.push(char::from_u32(c).unwrap_or('\u{fffd}'));
                        continue;
                    }
                    out.push('\u{fffd}');
                }
                if (0xd800..0xdc00).contains(&unit) {
                    pending_high = Some(unit);
                } else {
                    out.push(char::from_u32(unit as u32).unwrap_or('\u{fffd}'));
                }
            }
            _ => {
                if pending_high.take().is_some() {
                    out.push('\u{fffd}');
                }
                let start = *i;
                while *i < b.len() && b[*i] != b'"' && b[*i] != b'\\' {
                    *i += 1;
                }
                out.push_str(&String::from_utf8_lossy(&b[start..*i]));
            }
        }
    }
    Err("unterminated string".into())
}

fn array(b: &[u8], i: &mut usize) -> Result<Json, String> {
    *i += 1;
    let mut v = Vec::new();
    ws(b, i);
    if b.get(*i) == Some(&b']') {
        *i += 1;
        return Ok(Json::Arr(v));
    }
    loop {
        v.push(value(b, i)?);
        ws(b, i);
        match b.get(*i) {
            Some(b',') => *i += 1,
            Some(b']') => {
                *i += 1;
                return Ok(Json::Arr(v));
            }
            _ => return Err(format!("expected , or ] at byte {i}", i = *i)),
        }
    }
}

fn object(b: &[u8], i: &mut usize) -> Result<Json, String> {
    *i += 1;
    let mut m = Vec::new();
    ws(b, i);
    if b.get(*i) == Some(&b'}') {
        *i += 1;
        return Ok(Json::Obj(m));
    }
    loop {
        ws(b, i);
        let k = string(b, i)?;
        ws(b, i);
        if b.get(*i) != Some(&b':') {
            return Err(format!("expected : at byte {i}", i = *i));
        }
        *i += 1;
        m.push((k, value(b, i)?));
        ws(b, i);
        match b.get(*i) {
            Some(b',') => *i += 1,
            Some(b'}') => {
                *i += 1;
                return Ok(Json::Obj(m));
            }
            _ => return Err(format!("expected , or }} at byte {i}", i = *i)),
        }
    }
}

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

    #[test]
    fn a_nested_cdp_reply_is_read_by_path() {
        let j = parse(r#"{"id":3,"result":{"result":{"type":"string","value":"ok"}}}"#).unwrap();
        assert_eq!(j.text("result.result.value"), "ok");
        assert_eq!(j.path("result.result.missing"), None);
    }

    #[test]
    fn escapes_and_surrogate_pairs_survive_the_round_trip() {
        let j = parse(r#"{"a":"line\nquote\"tab\tstar★pair😀"}"#).unwrap();
        assert_eq!(j.text("a"), "line\nquote\"tab\tstar\u{2605}pair\u{1f600}");
        // The escaper is the inverse for everything a page title can hold.
        assert_eq!(parse(&esc(&j.text("a"))).unwrap().as_str().unwrap(), j.text("a"));
    }

    #[test]
    fn a_missing_key_is_never_a_pass() {
        // Measure, never assert: the accessor that rows are written against
        // must not turn an
        // absent field into a usable value.
        let j = parse(r#"{"ok":false}"#).unwrap();
        assert_eq!(j.path("ok").and_then(|v| v.as_bool()), Some(false));
        assert_eq!(j.path("nope").and_then(|v| v.as_bool()), None);
        assert_eq!(j.text("nope"), "");
    }

    #[test]
    fn arrays_of_objects_are_walkable() {
        let j = parse(r#"{"repos":[{"name":"a"},{"name":"b"}]}"#).unwrap();
        let names: Vec<_> = j.path("repos").unwrap().arr().iter().map(|r| r.text("name")).collect();
        assert_eq!(names, vec!["a", "b"]);
    }
}