diff --git a/crates/schemars-docgen/Cargo.toml b/crates/schemars-docgen/Cargo.toml new file mode 100644 index 00000000..f044866b --- /dev/null +++ b/crates/schemars-docgen/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "schemars-docgen" +version = "0.1.0" +edition = "2024" +license-file = "LICENSE.md" + +[lib] +name = "schemars_docgen" +path = "src/lib.rs" + +[[bin]] +name = "schemars-docgen" +path = "src/main.rs" + +[dependencies] +schemars = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tera = "1" +pulldown-cmark = "0.13" +clap = { version = "4", features = ["derive"] } +thiserror = "2" diff --git a/crates/schemars-docgen/ENUMS.md b/crates/schemars-docgen/ENUMS.md new file mode 100644 index 00000000..2f2a1da7 --- /dev/null +++ b/crates/schemars-docgen/ENUMS.md @@ -0,0 +1,826 @@ +# Comprehensive Analysis: Rust Enum Representations in JSONSchema + +## Overview + +This document provides an exhaustive analysis of all the different ways Rust enums in the `komorebi-for-mac` project are represented in the generated schema files (`schema.json` and `schema.bar.json`). The project uses `schemars` with the `JsonSchema` derive macro for schema generation. + +--- + +## Pattern 1: Simple Unit Enums (String Constants) + +**Rust Pattern:** Enums where all variants have no associated data. + +**Serde Attributes:** None (default) or `#[serde(rename_all = "...")]` + +**JSONSchema Representation:** Uses `oneOf` with each variant as `{ "type": "string", "const": "VariantName" }` + +**Source Example:** `ApplicationIdentifier` in `komorebi/src/core/mod.rs` +```rust +#[derive(Clone, Copy, Debug, Serialize, Deserialize, Display, EnumString, ValueEnum)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum ApplicationIdentifier { + /// Executable name + Exe, + /// Title + Title, + /// Path + Path, + /// App Bundle name + Name, +} +``` + +**Schema Output:** +```json +{ + "description": "Application identifier", + "oneOf": [ + { "description": "Executable name", "type": "string", "const": "Exe" }, + { "description": "Title", "type": "string", "const": "Title" }, + { "description": "Path", "type": "string", "const": "Path" }, + { "description": "App Bundle name", "type": "string", "const": "Name" } + ] +} +``` + +**Title/Discriminant Location:** +| Field | JSON Path | Description | +|-------|-----------|-------------| +| Variant Name | `oneOf[n].const` | The variant name as a string constant | +| Variant Description | `oneOf[n].description` | Doc comment from the variant | +| Enum Description | `description` | Doc comment from the enum | + +**Examples in schema.json:** +| Definition | Lines | +|------------|-------| +| `ApplicationIdentifier` | L570-594 | +| `Axis` | L622-641 | +| `CrossBoundaryBehaviour` | L2410-2424 | +| `FloatingLayerBehaviour` | L2476-2490 | +| `MatchingStrategy` | L2898-2952 | +| `MoveBehaviour` | L3050-3069 | +| `OperationBehaviour` | L3070-3084 | +| `Placement` | L3141-3160 | +| `PredefinedAspectRatio` | L3161-3180 | +| `ThemeVariant` | L3366-3380 | +| `WindowContainerBehaviour` | L3413-3427 | +| `WindowHidingPosition` | L3428-3442 | +| `Catppuccin` | L2237-2261 | +| `CatppuccinValue` | L2262-2396 | +| `Base16Value` | L2080-2164 | +| `Base16` | L642-1991 (270 theme variants) | + +**Examples in schema.bar.json:** +| Definition | Lines | +|------------|-------| +| `ApplicationsDisplayFormat` | L268-287 | +| `DisplayFormat` | L2241-2270 | +| `GroupingStyle` | L2441-2480 | +| `LabelPrefix` | L2958-2982 | + +--- + +## Pattern 2: Unit Enum with Default Value + +**Rust Pattern:** Unit enum with `#[schemars(extend("default" = Self::Variant))]` + +**JSONSchema Representation:** Same as Pattern 1, but with an additional `"default"` field at the enum level. + +**Source Example:** `DefaultLayout` in `komorebi/src/core/default_layout.rs` +```rust +#[derive(Clone, Copy, Debug, Serialize, Deserialize, Display, EnumString, ValueEnum, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "schemars", schemars(extend("default" = Self::BSP)))] +pub enum DefaultLayout { + /// BSP Layout + BSP, + /// Columns Layout + Columns, + // ... more variants +} +``` + +**Schema Output:** +```json +{ + "description": "A predefined komorebi layout", + "default": "BSP", + "oneOf": [ + { "description": "BSP Layout", "type": "string", "const": "BSP" }, + { "description": "Columns Layout", "type": "string", "const": "Columns" }, + ... + ] +} +``` + +**Title/Discriminant Location:** +| Field | JSON Path | Description | +|-------|-----------|-------------| +| Variant Name | `oneOf[n].const` | The variant name as a string constant | +| Default Value | `default` | The default variant name (at enum root level) | +| Variant Description | `oneOf[n].description` | Doc comment from the variant | + +**Examples:** +| Schema | Definition | Lines | +|--------|------------|-------| +| schema.json | `DefaultLayout` | L2425-2475 | +| schema.bar.json | `DefaultLayout` | L2190-2240 | + +--- + +## Pattern 3: Mixed Enum (Unit Variants + Newtype Variants) + +**Rust Pattern:** Enum with mostly unit variants plus one or more tuple/newtype variants containing data. Uses default external tagging. + +**JSONSchema Representation:** Uses `oneOf` where: +- Unit variants become `{ "type": "string", "const": "Name" }` +- Newtype variants become objects with the variant name as a property key + +**Source Example:** `DateFormat` in `komorebi-bar/src/widgets/date.rs` +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum DateFormat { + /// Month/Date/Year format (09/08/24) + MonthDateYear, + /// Year-Month-Date format (2024-09-08) + YearMonthDate, + /// Custom format + #[cfg_attr(feature = "schemars", schemars(title = "Custom"))] + Custom(String), + /// Custom format with modifiers + #[cfg_attr(feature = "schemars", schemars(title = "CustomModifiers"))] + CustomModifiers(CustomModifiers), +} +``` + +**Schema Output:** +```json +{ + "description": "Date widget format", + "oneOf": [ + { "description": "Month/Date/Year format", "type": "string", "const": "MonthDateYear" }, + { "description": "Year-Month-Date format", "type": "string", "const": "YearMonthDate" }, + { + "title": "Custom", + "description": "Custom format", + "type": "object", + "properties": { + "Custom": { "type": "string" } + }, + "additionalProperties": false, + "required": ["Custom"] + }, + { + "title": "CustomModifiers", + "type": "object", + "properties": { + "CustomModifiers": { "$ref": "#/$defs/CustomModifiers" } + }, + "additionalProperties": false, + "required": ["CustomModifiers"] + } + ] +} +``` + +**Title/Discriminant Location:** +| Variant Type | Field | JSON Path | Description | +|--------------|-------|-----------|-------------| +| Unit | Variant Name | `oneOf[n].const` | The variant name as a string constant | +| Unit | Description | `oneOf[n].description` | Doc comment from the variant | +| Newtype | Title | `oneOf[n].title` | Variant name (from `#[schemars(title = "...")]`) | +| Newtype | Property Key | `oneOf[n].properties.` | Variant name as object property key | +| Newtype | Required | `oneOf[n].required[0]` | Variant name in required array | + +**Examples in schema.bar.json:** +| Definition | Lines | +|------------|-------| +| `DateFormat` | L2137-2189 | +| `TimeFormat` | L3442-3490 | +| `KomorebiLayout` | L2794-2832 | + +**Examples in schema.json:** +| Definition | Lines | Notes | +|------------|-------|-------| +| `AnimationStyle` | L312-505 | 31 unit variants + `CubicBezier(f64,f64,f64,f64)` - **Note: Has custom Serialize/Deserialize that serializes CubicBezier as bare array, but schema shows wrapped object** | + +--- + +## Pattern 4: Untagged Enum (anyOf) + +**Rust Pattern:** Enum with `#[serde(untagged)]` attribute. + +**JSONSchema Representation:** Uses `anyOf` (not `oneOf`) with each variant's type directly, without wrapper objects or discriminator fields. + +### Subpattern 4a: Untagged with Primitives + +**Source Example:** `MonitorConfigOrIndex` in `komorebi-bar/src/config.rs` +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum MonitorConfigOrIndex { + /// The monitor index + Index(usize), + /// The full monitor config + MonitorConfig(MonitorConfig), +} +``` + +**Schema Output:** +```json +{ + "description": "Monitor configuration or monitor index", + "anyOf": [ + { "description": "The monitor index", "type": "integer", "format": "uint", "minimum": 0 }, + { "description": "The full monitor config", "$ref": "#/$defs/MonitorConfig" } + ] +} +``` + +### Subpattern 4b: Untagged with Multiple Struct Refs + +**Source Example:** `SpacingKind` in `komorebi-bar/src/config.rs` +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +// WARNING: Order matters! Grouped must come last +pub enum SpacingKind { + /// Spacing applied to all sides + All(f32), + /// Individual spacing + Individual(IndividualSpacingConfig), + /// Grouped spacing + Grouped(GroupedSpacingConfig), +} +``` + +**Schema Output:** +```json +{ + "description": "Spacing kind", + "anyOf": [ + { "description": "Spacing applied to all sides", "type": "number", "format": "float" }, + { "description": "Individual spacing", "$ref": "#/$defs/IndividualSpacingConfig" }, + { "description": "Grouped spacing", "$ref": "#/$defs/GroupedSpacingConfig" } + ] +} +``` + +### Subpattern 4c: Untagged with Two $refs + +**Source Example:** `Colour` in `komorebi/src/core/colour.rs` +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum Colour { + /// RGB representation + Rgb(Rgb), + /// Hex representation + Hex(Hex), +} +``` + +**Schema Output:** +```json +{ + "description": "Colour representation", + "anyOf": [ + { "description": "Colour represented as RGB", "$ref": "#/$defs/Rgb" }, + { "description": "Colour represented as Hex", "$ref": "#/$defs/Hex" } + ] +} +``` + +### Subpattern 4d: Untagged with Single vs Array + +**Source Example:** `MatchingRule` in `komorebi/src/core/config_generation.rs` +```rust +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum MatchingRule { + /// Simple matching rule + Simple(IdWithIdentifier), + /// Composite matching rule + Composite(Vec), +} +``` + +**Schema Output:** +```json +{ + "description": "Rule for matching applications", + "anyOf": [ + { "description": "Simple matching rule", "$ref": "#/$defs/IdWithIdentifier" }, + { "description": "Composite matching rule", "type": "array", "items": { "$ref": "#/$defs/IdWithIdentifier" } } + ] +} +``` + +### Subpattern 4e: Untagged with Tuple + +**Source Example:** `GroupedSpacingOptions` in `komorebi-bar/src/config.rs` +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum GroupedSpacingOptions { + /// Symmetrical spacing + Symmetrical(f32), + /// Split spacing (top/left, bottom/right) + Split(f32, f32), +} +``` + +**Schema Output:** +```json +{ + "description": "Grouped spacing options", + "anyOf": [ + { "description": "Symmetrical spacing", "type": "number", "format": "float" }, + { + "description": "Split spacing", + "type": "array", + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { "type": "number", "format": "float" }, + { "type": "number", "format": "float" } + ] + } + ] +} +``` + +**Title/Discriminant Location:** +| Field | JSON Path | Description | +|-------|-----------|-------------| +| Variant Description | `anyOf[n].description` | Doc comment from the variant | +| Variant Title (optional) | `anyOf[n].title` | Title if `#[schemars(title = "...")]` is used | +| **No discriminant** | N/A | Untagged enums have no discriminant field - they rely on type matching | + +**Note:** Untagged enums use `anyOf` (not `oneOf`) and do not have explicit discriminant values. The JSON parser tries each variant in order until one matches the input structure. + +**All Untagged Enum Examples:** +| Schema | Definition | Lines | Subpattern | +|--------|------------|-------|------------| +| schema.json | `Colour` | L2397-2409 | 4c | +| schema.json | `MatchingRule` | L2882-2897 | 4d | +| schema.json | `AspectRatio` | L595-621 | 4a (ref + tuple) | +| schema.json | `AppSpecificConfigurationPath` | L554-569 | 4d (ref + array) | +| schema.bar.json | `MonitorConfigOrIndex` | L3053-3067 | 4a | +| schema.bar.json | `SpacingKind` | L3310-3327 | 4b | +| schema.bar.json | `GroupedSpacingOptions` | L2312-2337 | 4e | +| schema.bar.json | `RoundingConfig` | L3290-3309 | 4e | + +--- + +## Pattern 5: Internally Tagged Enum (Struct Variants with Discriminator) + +**Rust Pattern:** Enum with `#[serde(tag = "field_name")]` where variants have named fields (struct variants). + +**JSONSchema Representation:** Uses `oneOf` where each variant is an object with: +- A discriminator field with `{ "type": "string", "const": "VariantName" }` +- All other fields from the struct variant +- The discriminator field in `required` array + +**Source Example:** `KomorebiTheme` in `komorebi/src/static_config.rs` +```rust +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(tag = "palette")] +pub enum KomorebiTheme { + /// A theme from catppuccin-egui + #[cfg_attr(feature = "schemars", schemars(title = "Catppuccin"))] + Catppuccin { + name: komorebi_themes::Catppuccin, + #[serde(skip_serializing_if = "Option::is_none")] + single_border: Option, + // ... more fields + }, + /// A theme from base16-egui-themes + #[cfg_attr(feature = "schemars", schemars(title = "Base16"))] + Base16 { + name: komorebi_themes::Base16, + // ... fields + }, + /// A custom Base16 theme + #[cfg_attr(feature = "schemars", schemars(title = "Custom"))] + Custom { + colours: Base16ColourPalette, + // ... fields + }, +} +``` + +**Schema Output:** +```json +{ + "description": "Komorebi theme", + "oneOf": [ + { + "title": "Catppuccin", + "description": "A theme from catppuccin-egui", + "type": "object", + "properties": { + "palette": { "type": "string", "const": "Catppuccin" }, + "name": { "$ref": "#/$defs/Catppuccin" }, + "single_border": { "anyOf": [{ "$ref": "#/$defs/CatppuccinValue" }, { "type": "null" }], "default": "Blue" } + }, + "required": ["palette", "name"] + }, + { + "title": "Base16", + "type": "object", + "properties": { + "palette": { "type": "string", "const": "Base16" }, + "name": { "$ref": "#/$defs/Base16" } + }, + "required": ["palette", "name"] + }, + { + "title": "Custom", + "type": "object", + "properties": { + "palette": { "type": "string", "const": "Custom" }, + "colours": { "$ref": "#/$defs/Base16ColourPalette" } + }, + "required": ["palette", "colours"] + } + ] +} +``` + +**Title/Discriminant Location:** +| Field | JSON Path | Description | +|-------|-----------|-------------| +| Variant Title | `oneOf[n].title` | Variant name (from `#[schemars(title = "...")]`) | +| **Discriminant Field** | `oneOf[n].properties.` | The tag field (e.g., `palette`) as a property | +| **Discriminant Value** | `oneOf[n].properties..const` | The variant name as const value | +| Required Fields | `oneOf[n].required` | Contains the tag field name | + +**Note:** The discriminant field name comes from `#[serde(tag = "...")]`. In this case, `palette` is the tag field, and each variant has `properties.palette.const` set to its variant name. + +**Examples:** +| Schema | Definition | Discriminator | Lines | +|--------|------------|---------------|-------| +| schema.json | `KomorebiTheme` | `palette` | L2540-2853 | +| schema.bar.json | `KomobarTheme` | `palette` | L2518-2669 | + +--- + +## Pattern 6: Internally Tagged Enum with Flattened Newtype Variants + +**Rust Pattern:** Enum with `#[serde(tag = "kind")]` where some variants wrap a struct (newtype pattern). The wrapped struct's fields get flattened. + +**JSONSchema Representation:** Uses `oneOf` where: +- Each variant has a discriminator field (`kind`) +- Variants that wrap structs include `$ref` at the same level to merge properties + +**Source Example:** `Grouping` in `komorebi-bar/src/render.rs` +```rust +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(tag = "kind")] +pub enum Grouping { + /// No grouping + #[cfg_attr(feature = "schemars", schemars(title = "None"))] + None, + /// Grouped as a whole + #[cfg_attr(feature = "schemars", schemars(title = "Bar"))] + Bar(GroupingConfig), + /// Grouped by alignment + #[cfg_attr(feature = "schemars", schemars(title = "Alignment"))] + Alignment(GroupingConfig), + /// Grouped individually + #[cfg_attr(feature = "schemars", schemars(title = "Widget"))] + Widget(GroupingConfig), +} +``` + +**Schema Output:** +```json +{ + "description": "Grouping", + "oneOf": [ + { + "title": "None", + "description": "No grouping is applied", + "type": "object", + "properties": { + "kind": { "type": "string", "const": "None" } + }, + "required": ["kind"] + }, + { + "title": "Bar", + "description": "Widgets are grouped as a whole", + "type": "object", + "properties": { + "kind": { "type": "string", "const": "Bar" } + }, + "$ref": "#/$defs/GroupingConfig", + "required": ["kind"] + } + ] +} +``` + +**Title/Discriminant Location:** +| Field | JSON Path | Description | +|-------|-----------|-------------| +| Variant Title | `oneOf[n].title` | Variant name (from `#[schemars(title = "...")]`) | +| **Discriminant Field** | `oneOf[n].properties.` | The tag field (e.g., `kind`) as a property | +| **Discriminant Value** | `oneOf[n].properties..const` | The variant name as const value | +| Flattened Properties | `oneOf[n].$ref` | Reference to merged struct (only for newtype variants) | +| Required Fields | `oneOf[n].required` | Contains the tag field name | + +**Note:** Similar to Pattern 5, but newtype variants include a `$ref` at the variant level that merges in additional properties from the wrapped struct. The `$ref` appears alongside `properties` and `required`. + +**Examples:** +| Schema | Definition | Discriminator | Lines | +|--------|------------|---------------|-------| +| schema.bar.json | `Grouping` | `kind` | L2338-2401 | + +--- + +## Pattern 7: Externally Tagged Enum (Newtype Variants Only) + +**Rust Pattern:** Enum with only newtype variants (no unit variants), using default external tagging. + +**JSONSchema Representation:** Uses `oneOf` where each variant is an object with: +- The variant name as a property key +- The wrapped type as the property value (usually a `$ref`) +- `additionalProperties: false` +- The variant name in `required` array + +**Source Example:** `WidgetConfig` in `komorebi-bar/src/widgets/widget.rs` +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum WidgetConfig { + /// Applications widget + #[cfg_attr(feature = "schemars", schemars(title = "Applications"))] + Applications(ApplicationsConfig), + /// Battery widget + #[cfg_attr(feature = "schemars", schemars(title = "Battery"))] + Battery(BatteryConfig), + // ... 8 more variants +} +``` + +**Schema Output:** +```json +{ + "description": "Widget configuration", + "oneOf": [ + { + "title": "Applications", + "description": "Applications widget configuration", + "type": "object", + "properties": { + "Applications": { "$ref": "#/$defs/ApplicationsConfig" } + }, + "additionalProperties": false, + "required": ["Applications"] + }, + { + "title": "Battery", + "type": "object", + "properties": { + "Battery": { "$ref": "#/$defs/BatteryConfig" } + }, + "additionalProperties": false, + "required": ["Battery"] + } + ] +} +``` + +**Title/Discriminant Location:** +| Field | JSON Path | Description | +|-------|-----------|-------------| +| Variant Title | `oneOf[n].title` | Variant name (from `#[schemars(title = "...")]`) | +| **Discriminant (Property Key)** | `oneOf[n].properties.` | Variant name is the property key itself | +| **Discriminant (Required)** | `oneOf[n].required[0]` | Variant name in required array | +| Wrapped Type | `oneOf[n].properties..$ref` | Reference to the wrapped struct | + +**Note:** In externally tagged enums, the variant name serves as both the property key and the discriminant. There is no separate `const` field - the presence of the property key identifies the variant. + +**Examples:** +| Schema | Definition | Lines | +|--------|------------|-------| +| schema.bar.json | `WidgetConfig` | L3525-3669 | + +--- + +## Pattern 8: Adjacently Tagged Enum + +**Rust Pattern:** Enum with `#[serde(tag = "type", content = "content")]` - tag and content in separate fields. + +**JSONSchema Representation:** This pattern is used for internal message types but may not appear in the public config schemas. + +**Source Example:** `SocketMessage` in `komorebi/src/core/mod.rs` +```rust +#[serde_with::serde_as] +#[derive(Clone, Debug, Serialize, Deserialize, Display)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(tag = "type", content = "content")] +pub enum SocketMessage { + FocusWindow(OperationDirection), + MoveWindow(OperationDirection), + CancelPreselect, + // ... many more variants +} +``` + +**Title/Discriminant Location:** +| Field | JSON Path | Description | +|-------|-----------|-------------| +| **Tag Field** | `oneOf[n].properties.` | The tag field (e.g., `type`) | +| **Tag Value** | `oneOf[n].properties..const` | The variant name as const value | +| **Content Field** | `oneOf[n].properties.` | The content field (e.g., `content`) | +| Content Value | `oneOf[n].properties..*` | The variant's data | + +**Note:** `SocketMessage`, `WindowManagerEvent`, and `SystemNotification` use this pattern but are primarily for IPC, not user configuration. They may not appear in the public schema files. + +--- + +## Pattern 9: Untagged Hybrid Enum (Unit Variants + $ref Fallback) + +**Rust Pattern:** Macro-generated enum using `extend_enum!` with `#[serde(untagged)]` on the fallback variant. + +**Source Example:** `WorkspacesDisplayFormat` in `komorebi-bar/src/config.rs` +```rust +extend_enum!( + DisplayFormat, WorkspacesDisplayFormat, { + /// Show all icons only + AllIcons, + /// Show both all icons and text + AllIconsAndText, + /// Show all icons and text for the selected element + AllIconsAndTextOnSelected, +}); +``` + +Which expands to include an `#[serde(untagged)] Existing(DisplayFormat)` variant. + +**Schema Output:** +```json +{ + "description": "Workspaces display format", + "anyOf": [ + { "description": "Show all icons only", "type": "string", "const": "AllIcons" }, + { "description": "Show both all icons and text", "type": "string", "const": "AllIconsAndText" }, + { "description": "Show all icons and text for the selected element", "type": "string", "const": "AllIconsAndTextOnSelected" }, + { "$ref": "#/$defs/DisplayFormat" } + ] +} +``` + +**Title/Discriminant Location:** +| Variant Type | Field | JSON Path | Description | +|--------------|-------|-----------|-------------| +| Unit | Variant Name | `anyOf[n].const` | The variant name as a string constant | +| Unit | Description | `anyOf[n].description` | Doc comment from the variant | +| Fallback | Reference | `anyOf[n].$ref` | Reference to the base enum for additional variants | + +**Note:** This hybrid pattern combines explicit unit variants with a `$ref` fallback to another enum. The `$ref` variant has no `const` - it delegates to the referenced enum's schema. + +**Examples:** +| Schema | Definition | Lines | +|--------|------------|-------| +| schema.bar.json | `WorkspacesDisplayFormat` | L3670-3692 | + +--- + +## Pattern 10: Generic Enum Instantiations + +**Rust Pattern:** Generic enum like `PerAnimationPrefixConfig` with `#[serde(untagged)]` instantiated with different type parameters. + +**JSONSchema Representation:** Creates separate definitions for each instantiation, with numbered suffixes. + +**Source Example:** (likely in `komorebi/src/animation/mod.rs`) +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum PerAnimationPrefixConfig { + /// Prefixed config + Prefix(HashMap), + /// Global config + Global(T), +} +``` + +**Schema Output (for bool):** +```json +{ + "description": "Animation configuration", + "anyOf": [ + { + "description": "Prefixed config", + "type": "object", + "additionalProperties": { "type": "boolean" } + }, + { + "description": "Global config", + "type": "boolean" + } + ] +} +``` + +**Title/Discriminant Location:** +| Field | JSON Path | Description | +|-------|-----------|-------------| +| Variant Description | `anyOf[n].description` | Doc comment from the variant | +| **No discriminant** | N/A | Untagged - relies on type matching | +| Definition Name | `$defs/` or `$defs/` | Numbered suffix for each instantiation | + +**Note:** Each generic instantiation creates a separate definition. The first uses the base name, subsequent ones get numeric suffixes (e.g., `PerAnimationPrefixConfig2`, `PerAnimationPrefixConfig3`). + +**Examples in schema.json:** +| Definition | Type Parameter | Lines | +|------------|---------------|-------| +| `PerAnimationPrefixConfig` | `bool` | L3089-3104 | +| `PerAnimationPrefixConfig2` | `u64` | L3105-3124 | +| `PerAnimationPrefixConfig3` | `AnimationStyle` | L3125-3140 | + +--- + +## Summary Table + +| Pattern | Serde Attribute | JSONSchema Keyword | Discriminant Location | +|---------|-----------------|-------------------|----------------------| +| 1. Simple Unit | (none) | `oneOf` | `oneOf[n].const` | +| 2. Unit + Default | `#[schemars(extend)]` | `oneOf` + `default` | `oneOf[n].const` + root `default` | +| 3. Mixed (Unit + Newtype) | (none) | `oneOf` | Units: `oneOf[n].const`; Newtypes: `oneOf[n].properties.` + `oneOf[n].required[0]` | +| 4. Untagged | `#[serde(untagged)]` | `anyOf` | None (type-based matching) | +| 5. Internally Tagged | `#[serde(tag = "...")]` | `oneOf` | `oneOf[n].properties..const` | +| 6. Internally Tagged + Newtype | `#[serde(tag = "...")]` | `oneOf` with `$ref` | `oneOf[n].properties..const` + `oneOf[n].$ref` | +| 7. Externally Tagged (Newtype) | (none/default) | `oneOf` | `oneOf[n].properties.` + `oneOf[n].required[0]` | +| 8. Adjacently Tagged | `#[serde(tag, content)]` | `oneOf` | `oneOf[n].properties..const` + `oneOf[n].properties.` | +| 9. Untagged Hybrid | `#[serde(untagged)]` on fallback | `anyOf` | Units: `anyOf[n].const`; Fallback: `anyOf[n].$ref` | +| 10. Generic | `#[serde(untagged)]` | `anyOf` | None; Definition name has numeric suffix | + +### Title Location by Pattern + +| Pattern | Title Location | Notes | +|---------|---------------|-------| +| 1. Simple Unit | No `title` field | Variant name is in `const` | +| 2. Unit + Default | No `title` field | Variant name is in `const` | +| 3. Mixed | `oneOf[n].title` (newtypes only) | From `#[schemars(title = "...")]` | +| 4. Untagged | `anyOf[n].title` (optional) | Rarely used | +| 5. Internally Tagged | `oneOf[n].title` | From `#[schemars(title = "...")]` | +| 6. Internally Tagged + Newtype | `oneOf[n].title` | From `#[schemars(title = "...")]` | +| 7. Externally Tagged | `oneOf[n].title` | From `#[schemars(title = "...")]` | +| 8. Adjacently Tagged | `oneOf[n].title` | From `#[schemars(title = "...")]` | +| 9. Untagged Hybrid | No `title` for units | Unit names in `const`, fallback is `$ref` | +| 10. Generic | No `title` | Definition name identifies the instantiation | + +--- + +## Key Observations + +1. **`oneOf` vs `anyOf`**: Tagged enums use `oneOf` (exactly one must match); untagged enums use `anyOf` (first match wins). + +2. **Unit variants**: Always represented as `{ "type": "string", "const": "VariantName" }`. + +3. **Newtype variants with default tagging**: Become objects where the variant name is a property key containing the data. + +4. **Struct variants with internal tagging**: The tag field appears as a regular property with a `const` value. + +5. **Generic type monomorphization**: Each instantiation gets a separate schema definition with numeric suffixes. + +6. **Descriptions preserved**: Both enum-level and variant-level doc comments appear in the schema. + +7. **`#[schemars(title = "...")]`**: Used to control the `title` field in schema output for variants. + +8. **Custom Serialize/Deserialize**: When enums have custom implementations (like `AnimationStyle`), the schema may not match the actual serialization format exactly. + +9. **Variant ordering in untagged enums**: Order matters! See `SpacingKind` warning comment. + +10. **Optional fields in struct variants**: Use `anyOf: [{ $ref }, { type: null }]` pattern with `default` values. + +--- + +## Source Files Reference + +| File | Key Enums | +|------|-----------| +| `komorebi/src/core/mod.rs` | `ApplicationIdentifier`, `SocketMessage`, `OperationBehaviour`, `WindowContainerBehaviour`, `FloatingLayerBehaviour`, `Placement`, `MoveBehaviour`, `CrossBoundaryBehaviour` | +| `komorebi/src/core/config_generation.rs` | `MatchingRule`, `MatchingStrategy`, `ApplicationOptions` | +| `komorebi/src/core/default_layout.rs` | `DefaultLayout` | +| `komorebi/src/core/animation.rs` | `AnimationStyle` | +| `komorebi/src/static_config.rs` | `KomorebiTheme` | +| `komorebi/src/animation/mod.rs` | `PerAnimationPrefixConfig` | +| `komorebi-bar/src/config.rs` | `MonitorConfigOrIndex`, `SpacingKind`, `GroupedSpacingOptions`, `KomobarTheme`, `WorkspacesDisplayFormat` | +| `komorebi-bar/src/render.rs` | `Grouping`, `GroupingStyle`, `RoundingConfig` | +| `komorebi-bar/src/widgets/widget.rs` | `WidgetConfig` | +| `komorebi-bar/src/widgets/date.rs` | `DateFormat` | +| `komorebi-bar/src/widgets/time.rs` | `TimeFormat` | diff --git a/crates/schemars-docgen/src/lib.rs b/crates/schemars-docgen/src/lib.rs new file mode 100644 index 00000000..e2e42b57 --- /dev/null +++ b/crates/schemars-docgen/src/lib.rs @@ -0,0 +1,480 @@ +#![warn(clippy::all)] + +//! # schemars-docgen +//! +//! Generate MDX documentation from `schemars::Schema` objects. +//! +//! This library takes a JSON Schema (as produced by schemars) and generates +//! MDX documentation suitable for Astro Starlight with: +//! +//! - YAML frontmatter +//! - Starlight components +//! - Markdown rendering for descriptions +//! +//! ## Usage +//! +//! ```rust,no_run +//! use schemars::schema_for; +//! use schemars_docgen::{generate_mdx, MdxOptions}; +//! +//! #[derive(schemars::JsonSchema)] +//! struct MyConfig { +//! /// The name of the configuration +//! name: String, +//! } +//! +//! let schema = schema_for!(MyConfig); +//! let options = MdxOptions::default(); +//! let mdx = generate_mdx(&schema, &options).unwrap(); +//! ``` + +mod mdx_generator; +mod prepare; + +pub use mdx_generator::MdxGenerator; + +use schemars::Schema; +use serde_json::Value; + +/// Options for MDX generation +#[derive(Debug, Clone, Default)] +pub struct MdxOptions { + /// Override the schema title + pub title_override: Option, + /// Override the schema description (used in frontmatter) + pub description_override: Option, +} + +/// Error type for documentation generation +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Template error: {0}")] + Template(#[from] tera::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} + +/// Generate MDX documentation from a schema +/// +/// This generates an MDX document suitable for use with Astro Starlight. +/// The output includes YAML frontmatter and uses Starlight components. +pub fn generate_mdx(schema: &Schema, options: &MdxOptions) -> Result { + let generator = MdxGenerator::new()?; + generator.generate(schema, options) +} + +/// Extension trait for convenient access to common schema properties +/// +/// Since `schemars::Schema` is a wrapper around `serde_json::Value`, +/// this trait provides typed accessors for standard JSON Schema keywords. +pub trait SchemaExt { + /// Get the schema title + fn title(&self) -> Option<&str>; + + /// Get the schema description + fn description(&self) -> Option<&str>; + + /// Get the schema type (can be a string or array of strings) + fn schema_type(&self) -> Option<&Value>; + + /// Get object properties + fn properties(&self) -> Option<&serde_json::Map>; + + /// Get schema definitions ($defs) + fn definitions(&self) -> Option<&serde_json::Map>; + + /// Get array items schema + fn items(&self) -> Option<&Value>; + + /// Get anyOf schemas + fn any_of(&self) -> Option<&Vec>; + + /// Get oneOf schemas + fn one_of(&self) -> Option<&Vec>; + + /// Get allOf schemas + fn all_of(&self) -> Option<&Vec>; + + /// Get enum values + fn enum_values(&self) -> Option<&Vec>; + + /// Get $ref target + fn ref_target(&self) -> Option<&str>; + + /// Get default value + fn default_value(&self) -> Option<&Value>; + + /// Get required fields + fn required_fields(&self) -> Option<&Vec>; + + /// Get format constraint + fn format(&self) -> Option<&str>; + + /// Get pattern constraint + fn pattern(&self) -> Option<&str>; + + /// Get minimum constraint + fn minimum(&self) -> Option; + + /// Get maximum constraint + fn maximum(&self) -> Option; + + /// Get const value + fn const_value(&self) -> Option<&Value>; + + /// Get pattern properties + fn pattern_properties(&self) -> Option<&serde_json::Map>; + + /// Get additional properties schema + fn additional_properties(&self) -> Option<&Value>; + + /// Get prefix items (tuple validation) + fn prefix_items(&self) -> Option<&Vec>; + + /// Get min items constraint + fn min_items(&self) -> Option; + + /// Get max items constraint + fn max_items(&self) -> Option; + + /// Check if deprecated + fn is_deprecated(&self) -> bool; +} + +impl SchemaExt for Schema { + fn title(&self) -> Option<&str> { + self.as_object()?.get("title")?.as_str() + } + + fn description(&self) -> Option<&str> { + self.as_object()?.get("description")?.as_str() + } + + fn schema_type(&self) -> Option<&Value> { + self.as_object()?.get("type") + } + + fn properties(&self) -> Option<&serde_json::Map> { + self.as_object()?.get("properties")?.as_object() + } + + fn definitions(&self) -> Option<&serde_json::Map> { + self.as_object()?.get("$defs")?.as_object() + } + + fn items(&self) -> Option<&Value> { + self.as_object()?.get("items") + } + + fn any_of(&self) -> Option<&Vec> { + self.as_object()?.get("anyOf")?.as_array() + } + + fn one_of(&self) -> Option<&Vec> { + self.as_object()?.get("oneOf")?.as_array() + } + + fn all_of(&self) -> Option<&Vec> { + self.as_object()?.get("allOf")?.as_array() + } + + fn enum_values(&self) -> Option<&Vec> { + self.as_object()?.get("enum")?.as_array() + } + + fn ref_target(&self) -> Option<&str> { + self.as_object()?.get("$ref")?.as_str() + } + + fn default_value(&self) -> Option<&Value> { + self.as_object()?.get("default") + } + + fn required_fields(&self) -> Option<&Vec> { + self.as_object()?.get("required")?.as_array() + } + + fn format(&self) -> Option<&str> { + self.as_object()?.get("format")?.as_str() + } + + fn pattern(&self) -> Option<&str> { + self.as_object()?.get("pattern")?.as_str() + } + + fn minimum(&self) -> Option { + self.as_object()?.get("minimum")?.as_f64() + } + + fn maximum(&self) -> Option { + self.as_object()?.get("maximum")?.as_f64() + } + + fn const_value(&self) -> Option<&Value> { + self.as_object()?.get("const") + } + + fn pattern_properties(&self) -> Option<&serde_json::Map> { + self.as_object()?.get("patternProperties")?.as_object() + } + + fn additional_properties(&self) -> Option<&Value> { + self.as_object()?.get("additionalProperties") + } + + fn prefix_items(&self) -> Option<&Vec> { + self.as_object()?.get("prefixItems")?.as_array() + } + + fn min_items(&self) -> Option { + self.as_object()?.get("minItems")?.as_u64() + } + + fn max_items(&self) -> Option { + self.as_object()?.get("maxItems")?.as_u64() + } + + fn is_deprecated(&self) -> bool { + self.as_object() + .and_then(|o| o.get("deprecated")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } +} + +/// Extension trait for Value to use the same accessors +impl SchemaExt for Value { + fn title(&self) -> Option<&str> { + self.as_object()?.get("title")?.as_str() + } + + fn description(&self) -> Option<&str> { + self.as_object()?.get("description")?.as_str() + } + + fn schema_type(&self) -> Option<&Value> { + self.as_object()?.get("type") + } + + fn properties(&self) -> Option<&serde_json::Map> { + self.as_object()?.get("properties")?.as_object() + } + + fn definitions(&self) -> Option<&serde_json::Map> { + self.as_object()?.get("$defs")?.as_object() + } + + fn items(&self) -> Option<&Value> { + self.as_object()?.get("items") + } + + fn any_of(&self) -> Option<&Vec> { + self.as_object()?.get("anyOf")?.as_array() + } + + fn one_of(&self) -> Option<&Vec> { + self.as_object()?.get("oneOf")?.as_array() + } + + fn all_of(&self) -> Option<&Vec> { + self.as_object()?.get("allOf")?.as_array() + } + + fn enum_values(&self) -> Option<&Vec> { + self.as_object()?.get("enum")?.as_array() + } + + fn ref_target(&self) -> Option<&str> { + self.as_object()?.get("$ref")?.as_str() + } + + fn default_value(&self) -> Option<&Value> { + self.as_object()?.get("default") + } + + fn required_fields(&self) -> Option<&Vec> { + self.as_object()?.get("required")?.as_array() + } + + fn format(&self) -> Option<&str> { + self.as_object()?.get("format")?.as_str() + } + + fn pattern(&self) -> Option<&str> { + self.as_object()?.get("pattern")?.as_str() + } + + fn minimum(&self) -> Option { + self.as_object()?.get("minimum")?.as_f64() + } + + fn maximum(&self) -> Option { + self.as_object()?.get("maximum")?.as_f64() + } + + fn const_value(&self) -> Option<&Value> { + self.as_object()?.get("const") + } + + fn pattern_properties(&self) -> Option<&serde_json::Map> { + self.as_object()?.get("patternProperties")?.as_object() + } + + fn additional_properties(&self) -> Option<&Value> { + self.as_object()?.get("additionalProperties") + } + + fn prefix_items(&self) -> Option<&Vec> { + self.as_object()?.get("prefixItems")?.as_array() + } + + fn min_items(&self) -> Option { + self.as_object()?.get("minItems")?.as_u64() + } + + fn max_items(&self) -> Option { + self.as_object()?.get("maxItems")?.as_u64() + } + + fn is_deprecated(&self) -> bool { + self.as_object() + .and_then(|o| o.get("deprecated")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_schema_ext_title() { + let schema: Schema = + serde_json::from_str(r#"{"title": "Test Schema", "type": "object"}"#).unwrap(); + assert_eq!(schema.title(), Some("Test Schema")); + } + + #[test] + fn test_schema_ext_description() { + let schema: Schema = + serde_json::from_str(r#"{"description": "A test description"}"#).unwrap(); + assert_eq!(schema.description(), Some("A test description")); + } + + #[test] + fn test_schema_ext_ref() { + let schema: Schema = serde_json::from_str(r##"{"$ref": "#/$defs/MyType"}"##).unwrap(); + assert_eq!(schema.ref_target(), Some("#/$defs/MyType")); + } + + #[test] + fn test_schema_ext_properties() { + let schema: Schema = serde_json::from_str( + r#"{"type": "object", "properties": {"name": {"type": "string"}}}"#, + ) + .unwrap(); + let props = schema.properties().unwrap(); + assert!(props.contains_key("name")); + } + + #[test] + fn test_tuple_with_ref_items() { + let schema: Schema = serde_json::from_str( + r##"{ + "type": "object", + "properties": { + "coords": { + "type": "array", + "prefixItems": [ + { "$ref": "#/$defs/X" }, + { "$ref": "#/$defs/Y" } + ] + } + }, + "$defs": { + "X": { "type": "number" }, + "Y": { "type": "number" } + } + }"##, + ) + .unwrap(); + + let result = generate_mdx(&schema, &MdxOptions::default()); + assert!(result.is_ok(), "Should handle $ref in prefixItems"); + } + + #[test] + fn test_tuple_with_mixed_items() { + let schema: Schema = serde_json::from_str( + r##"{ + "type": "object", + "properties": { + "pair": { + "type": "array", + "prefixItems": [ + { "$ref": "#/$defs/Name" }, + { "type": "integer" } + ] + } + }, + "$defs": { + "Name": { "type": "string" } + } + }"##, + ) + .unwrap(); + + let result = generate_mdx(&schema, &MdxOptions::default()); + assert!( + result.is_ok(), + "Should handle mixed $ref and type in prefixItems" + ); + } + + #[test] + fn test_tuple_with_anyof_items() { + let schema: Schema = serde_json::from_str( + r##"{ + "type": "object", + "properties": { + "optional_pair": { + "type": "array", + "prefixItems": [ + { "type": "string" }, + { "anyOf": [{ "type": "number" }, { "type": "null" }] } + ] + } + } + }"##, + ) + .unwrap(); + + let result = generate_mdx(&schema, &MdxOptions::default()); + assert!(result.is_ok(), "Should handle anyOf in prefixItems"); + } + + #[test] + fn test_homogeneous_tuple_still_works() { + let schema: Schema = serde_json::from_str( + r#"{ + "type": "object", + "properties": { + "point": { + "type": "array", + "prefixItems": [ + { "type": "number" }, + { "type": "number" }, + { "type": "number" } + ] + } + } + }"#, + ) + .unwrap(); + + let result = generate_mdx(&schema, &MdxOptions::default()); + assert!(result.is_ok()); + } +} diff --git a/crates/schemars-docgen/src/main.rs b/crates/schemars-docgen/src/main.rs new file mode 100644 index 00000000..59d30f5d --- /dev/null +++ b/crates/schemars-docgen/src/main.rs @@ -0,0 +1,50 @@ +#![warn(clippy::all)] + +use clap::Parser; +use schemars_docgen::MdxOptions; +use schemars_docgen::generate_mdx; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "schemars-docgen")] +#[command(about = "Generate documentation from JSON Schema")] +struct Cli { + /// Input JSON Schema file + input: PathBuf, + + /// Output file (defaults to input filename with .mdx extension) + #[arg(short, long)] + output: Option, + + /// Schema title (overrides title from the schema) + #[arg(short, long)] + title: Option, + + /// Schema description (overrides description from the schema) + #[arg(short, long)] + description: Option, +} + +fn main() -> Result<(), Box> { + let cli = Cli::parse(); + + let schema_json = std::fs::read_to_string(&cli.input)?; + let schema: schemars::Schema = serde_json::from_str(&schema_json)?; + + let options = MdxOptions { + title_override: cli.title, + description_override: cli.description, + }; + let output = generate_mdx(&schema, &options)?; + + let output_path = cli.output.unwrap_or_else(|| { + let mut path = cli.input.clone(); + path.set_extension("mdx"); + path + }); + + std::fs::write(&output_path, output)?; + println!("Generated documentation: {}", output_path.display()); + + Ok(()) +} diff --git a/crates/schemars-docgen/src/mdx_generator.rs b/crates/schemars-docgen/src/mdx_generator.rs new file mode 100644 index 00000000..e71c4525 --- /dev/null +++ b/crates/schemars-docgen/src/mdx_generator.rs @@ -0,0 +1,1585 @@ +//! MDX generation using Tera templates for Astro Starlight + +use crate::Error; +use crate::MdxOptions; +use crate::prepare::NavigationData; +use crate::prepare::build_navigation; +use crate::prepare::embed_usages_in_defs; +use crate::prepare::prepare_for_template; +use crate::prepare::strip_markdown; +use schemars::Schema; +use serde_json::Value; +use std::collections::HashMap; +use tera::Context; +use tera::Tera; + +/// Tera filter to escape curly braces for MDX +/// +/// MDX interprets `{` and `}` as JSX expressions. This filter escapes them +/// so they render as literal text, which is necessary for descriptions +/// containing JSON code blocks. +/// +/// This filter is code-block aware: it will NOT escape braces inside fenced +/// code blocks (```) or inline code (`), preserving proper code formatting. +fn mdx_escape(value: &Value, _args: &HashMap) -> tera::Result { + match value { + Value::String(s) => { + let escaped = escape_braces_outside_code(s); + Ok(Value::String(escaped)) + } + _ => Ok(value.clone()), + } +} + +/// Escape `{` and `}` characters outside of code blocks and inline code. +/// +/// This preserves code block content while escaping braces in regular text +/// that would otherwise be interpreted as JSX expressions by MDX. +fn escape_braces_outside_code(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let chars: Vec = text.chars().collect(); + let len = chars.len(); + let mut i = 0; + + // Track whether we're inside a fenced code block + let mut in_fenced_block = false; + + while i < len { + // Check for fenced code block start/end (```) + if i + 2 < len && chars[i] == '`' && chars[i + 1] == '`' && chars[i + 2] == '`' { + // Toggle fenced block state + in_fenced_block = !in_fenced_block; + result.push('`'); + result.push('`'); + result.push('`'); + i += 3; + continue; + } + + // If we're in a fenced code block, pass through everything + if in_fenced_block { + result.push(chars[i]); + i += 1; + continue; + } + + // Check for inline code (single backtick, not part of ```) + if chars[i] == '`' { + result.push('`'); + i += 1; + // Find closing backtick and pass through content + while i < len && chars[i] != '`' { + result.push(chars[i]); + i += 1; + } + // Push closing backtick if found + if i < len { + result.push('`'); + i += 1; + } + continue; + } + + // Outside of any code: escape braces + if chars[i] == '{' { + result.push_str("\\{"); + } else if chars[i] == '}' { + result.push_str("\\}"); + } else { + result.push(chars[i]); + } + i += 1; + } + + result +} + +/// MDX documentation generator for Astro Starlight +pub struct MdxGenerator { + tera: Tera, +} + +impl MdxGenerator { + /// Create a new MDX generator with embedded templates + pub fn new() -> Result { + let mut tera = Tera::default(); + + // Register custom filters + tera.register_filter("mdx_escape", mdx_escape); + + // Add MDX-specific templates + // Order matters: macros must be added before base which imports them + tera.add_raw_template("mdx_macros.md", include_str!("templates/mdx_macros.md"))?; + tera.add_raw_template("mdx_base.md", include_str!("templates/mdx_base.md"))?; + + Ok(Self { tera }) + } + + /// Generate MDX documentation from a schema + pub fn generate(&self, schema: &Schema, options: &MdxOptions) -> Result { + // Prepare the schema for template rendering (reuse existing preparation logic) + let mut prepared_schema = prepare_for_template(schema.as_value()); + + // Build navigation data from the prepared schema + let navigation = build_navigation(&prepared_schema); + + // Embed usages directly into $defs + embed_usages_in_defs(&mut prepared_schema, &navigation.usages); + + // Build the template context + let context = self.build_context(&prepared_schema, &navigation, options)?; + + // Render the MDX template + let mdx = self.tera.render("mdx_base.md", &context)?; + + Ok(mdx) + } + + /// Build the Tera context from schema and options + fn build_context( + &self, + schema: &Value, + navigation: &NavigationData, + options: &MdxOptions, + ) -> Result { + let mut context = Context::new(); + + // Title: use override, then schema title, then fallback + let title = options + .title_override + .clone() + .or_else(|| { + schema + .get("title") + .and_then(|v| v.as_str()) + .map(String::from) + }) + .unwrap_or_else(|| "Schema Documentation".to_string()); + // Strip markdown for plain text title (for frontmatter) + let title_plain = strip_markdown(&title); + context.insert("title", &title_plain); + + // Description: use override, then schema description, then title + let description = options + .description_override + .clone() + .or_else(|| { + schema + .get("description") + .and_then(|v| v.as_str()) + .map(String::from) + }) + .or_else(|| Some(title_plain.clone())) + .unwrap_or_default(); + // Strip markdown and sanitize for YAML frontmatter + let description_plain = strip_markdown(&description) + .replace(['\n', '\r'], " ") + .replace('"', "'"); // Replace double quotes with single quotes for YAML safety + // Truncate very long descriptions for frontmatter + let description_truncated = if description_plain.len() > 200 { + format!("{}...", &description_plain[..197]) + } else { + description_plain + }; + context.insert("description", &description_truncated); + + // The full schema for property rendering + context.insert("schema", schema); + + // Navigation data + context.insert("navigation", navigation); + + // 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") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + context.insert("required_set", &required_set); + + Ok(context) + } +} + +impl Default for MdxGenerator { + fn default() -> Self { + Self::new().expect("Failed to create default MDX generator") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn make_schema(value: Value) -> Schema { + serde_json::from_value(value).unwrap() + } + + #[test] + fn test_mdx_generator_new() { + let generator = MdxGenerator::new(); + assert!(generator.is_ok()); + } + + #[test] + fn test_generate_simple_schema() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "TestConfig", + "description": "A test configuration", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name" + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options); + assert!(mdx.is_ok()); + + let mdx = mdx.unwrap(); + // Check frontmatter + assert!(mdx.contains("---"), "Should have frontmatter delimiters"); + assert!( + mdx.contains("title: \"TestConfig\""), + "Should have title in frontmatter" + ); + // Check content + assert!(mdx.contains("name"), "Should contain property name"); + assert!( + mdx.contains("The name"), + "Should contain property description" + ); + } + + #[test] + fn test_generate_with_title_override() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "OriginalTitle", + "type": "object" + })); + + let options = MdxOptions { + title_override: Some("Custom Title".to_string()), + ..Default::default() + }; + + let mdx = generator.generate(&schema, &options).unwrap(); + assert!(mdx.contains("title: \"Custom Title\"")); + assert!(!mdx.contains("title: \"OriginalTitle\"")); + } + + #[test] + fn test_generate_with_description_override() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "description": "Original description", + "type": "object" + })); + + let options = MdxOptions { + description_override: Some("Custom description".to_string()), + ..Default::default() + }; + + let mdx = generator.generate(&schema, &options).unwrap(); + assert!(mdx.contains("description: \"Custom description\"")); + } + + #[test] + fn test_frontmatter_generation() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "My Schema", + "description": "Schema description", + "type": "object" + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Frontmatter should be at the start + assert!(mdx.starts_with("---"), "MDX should start with frontmatter"); + // Should have closing delimiter + let parts: Vec<&str> = mdx.splitn(3, "---").collect(); + assert!( + parts.len() >= 3, + "Should have opening and closing frontmatter delimiters" + ); + } + + #[test] + fn test_component_imports() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "field": { "type": "string" } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Should import Badge component from Starlight + assert!( + mdx.contains("import { Badge }") || mdx.contains("import {Badge}"), + "Should import Badge component" + ); + assert!( + mdx.contains("@astrojs/starlight/components"), + "Should import from Starlight" + ); + } + + #[test] + fn test_heading_structure() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "alpha": { "type": "string" }, + "beta": { "type": "number" } + }, + "$defs": { + "MyType": { "type": "string" } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Should have H2 for main sections + assert!( + mdx.contains("## Configuration"), + "Should have Configuration H2" + ); + assert!( + mdx.contains("## Type Definitions"), + "Should have Type Definitions H2" + ); + + // Should have H3 for properties and types (using markdown headings with custom IDs) + assert!(mdx.contains("### alpha {#alpha}"), "Should have alpha H3"); + assert!(mdx.contains("### beta {#beta}"), "Should have beta H3"); + assert!( + mdx.contains("### MyType {#mytype}"), + "Should have MyType H3" + ); + } + + #[test] + fn test_primitive_type_badges() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "str_field": { "type": "string" }, + "num_field": { "type": "number" }, + "int_field": { "type": "integer" }, + "bool_field": { "type": "boolean" } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Primitive types should be shown as inline code + assert!(mdx.contains("`string`"), "Should show string type"); + assert!(mdx.contains("`number`"), "Should show number type"); + assert!(mdx.contains("`integer`"), "Should show integer type"); + assert!(mdx.contains("`boolean`"), "Should show boolean type"); + } + + #[test] + fn test_required_property_badge() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Required properties should have a Badge + assert!( + mdx.contains("Badge") && mdx.contains("required"), + "Should have required badge" + ); + } + + #[test] + fn test_deprecated_property_badge() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "old_field": { + "type": "string", + "deprecated": true + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Deprecated properties should have a Badge + assert!( + mdx.contains("Badge") && mdx.contains("deprecated"), + "Should have deprecated badge" + ); + } + + #[test] + fn test_optional_property_badge() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "maybe_name": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Option pattern should show optional badge + assert!( + mdx.contains("optional"), + "Should have optional badge for Option" + ); + } + + #[test] + fn test_ref_type_links() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "MyType": { "type": "string" } + }, + "properties": { + "field": { "$ref": "#/$defs/MyType" } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Should have a link to the type definition + assert!( + mdx.contains("[MyType](#mytype)") || mdx.contains("[`MyType`](#mytype)"), + "Should have link to type definition" + ); + } + + #[test] + fn test_enum_values() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["active", "inactive", "pending"] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Should list enum values + assert!(mdx.contains("active"), "Should contain enum value 'active'"); + assert!( + mdx.contains("inactive"), + "Should contain enum value 'inactive'" + ); + assert!( + mdx.contains("pending"), + "Should contain enum value 'pending'" + ); + } + + #[test] + fn test_oneof_variants() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "choice": { + "oneOf": [ + { "type": "string", "const": "A", "description": "Option A" }, + { "type": "string", "const": "B", "description": "Option B" } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Should show variants + assert!( + mdx.contains("Option A") || mdx.contains("\"A\""), + "Should contain variant A" + ); + assert!( + mdx.contains("Option B") || mdx.contains("\"B\""), + "Should contain variant B" + ); + } + + #[test] + fn test_anyof_variants() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "value": { + "anyOf": [ + { "type": "string", "description": "A string value" }, + { "type": "number", "description": "A number value" }, + { "type": "boolean", "description": "A boolean value" } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Should show variant descriptions (not type badges for enum variants) + assert!( + mdx.contains("A string value"), + "Should contain string variant description" + ); + assert!( + mdx.contains("A number value"), + "Should contain number variant description" + ); + assert!( + mdx.contains("A boolean value"), + "Should contain boolean variant description" + ); + // Should show enum variants section + assert!( + mdx.contains("Enum variants"), + "Should show enum variants header" + ); + } + + #[test] + fn test_array_type_with_items() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { "type": "string" } + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Should show array type + assert!(mdx.contains("array"), "Should contain array type"); + assert!(mdx.contains("string"), "Should contain item type"); + } + + #[test] + fn test_format_constraint() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Should show format constraint + assert!(mdx.contains("email"), "Should show format constraint"); + } + + #[test] + fn test_pattern_constraint() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "code": { + "type": "string", + "pattern": "^[A-Z]{3}$" + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Should show pattern constraint + assert!(mdx.contains("^[A-Z]{3}$"), "Should show pattern constraint"); + } + + #[test] + fn test_min_max_constraints() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "age": { + "type": "integer", + "minimum": 0, + "maximum": 120 + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Should show min/max constraints + assert!(mdx.contains("0"), "Should show minimum"); + assert!(mdx.contains("120"), "Should show maximum"); + } + + #[test] + fn test_usage_links() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "SharedType": { "type": "string", "description": "A shared type" } + }, + "properties": { + "field1": { "$ref": "#/$defs/SharedType" }, + "field2": { "$ref": "#/$defs/SharedType" } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Type definition should have usage links + assert!( + mdx.contains("field1") && mdx.contains("field2"), + "Should show where the type is used" + ); + } + + #[test] + fn test_anchor_link_format() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "MyType": { "type": "string" } + }, + "properties": { + "field": { "$ref": "#/$defs/MyType" } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Anchor links should be lowercase + assert!(mdx.contains("#mytype"), "Anchor links should be lowercase"); + } + + #[test] + fn test_wrapper_types() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "InnerType": { "type": "string" }, + "WrapperType": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/InnerType" } + }, + { "$ref": "#/$defs/InnerType" } + ] + } + }, + "properties": { + "config": { "$ref": "#/$defs/WrapperType" } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Wrapper types should show inner type + assert!( + mdx.contains("InnerType") || mdx.contains("innertype"), + "Should show inner type for wrapper" + ); + } + + #[test] + fn test_option_t_pattern() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "optional_value": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Should show optional badge, not enum variants + assert!(mdx.contains("optional"), "Should show optional badge"); + // Should show the actual type + assert!(mdx.contains("string"), "Should show string type"); + } + + #[test] + fn test_allof_schemas() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "combined": { + "allOf": [ + { "type": "object", "properties": { "name": { "type": "string" } } }, + { "type": "object", "properties": { "age": { "type": "integer" } } } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Should show allOf content + assert!( + mdx.contains("name"), + "Should show first allOf schema properties" + ); + assert!( + mdx.contains("age"), + "Should show second allOf schema properties" + ); + } + + #[test] + fn test_homogeneous_tuple() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "point": { + "type": "array", + "prefixItems": [ + { "type": "number" }, + { "type": "number" } + ], + "minItems": 2, + "maxItems": 2 + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Should show compact tuple notation + assert!( + mdx.contains("float; 2") + || mdx.contains("number; 2") + || mdx.contains("[float; 2]") + || mdx.contains("[number; 2]"), + "Should show compact tuple notation" + ); + } + + #[test] + fn test_string_enum_detection() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "mode": { + "oneOf": [ + { "type": "string", "const": "A", "description": "Mode A" }, + { "type": "string", "const": "B", "description": "Mode B" } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // String enum should be detected + assert!( + mdx.contains("enum") || mdx.contains("Enum"), + "Should detect string enum pattern" + ); + } + + #[test] + fn test_escape_braces_outside_code_plain_text() { + // Plain text should have braces escaped + let result = escape_braces_outside_code("Use {foo} and {bar}"); + assert_eq!(result, "Use \\{foo\\} and \\{bar\\}"); + } + + #[test] + fn test_escape_braces_outside_code_inline_code() { + // Braces inside inline code should NOT be escaped + let result = escape_braces_outside_code("Use `{foo}` in your code"); + assert_eq!(result, "Use `{foo}` in your code"); + } + + #[test] + fn test_escape_braces_outside_code_fenced_block() { + // Braces inside fenced code blocks should NOT be escaped + let input = "Example:\n```json\n{\"key\": \"value\"}\n```\nEnd"; + let result = escape_braces_outside_code(input); + assert_eq!(result, "Example:\n```json\n{\"key\": \"value\"}\n```\nEnd"); + } + + #[test] + fn test_escape_braces_outside_code_mixed() { + // Mix of plain text and code - only plain text braces escaped + let input = "Set `{option}` to {value}"; + let result = escape_braces_outside_code(input); + assert_eq!(result, "Set `{option}` to \\{value\\}"); + } + + #[test] + fn test_escape_braces_outside_code_multiple_code_blocks() { + // Multiple fenced code blocks + let input = "First:\n```\n{a}\n```\nMiddle {b}\n```\n{c}\n```\nEnd"; + let result = escape_braces_outside_code(input); + assert_eq!( + result, + "First:\n```\n{a}\n```\nMiddle \\{b\\}\n```\n{c}\n```\nEnd" + ); + } + + #[test] + fn test_escape_braces_outside_code_no_braces() { + // No braces should pass through unchanged + let result = escape_braces_outside_code("Just plain text"); + assert_eq!(result, "Just plain text"); + } + + #[test] + fn test_escape_braces_outside_code_nested_inline() { + // Multiple inline code segments + let result = escape_braces_outside_code("`{a}` and `{b}` with {c}"); + assert_eq!(result, "`{a}` and `{b}` with \\{c\\}"); + } + + // ========================================================================= + // Enum Pattern Tests - Ensure Value: is never rendered twice + // See ENUMS.md for full pattern documentation + // ========================================================================= + + /// Helper to count occurrences of a pattern in a string + fn count_occurrences(haystack: &str, needle: &str) -> usize { + haystack.matches(needle).count() + } + + /// Helper to count occurrences of "Value:" followed by a specific value + fn count_value_occurrences(mdx: &str, value: &str) -> usize { + // Look for patterns like: **Value:** `"Horizontal"` + let pattern = format!("**Value:** `\"{}\"`", value); + count_occurrences(mdx, &pattern) + } + + // Pattern 1: Simple Unit Enums (String Constants) + // oneOf with {type: "string", const: "Value"} + #[test] + fn test_pattern1_simple_unit_enum_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "axis": { + "description": "Axis selection", + "oneOf": [ + { "description": "Horizontal axis", "type": "string", "const": "Horizontal" }, + { "description": "Vertical axis", "type": "string", "const": "Vertical" }, + { "description": "Both axes", "type": "string", "const": "HorizontalAndVertical" } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // Each variant's Value: should appear exactly once + assert_eq!( + count_value_occurrences(&mdx, "Horizontal"), + 1, + "Horizontal Value: should appear exactly once. MDX:\n{}", + mdx + ); + assert_eq!( + count_value_occurrences(&mdx, "Vertical"), + 1, + "Vertical Value: should appear exactly once. MDX:\n{}", + mdx + ); + assert_eq!( + count_value_occurrences(&mdx, "HorizontalAndVertical"), + 1, + "HorizontalAndVertical Value: should appear exactly once. MDX:\n{}", + mdx + ); + } + + // Pattern 2: Unit Enum with Default Value + // Same as Pattern 1 but with "default" field at enum level + #[test] + fn test_pattern2_unit_enum_with_default_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "layout": { + "description": "A predefined layout", + "default": "BSP", + "oneOf": [ + { "description": "BSP Layout", "type": "string", "const": "BSP" }, + { "description": "Columns Layout", "type": "string", "const": "Columns" } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + assert_eq!( + count_value_occurrences(&mdx, "BSP"), + 1, + "BSP Value: should appear exactly once. MDX:\n{}", + mdx + ); + assert_eq!( + count_value_occurrences(&mdx, "Columns"), + 1, + "Columns Value: should appear exactly once. MDX:\n{}", + mdx + ); + } + + // Pattern 3: Mixed Enum (Unit Variants + Newtype Variants) + // oneOf with unit variants as {type: "string", const: "Name"} and + // newtype variants as objects with variant name as property key + #[test] + fn test_pattern3_mixed_enum_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "format": { + "description": "Date format", + "oneOf": [ + { "description": "Month/Date/Year format", "type": "string", "const": "MonthDateYear" }, + { "description": "Year-Month-Date format", "type": "string", "const": "YearMonthDate" }, + { + "title": "Custom", + "description": "Custom format", + "type": "object", + "properties": { + "Custom": { "type": "string" } + }, + "additionalProperties": false, + "required": ["Custom"] + } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + assert_eq!( + count_value_occurrences(&mdx, "MonthDateYear"), + 1, + "MonthDateYear Value: should appear exactly once. MDX:\n{}", + mdx + ); + assert_eq!( + count_value_occurrences(&mdx, "YearMonthDate"), + 1, + "YearMonthDate Value: should appear exactly once. MDX:\n{}", + mdx + ); + } + + // Pattern 4a: Untagged with Primitives + // anyOf with {type: "integer"} and {$ref: "..."} + #[test] + fn test_pattern4a_untagged_primitives_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "MonitorConfig": { "type": "object", "properties": { "name": { "type": "string" } } } + }, + "properties": { + "monitor": { + "description": "Monitor configuration or index", + "anyOf": [ + { "description": "The monitor index", "type": "integer", "format": "uint", "minimum": 0 }, + { "description": "The full config", "$ref": "#/$defs/MonitorConfig" } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // For anyOf with primitives, we should see the type badge, not duplicate values + // Count total **Value:** occurrences - should not have duplicates + let total_value_lines = count_occurrences(&mdx, "**Value:**"); + // Each variant should have at most one Value: line + assert!( + total_value_lines <= 2, + "Should have at most 2 Value: lines (one per variant). Found {}. MDX:\n{}", + total_value_lines, + mdx + ); + } + + // Pattern 4b: Untagged with Multiple Struct Refs + #[test] + fn test_pattern4b_untagged_multiple_refs_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "IndividualSpacing": { "type": "object" }, + "GroupedSpacing": { "type": "object" } + }, + "properties": { + "spacing": { + "description": "Spacing kind", + "anyOf": [ + { "description": "All sides", "type": "number", "format": "float" }, + { "description": "Individual", "$ref": "#/$defs/IndividualSpacing" }, + { "description": "Grouped", "$ref": "#/$defs/GroupedSpacing" } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + let total_value_lines = count_occurrences(&mdx, "**Value:**"); + assert!( + total_value_lines <= 3, + "Should have at most 3 Value: lines. Found {}. MDX:\n{}", + total_value_lines, + mdx + ); + } + + // Pattern 4c: Untagged with Two $refs + #[test] + fn test_pattern4c_untagged_two_refs_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "Rgb": { "type": "object" }, + "Hex": { "type": "string" } + }, + "properties": { + "colour": { + "description": "Colour representation", + "anyOf": [ + { "description": "RGB", "$ref": "#/$defs/Rgb" }, + { "description": "Hex", "$ref": "#/$defs/Hex" } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + let total_value_lines = count_occurrences(&mdx, "**Value:**"); + assert!( + total_value_lines <= 2, + "Should have at most 2 Value: lines. Found {}. MDX:\n{}", + total_value_lines, + mdx + ); + } + + // Pattern 4d: Untagged with Single vs Array + #[test] + fn test_pattern4d_untagged_single_vs_array_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "IdWithIdentifier": { "type": "object" } + }, + "properties": { + "rule": { + "description": "Matching rule", + "anyOf": [ + { "description": "Simple rule", "$ref": "#/$defs/IdWithIdentifier" }, + { "description": "Composite rule", "type": "array", "items": { "$ref": "#/$defs/IdWithIdentifier" } } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + let total_value_lines = count_occurrences(&mdx, "**Value:**"); + assert!( + total_value_lines <= 2, + "Should have at most 2 Value: lines. Found {}. MDX:\n{}", + total_value_lines, + mdx + ); + } + + // Pattern 4e: Untagged with Tuple + #[test] + fn test_pattern4e_untagged_tuple_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "spacing_options": { + "description": "Grouped spacing options", + "anyOf": [ + { "description": "Symmetrical", "type": "number", "format": "float" }, + { + "description": "Split spacing", + "type": "array", + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { "type": "number", "format": "float" }, + { "type": "number", "format": "float" } + ] + } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + let total_value_lines = count_occurrences(&mdx, "**Value:**"); + assert!( + total_value_lines <= 2, + "Should have at most 2 Value: lines. Found {}. MDX:\n{}", + total_value_lines, + mdx + ); + } + + // Pattern 5: Internally Tagged Enum (Struct Variants with Discriminator) + // oneOf with discriminator field having const value + #[test] + fn test_pattern5_internally_tagged_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "ThemeName": { "type": "string" } + }, + "properties": { + "theme": { + "description": "Theme selection", + "oneOf": [ + { + "title": "Catppuccin", + "description": "A catppuccin theme", + "type": "object", + "properties": { + "palette": { "type": "string", "const": "Catppuccin" }, + "name": { "$ref": "#/$defs/ThemeName" } + }, + "required": ["palette", "name"] + }, + { + "title": "Base16", + "type": "object", + "properties": { + "palette": { "type": "string", "const": "Base16" }, + "name": { "$ref": "#/$defs/ThemeName" } + }, + "required": ["palette", "name"] + } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // The discriminator const values should not cause duplicate Value: lines + // Catppuccin and Base16 are titles, not const values to be rendered + let total_value_lines = count_occurrences(&mdx, "**Value:**"); + // With internally tagged, we typically don't render Value: for the discriminator + // but we might render it for the variant as a whole + assert!( + total_value_lines <= 2, + "Should have reasonable Value: count. Found {}. MDX:\n{}", + total_value_lines, + mdx + ); + } + + // Pattern 6: Internally Tagged Enum with Flattened Newtype Variants + // oneOf with properties + $ref at same level + #[test] + fn test_pattern6_internally_tagged_flatten_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "GroupingConfig": { + "type": "object", + "properties": { + "style": { "type": "string" } + } + } + }, + "properties": { + "grouping": { + "description": "Grouping mode", + "oneOf": [ + { + "title": "None", + "description": "No grouping", + "type": "object", + "properties": { + "kind": { "type": "string", "const": "None" } + }, + "required": ["kind"] + }, + { + "title": "Bar", + "description": "Bar grouping", + "type": "object", + "properties": { + "kind": { "type": "string", "const": "Bar" } + }, + "$ref": "#/$defs/GroupingConfig", + "required": ["kind"] + } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + let total_value_lines = count_occurrences(&mdx, "**Value:**"); + assert!( + total_value_lines <= 2, + "Should have reasonable Value: count. Found {}. MDX:\n{}", + total_value_lines, + mdx + ); + + // Pattern 6 variants with $ref should show the type link, not "object" + // The "Bar" variant has a $ref, so it should show the link + assert!( + mdx.contains("[`GroupingConfig`](#groupingconfig)"), + "Pattern 6 variant with $ref should show type link. MDX:\n{}", + mdx + ); + // The "Bar" variant should NOT show "object" - it should show the $ref link + // Note: "None" variant has no $ref, so it correctly shows "object" + assert!( + !mdx.contains("**Value:** [`GroupingConfig`](#groupingconfig)") + || count_occurrences(&mdx, "**Value:** `object`") <= 1, + "Pattern 6 variant with $ref should show type link instead of 'object'. MDX:\n{}", + mdx + ); + + // Pattern 6 variants with $ref should NOT inline the referenced type's properties + // Since we show a link to the type, the properties are redundant + // The "style" property from GroupingConfig should NOT appear in the variant body + // (it should only appear in the type definition section, not in the enum variant) + let style_count = count_occurrences(&mdx, "#### style"); + assert!( + style_count <= 1, + "Pattern 6 variant should not inline $ref properties. Found {} '#### style' occurrences (should be 0 or 1 for the $defs section only). MDX:\n{}", + style_count, + mdx + ); + } + + // Pattern 7: Externally Tagged Enum (Newtype Variants Only) + // oneOf with variant name as property key + #[test] + fn test_pattern7_externally_tagged_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "ApplicationsConfig": { "type": "object" }, + "BatteryConfig": { "type": "object" } + }, + "properties": { + "widget": { + "description": "Widget configuration", + "oneOf": [ + { + "title": "Applications", + "description": "Applications widget", + "type": "object", + "properties": { + "Applications": { "$ref": "#/$defs/ApplicationsConfig" } + }, + "additionalProperties": false, + "required": ["Applications"] + }, + { + "title": "Battery", + "type": "object", + "properties": { + "Battery": { "$ref": "#/$defs/BatteryConfig" } + }, + "additionalProperties": false, + "required": ["Battery"] + } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + let total_value_lines = count_occurrences(&mdx, "**Value:**"); + assert!( + total_value_lines <= 2, + "Should have reasonable Value: count. Found {}. MDX:\n{}", + total_value_lines, + mdx + ); + } + + // Pattern 9: Untagged Hybrid Enum (Unit Variants + $ref Fallback) + // anyOf mixing const strings + $ref + #[test] + fn test_pattern9_untagged_hybrid_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "DisplayFormat": { + "oneOf": [ + { "type": "string", "const": "Text" }, + { "type": "string", "const": "Icon" } + ] + } + }, + "properties": { + "format": { + "description": "Display format", + "anyOf": [ + { "description": "All icons", "type": "string", "const": "AllIcons" }, + { "description": "All icons and text", "type": "string", "const": "AllIconsAndText" }, + { "$ref": "#/$defs/DisplayFormat" } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + assert_eq!( + count_value_occurrences(&mdx, "AllIcons"), + 1, + "AllIcons Value: should appear exactly once. MDX:\n{}", + mdx + ); + assert_eq!( + count_value_occurrences(&mdx, "AllIconsAndText"), + 1, + "AllIconsAndText Value: should appear exactly once. MDX:\n{}", + mdx + ); + } + + // Pattern 10: Generic Enum Instantiations + // anyOf with {additionalProperties: T} + T + #[test] + fn test_pattern10_generic_enum_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "config": { + "description": "Per-animation config", + "anyOf": [ + { + "description": "Prefixed config", + "type": "object", + "additionalProperties": { "type": "boolean" } + }, + { + "description": "Global config", + "type": "boolean" + } + ] + } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + let total_value_lines = count_occurrences(&mdx, "**Value:**"); + assert!( + total_value_lines <= 2, + "Should have at most 2 Value: lines. Found {}. MDX:\n{}", + total_value_lines, + mdx + ); + } + + // Test for the specific issue reported - Pattern 1 with Axis enum + #[test] + fn test_axis_enum_exact_case_no_duplicate_value() { + let generator = MdxGenerator::new().unwrap(); + // This is the exact schema structure from the bug report + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "Axis": { + "description": "Axis", + "oneOf": [ + { "description": "Horizontal axis", "type": "string", "const": "Horizontal" }, + { "description": "Vertical axis", "type": "string", "const": "Vertical" }, + { "description": "Both horizontal and vertical axes", "type": "string", "const": "HorizontalAndVertical" } + ] + } + }, + "properties": { + "axis": { "$ref": "#/$defs/Axis" } + } + })); + + let options = MdxOptions::default(); + let mdx = generator.generate(&schema, &options).unwrap(); + + // The bug was that each variant's Value: was appearing twice + assert_eq!( + count_value_occurrences(&mdx, "Horizontal"), + 1, + "Horizontal Value: should appear exactly once (was appearing twice). MDX:\n{}", + mdx + ); + assert_eq!( + count_value_occurrences(&mdx, "Vertical"), + 1, + "Vertical Value: should appear exactly once (was appearing twice). MDX:\n{}", + mdx + ); + assert_eq!( + count_value_occurrences(&mdx, "HorizontalAndVertical"), + 1, + "HorizontalAndVertical Value: should appear exactly once (was appearing twice). MDX:\n{}", + mdx + ); + } +} diff --git a/crates/schemars-docgen/src/prepare.rs b/crates/schemars-docgen/src/prepare.rs new file mode 100644 index 00000000..9974b693 --- /dev/null +++ b/crates/schemars-docgen/src/prepare.rs @@ -0,0 +1,2966 @@ +//! Schema preparation for template rendering +//! +//! This module handles light preparation of the schema before passing it to Tera: +//! - Rendering markdown descriptions to HTML +//! - Generating anchor IDs for navigation +//! - Building the search index + +use pulldown_cmark::Parser; +use pulldown_cmark::html; +use serde::Serialize; +use serde_json::Map; +use serde_json::Value; +use std::collections::HashMap; +use std::collections::HashSet; + +/// Prepare a schema value for template rendering +/// +/// This function: +/// 1. Clones the schema value +/// 2. Recursively renders all `description` fields as markdown to `description_html` +/// 3. Normalizes non-$defs refs to prevent template errors +/// 4. Returns the modified value ready for Tera +pub fn prepare_for_template(value: &Value) -> Value { + let mut prepared = value.clone(); + process_value(&mut prepared); + let root_schema = prepared.clone(); + normalize_refs(&mut prepared, &root_schema); + detect_wrapper_types(&mut prepared); + add_wrapper_display_names(&mut prepared); + annotate_defaults_with_refs(&mut prepared); + prepared +} + +/// Add display names to wrapper types by stripping numeric suffixes +/// +/// For wrapper types like `PerAnimationPrefixConfig2` or `PerAnimationPrefixConfig3`, +/// this adds a `_display_name` field with the base name (e.g., `PerAnimationPrefixConfig`). +/// This only applies to types marked with `_wrapper_type: true`, not all types ending in digits. +/// Legitimate type names like `Base16` or `Sha256` are left unchanged. +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() { + // Only strip trailing digits from wrapper types (not all types) + if def.get("_wrapper_type") == Some(&Value::Bool(true)) { + let display_name = name.trim_end_matches(|c: char| c.is_ascii_digit()); + + // Only add _display_name if it differs from the original name + if display_name != name + && let Some(obj) = def.as_object_mut() + { + obj.insert( + "_display_name".to_string(), + Value::String(display_name.to_string()), + ); + } + } + } + } +} + +/// Annotate default values with type references for linking +/// +/// When a property has a `default` value and references a type via `$ref` (directly or +/// through `anyOf`/`oneOf` for Option patterns), this adds a `_default_ref` field +/// with the referenced type name. This allows templates to render defaults as links +/// to the type definition. +/// +/// For example, if a property has: +/// - `"anyOf": [{"$ref": "#/$defs/MoveBehaviour"}, {"type": "null"}]` +/// - `"default": "Swap"` +/// +/// This adds `"_default_ref": "MoveBehaviour"` so the template can render: +/// `Default: [Swap](#movebehaviour)` +fn annotate_defaults_with_refs(schema: &mut Value) { + annotate_defaults_recursive(schema); +} + +fn annotate_defaults_recursive(value: &mut Value) { + match value { + Value::Object(map) => { + // Check if this object has a default value + if map.contains_key("default") { + // Try to find a $ref to a type definition + if let Some(ref_name) = extract_type_ref(map) { + map.insert("_default_ref".to_string(), Value::String(ref_name)); + } + } + + // Recurse into all nested values + for v in map.values_mut() { + annotate_defaults_recursive(v); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + annotate_defaults_recursive(v); + } + } + _ => {} + } +} + +/// Extract the type name from a $ref, handling various patterns: +/// - Direct `$ref`: `{"$ref": "#/$defs/TypeName"}` +/// - Option via anyOf: `{"anyOf": [{"$ref": "#/$defs/TypeName"}, {"type": "null"}]}` +/// - Option via oneOf: `{"oneOf": [{"$ref": "#/$defs/TypeName"}, {"type": "null"}]}` +fn extract_type_ref(map: &Map) -> Option { + // Check for direct $ref + if let Some(ref_value) = map.get("$ref") + && let Some(ref_str) = ref_value.as_str() + && let Some(type_name) = ref_str.strip_prefix("#/$defs/") + { + return Some(type_name.to_string()); + } + + // Check for anyOf/oneOf Option pattern + for key in ["anyOf", "oneOf"] { + if let Some(variants) = map.get(key).and_then(|v| v.as_array()) { + // Look for the pattern: exactly 2 variants, one null and one $ref + if variants.len() == 2 { + let null_count = variants + .iter() + .filter(|v| v.get("type").and_then(|t| t.as_str()) == Some("null")) + .count(); + + if null_count == 1 { + // Find the non-null variant with a $ref + for variant in variants { + if variant.get("type").and_then(|t| t.as_str()) != Some("null") + && let Some(ref_str) = variant.get("$ref").and_then(|v| v.as_str()) + && let Some(type_name) = ref_str.strip_prefix("#/$defs/") + { + return Some(type_name.to_string()); + } + } + } + } + } + } + + None +} + +/// Embed usages directly into each $defs entry +/// +/// This avoids Tera template lookup issues with special characters in def names +/// (like quotes or empty strings). Each definition will have `_usages` with +/// `config_usages` and `type_references` arrays. +pub fn embed_usages_in_defs(schema: &mut Value, usages: &HashMap) { + if let Some(defs) = schema.get_mut("$defs").and_then(|v| v.as_object_mut()) { + for (name, def) in defs.iter_mut() { + if let Some(obj) = def.as_object_mut() + && let Some(type_usages) = usages.get(name) + { + obj.insert( + "_usages".to_string(), + serde_json::to_value(type_usages).unwrap_or(Value::Null), + ); + } + } + } +} + +/// Normalize $ref values that don't follow the #/$defs/TypeName pattern +/// +/// The template expects all $refs to be in the format #/$defs/TypeName. +/// This function handles other ref patterns: +/// - `#` (root ref): Replaces with the root schema inline +/// - `#/properties/foo` (JSON pointer): Resolves to the target or marks as unresolved +/// - `#/$defs/percent%25field` (URL-encoded): Decodes to the actual $defs name +/// - External URIs: Marks as unresolved reference +fn normalize_refs(value: &mut Value, root_schema: &Value) { + normalize_refs_recursive(value, root_schema); +} + +fn normalize_refs_recursive(value: &mut Value, root_schema: &Value) { + match value { + Value::Object(map) => { + // Check if this object has a $ref that needs normalization + if let Some(ref_value) = map.get("$ref").cloned() + && let Some(ref_str) = ref_value.as_str() + && let Some(normalized) = normalize_single_ref(ref_str, root_schema) + { + // Replace this object with the resolved schema + // But preserve any sibling properties (like description) + if let Value::Object(resolved_map) = normalized { + // Merge: keep original properties, add resolved ones + let original_ref = map.remove("$ref"); + for (k, v) in resolved_map { + if !map.contains_key(&k) { + map.insert(k, v); + } + } + // If we couldn't resolve, restore the ref but mark it + if (map.is_empty() + || (!map.contains_key("type") + && !map.contains_key("anyOf") + && !map.contains_key("oneOf") + && !map.contains_key("allOf") + && !map.contains_key("properties") + && !map.contains_key("enum") + && !map.contains_key("const"))) + && let Some(orig) = original_ref + { + map.insert("$ref".to_string(), orig); + } + } + } + + // Recurse into all nested values + for v in map.values_mut() { + normalize_refs_recursive(v, root_schema); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + normalize_refs_recursive(v, root_schema); + } + } + _ => {} + } +} + +/// Normalize a single $ref string, returning the resolved schema if needed +fn normalize_single_ref(ref_str: &str, root_schema: &Value) -> Option { + // Already in expected format - decode URL-encoded characters for $defs lookup + if let Some(encoded_name) = ref_str.strip_prefix("#/$defs/") { + // Skip "#/$defs/" + + // Decode JSON pointer escapes: ~1 -> /, ~0 -> ~ + let decoded = encoded_name.replace("~1", "/").replace("~0", "~"); + + // Decode URL-encoded characters: %25 -> %, etc. + let decoded = percent_decode(&decoded); + + // If the decoded name differs from encoded, update the ref + if decoded != encoded_name { + // Check if the decoded name exists in $defs + if let Some(defs) = root_schema.get("$defs").and_then(|v| v.as_object()) + && defs.contains_key(&decoded) + { + return Some(serde_json::json!({ + "$ref": format!("#/$defs/{}", decoded) + })); + } + } + return None; // No change needed + } + + // Root reference: # + if ref_str == "#" { + // Return a placeholder type - we can't inline the whole root + return Some(serde_json::json!({ + "type": "object", + "description": "(recursive reference to root schema)" + })); + } + + // JSON pointer reference: #/properties/foo, #/items, etc. + if ref_str.starts_with("#/") { + if let Some(resolved) = resolve_json_pointer(ref_str, root_schema) { + return Some(resolved.clone()); + } + // Couldn't resolve - return a placeholder + return Some(serde_json::json!({ + "description": format!("(unresolved reference: {})", ref_str) + })); + } + + // External URI reference (http://, https://, urn:, file://, etc.) + if ref_str.contains("://") || ref_str.starts_with("urn:") { + return Some(serde_json::json!({ + "description": format!("(external reference: {})", ref_str) + })); + } + + // Relative URI reference (e.g., "other-schema.json#/$defs/Foo") + if ref_str.contains('#') || ref_str.ends_with(".json") { + return Some(serde_json::json!({ + "description": format!("(external reference: {})", ref_str) + })); + } + + None // Unknown format, leave as-is +} + +/// Decode percent-encoded characters in a string +fn percent_decode(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '%' { + // Try to read two hex digits + let hex: String = chars.by_ref().take(2).collect(); + if hex.len() == 2 + && let Ok(byte) = u8::from_str_radix(&hex, 16) + { + result.push(byte as char); + continue; + } + // If decoding failed, keep the original + result.push('%'); + result.push_str(&hex); + } else { + result.push(c); + } + } + + result +} + +/// Resolve a JSON pointer within the schema +fn resolve_json_pointer<'a>(pointer: &str, schema: &'a Value) -> Option<&'a Value> { + // Remove leading # + let pointer = pointer.strip_prefix('#')?; + + if pointer.is_empty() { + return Some(schema); + } + + // Remove leading / + let pointer = pointer.strip_prefix('/')?; + + let mut current = schema; + for segment in pointer.split('/') { + // Decode JSON pointer escapes + let decoded = segment.replace("~1", "/").replace("~0", "~"); + // Also decode percent-encoding + let decoded = percent_decode(&decoded); + + current = match current { + Value::Object(map) => map.get(&decoded)?, + Value::Array(arr) => { + let index: usize = decoded.parse().ok()?; + arr.get(index)? + } + _ => return None, + }; + } + + Some(current) +} + +/// Recursively process a value, rendering markdown descriptions +fn process_value(value: &mut Value) { + match value { + Value::Object(map) => { + // Render description if present + if let Some(Value::String(desc)) = map.get("description") { + let html = render_markdown(desc); + map.insert("description_html".to_string(), Value::String(html)); + } + + // Recurse into all nested values + for v in map.values_mut() { + process_value(v); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + process_value(v); + } + } + _ => {} + } +} + +/// 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 +/// +/// Handles two patterns: +/// 1. Old pattern (schemars 1.1): object with additionalProperties: { schema } +/// 2. New pattern (schemars 1.2): object with properties: { key: { schema } } + additionalProperties: false +/// This is used for HashMap where enum keys become explicit property names +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; + } + + // Pattern 1: additionalProperties as object schema (schemars 1.1) + if let Some(additional) = obj.get("additionalProperties") { + if additional.is_object() { + return Some(additional); + } + } + + // Pattern 2: enum-keyed map (schemars 1.2) + // Has properties with additionalProperties: false + if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) { + if obj.get("additionalProperties") == Some(&Value::Bool(false)) && !props.is_empty() { + // Return the first property's value as the inner type + // (all properties should have the same schema for enum-keyed maps) + if let Some((_, first_prop)) = props.iter().next() { + return Some(first_prop); + } + } + } + + 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); + let mut html_output = String::new(); + html::push_html(&mut html_output, parser); + html_output +} + +/// Strip markdown formatting from text, keeping only the plain text content. +/// Useful for HTML tags where markdown syntax shouldn't appear. +pub fn strip_markdown(text: &str) -> String { + let parser = Parser::new(text); + let mut plain_text = String::new(); + for event in parser { + if let pulldown_cmark::Event::Text(t) | pulldown_cmark::Event::Code(t) = event { + plain_text.push_str(&t); + } + } + plain_text +} + +/// Information about a definition for the sidebar navigation +#[derive(Debug, Clone, Serialize)] +pub struct DefinitionInfo { + pub name: String, + pub anchor: String, + pub description: Option<String>, + pub display_name: Option<String>, +} + +/// An outline entry for hierarchical navigation +#[derive(Debug, Clone, Serialize)] +pub struct OutlineEntry { + pub name: String, + pub anchor: String, + pub children: Vec<OutlineEntry>, +} + +/// A search index entry +#[derive(Debug, Clone, Serialize)] +pub struct SearchEntry { + pub name: String, + pub path: String, + pub anchor: String, + pub entry_type: String, +} + +/// A usage of a type in the Configuration section +#[derive(Debug, Clone, Serialize)] +pub struct ConfigUsage { + /// jq-style path like ".monitors[].workspaces[].layout" + pub path: String, + /// HTML anchor for linking to the usage location (no leading dash) + pub anchor: String, +} + +/// A reference to a type from another type definition +#[derive(Debug, Clone, Serialize)] +pub struct TypeReference { + /// Name of the referencing type, e.g., "IdWithIdentifier" + pub type_name: String, + /// Anchor to the type definition + pub anchor: String, +} + +/// All usages of a type +#[derive(Debug, Clone, Serialize)] +pub struct TypeUsages { + /// Usages in Configuration section + pub config_usages: Vec<ConfigUsage>, + /// References from other $defs types + pub type_references: Vec<TypeReference>, +} + +/// Navigation data extracted from the schema +#[derive(Debug, Clone, Serialize)] +pub struct NavigationData { + pub outline: Vec<OutlineEntry>, + pub definitions: Vec<DefinitionInfo>, + 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 +const MAX_OUTLINE_DEPTH: usize = 3; + +/// Build a hierarchical outline tree from schema properties +fn build_outline_tree( + schema: &Value, + defs: Option<&Map<String, Value>>, + parent_path: &str, + depth: usize, +) -> Vec<OutlineEntry> { + if depth >= MAX_OUTLINE_DEPTH { + return Vec::new(); + } + + let mut entries = Vec::new(); + + // Resolve the schema, getting both the effective schema and the anchor base + // When we follow a $ref, the anchor base becomes the type name + let (effective_schema, anchor_base) = resolve_schema_for_outline(schema, defs, parent_path); + + // Check if this is a oneOf - show all variants as alternatives + if let Some(one_of) = effective_schema.get("oneOf").and_then(|v| v.as_array()) { + for (i, variant) in one_of.iter().enumerate() { + // Get variant label: prefer title, then look for a const discriminator, then fall back to "Variant N" + let variant_label = variant + .get("title") + .and_then(|t| t.as_str()) + .map(String::from) + .or_else(|| { + // Look for a const property that could serve as a discriminator (e.g., "palette": {"const": "Catppuccin"}) + variant + .get("properties") + .and_then(|p| p.as_object()) + .and_then(|props| { + props.values().find_map(|prop| { + prop.get("const").and_then(|c| c.as_str()).map(String::from) + }) + }) + }) + .unwrap_or_else(|| format!("Variant {}", i + 1)); + + // Build the variant path to match template-generated IDs + // Template uses: path ~ ".oneOf[" ~ loop.index0 ~ "]" + let variant_path = format!("{}.oneOf[{}]", anchor_base, i); + + // Build children for this variant + let variant_children = build_outline_tree(variant, defs, &variant_path, depth + 1); + + if !variant_children.is_empty() { + entries.push(OutlineEntry { + name: format!("⊕ {}", variant_label), // ⊕ indicates "one of" + // Link to the type definition since individual variants don't have anchors + anchor: make_anchor(&anchor_base), + children: variant_children, + }); + } + } + return entries; + } + + if let Some(props) = effective_schema + .get("properties") + .and_then(|v| v.as_object()) + { + for (name, prop) in props { + // Skip properties with const values (discriminator fields in internally tagged enums) + // These are shown as variant headings in templates, not as rendered properties + if prop.get("const").is_some() { + continue; + } + + // Build the anchor path using the resolved anchor base + let child_anchor_path = if anchor_base.is_empty() { + name.clone() + } else { + format!("{}.{}", anchor_base, name) + }; + + // Recursively build children + let children = build_outline_tree(prop, defs, &child_anchor_path, depth + 1); + + entries.push(OutlineEntry { + name: name.clone(), + anchor: make_anchor(&child_anchor_path), + children, + }); + } + // Sort alphabetically + entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + } + + entries +} + +/// Resolve schema following $refs, arrays, and Option<T>, returning (effective_schema, anchor_base) +/// When we follow a $ref, anchor_base becomes the type name so links go to the type definition +/// For true union types (anyOf with multiple non-null variants), we stop and treat as leaf +fn resolve_schema_for_outline<'a>( + schema: &'a Value, + defs: Option<&'a Map<String, Value>>, + current_path: &str, +) -> (&'a Value, String) { + // Handle $ref - use the ref name as anchor base + if let Some(ref_path) = schema.get("$ref").and_then(|v| v.as_str()) { + let ref_name = ref_path.trim_start_matches("#/$defs/"); + if let Some(def) = defs.and_then(|d| d.get(ref_name)) { + // Recursively resolve in case the def itself has a $ref or anyOf + // Use the ref_name as the new anchor base + return resolve_schema_for_outline(def, defs, ref_name); + } + return (schema, current_path.to_string()); + } + + // 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); + } + + // 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(); + + // 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 + + // Inline object or leaf type - keep current path + (schema, current_path.to_string()) +} + +/// Check if schema represents an array type +fn is_array_type(schema: &Value) -> bool { + // Check type: "array" + if schema.get("type").and_then(|t| t.as_str()) == Some("array") { + return true; + } + // 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; + } + false +} + +/// Check if schema is an enum-keyed map (schemars 1.2 pattern) +/// +/// This pattern is generated for HashMap<EnumKey, T> where enum variant names +/// become explicit property keys with additionalProperties: false. +/// These properties are rendered inline in templates (Pattern 7), not as separate headings. +fn is_enum_keyed_map(schema: &Value) -> bool { + if let Some(obj) = schema.as_object() { + if obj.get("type").and_then(|t| t.as_str()) == Some("object") { + if obj.get("additionalProperties") == Some(&Value::Bool(false)) { + if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) { + return !props.is_empty(); + } + } + } + } + false +} + +/// Collect all usages of type definitions ($refs) in the Configuration section +/// +/// Recursively traverses the schema and records where each type is referenced, +/// building jq-style paths for each usage location. Follows $refs transitively +/// to collect usages for types nested inside other types. +/// +/// Parameters: +/// - `current_type`: When inside a followed $ref, this is the type name we're inside. +/// Used to build correct anchors that point to the type definition's rendered properties. +/// - `type_relative_path`: When inside a type, this tracks the path relative to that type +/// (e.g., "workspaces" inside MonitorConfig), used for building anchors. +fn collect_config_usages( + schema: &Value, + path: String, + usages: &mut HashMap<String, Vec<ConfigUsage>>, + defs: Option<&Map<String, Value>>, + visited: &mut HashSet<String>, + current_type: Option<&str>, + type_relative_path: String, +) { + // Check for $ref at current level + if let Some(ref_path) = schema.get("$ref").and_then(|v| v.as_str()) { + let type_name = ref_path.trim_start_matches("#/$defs/").to_string(); + + // Build the anchor based on whether we're inside a type or at config root + let anchor = if let Some(parent_type) = current_type { + // Inside a type definition - anchor points to property within that type + // e.g., MonitorConfig.workspaces -> "monitorconfig-workspaces" + // e.g., KomorebiTheme.oneOf[0].bar_accent -> "komorebitheme-oneof[0]-bar_accent" + // Note: Keep [N] for variant indices, but remove empty [] from array notation + let clean_path = type_relative_path.replace("[]", ""); + + // If the path is ONLY a variant index (e.g., "oneOf[1]"), the $ref is at variant root. + // In this case, link to the parent type, not the non-existent variant anchor. + // The template doesn't render separate heading IDs for bare $ref variants. + let is_variant_root_ref = (clean_path.starts_with("oneOf[") + || clean_path.starts_with("anyOf[") + || clean_path.starts_with("allOf[")) + && !clean_path.contains('.'); + + if clean_path.is_empty() || is_variant_root_ref { + make_anchor(parent_type) + } else { + make_anchor(&format!("{}.{}", parent_type, clean_path)) + } + } else { + // 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("]", ""); + make_anchor(&clean_path) + }; + + let usage = ConfigUsage { + path: if path.is_empty() { + ".".to_string() + } else { + path.clone() + }, + anchor, + }; + usages.entry(type_name.clone()).or_default().push(usage); + + // 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 + } + return; // $ref schema has no other content to process + } + + // Recurse into properties + // Skip enum-keyed maps (schemars 1.2) - they render inline without heading anchors + if !is_enum_keyed_map(schema) { + if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) { + for (prop_name, prop_schema) in props { + let new_path = if path.is_empty() { + format!(".{}", prop_name) + } else { + format!("{}.{}", path, prop_name) + }; + let new_type_relative = if type_relative_path.is_empty() { + prop_name.clone() + } else { + format!("{}.{}", type_relative_path, prop_name) + }; + collect_config_usages( + prop_schema, + new_path, + usages, + defs, + visited, + current_type, + new_type_relative, + ); + } + } + } + + // Recurse into array items + if let Some(items) = schema.get("items") { + let new_path = format!("{}[]", path); + // For arrays, the relative path stays the same (items are part of the array property) + collect_config_usages( + items, + new_path, + usages, + defs, + visited, + current_type, + type_relative_path.clone(), + ); + } + + // Recurse into prefixItems (tuples) + if let Some(prefix_items) = schema.get("prefixItems").and_then(|v| v.as_array()) { + for (i, item) in prefix_items.iter().enumerate() { + let new_path = format!("{}[{}]", path, i); + collect_config_usages( + item, + new_path, + usages, + defs, + visited, + current_type, + type_relative_path.clone(), + ); + } + } + + // Recurse into anyOf/oneOf/allOf variants + // Only add variant indices to path when the variant has properties (will render sub-IDs) + // For $ref or primitive variants, the template doesn't render separate IDs + for key in ["anyOf", "oneOf", "allOf"] { + if let Some(variants) = schema.get(key).and_then(|v| v.as_array()) { + for (i, variant) in variants.iter().enumerate() { + // Only add variant index if: + // 1. We're inside a type (current_type.is_some()) + // 2. We're at the type root level (type_relative_path.is_empty()) + // 3. The variant has properties (will render sub-IDs in template) + let variant_has_properties = variant.get("properties").is_some(); + let new_type_relative = if current_type.is_some() + && type_relative_path.is_empty() + && variant_has_properties + { + format!("{}[{}]", key, i) + } else { + type_relative_path.clone() + }; + collect_config_usages( + variant, + path.clone(), + usages, + defs, + visited, + current_type, + new_type_relative, + ); + } + } + } + + // Recurse into patternProperties + if let Some(patterns) = schema.get("patternProperties").and_then(|v| v.as_object()) { + for (_pattern, prop_schema) in patterns { + let new_path = format!("{}[*]", path); + collect_config_usages( + prop_schema, + new_path, + usages, + defs, + visited, + current_type, + type_relative_path.clone(), + ); + } + } + + // 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(), + ); + } +} + +/// Collect references to types from within $defs +/// +/// For each type definition, find all $refs it contains and record them +/// as references from that type to the target type. +fn collect_type_references(defs: &Map<String, Value>) -> HashMap<String, Vec<TypeReference>> { + let mut references: HashMap<String, Vec<TypeReference>> = HashMap::new(); + + for (def_name, def_schema) in defs { + collect_refs_in_schema(def_schema, def_name, &mut references); + } + + // Sort and deduplicate references + for ref_list in references.values_mut() { + ref_list.sort_by(|a, b| a.type_name.cmp(&b.type_name)); + ref_list.dedup_by(|a, b| a.type_name == b.type_name); + } + + references +} + +/// Recursively find all $refs in a schema and record them as references +fn collect_refs_in_schema( + schema: &Value, + source_type: &str, + references: &mut HashMap<String, Vec<TypeReference>>, +) { + if let Some(ref_path) = schema.get("$ref").and_then(|v| v.as_str()) { + let target_type = ref_path.trim_start_matches("#/$defs/").to_string(); + if target_type != source_type { + // Don't self-reference + let reference = TypeReference { + type_name: source_type.to_string(), + anchor: make_anchor(source_type), // Links to type definition + }; + references.entry(target_type).or_default().push(reference); + } + } + + // Recurse into all nested structures + if let Some(obj) = schema.as_object() { + for value in obj.values() { + collect_refs_in_schema(value, source_type, references); + } + } + if let Some(arr) = schema.as_array() { + for value in arr { + collect_refs_in_schema(value, source_type, references); + } + } +} + +/// Build navigation data from a schema value +pub fn build_navigation(schema: &Value) -> NavigationData { + let mut definitions = Vec::new(); + let mut search_index = Vec::new(); + + // Get $defs for resolving references + let defs = schema.get("$defs").and_then(|v| v.as_object()); + + // Build hierarchical outline from top-level properties + let outline = build_outline_tree(schema, defs, "", 0); + + // Also collect for search index + if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) { + collect_properties(props, "", &mut search_index); + } + + // Extract definitions from $defs + if let Some(defs) = schema.get("$defs").and_then(|v| v.as_object()) { + for (name, def) in defs { + let anchor = make_anchor(name); + let description = def.get("description").and_then(|v| v.as_str()).map(|s| { + // Truncate long descriptions for the sidebar + if s.len() > 100 { + format!("{}...", &s[..97]) + } else { + s.to_string() + } + }); + + 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 { + name: name.clone(), + path: name.clone(), + anchor, + entry_type: "definition".to_string(), + }); + } + } + + // 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(); + collect_config_usages( + schema, + String::new(), + &mut config_usages, + defs, + &mut visited, + None, // not inside any type yet + String::new(), // no type-relative path yet + ); + + // Sort config usages alphabetically by path + for usage_list in config_usages.values_mut() { + usage_list.sort_by(|a, b| a.path.cmp(&b.path)); + } + + // Collect type references from $defs + let type_references = if let Some(defs) = schema.get("$defs").and_then(|v| v.as_object()) { + collect_type_references(defs) + } else { + HashMap::new() + }; + + // Merge into TypeUsages for each type + let mut usages: HashMap<String, TypeUsages> = HashMap::new(); + if let Some(defs) = schema.get("$defs").and_then(|v| v.as_object()) { + for type_name in defs.keys() { + usages.insert( + type_name.clone(), + TypeUsages { + config_usages: config_usages.remove(type_name).unwrap_or_default(), + type_references: type_references.get(type_name).cloned().unwrap_or_default(), + }, + ); + } + } + + NavigationData { + outline, + definitions, + search_index, + usages, + display_names, + } +} + +/// Recursively collect properties for the search index +fn collect_properties(props: &Map<String, Value>, prefix: &str, index: &mut Vec<SearchEntry>) { + for (name, prop) in props { + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{}.{}", prefix, name) + }; + + let anchor = make_anchor(&path); + + index.push(SearchEntry { + name: name.clone(), + path: path.clone(), + anchor, + entry_type: "property".to_string(), + }); + + // Recurse into nested properties + if let Some(nested_props) = prop.get("properties").and_then(|v| v.as_object()) { + collect_properties(nested_props, &path, index); + } + + // Handle anyOf/oneOf which might contain objects with properties + for key in ["anyOf", "oneOf", "allOf"] { + if let Some(variants) = prop.get(key).and_then(|v| v.as_array()) { + for variant in variants { + if let Some(nested_props) = + variant.get("properties").and_then(|v| v.as_object()) + { + collect_properties(nested_props, &path, index); + } + } + } + } + } +} + +/// Create a URL-safe anchor ID from a name +pub fn make_anchor(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' || c == '[' || c == ']' { + c.to_ascii_lowercase() + } else if c == '.' || c == ' ' { + '-' + } else { + '_' + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_render_markdown() { + let md = "This is **bold** and `code`"; + let html = render_markdown(md); + assert!(html.contains("<strong>bold</strong>")); + assert!(html.contains("<code>code</code>")); + } + + #[test] + fn test_render_markdown_with_links() { + let md = "See [docs](https://example.com)"; + let html = render_markdown(md); + assert!(html.contains("<a href=\"https://example.com\">docs</a>")); + } + + #[test] + fn test_prepare_for_template() { + let schema = json!({ + "title": "Test", + "description": "A **bold** description", + "properties": { + "name": { + "description": "The `name` field" + } + } + }); + + let prepared = prepare_for_template(&schema); + + assert!(prepared.get("description_html").is_some()); + assert!( + prepared["description_html"] + .as_str() + .unwrap() + .contains("<strong>bold</strong>") + ); + + assert!(prepared["properties"]["name"]["description_html"].is_string()); + } + + #[test] + fn test_make_anchor() { + assert_eq!(make_anchor("MyType"), "mytype"); + assert_eq!(make_anchor("my.nested.prop"), "my-nested-prop"); + assert_eq!(make_anchor("with spaces"), "with-spaces"); + assert_eq!(make_anchor("with_underscore"), "with_underscore"); + } + + #[test] + fn test_build_navigation() { + let schema = json!({ + "title": "Test", + "$defs": { + "TypeA": {"type": "object", "description": "Type A description"}, + "TypeB": {"type": "string"} + }, + "properties": { + "config": { + "properties": { + "nested": {"type": "string"} + } + } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.definitions.len(), 2); + assert!(nav.definitions.iter().any(|d| d.name == "TypeA")); + assert!(nav.definitions.iter().any(|d| d.name == "TypeB")); + + // Check search index includes properties + assert!(nav.search_index.iter().any(|e| e.name == "config")); + assert!(nav.search_index.iter().any(|e| e.path == "config.nested")); + } + + #[test] + fn test_build_navigation_outline() { + let schema = json!({ + "title": "Test", + "properties": { + "zebra": { "type": "string" }, + "alpha": { "type": "number" }, + "beta": { "type": "boolean" } + } + }); + + let nav = build_navigation(&schema); + + // Should have 3 outline entries + assert_eq!(nav.outline.len(), 3); + + // Should be sorted alphabetically + assert_eq!(nav.outline[0].name, "alpha"); + assert_eq!(nav.outline[1].name, "beta"); + assert_eq!(nav.outline[2].name, "zebra"); + + // Check anchors + assert_eq!(nav.outline[0].anchor, "alpha"); + assert_eq!(nav.outline[1].anchor, "beta"); + assert_eq!(nav.outline[2].anchor, "zebra"); + + // Primitive types should have no children + assert!(nav.outline[0].children.is_empty()); + } + + #[test] + fn test_outline_array_shows_item_properties() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "ChildType": { + "type": "object", + "properties": { + "nested_prop": { "type": "string" } + } + } + }, + "properties": { + "parent": { + "type": "array", + "items": { "$ref": "#/$defs/ChildType" } + } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.outline.len(), 1); + assert_eq!(nav.outline[0].name, "parent"); + // Arrays should show their item type's properties + assert_eq!(nav.outline[0].children.len(), 1); + assert_eq!(nav.outline[0].children[0].name, "nested_prop"); + // The anchor should point to the type definition, not the parent path + assert_eq!(nav.outline[0].children[0].anchor, "childtype-nested_prop"); + } + + #[test] + fn test_outline_union_type_is_leaf() { + // Union types (anyOf with multiple non-null variants) should be leaf nodes + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Colour": { + "anyOf": [ + { "$ref": "#/$defs/Rgb" }, + { "$ref": "#/$defs/Hex" } + ] + }, + "Rgb": { + "type": "object", + "properties": { + "r": { "type": "integer" }, + "g": { "type": "integer" }, + "b": { "type": "integer" } + } + }, + "Hex": { + "type": "string" + } + }, + "properties": { + "color": { "$ref": "#/$defs/Colour" } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.outline.len(), 1); + assert_eq!(nav.outline[0].name, "color"); + // Union type should be a leaf - don't drill into its variants + assert!(nav.outline[0].children.is_empty()); + } + + #[test] + fn test_outline_unwraps_optional() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "InnerType": { + "type": "object", + "properties": { + "value": { "type": "string" } + } + } + }, + "properties": { + "optional_obj": { + "anyOf": [ + { "$ref": "#/$defs/InnerType" }, + { "type": "null" } + ] + } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.outline[0].name, "optional_obj"); + // Optional should be transparent - show InnerType's properties as children + assert_eq!(nav.outline[0].children.len(), 1); + assert_eq!(nav.outline[0].children[0].name, "value"); + } + + #[test] + fn test_outline_one_of_shows_variants_with_title() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "ThemeChoice": { + "oneOf": [ + { + "title": "CatppuccinTheme", + "type": "object", + "properties": { + "name": { "type": "string" }, + "accent": { "type": "string" } + } + }, + { + "title": "CustomTheme", + "type": "object", + "properties": { + "colors": { "type": "object" }, + "font": { "type": "string" } + } + } + ] + } + }, + "properties": { + "theme": { "$ref": "#/$defs/ThemeChoice" } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.outline.len(), 1); + assert_eq!(nav.outline[0].name, "theme"); + + // Should have 2 variant children (one for each oneOf variant) + assert_eq!(nav.outline[0].children.len(), 2); + + // First variant should use title + assert_eq!(nav.outline[0].children[0].name, "⊕ CatppuccinTheme"); + assert_eq!(nav.outline[0].children[0].children.len(), 2); // name, accent + // Anchor should point to the type definition + assert_eq!(nav.outline[0].children[0].anchor, "themechoice"); + + // Second variant + assert_eq!(nav.outline[0].children[1].name, "⊕ CustomTheme"); + assert_eq!(nav.outline[0].children[1].children.len(), 2); // colors, font + } + + #[test] + fn test_outline_one_of_uses_const_discriminator() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "ThemeChoice": { + "oneOf": [ + { + "type": "object", + "properties": { + "palette": { "const": "Catppuccin" }, + "name": { "type": "string" } + } + }, + { + "type": "object", + "properties": { + "palette": { "const": "Base16" }, + "name": { "type": "string" } + } + } + ] + } + }, + "properties": { + "theme": { "$ref": "#/$defs/ThemeChoice" } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.outline[0].children.len(), 2); + + // Should use const value as discriminator + assert_eq!(nav.outline[0].children[0].name, "⊕ Catppuccin"); + assert_eq!(nav.outline[0].children[1].name, "⊕ Base16"); + } + + #[test] + fn test_outline_one_of_fallback_to_variant_n() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Choice": { + "oneOf": [ + { + "type": "object", + "properties": { + "option_a": { "type": "string" } + } + }, + { + "type": "object", + "properties": { + "option_b": { "type": "number" } + } + } + ] + } + }, + "properties": { + "selection": { "$ref": "#/$defs/Choice" } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.outline[0].name, "selection"); + assert_eq!(nav.outline[0].children.len(), 2); + + // Without title or const, should use "Variant N" + assert_eq!(nav.outline[0].children[0].name, "⊕ Variant 1"); + assert_eq!(nav.outline[0].children[1].name, "⊕ Variant 2"); + } + + #[test] + fn test_outline_depth_limit() { + // Create a deeply nested schema + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Level1": { + "type": "object", + "properties": { + "level2": { "$ref": "#/$defs/Level2" } + } + }, + "Level2": { + "type": "object", + "properties": { + "level3": { "$ref": "#/$defs/Level3" } + } + }, + "Level3": { + "type": "object", + "properties": { + "level4": { "$ref": "#/$defs/Level4" } + } + }, + "Level4": { + "type": "object", + "properties": { + "deep": { "type": "string" } + } + } + }, + "properties": { + "root": { "$ref": "#/$defs/Level1" } + } + }); + + let nav = build_navigation(&schema); + + // Should have root + assert_eq!(nav.outline.len(), 1); + assert_eq!(nav.outline[0].name, "root"); + + // Should have level2 as child + assert_eq!(nav.outline[0].children.len(), 1); + assert_eq!(nav.outline[0].children[0].name, "level2"); + + // Should have level3 as grandchild + assert_eq!(nav.outline[0].children[0].children.len(), 1); + assert_eq!(nav.outline[0].children[0].children[0].name, "level3"); + + // Should stop at max depth (3) - level4 should not have children + assert!(nav.outline[0].children[0].children[0].children.is_empty()); + } + + #[test] + fn test_config_usages_collection() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Layout": { + "type": "string", + "enum": ["horizontal", "vertical"] + }, + "Workspace": { + "type": "object" + } + }, + "properties": { + "workspaces": { + "type": "array", + "items": { "$ref": "#/$defs/Workspace" } + }, + "default_layout": { "$ref": "#/$defs/Layout" }, + "alternate_layout": { "$ref": "#/$defs/Layout" } + } + }); + + let nav = build_navigation(&schema); + + // Layout should have 2 config usages + assert!(nav.usages.contains_key("Layout")); + let layout_usages = &nav.usages["Layout"]; + assert_eq!(layout_usages.config_usages.len(), 2); + + // Check paths are jq-style and sorted + let paths: Vec<&str> = layout_usages + .config_usages + .iter() + .map(|u| u.path.as_str()) + .collect(); + assert!(paths.contains(&".alternate_layout")); + assert!(paths.contains(&".default_layout")); + + // Workspace should have 1 config usage + assert!(nav.usages.contains_key("Workspace")); + let workspace_usages = &nav.usages["Workspace"]; + assert_eq!(workspace_usages.config_usages.len(), 1); + assert_eq!(workspace_usages.config_usages[0].path, ".workspaces[]"); + } + + #[test] + fn test_config_usages_anchor_format() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "MyType": { "type": "string" } + }, + "properties": { + "my_field": { "$ref": "#/$defs/MyType" } + } + }); + + let nav = build_navigation(&schema); + + let usages = &nav.usages["MyType"]; + assert_eq!(usages.config_usages.len(), 1); + // Anchor should NOT have leading dash + assert_eq!(usages.config_usages[0].anchor, "my_field"); + // Path should have leading dot + assert_eq!(usages.config_usages[0].path, ".my_field"); + } + + #[test] + fn test_config_usages_jq_path_format() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Config": { "type": "object" } + }, + "properties": { + "monitors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "workspaces": { + "type": "array", + "items": { + "type": "object", + "properties": { + "config": { "$ref": "#/$defs/Config" } + } + } + } + } + } + } + } + }); + + let nav = build_navigation(&schema); + + assert!(nav.usages.contains_key("Config")); + let config_usages = &nav.usages["Config"]; + assert_eq!(config_usages.config_usages.len(), 1); + // Should have full jq-style path + assert_eq!( + config_usages.config_usages[0].path, + ".monitors[].workspaces[].config" + ); + // Anchor should match template ID format (no [] brackets, dots become dashes) + assert_eq!( + config_usages.config_usages[0].anchor, + "monitors-workspaces-config" + ); + } + + #[test] + fn test_type_references_collected() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "MatchingStrategy": { + "type": "string", + "enum": ["Equals", "Contains"] + }, + "IdWithIdentifier": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "matching_strategy": { + "anyOf": [ + { "$ref": "#/$defs/MatchingStrategy" }, + { "type": "null" } + ] + } + } + } + }, + "properties": { + "rules": { + "type": "array", + "items": { "$ref": "#/$defs/IdWithIdentifier" } + } + } + }); + + let nav = build_navigation(&schema); + + // MatchingStrategy now has transitive config usages (through IdWithIdentifier) + let matching_strategy = &nav.usages["MatchingStrategy"]; + assert_eq!(matching_strategy.config_usages.len(), 1); + assert_eq!( + matching_strategy.config_usages[0].path, + ".rules[].matching_strategy" + ); + + // It should also have a type reference from IdWithIdentifier + assert_eq!(matching_strategy.type_references.len(), 1); + assert_eq!( + matching_strategy.type_references[0].type_name, + "IdWithIdentifier" + ); + assert_eq!( + matching_strategy.type_references[0].anchor, + "idwithidentifier" + ); + + // IdWithIdentifier has config usages + let id_with_identifier = &nav.usages["IdWithIdentifier"]; + assert_eq!(id_with_identifier.config_usages.len(), 1); + assert_eq!(id_with_identifier.config_usages[0].path, ".rules[]"); + } + + #[test] + fn test_type_with_both_usages() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Colour": { + "type": "object", + "properties": { + "r": { "type": "integer" }, + "g": { "type": "integer" }, + "b": { "type": "integer" } + } + }, + "Theme": { + "type": "object", + "properties": { + "primary": { "$ref": "#/$defs/Colour" }, + "secondary": { "$ref": "#/$defs/Colour" } + } + } + }, + "properties": { + "background": { "$ref": "#/$defs/Colour" }, + "theme": { "$ref": "#/$defs/Theme" } + } + }); + + let nav = build_navigation(&schema); + + // Colour has config usages: direct (background) and transitive through Theme + let colour = &nav.usages["Colour"]; + assert_eq!(colour.config_usages.len(), 3); + + // Find usages by path and verify anchors + let background_usage = colour + .config_usages + .iter() + .find(|u| u.path == ".background") + .expect("should have .background usage"); + // Direct usage at config root -> anchor is "background" + assert_eq!(background_usage.anchor, "background"); + + let primary_usage = colour + .config_usages + .iter() + .find(|u| u.path == ".theme.primary") + .expect("should have .theme.primary usage"); + // Transitive through Theme -> anchor points to Theme's property: "theme-primary" + assert_eq!(primary_usage.anchor, "theme-primary"); + + let secondary_usage = colour + .config_usages + .iter() + .find(|u| u.path == ".theme.secondary") + .expect("should have .theme.secondary usage"); + // Transitive through Theme -> anchor points to Theme's property: "theme-secondary" + assert_eq!(secondary_usage.anchor, "theme-secondary"); + + // Also has type reference from Theme + assert_eq!(colour.type_references.len(), 1); + assert_eq!(colour.type_references[0].type_name, "Theme"); + } + + #[test] + fn test_anchor_links_match_property_ids() { + // Verify that anchors generated for config usages match the IDs generated by the template + // Template uses: id="{{ path | lower | replace(from=".", to="-") }}" + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Type1": { "type": "string" }, + "Type2": { "type": "string" } + }, + "properties": { + "simple_field": { "$ref": "#/$defs/Type1" }, + "nested": { + "type": "object", + "properties": { + "inner_field": { "$ref": "#/$defs/Type2" } + } + } + } + }); + + let nav = build_navigation(&schema); + + // simple_field: path=simple_field, template ID=simple_field + let type1 = &nav.usages["Type1"]; + assert_eq!(type1.config_usages[0].anchor, "simple_field"); + + // nested.inner_field: path=nested.inner_field, template ID=nested-inner_field + let type2 = &nav.usages["Type2"]; + assert_eq!(type2.config_usages[0].anchor, "nested-inner_field"); + } + + #[test] + fn test_transitive_config_usages() { + // Types nested inside other types should show their full Configuration path + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "MonitorConfig": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "workspaces": { + "type": "array", + "items": { "$ref": "#/$defs/WorkspaceConfig" } + } + } + }, + "WorkspaceConfig": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "layout": { "$ref": "#/$defs/Layout" } + } + }, + "Layout": { + "type": "string", + "enum": ["horizontal", "vertical"] + } + }, + "properties": { + "monitors": { + "type": "array", + "items": { "$ref": "#/$defs/MonitorConfig" } + } + } + }); + + let nav = build_navigation(&schema); + + // MonitorConfig should have direct usage at config root + // Anchor points to "monitors" in the Configuration section (no [] in IDs) + let monitor_usages = &nav.usages["MonitorConfig"]; + assert_eq!(monitor_usages.config_usages.len(), 1); + assert_eq!(monitor_usages.config_usages[0].path, ".monitors[]"); + assert_eq!(monitor_usages.config_usages[0].anchor, "monitors"); + + // WorkspaceConfig should have transitive usage through MonitorConfig + // Anchor points to "monitorconfig-workspaces" (property in MonitorConfig type definition) + let workspace_usages = &nav.usages["WorkspaceConfig"]; + assert_eq!(workspace_usages.config_usages.len(), 1); + assert_eq!( + workspace_usages.config_usages[0].path, + ".monitors[].workspaces[]" + ); + assert_eq!( + workspace_usages.config_usages[0].anchor, + "monitorconfig-workspaces" + ); + + // Layout should have transitive usage through WorkspaceConfig + // Anchor points to "workspaceconfig-layout" (property in WorkspaceConfig type definition) + let layout_usages = &nav.usages["Layout"]; + assert_eq!(layout_usages.config_usages.len(), 1); + assert_eq!( + layout_usages.config_usages[0].path, + ".monitors[].workspaces[].layout" + ); + assert_eq!( + layout_usages.config_usages[0].anchor, + "workspaceconfig-layout" + ); + } + + #[test] + fn test_recursive_type_no_infinite_loop() { + // Recursive types should not cause infinite loops + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": { "type": "string" }, + "children": { + "type": "array", + "items": { "$ref": "#/$defs/Node" } + } + } + } + }, + "properties": { + "root": { "$ref": "#/$defs/Node" } + } + }); + + let nav = build_navigation(&schema); + + // Should complete without hanging + let node_usages = &nav.usages["Node"]; + // Node should have at least the direct usage at .root + assert!(node_usages.config_usages.len() >= 1); + assert!(node_usages.config_usages.iter().any(|u| u.path == ".root")); + } + + #[test] + fn test_same_type_multiple_paths() { + // A type used in multiple places should show all paths + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Colour": { + "type": "object", + "properties": { + "r": { "type": "integer" }, + "g": { "type": "integer" }, + "b": { "type": "integer" } + } + }, + "Theme": { + "type": "object", + "properties": { + "primary": { "$ref": "#/$defs/Colour" }, + "secondary": { "$ref": "#/$defs/Colour" } + } + } + }, + "properties": { + "background": { "$ref": "#/$defs/Colour" }, + "foreground": { "$ref": "#/$defs/Colour" }, + "theme": { "$ref": "#/$defs/Theme" } + } + }); + + let nav = build_navigation(&schema); + + // Colour should have 4 config usages: + // - .background (direct) + // - .foreground (direct) + // - .theme.primary (transitive through Theme) + // - .theme.secondary (transitive through Theme) + let colour_usages = &nav.usages["Colour"]; + assert_eq!(colour_usages.config_usages.len(), 4); + + let paths: Vec<&str> = colour_usages + .config_usages + .iter() + .map(|u| u.path.as_str()) + .collect(); + assert!(paths.contains(&".background")); + assert!(paths.contains(&".foreground")); + assert!(paths.contains(&".theme.primary")); + assert!(paths.contains(&".theme.secondary")); + } + + #[test] + fn test_oneof_with_object_variants_anchor() { + // oneOf types where variants have properties should include variant index in anchor + // This matches template behavior: KomorebiTheme.oneOf[0].bar_accent -> komorebitheme-oneof[0]-bar_accent + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Colour": { "type": "string" }, + "Theme": { + "oneOf": [ + { + "type": "object", + "properties": { + "primary": { "$ref": "#/$defs/Colour" }, + "name": { "type": "string", "const": "light" } + } + }, + { + "type": "object", + "properties": { + "primary": { "$ref": "#/$defs/Colour" }, + "name": { "type": "string", "const": "dark" } + } + } + ] + } + }, + "properties": { + "theme": { "$ref": "#/$defs/Theme" } + } + }); + + let nav = build_navigation(&schema); + + // Colour should have 2 usages - one from each oneOf variant + let colour_usages = &nav.usages["Colour"]; + assert_eq!(colour_usages.config_usages.len(), 2); + + // Both should have oneOf[N] in the anchor since variants have properties + let anchors: Vec<&str> = colour_usages + .config_usages + .iter() + .map(|u| u.anchor.as_str()) + .collect(); + assert!(anchors.contains(&"theme-oneof[0]-primary")); + assert!(anchors.contains(&"theme-oneof[1]-primary")); + } + + #[test] + fn test_anyof_type_union_anchor() { + // anyOf types that are pure type unions (no properties in variants) should use type anchor + // e.g., Colour = Hex | Rgb - anchor should be "colour", not "colour-anyof[0]" + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Hex": { "type": "string", "format": "hex" }, + "Rgb": { + "type": "object", + "properties": { + "r": { "type": "integer" }, + "g": { "type": "integer" }, + "b": { "type": "integer" } + } + }, + "Colour": { + "anyOf": [ + { "$ref": "#/$defs/Hex" }, + { "$ref": "#/$defs/Rgb" } + ] + } + }, + "properties": { + "background": { "$ref": "#/$defs/Colour" } + } + }); + + let nav = build_navigation(&schema); + + // Hex and Rgb should have usages through Colour + // Since Colour's anyOf variants are just $refs (no properties), anchor should be "colour" + let hex_usages = &nav.usages["Hex"]; + assert_eq!(hex_usages.config_usages.len(), 1); + assert_eq!(hex_usages.config_usages[0].path, ".background"); + assert_eq!(hex_usages.config_usages[0].anchor, "colour"); + + let rgb_usages = &nav.usages["Rgb"]; + assert_eq!(rgb_usages.config_usages.len(), 1); + assert_eq!(rgb_usages.config_usages[0].path, ".background"); + assert_eq!(rgb_usages.config_usages[0].anchor, "colour"); + } + + #[test] + fn test_nested_oneof_in_property_no_variant_index() { + // Properties with anyOf (like optional values) should NOT add variant index + // e.g., MonitorConfig.wallpaper which is anyOf[Wallpaper, null] -> anchor is "monitorconfig-wallpaper" + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Wallpaper": { + "type": "object", + "properties": { + "path": { "type": "string" } + } + }, + "MonitorConfig": { + "type": "object", + "properties": { + "wallpaper": { + "anyOf": [ + { "$ref": "#/$defs/Wallpaper" }, + { "type": "null" } + ] + } + } + } + }, + "properties": { + "monitor": { "$ref": "#/$defs/MonitorConfig" } + } + }); + + let nav = build_navigation(&schema); + + // Wallpaper should have usage through MonitorConfig.wallpaper + // The anyOf is within a property, so NO variant index in anchor + let wallpaper_usages = &nav.usages["Wallpaper"]; + assert_eq!(wallpaper_usages.config_usages.len(), 1); + assert_eq!(wallpaper_usages.config_usages[0].path, ".monitor.wallpaper"); + assert_eq!( + wallpaper_usages.config_usages[0].anchor, + "monitorconfig-wallpaper" + ); + } + + #[test] + fn test_array_path_strips_brackets_in_anchor() { + // Array paths like .monitors[] should have anchor "monitors" (no brackets) + // Template IDs don't include [] notation + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Item": { "type": "string" } + }, + "properties": { + "items": { + "type": "array", + "items": { "$ref": "#/$defs/Item" } + } + } + }); + + let nav = build_navigation(&schema); + + let item_usages = &nav.usages["Item"]; + assert_eq!(item_usages.config_usages.len(), 1); + assert_eq!(item_usages.config_usages[0].path, ".items[]"); + // 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" + ); + } + + #[test] + fn test_type_names_ending_in_digits_not_stripped() { + // Legitimate type names ending in digits should NOT have _display_name added + let schema = json!({ + "$defs": { + "Base16": { "type": "string" }, + "Sha256": { "type": "string" }, + "Level3": { "type": "object" } + } + }); + + let prepared = prepare_for_template(&schema); + let defs = prepared.get("$defs").unwrap().as_object().unwrap(); + + // Type names that legitimately end in digits should NOT have _display_name added + assert!( + defs.get("Base16").unwrap().get("_display_name").is_none(), + "Base16 should not have _display_name" + ); + assert!( + defs.get("Sha256").unwrap().get("_display_name").is_none(), + "Sha256 should not have _display_name" + ); + assert!( + defs.get("Level3").unwrap().get("_display_name").is_none(), + "Level3 should not have _display_name" + ); + } + + #[test] + fn test_wrapper_types_get_display_name_stripped() { + // Real-world pattern from komorebi: PerAnimationPrefixConfig<T> generates + // multiple $defs entries for different type parameters: + // - PerAnimationPrefixConfig (bool) + // - PerAnimationPrefixConfig2 (uint64) + // - PerAnimationPrefixConfig3 (AnimationStyle ref) + // All should display as "PerAnimationPrefixConfig" + let schema = json!({ + "$defs": { + "AnimationStyle": { "type": "string" }, + "PerAnimationPrefixConfig": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "type": "boolean" } + }, + { "type": "boolean" } + ] + }, + "PerAnimationPrefixConfig2": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + ] + }, + "PerAnimationPrefixConfig3": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/AnimationStyle" } + }, + { "$ref": "#/$defs/AnimationStyle" } + ] + } + } + }); + + let prepared = prepare_for_template(&schema); + let defs = prepared.get("$defs").unwrap().as_object().unwrap(); + + // All three should be detected as wrapper types + assert_eq!( + defs.get("PerAnimationPrefixConfig") + .unwrap() + .get("_wrapper_type"), + Some(&json!(true)), + "PerAnimationPrefixConfig should be detected as wrapper type" + ); + assert_eq!( + defs.get("PerAnimationPrefixConfig2") + .unwrap() + .get("_wrapper_type"), + Some(&json!(true)), + "PerAnimationPrefixConfig2 should be detected as wrapper type" + ); + assert_eq!( + defs.get("PerAnimationPrefixConfig3") + .unwrap() + .get("_wrapper_type"), + Some(&json!(true)), + "PerAnimationPrefixConfig3 should be detected as wrapper type" + ); + + // The base type has no digits, so no _display_name needed + assert!( + defs.get("PerAnimationPrefixConfig") + .unwrap() + .get("_display_name") + .is_none(), + "Base wrapper type should not have _display_name" + ); + + // Numbered variants should have display name with digits stripped + assert_eq!( + defs.get("PerAnimationPrefixConfig2") + .unwrap() + .get("_display_name"), + Some(&json!("PerAnimationPrefixConfig")), + "PerAnimationPrefixConfig2 should have digits stripped" + ); + assert_eq!( + defs.get("PerAnimationPrefixConfig3") + .unwrap() + .get("_display_name"), + Some(&json!("PerAnimationPrefixConfig")), + "PerAnimationPrefixConfig3 should have digits stripped" + ); + } + + #[test] + fn test_default_with_direct_ref_gets_annotated() { + // When a property has a default and a direct $ref, it should get _default_ref + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Layout": { + "type": "string", + "enum": ["horizontal", "vertical"] + } + }, + "properties": { + "layout": { + "$ref": "#/$defs/Layout", + "default": "horizontal" + } + } + }); + + let prepared = prepare_for_template(&schema); + let props = prepared.get("properties").unwrap().as_object().unwrap(); + let layout_prop = props.get("layout").unwrap(); + + assert_eq!( + layout_prop.get("_default_ref"), + Some(&json!("Layout")), + "Direct $ref with default should have _default_ref" + ); + } + + #[test] + fn test_default_with_anyof_option_pattern_gets_annotated() { + // When a property has a default and anyOf Option<T> pattern, it should get _default_ref + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "MoveBehaviour": { + "oneOf": [ + { "type": "string", "const": "Swap" }, + { "type": "string", "const": "Insert" } + ] + } + }, + "properties": { + "move_behaviour": { + "anyOf": [ + { "$ref": "#/$defs/MoveBehaviour" }, + { "type": "null" } + ], + "default": "Swap" + } + } + }); + + let prepared = prepare_for_template(&schema); + let props = prepared.get("properties").unwrap().as_object().unwrap(); + let move_prop = props.get("move_behaviour").unwrap(); + + assert_eq!( + move_prop.get("_default_ref"), + Some(&json!("MoveBehaviour")), + "anyOf Option<T> pattern with default should have _default_ref" + ); + } + + #[test] + fn test_default_with_oneof_option_pattern_gets_annotated() { + // When a property has a default and oneOf Option<T> pattern, it should get _default_ref + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "BorderStyle": { + "type": "string", + "enum": ["solid", "dashed"] + } + }, + "properties": { + "border_style": { + "oneOf": [ + { "$ref": "#/$defs/BorderStyle" }, + { "type": "null" } + ], + "default": "solid" + } + } + }); + + let prepared = prepare_for_template(&schema); + let props = prepared.get("properties").unwrap().as_object().unwrap(); + let border_prop = props.get("border_style").unwrap(); + + assert_eq!( + border_prop.get("_default_ref"), + Some(&json!("BorderStyle")), + "oneOf Option<T> pattern with default should have _default_ref" + ); + } + + #[test] + fn test_primitive_default_not_annotated() { + // Primitive defaults (boolean, integer, etc.) should NOT get _default_ref + let schema = json!({ + "title": "Test", + "type": "object", + "properties": { + "enabled": { + "type": ["boolean", "null"], + "default": true + }, + "count": { + "type": ["integer", "null"], + "default": 10 + } + } + }); + + let prepared = prepare_for_template(&schema); + let props = prepared.get("properties").unwrap().as_object().unwrap(); + + assert!( + props.get("enabled").unwrap().get("_default_ref").is_none(), + "Boolean default should not have _default_ref" + ); + assert!( + props.get("count").unwrap().get("_default_ref").is_none(), + "Integer default should not have _default_ref" + ); + } + + #[test] + fn test_default_without_ref_not_annotated() { + // Defaults on types without $ref should NOT get _default_ref + let schema = json!({ + "title": "Test", + "type": "object", + "properties": { + "name": { + "type": "string", + "default": "untitled" + } + } + }); + + let prepared = prepare_for_template(&schema); + let props = prepared.get("properties").unwrap().as_object().unwrap(); + + assert!( + props.get("name").unwrap().get("_default_ref").is_none(), + "String type without $ref should not have _default_ref" + ); + } + + #[test] + fn test_nested_defaults_annotated() { + // Defaults inside nested structures should also be annotated + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "AnimationStyle": { + "type": "string", + "enum": ["Linear", "EaseIn", "EaseOut"] + }, + "AnimationConfig": { + "type": "object", + "properties": { + "style": { + "anyOf": [ + { "$ref": "#/$defs/AnimationStyle" }, + { "type": "null" } + ], + "default": "Linear" + } + } + } + }, + "properties": { + "animation": { "$ref": "#/$defs/AnimationConfig" } + } + }); + + let prepared = prepare_for_template(&schema); + let defs = prepared.get("$defs").unwrap().as_object().unwrap(); + let anim_config = defs.get("AnimationConfig").unwrap(); + let style_prop = anim_config.get("properties").unwrap().get("style").unwrap(); + + assert_eq!( + style_prop.get("_default_ref"), + Some(&json!("AnimationStyle")), + "Nested default with $ref should have _default_ref" + ); + } + + // ========================================================================= + // Schemars 1.2 Enum-Keyed Map Tests + // ========================================================================= + + #[test] + fn test_schemars_1_2_enum_keyed_map_wrapper_detection() { + // Test that enum-keyed maps (schemars 1.2 pattern) are detected as wrapper types + // This pattern is generated for HashMap<EnumKey, T> + let schema = json!({ + "$defs": { + "PerAnimationPrefixConfig": { + "anyOf": [ + { + "type": "object", + "properties": { + "movement": { "type": "boolean" } + }, + "additionalProperties": false + }, + { "type": "boolean" } + ] + } + } + }); + + let prepared = prepare_for_template(&schema); + let def = &prepared["$defs"]["PerAnimationPrefixConfig"]; + + assert_eq!( + def["_wrapper_type"], + json!(true), + "Enum-keyed map should be detected as wrapper type" + ); + assert_eq!( + def["_wrapper_inner"]["type"], + json!("boolean"), + "Wrapper inner type should be boolean" + ); + } + + #[test] + fn test_schemars_1_2_enum_keyed_map_with_ref() { + // Test enum-keyed map where the value is a $ref + let schema = json!({ + "$defs": { + "AnimationStyle": { + "type": "string", + "enum": ["Linear", "EaseIn"] + }, + "PerAnimationPrefixConfig": { + "anyOf": [ + { + "type": "object", + "properties": { + "movement": { "$ref": "#/$defs/AnimationStyle" } + }, + "additionalProperties": false + }, + { "$ref": "#/$defs/AnimationStyle" } + ] + } + } + }); + + let prepared = prepare_for_template(&schema); + let def = &prepared["$defs"]["PerAnimationPrefixConfig"]; + + assert_eq!( + def["_wrapper_type"], + json!(true), + "Enum-keyed map with $ref should be detected as wrapper type" + ); + assert_eq!( + def["_wrapper_inner"]["$ref"], + json!("#/$defs/AnimationStyle"), + "Wrapper inner type should be the $ref" + ); + } + + #[test] + fn test_enum_keyed_map_usage_links_to_parent() { + // Test that usage links for enum-keyed map properties don't include the enum key name + // (since those properties are rendered inline without heading anchors) + let schema = json!({ + "type": "object", + "$defs": { + "Config": { + "anyOf": [ + { + "type": "object", + "properties": { + "variant_a": { "$ref": "#/$defs/Inner" } + }, + "additionalProperties": false + }, + { "$ref": "#/$defs/Inner" } + ] + }, + "Inner": { "type": "string" } + }, + "properties": { + "config": { "$ref": "#/$defs/Config" } + } + }); + + let prepared = prepare_for_template(&schema); + let nav = build_navigation(&prepared); + let inner_usages = nav.usages.get("Inner").unwrap(); + + // Should not have links containing "variant_a" (the enum key) + for usage in &inner_usages.config_usages { + assert!( + !usage.anchor.contains("variant_a"), + "Usage anchor should not contain enum key 'variant_a': {}", + usage.anchor + ); + } + } + + #[test] + fn test_is_enum_keyed_map() { + // Test the is_enum_keyed_map helper function + let enum_keyed_map = json!({ + "type": "object", + "properties": { + "movement": { "type": "boolean" } + }, + "additionalProperties": false + }); + + assert!( + is_enum_keyed_map(&enum_keyed_map), + "Should detect enum-keyed map pattern" + ); + + // Regular object with properties should NOT be detected + let regular_object = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }); + + assert!( + !is_enum_keyed_map(®ular_object), + "Regular object without additionalProperties: false should not be detected" + ); + + // Object with additionalProperties as schema should NOT be detected + let map_with_additional = json!({ + "type": "object", + "additionalProperties": { "type": "string" } + }); + + assert!( + !is_enum_keyed_map(&map_with_additional), + "Map with additionalProperties schema should not be detected as enum-keyed" + ); + } + + #[test] + fn test_schemars_1_1_wrapper_still_works() { + // Ensure backward compatibility: schemars 1.1 pattern should still be detected + let schema = json!({ + "$defs": { + "WrapperType": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "type": "boolean" } + }, + { "type": "boolean" } + ] + } + } + }); + + let prepared = prepare_for_template(&schema); + let def = &prepared["$defs"]["WrapperType"]; + + assert_eq!( + def["_wrapper_type"], + json!(true), + "Schemars 1.1 wrapper pattern should still be detected" + ); + assert_eq!( + def["_wrapper_inner"]["type"], + json!("boolean"), + "Wrapper inner type should be boolean" + ); + } +} diff --git a/crates/schemars-docgen/src/templates/mdx_base.md b/crates/schemars-docgen/src/templates/mdx_base.md new file mode 100644 index 00000000..e7c0e6e3 --- /dev/null +++ b/crates/schemars-docgen/src/templates/mdx_base.md @@ -0,0 +1,32 @@ +{%- import "mdx_macros.md" as macros -%} +--- +title: "{{ title }}" +description: "{{ description }}" +--- + +import { Badge } from '@astrojs/starlight/components'; + +## Configuration +{% if schema.properties %} +{# First render required properties #} +{% for prop_name, prop in schema.properties -%} +{%- if prop_name in required_set -%} +{{ macros::render_property(name=prop_name, node=prop, required=true, depth=0, path=prop_name) }} +{% endif -%} +{%- endfor -%} +{# Then render optional properties #} +{% for prop_name, prop in schema.properties -%} +{%- if prop_name not in required_set -%} +{{ macros::render_property(name=prop_name, node=prop, required=false, depth=0, path=prop_name) }} +{% endif -%} +{%- endfor %} +{% else %} +*No configuration properties defined.* +{% endif %} +{% if schema["$defs"] %} +## Type Definitions + +{% for def_name, def in schema["$defs"] -%} +{{ macros::render_definition(def_name=def_name, def=def) }} +{% endfor %} +{% endif %} diff --git a/crates/schemars-docgen/src/templates/mdx_macros.md b/crates/schemars-docgen/src/templates/mdx_macros.md new file mode 100644 index 00000000..c994465a --- /dev/null +++ b/crates/schemars-docgen/src/templates/mdx_macros.md @@ -0,0 +1,718 @@ +{# ============================================================================ #} +{# MDX Macros for Astro Starlight documentation generation #} +{# ============================================================================ #} +{# + Enum Pattern Reference (see ENUMS.md for full details): + - Pattern 1: Simple Unit Enums - oneOf with {type: "string", const: "Value"} + - Pattern 2: Unit Enum with Default - Pattern 1 + "default" field + - Pattern 3: Mixed Enum - oneOf with unit variants + tuple/object variants + - Pattern 4a-d: Untagged Enums - anyOf with various combinations ($ref, tuple, array) + - Pattern 5: Internally Tagged - oneOf with discriminator field (const) + properties + - Pattern 6: Internally Tagged + Flatten - oneOf with properties + $ref at same level + - Pattern 7: Externally Tagged - oneOf with variant name as property key + - Pattern 8: Untagged Hybrid - anyOf mixing const strings + $ref + - Pattern 9: Untagged with Nulls - anyOf with multiple {type: "null"} variants + - Pattern 10: Generic Enum - anyOf with {additionalProperties: T} + T + - Pattern 11: Nullable Optional - anyOf with $ref + {type: "null"} + default +#} + +{# ============================================================================ #} +{# SECTION 1: Type Badge Rendering #} +{# ============================================================================ #} +{# Render the type badge for a schema - outputs inline code or links #} +{# When node has $ref, always prioritize it over type (Pattern 6, etc.) #} + +{% macro render_type_badge(node) %} +{%- if node["$ref"] -%} + {# $ref always takes priority - render as link to type definition #} + {%- set ref_name = node["$ref"] | replace(from="#/$defs/", to="") -%} + {%- set display = display_names[ref_name] | default(value=ref_name) -%} + {%- if schema["$defs"][ref_name] -%} + {%- set ref_def = schema["$defs"][ref_name] -%} +[`{{ display }}`](#{{ ref_name | lower }}){% if ref_def._wrapper_inner %} {{ self::render_type_badge(node=ref_def._wrapper_inner) }}{% endif -%} + {%- else -%} +`$ref` + {%- endif -%} +{%- elif node.type -%} + {%- if node.type is string -%} + {%- if node.type == "array" and node.prefixItems -%} + {# Check if all prefixItems have the same type (homogeneous tuple) #} + {%- set first_type = node.prefixItems | first -%} + {%- set_global is_homogeneous = true -%} + {%- for item in node.prefixItems -%} + {%- if item.type is defined and first_type.type is defined -%} + {%- if item.type != first_type.type -%} + {%- set_global is_homogeneous = false -%} + {%- endif -%} + {%- else -%} + {%- set_global is_homogeneous = false -%} + {%- endif -%} + {%- endfor -%} + {%- if is_homogeneous and first_type.type -%} +`array` `[{% if first_type.format %}{{ first_type.format }}{% else %}{{ first_type.type }}{% endif %}; {{ node.prefixItems | length }}]` + {%- else -%} +`array` `tuple` + {%- endif -%} + {%- elif node.type == "array" and node.items -%} +`array` {{ self::render_type_badge(node=node.items) }} + {%- else -%} +`{{ node.type }}` + {%- endif -%} + {%- else -%} + {# Check for Option<T> pattern: type array with exactly 2 types where one is null #} + {%- if node.type | length == 2 and "null" in node.type -%} +<Badge text="optional" variant="note" /> {% for t in node.type %}{% if t != "null" %}{% if t == "array" and node.items %}`array` {{ self::render_type_badge(node=node.items) }}{% else %}`{{ t }}`{% endif %}{% endif %}{% endfor %} + {%- else -%} +{% for t in node.type %}`{{ t }}` {% endfor %} + {%- endif -%} + {%- endif -%} +{%- elif node.anyOf -%} + {# Pattern 10: Wrapper type (detected in prepare.rs) #} + {%- if node._wrapper_type -%} +{{ self::render_type_badge(node=node._wrapper_inner) }} + {%- elif node.anyOf | length == 2 -%} + {%- set first = node.anyOf | first -%} + {%- set second = node.anyOf | last -%} + {# Pattern 11: Nullable Optional - anyOf with T + null #} + {%- if second.type and second.type == "null" -%} +<Badge text="optional" variant="note" /> {{ self::render_type_badge(node=first) }} + {%- elif first.type and first.type == "null" -%} +<Badge text="optional" variant="note" /> {{ self::render_type_badge(node=second) }} + {%- else -%} + {# Pattern 8: Check if all variants are string const (untagged hybrid) #} + {%- set_global is_string_enum = true -%} + {%- for variant in node.anyOf -%} + {%- if variant.type and variant.type == "string" and variant.const -%} + {%- else -%} + {%- set_global is_string_enum = false -%} + {%- endif -%} + {%- endfor -%} + {%- if is_string_enum -%} +`string` `enum` + {%- else -%} +`enum` + {%- endif -%} + {%- endif -%} + {%- else -%} + {# Pattern 4a-d, 8, 9: Untagged enums with 3+ variants #} + {%- set_global is_string_enum = true -%} + {%- for variant in node.anyOf -%} + {%- if variant.type and variant.type == "string" and variant.const -%} + {%- else -%} + {%- set_global is_string_enum = false -%} + {%- endif -%} + {%- endfor -%} + {%- if is_string_enum -%} +`string` `enum` + {%- else -%} +`enum` + {%- endif -%} + {%- endif -%} +{%- elif node.oneOf -%} + {# Pattern 1-3, 5-7: Tagged enums using oneOf #} + {%- set_global is_string_enum = true -%} + {%- for variant in node.oneOf -%} + {# Pattern 1-2: Simple unit enum with string const #} + {%- if variant.type and variant.type == "string" and variant.const -%} + {%- else -%} + {# Pattern 3, 5, 6, 7: Complex variants (objects, refs, etc.) #} + {%- set_global is_string_enum = false -%} + {%- endif -%} + {%- endfor -%} + {%- if is_string_enum -%} +`string` `enum` + {%- else -%} +`enum` + {%- endif -%} +{%- elif node.allOf -%} +`allOf` +{%- elif node.const -%} +`const` +{%- elif node.enum -%} +`enum` +{%- endif -%} +{% endmacro render_type_badge %} + +{# ============================================================================ #} +{# SECTION 2: Constraint Rendering #} +{# ============================================================================ #} +{# Render constraints like min, max, pattern, format #} + +{% macro render_constraints(node) %} +{# Check if this is a homogeneous tuple - skip minItems/maxItems if so #} +{%- set_global skip_item_constraints = false -%} +{%- if node.prefixItems -%} + {%- set first_type = node.prefixItems | first -%} + {%- set_global is_homogeneous = true -%} + {%- for item in node.prefixItems -%} + {%- if item.type is defined and first_type.type is defined -%} + {%- if item.type != first_type.type -%} + {%- set_global is_homogeneous = false -%} + {%- endif -%} + {%- else -%} + {%- set_global is_homogeneous = false -%} + {%- endif -%} + {%- endfor -%} + {%- if is_homogeneous and first_type.type -%} + {%- set_global skip_item_constraints = true -%} + {%- endif -%} +{%- endif -%} +{%- if node.format or node.pattern or node.minimum is defined or node.maximum is defined or node.minLength is defined or node.maxLength is defined or (node.minItems is defined and not skip_item_constraints) or (node.maxItems is defined and not skip_item_constraints) %} + +{% if node.format %}- **Format:** `{{ node.format }}` +{% endif %}{% if node.pattern %}- **Pattern:** `{{ node.pattern }}` +{% endif %}{% if node.minimum is defined %}- **Minimum:** {{ node.minimum }} +{% endif %}{% if node.maximum is defined %}- **Maximum:** {{ node.maximum }} +{% endif %}{% if node.minLength is defined %}- **Min length:** {{ node.minLength }} +{% endif %}{% if node.maxLength is defined %}- **Max length:** {{ node.maxLength }} +{% endif %}{% if node.minItems is defined and not skip_item_constraints %}- **Min items:** {{ node.minItems }} +{% endif %}{% if node.maxItems is defined and not skip_item_constraints %}- **Max items:** {{ node.maxItems }} +{% endif %} +{%- endif -%} +{% endmacro render_constraints %} + +{# ============================================================================ #} +{# SECTION 3: Schema Body Rendering #} +{# ============================================================================ #} +{# Render the body of a schema (properties, items, variants, etc.) #} + +{% macro render_schema_body(node, depth, path) %} +{%- if depth < 10 -%} +{%- if node["$ref"] -%} + {# Reference only, already shown as type badge #} +{%- elif node.properties -%} + {%- set required_arr = node.required | default(value=[]) -%} + +<details open> +<summary>Properties ({{ node.properties | length }})</summary> + +{# First render required properties #} +{% for prop_name, prop in node.properties -%} +{%- if prop_name in required_arr -%} +{%- set is_required = true -%} +{{ self::render_nested_property(name=prop_name, node=prop, required=is_required, depth=depth + 1, path=path ~ "." ~ prop_name) }} +{% endif -%} +{%- endfor -%} +{# Then render optional properties #} +{% for prop_name, prop in node.properties -%} +{%- if prop_name not in required_arr -%} +{%- set is_required = false -%} +{{ self::render_nested_property(name=prop_name, node=prop, required=is_required, depth=depth + 1, path=path ~ "." ~ prop_name) }} +{% endif -%} +{%- endfor %} +</details> +{%- elif node.patternProperties -%} + +{% for pattern, prop in node.patternProperties %} +{# Convert known patterns to friendly type names #} +{# Note: patterns use single backslash in the parsed JSON value #} +{% if pattern == "^\d+$" or pattern == "^[0-9]+$" or pattern == "^-?\d+$" or pattern == "^-?[0-9]+$" -%} +**Key:** `"<integer>"` **→** {{ self::render_type_badge(node=prop) }} +{% elif pattern == ".*" or pattern == "^.*$" -%} +**Key:** `"<string>"` **→** {{ self::render_type_badge(node=prop) }} +{% else -%} +**Pattern:** `{{ pattern }}` **→** {{ self::render_type_badge(node=prop) }} +{% endif %} +{% if prop.description %}{{ prop.description | mdx_escape }}{% endif %} +{{ self::render_schema_body(node=prop, depth=depth + 1, path=path ~ ".[key]") }} +{% endfor %} +{%- elif node.items -%} + {%- if node.items["$ref"] -%} + {# Already shown in type badge #} + {%- elif node.items.type and node.items.type is string -%} + {# Simple type - already shown in type badge #} + {%- else -%} + +<details open> +<summary>Array Items</summary> + +{{ self::render_schema(node=node.items, depth=depth + 1, path=path ~ "[]") }} +</details> + {%- endif -%} +{%- elif node.prefixItems -%} + {# Check if homogeneous tuple #} + {%- set first_type = node.prefixItems | first -%} + {%- set_global is_homogeneous = true -%} + {%- for item in node.prefixItems -%} + {%- if item.type is defined and first_type.type is defined -%} + {%- if item.type != first_type.type -%} + {%- set_global is_homogeneous = false -%} + {%- endif -%} + {%- else -%} + {%- set_global is_homogeneous = false -%} + {%- endif -%} + {%- endfor -%} + {%- if is_homogeneous and first_type.type -%} + {# Homogeneous tuple - already shown as [T; N] in badge #} + {%- else -%} + +<details open> +<summary>Tuple Items ({{ node.prefixItems | length }})</summary> + +{% for item in node.prefixItems %} +**[{{ loop.index0 }}]** {{ self::render_schema(node=item, depth=depth + 1, path=path ~ "[" ~ loop.index0 ~ "]") }} +{% endfor %} +</details> + {%- endif -%} +{%- elif node.anyOf -%} + {# Pattern 10: Wrapper type (detected in prepare.rs) #} + {%- if node._wrapper_type -%} +{{ self::render_schema_body(node=node._wrapper_inner, depth=depth, path=path) }} + {%- elif node.anyOf | length == 2 -%} + {%- set first = node.anyOf | first -%} + {%- set second = node.anyOf | last -%} + {# Pattern 11: Nullable Optional - unwrap the non-null variant #} + {%- if second.type and second.type == "null" -%} +{{ self::render_schema_body(node=first, depth=depth, path=path) }} + {%- elif first.type and first.type == "null" -%} +{{ self::render_schema_body(node=second, depth=depth, path=path) }} + {%- else -%} + {# Pattern 4a-d, 8: Two non-null variants #} + +<details open> +<summary>Enum variants ({{ node.anyOf | length }})</summary> + +{% for variant in node.anyOf %} +--- +{{ self::render_enum_variant(node=variant, depth=depth + 1, path=path ~ ".anyOf[" ~ loop.index0 ~ "]") }} +{% endfor %} +</details> + {%- endif -%} + {%- else -%} + {# Pattern 4a-d, 8, 9: Untagged enums with 3+ variants #} + +<details open> +<summary>Enum variants ({{ node.anyOf | length }})</summary> + +{% for variant in node.anyOf %} +--- +{{ self::render_enum_variant(node=variant, depth=depth + 1, path=path ~ ".anyOf[" ~ loop.index0 ~ "]") }} +{% endfor %} +</details> + {%- endif -%} +{%- elif node.oneOf -%} +{# Pattern 1-3, 5-7: Tagged enums using oneOf #} + +<details open> +<summary>Enum variants ({{ node.oneOf | length }})</summary> + +{% for variant in node.oneOf %} +--- +{{ self::render_enum_variant(node=variant, depth=depth + 1, path=path ~ ".oneOf[" ~ loop.index0 ~ "]") }} +{% endfor %} +</details> +{%- elif node.allOf -%} + +<details open> +<summary>All of ({{ node.allOf | length }} schemas)</summary> + +{% for variant in node.allOf %} +--- +{{ self::render_schema(node=variant, depth=depth + 1, path=path ~ ".allOf[" ~ loop.index0 ~ "]") }} +{% endfor %} +</details> +{%- elif node.enum -%} + +<details open> +<summary>Enum variants ({{ node.enum | length }})</summary> + +{% for value in node.enum %}- `{{ value | json_encode() }}` +{% endfor %} +</details> +{%- elif node.const -%} + +**Value:** `{{ node.const | json_encode() }}` +{%- endif -%} +{%- if node.additionalProperties and node.additionalProperties is object -%} + +<details open> +<summary>Additional Properties</summary> + +{{ self::render_schema(node=node.additionalProperties, depth=depth + 1, path=path ~ ".[*]") }} +</details> +{%- endif -%} +{%- endif -%} +{% endmacro render_schema_body %} + +{# ============================================================================ #} +{# SECTION 4: Schema Rendering #} +{# ============================================================================ #} +{# Render a schema without property wrapper #} + +{% macro render_schema(node, depth, path) %} +{%- if depth < 10 -%} +{# For const variants with description, show description then value on separate lines #} +{%- if node.const and node.description -%} +{{ node.description | mdx_escape }} + +**Value:** `{{ node.const | json_encode() }}` +{%- elif node.const -%} +**Value:** `{{ node.const | json_encode() }}` +{%- else -%} +{{ self::render_type_badge(node=node) }} +{% if node.description %} +{{ node.description | mdx_escape }} +{% endif %} +{{ self::render_constraints(node=node) }} +{%- if node.default %} + +**Default:** {% if node._default_ref and schema["$defs"][node._default_ref] %}[`{{ node.default | json_encode() }}`](#{{ node._default_ref | lower }}){% else %}`{{ node.default | json_encode() }}`{% endif %} +{% endif -%} +{{ self::render_schema_body(node=node, depth=depth, path=path) }} +{%- endif -%} +{%- endif -%} +{% endmacro render_schema %} + +{# ============================================================================ #} +{# SECTION 5: Enum Variant Rendering #} +{# ============================================================================ #} +{# Render an enum variant - like render_schema but uses render_enum_variant_body #} +{# for flat property display. Uses exhaustive fallback chain for variant titles. #} +{# #} +{# Title extraction priority (see ENUMS.md for pattern details): #} +{# 1. node.title (explicit #[schemars(title = "...")]) #} +{# 2. node.const (Pattern 1, 2, 3 unit variants, Pattern 9 hybrid) #} +{# 3. properties.<tag>.const (Pattern 5, 6, 8 discriminator field) #} +{# 4. required[0] when additionalProperties=false (Pattern 7 externally tagged) #} +{# 5. $ref type name (Pattern 4, 9, 10 reference types) #} +{# 6. Type-based fallback (array, tuple, map, primitives) #} +{# ============================================================================ #} + +{% macro render_enum_variant(node, depth, path) %} +{%- if depth < 10 -%} +{# Extract variant name using exhaustive fallback chain #} +{%- set_global variant_name = "" -%} + +{# Priority 1: Explicit title #} +{%- if node.title -%} + {%- set_global variant_name = node.title -%} + +{# Priority 2: Const value (Pattern 1, 2, 3 unit variants, Pattern 9) #} +{%- elif node.const -%} + {%- set_global variant_name = node.const -%} + +{# Priority 3 & 4: Properties-based extraction #} +{%- elif node.properties -%} + {# Priority 3: Discriminator const from properties (Pattern 5, 6, 8) #} + {%- for prop_name, prop in node.properties -%} + {%- if prop.const and variant_name == "" -%} + {%- set_global variant_name = prop.const -%} + {%- endif -%} + {%- endfor -%} + {# Priority 4: Externally tagged - property key (Pattern 7) #} + {%- if variant_name == "" and node.additionalProperties is defined and node.additionalProperties == false -%} + {%- if node.required and node.required | length > 0 -%} + {%- set_global variant_name = node.required | first -%} + {%- endif -%} + {%- endif -%} + +{# Priority 5: $ref type name (Pattern 4, 9, 10) #} +{%- elif node["$ref"] -%} + {%- set ref_name = node["$ref"] | replace(from="#/$defs/", to="") -%} + {%- set_global variant_name = display_names[ref_name] | default(value=ref_name) -%} + +{# Priority 6: Type-based fallback #} +{%- elif node.type -%} + {%- if node.type is string -%} + {%- if node.type == "array" -%} + {%- if node.items and node.items["$ref"] -%} + {%- set item_ref = node.items["$ref"] | replace(from="#/$defs/", to="") -%} + {%- set item_display = display_names[item_ref] | default(value=item_ref) -%} + {%- set_global variant_name = item_display ~ " Array" -%} + {%- elif node.prefixItems -%} + {%- set_global variant_name = "Tuple" -%} + {%- else -%} + {%- set_global variant_name = "Array" -%} + {%- endif -%} + {%- elif node.type == "object" and node.additionalProperties -%} + {%- set_global variant_name = "Map" -%} + {%- else -%} + {%- set_global variant_name = node.type | capitalize -%} + {%- endif -%} + {%- endif -%} +{%- endif -%} + +{# Render heading if we have a variant name #} +{%- if variant_name %} +#### {{ variant_name }} +{% endif -%} + +{# Render description #} +{% if node.description %} +{{ node.description | mdx_escape }} +{% endif %} + +{# Render value based on variant type #} +{# First check for Pattern 7: Externally Tagged (single property with additionalProperties: false) #} +{# These are handled specially in render_enum_variant_body, so skip generic value here #} +{%- set_global is_pattern7 = false -%} +{%- if node.properties -%} + {%- set props_count = node.properties | length -%} + {%- if props_count == 1 and node.additionalProperties is defined and node.additionalProperties == false -%} + {%- set_global is_pattern7 = true -%} + {%- endif -%} +{%- endif -%} +{%- if node.const -%} +**Value:** `{{ node.const | json_encode() }}` +{%- elif not is_pattern7 and (node["$ref"] or node.type or node.anyOf or node.oneOf or node.allOf) %} +**Value:** {{ self::render_type_badge(node=node) }} +{%- endif %} + +{{ self::render_constraints(node=node) }} +{%- if node.default %} + +**Default:** {% if node._default_ref and schema["$defs"][node._default_ref] %}[`{{ node.default | json_encode() }}`](#{{ node._default_ref | lower }}){% else %}`{{ node.default | json_encode() }}`{% endif %} +{% endif -%} +{{ self::render_enum_variant_body(node=node, depth=depth, path=path) }} +{%- endif -%} +{% endmacro render_enum_variant %} + +{# ============================================================================ #} +{# SECTION 6: Enum Variant Body Rendering #} +{# ============================================================================ #} +{# Render the body of an enum variant - properties are shown directly without #} +{# details wrapper, required first. #} +{# Handles Pattern 3 (mixed), Pattern 5 (internally tagged), #} +{# Pattern 6 (internally tagged + flatten), Pattern 7 (externally tagged) #} + +{% macro render_enum_variant_body(node, depth, path) %} +{%- if depth < 10 -%} +{%- if node.properties -%} + {# Check for Pattern 7: Externally Tagged Enum (single property with additionalProperties: false) #} + {%- set props_count = node.properties | length -%} + {%- if props_count == 1 and node.additionalProperties is defined and node.additionalProperties == false -%} + {# Pattern 7: Externally Tagged - render variant name and value inline #} + {%- for prop_name, prop in node.properties -%} + +**Value:** `{{ prop_name }}` → {{ self::render_type_badge(node=prop) }} + {%- endfor -%} + {%- else -%} + {# Pattern 5, 6: Object variants with properties (internally tagged) #} + {# Pattern 6: Handle properties first - this covers cases where both properties and $ref exist #} + {# Skip properties with const values (discriminator fields) - already shown as variant heading #} + {%- set required_arr = node.required | default(value=[]) -%} + +{# First render required properties (skip discriminator fields with const) #} +{% for prop_name, prop in node.properties -%} +{%- if prop_name in required_arr and not prop.const -%} +{%- set is_required = true -%} +{{ self::render_nested_property(name=prop_name, node=prop, required=is_required, depth=depth + 1, path=path ~ "." ~ prop_name) }} +{% endif -%} +{%- endfor -%} +{# Then render optional properties (skip discriminator fields with const) #} +{% for prop_name, prop in node.properties -%} +{%- if prop_name not in required_arr and not prop.const -%} +{%- set is_required = false -%} +{{ self::render_nested_property(name=prop_name, node=prop, required=is_required, depth=depth + 1, path=path ~ "." ~ prop_name) }} +{% endif -%} +{%- endfor -%} + {%- endif -%} +{# Pattern 6: When node has both properties AND $ref, the $ref is shown as a link in Value: #} +{# so we don't need to inline the referenced type's properties here #} +{%- elif node["$ref"] -%} + {# Reference only, already shown as type badge #} +{%- elif node.patternProperties -%} + +{% for pattern, prop in node.patternProperties %} +{% if pattern == "^\d+$" or pattern == "^[0-9]+$" or pattern == "^-?\d+$" or pattern == "^-?[0-9]+$" -%} +**Key:** `"<integer>"` **→** {{ self::render_type_badge(node=prop) }} +{% elif pattern == ".*" or pattern == "^.*$" -%} +**Key:** `"<string>"` **→** {{ self::render_type_badge(node=prop) }} +{% else -%} +**Pattern:** `{{ pattern }}` **→** {{ self::render_type_badge(node=prop) }} +{% endif %} +{% if prop.description %}{{ prop.description | mdx_escape }}{% endif %} +{{ self::render_enum_variant_body(node=prop, depth=depth + 1, path=path ~ ".[key]") }} +{% endfor %} +{%- elif node.items -%} + {%- if node.items["$ref"] -%} + {# Already shown in type badge #} + {%- elif node.items.type and node.items.type is string -%} + {# Simple type - already shown in type badge #} + {%- else -%} + +<details open> +<summary>Array Items</summary> + +{{ self::render_enum_variant(node=node.items, depth=depth + 1, path=path ~ "[]") }} +</details> + {%- endif -%} +{%- elif node.prefixItems -%} + {%- set first_type = node.prefixItems | first -%} + {%- set_global is_homogeneous = true -%} + {%- for item in node.prefixItems -%} + {%- if item.type is defined and first_type.type is defined -%} + {%- if item.type != first_type.type -%} + {%- set_global is_homogeneous = false -%} + {%- endif -%} + {%- else -%} + {%- set_global is_homogeneous = false -%} + {%- endif -%} + {%- endfor -%} + {%- if is_homogeneous and first_type.type -%} + {# Homogeneous tuple - already shown as [T; N] in badge #} + {%- else -%} + +<details open> +<summary>Tuple Items ({{ node.prefixItems | length }})</summary> + +{% for item in node.prefixItems %} +**[{{ loop.index0 }}]** {{ self::render_enum_variant(node=item, depth=depth + 1, path=path ~ "[" ~ loop.index0 ~ "]") }} +{% endfor %} +</details> + {%- endif -%} +{%- elif node.anyOf -%} + {%- if node._wrapper_type -%} +{{ self::render_enum_variant_body(node=node._wrapper_inner, depth=depth, path=path) }} + {%- elif node.anyOf | length == 2 -%} + {%- set first = node.anyOf | first -%} + {%- set second = node.anyOf | last -%} + {%- if second.type and second.type == "null" -%} +{{ self::render_enum_variant_body(node=first, depth=depth, path=path) }} + {%- elif first.type and first.type == "null" -%} +{{ self::render_enum_variant_body(node=second, depth=depth, path=path) }} + {%- else -%} + +<details open> +<summary>Enum variants ({{ node.anyOf | length }})</summary> + +{% for variant in node.anyOf %} +--- +{{ self::render_enum_variant(node=variant, depth=depth + 1, path=path ~ ".anyOf[" ~ loop.index0 ~ "]") }} +{% endfor %} +</details> + {%- endif -%} + {%- else -%} + +<details open> +<summary>Enum variants ({{ node.anyOf | length }})</summary> + +{% for variant in node.anyOf %} +--- +{{ self::render_enum_variant(node=variant, depth=depth + 1, path=path ~ ".anyOf[" ~ loop.index0 ~ "]") }} +{% endfor %} +</details> + {%- endif -%} +{%- elif node.oneOf -%} + +<details open> +<summary>Enum variants ({{ node.oneOf | length }})</summary> + +{% for variant in node.oneOf %} +--- +{{ self::render_enum_variant(node=variant, depth=depth + 1, path=path ~ ".oneOf[" ~ loop.index0 ~ "]") }} +{% endfor %} +</details> +{%- elif node.allOf -%} + +<details open> +<summary>All of ({{ node.allOf | length }} schemas)</summary> + +{% for variant in node.allOf %} +--- +{{ self::render_enum_variant(node=variant, depth=depth + 1, path=path ~ ".allOf[" ~ loop.index0 ~ "]") }} +{% endfor %} +</details> +{%- elif node.enum -%} + +<details open> +<summary>Enum variants ({{ node.enum | length }})</summary> + +{% for value in node.enum %}- `{{ value | json_encode() }}` +{% endfor %} +</details> +{# Note: node.const is NOT handled here - it's already rendered in render_enum_variant #} +{%- endif -%} +{%- if node.additionalProperties and node.additionalProperties is object -%} + +<details open> +<summary>Additional Properties</summary> + +{{ self::render_enum_variant(node=node.additionalProperties, depth=depth + 1, path=path ~ ".[*]") }} +</details> +{%- endif -%} +{%- endif -%} +{% endmacro render_enum_variant_body %} + +{# ============================================================================ #} +{# SECTION 7: Property Rendering #} +{# ============================================================================ #} +{# Render a nested property (used within details blocks) #} + +{% macro render_nested_property(name, node, required, depth, path) %} +{%- if depth < 10 -%} +{%- set anchor = path | lower | replace(from=".", to="-") -%} +#### {{ name }} {{"{"}}#{{ anchor }}{{"}"}} + +{% if required %}<Badge text="required" variant="success" /> {% endif %}{% if node.deprecated %}<Badge text="deprecated" variant="danger" /> {% endif %}{{ self::render_type_badge(node=node) }} + +{% if node.description %}{{ node.description | mdx_escape }}{% endif %} +{{ self::render_constraints(node=node) }} +{%- if node.default %} + +**Default:** {% if node._default_ref and schema["$defs"][node._default_ref] %}[`{{ node.default | json_encode() }}`](#{{ node._default_ref | lower }}){% else %}`{{ node.default | json_encode() }}`{% endif %} +{% endif -%} +{{ self::render_schema_body(node=node, depth=depth, path=path) }} +{%- endif -%} +{% endmacro render_nested_property %} + +{# Render a top-level property as H3 #} + +{% macro render_property(name, node, required, depth, path) %} +{%- if depth < 10 -%} +{%- set anchor = path | lower | replace(from=".", to="-") -%} +### {{ name }} {{"{"}}#{{ anchor }}{{"}"}} + +{% if required %}<Badge text="required" variant="success" /> {% endif %}{% if node.deprecated %}<Badge text="deprecated" variant="danger" /> {% endif %}{{ self::render_type_badge(node=node) }} + +{% if node.description %}{{ node.description | mdx_escape }}{% endif %} +{{ self::render_constraints(node=node) }} +{%- if node.default %} + +**Default:** {% if node._default_ref and schema["$defs"][node._default_ref] %}[`{{ node.default | json_encode() }}`](#{{ node._default_ref | lower }}){% else %}`{{ node.default | json_encode() }}`{% endif %} +{% endif -%} +{{ self::render_schema_body(node=node, depth=depth, path=path) }} + +--- + +{%- endif -%} +{% endmacro render_property %} + +{# ============================================================================ #} +{# SECTION 8: Definition & Usage Rendering #} +{# ============================================================================ #} +{# Render usages section for a type definition #} + +{% macro render_usages(def) %} +{%- if def._usages and (def._usages.config_usages | length > 0 or def._usages.type_references | length > 0) -%} + +#### Usages + +{% if def._usages.config_usages | length > 0 -%} +**Configuration:** +{% for usage in def._usages.config_usages %}- [`{{ usage.path }}`](#{{ usage.anchor }}) +{% endfor %} +{% endif %} +{% if def._usages.type_references | length > 0 -%} +**Referenced by:** +{% for ref in def._usages.type_references %}{%- set ref_display = display_names[ref.type_name] | default(value=ref.type_name) -%} +- [`{{ ref_display }}`](#{{ ref.anchor }}) +{% endfor %} +{% endif %} +{%- endif -%} +{% endmacro render_usages %} + +{# Render a type definition as H3 #} + +{% macro render_definition(def_name, def) %} +{%- set anchor = def_name | lower | replace(from=".", to="-") -%} +### {{ def._display_name | default(value=def_name) }} {{"{"}}#{{ anchor }}{{"}"}} + +{% if def.description %}{{ def.description | mdx_escape }}{% endif %} +{{ self::render_constraints(node=def) }} +{{ self::render_schema_body(node=def, depth=0, path=def_name) }} +{{ self::render_usages(def=def) }} + +--- + +{% endmacro render_definition %} diff --git a/nix/docgen/build.nix b/nix/docgen/build.nix new file mode 100644 index 00000000..15efe8c1 --- /dev/null +++ b/nix/docgen/build.nix @@ -0,0 +1,143 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + crane.url = "github:ipetkov/crane"; + rust-overlay.url = "github:oxalica/rust-overlay"; + rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; + treefmt-nix.url = "github:numtide/treefmt-nix"; + treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; + git-hooks-nix.url = "github:cachix/git-hooks.nix"; + git-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = + inputs@{ + nixpkgs, + flake-parts, + crane, + rust-overlay, + ... + }: + flake-parts.lib.mkFlake { inherit inputs; } { + imports = [ + inputs.treefmt-nix.flakeModule + inputs.git-hooks-nix.flakeModule + ]; + + systems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + + perSystem = + { + config, + system, + ... + }: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + }; + + rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + nightlyRustfmt = pkgs.rust-bin.nightly.latest.rustfmt; + rustToolchainWithNightlyRustfmt = pkgs.symlinkJoin { + name = "rust-toolchain-with-nightly-rustfmt"; + paths = [ + nightlyRustfmt + rustToolchain + ]; + }; + + nightlyToolchain = pkgs.rust-bin.nightly.latest.default; + cargo-udeps = pkgs.writeShellScriptBin "cargo-udeps" '' + export PATH="${nightlyToolchain}/bin:$PATH" + exec ${pkgs.cargo-udeps}/bin/cargo-udeps "$@" + ''; + + craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; + + src = pkgs.lib.cleanSourceWith { + src = ./.; + filter = + path: type: + (craneLib.filterCargoSources path type) + || (builtins.match ".*\\.css$" path != null) + || (builtins.match ".*\\.html$" path != null) + || (builtins.match ".*\\.md$" path != null) + || (builtins.match ".*\\.json$" path != null); + }; + + commonArgs = { + inherit src; + strictDeps = true; + }; + + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + + schemars-docgen = craneLib.buildPackage ( + commonArgs + // { + inherit cargoArtifacts; + } + ); + in + { + checks = { + clippy = craneLib.cargoClippy ( + commonArgs + // { + inherit cargoArtifacts; + } + ); + tests = craneLib.cargoTest ( + commonArgs + // { + inherit cargoArtifacts; + } + ); + }; + + packages = { + inherit schemars-docgen; + default = schemars-docgen; + }; + + devShells.default = pkgs.mkShell { + inputsFrom = [ schemars-docgen ]; + packages = [ + rustToolchainWithNightlyRustfmt + cargo-udeps + pkgs.cargo-outdated + pkgs.bacon + ]; + }; + + pre-commit = { + check.enable = true; + settings.hooks.treefmt = { + enable = true; + package = config.treefmt.build.wrapper; + pass_filenames = false; + }; + }; + + treefmt = { + projectRootFile = "flake.nix"; + programs = { + rustfmt = { + enable = true; + package = nightlyRustfmt; + }; + taplo.enable = true; + nixfmt.enable = true; + deadnix.enable = true; + }; + }; + }; + }; +}