// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// The seam a vocabulary outside this library plugs into: one trait for a
// keyword, one for a vocabulary, and a registry that decides which of them a
// given document puts in force.
//
// The rules, which are the whole of the contract:
//
// | Question | Answer |
// |:--|:--|
// | how a keyword is shaped | `pub(open) trait Keyword`, one method, `Verdict` back |
// | how a vocabulary is shaped | `pub(open) trait Vocabulary`, its URI and its keywords |
// | how one is registered | `Registry::add`, raising `Refused` on a clash |
// | when it is in force | the document's `$vocabulary` names the vocabulary's URI |
// | a name a built-in uses | not available; the registration is refused |
// | a name another vocabulary took | not available; the second is refused |
// | `false` in `$vocabulary` | its keywords annotate, and assert nothing |
// | a wanted vocabulary nobody holds | the document is refused, not half-read |
///|
/// Where a keyword sits, for the benefit of a keyword that has to say where it
/// looked.
pub(all) struct Site {
/// Where in the instance, as a JSON Pointer; empty for the instance itself.
at : String
/// Where in the schema, as a JSON Pointer, following the walk that arrived.
schema_at : String
} derive(Eq, Debug)
///|
pub extend Site with Eq::{equal, not_equal}
///|
pub extend Site with Debug::{to_repr}
///|
/// What a keyword says about one instance.
pub(all) enum Verdict {
/// It conforms, and has nothing to add.
Accept
/// It conforms, and contributes `annotation` to the output — which is what a
/// keyword that annotates rather than asserts always answers.
Annotate(annotation~ : Json)
/// It does not conform, and this says why in one clause.
Refuse(why~ : String)
} derive(Eq, Debug)
///|
pub extend Verdict with Eq::{equal, not_equal}
///|
pub extend Verdict with Debug::{to_repr}
///|
/// A keyword someone outside this library defines.
///
/// It is written in a schema as [`Keyword::name`] and read once per instance,
/// with the keyword's own value, the instance, and where both sit. It asserts,
/// annotates, or both; it does not apply subschemas — a keyword that has to walk
/// a subschema is an applicator, and applicators are this library's own.
///
/// ```moonbit nocheck
/// struct Even {}
///
/// pub impl Keyword for Even with fn name(_self) {
/// "x-even"
/// }
///
/// pub impl Keyword for Even with fn judge(_self, value, instance, site) {
/// ignore(site)
/// match (value, instance) {
/// (True, Number(number, ..)) =>
/// if number % 2.0 == 0.0 {
/// Accept
/// } else {
/// Refuse("odd")
/// }
/// _ => Accept()
/// }
/// }
///
/// pub extend Even with Keyword::{name, judge}
/// ```
pub(open) trait Keyword {
/// The name it is written as in a schema.
fn name(Self) -> String
/// What it says about `instance` at `site`.
fn judge(Self, value : Json, instance : Json, site : Site) -> Verdict
}
///|
/// A set of keywords that a document puts in force by naming its URI in
/// `$vocabulary`.
///
/// The URI is the one the schema's metaschema uses — a vocabulary is identified
/// by an absolute URI, conventionally one ending in `/vocab/`. A document
/// whose metaschema names this URI with `true` gets the keywords; with `false`,
/// they annotate and assert nothing.
pub(open) trait Vocabulary {
/// The URI a `$vocabulary` names it by.
fn uri(Self) -> String
/// The keywords it defines.
fn keywords(Self) -> Array[&Keyword]
}
///|
/// The vocabularies a caller can offer a document, by URI.
///
/// A registry is not global: two schemas in one program may be read against
/// different vocabularies, and a schema is read against what its own caller
/// handed it. [`registry`] is the empty one, which is what a caller who adds
/// nothing gets; a caller with vocabularies of their own starts from
/// [`Registry::new`].
pub struct Registry {
priv held : Map[String, &Vocabulary]
/// Every name taken so far, by the URI of the vocabulary that took it. A
/// built-in keyword's name is in here from the start, held by the empty URI.
priv names : Map[String, String]
}
///|
/// Whether a vocabulary is one this library implements, which is every
/// vocabulary 2019-09 and 2020-12 define. A document that declares one of them
/// is declaring something already in force, and nothing has to hold it.
fn ours(vocabulary : String) -> Bool {
vocabulary.has_prefix("https://json-schema.org/draft/2019-09/vocab/") ||
vocabulary.has_prefix("https://json-schema.org/draft/2020-12/vocab/") ||
vocabulary.has_prefix("http://json-schema.org/draft/2019-09/vocab/") ||
vocabulary.has_prefix("http://json-schema.org/draft/2020-12/vocab/")
}
///|
/// The vocabulary held under `uri`, or `None`.
fn Registry::get(self : Registry, uri : String) -> &Vocabulary? {
self.held.get(uri)
}
///|
/// A registry that holds nothing, and answers for no name but the built-in ones.
pub fn Registry::new() -> Registry {
let names : Map[String, String] = Map::Map([])
for keyword in builtin_keywords() {
names[keyword] = ""
}
{ held: Map::Map([]), names, }
}
///|
/// No vocabularies beyond the built-in ones.
pub let registry : Registry = Registry::new()
///|
/// The names the built-in keywords answer to, across every version. A
/// third-party keyword cannot take one of them.
fn builtin_keywords() -> Array[String] {
[
"$anchor", "$comment", "$defs", "$dynamicAnchor", "$dynamicRef", "$id", "$recursiveAnchor",
"$recursiveRef", "$ref", "$schema", "$vocabulary", "additionalItems", "additionalProperties",
"allOf", "anyOf", "const", "contains", "contentEncoding", "contentMediaType",
"contentSchema", "default", "definitions", "dependencies", "dependentRequired",
"dependentSchemas", "deprecated", "description", "else", "enum", "examples",
"exclusiveMaximum", "exclusiveMinimum", "format", "if", "items", "maxContains",
"maximum", "maxItems", "maxLength", "maxProperties", "minContains", "minimum",
"minItems", "minLength", "minProperties", "multipleOf", "not", "oneOf", "pattern",
"patternProperties", "prefixItems", "properties", "propertyNames", "propertyDependencies",
"readOnly", "required", "then", "title", "type", "unevaluatedItems", "unevaluatedProperties",
"uniqueItems", "writeOnly",
]
}
///|
/// Add a vocabulary.
///
/// It is refused when the vocabulary's URI is already held, when one of its
/// keywords is a name a built-in keyword answers to, or when one of them is a
/// name another vocabulary already took. A clash is refused rather than
/// resolved in someone's favour: a keyword that quietly means one thing to the
/// library and another to the document is worse than one that does not load.
pub fn Registry::add(
self : Registry,
vocabulary : &Vocabulary,
) -> Unit raise Refused {
let uri = vocabulary.uri()
guard self.held.get(uri) is None else {
raise Broken(at=uri, why="this vocabulary is already in the registry")
}
let claimed = vocabulary.keywords()
for keyword in claimed {
let name = keyword.name()
match self.names.get(name) {
Some(_) =>
raise Broken(
at=name,
why="a keyword of that name is already answered for",
)
None => ()
}
self.names[name] = uri
}
self.held[uri] = vocabulary
}