diff --git a/Cargo.lock b/Cargo.lock index b3a10fbc..bd0633e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -740,6 +740,7 @@ dependencies = [ "clap", "pretty_assertions", "pulldown-cmark", + "regex", "schemars", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 3690b7e9..04881edc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,4 @@ thiserror = "2" [dev-dependencies] pretty_assertions = "1.4" +regex = "1" diff --git a/src/generator.rs b/src/generator.rs index a2d0a25d..37bb61ec 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -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 = 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 = "

"; + 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 = 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 = 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::>() + ); + } } diff --git a/src/prepare.rs b/src/prepare.rs index 976fc36c..50dfcd6b 100644 --- a/src/prepare.rs +++ b/src/prepare.rs @@ -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` 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 { + // 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, 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, + pub display_name: Option, } /// An outline entry for hierarchical navigation @@ -130,6 +277,8 @@ pub struct NavigationData { pub search_index: Vec, /// Map from type name to its usages pub usages: HashMap, + /// Map from type name to display name (for wrapper types with numeric suffixes) + pub display_names: HashMap, } /// 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 (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 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 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>) 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 = 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 = 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> = HashMap::new(); let mut visited: HashSet = 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 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> 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" + ); + } } diff --git a/src/templates/base.html b/src/templates/base.html index 8bfe364a..9aaf5863 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -58,7 +58,8 @@ href="#{{ def.anchor }}" class="nav-link" data-search-name="{{ def.name | lower }}" - >{{ def.name }}{{ def.display_name | default(value=def.name) + }} {%- endfor -%} @@ -88,7 +89,9 @@ class="definition" data-search-name="{{ def_name | lower }}" > -

{{ def_name }}

+

+ {{ def._display_name | default(value=def_name) }} +

{%- if def.description_html %}
{{ def.description_html | safe }} @@ -119,10 +122,12 @@

Referenced by

    {%- for ref in type_usages.type_references - -%} + -%} {%- set ref_display = + display_names[ref.type_name] | + default(value=ref.type_name) -%}
  • {{ ref.type_name }}{{ ref_display }}
  • {%- endfor -%} diff --git a/src/templates/macros.html b/src/templates/macros.html index d9026647..4f0f5fc7 100644 --- a/src/templates/macros.html +++ b/src/templates/macros.html @@ -45,7 +45,13 @@ {% macro render_type_badge(node) %} {%- if node["$ref"] -%} {%- set ref_name = node["$ref"] | replace(from="#/$defs/", to="") -%} - {{ ref_name }} + {%- set display = display_names[ref_name] | default(value=ref_name) -%} + {%- set ref_def = schema["$defs"][ref_name] -%} + {{ display }} + {%- 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 -%} - array {{ first_type.type }}; {{ node.prefixItems | length }} + array {%- if first_type.type == "number" -%}float; {{ node.prefixItems | length }}{%- else -%}{{ first_type.type }}; {{ node.prefixItems | length }}{%- endif -%} {%- else -%} array tuple {%- 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 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 @@ {%- 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 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" -%} diff --git a/src/templates/style.css b/src/templates/style.css index fefcee81..4b852da5 100644 --- a/src/templates/style.css +++ b/src/templates/style.css @@ -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;