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
15pub(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
37pub 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
103fn 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
123fn 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 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}