// Matchers for `Json`.
///|
/// One step of a path into a Json value.
priv enum PathStep {
Key(String)
Index(Int)
}
///|
/// Parse a path such as `items[0].name`. Keys are separated by `.`, and
/// array indices are in brackets.
fn parse_path(path : String) -> Array[PathStep]? {
let steps = []
let key = StringBuilder()
let chars = path.to_array()
let flush = () => {
let text = key.to_string()
key.reset()
text
}
let mut i = 0
while i < chars.length() {
match chars[i] {
'.' => {
let text = flush()
// A dot must follow a key or an index.
if text.is_empty() && !(steps.last() is Some(Index(_))) {
return None
}
if !text.is_empty() {
steps.push(Key(text))
}
i += 1
}
'[' => {
let text = flush()
if !text.is_empty() {
steps.push(Key(text))
}
let digits = StringBuilder()
i += 1
while i < chars.length() && chars[i] != ']' {
guard chars[i].is_ascii_digit() else { return None }
digits.write_char(chars[i])
i += 1
}
guard i < chars.length() && !digits.to_string().is_empty() else {
return None
}
steps.push(
Index(
@string.parse_int(digits.to_string()) catch {
_ => return None
},
),
)
i += 1
}
c => {
key.write_char(c)
i += 1
}
}
}
let text = flush()
if !text.is_empty() {
steps.push(Key(text))
} else if steps.is_empty() || path.has_suffix(".") {
return None
}
Some(steps)
}
///|
/// The path of the first `count` steps, such as `items[0]`.
fn path_text(steps : Array[PathStep], count : Int) -> String {
let builder = StringBuilder()
for i, step in steps[:count] {
match step {
Key(key) => {
if i > 0 {
builder.write_char('.')
}
builder.write_string(key)
}
Index(index) => builder.write_string("[\{index}]")
}
}
builder.to_string()
}
///|
/// The kind of a Json value, with an article: `an array`.
fn json_kind(value : Json) -> String {
match value {
Null => "null"
True | False => "a boolean"
Number(_, ..) => "a number"
String(_) => "a string"
Array(_) => "an array"
Object(_) => "an object"
}
}
///|
/// Return an expectation on the value at `path`, such as `items[0].name`.
/// Keys are separated by `.`, and array indices are in brackets. Fails when
/// the path does not exist. Cannot be used after `not()`.
#callsite(autofill(loc))
pub fn Expectation::at(
self : Expectation[Json],
path : String,
loc~ : SourceLoc,
) -> Expectation[Json] raise Error {
self.check_not_negated("at", loc)
guard parse_path(path) is Some(steps) else {
self.report("at", "path", [("Error", "invalid path \{show(path)}")], loc)
}
let mut current = self
for i, step in steps {
let here = if i == 0 { "the value" } else { path_text(steps, i) }
let found = match (step, current.actual) {
(Key(key), Object(map)) =>
match map.get(key) {
Some(value) => Ok(current.navigate(value, key))
None => Err("\{here} has no key \{show(key)}")
}
(Index(index), Array(items)) =>
match items.get(index) {
Some(value) => Ok(current.navigate(value, "[\{index}]"))
None => Err("\{here} is an array of length \{items.length()}")
}
(Key(_), value) => Err("\{here} is \{json_kind(value)}, not an object")
(Index(_), value) => Err("\{here} is \{json_kind(value)}, not an array")
}
match found {
Ok(next) => current = next
Err(message) =>
self.report(
"at",
"path",
[
("Expected", "a value at \{show(path)}"),
("Received", self.actual.stringify()),
("Found", message),
],
loc,
)
}
}
current
}
///|
/// Compare `actual` with `expected` as a subset. Add the missing paths and
/// the differences to the arrays.
fn json_subset(
actual : Json,
expected : Json,
path : String,
missing : Array[String],
different : Array[String],
) -> Unit {
let name = if path.is_empty() { "the value" } else { path }
let child = (key : String) => {
if path.is_empty() {
key
} else {
"\{path}.\{key}"
}
}
match (actual, expected) {
(Object(actual_map), Object(expected_map)) =>
for key, value in expected_map {
match actual_map.get(key) {
Some(actual_value) =>
json_subset(actual_value, value, child(key), missing, different)
None => missing.push(child(key))
}
}
(Array(actual_items), Array(expected_items)) =>
if actual_items.length() != expected_items.length() {
different.push(
"\{name}: expected an array of length \{expected_items.length()}, received length \{actual_items.length()}",
)
} else {
for i, value in expected_items {
json_subset(
actual_items[i],
value,
"\{path}[\{i}]",
missing,
different,
)
}
}
(Number(a, ..), Number(b, ..)) =>
if a != b {
different.push(
"\{name}: expected \{expected.stringify()}, received \{actual.stringify()}",
)
}
_ =>
if actual != expected {
different.push(
"\{name}: expected \{expected.stringify()}, received \{actual.stringify()}",
)
}
}
}
///|
/// Assert a Json value contains `expected`: every key of an object in
/// `expected` must exist with a value that contains the expected value, at
/// every depth. Other keys are allowed. Arrays must have the same length,
/// and each element must contain the expected element. Numbers compare by
/// value.
#callsite(autofill(loc))
pub fn Expectation::to_contain_json(
self : Expectation[Json],
expected : Json,
loc~ : SourceLoc,
) -> Unit raise Error {
let missing = []
let different = []
json_subset(self.actual, expected, "", missing, different)
self.assert_that(
missing.is_empty() && different.is_empty(),
"to_contain_json",
args="expected",
expected=() => "containing \{expected.stringify()}",
received=() => self.actual.stringify(),
details=() => {
let details = []
if !self.negated && !missing.is_empty() {
details.push(("Missing", missing.join("\n")))
}
if !self.negated && !different.is_empty() {
details.push(("Different", different.join("\n")))
}
details
},
loc~,
)
}