Skip to main content

jsonschema_explain/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod fmt;
4mod render;
5mod schema;
6
7use core::fmt::Write;
8
9use serde_json::Value;
10
11use fmt::{COMPOSITION_KEYWORDS, Fmt, format_header, format_type, format_type_suffix};
12use render::{render_properties, render_subschema, render_variant_block};
13use schema::{get_description, required_set, resolve_ref, schema_type_str};
14
15/// Write a multi-line description to the output buffer.
16///
17/// When color is enabled, markdown is rendered to ANSI with syntax-highlighted
18/// code blocks sized to fit within the terminal minus the indent.
19/// When color is off, raw markdown text is written with indentation.
20pub(crate) fn write_description(out: &mut String, text: &str, f: &Fmt<'_>, indent: &str) {
21    let rendered = if f.is_color() {
22        let term_width = terminal_size::terminal_size().map_or(80, |(w, _)| w.0 as usize);
23        let available = term_width.saturating_sub(indent.len());
24        markdown_to_ansi::render(text, &f.md_opts(Some(available)))
25    } else {
26        text.to_string()
27    };
28    for line in rendered.split('\n') {
29        if line.trim().is_empty() {
30            out.push('\n');
31        } else {
32            let _ = writeln!(out, "{indent}{line}");
33        }
34    }
35}
36
37/// Render a JSON Schema as human-readable terminal documentation.
38///
39/// `schema` is a parsed JSON Schema value. `name` is a display name
40/// (e.g. from a catalog entry). When `color` is true, ANSI escape
41/// codes are used for formatting. When `syntax_highlight` is true,
42/// fenced code blocks in descriptions are syntax-highlighted.
43pub fn explain(schema: &Value, name: &str, color: bool, syntax_highlight: bool) -> String {
44    let mut out = String::new();
45    let mut f = if color { Fmt::color() } else { Fmt::plain() };
46    f.syntax_highlight = syntax_highlight;
47
48    let upper = name.to_uppercase();
49    let header = format_header(&upper, "JSON Schema");
50    let _ = writeln!(out, "{}{header}{}\n", f.bold, f.reset);
51
52    let title = schema.get("title").and_then(Value::as_str).unwrap_or(name);
53    let description = get_description(schema);
54
55    let _ = writeln!(out, "{}NAME{}", f.yellow, f.reset);
56    if let Some(desc) = description {
57        let inline_desc = if f.is_color() {
58            markdown_to_ansi::render_inline(desc, &f.md_opts(None))
59        } else {
60            desc.to_string()
61        };
62        let _ = writeln!(out, "    {}{title}{} - {inline_desc}", f.bold, f.reset);
63    } else {
64        let _ = writeln!(out, "    {}{title}{}", f.bold, f.reset);
65    }
66    out.push('\n');
67
68    if let Some(desc) = description
69        && schema.get("title").and_then(Value::as_str).is_some()
70    {
71        let _ = writeln!(out, "{}DESCRIPTION{}", f.yellow, f.reset);
72        write_description(&mut out, desc, &f, "    ");
73        out.push('\n');
74    }
75
76    if let Some(ty) = schema_type_str(schema) {
77        let _ = writeln!(out, "{}TYPE{}", f.yellow, f.reset);
78        let _ = writeln!(out, "    {}", format_type(&ty, &f));
79        out.push('\n');
80    }
81
82    let required = required_set(schema);
83    if let Some(props) = schema.get("properties").and_then(Value::as_object) {
84        let _ = writeln!(out, "{}PROPERTIES{}", f.yellow, f.reset);
85        render_properties(&mut out, props, &required, schema, &f, 1);
86        out.push('\n');
87    }
88
89    if schema_type_str(schema).as_deref() == Some("array")
90        && let Some(items) = schema.get("items")
91    {
92        let _ = writeln!(out, "{}ITEMS{}", f.yellow, f.reset);
93        render_subschema(&mut out, items, schema, &f, 1);
94        out.push('\n');
95    }
96
97    render_variants_section(&mut out, schema, &f);
98    render_definitions_section(&mut out, schema, &f);
99
100    out
101}
102
103/// Render `oneOf`/`anyOf`/`allOf` variant sections.
104fn render_variants_section(out: &mut String, schema: &Value, f: &Fmt<'_>) {
105    for keyword in COMPOSITION_KEYWORDS {
106        if let Some(variants) = schema.get(*keyword).and_then(Value::as_array) {
107            let label = match *keyword {
108                "oneOf" => "ONE OF",
109                "anyOf" => "ANY OF",
110                "allOf" => "ALL OF",
111                _ => keyword,
112            };
113            let _ = writeln!(out, "{}{label}{}", f.yellow, f.reset);
114            for (i, variant) in variants.iter().enumerate() {
115                let resolved = resolve_ref(variant, schema);
116                render_variant_block(out, resolved, variant, schema, f, i + 1);
117            }
118            out.push('\n');
119        }
120    }
121}
122
123/// Render the DEFINITIONS section (`$defs`/`definitions`).
124fn render_definitions_section(out: &mut String, schema: &Value, f: &Fmt<'_>) {
125    for defs_key in &["$defs", "definitions"] {
126        if let Some(defs) = schema.get(*defs_key).and_then(Value::as_object)
127            && !defs.is_empty()
128        {
129            let _ = writeln!(out, "{}DEFINITIONS{}", f.yellow, f.reset);
130            for (def_name, def_schema) in defs {
131                let ty = schema_type_str(def_schema).unwrap_or_default();
132                let suffix = format_type_suffix(&ty, f);
133                let _ = writeln!(out, "    {}{def_name}{}{suffix}", f.green, f.reset);
134                if let Some(desc) = get_description(def_schema) {
135                    write_description(out, desc, f, "        ");
136                }
137                if let Some(props) = def_schema.get("properties").and_then(Value::as_object) {
138                    let req = required_set(def_schema);
139                    render_properties(out, props, &req, schema, f, 2);
140                }
141                out.push('\n');
142            }
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::fmt::{BLUE, BOLD, CYAN, GREEN, RESET};
151    use serde_json::json;
152
153    #[test]
154    fn simple_object_schema() {
155        let schema = json!({
156            "title": "Test",
157            "description": "A test schema",
158            "type": "object",
159            "properties": {
160                "name": {
161                    "type": "string",
162                    "description": "The name field"
163                },
164                "age": {
165                    "type": "integer",
166                    "description": "The age field"
167                }
168            }
169        });
170
171        let output = explain(&schema, "test", false, false);
172        assert!(output.contains("NAME"));
173        assert!(output.contains("Test - A test schema"));
174        assert!(output.contains("PROPERTIES"));
175        assert!(output.contains("name (string)"));
176        assert!(output.contains("The name field"));
177        assert!(output.contains("age (integer)"));
178    }
179
180    #[test]
181    fn nested_object_renders_with_indentation() {
182        let schema = json!({
183            "type": "object",
184            "properties": {
185                "config": {
186                    "type": "object",
187                    "description": "Configuration block",
188                    "properties": {
189                        "debug": {
190                            "type": "boolean",
191                            "description": "Enable debug mode"
192                        }
193                    }
194                }
195            }
196        });
197
198        let output = explain(&schema, "nested", false, false);
199        assert!(output.contains("config (object)"));
200        assert!(output.contains("debug (boolean)"));
201        assert!(output.contains("Enable debug mode"));
202    }
203
204    #[test]
205    fn enum_values_listed() {
206        let schema = json!({
207            "type": "object",
208            "properties": {
209                "level": {
210                    "type": "string",
211                    "enum": ["low", "medium", "high"]
212                }
213            }
214        });
215
216        let output = explain(&schema, "enum-test", false, false);
217        assert!(output.contains("Values: low, medium, high"));
218    }
219
220    #[test]
221    fn required_properties_marked() {
222        let schema = json!({
223            "type": "object",
224            "required": ["name"],
225            "properties": {
226                "name": {
227                    "type": "string"
228                },
229                "optional": {
230                    "type": "string"
231                }
232            }
233        });
234
235        let output = explain(&schema, "required-test", false, false);
236        assert!(output.contains("name (string, *required)"));
237        assert!(output.contains("optional (string)"));
238        assert!(!output.contains("optional (string, *required)"));
239
240        // Required fields should appear before optional fields
241        let name_pos = output
242            .find("name (string")
243            .expect("name field should be present");
244        let optional_pos = output
245            .find("optional (string")
246            .expect("optional field should be present");
247        assert!(
248            name_pos < optional_pos,
249            "required field 'name' should appear before optional field"
250        );
251    }
252
253    #[test]
254    fn schema_with_no_properties_handled() {
255        let schema = json!({
256            "type": "string",
257            "description": "A plain string type"
258        });
259
260        let output = explain(&schema, "simple", false, false);
261        assert!(output.contains("NAME"));
262        assert!(output.contains("A plain string type"));
263        assert!(!output.contains("PROPERTIES"));
264    }
265
266    #[test]
267    fn color_output_contains_ansi() {
268        let schema = json!({
269            "title": "Colored",
270            "type": "object",
271            "properties": {
272                "x": { "type": "string" }
273            }
274        });
275
276        let colored = explain(&schema, "colored", true, true);
277        let plain = explain(&schema, "colored", false, false);
278
279        assert!(colored.contains(BOLD));
280        assert!(colored.contains(RESET));
281        assert!(colored.contains(CYAN));
282        assert!(colored.contains(GREEN));
283        assert!(!plain.contains(BOLD));
284        assert!(!plain.contains(RESET));
285    }
286
287    #[test]
288    fn default_value_shown() {
289        let schema = json!({
290            "type": "object",
291            "properties": {
292                "port": {
293                    "type": "integer",
294                    "default": 8080
295                }
296            }
297        });
298
299        let output = explain(&schema, "defaults", false, false);
300        assert!(output.contains("Default: 8080"));
301    }
302
303    #[test]
304    fn ref_resolution() {
305        let schema = json!({
306            "type": "object",
307            "properties": {
308                "item": { "$ref": "#/$defs/Item" }
309            },
310            "$defs": {
311                "Item": {
312                    "type": "object",
313                    "description": "An item definition"
314                }
315            }
316        });
317
318        let output = explain(&schema, "ref-test", false, false);
319        assert!(output.contains("item (object)"));
320        assert!(output.contains("An item definition"));
321    }
322
323    #[test]
324    fn any_of_variants_listed() {
325        let schema = json!({
326            "anyOf": [
327                { "type": "string", "description": "A string value" },
328                { "type": "integer", "description": "An integer value" }
329            ]
330        });
331
332        let output = explain(&schema, "union", false, false);
333        assert!(output.contains("ANY OF"));
334        assert!(output.contains("A string value"));
335        assert!(output.contains("An integer value"));
336    }
337
338    #[test]
339    fn format_header_centers() {
340        let h = format_header("TEST", "JSON Schema");
341        assert!(h.starts_with("TEST"));
342        assert!(h.ends_with("TEST"));
343        assert!(h.contains("JSON Schema"));
344    }
345
346    #[test]
347    fn inline_backtick_colorization() {
348        let f = Fmt::color();
349        let result = markdown_to_ansi::render_inline("Use `foo` and `bar`", &f.md_opts(None));
350        assert!(result.contains(BLUE));
351        assert!(result.contains("foo"));
352        assert!(result.contains("bar"));
353        assert!(!result.contains('`'));
354    }
355
356    #[test]
357    fn inline_bold_rendering() {
358        let f = Fmt::color();
359        let result =
360            markdown_to_ansi::render_inline("This is **important** text", &f.md_opts(None));
361        assert!(result.contains(BOLD));
362        assert!(result.contains("important"));
363        assert!(!result.contains("**"));
364    }
365
366    #[test]
367    fn inline_markdown_link() {
368        let f = Fmt::color();
369        let result = markdown_to_ansi::render_inline(
370            "See [docs](https://example.com) here",
371            &f.md_opts(None),
372        );
373        assert!(result.contains("docs"));
374        assert!(result.contains("https://example.com"));
375        assert!(result.contains("\x1b]8;;"));
376    }
377
378    #[test]
379    fn inline_raw_url() {
380        let f = Fmt::color();
381        let result =
382            markdown_to_ansi::render_inline("See more: https://example.com/foo", &f.md_opts(None));
383        assert!(result.contains("https://example.com/foo"));
384    }
385
386    #[test]
387    fn type_formatting_union() {
388        let f = Fmt::plain();
389        let result = format_type("object | null", &f);
390        assert!(result.contains("object"));
391        assert!(result.contains("null"));
392        assert!(result.contains('|'));
393    }
394
395    #[test]
396    fn definitions_not_truncated() {
397        let schema = json!({
398            "definitions": {
399                "myDef": {
400                    "type": "object",
401                    "description": "This is a very long description that should not be truncated at all because we want to show the full text to users who are reading the documentation"
402                }
403            }
404        });
405
406        let output = explain(&schema, "test", false, false);
407        assert!(output.contains("reading the documentation"));
408        assert!(!output.contains("..."));
409    }
410
411    #[test]
412    fn allof_refs_expanded() {
413        let schema = json!({
414            "allOf": [
415                { "$ref": "#/definitions/base" }
416            ],
417            "definitions": {
418                "base": {
419                    "type": "object",
420                    "description": "Base configuration",
421                    "properties": {
422                        "name": {
423                            "type": "string",
424                            "description": "The name"
425                        }
426                    }
427                }
428            }
429        });
430
431        let output = explain(&schema, "test", false, false);
432        assert!(output.contains("ALL OF"));
433        assert!(output.contains("base"));
434        assert!(output.contains("Base configuration"));
435        assert!(output.contains("name (string)"));
436    }
437
438    #[test]
439    fn prefers_markdown_description() {
440        let schema = json!({
441            "type": "object",
442            "properties": {
443                "target": {
444                    "type": "string",
445                    "description": "Plain description",
446                    "markdownDescription": "Rich **markdown** description"
447                }
448            }
449        });
450
451        let output = explain(&schema, "test", false, false);
452        assert!(output.contains("Rich **markdown** description"));
453        assert!(!output.contains("Plain description"));
454    }
455
456    #[test]
457    fn no_premature_wrapping() {
458        let schema = json!({
459            "type": "object",
460            "properties": {
461                "x": {
462                    "type": "string",
463                    "description": "This is a very long description that should not be wrapped at 72 characters because we want the pager to handle wrapping at the terminal width instead"
464                }
465            }
466        });
467
468        let output = explain(&schema, "test", false, false);
469        let desc_line = output
470            .lines()
471            .find(|l| l.contains("This is a very long"))
472            .expect("description line should be present");
473        assert!(desc_line.contains("terminal width instead"));
474    }
475}