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