Skip to main content

jsonschema_explain/
schema.rs

1use jsonschema_schema::{Schema, SchemaValue, ref_name};
2
3use crate::fmt::{Fmt, format_type};
4
5/// Resolve a `$ref` within the same schema document.
6///
7/// Handles both local refs (`#/…`) and absolute URLs with fragments
8/// (`https://…#/…`) by extracting the fragment and navigating the root.
9pub fn resolve_ref<'a>(sv: &'a SchemaValue, root: &'a SchemaValue) -> &'a SchemaValue {
10    let Some(schema) = sv.as_schema() else {
11        return sv;
12    };
13    let Some(ref ref_str) = schema.ref_ else {
14        return sv;
15    };
16    let fragment = if let Some(path) = ref_str.strip_prefix('#') {
17        path
18    } else if let Some(pos) = ref_str.find('#') {
19        &ref_str[pos + 1..]
20    } else {
21        return sv;
22    };
23    if let Ok(resolved) = jsonschema_schema::navigate_pointer(root, root, fragment) {
24        return resolved;
25    }
26    sv
27}
28
29/// Walk a JSON Pointer path through a schema, resolving `$ref` at each step.
30///
31/// # Errors
32///
33/// Returns an error if a segment in the pointer cannot be resolved.
34pub fn navigate_pointer<'a>(
35    schema: &'a SchemaValue,
36    root: &'a SchemaValue,
37    pointer: &str,
38) -> Result<&'a SchemaValue, String> {
39    jsonschema_schema::navigate_pointer(schema, root, pointer)
40}
41
42/// Extract the `required` array from a schema as a list of strings.
43pub(crate) fn required_set(schema: &Schema) -> Vec<String> {
44    schema.required_set().to_vec()
45}
46
47/// Produce a short human-readable type string for a schema.
48pub(crate) fn schema_type_str(schema: &Schema) -> Option<String> {
49    schema.type_str()
50}
51
52/// Get the best description text from a schema, preferring `markdownDescription`.
53pub(crate) fn get_description(schema: &Schema) -> Option<&str> {
54    schema.description()
55}
56
57/// Produce a one-line summary of a variant schema for `oneOf`/`anyOf`/`allOf` listings.
58pub(crate) fn variant_summary(variant: &SchemaValue, root: &SchemaValue, f: &Fmt<'_>) -> String {
59    let resolved_sv = resolve_ref(variant, root);
60    let Some(resolved) = resolved_sv.as_schema() else {
61        return format!("{}(schema){}", f.dim, f.reset);
62    };
63
64    let dep = if resolved.is_deprecated() {
65        format!(" {}[DEPRECATED]{}", f.dim, f.reset)
66    } else {
67        String::new()
68    };
69
70    // Title first — best label for any variant.
71    if let Some(title) = resolved.title.as_deref() {
72        let ty = schema_type_str(resolved).unwrap_or_default();
73        if ty.is_empty() {
74            return format!("{}{title}{}{dep}", f.bold, f.reset);
75        }
76        return format!(
77            "{}{title}{}{dep} ({})",
78            f.bold,
79            f.reset,
80            format_type(&ty, f)
81        );
82    }
83
84    // $ref variants without a title: show the ref name — DEFINITIONS has details.
85    if let Some(schema) = variant.as_schema()
86        && let Some(ref r) = schema.ref_
87    {
88        if r.starts_with("#/") {
89            return format!("{}{}{}{dep}", f.cyan, ref_name(r), f.reset);
90        }
91        return format!("{}(see: {r}){}{dep}", f.dim, f.reset);
92    }
93
94    if let Some(desc) = get_description(resolved) {
95        let first_line = first_sentence(desc);
96        let ty = schema_type_str(resolved).unwrap_or_default();
97        let rendered = if f.is_color() {
98            markdown_to_ansi::render_inline(first_line, &f.md_opts(None))
99        } else {
100            first_line.to_string()
101        };
102        if ty.is_empty() {
103            return format!("{rendered}{dep}");
104        }
105        return format!("{} - {rendered}{dep}", format_type(&ty, f));
106    }
107
108    if let Some(ty) = schema_type_str(resolved) {
109        return format!("{}{dep}", format_type(&ty, f));
110    }
111
112    // Pattern-only variant (e.g. `{"pattern": "^..."}`)
113    if let Some(ref pat) = resolved.pattern {
114        return format!("pattern: {}{pat}{}{dep}", f.magenta, f.reset);
115    }
116
117    format!("{}(schema){}{dep}", f.dim, f.reset)
118}
119
120/// Extract the first sentence or line from a description for one-line summaries.
121fn first_sentence(desc: &str) -> &str {
122    // Use the first line break (paragraph boundary) if present.
123    let trimmed = desc.trim();
124    if let Some(pos) = trimmed.find("\n\n") {
125        let first = trimmed[..pos].trim();
126        if !first.is_empty() {
127            return first;
128        }
129    }
130    if let Some(pos) = trimmed.find('\n') {
131        let first = trimmed[..pos].trim();
132        if !first.is_empty() {
133            return first;
134        }
135    }
136    trimmed
137}
138
139#[cfg(test)]
140#[allow(clippy::unwrap_used)]
141mod tests {
142    use super::*;
143    use serde_json::json;
144
145    /// Parse with migration so tests work with older JSON Schema drafts.
146    fn sv(val: serde_json::Value) -> SchemaValue {
147        SchemaValue::Schema(Box::new(jsonschema_migrate::migrate(val).unwrap()))
148    }
149
150    // --- navigate_pointer ---
151
152    #[test]
153    fn navigate_empty_pointer_returns_schema() {
154        let schema = sv(json!({"type": "object"}));
155        let result = navigate_pointer(&schema, &schema, "").unwrap();
156        assert!(result.as_schema().is_some());
157    }
158
159    #[test]
160    fn navigate_root_slash_returns_schema() {
161        let schema = sv(json!({"type": "object"}));
162        let result = navigate_pointer(&schema, &schema, "/").unwrap();
163        assert!(result.as_schema().is_some());
164    }
165
166    #[test]
167    fn navigate_single_segment() {
168        let schema = sv(json!({
169            "properties": {
170                "name": { "type": "string" }
171            }
172        }));
173        let result = navigate_pointer(&schema, &schema, "/properties/name").unwrap();
174        let s = result.as_schema().unwrap();
175        assert!(s.type_str().as_deref() == Some("string"));
176    }
177
178    #[test]
179    fn navigate_nested_segments() {
180        let schema = sv(json!({
181            "properties": {
182                "name": { "type": "string", "description": "The name" }
183            }
184        }));
185        let result = navigate_pointer(&schema, &schema, "/properties/name").unwrap();
186        let s = result.as_schema().unwrap();
187        assert_eq!(s.description.as_deref(), Some("The name"));
188    }
189
190    #[test]
191    fn navigate_resolves_ref_at_each_step() {
192        let schema = sv(json!({
193            "properties": {
194                "item": { "$ref": "#/$defs/Item" }
195            },
196            "$defs": {
197                "Item": {
198                    "type": "object",
199                    "description": "An item"
200                }
201            }
202        }));
203        let result = navigate_pointer(&schema, &schema, "/properties/item").unwrap();
204        let s = result.as_schema().unwrap();
205        assert_eq!(s.description.as_deref(), Some("An item"));
206    }
207
208    #[test]
209    fn navigate_through_ref_then_deeper() {
210        let schema = sv(json!({
211            "properties": {
212                "config": { "$ref": "#/$defs/Config" }
213            },
214            "$defs": {
215                "Config": {
216                    "type": "object",
217                    "properties": {
218                        "debug": { "type": "boolean" }
219                    }
220                }
221            }
222        }));
223        let result =
224            navigate_pointer(&schema, &schema, "/properties/config/properties/debug").unwrap();
225        let s = result.as_schema().unwrap();
226        assert!(s.type_str().as_deref() == Some("boolean"));
227    }
228
229    #[test]
230    fn navigate_array_index() {
231        let schema = sv(json!({
232            "oneOf": [
233                { "type": "string" },
234                { "type": "integer" }
235            ]
236        }));
237        let result = navigate_pointer(&schema, &schema, "/oneOf/1").unwrap();
238        let s = result.as_schema().unwrap();
239        assert!(s.type_str().as_deref() == Some("integer"));
240    }
241
242    #[test]
243    fn navigate_missing_segment_errors() {
244        let schema = sv(json!({"type": "object"}));
245        let err = navigate_pointer(&schema, &schema, "/nonexistent").unwrap_err();
246        assert!(err.contains("nonexistent"), "error was: {err}");
247    }
248
249    #[test]
250    fn navigate_defs_directly() {
251        let schema = sv(json!({
252            "$defs": {
253                "Foo": { "type": "string" }
254            }
255        }));
256        let result = navigate_pointer(&schema, &schema, "/$defs/Foo").unwrap();
257        let s = result.as_schema().unwrap();
258        assert!(s.type_str().as_deref() == Some("string"));
259    }
260
261    // --- resolve_ref ---
262
263    #[test]
264    fn resolve_ref_no_ref_returns_self() {
265        let schema = sv(json!({"type": "string"}));
266        let result = resolve_ref(&schema, &schema);
267        assert!(result.as_schema().is_some());
268    }
269
270    #[test]
271    fn resolve_ref_follows_local_ref() {
272        let root = sv(json!({
273            "$defs": {
274                "Name": { "type": "string" }
275            }
276        }));
277        let schema = sv(json!({"$ref": "#/$defs/Name"}));
278        let resolved = resolve_ref(&schema, &root);
279        let s = resolved.as_schema().unwrap();
280        assert!(s.type_str().as_deref() == Some("string"));
281    }
282
283    #[test]
284    fn resolve_ref_missing_target_returns_self() {
285        let root = sv(json!({"$defs": {}}));
286        let schema = sv(json!({"$ref": "#/$defs/Missing"}));
287        let resolved = resolve_ref(&schema, &root);
288        let s = resolved.as_schema().unwrap();
289        assert!(s.ref_.is_some());
290    }
291
292    #[test]
293    fn resolve_ref_external_ref_returns_self() {
294        let root = sv(json!({}));
295        let schema = sv(json!({"$ref": "https://example.com/schema.json"}));
296        let resolved = resolve_ref(&schema, &root);
297        let s = resolved.as_schema().unwrap();
298        assert!(s.ref_.is_some());
299    }
300}