From 74a17d858455765f5edc9d7f796f208fa7187abb Mon Sep 17 00:00:00 2001 From: Ian Macalinao Date: Wed, 25 Feb 2026 07:31:06 +0800 Subject: [PATCH] Improve explain output for anyOf/oneOf variants and deprecated schemas - Expand inline anyOf/oneOf variant properties for non-$ref schemas - Show $ref variants as one-line references (details in DEFINITIONS) - Add [DEPRECATED] tag to properties, variants, and definitions - Sort deprecated properties and definitions to the end of their lists - Truncate variant descriptions to first sentence for clean one-liners - Prioritize title over $ref name over description in variant summaries --- crates/jsonschema-explain/src/lib.rs | 22 ++++- crates/jsonschema-explain/src/render.rs | 105 ++++++++++++++++++++++-- crates/jsonschema-explain/src/schema.rs | 66 +++++++++++---- 3 files changed, 169 insertions(+), 24 deletions(-) diff --git a/crates/jsonschema-explain/src/lib.rs b/crates/jsonschema-explain/src/lib.rs index d6c96d8..158fcdc 100644 --- a/crates/jsonschema-explain/src/lib.rs +++ b/crates/jsonschema-explain/src/lib.rs @@ -178,10 +178,28 @@ fn render_definitions_section(out: &mut String, schema: &Value, f: &Fmt<'_>) { && !defs.is_empty() { write_section(out, "DEFINITIONS", f); - for (def_name, def_schema) in defs { + // Sort deprecated definitions to the end. + let mut sorted_defs: Vec<_> = defs.iter().collect(); + sorted_defs.sort_by_key(|(_, s)| { + i32::from( + s.get("deprecated") + .and_then(Value::as_bool) + .unwrap_or(false), + ) + }); + for (def_name, def_schema) in sorted_defs { let ty = schema_type_str(def_schema).unwrap_or_default(); let suffix = format_type_suffix(&ty, f); - let _ = writeln!(out, " {}{def_name}{}{suffix}", f.green, f.reset); + let is_deprecated = def_schema + .get("deprecated") + .and_then(Value::as_bool) + .unwrap_or(false); + let dep_tag = if is_deprecated { + format!(" {}[DEPRECATED]{}", f.dim, f.reset) + } else { + String::new() + }; + let _ = writeln!(out, " {}{def_name}{}{dep_tag}{suffix}", f.green, f.reset); if let Some(desc) = get_description(def_schema) { write_description(out, desc, f, " "); } diff --git a/crates/jsonschema-explain/src/render.rs b/crates/jsonschema-explain/src/render.rs index b1cc3b8..2ed4e77 100644 --- a/crates/jsonschema-explain/src/render.rs +++ b/crates/jsonschema-explain/src/render.rs @@ -41,13 +41,15 @@ pub(crate) fn render_variant_block( .is_some_and(|p| !p.is_empty()); let desc = get_description(resolved); + let dep_tag = deprecated_tag(resolved, f); + if has_properties || desc.is_some() { // Expanded block let ty = schema_type_str(resolved).unwrap_or_default(); let suffix = format_type_suffix(&ty, f); let _ = writeln!( out, - " {}({index}){} {}{label}{}{suffix}", + " {}({index}){} {}{label}{}{dep_tag}{suffix}", f.dim, f.reset, f.green, f.reset ); if let Some(desc) = desc { @@ -77,24 +79,41 @@ pub(crate) fn render_properties( let indent = " ".repeat(depth); let desc_indent = format!("{indent} "); - // Sort required fields first, preserving relative order within each group. + // Sort: required first, then normal, then deprecated — preserving + // relative order within each group. let mut sorted_props: Vec<_> = props.iter().collect(); - sorted_props.sort_by_key(|(name, _)| i32::from(!required.contains(name))); + sorted_props.sort_by_key(|(name, schema)| { + let deprecated = resolve_ref(schema, root) + .get("deprecated") + .and_then(Value::as_bool) + .unwrap_or(false); + // 0 = required, 1 = normal, 2 = deprecated + i32::from(deprecated) * 2 + i32::from(!required.contains(name)) + }); for (prop_name, prop_schema) in sorted_props { let prop_schema = resolve_ref(prop_schema, root); let ty = schema_type_str(prop_schema).unwrap_or_default(); let is_required = required.contains(prop_name); + let is_deprecated = prop_schema + .get("deprecated") + .and_then(Value::as_bool) + .unwrap_or(false); let type_display = format_type(&ty, f); let req_tag = if is_required { format!(", {}*required{}", f.red, f.reset) } else { String::new() }; + let deprecated_tag = if is_deprecated { + format!(" {}[DEPRECATED]{}", f.dim, f.reset) + } else { + String::new() + }; let _ = writeln!( out, - "{indent}{}{prop_name}{} ({type_display}{req_tag})", + "{indent}{}{prop_name}{}{deprecated_tag} ({type_display}{req_tag})", f.green, f.reset ); @@ -173,9 +192,9 @@ fn render_property_details( _ => keyword, }; let _ = writeln!(out, "{desc_indent}{}{label}:{}", f.dim, f.reset); - for variant in variants { - let summary = variant_summary(variant, root, f); - let _ = writeln!(out, "{desc_indent} - {summary}"); + for (i, variant) in variants.iter().enumerate() { + let resolved = resolve_ref(variant, root); + render_inline_variant(out, resolved, variant, root, f, depth, desc_indent, i + 1); } } } @@ -189,6 +208,78 @@ fn render_property_details( } } +/// Render a variant inline within a property's composition list. +/// +/// `$ref` variants are always shown as one-line references (the DEFINITIONS +/// section has the full details). Non-ref variants with properties are +/// expanded inline when depth allows. +#[allow(clippy::too_many_arguments)] +fn render_inline_variant( + out: &mut String, + resolved: &Value, + original: &Value, + root: &Value, + f: &Fmt<'_>, + depth: usize, + desc_indent: &str, + index: usize, +) { + let is_ref = original.get("$ref").is_some(); + let has_properties = resolved + .get("properties") + .and_then(Value::as_object) + .is_some_and(|p| !p.is_empty()); + + // $ref variants are kept as one-line references; non-ref variants with + // properties are expanded when depth allows. + if !is_ref && has_properties && depth < MAX_DEPTH { + let deprecated_tag = deprecated_tag(resolved, f); + let (label, label_is_type) = + if let Some(title) = resolved.get("title").and_then(Value::as_str) { + (title.to_string(), false) + } else if let Some(ty) = schema_type_str(resolved) { + (ty, true) + } else { + (format!("variant {index}"), false) + }; + let suffix = if label_is_type { + String::new() + } else { + let ty = schema_type_str(resolved).unwrap_or_default(); + format_type_suffix(&ty, f) + }; + let _ = writeln!( + out, + "{desc_indent} {}({index}){} {}{label}{}{deprecated_tag}{suffix}", + f.dim, f.reset, f.green, f.reset + ); + if let Some(desc) = get_description(resolved) { + let nested_indent = format!("{desc_indent} "); + write_description(out, desc, f, &nested_indent); + } + if let Some(props) = resolved.get("properties").and_then(Value::as_object) { + let req = required_set(resolved); + render_properties(out, props, &req, root, f, depth + 2); + } + } else { + let summary = variant_summary(original, root, f); + let _ = writeln!(out, "{desc_indent} - {summary}"); + } +} + +/// Return a `" [DEPRECATED]"` tag if the schema has `"deprecated": true`. +fn deprecated_tag(schema: &Value, f: &Fmt<'_>) -> String { + if schema + .get("deprecated") + .and_then(Value::as_bool) + .unwrap_or(false) + { + format!(" {}[DEPRECATED]{}", f.dim, f.reset) + } else { + String::new() + } +} + /// Render JSON Schema validation constraints (numeric bounds, string length, /// pattern, array items, format, etc.) as a compact annotation line. fn render_constraints(out: &mut String, schema: &Value, f: &Fmt<'_>, indent: &str) { diff --git a/crates/jsonschema-explain/src/schema.rs b/crates/jsonschema-explain/src/schema.rs index e2595c1..040f1ec 100644 --- a/crates/jsonschema-explain/src/schema.rs +++ b/crates/jsonschema-explain/src/schema.rs @@ -161,40 +161,76 @@ pub(crate) fn get_description(schema: &Value) -> Option<&str> { /// Produce a one-line summary of a variant schema for `oneOf`/`anyOf`/`allOf` listings. pub(crate) fn variant_summary(variant: &Value, root: &Value, f: &Fmt<'_>) -> String { let resolved = resolve_ref(variant, root); + let dep = if resolved + .get("deprecated") + .and_then(Value::as_bool) + .unwrap_or(false) + { + format!(" {}[DEPRECATED]{}", f.dim, f.reset) + } else { + String::new() + }; + // Title first — best label for any variant. if let Some(title) = resolved.get("title").and_then(Value::as_str) { let ty = schema_type_str(resolved).unwrap_or_default(); if ty.is_empty() { - return format!("{}{title}{}", f.bold, f.reset); + return format!("{}{title}{}{dep}", f.bold, f.reset); } - return format!("{}{title}{} ({})", f.bold, f.reset, format_type(&ty, f)); + return format!( + "{}{title}{}{dep} ({})", + f.bold, + f.reset, + format_type(&ty, f) + ); + } + + // $ref variants without a title: show the ref name — DEFINITIONS has details. + if let Some(r) = variant.get("$ref").and_then(Value::as_str) { + if r.starts_with("#/") { + return format!("{}{}{}{dep}", f.cyan, ref_name(r), f.reset); + } + return format!("{}(see: {r}){}{dep}", f.dim, f.reset); } if let Some(desc) = get_description(resolved) { + let first_line = first_sentence(desc); let ty = schema_type_str(resolved).unwrap_or_default(); let rendered = if f.is_color() { - markdown_to_ansi::render_inline(desc, &f.md_opts(None)) + markdown_to_ansi::render_inline(first_line, &f.md_opts(None)) } else { - desc.to_string() + first_line.to_string() }; if ty.is_empty() { - return rendered; - } - return format!("{} - {rendered}", format_type(&ty, f)); - } - - if let Some(r) = variant.get("$ref").and_then(Value::as_str) { - if r.starts_with("#/") { - return format!("{}{}{}", f.cyan, ref_name(r), f.reset); + return format!("{rendered}{dep}"); } - return format!("{}(see: {r}){}", f.dim, f.reset); + return format!("{} - {rendered}{dep}", format_type(&ty, f)); } if let Some(ty) = schema_type_str(resolved) { - return format_type(&ty, f); + return format!("{}{dep}", format_type(&ty, f)); } - format!("{}(schema){}", f.dim, f.reset) + format!("{}(schema){}{dep}", f.dim, f.reset) +} + +/// Extract the first sentence or line from a description for one-line summaries. +fn first_sentence(desc: &str) -> &str { + // Use the first line break (paragraph boundary) if present. + let trimmed = desc.trim(); + if let Some(pos) = trimmed.find("\n\n") { + let first = trimmed[..pos].trim(); + if !first.is_empty() { + return first; + } + } + if let Some(pos) = trimmed.find('\n') { + let first = trimmed[..pos].trim(); + if !first.is_empty() { + return first; + } + } + trimmed } #[cfg(test)]