// A bridge from the JSON Schema subset that agent tool declarations already
// use into the library's skeleton (`Schema`).
//
// The library is not a JSON Schema validator and does not aim to become one
// (`schema.mbt` explains why). What this file adds is narrower and more
// useful: the shape of a tool's `inputSchema` — object with typed properties,
// a `required` list, array items and the handful of value constraints the
// skeleton already understands — can be read directly, so an integration does
// not have to hand-translate the declaration it already has.
//
// The contract is deliberately strict:
//
// * a supported keyword is mapped;
// * an annotation (`title`, `description`, `default`, `examples`, `$schema`,
// `$id`, `deprecated`, `readOnly`, `writeOnly`, `format`) is ignored;
// * `additionalProperties: true` is a no-op, `false` is refused because the
// skeleton cannot enforce "no other members";
// * anything else (`$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `minItems`,
// `uniqueItems`, `multipleOf`, …) is an `E070`, never a silent ignore.
//
// A gate that silently drops half of a declaration is worse than one that
// refuses it: the caller would believe a guarantee that was never checked.
///|
/// Fields of a JSON Schema node that carry no validation meaning here.
fn json_schema_annotation(key : String) -> Bool {
key == "title" ||
key == "description" ||
key == "default" ||
key == "examples" ||
key == "$schema" ||
key == "$id" ||
key == "$comment" ||
key == "deprecated" ||
key == "readOnly" ||
key == "writeOnly" ||
key == "format" ||
key == "contentEncoding" ||
key == "contentMediaType"
}
///|
/// Refuse a keyword the skeleton cannot represent.
fn json_schema_unsupported(key : String) -> Unit raise MendError {
raise SchemaError(
"json schema: unsupported keyword `" +
key +
"`; supported: type, properties, required, items, enum, const, minimum, maximum, exclusiveMinimum, exclusiveMaximum, minLength, maxLength, pattern, additionalProperties",
)
}
///|
/// A numeric field, accepting an integer or a finite double.
fn json_schema_number(v : Json, key : String) -> Double raise MendError {
match v {
Json::Number(d, ..) =>
if d.is_nan() || d.is_inf() {
raise SchemaError("json schema: `" + key + "` must be finite")
} else {
d
}
_ => raise SchemaError("json schema: `" + key + "` must be a number")
}
}
///|
/// A non-negative integer field.
fn json_schema_uint(v : Json, key : String) -> Int32 raise MendError {
let d = json_schema_number(v, key)
let n = d.to_int()
if n.to_double() != d || n < 0 {
raise SchemaError(
"json schema: `" + key + "` must be a non-negative integer",
)
}
n
}
///|
/// A string field.
fn json_schema_string(v : Json, key : String) -> String raise MendError {
match v {
Json::String(s) => s
_ => raise SchemaError("json schema: `" + key + "` must be a string")
}
}
///|
/// `required` as a lookup table.
fn json_schema_required(v : Json) -> Map[String, Bool] raise MendError {
let out : Map[String, Bool] = Map([])
match v {
Json::Array(items) =>
for item in items {
out[json_schema_string(item, "required")] = true
}
_ =>
raise SchemaError("json schema: `required` must be an array of strings")
}
out
}
///|
/// A `pattern` translated into the skeleton's character class plus the length
/// bounds its quantifier implies.
priv struct PatternSpec {
class_text : String
min_len : Int32?
max_len : Int32?
}
///|
/// Parse a decimal integer used in a `{n,m}` quantifier.
fn json_schema_quantifier_int(s : StringView) -> Int32 raise MendError {
if s.length() == 0 {
raise SchemaError("json schema: empty quantifier bound")
}
let mut n = 0
for i = 0; i < s.length(); i = i + 1 {
let c = s.at(i).to_int()
if c < 0x30 || c > 0x39 {
raise SchemaError(
"json schema: quantifier bounds must be decimal integers",
)
}
n = n * 10 + (c - 0x30)
}
n
}
///|
/// Translate a JSON Schema `pattern` only when it is an anchored character
/// class, and turn its quantifier into length bounds.
///
/// JSON Schema's `pattern` is a regular expression and is *not* anchored: the
/// validator only requires a match somewhere in the string. The skeleton's
/// `pattern` requires every character to be in the class. Mapping an
/// unanchored expression onto it would be stricter (safe) but would silently
/// mean something else, so only anchored forms are accepted. The quantifier
/// then has an exact translation:
///
/// | pattern | skeleton |
/// | --- | --- |
/// | `^[a-z]*$` | `string(pattern:[a-z])` |
/// | `^[a-z]+$` | `string(minLength:1,pattern:[a-z])` |
/// | `^[a-z]?$` | `string(maxLength:1,pattern:[a-z])` |
/// | `^[a-z]$` | `string(minLength:1,maxLength:1,pattern:[a-z])` |
/// | `^[A-Z]{2,4}$` | `string(minLength:2,maxLength:4,pattern:[A-Z])` |
///
/// Everything else — `.`, `*` inside the class, escapes, alternation, groups —
/// is an `E070`.
fn json_schema_pattern(s : String) -> PatternSpec raise MendError {
let n = s.length()
if n < 4 || s.at(0).to_int() != 0x5E || s.at(n - 1).to_int() != 0x24 {
raise SchemaError(
"json schema: `pattern` must be an anchored character class such as `^[a-z0-9_]*$`; unanchored or general regular expressions are not supported",
)
}
// Drop the anchors.
let body = s[1:n - 1].to_owned()
if body.length() < 2 || body.at(0).to_int() != 0x5B {
raise SchemaError("json schema: `pattern` must be a single character class")
}
// Find the closing bracket. Escapes are not supported, so the first `]`
// closes the class.
let mut close = 1
while close < body.length() && body.at(close).to_int() != 0x5D {
close = close + 1
}
if close >= body.length() {
raise SchemaError("json schema: `pattern` has an unterminated class")
}
let inner = body[1:close].to_owned()
if inner.contains("\\") || inner.contains("[") {
raise SchemaError(
"json schema: `pattern` character classes may not contain escapes or nested classes",
)
}
let rest = body[close + 1:].to_owned()
let (min_len, max_len) : (Int32?, Int32?) = if rest == "" {
(Some(1), Some(1))
} else if rest == "*" {
(Some(0), None)
} else if rest == "+" {
(Some(1), None)
} else if rest == "?" {
(Some(0), Some(1))
} else if rest.length() >= 2 &&
rest.at(0).to_int() == 0x7B &&
rest.at(rest.length() - 1).to_int() == 0x7D {
let spec = rest[1:rest.length() - 1].to_owned()
let parts = spec.split(",").to_array()
if parts.length() == 1 {
let k = json_schema_quantifier_int(parts[0])
(Some(k), Some(k))
} else if parts.length() == 2 {
let lo = json_schema_quantifier_int(parts[0])
if parts[1].length() == 0 {
(Some(lo), None)
} else {
let hi = json_schema_quantifier_int(parts[1])
if hi < lo {
raise SchemaError(
"json schema: quantifier upper bound is below its lower bound",
)
}
(Some(lo), Some(hi))
}
} else {
raise SchemaError("json schema: malformed quantifier")
}
} else {
raise SchemaError(
"json schema: unsupported `pattern` quantifier `" + rest + "`",
)
}
{ class_text: inner, min_len, max_len, }
}
///|
/// Build the constraint record of one leaf, or `None` when the node declared
/// no value constraint.
fn json_schema_constraints(
obj : Map[String, Json],
kind : Schema,
) -> Constraints? raise MendError {
let c = Constraints::none()
let mut any = false
let mut pattern_spec : PatternSpec? = None
for key, value in obj {
if key == "enum" {
match value {
Json::Array(items) => {
c.enum_values = Some(items.copy())
any = true
}
_ => raise SchemaError("json schema: `enum` must be an array")
}
} else if key == "const" {
c.enum_values = Some([value])
any = true
} else if key == "minimum" {
c.minimum = Some(json_schema_number(value, key))
any = true
} else if key == "exclusiveMinimum" {
c.exclusive_minimum = Some(json_schema_number(value, key))
any = true
} else if key == "maximum" {
c.maximum = Some(json_schema_number(value, key))
any = true
} else if key == "exclusiveMaximum" {
c.exclusive_maximum = Some(json_schema_number(value, key))
any = true
} else if key == "minLength" {
c.min_length = Some(json_schema_uint(value, key))
any = true
} else if key == "maxLength" {
c.max_length = Some(json_schema_uint(value, key))
any = true
} else if key == "pattern" {
let spec = json_schema_pattern(json_schema_string(value, key))
c.pattern = Some(spec.class_text)
pattern_spec = Some(spec)
any = true
}
}
match pattern_spec {
Some(spec) => {
// The quantifier is a length bound. Combine it with an explicit
// `minLength`/`maxLength` by intersection; an empty intersection means
// the declaration can never match, which is an E070 rather than a gate
// that rejects everything at runtime.
let lo = match (c.min_length, spec.min_len) {
(Some(a), Some(b)) => Some(if a > b { a } else { b })
(Some(a), None) => Some(a)
(None, b) => b
}
let hi = match (c.max_length, spec.max_len) {
(Some(a), Some(b)) => Some(if a < b { a } else { b })
(Some(a), None) => Some(a)
(None, b) => b
}
match (lo, hi) {
(Some(a), Some(b)) =>
if a > b {
raise SchemaError(
"json schema: `pattern` quantifier and length bounds cannot be satisfied together",
)
}
_ => ()
}
c.min_length = match lo {
Some(0) => None
_ => lo
}
c.max_length = hi
}
None => ()
}
if !any {
return None
}
// The kind determines which constraints make sense. Refusing a mismatched
// constraint here is the same policy as `Schema::parse`: a declaration that
// can never fire is an `E070`, not a silently weaker gate.
let has_number_constraint = c.minimum is Some(_) ||
c.maximum is Some(_) ||
c.exclusive_minimum is Some(_) ||
c.exclusive_maximum is Some(_)
let has_string_constraint = c.min_length is Some(_) ||
c.max_length is Some(_) ||
c.pattern is Some(_)
match kind {
Int | Num =>
if has_string_constraint {
raise SchemaError(
"json schema: length/pattern constraints apply to strings, not numbers",
)
}
Str =>
if has_number_constraint {
raise SchemaError(
"json schema: numeric constraints apply to numbers, not strings",
)
}
_ =>
if has_number_constraint || has_string_constraint {
raise SchemaError(
"json schema: value constraints are not supported on this kind",
)
}
}
Some(c)
}
///|
/// Translate one JSON Schema node into a skeleton.
fn json_schema_node(v : Json) -> Schema raise MendError {
match v {
Json::Object(obj) => {
// Walk every keyword first so an unsupported one is named even if another
// field is malformed.
for key, value in obj {
if json_schema_annotation(key) ||
key == "type" ||
key == "properties" ||
key == "required" ||
key == "items" ||
key == "enum" ||
key == "const" ||
key == "minimum" ||
key == "maximum" ||
key == "exclusiveMinimum" ||
key == "exclusiveMaximum" ||
key == "minLength" ||
key == "maxLength" ||
key == "pattern" {
continue
}
if key == "additionalProperties" {
// `true` (the JSON Schema default) adds nothing the skeleton can
// check; `false` is a constraint it cannot enforce.
match value {
Json::True => continue
Json::False =>
raise SchemaError(
"json schema: `additionalProperties: false` is not supported; the skeleton does not close objects",
)
_ =>
raise SchemaError(
"json schema: `additionalProperties` must be a boolean",
)
}
}
json_schema_unsupported(key)
}
let mut kind_name = ""
match obj.get("type") {
Some(Json::String(s)) => kind_name = s
Some(Json::Array(items)) => {
// `["string","null"]`-style unions are common but the skeleton has
// no union. A one-element array is harmless; anything else is not.
if items.length() != 1 {
raise SchemaError(
"json schema: `type` arrays are not supported; use a single type",
)
}
kind_name = json_schema_string(items[0], "type")
}
Some(_) => raise SchemaError("json schema: `type` must be a string")
None =>
// No `type` but an object shape: treat it as an object, which is
// what every tool declaration means.
if obj.contains("properties") || obj.contains("required") {
kind_name = "object"
}
}
let base : Schema = if kind_name == "" || kind_name == "any" {
Any
} else if kind_name == "null" {
Null
} else if kind_name == "boolean" {
Bool
} else if kind_name == "number" {
Num
} else if kind_name == "integer" {
Int
} else if kind_name == "string" {
Str
} else if kind_name == "array" {
let item = match obj.get("items") {
Some(s) => json_schema_node(s)
None => Any
}
Arr(item)
} else if kind_name == "object" {
let required = match obj.get("required") {
Some(r) => json_schema_required(r)
None => Map([])
}
let members : Array[SchemaMember] = []
let seen : Map[String, Bool] = Map([])
match obj.get("properties") {
Some(Json::Object(props)) =>
for key, sub in props {
members.push({
key,
required: required.get(key).unwrap_or(false),
schema: json_schema_node(sub),
})
seen[key] = true
}
Some(_) =>
raise SchemaError("json schema: `properties` must be an object")
None => ()
}
// A required member may be declared without a `properties` entry; in
// JSON Schema that means "present, any value".
for key, _ in required {
if !seen.get(key).unwrap_or(false) {
members.push({ key, required: true, schema: Any, })
}
}
Obj(members)
} else {
raise SchemaError("json schema: unknown type `" + kind_name + "`")
}
match json_schema_constraints(obj, base) {
Some(c) => Constrained(base, c)
None => base
}
}
_ =>
raise SchemaError(
"json schema: a schema node must be an object with a `type`, `properties`, `items`, `enum` or `const`",
)
}
}
///|
/// Translate a JSON Schema subset into the library's skeleton.
///
/// The supported subset is the part a tool declaration actually uses:
///
/// ```
/// { "type": "object",
/// "properties": { "query": { "type": "string" },
/// "limit": { "type": "integer", "minimum": 1 } },
/// "required": ["query"] }
/// ```
///
/// is the same skeleton as `{!query:string,?limit:integer(minimum:1)}`.
/// Unsupported keywords raise `E070`; annotations are ignored.
pub fn Schema::of_json_schema(value : Json) -> Schema raise MendError {
json_schema_node(value)
}
///|
/// Parse JSON Schema text and translate it.
pub fn Schema::parse_json_schema(text : String) -> Schema raise MendError {
let value = @json.parse(text) catch {
_ => raise SchemaError("json schema: input is not valid JSON")
}
json_schema_node(value)
}