// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// One keyword at a time, in the order the specification groups them: the core
// keywords that address, the applicators that hand the instance on, and the
// assertions that look at it.
///|
/// Whether `value` validates against `node`, recording every reason in `faults`
/// when the caller asked for them.
///
/// `marks` is what this schema evaluated, which is what an `unevaluated*` keyword
/// in a *neighbouring* schema asks about. `base` is the URI a reference here
/// resolves against, which changes as `$id` changes it.
fn Schema::check(
self : Schema,
node : Json,
base : String,
value : Json,
at : String,
spath : String,
faults : Array[Fault]?,
marks : Marks,
) -> Bool {
match node {
True => return true
False =>
return note(
faults, at, spath, "schema", "no instance validates against false",
)
Object(members) => {
let here = match
members.get(if self.draft == Draft4 { "id" } else { "$id" }) {
Some(String(id)) => split_fragment(resolve(base, id)).0
_ => base
}
// Entering a schema resource is what makes the dynamic scope grow, and
// what a `$dynamicRef` inside it resolves against.
let entered = here != base
if entered {
self.scope.push(here)
}
defer (if entered { self.scope.pop() |> ignore })
let own = Marks::new()
let mut ok = true
// Before 2019-09 a `$ref` is the whole schema: everything beside it is
// ignored, which is why a document from then puts nothing beside one.
let alone = self.draft == Draft4 ||
self.draft == Draft6 ||
self.draft == Draft7
if alone && members.get("$ref") is Some(String(reference)) {
// Everything beside it is ignored, and a sibling `$id` is beside it: the
// reference resolves against the base this schema was reached with.
return self.refer(
reference,
base,
value,
at,
step(spath, "$ref"),
faults,
marks,
)
}
for keyword, schema in members {
let kpath = step(spath, keyword)
// With the validation vocabulary out of force, its keywords annotate
// rather than assert, so the instance passes whatever they say.
if !self.asserts && asserted(keyword) {
continue
}
let passed = match keyword {
"$ref" =>
match schema {
String(reference) =>
self.refer(reference, here, value, at, kpath, faults, own)
_ => true
}
"$dynamicRef" | "$recursiveRef" =>
match schema {
String(reference) =>
self.refer_dynamic(
reference, here, value, at, kpath, faults, own,
)
_ => true
}
"type" => self.check_type(schema, value, at, kpath, faults)
"enum" =>
match schema {
Array(allowed) =>
if allowed.search_by(one => same(one, value)) is Some(_) {
true
} else {
note(
faults, at, kpath, "enum", "not one of the allowed values",
)
}
_ => true
}
"const" =>
if same(schema, value) {
true
} else {
note(faults, at, kpath, "const", "not the constant value")
}
"multipleOf" =>
match (schema, value) {
(Number(divisor, ..), Number(number, ..)) =>
if multiple_of(number, divisor) {
true
} else {
note(faults, at, kpath, "multipleOf", "not a multiple")
}
_ => true
}
"maximum" =>
self.check_bound(members, schema, value, at, kpath, faults, true)
"minimum" =>
self.check_bound(members, schema, value, at, kpath, faults, false)
"exclusiveMaximum" =>
match (schema, value) {
(Number(limit, ..), Number(number, ..)) =>
if number < limit {
true
} else {
note(
faults, at, kpath, "exclusiveMaximum", "above the maximum",
)
}
// Before draft-06 this was a flag on `maximum`, handled there.
_ => true
}
"exclusiveMinimum" =>
match (schema, value) {
(Number(limit, ..), Number(number, ..)) =>
if number > limit {
true
} else {
note(
faults, at, kpath, "exclusiveMinimum", "below the minimum",
)
}
_ => true
}
"maxLength" =>
match (schema, value) {
(Number(limit, ..), String(text)) =>
if code_points(text) <= limit.to_int() {
true
} else {
note(faults, at, kpath, "maxLength", "longer than allowed")
}
_ => true
}
"minLength" =>
match (schema, value) {
(Number(limit, ..), String(text)) =>
if code_points(text) >= limit.to_int() {
true
} else {
note(faults, at, kpath, "minLength", "shorter than allowed")
}
_ => true
}
"pattern" =>
match (schema, value) {
(String(expression), String(text)) =>
if self.matches(expression, text) {
true
} else {
note(
faults, at, kpath, "pattern", "does not match the pattern",
)
}
_ => true
}
"prefixItems" | "items" | "additionalItems" =>
self.check_items(
members, keyword, schema, here, value, at, spath, faults, own,
)
"contains" =>
self.check_contains(
members, schema, here, value, at, kpath, faults, own,
)
"maxItems" =>
match (schema, value) {
(Number(limit, ..), Array(items)) =>
if items.length() <= limit.to_int() {
true
} else {
note(faults, at, kpath, "maxItems", "more items than allowed")
}
_ => true
}
"minItems" =>
match (schema, value) {
(Number(limit, ..), Array(items)) =>
if items.length() >= limit.to_int() {
true
} else {
note(
faults, at, kpath, "minItems", "fewer items than allowed",
)
}
_ => true
}
"uniqueItems" =>
match (schema, value) {
(True, Array(items)) =>
if unique(items) {
true
} else {
note(faults, at, kpath, "uniqueItems", "two items are equal")
}
_ => true
}
"properties" | "patternProperties" | "additionalProperties" =>
self.check_properties(
members, keyword, schema, here, value, at, spath, faults, own,
)
"propertyNames" =>
match value {
Object(instance) => {
let mut every = true
for name, _ in instance {
if !self.check(
schema,
here,
Json::string(name),
step(at, name),
kpath,
faults,
Marks::new(),
) {
every = false
if faults is None {
break
}
}
}
every
}
_ => true
}
"required" =>
match (schema, value) {
(Array(names), Object(instance)) => {
let mut every = true
for name in names {
match name {
String(text) =>
if instance.get(text) is None {
every = note(
faults,
at,
kpath,
"required",
"missing " + text,
)
if faults is None {
break
}
}
_ => ()
}
}
every
}
_ => true
}
"maxProperties" =>
match (schema, value) {
(Number(limit, ..), Object(instance)) =>
if instance.length() <= limit.to_int() {
true
} else {
note(
faults, at, kpath, "maxProperties", "more properties than allowed",
)
}
_ => true
}
"minProperties" =>
match (schema, value) {
(Number(limit, ..), Object(instance)) =>
if instance.length() >= limit.to_int() {
true
} else {
note(
faults, at, kpath, "minProperties", "fewer properties than allowed",
)
}
_ => true
}
"dependencies" | "dependentRequired" | "dependentSchemas" =>
self.check_dependencies(
keyword, schema, here, value, at, kpath, faults, own,
)
"allOf" =>
match schema {
Array(branches) => {
let mut every = true
for i = 0; i < branches.length(); i = i + 1 {
let branch = Marks::new()
if self.check(
branches[i],
here,
value,
at,
step(kpath, i.to_string()),
faults,
branch,
) {
own.take(branch)
} else {
every = false
if faults is None {
break
}
}
}
every
}
_ => true
}
"anyOf" =>
match schema {
Array(branches) => {
let mut any = false
for i = 0; i < branches.length(); i = i + 1 {
let branch = Marks::new()
// A branch that fails is not a fault of the instance: only
// every branch failing is, so the reasons are dropped here.
if self.check(
branches[i],
here,
value,
at,
step(kpath, i.to_string()),
None,
branch,
) {
own.take(branch)
any = true
}
}
if any {
true
} else {
note(faults, at, kpath, "anyOf", "no branch validated")
}
}
_ => true
}
"oneOf" =>
match schema {
Array(branches) => {
let mut count = 0
let winner = Marks::new()
for i = 0; i < branches.length(); i = i + 1 {
let branch = Marks::new()
if self.check(
branches[i],
here,
value,
at,
step(kpath, i.to_string()),
None,
branch,
) {
count = count + 1
if count == 1 {
winner.take(branch)
}
}
}
if count == 1 {
own.take(winner)
true
} else if count == 0 {
note(faults, at, kpath, "oneOf", "no branch validated")
} else {
note(
faults,
at,
kpath,
"oneOf",
count.to_string() + " branches validated, not one",
)
}
}
_ => true
}
"not" =>
if self.check(schema, here, value, at, kpath, None, Marks::new()) {
note(
faults, at, kpath, "not", "validated against the negated schema",
)
} else {
true
}
// `if` never fails on its own: it picks which of `then` and `else`
// applies, and the reasons come from whichever it picked.
"if" => {
let branch = Marks::new()
let taken = self.check(schema, here, value, at, kpath, None, branch)
if taken {
own.take(branch)
}
let arm = if taken { "then" } else { "else" }
match members.get(arm) {
Some(consequent) => {
let inner = Marks::new()
if self.check(
consequent,
here,
value,
at,
step(spath, arm),
faults,
inner,
) {
own.take(inner)
true
} else {
false
}
}
None => true
}
}
// `then` and `else` are applied by `if` and never on their own.
"then" | "else" => true
_ => true
}
if !passed {
ok = false
if faults is None {
break
}
}
}
if ok &&
self.draft != Draft4 &&
self.draft != Draft6 &&
self.draft != Draft7 {
ok = self.check_unevaluated(
members, here, value, at, spath, faults, own,
)
}
if ok {
marks.take(own)
}
ok
}
_ => true
}
}
///|
/// Follow a `$ref` and validate against what it names.
fn Schema::refer(
self : Schema,
reference : String,
base : String,
value : Json,
at : String,
spath : String,
faults : Array[Fault]?,
marks : Marks,
) -> Bool {
match self.follow(base, reference) {
Some((target, outside)) => {
let inner = Marks::new()
let resource = split_fragment(resolve(base, reference)).0
// Landing inside another resource enters it, however the reference was
// written and whether or not the target itself carries the `$id`.
let entered = resource != "" && resource != base
if entered {
self.scope.push(resource)
}
defer (if entered { self.scope.pop() |> ignore })
let ok = self.check(target, outside, value, at, spath, faults, inner)
if ok {
marks.take(inner)
}
ok
}
None => note(faults, at, spath, "$ref", "reference resolves to nothing")
}
}
///|
/// Follow a `$dynamicRef` or a `$recursiveRef`, which land on the outermost
/// anchor of the same name rather than the nearest (2020-12 §8.2.3.2).
fn Schema::refer_dynamic(
self : Schema,
reference : String,
base : String,
value : Json,
at : String,
spath : String,
faults : Array[Fault]?,
marks : Marks,
) -> Bool {
let name = if reference == "#" { "" } else { split_fragment(reference).1 }
// It resolves like a plain reference first. Only when what it lands on is
// itself a dynamic anchor of that name does the dynamic scope take over — and
// then it is the outermost resource in scope that answers, not the nearest.
let landed = self.follow(base, reference)
let dynamic = match landed {
Some((Object(members), _)) =>
if name == "" {
members.get("$recursiveAnchor") is Some(True)
} else {
match members.get("$dynamicAnchor") {
Some(String(found)) => found == name
_ => false
}
}
_ => false
}
let target = if dynamic {
match self.outermost(name) {
Some(found) => Some((found, base))
None => landed
}
} else {
landed
}
match target {
Some((found, outside)) => {
let inner = Marks::new()
let ok = self.check(found, outside, value, at, spath, faults, inner)
if ok {
marks.take(inner)
}
ok
}
None =>
note(faults, at, spath, "$dynamicRef", "reference resolves to nothing")
}
}
///|
/// Record a fault, and answer `false` so a caller can `return note(...)`.
fn note(
faults : Array[Fault]?,
at : String,
schema_at : String,
keyword : String,
message : String,
) -> Bool {
match faults {
Some(list) => list.push({ at, schema_at, keyword, message, })
None => ()
}
false
}
///|
/// Whether a keyword belongs to the validation vocabulary, which is the set a
/// metaschema can leave out (2020-12 §6).
fn asserted(keyword : String) -> Bool {
match keyword {
"type"
| "enum"
| "const"
| "multipleOf"
| "maximum"
| "exclusiveMaximum"
| "minimum"
| "exclusiveMinimum"
| "maxLength"
| "minLength"
| "pattern"
| "maxItems"
| "minItems"
| "uniqueItems"
| "maxContains"
| "minContains"
| "maxProperties"
| "minProperties"
| "required"
| "dependentRequired" => true
_ => false
}
}