///|
/// Compile a schema once. Unknown extension keywords are annotations; known
/// malformed keywords fail compilation. Local static `$ref` targets compile
/// into an indexed arena; unevaluated and cross-resource semantics stay
/// explicitly refused until their evaluation semantics are implemented.
pub fn Schema::compile(
document : Json,
max_depth? : Int = 128,
) -> Schema raise CompileError {
if max_depth < 1 {
raise CompileError::DepthLimit(max_depth)
}
check_schema_json(document, "", 0, max_depth)
let index = index_document(document, max_depth)
ignore(compile_into(index, document, "", 0, max_depth))
{ plan: { nodes: index.nodes, }, }
}
///|
// Compile one schema position and store its plan in the arena, so `$ref`
// rules can jump to it. The structural walk visits every keyword-owned
// position once; lazily referenced positions compile through the same entry
// point. Raising the visited flag on entry marks the active compile chain,
// which keeps recursive targets finite, and refs stay indexed instead of
// inlined.
fn compile_into(
index : RefIndex,
document : Json,
path : String,
depth : Int,
limit : Int,
) -> Plan raise CompileError {
if index.slots.contains(path) {
index.visited[index.slots[path]] = true
}
let plan = compile_node(index, document, path, depth, limit)
if index.slots.contains(path) {
let slot = index.slots[path]
index.nodes[slot] = plan
index.visited[slot] = true
}
plan
}
///|
// Bound annotation/const/enum traversal too, not just schema-node recursion.
fn check_schema_json(
value : Json,
path : String,
depth : Int,
limit : Int,
) -> Unit raise CompileError {
if depth > limit {
raise CompileError::DepthLimit(limit)
}
match value {
Number(_, ..) =>
if ExactNumber::of_json(value) is None {
invalid_keyword(path, "invalid JSON number or numeric representation")
}
Object(fields) =>
for name, child in fields {
check_schema_json(child, pointer_step(path, name), depth + 1, limit)
}
Array(values) =>
for index, child in values {
check_schema_json(
child,
pointer_step(path, index.to_string()),
depth + 1,
limit,
)
}
_ => ()
}
}
///|
fn invalid_keyword(path : String, reason : String) -> Unit raise CompileError {
raise InvalidKeyword(path~, reason~)
}
///|
fn compile_number(
value : Json,
path : String,
) -> ExactNumber raise CompileError {
match ExactNumber::of_json(value) {
Some(number) => number
None => {
invalid_keyword(path, "expected a finite JSON number")
ExactNumber::from_double(0.0)
}
}
}
///|
fn compile_count(
fields : Map[String, Json],
key : String,
path : String,
) -> Int? raise CompileError {
match fields.get(key) {
None => None
Some(value) => {
let location = pointer_step(path, key)
let number = compile_number(value, location)
let integer = number.is_integer() catch {
_ =>
raise InvalidKeyword(
path=location,
reason="invalid numeric representation",
)
}
let sign = number.sign() catch {
_ =>
raise InvalidKeyword(
path=location,
reason="invalid numeric representation",
)
}
if !integer || sign < 0 {
invalid_keyword(location, "expected a nonnegative integer")
}
let ordering = number.compare(ExactNumber::from_double(2147483647.0)) catch {
_ =>
raise InvalidKeyword(
path=location,
reason="invalid numeric representation",
)
}
if ordering > 0 {
raise UnsupportedKeyword(
path=location,
keyword="count exceeds supported range",
)
}
match value {
Number(d, ..) => Some(d.to_int())
_ => None
}
}
}
}
///|
fn compile_strings(
value : Json,
path : String,
) -> Array[String] raise CompileError {
guard value is Array(values) else {
invalid_keyword(path, "expected an array of unique strings")
return []
}
let strings : Array[String] = []
let seen : Set[String] = Set([])
for item in values {
guard item is String(text) else {
invalid_keyword(path, "expected an array of unique strings")
continue
}
if seen.contains(text) {
invalid_keyword(path, "duplicate string")
}
seen.add(text)
strings.push(text)
}
strings
}
///|
fn compile_type(value : Json, path : String) -> ValueType raise CompileError {
match value {
"null" => NullType
"boolean" => BooleanType
"number" => NumberType
"integer" => IntegerType
"string" => StringType
"array" => ArrayType
"object" => ObjectType
_ => {
invalid_keyword(path, "unknown JSON Schema type")
NullType
}
}
}
///|
fn optional_node(
index : RefIndex,
fields : Map[String, Json],
key : String,
path : String,
depth : Int,
limit : Int,
) -> Plan? raise CompileError {
fields
.get(key)
.map(value => {
compile_into(index, value, pointer_step(path, key), depth + 1, limit)
})
}
///|
fn compile_branches(
index : RefIndex,
value : Json,
path : String,
depth : Int,
limit : Int,
) -> Array[Plan] raise CompileError {
guard value is Array(branches) && !branches.is_empty() else {
invalid_keyword(path, "expected a nonempty array of schemas")
return []
}
branches.mapi((position, schema) => {
compile_into(
index,
schema,
pointer_step(path, position.to_string()),
depth + 1,
limit,
)
})
}
///|
fn compile_pattern_at(
value : Json,
path : String,
) -> PatternSupport raise CompileError {
guard value is String(text) else {
invalid_keyword(path, "expected a pattern string")
return compile_pattern("")
}
let support = compile_pattern(text)
match support {
Unsupported(reason~) =>
raise UnsupportedPattern(path~, pattern=text, reason~)
Supported(_) => support
}
}
///|
fn compile_node(
index : RefIndex,
document : Json,
path : String,
depth : Int,
limit : Int,
) -> Plan raise CompileError {
if depth > limit {
raise CompileError::DepthLimit(limit)
}
match document {
True => return Accept
False => return Reject(path)
Object(_) => ()
_ => {
invalid_keyword(path, "schema must be an object or boolean")
return Accept
}
}
guard document is Object(fields) else { return Accept }
let rules : Array[Rule] = []
for key, value in fields {
let location = pointer_step(path, key)
match key {
"$schema" => {
guard value is String(uri) else {
invalid_keyword(location, "expected a dialect URI")
continue
}
if uri != "https://json-schema.org/draft/2020-12/schema" {
raise UnknownDialect(uri)
}
}
"$ref" => {
guard value is String(reference) else {
invalid_keyword(location, "expected a reference URI string")
continue
}
rules.push(Ref(resolve_ref(index, reference, location, limit)))
}
"$dynamicRef"
| "$id"
| "$anchor"
| "$dynamicAnchor"
| "$vocabulary"
| "unevaluatedProperties"
| "unevaluatedItems" =>
raise UnsupportedKeyword(path=location, keyword=key)
"$defs" => {
guard value is Object(definitions) else {
invalid_keyword(location, "expected a map of schemas")
continue
}
for name, schema in definitions {
ignore(
compile_into(
index,
schema,
pointer_step(location, name),
depth + 1,
limit,
),
)
}
}
"type" => {
let types = match value {
Array(_) => {
let names = compile_strings(value, location)
if names.is_empty() {
invalid_keyword(location, "type array must not be empty")
}
names.map(name => compile_type(Json::string(name), location))
}
_ => [compile_type(value, location)]
}
rules.push(Types(types, location))
}
"const" => rules.push(Constant(owned_json(value), location))
"enum" => {
// 2020-12 only requires an array: entries SHOULD be unique and the
// array MAY be empty, in which case no instance ever matches.
guard value is Array(values) else {
invalid_keyword(location, "expected an array of enum values")
continue
}
rules.push(Enumeration(values.map(owned_json), location))
}
"minimum" | "exclusiveMinimum" =>
rules.push(
Lower(
compile_number(value, location),
key == "exclusiveMinimum",
location,
),
)
"maximum" | "exclusiveMaximum" =>
rules.push(
Upper(
compile_number(value, location),
key == "exclusiveMaximum",
location,
),
)
"multipleOf" => {
let divisor = compile_number(value, location)
let sign = divisor.sign() catch {
_ =>
raise InvalidKeyword(
path=location,
reason="invalid numeric representation",
)
}
if sign <= 0 {
invalid_keyword(location, "multipleOf must be strictly positive")
}
rules.push(Multiple(divisor, location))
}
"pattern" =>
rules.push(Pattern(compile_pattern_at(value, location), location))
"allOf" =>
rules.push(
All(compile_branches(index, value, location, depth, limit), location),
)
"anyOf" =>
rules.push(
Any(compile_branches(index, value, location, depth, limit), location),
)
"oneOf" =>
rules.push(
One(compile_branches(index, value, location, depth, limit), location),
)
"not" =>
rules.push(
Negate(
compile_into(index, value, location, depth + 1, limit),
location,
),
)
"title"
| "description"
| "$comment"
| "format"
| "contentEncoding"
| "contentMediaType" =>
if !(value is String(_)) {
invalid_keyword(location, "expected a string annotation")
}
"deprecated" | "readOnly" | "writeOnly" =>
if value != Json::boolean(true) && value != Json::boolean(false) {
invalid_keyword(location, "expected a boolean annotation")
}
"examples" =>
if !(value is Array(_)) {
invalid_keyword(location, "expected an array annotation")
}
"contentSchema" =>
ignore(compile_into(index, value, location, depth + 1, limit))
_ => ()
}
}
let min_length = compile_count(fields, "minLength", path)
let max_length = compile_count(fields, "maxLength", path)
if min_length is Some(_) || max_length is Some(_) {
rules.push(StringLength(min_length, max_length, path))
}
// Compile conditional arms even when `if` is absent: their shapes must
// still be legal, although their validation effects are then ignored.
let condition = optional_node(index, fields, "if", path, depth, limit)
let consequent = optional_node(index, fields, "then", path, depth, limit)
let alternate = optional_node(index, fields, "else", path, depth, limit)
if condition is Some(predicate) {
rules.push(Conditional(predicate, consequent, alternate))
}
let properties : Map[String, Plan] = Map([])
if fields.get("properties") is Some(value) {
guard value is Object(schemas) else {
invalid_keyword(
pointer_step(path, "properties"),
"expected a map of schemas",
)
return Accept
}
for name, schema in schemas {
properties.set(
name,
compile_into(
index,
schema,
pointer_step(pointer_step(path, "properties"), name),
depth + 1,
limit,
),
)
}
}
let patterns : Array[(PatternSupport, Plan)] = []
if fields.get("patternProperties") is Some(value) {
guard value is Object(schemas) else {
invalid_keyword(
pointer_step(path, "patternProperties"),
"expected a map of schemas",
)
return Accept
}
for expression, schema in schemas {
let location = pointer_step(
pointer_step(path, "patternProperties"),
expression,
)
patterns.push(
(
compile_pattern_at(Json::string(expression), location),
compile_into(index, schema, location, depth + 1, limit),
),
)
}
}
let required = match fields.get("required") {
Some(value) => compile_strings(value, pointer_step(path, "required"))
None => []
}
let dependent_required : Array[(String, Array[String])] = []
if fields.get("dependentRequired") is Some(value) {
guard value is Object(dependencies) else {
invalid_keyword(pointer_step(path, "dependentRequired"), "expected a map")
return Accept
}
for name, names in dependencies {
dependent_required.push(
(
name,
compile_strings(
names,
pointer_step(pointer_step(path, "dependentRequired"), name),
),
),
)
}
}
let dependent_schemas : Array[(String, Plan)] = []
if fields.get("dependentSchemas") is Some(value) {
guard value is Object(dependencies) else {
invalid_keyword(pointer_step(path, "dependentSchemas"), "expected a map")
return Accept
}
for name, schema in dependencies {
dependent_schemas.push(
(
name,
compile_into(
index,
schema,
pointer_step(pointer_step(path, "dependentSchemas"), name),
depth + 1,
limit,
),
),
)
}
}
let object_plan : ObjectPlan = {
path,
properties,
patterns,
required,
dependent_required,
dependent_schemas,
additional: optional_node(
index, fields, "additionalProperties", path, depth, limit,
),
names: optional_node(index, fields, "propertyNames", path, depth, limit),
minimum: compile_count(fields, "minProperties", path),
maximum: compile_count(fields, "maxProperties", path),
}
if [
"properties", "patternProperties", "required", "additionalProperties", "propertyNames",
"minProperties", "maxProperties", "dependentRequired", "dependentSchemas",
].any(key => fields.contains(key)) {
rules.push(Objects(object_plan))
}
let prefix = match fields.get("prefixItems") {
None => []
Some(value) =>
compile_branches(
index,
value,
pointer_step(path, "prefixItems"),
depth,
limit,
)
}
let unique = match fields.get("uniqueItems") {
Some(True) => true
None | Some(False) => false
_ => {
invalid_keyword(pointer_step(path, "uniqueItems"), "expected a boolean")
false
}
}
let array_plan : ArrayPlan = {
path,
prefix,
unique,
items: optional_node(index, fields, "items", path, depth, limit),
contains: optional_node(index, fields, "contains", path, depth, limit),
min_contains: compile_count(fields, "minContains", path).unwrap_or(1),
max_contains: compile_count(fields, "maxContains", path),
minimum: compile_count(fields, "minItems", path),
maximum: compile_count(fields, "maxItems", path),
}
if [
"prefixItems", "items", "contains", "minContains", "maxContains", "minItems",
"maxItems", "uniqueItems",
].any(key => fields.contains(key)) {
rules.push(Arrays(array_plan))
}
Rules(rules)
}