feat(docgen): add wrapper type detection and improved tuple/float display

Implement intelligent detection of "wrapper types" (anyOf patterns with map
variant and inner type) to render them more meaningfully in documentation.

Wrapper Type Detection:
- Detect anyOf patterns like `[{object with additionalProperties: T}, T]`
- Mark detected wrappers with `_wrapper_type` and `_wrapper_inner` metadata
- Display inner type directly instead of showing misleading "enum" badge
- Link wrapper types to their inner type definitions
- Add `_display_name` field for wrapper types with numeric suffixes

Float Display Improvements:
- Display "float" instead of "number" for JSON Schema number types in tuples
- Use `type-float` CSS class for consistent styling
- Homogeneous tuples now show as "float; N" for number arrays

Navigation Enhancements:
- Build navigation from prepared schema (includes wrapper display names)
- Add `display_names` map to template context for type name resolution
- Track type usages for "Usages" section in definitions

Test Coverage:
- Add comprehensive tests for wrapper type rendering
- Test tuple float/integer display formatting
- Verify wrapper types don't show "Enum variants" dropdown
- Test non-wrapper anyOf still correctly shows enum badge
This commit is contained in:
LGUG2Z committed 2025-12-22 09:31:29 -08:00
1 parent c85edf242f
commit 7d4b7857bb
7 files changed
+988 -54

No files matched your search

