Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions crates/jsonschema-explain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, " ");
}
Expand Down
105 changes: 98 additions & 7 deletions crates/jsonschema-explain/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
);

Expand Down Expand Up @@ -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);
}
}
}
Expand All @@ -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) {
Expand Down
66 changes: 51 additions & 15 deletions crates/jsonschema-explain/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down