jsonschema_schema/schema/mod.rs
1use alloc::collections::BTreeMap;
2
3use combine_structs::combine_fields;
4use indexmap::IndexMap;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::{Number, Value};
8use url::Url;
9
10use crate::extensions::IntellijSchemaExt;
11use crate::extensions::LintelSchemaExt;
12use crate::extensions::TaploInfoSchemaExt;
13use crate::extensions::TaploSchemaExt;
14use crate::extensions::TombiSchemaExt;
15
16mod add;
17mod navigate;
18pub mod vocabularies;
19
20#[cfg(test)]
21#[allow(clippy::unwrap_used)]
22mod tests;
23
24pub use navigate::{navigate_pointer, ref_name, resolve_ref};
25
26/// Helper for `#[serde(skip_serializing_if)]` on `bool` fields.
27#[allow(clippy::trivially_copy_pass_by_ref)] // serde skip_serializing_if requires &T
28pub(crate) fn is_false(v: &bool) -> bool {
29 !v
30}
31
32/// A JSON Schema value — either a boolean schema or an object schema.
33///
34/// A schema can be a JSON object or a JSON boolean. Boolean schemas are
35/// equivalent to certain object schemas:
36///
37/// - `true` — always validates successfully (equivalent to `{}`).
38/// - `false` — never validates successfully (equivalent to `{"not": {}}`).
39///
40/// The `Other` variant catches values that are neither booleans nor valid
41/// schema objects (e.g. bare strings injected by buggy generators). It
42/// is treated identically to `Bool(false)` by [`as_schema`](Self::as_schema).
43///
44/// See [JSON Schema Core §4.3.2](https://json-schema.org/draft/2020-12/json-schema-core#section-4.3.2).
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
46#[serde(untagged)]
47pub enum SchemaValue {
48 /// A boolean schema: `true` accepts everything, `false` rejects everything.
49 Bool(bool),
50 /// An object schema with keyword-based constraints.
51 Schema(Box<Schema>),
52 /// Catch-all for invalid schema values (strings, numbers, etc.).
53 Other(Value),
54}
55
56/// Primitive type names defined by JSON Schema (`simpleTypes`).
57///
58/// String values MUST be one of the six primitive types (`"null"`,
59/// `"boolean"`, `"object"`, `"array"`, `"number"`, or `"string"`), or
60/// `"integer"` which matches any number with a zero fractional part.
61///
62/// See [JSON Schema Validation §6.1.1](https://json-schema.org/draft/2020-12/json-schema-validation#section-6.1.1).
63#[derive(
64 Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, strum::Display,
65)]
66#[serde(rename_all = "camelCase")]
67#[strum(serialize_all = "lowercase")]
68pub enum SimpleType {
69 /// A JSON array (ordered sequence of values).
70 Array,
71 /// A JSON `true` or `false` value.
72 Boolean,
73 /// A JSON number with a zero fractional part (subset of `Number`).
74 Integer,
75 /// The JSON `null` value.
76 Null,
77 /// A JSON number (any numeric value, including integers).
78 Number,
79 /// A JSON object (unordered set of name/value pairs).
80 Object,
81 /// A JSON string.
82 String,
83}
84
85/// The value of the JSON Schema `type` keyword.
86///
87/// The value of this keyword MUST be either a string or an array. If it is
88/// an array, elements of the array MUST be strings and MUST be unique.
89///
90/// See [JSON Schema Validation §6.1.1](https://json-schema.org/draft/2020-12/json-schema-validation#section-6.1.1).
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92#[serde(untagged)]
93pub enum TypeValue {
94 /// A single type constraint, e.g. `"type": "string"`.
95 Single(SimpleType),
96 /// A union of types, e.g. `"type": ["string", "null"]`.
97 /// The array SHOULD have at least one element, and elements MUST be unique.
98 Union(Vec<SimpleType>),
99}
100
101// ---------------------------------------------------------------------------
102// Schema struct — generated by merging all vocabulary fields
103// ---------------------------------------------------------------------------
104
105/// A JSON Schema object (draft 2020-12).
106///
107/// Represents a single schema resource as defined by the
108/// [JSON Schema Core](https://json-schema.org/draft/2020-12/json-schema-core) and
109/// [JSON Schema Validation](https://json-schema.org/draft/2020-12/json-schema-validation)
110/// specifications.
111///
112/// Fields are grouped by vocabulary:
113///
114/// - **Core** (`$schema`, `$id`, `$ref`, `$anchor`, `$dynamicRef`,
115/// `$dynamicAnchor`, `$comment`, `$defs`, `$vocabulary`)
116/// - **Metadata / Annotation** (`title`, `description`, `default`,
117/// `deprecated`, `readOnly`, `writeOnly`, `examples`)
118/// - **Validation — type** (`type`, `enum`, `const`)
119/// - **Applicator — object** (`properties`, `patternProperties`,
120/// `additionalProperties`, `propertyNames`, `unevaluatedProperties`)
121/// - **Validation — object** (`required`, `minProperties`,
122/// `maxProperties`, `dependentRequired`)
123/// - **Applicator — array** (`items`, `prefixItems`, `contains`,
124/// `unevaluatedItems`)
125/// - **Validation — array** (`minItems`, `maxItems`, `uniqueItems`,
126/// `minContains`, `maxContains`)
127/// - **Validation — number** (`minimum`, `maximum`, `exclusiveMinimum`,
128/// `exclusiveMaximum`, `multipleOf`)
129/// - **Validation — string** (`minLength`, `maxLength`, `pattern`, `format`)
130/// - **Applicator — composition** (`allOf`, `anyOf`, `oneOf`, `not`)
131/// - **Applicator — conditional** (`if`, `then`, `else`,
132/// `dependentSchemas`)
133/// - **Content** (`contentMediaType`, `contentEncoding`, `contentSchema`)
134#[combine_fields(
135 CoreVocabulary,
136 ApplicatorVocabulary,
137 UnevaluatedVocabulary,
138 ValidationVocabulary,
139 MetaDataVocabulary,
140 FormatAnnotationVocabulary,
141 ContentVocabulary
142)]
143#[allow(clippy::struct_excessive_bools)] // mirrors the JSON Schema spec
144#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
145pub struct Schema {
146 /// The `markdownDescription` keyword — Markdown-formatted
147 /// description (VS Code / non-standard extension).
148 ///
149 /// Not part of the JSON Schema specification. When present, it is
150 /// preferred over [`description`](Self::description) by editors
151 /// that support Markdown rendering.
152 #[serde(
153 rename = "markdownDescription",
154 skip_serializing_if = "Option::is_none"
155 )]
156 pub markdown_description: Option<String>,
157
158 /// Per-enum-value Markdown descriptions (VS Code / non-standard extension).
159 #[serde(
160 rename = "markdownEnumDescriptions",
161 skip_serializing_if = "Option::is_none"
162 )]
163 pub markdown_enum_descriptions: Option<Vec<Option<String>>>,
164
165 /// Lintel provenance metadata (`x-lintel`).
166 #[serde(rename = "x-lintel", skip_serializing_if = "Option::is_none")]
167 pub x_lintel: Option<LintelSchemaExt>,
168
169 /// Taplo TOML toolkit extension (`x-taplo`).
170 #[serde(rename = "x-taplo", skip_serializing_if = "Option::is_none")]
171 pub x_taplo: Option<TaploSchemaExt>,
172 /// Taplo informational metadata (`x-taplo-info`).
173 #[serde(rename = "x-taplo-info", skip_serializing_if = "Option::is_none")]
174 pub x_taplo_info: Option<TaploInfoSchemaExt>,
175 /// Tombi TOML extensions (`x-tombi-*`).
176 #[serde(flatten)]
177 pub x_tombi: TombiSchemaExt,
178 /// `IntelliJ` IDEA extensions (`x-intellij-*`).
179 #[serde(flatten)]
180 pub x_intellij: IntellijSchemaExt,
181
182 /// Unknown or unsupported properties.
183 ///
184 /// Any JSON property that is not recognized as a standard keyword
185 /// or known extension is captured here, preserving round-trip
186 /// fidelity.
187 #[serde(flatten)]
188 pub extra: BTreeMap<String, Value>,
189}
190
191// ---------------------------------------------------------------------------
192// Impl blocks
193// ---------------------------------------------------------------------------
194
195impl SchemaValue {
196 /// Get the inner `Schema` if this is an object schema, `None` for bool
197 /// schemas and invalid (`Other`) values.
198 pub fn as_schema(&self) -> Option<&Schema> {
199 match self {
200 Self::Schema(s) => Some(s),
201 Self::Bool(_) | Self::Other(_) => None,
202 }
203 }
204}
205
206impl Schema {
207 /// Parse from a `serde_json::Value` without migration.
208 ///
209 /// # Errors
210 ///
211 /// Returns an error if the value cannot be deserialized into a `Schema`.
212 pub fn from_value(value: Value) -> Result<Self, serde_json::Error> {
213 serde_json::from_value(value)
214 }
215
216 /// Get the best description text, preferring `markdownDescription`.
217 pub fn description(&self) -> Option<&str> {
218 self.markdown_description
219 .as_deref()
220 .or(self.description.as_deref())
221 }
222
223 /// Get the required fields, or an empty slice.
224 pub fn required_set(&self) -> &[String] {
225 self.required.as_deref().unwrap_or_default()
226 }
227
228 /// Whether this schema is deprecated.
229 pub fn is_deprecated(&self) -> bool {
230 self.deprecated
231 }
232
233 /// Produce a short human-readable type string.
234 pub fn type_str(&self) -> Option<String> {
235 schema_type_str(self)
236 }
237
238 /// Validate structural integrity of this schema.
239 ///
240 /// Recursively walks the schema tree and checks that all local `$ref`
241 /// pointers (starting with `#/`) resolve to valid targets.
242 pub fn validate(&self) -> Vec<crate::validate::SchemaError> {
243 crate::validate::validate(self)
244 }
245
246 /// Rewrite all local `$ref` pointers (`#/…`) to absolute URLs using the
247 /// schema's `$id` as base. Returns the schema unchanged if `$id` is absent.
248 #[must_use]
249 pub fn absolute(&self) -> Schema {
250 crate::absolute::make_absolute(self)
251 }
252
253 /// Flatten composition keywords (currently `allOf`) into a single merged schema.
254 ///
255 /// Properties from `allOf` entries are merged into the root, and unreferenced
256 /// `$defs` entries are pruned. The `allOf` array is preserved so provenance
257 /// remains visible.
258 #[must_use]
259 pub fn flatten(&self, root: &SchemaValue) -> Schema {
260 crate::flatten::flatten_all_of(self, root)
261 }
262
263 /// Look up a schema-keyword field by its JSON key name.
264 ///
265 /// Returns a reference to the `SchemaValue` stored under that keyword,
266 /// or `None` if the field is absent.
267 pub fn get_keyword(&self, key: &str) -> Option<&SchemaValue> {
268 match key {
269 "items" => self.items.as_deref(),
270 "contains" => self.contains.as_deref(),
271 "additionalProperties" => self.additional_properties.as_deref(),
272 "propertyNames" => self.property_names.as_deref(),
273 "unevaluatedProperties" => self.unevaluated_properties.as_deref(),
274 "unevaluatedItems" => self.unevaluated_items.as_deref(),
275 "not" => self.not.as_deref(),
276 "if" => self.if_.as_deref(),
277 "then" => self.then_.as_deref(),
278 "else" => self.else_.as_deref(),
279 "contentSchema" => self.content_schema.as_deref(),
280 _ => None,
281 }
282 }
283
284 /// Look up a named child within a keyword that holds a map of schemas.
285 ///
286 /// For example, `get_map_entry("properties", "name")` returns the schema
287 /// for the `name` property.
288 pub fn get_map_entry(&self, keyword: &str, key: &str) -> Option<&SchemaValue> {
289 match keyword {
290 "properties" => self.properties.get(key),
291 "patternProperties" => self.pattern_properties.get(key),
292 "$defs" => self.defs.as_ref()?.get(key),
293 "dependentSchemas" => self.dependent_schemas.get(key),
294 _ => None,
295 }
296 }
297
298 /// Look up an indexed child within a keyword that holds an array of schemas.
299 pub fn get_array_entry(&self, keyword: &str, index: usize) -> Option<&SchemaValue> {
300 match keyword {
301 "allOf" => self.all_of.as_ref()?.get(index),
302 "anyOf" => self.any_of.as_ref()?.get(index),
303 "oneOf" => self.one_of.as_ref()?.get(index),
304 "prefixItems" => self.prefix_items.as_ref()?.get(index),
305 _ => None,
306 }
307 }
308
309 /// Look up a child by a JSON pointer segment name.
310 /// This handles both map keywords (where the segment is a key within the map)
311 /// and direct keywords.
312 fn get_map_entry_by_pointer_segment(&self, segment: &str) -> Option<&SchemaValue> {
313 // Try all map-bearing keyword fields.
314 // For pointer navigation, when we're inside a "properties" object,
315 // the segment is the property name.
316 self.properties
317 .get(segment)
318 .or_else(|| self.pattern_properties.get(segment))
319 .or_else(|| self.defs.as_ref().and_then(|m| m.get(segment)))
320 .or_else(|| self.dependent_schemas.get(segment))
321 }
322}
323
324/// Produce a short human-readable type string for a schema.
325fn schema_type_str(schema: &Schema) -> Option<String> {
326 // Explicit type field
327 if let Some(ref ty) = schema.type_ {
328 return match ty {
329 TypeValue::Single(s) if *s == SimpleType::Array => {
330 let item_ty = schema
331 .items
332 .as_ref()
333 .and_then(|sv| sv.as_schema())
334 .and_then(schema_type_str);
335 match item_ty {
336 Some(item_ty) => Some(format!("{item_ty}[]")),
337 None => Some("array".to_string()),
338 }
339 }
340 TypeValue::Single(s) => Some(s.to_string()),
341 TypeValue::Union(arr) => Some(
342 arr.iter()
343 .map(SimpleType::to_string)
344 .collect::<Vec<_>>()
345 .join(" | "),
346 ),
347 };
348 }
349
350 // $ref
351 if let Some(ref r) = schema.ref_ {
352 return Some(ref_name(r).to_string());
353 }
354
355 // oneOf/anyOf
356 for variants in [&schema.one_of, &schema.any_of].into_iter().flatten() {
357 let mut types: Vec<String> = variants
358 .iter()
359 .filter_map(|v| match v {
360 SchemaValue::Schema(s) => {
361 schema_type_str(s).or_else(|| s.ref_.as_ref().map(|r| ref_name(r).to_string()))
362 }
363 SchemaValue::Bool(_) | SchemaValue::Other(_) => None,
364 })
365 .collect();
366 types.dedup();
367 if !types.is_empty() {
368 return Some(types.join(" | "));
369 }
370 }
371
372 // const
373 if let Some(ref c) = schema.const_ {
374 return Some(format!("const: {c}"));
375 }
376
377 // enum — single-value enums show the value (e.g. `"lf"`), multi-value show `enum`
378 if let Some(ref values) = schema.enum_ {
379 if values.len() == 1 {
380 let val = &values[0];
381 return Some(
382 val.as_str()
383 .map_or_else(|| val.to_string(), |s| format!("\"{s}\"")),
384 );
385 }
386 return Some("enum".to_string());
387 }
388
389 None
390}