Generated
+1
View File
@@ -740,6 +740,7 @@ dependencies = [
"clap",
"pretty_assertions",
"pulldown-cmark",
"regex",
"schemars",
"serde",
"serde_json",
+1
View File
@@ -24,3 +24,4 @@ thiserror = "2"
[dev-dependencies]
pretty_assertions = "1.4"
regex = "1"
+479 -2
View File
@@ -36,8 +36,8 @@ impl Generator {
// Prepare the schema for template rendering
let prepared_schema = prepare_for_template(schema.as_value());
// Build navigation data
let navigation = build_navigation(schema.as_value());
// Build navigation data from the prepared schema (includes _display_name for wrapper types)
let navigation = build_navigation(&prepared_schema);
// Build the template context
let context = self.build_context(&prepared_schema, &navigation, options)?;
@@ -93,6 +93,9 @@ impl Generator {
// Type usages for "Usages" section in type definitions
context.insert("usages", &navigation.usages);
// Display names for wrapper types (type_name -> display_name)
context.insert("display_names", &navigation.display_names);
// Build required set for the root schema
let required_set: Vec<String> = schema
.get("required")
@@ -1201,4 +1204,478 @@ mod tests {
);
assert!(html.contains("Max length:"), "Should render maxLength");
}
// Test: Homogeneous tuple with floats should display "float" instead of "number"
#[test]
fn test_tuple_float_display() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"properties": {
"point": {
"type": "array",
"prefixItems": [
{ "type": "number" },
{ "type": "number" }
]
}
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// Should show "float" instead of "number" for tuple display
assert!(
html.contains("float; 2"),
"Should display 'float; 2' for number tuples"
);
assert!(
!html.contains("number; 2"),
"Should NOT display 'number; 2' for float tuples"
);
assert!(
html.contains("type-float"),
"Should use type-float CSS class"
);
}
// Test: Homogeneous tuple with integers should still display "integer"
#[test]
fn test_tuple_integer_display_unchanged() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"properties": {
"coords": {
"type": "array",
"prefixItems": [
{ "type": "integer" },
{ "type": "integer" },
{ "type": "integer" }
]
}
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// Should still show "integer" for integer tuples
assert!(
html.contains("integer; 3"),
"Should display 'integer; 3' for integer tuples"
);
assert!(
html.contains("type-integer"),
"Should use type-integer CSS class"
);
}
// Test: Float tuple with format (e.g., format: "double") still displays "float"
#[test]
fn test_tuple_float_with_format() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"properties": {
"bezier": {
"type": "array",
"prefixItems": [
{ "type": "number", "format": "double" },
{ "type": "number", "format": "double" },
{ "type": "number", "format": "double" },
{ "type": "number", "format": "double" }
]
}
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// Should show "float" for number tuples regardless of format
assert!(
html.contains("float; 4"),
"Should display 'float; 4' for number tuples with format"
);
}
// ==================== Wrapper Type Rendering Tests ====================
// Test: Wrapper type with $ref renders inner type as link
#[test]
fn test_wrapper_type_renders_inner_ref() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"$defs": {
"InnerType": {
"type": "string",
"description": "The inner type"
},
"WrapperType": {
"anyOf": [
{
"type": "object",
"additionalProperties": { "$ref": "#/$defs/InnerType" }
},
{ "$ref": "#/$defs/InnerType" }
]
}
},
"properties": {
"config": { "$ref": "#/$defs/WrapperType" }
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// WrapperType definition should show InnerType as a link
assert!(
html.contains("href=\"#innertype\""),
"Should link to inner type definition"
);
// Should NOT show "enum" badge for wrapper type
// (Note: The wrapper type is referenced via $ref, so we check the definition section)
}
// Test: Wrapper type with primitive renders primitive badge
#[test]
fn test_wrapper_type_renders_primitive_badge() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"$defs": {
"BoolWrapper": {
"anyOf": [
{
"type": "object",
"additionalProperties": { "type": "boolean" }
},
{ "type": "boolean" }
]
}
},
"properties": {
"enabled": { "$ref": "#/$defs/BoolWrapper" }
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// Should show boolean type badge in the BoolWrapper definition
assert!(
html.contains("type-boolean"),
"Should show boolean type badge for wrapper with primitive inner type"
);
}
// Test: Wrapper type with integer constraints renders integer badge
#[test]
fn test_wrapper_type_with_integer_renders_correctly() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"$defs": {
"IntWrapper": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "uint64",
"minimum": 0
}
},
{
"type": "integer",
"format": "uint64",
"minimum": 0
}
]
}
},
"properties": {
"duration": { "$ref": "#/$defs/IntWrapper" }
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// Should show integer type badge
assert!(
html.contains("type-integer"),
"Should show integer type badge for wrapper with integer inner type"
);
}
// Test: Non-wrapper anyOf still shows enum badge
#[test]
fn test_non_wrapper_anyof_still_shows_enum() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"$defs": {
"NotWrapper": {
"anyOf": [
{
"type": "object",
"additionalProperties": { "type": "string" }
},
{ "type": "integer" }
]
}
},
"properties": {
"value": { "$ref": "#/$defs/NotWrapper" }
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// Should show enum badge since inner types don't match
assert!(
html.contains(">enum<"),
"Should show enum badge for non-wrapper anyOf"
);
}
// Test: Wrapper type does not show "Enum variants" dropdown
#[test]
fn test_wrapper_type_no_enum_variants_dropdown() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"$defs": {
"StringWrapper": {
"anyOf": [
{
"type": "object",
"additionalProperties": { "type": "string" }
},
{ "type": "string" }
]
}
},
"properties": {
"name": { "$ref": "#/$defs/StringWrapper" }
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// Count occurrences of "Enum variants" - should not appear for wrapper types
// (The StringWrapper definition should not have an "Enum variants" dropdown)
let enum_variants_in_stringwrapper = html
.find("StringWrapper")
.map(|pos| {
let section = &html[pos..pos.min(html.len()).saturating_add(500)];
section.contains("Enum variants")
})
.unwrap_or(false);
assert!(
!enum_variants_in_stringwrapper,
"Wrapper type should NOT show 'Enum variants' dropdown"
);
}
// Test: Wrapper type with $ref to AnimationStyle (like PerAnimationPrefixConfig3)
#[test]
fn test_wrapper_type_like_per_animation_prefix_config() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"$defs": {
"AnimationStyle": {
"type": "string",
"enum": ["Linear", "EaseIn", "EaseOut"]
},
"PerAnimationPrefixConfig": {
"anyOf": [
{
"type": "object",
"additionalProperties": { "$ref": "#/$defs/AnimationStyle" }
},
{ "$ref": "#/$defs/AnimationStyle" }
]
}
},
"properties": {
"style": { "$ref": "#/$defs/PerAnimationPrefixConfig" }
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// The PerAnimationPrefixConfig definition should link to AnimationStyle
assert!(
html.contains("href=\"#animationstyle\""),
"Wrapper type should link to inner type (AnimationStyle)"
);
}
// Test: Wrapper types display base name without numeric suffix
#[test]
fn test_wrapper_type_display_name() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"$defs": {
"PerAnimationPrefixConfig": {
"anyOf": [
{
"type": "object",
"additionalProperties": { "type": "boolean" }
},
{ "type": "boolean" }
]
},
"PerAnimationPrefixConfig2": {
"anyOf": [
{
"type": "object",
"additionalProperties": { "type": "integer" }
},
{ "type": "integer" }
]
},
"PerAnimationPrefixConfig3": {
"anyOf": [
{
"type": "object",
"additionalProperties": { "$ref": "#/$defs/AnimationStyle" }
},
{ "$ref": "#/$defs/AnimationStyle" }
]
},
"AnimationStyle": {
"type": "string",
"enum": ["Linear", "EaseIn"]
}
},
"properties": {
"config1": { "$ref": "#/$defs/PerAnimationPrefixConfig" },
"config2": { "$ref": "#/$defs/PerAnimationPrefixConfig2" },
"config3": { "$ref": "#/$defs/PerAnimationPrefixConfig3" }
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// Definition headers should show base name without numeric suffix
// PerAnimationPrefixConfig2 header should show "PerAnimationPrefixConfig"
// Count definition-name headers containing "PerAnimationPrefixConfig" (base name)
// The HTML has whitespace/newlines in formatting
let header_pattern = "<h3 class=\"definition-name\">";
let per_anim_header_count = html
.match_indices(header_pattern)
.filter(|(i, _)| {
let after = &html[*i + header_pattern.len()..];
after.trim_start().starts_with("PerAnimationPrefixConfig")
})
.count();
assert_eq!(
per_anim_header_count, 3,
"All 3 wrapper types should show 'PerAnimationPrefixConfig' in header (found {})",
per_anim_header_count
);
// Verify that PerAnimationPrefixConfig2 and PerAnimationPrefixConfig3 do NOT appear in headers
// (They should only appear in anchor IDs and href links)
assert!(
!html.contains("class=\"definition-name\">PerAnimationPrefixConfig2") &&
!html.contains("class=\"definition-name\">\n PerAnimationPrefixConfig2"),
"Should NOT show numeric suffix 2 in definition header"
);
assert!(
!html.contains("class=\"definition-name\">PerAnimationPrefixConfig3") &&
!html.contains("class=\"definition-name\">\n PerAnimationPrefixConfig3"),
"Should NOT show numeric suffix 3 in definition header"
);
// Sidebar should also show base names (navigation uses display_name)
let sidebar_base_name_count = html.matches(">PerAnimationPrefixConfig<").count();
assert!(
sidebar_base_name_count >= 3,
"Sidebar should show base name for wrapper types (found {})",
sidebar_base_name_count
);
// Anchors should still use full names for correct linking
assert!(
html.contains("id=\"peranimationprefixconfig\""),
"Anchor should use full name"
);
assert!(
html.contains("id=\"peranimationprefixconfig2\""),
"Anchor should use full name with suffix"
);
assert!(
html.contains("id=\"peranimationprefixconfig3\""),
"Anchor should use full name with suffix"
);
}
// Test: No broken anchor links in rendered output
#[test]
fn test_no_broken_anchor_links() {
use regex::Regex;
use std::collections::HashSet;
let generator = Generator::new().unwrap();
let schema_json = include_str!("../schema.json");
let schema: Schema = serde_json::from_str(schema_json).unwrap();
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// Extract all href="#..." targets (excluding JavaScript template literals like ${...})
let href_re = Regex::new(r##"href="#([^"]+)""##).unwrap();
let hrefs: HashSet<String> = href_re
.captures_iter(&html)
.map(|c| c[1].to_string())
.filter(|href| !href.starts_with("${")) // Exclude JS template literals
.collect();
// Extract all id="..." values
let id_re = Regex::new(r##"id="([^"]+)""##).unwrap();
let ids: HashSet<String> = id_re
.captures_iter(&html)
.map(|c| c[1].to_string())
.collect();
// Find broken links
let broken: Vec<&String> = hrefs.iter().filter(|href| !ids.contains(*href)).collect();
if !broken.is_empty() {
eprintln!("Broken anchor links found: {:?}", broken);
}
assert!(
broken.is_empty(),
"Found {} broken anchor links: {:?}",
broken.len(),
broken.iter().take(20).collect::<Vec<_>>()
);
}
}
+476 -44
View File
@@ -22,9 +22,31 @@ use std::collections::HashSet;
pub fn prepare_for_template(value: &Value) -> Value {
let mut prepared = value.clone();
process_value(&mut prepared);
detect_wrapper_types(&mut prepared);
add_wrapper_display_names(&mut prepared);
prepared
}
/// Add display names to wrapper types by stripping numeric suffixes
///
/// For wrapper types like `PerAnimationPrefixConfig2`, this adds a
/// `_display_name` field with the base name `PerAnimationPrefixConfig`.
fn add_wrapper_display_names(schema: &mut Value) {
if let Some(defs) = schema.get_mut("$defs").and_then(|v| v.as_object_mut()) {
for (name, def) in defs.iter_mut() {
if def.get("_wrapper_type") == Some(&Value::Bool(true)) {
let display_name = name.trim_end_matches(|c: char| c.is_ascii_digit());
if let Some(obj) = def.as_object_mut() {
obj.insert(
"_display_name".to_string(),
Value::String(display_name.to_string()),
);
}
}
}
}
}
/// Recursively process a value, rendering markdown descriptions
fn process_value(value: &mut Value) {
match value {
@@ -49,6 +71,130 @@ fn process_value(value: &mut Value) {
}
}
/// Detect and annotate wrapper type patterns in the schema
///
/// A wrapper type is an anyOf with exactly 2 variants where:
/// - One variant is an object with additionalProperties of type T
/// - The other variant is directly type T
///
/// This pattern is common for types like `PerAnimationPrefixConfig<T>` which
/// can be either a map of animation prefixes to T, or directly a T value.
fn detect_wrapper_types(value: &mut Value) {
match value {
Value::Object(map) => {
// Check for wrapper type pattern in anyOf
if let Some(any_of) = map.get("anyOf").and_then(|v| v.as_array())
&& let Some(inner) = detect_wrapper_pattern(any_of) {
map.insert("_wrapper_type".to_string(), Value::Bool(true));
map.insert("_wrapper_inner".to_string(), inner);
}
// Recurse into all nested values
for v in map.values_mut() {
detect_wrapper_types(v);
}
}
Value::Array(arr) => {
for v in arr.iter_mut() {
detect_wrapper_types(v);
}
}
_ => {}
}
}
/// Check if anyOf array matches the wrapper type pattern
///
/// Returns the inner type if the pattern matches, None otherwise.
fn detect_wrapper_pattern(any_of: &[Value]) -> Option<Value> {
// Must have exactly 2 variants
if any_of.len() != 2 {
return None;
}
let first = &any_of[0];
let second = &any_of[1];
// Skip if either variant is null (that's Option<T>, not wrapper)
if is_null_type(first) || is_null_type(second) {
return None;
}
// Try first as map variant, second as inner type
if let Some(map_inner) = get_map_inner_type(first)
&& types_are_equal(map_inner, second) {
return Some(second.clone());
}
// Try second as map variant, first as inner type
if let Some(map_inner) = get_map_inner_type(second)
&& types_are_equal(map_inner, first) {
return Some(first.clone());
}
None
}
/// Check if a schema represents the null type
fn is_null_type(schema: &Value) -> bool {
schema
.get("type")
.and_then(|t| t.as_str())
.map(|t| t == "null")
.unwrap_or(false)
}
/// Extract the inner type from a map schema (object with additionalProperties)
///
/// Returns the additionalProperties schema if this is an object type with
/// additionalProperties defined, None otherwise.
fn get_map_inner_type(schema: &Value) -> Option<&Value> {
let obj = schema.as_object()?;
// Must be type: "object"
let type_val = obj.get("type")?;
if type_val.as_str()? != "object" {
return None;
}
// Must have additionalProperties (and it must be an object, not boolean)
let additional = obj.get("additionalProperties")?;
if additional.is_object() {
Some(additional)
} else {
None
}
}
/// Check if two type schemas are equivalent
///
/// Handles:
/// - $ref comparison (by ref string)
/// - Primitive type comparison (by type string)
/// - Deep equality for complex types
fn types_are_equal(a: &Value, b: &Value) -> bool {
// Handle $ref comparison
if let (Some(ref_a), Some(ref_b)) = (
a.get("$ref").and_then(|v| v.as_str()),
b.get("$ref").and_then(|v| v.as_str()),
) {
return ref_a == ref_b;
}
// Handle primitive type comparison
if let (Some(type_a), Some(type_b)) = (
a.get("type").and_then(|v| v.as_str()),
b.get("type").and_then(|v| v.as_str()),
) {
// For primitive types, just compare the type string
// (ignoring format, constraints, etc. for matching purposes)
return type_a == type_b;
}
// Deep equality for complex types
a == b
}
/// Render markdown text to HTML
pub fn render_markdown(text: &str) -> String {
let parser = Parser::new(text);
@@ -76,6 +222,7 @@ pub struct DefinitionInfo {
pub name: String,
pub anchor: String,
pub description: Option<String>,
pub display_name: Option<String>,
}
/// An outline entry for hierarchical navigation
@@ -130,6 +277,8 @@ pub struct NavigationData {
pub search_index: Vec<SearchEntry>,
/// Map from type name to its usages
pub usages: HashMap<String, TypeUsages>,
/// Map from type name to display name (for wrapper types with numeric suffixes)
pub display_names: HashMap<String, String>,
}
/// Maximum depth for outline tree to prevent infinite recursion
@@ -241,30 +390,32 @@ fn resolve_schema_for_outline<'a>(
// Handle array - unwrap to items schema
if is_array_type(schema)
&& let Some(items) = schema.get("items") {
return resolve_schema_for_outline(items, defs, current_path);
}
&& let Some(items) = schema.get("items")
{
return resolve_schema_for_outline(items, defs, current_path);
}
// Handle anyOf - only unwrap if it's Option<T> (exactly 2 variants, one null)
// For true unions (multiple non-null variants), don't unwrap - treat as leaf
if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array())
&& any_of.len() == 2 {
let null_count = any_of
.iter()
.filter(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"))
.count();
&& any_of.len() == 2
{
let null_count = any_of
.iter()
.filter(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"))
.count();
// Only unwrap if exactly one null variant (Option<T> pattern)
if null_count == 1 {
for variant in any_of {
let is_null = variant.get("type").and_then(|t| t.as_str()) == Some("null");
if !is_null {
return resolve_schema_for_outline(variant, defs, current_path);
}
// Only unwrap if exactly one null variant (Option<T> pattern)
if null_count == 1 {
for variant in any_of {
let is_null = variant.get("type").and_then(|t| t.as_str()) == Some("null");
if !is_null {
return resolve_schema_for_outline(variant, defs, current_path);
}
}
}
// For true unions (0 or 2+ null variants, or 3+ total variants), don't unwrap
}
// For true unions (0 or 2+ null variants, or 3+ total variants), don't unwrap
// Inline object or leaf type - keep current path
(schema, current_path.to_string())
@@ -278,9 +429,10 @@ fn is_array_type(schema: &Value) -> bool {
}
// Check type: ["array", "null"] (Option<Vec<T>>)
if let Some(type_arr) = schema.get("type").and_then(|v| v.as_array())
&& type_arr.iter().any(|t| t.as_str() == Some("array")) {
return true;
}
&& type_arr.iter().any(|t| t.as_str() == Some("array"))
{
return true;
}
false
}
@@ -324,8 +476,10 @@ fn collect_config_usages(
// At config root - anchor points to config section property
// e.g., .monitors[] -> "monitors" (template IDs don't have [])
// Remove all bracket notation for config paths
// Also remove [*] notation from patternProperties/additionalProperties
let clean_path = path
.trim_start_matches('.')
.replace("[*]", "")
.replace("[]", "")
.replace("[", "-")
.replace("]", "");
@@ -345,19 +499,20 @@ fn collect_config_usages(
// Follow the $ref into the type definition (if not already visited)
// When following, we enter the new type with an empty relative path
if !visited.contains(&type_name)
&& let Some(def) = defs.and_then(|d| d.get(&type_name)) {
visited.insert(type_name.clone());
collect_config_usages(
def,
path,
usages,
defs,
visited,
Some(&type_name),
String::new(), // Reset relative path when entering new type
);
visited.remove(&type_name); // Allow revisiting from different paths
}
&& let Some(def) = defs.and_then(|d| d.get(&type_name))
{
visited.insert(type_name.clone());
collect_config_usages(
def,
path,
usages,
defs,
visited,
Some(&type_name),
String::new(), // Reset relative path when entering new type
);
visited.remove(&type_name); // Allow revisiting from different paths
}
return; // $ref schema has no other content to process
}
@@ -467,18 +622,19 @@ fn collect_config_usages(
// Recurse into additionalProperties
if let Some(additional) = schema.get("additionalProperties")
&& additional.is_object() {
let new_path = format!("{}[*]", path);
collect_config_usages(
additional,
new_path,
usages,
defs,
visited,
current_type,
type_relative_path.clone(),
);
}
&& additional.is_object()
{
let new_path = format!("{}[*]", path);
collect_config_usages(
additional,
new_path,
usages,
defs,
visited,
current_type,
type_relative_path.clone(),
);
}
}
/// Collect references to types from within $defs
@@ -561,10 +717,16 @@ pub fn build_navigation(schema: &Value) -> NavigationData {
}
});
let display_name = def
.get("_display_name")
.and_then(|v| v.as_str())
.map(String::from);
definitions.push(DefinitionInfo {
name: name.clone(),
anchor: anchor.clone(),
description,
display_name,
});
search_index.push(SearchEntry {
@@ -576,9 +738,30 @@ pub fn build_navigation(schema: &Value) -> NavigationData {
}
}
// Build display_names map (type_name -> display_name for wrapper types)
let mut display_names: HashMap<String, String> = HashMap::new();
for def in &definitions {
if let Some(display) = &def.display_name {
display_names.insert(def.name.clone(), display.clone());
}
}
// Sort definitions alphabetically
definitions.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
// Deduplicate definitions by display_name (for wrapper types with same base name)
// Keep only the first occurrence of each display_name
let mut seen_display_names: HashSet<String> = HashSet::new();
definitions.retain(|def| {
let display = def.display_name.as_ref().unwrap_or(&def.name);
if seen_display_names.contains(display) {
false // Skip duplicate
} else {
seen_display_names.insert(display.clone());
true // Keep first occurrence
}
});
// Collect configuration usages (from main schema, following $refs transitively)
let mut config_usages: HashMap<String, Vec<ConfigUsage>> = HashMap::new();
let mut visited: HashSet<String> = HashSet::new();
@@ -623,6 +806,7 @@ pub fn build_navigation(schema: &Value) -> NavigationData {
definitions,
search_index,
usages,
display_names,
}
}
@@ -1664,4 +1848,252 @@ mod tests {
// Anchor should NOT have brackets
assert_eq!(item_usages.config_usages[0].anchor, "items");
}
// ==================== Wrapper Type Detection Tests ====================
#[test]
fn test_wrapper_type_detection_with_ref() {
// Wrapper type pattern: anyOf with object+additionalProperties and direct $ref
let schema = json!({
"anyOf": [
{
"type": "object",
"additionalProperties": { "$ref": "#/$defs/InnerType" }
},
{ "$ref": "#/$defs/InnerType" }
]
});
let prepared = prepare_for_template(&schema);
assert_eq!(
prepared.get("_wrapper_type"),
Some(&Value::Bool(true)),
"Should detect wrapper type pattern"
);
assert!(
prepared.get("_wrapper_inner").is_some(),
"Should have _wrapper_inner"
);
assert_eq!(
prepared.get("_wrapper_inner").unwrap().get("$ref"),
Some(&Value::String("#/$defs/InnerType".to_string())),
"Inner type should be the $ref"
);
}
#[test]
fn test_wrapper_type_detection_with_primitive() {
// Wrapper type with primitive inner type
let schema = json!({
"anyOf": [
{
"type": "object",
"additionalProperties": { "type": "boolean" }
},
{ "type": "boolean" }
]
});
let prepared = prepare_for_template(&schema);
assert_eq!(
prepared.get("_wrapper_type"),
Some(&Value::Bool(true)),
"Should detect wrapper type with primitive"
);
assert_eq!(
prepared
.get("_wrapper_inner")
.unwrap()
.get("type")
.unwrap()
.as_str(),
Some("boolean"),
"Inner type should be boolean"
);
}
#[test]
fn test_wrapper_type_detection_reversed_order() {
// Wrapper type with map variant second
let schema = json!({
"anyOf": [
{ "type": "integer" },
{
"type": "object",
"additionalProperties": { "type": "integer" }
}
]
});
let prepared = prepare_for_template(&schema);
assert_eq!(
prepared.get("_wrapper_type"),
Some(&Value::Bool(true)),
"Should detect wrapper type regardless of variant order"
);
}
#[test]
fn test_non_wrapper_mismatched_types() {
// Not a wrapper: inner types don't match
let schema = json!({
"anyOf": [
{
"type": "object",
"additionalProperties": { "type": "string" }
},
{ "type": "integer" }
]
});
let prepared = prepare_for_template(&schema);
assert!(
prepared.get("_wrapper_type").is_none(),
"Should NOT detect wrapper when inner types don't match"
);
}
#[test]
fn test_option_not_detected_as_wrapper() {
// Option<T> pattern (anyOf with null) should NOT be detected as wrapper
let schema = json!({
"anyOf": [
{
"type": "object",
"additionalProperties": { "type": "string" }
},
{ "type": "null" }
]
});
let prepared = prepare_for_template(&schema);
assert!(
prepared.get("_wrapper_type").is_none(),
"Option<Map<K,V>> should NOT be detected as wrapper type"
);
}
#[test]
fn test_regular_anyof_not_wrapper() {
// Regular anyOf (no additionalProperties pattern)
let schema = json!({
"anyOf": [
{ "type": "string" },
{ "type": "integer" }
]
});
let prepared = prepare_for_template(&schema);
assert!(
prepared.get("_wrapper_type").is_none(),
"Regular anyOf should NOT be detected as wrapper"
);
}
#[test]
fn test_wrapper_detection_nested() {
// Wrapper type nested inside a larger schema
let schema = json!({
"$defs": {
"WrapperType": {
"anyOf": [
{
"type": "object",
"additionalProperties": { "$ref": "#/$defs/Inner" }
},
{ "$ref": "#/$defs/Inner" }
]
},
"Inner": { "type": "string" }
}
});
let prepared = prepare_for_template(&schema);
let wrapper_def = prepared.get("$defs").unwrap().get("WrapperType").unwrap();
assert_eq!(
wrapper_def.get("_wrapper_type"),
Some(&Value::Bool(true)),
"Should detect nested wrapper type"
);
}
#[test]
fn test_wrapper_with_integer_constraints() {
// Wrapper type where inner type has constraints (should still match by type)
let schema = json!({
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "uint64",
"minimum": 0
}
},
{
"type": "integer",
"format": "uint64",
"minimum": 0
}
]
});
let prepared = prepare_for_template(&schema);
assert_eq!(
prepared.get("_wrapper_type"),
Some(&Value::Bool(true)),
"Should detect wrapper even with constraints on inner type"
);
}
#[test]
fn test_three_variant_anyof_not_wrapper() {
// anyOf with 3+ variants should NOT be detected as wrapper
let schema = json!({
"anyOf": [
{
"type": "object",
"additionalProperties": { "type": "string" }
},
{ "type": "string" },
{ "type": "null" }
]
});
let prepared = prepare_for_template(&schema);
assert!(
prepared.get("_wrapper_type").is_none(),
"anyOf with 3+ variants should NOT be detected as wrapper"
);
}
#[test]
fn test_object_without_additional_properties_not_wrapper() {
// Object without additionalProperties should NOT be detected as wrapper
let schema = json!({
"anyOf": [
{
"type": "object",
"properties": { "name": { "type": "string" } }
},
{ "type": "string" }
]
});
let prepared = prepare_for_template(&schema);
assert!(
prepared.get("_wrapper_type").is_none(),
"Object with properties (not additionalProperties) should NOT be wrapper"
);
}
}
+9 -4
View File
@@ -58,7 +58,8 @@
href="#{{ def.anchor }}"
class="nav-link"
data-search-name="{{ def.name | lower }}"
>{{ def.name }}</a
>{{ def.display_name | default(value=def.name)
}}</a
>
</li>
{%- endfor -%}
@@ -88,7 +89,9 @@
class="definition"
data-search-name="{{ def_name | lower }}"
>
<h3 class="definition-name">{{ def_name }}</h3>
<h3 class="definition-name">
{{ def._display_name | default(value=def_name) }}
</h3>
{%- if def.description_html %}
<div class="description">
{{ def.description_html | safe }}
@@ -119,10 +122,12 @@
<h4>Referenced by</h4>
<ul class="usage-list">
{%- for ref in type_usages.type_references
-%}
-%} {%- set ref_display =
display_names[ref.type_name] |
default(value=ref.type_name) -%}
<li>
<a href="#{{ ref.anchor }}"
>{{ ref.type_name }}</a
>{{ ref_display }}</a
>
</li>
{%- endfor -%}
+18 -4
View File
@@ -45,7 +45,13 @@
{% macro render_type_badge(node) %}
{%- if node["$ref"] -%}
{%- set ref_name = node["$ref"] | replace(from="#/$defs/", to="") -%}
<a href="#{{ ref_name | lower }}" class="type-ref">{{ ref_name }}</a>
{%- set display = display_names[ref_name] | default(value=ref_name) -%}
{%- set ref_def = schema["$defs"][ref_name] -%}
<a href="#{{ ref_name | lower }}" class="type-ref">{{ display }}</a>
{%- if ref_def._wrapper_inner -%}
{# Show the inner type for wrapper types #}
{{ self::render_type_badge(node=ref_def._wrapper_inner) }}
{%- endif -%}
{%- elif node.type -%}
{%- if node.type is string -%}
{%- if node.type == "array" and node.prefixItems -%}
@@ -58,7 +64,7 @@
{%- endif -%}
{%- endfor -%}
{%- if is_homogeneous and first_type.type -%}
<span class="type-badge type-array">array</span> <span class="type-badge type-{{ first_type.type }}">{{ first_type.type }}; {{ node.prefixItems | length }}</span>
<span class="type-badge type-array">array</span> {%- if first_type.type == "number" -%}<span class="type-badge type-float">float; {{ node.prefixItems | length }}</span>{%- else -%}<span class="type-badge type-{{ first_type.type }}">{{ first_type.type }}; {{ node.prefixItems | length }}</span>{%- endif -%}
{%- else -%}
<span class="type-badge type-array">array</span> <span class="type-badge type-array">tuple</span>
{%- endif -%}
@@ -86,8 +92,12 @@
{%- endif -%}
{%- endif -%}
{%- elif node.anyOf -%}
{# Check for wrapper type pattern first (detected in prepare.rs) #}
{%- if node._wrapper_type -%}
{# Wrapper type: show inner type badge (type name shown separately in type definitions) #}
{{ self::render_type_badge(node=node._wrapper_inner) }}
{# Check for Option<T> pattern: anyOf with exactly 2 variants where one is null #}
{%- if node.anyOf | length == 2 -%}
{%- elif node.anyOf | length == 2 -%}
{%- set first = node.anyOf | first -%}
{%- set second = node.anyOf | last -%}
{%- if second.type and second.type == "null" -%}
@@ -254,8 +264,12 @@
</details>
{%- endif -%}
{%- elif node.anyOf -%}
{# Check for wrapper type pattern first (detected in prepare.rs) #}
{%- if node._wrapper_type -%}
{# Wrapper type - render the inner type's body #}
{{ self::render_schema_body(node=node._wrapper_inner, depth=depth, path=path) }}
{# Check for Option<T> pattern: anyOf with exactly 2 variants where one is null #}
{%- if node.anyOf | length == 2 -%}
{%- elif node.anyOf | length == 2 -%}
{%- set first = node.anyOf | first -%}
{%- set second = node.anyOf | last -%}
{%- if second.type and second.type == "null" -%}
+4
View File
@@ -570,6 +570,10 @@ body {
background-color: #0a58ca;
color: white;
}
.type-badge.type-float {
background-color: #0d6efd;
color: white;
}
.type-badge.type-boolean {
background-color: #fd7e14;
color: white;