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"]);
}
}