ferrox_models/grammar/json_schema/error.rs
1//! Every way this converter refuses a schema, naming what is missing.
2//!
3//! llama.cpp collects strings into `_errors` and throws one
4//! `std::invalid_argument` at the end. Worse, several keywords it does not
5//! implement are not errors at all: they fall through the `visit` chain
6//! and vanish. A `pattern` it cannot compile, a `minLength` on a schema
7//! with no explicit `"type": "string"`, `minItems` beside a tuple
8//! `items` -- each of those produces a grammar that *permits documents the
9//! schema forbids*, which is a wrong answer wearing the costume of a
10//! working one.
11//!
12//! Per `CLAUDE.md` ("return `Result` and name what is missing"), this port
13//! refuses instead. [`SchemaError::UnsupportedKeyword`] is raised for any
14//! keyword the branch that was chosen does not consume, so silence is not
15//! a reachable outcome.
16
17use crate::grammar::GrammarError;
18use std::fmt;
19
20/// What the JSON-schema converter refused, and why.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum SchemaError {
23 /// The schema text was not valid JSON.
24 NotJson(String),
25 /// A schema, or a subschema, was not a JSON object. JSON Schema's
26 /// boolean form (`true` / `false` as a whole schema) is not ported.
27 NotAnObject { at: String, kind: &'static str },
28 /// A keyword is present that the chosen branch does not act on.
29 /// Ignoring it would widen the grammar past what the schema allows.
30 UnsupportedKeyword {
31 keyword: String,
32 at: String,
33 why: &'static str,
34 },
35 /// A `pattern` this port cannot compile to GBNF. llama.cpp's
36 /// `_visit_pattern` handles a subset of ECMA-262; the cases named here
37 /// are the ones it mishandles rather than the ones it skips.
38 UnsupportedPattern { pattern: String, why: String },
39 /// `"format"` names something outside the six llama.cpp special-cases
40 /// (`date`, `time`, `date-time`, `uuid`, `uuid1`..`uuid5`).
41 UnsupportedFormat { format: String, at: String },
42 /// `"type"` is not a string, or names no JSON type.
43 UnknownType { at: String, found: String },
44 /// A `$ref` this port will not follow: a remote URL, or anything that
45 /// is not a `#/`-rooted JSON pointer into the same document.
46 UnsupportedRef { reference: String },
47 /// A `#/`-rooted `$ref` whose pointer does not land on anything.
48 RefNotFound { reference: String, token: String },
49 /// Two schemas compiled into ONE grammar ([`super::GrammarBuilder`])
50 /// spell the same `$ref` pointer and mean different subschemas. The
51 /// rule name is derived from the pointer, so honouring the first
52 /// would compile the second against the wrong definition.
53 RefCollision { pointer: String },
54 /// A keyword's value has the wrong JSON type for what it means.
55 BadValue {
56 keyword: String,
57 at: String,
58 why: String,
59 },
60 /// The top-level schema did not end up owning the rule named `root`,
61 /// so the grammar's entry point would be some subschema's rule. This
62 /// is reachable upstream (a property named `""` takes `root` first)
63 /// and silently produces a grammar for the wrong document.
64 RootDisplaced { got: String },
65 /// The converter emitted a grammar the engine in this repo cannot
66 /// parse. That is a bug in this module, never in the caller's schema.
67 Emitted(GrammarError),
68 /// An invariant of this module was violated -- a builtin table lookup
69 /// that cannot fail did. A bug here, not in the caller's schema.
70 Internal(&'static str),
71}
72
73impl fmt::Display for SchemaError {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 match self {
76 SchemaError::NotJson(msg) => write!(f, "JSON schema is not valid JSON: {msg}"),
77 SchemaError::NotAnObject { at, kind } => write!(
78 f,
79 "JSON schema at {at} is a {kind}, not an object; boolean schemas are not supported"
80 ),
81 SchemaError::UnsupportedKeyword { keyword, at, why } => write!(
82 f,
83 "JSON schema keyword {keyword:?} at {at} is not supported ({why}); it would be \
84 ignored, and the grammar would then accept documents the schema rejects"
85 ),
86 SchemaError::UnsupportedPattern { pattern, why } => write!(
87 f,
88 "JSON schema \"pattern\" {pattern:?} cannot be compiled to a grammar: {why}"
89 ),
90 SchemaError::UnsupportedFormat { format, at } => write!(
91 f,
92 "JSON schema format {format:?} at {at} is not supported; only \"date\", \"time\", \
93 \"date-time\" and \"uuid\" (or \"uuid1\"..\"uuid5\") have grammars"
94 ),
95 SchemaError::UnknownType { at, found } => {
96 write!(
97 f,
98 "JSON schema \"type\" at {at} is {found}, which names no JSON type"
99 )
100 }
101 SchemaError::UnsupportedRef { reference } => write!(
102 f,
103 "JSON schema $ref {reference:?} is not supported; only same-document refs of the \
104 form \"#/...\" are resolved, and nothing is fetched over the network"
105 ),
106 SchemaError::RefNotFound { reference, token } => write!(
107 f,
108 "JSON schema $ref {reference:?} does not resolve: {token:?} is not in the document"
109 ),
110 SchemaError::RefCollision { pointer } => write!(
111 f,
112 "two schemas compiled into one grammar both define {pointer:?} and disagree about \
113 what it is; rename one of them"
114 ),
115 SchemaError::BadValue { keyword, at, why } => {
116 write!(
117 f,
118 "JSON schema keyword {keyword:?} at {at} is invalid: {why}"
119 )
120 }
121 SchemaError::RootDisplaced { got } => write!(
122 f,
123 "the top-level schema compiled to rule {got:?} rather than \"root\", so the \
124 grammar would start from a subschema; rename the colliding property"
125 ),
126 SchemaError::Internal(what) => {
127 write!(f, "internal error in the JSON schema converter: {what}")
128 }
129 SchemaError::Emitted(e) => write!(
130 f,
131 "the grammar generated from this schema does not parse, which is a bug in the \
132 converter: {e}"
133 ),
134 }
135 }
136}
137
138impl std::error::Error for SchemaError {}
139
140impl From<GrammarError> for SchemaError {
141 fn from(e: GrammarError) -> Self {
142 SchemaError::Emitted(e)
143 }
144}
145
146/// The name of a subschema as it appears in an error, with the empty
147/// top-level name spelled out.
148pub(super) fn at(name: &str) -> String {
149 if name.is_empty() {
150 "the top level".to_string()
151 } else {
152 format!("{name:?}")
153 }
154}