// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// The validator. A schema is read as the `Json` it is — there is no compilation
// step, by the decision recorded in the README — and an instance is walked
// against it keyword by keyword.
//
// Every keyword of every version lives in one `check`, switched on the draft
// where the versions differ, because that is what the versions are: one language
// with a handful of differences, not five languages.
///|
/// Why a schema could not be read.
///
/// These are faults in the schema, not in the instance an instance that fails
/// validation produces [`Fault`]s instead.
pub(all) suberror Refused {
/// A `$schema` naming a version this does not know.
Unknown(String)
/// A reference that resolves to nothing, or a keyword whose value is not the
/// shape the specification gives it.
Broken(at~ : String, why~ : String)
} derive(Eq, Debug)
///|
pub extend Refused with Eq::{equal, not_equal}
///|
pub extend Refused with Debug::{to_repr}
///|
pub impl Show for Refused with fn output(self, logger) {
logger.write_string(
match self {
Unknown(uri) => "unknown $schema: " + uri
Broken(at~, why~) => "broken schema at " + at + ": " + why
},
)
}
///|
pub extend Refused with Show::{output, to_string}
///|
/// One reason an instance failed, in the shape the basic output gives it
/// (2020-12 §12.4.2).
pub(all) struct Fault {
/// Where in the instance, as a JSON Pointer.
at : String
/// Where in the schema, as a JSON Pointer.
schema_at : String
/// The keyword that refused it.
keyword : String
/// What that keyword had to say.
message : String
} derive(Eq, Debug)
///|
pub extend Fault with Eq::{equal, not_equal}
///|
pub extend Fault with Debug::{to_repr}
///|
pub impl Show for Fault with fn output(self, logger) {
logger.write_string(
(if self.at == "" { "the instance" } else { self.at }) +
": " +
self.message +
" (" +
self.keyword +
" at " +
(if self.schema_at == "" { "/" } else { self.schema_at }) +
")",
)
}
///|
pub extend Fault with Show::{output, to_string}
///|
/// A schema document, read and ready to validate instances against.
///
/// The version is the one the document names in `$schema`, whatever `draft` said;
/// a document is more specific than a setting. `remotes` are the documents a
/// `$ref` may reach outside this one, by the URI each is registered under: this
/// library opens no sockets, so what is not fed to it does not exist.
pub struct Schema {
priv root : Json
priv draft : Draft
/// Whether the validation vocabulary is in force, which is what decides
/// whether `minimum` and its like assert or merely annotate.
priv asserts : Bool
priv base : String
/// Every `$id`, `$anchor` and `$dynamicAnchor` in reach, by absolute URI.
priv ids : Map[String, Json]
/// The `$dynamicAnchor` of each resource, by the resource's URI and then by
/// the anchor's name. A `$recursiveAnchor` is the anchor whose name is empty.
priv dynamic : Map[String, Map[String, Json]]
/// The base in effect *outside* each node a reference can land on, by the
/// same URI the reference resolves to. A target's own `$id` is applied by the
/// validator when it reads it, so what is remembered here is the base before
/// that — otherwise a relative `$id` would be applied twice.
priv outer : Map[String, String]
/// Each pattern as it was read, so a class of a few thousand ranges is built
/// once rather than once per member of every instance. `None` is a pattern
/// the engine could not read.
priv patterns : Map[String, @string.Regex?]
/// The resources evaluation has entered, outermost first. A `$dynamicRef`
/// resolves against this and not against where it was written, which is the
/// whole of what makes it dynamic.
priv scope : Array[String]
}
///|
/// Read a schema document.
///
/// `draft` is the version to read it at when it does not name one; a `$schema`
/// it does name wins. `remotes` are the documents a `$ref` may reach, each under
/// the URI it is referred to by.
pub fn Schema::new(
document : Json,
draft? : Draft = draft,
remotes? : Map[String, Json] = Map::Map([]),
) -> Schema raise Refused {
let mut version = draft
// Every keyword this validates with is in the validation vocabulary. A
// metaschema that does not declare that vocabulary is asking for the rest of
// the keywords to be read as annotations, and a validator that asserted them
// anyway would refuse instances the schema's own author allows.
let mut asserts = true
match document {
Object(members) =>
match members.get("$schema") {
Some(String(uri)) => {
let (named, validating) = read_version(uri, remotes)
version = named
asserts = validating
}
_ => ()
}
_ => ()
}
let base = match document {
Object(members) =>
match members.get(if version == Draft4 { "id" } else { "$id" }) {
Some(String(id)) => split_fragment(id).0
_ => ""
}
_ => ""
}
let schema = {
root: document,
draft: version,
asserts,
base,
ids: Map::Map([]),
outer: Map::Map([]),
dynamic: Map::Map([]),
patterns: Map::Map([]),
scope: [],
}
schema.index(document, base, base, "")
for uri, remote in remotes {
let remote_base = split_fragment(uri).0
schema.ids[remote_base] = remote
schema.outer[remote_base] = remote_base
schema.index(remote, remote_base, remote_base, "")
}
schema
}
///|
/// Whether `instance` validates.
///
/// This is the flag output (§12.4.1): the cheapest answer, and the one a guard on
/// a request wants. [`Schema::faults`] says why not.
pub fn Schema::valid(self : Schema, instance : Json) -> Bool {
self.scope.clear()
self.scope.push(self.base)
self.check(self.root, self.base, instance, "", "", None, Marks::new())
}
///|
/// Every reason `instance` failed, or an empty array when it validated.
///
/// This is the basic output (§12.4.2), flattened: each fault says where in the
/// instance it was, which keyword refused it, and where that keyword was.
pub fn Schema::faults(self : Schema, instance : Json) -> Array[Fault] {
let faults : Array[Fault] = []
self.scope.clear()
self.scope.push(self.base)
self.check(self.root, self.base, instance, "", "", Some(faults), Marks::new())
|> ignore
faults
}
// ------------------------------------------------------------------ annotation
///|
/// What a schema evaluated, which is what `unevaluatedProperties` and
/// `unevaluatedItems` ask their neighbours about (2019-09 §9.3.2.4).
priv struct Marks {
/// The members an applicator validated.
props : Set[String]
/// The item positions one validated.
idx : Set[Int]
/// Whether one validated every item there is, however many arrive.
mut all_items : Bool
}
///|
fn Marks::new() -> Marks {
{ props: Set::Set([]), idx: Set::Set([]), all_items: false, }
}
///|
/// Take in what a sibling applicator evaluated, which only happens when that
/// applicator validated: a failed branch annotates nothing.
fn Marks::take(self : Marks, other : Marks) -> Unit {
for name in other.props {
self.props.add(name)
}
for index in other.idx {
self.idx.add(index)
}
if other.all_items {
self.all_items = true
}
}
// ------------------------------------------------------------------- the walk
///|
/// Index every `$id`, `$anchor` and `$dynamicAnchor` the document holds, so a
/// `$ref` has somewhere to land.
fn Schema::index(
self : Schema,
node : Json,
base : String,
document : String,
path : String,
) -> Unit {
guard node is Object(members) else { return }
// Every node is addressable by a pointer into the document it is in, and the
// base outside it is what a reference landing there resolves against.
self.outer[document + "#" + path] = base
let mut here = base
let id_keyword = if self.draft == Draft4 { "id" } else { "$id" }
match members.get(id_keyword) {
Some(String(id)) => {
let resolved = resolve(here, id)
// Before 2019-09 an `$id` that is only a fragment is how an anchor was
// written; from 2019-09 that is `$anchor` and an `$id` names a document.
let (named, fragment) = split_fragment(resolved)
if fragment != "" {
self.ids[named + "#" + fragment] = node
self.outer[named + "#" + fragment] = base
} else {
here = named
self.ids[named] = node
self.outer[named] = base
}
}
_ => ()
}
match members.get("$anchor") {
Some(String(anchor)) => {
self.ids[here + "#" + anchor] = node
self.outer[here + "#" + anchor] = base
}
_ => ()
}
match members.get("$dynamicAnchor") {
Some(String(anchor)) => {
self.ids[here + "#" + anchor] = node
self.outer[here + "#" + anchor] = base
self.anchor_dynamic(here, anchor, node)
}
_ => ()
}
match members.get("$recursiveAnchor") {
Some(True) => self.anchor_dynamic(here, "", node)
_ => ()
}
for keyword, value in members {
match keyword {
"additionalProperties"
| "additionalItems"
| "contains"
| "propertyNames"
| "not"
| "if"
| "then"
| "else"
| "unevaluatedItems"
| "unevaluatedProperties"
| "contentSchema"
| "items" =>
match value {
Array(schemas) =>
for i = 0; i < schemas.length(); i = i + 1 {
self.index(
schemas[i],
here,
document,
step(step(path, keyword), i.to_string()),
)
}
_ => self.index(value, here, document, step(path, keyword))
}
"allOf" | "anyOf" | "oneOf" | "prefixItems" =>
match value {
Array(schemas) =>
for i = 0; i < schemas.length(); i = i + 1 {
self.index(
schemas[i],
here,
document,
step(step(path, keyword), i.to_string()),
)
}
_ => ()
}
"properties"
| "patternProperties"
| "$defs"
| "definitions"
| "dependentSchemas"
| "dependencies" =>
match value {
Object(schemas) =>
for name, schema in schemas {
self.index(
schema,
here,
document,
step(step(path, keyword), name),
)
}
_ => ()
}
_ => ()
}
}
}
///|
/// The subschema a reference names, or `None` when nothing is there.
fn Schema::follow(
self : Schema,
base : String,
reference : String,
) -> (Json, String)? {
let resolved = resolve(base, reference)
let (document, fragment) = split_fragment(resolved)
if fragment.has_prefix("/") || fragment == "" {
// A pointer into whichever document the reference names.
let root = if document == self.base || document == "" {
Some(self.root)
} else {
self.ids.get(document)
}
match root {
Some(found) =>
match pointer(found, fragment) {
Some(node) =>
Some(
(
node,
self.outside(resolved, document + "#" + fragment, document),
),
)
None => None
}
None =>
match self.ids.get(resolved) {
Some(node) =>
Some(
(
node,
self.outside(resolved, document + "#" + fragment, document),
),
)
None => None
}
}
} else {
match self.ids.get(resolved) {
Some(found) =>
Some(
(found, self.outside(resolved, document + "#" + fragment, document)),
)
// An anchor written against a document that named no `$id` of its own.
None =>
match self.ids.get("#" + fragment) {
Some(found) =>
Some(
(found, self.outside("#" + fragment, "#" + fragment, document)),
)
None => None
}
}
}
}
///|
/// The base in effect outside the node an address names.
///
/// A node is addressable two ways — by the URI its own `$id` gave it, and by a
/// pointer into the document it sits in — and only one of them is recorded when
/// the node has an `$id`. Both are tried, because a reference may arrive either
/// way, and the answer has to be the base *outside* the node: applying its own
/// `$id` is the validator's job, and doing it twice moves the resource.
fn Schema::outside(
self : Schema,
named : String,
pointed : String,
fallback : String,
) -> String {
match self.outer.get(named) {
Some(base) => base
None =>
match self.outer.get(pointed) {
Some(base) => base
None => fallback
}
}
}
///|
/// Record a dynamic anchor under the resource it belongs to.
fn Schema::anchor_dynamic(
self : Schema,
resource : String,
anchor : String,
node : Json,
) -> Unit {
match self.dynamic.get(resource) {
Some(anchors) => anchors[anchor] = node
None => {
let anchors : Map[String, Json] = Map::Map([])
anchors[anchor] = node
self.dynamic[resource] = anchors
}
}
}
///|
/// The schema a dynamic anchor names, taken from the outermost resource in the
/// current dynamic scope that has one by that name (2020-12 §8.2.3.2).
fn Schema::outermost(self : Schema, anchor : String) -> Json? {
for resource in self.scope {
match self.dynamic.get(resource) {
Some(anchors) =>
match anchors.get(anchor) {
Some(found) => return Some(found)
None => ()
}
None => ()
}
}
None
}
///|
/// The version a `$schema` URI names, and whether its validation vocabulary is
/// in force.
///
/// A URI this does not know is looked for among the documents the caller fed:
/// a custom metaschema is a document like any other, and what it says about
/// itself — the version it is written against, the vocabularies it declares —
/// is read from it. A URI that is neither known nor fed is refused, because a
/// document written against something nobody can read is not one to guess at.
fn read_version(
uri : String,
remotes : Map[String, Json],
) -> (Draft, Bool) raise Refused {
match Draft::of(uri[:]) {
Some(named) => (named, true)
None =>
match remotes.get(split_fragment(uri).0) {
Some(Object(meta)) => {
let version = match meta.get("$schema") {
Some(String(parent)) => read_version(parent, remotes).0
_ => raise Unknown(uri)
}
let mut asserts = true
match meta.get("$vocabulary") {
Some(Object(declared)) => {
asserts = false
for vocabulary, wanted in declared {
if vocabulary.has_suffix("/vocab/validation") && wanted is True {
asserts = true
}
}
}
_ => ()
}
(version, asserts)
}
_ => raise Unknown(uri)
}
}
}