From 665c301f8cdf708ed76ea9f1b73fd83c5f54a262 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Tue, 10 Mar 2026 15:05:04 -0700 Subject: [PATCH 1/9] Add CLAUDE.md with project guidance for Claude Code --- CLAUDE.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c83583e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working with code in this repository. + +## Project Overview + +terranix-codegen transforms Terraform provider schemas (go-cty type system) into type-safe NixOS modules for Terranix. It parses provider specs, runs `tofu providers schema`, then generates Nix code through a linear pipeline of AST transformations. + +## Build and Development + +```bash +nix develop # Enter dev shell (provides GHC, cabal, formatters, linters) +cabal build # Build the project +cabal test --enable-tests # Run all tests +cabal test --enable-coverage # Run tests with HPC coverage +nix build # Full Nix build +nix flake check # Run all checks (build, format, lint) +``` + +GHC 9.10.3 with GHC2024 edition. The flake uses flake-parts. + +## Testing + +Hspec with hspec-discover. Tests are in `test/TerranixCodegen/` with `*Spec.hs` suffix. + +Key testing patterns: + +- **`shouldMapTo` custom matcher** in `test/TestUtils.hs` — compares `NExpr` ASTs via pretty-printed output +- **QuasiQuotes** — tests use `[nix| ... |]` syntax for inline Nix AST literals +- HLint is configured (`.hlint.yaml`) to ignore parse errors from QuasiQuotes in test files + +## Formatting and Linting + +Treefmt-based with pre-commit hooks via hk. Hooks install automatically on dev shell entry. + +| Tool | Purpose | +| -------------------------- | ----------------------------------------------------------------------- | +| fourmolu | Haskell formatting (2-space indent, leading commas, record brace space) | +| hlint | Haskell linting | +| cabal-gild | .cabal file formatting | +| alejandra, deadnix, statix | Nix formatting and linting | + +## Architecture + +### Pipeline + +``` +ProviderSpec (text) → TerraformGenerator (runs tofu CLI) + → ProviderSchema (JSON via aeson) → TypeMapper (CtyType → NExpr) + → OptionBuilder (SchemaAttribute → mkOption) → ModuleGenerator (NixOS module assembly) + → FileOrganizer (directory tree output) +``` + +### Source Layout + +- `app/` — CLI entry point using optparse-applicative (`CLI/Types.hs`, `CLI/Parser.hs`, `CLI/Commands.hs`) +- `lib/TerranixCodegen/` — Library code (pipeline stages) + - `ProviderSchema/` — Aeson-based JSON parsing into ADTs (`CtyType.hs`, `Attribute.hs`, `Block.hs`, `Schema.hs`, `Provider.hs`, `Function.hs`, `Identity.hs`, `Types.hs`) + - `ProviderSpec.hs` — Megaparsec parser for provider specs (e.g. `hashicorp/aws:5.0.0`) + - `TypeMapper.hs` — Maps go-cty types to Nix types via hnix AST + - `OptionBuilder.hs` — Builds `mkOption` expressions from schema attributes + - `ModuleGenerator.hs` — Assembles complete NixOS module expressions + - `FileOrganizer.hs` — Writes organized directory tree with auto-generated `default.nix` imports + - `PrettyPrint.hs` — Colorized terminal output +- `test/` — Hspec test suite +- `nix/` — Flake modules (haskell build, devshell, treefmt, docs, CI workflow generation) +- `vendor/` — Vendored terraform-json schema library + +### Critical Design Decisions + +- **AST-based code generation, not string templates.** All Nix output goes through hnix's `NExpr` AST and `prettyNix`. This guarantees syntactic validity. Never generate Nix as raw strings. +- **`types.nullOr` for optional attributes** — preserves Terraform's null semantics (unset ≠ default value). +- **Custom `types.tupleOf`** — implemented in `nix/lib/tuple.nix` for fixed-length, per-position type validation matching Terraform tuples. +- **`StrictData` extension** — used in all `ProviderSchema/` modules for strict-by-default record fields. + +### Key Types + +| Type | Module | Role | +| ----------------- | -------------------------- | --------------------------------------------------------------------------------- | +| `CtyType` | `ProviderSchema.CtyType` | go-cty type system (Bool, Number, String, Dynamic, List, Set, Map, Object, Tuple) | +| `SchemaAttribute` | `ProviderSchema.Attribute` | Attribute metadata (type, required/optional, computed, deprecated, sensitive) | +| `SchemaBlock` | `ProviderSchema.Block` | Block with attributes, nested blocks, and nesting mode | +| `ProviderSpec` | `ProviderSpec` | Parsed provider specification (namespace/name:version) | +| `NExpr` | hnix | Nix expression AST — the core intermediate representation | + +## CI + +GitHub Actions workflow auto-generated from Nix (`nix/flake/ci/`). Three jobs: `check` (flake check), `build` (nix build), `coverage` (HPC report). From c889496de15e7a583957bcb10060c7730f6093e4 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Tue, 10 Mar 2026 15:30:59 -0700 Subject: [PATCH 2/9] Add design doc for description generation gaps Covers three gaps: unused SchemaDescriptionKind, missing block metadata enrichment, and hardcoded top-level descriptions. Introduces a unified Description type with mdDoc support for markdown-kind descriptions. --- .../2026-03-10-description-gaps-design.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/plans/2026-03-10-description-gaps-design.md diff --git a/docs/plans/2026-03-10-description-gaps-design.md b/docs/plans/2026-03-10-description-gaps-design.md new file mode 100644 index 0000000..7d7441d --- /dev/null +++ b/docs/plans/2026-03-10-description-gaps-design.md @@ -0,0 +1,122 @@ +# Description Gaps Design + +## Problem + +Three gaps in how descriptions flow through the codegen pipeline: + +1. **`SchemaDescriptionKind` is parsed but unused** — markdown descriptions are treated as plain text. +1. **Block descriptions get no metadata enrichment** — `blockDeprecated` is ignored in generated output. +1. **Top-level module descriptions are hardcoded** — `"Instances of "` ignores the schema's actual block description. + +Identity attribute descriptions (gap 4) are deferred to a separate effort. + +## Approach: Unified `Description` Type + +New module `lib/TerranixCodegen/Description.hs` encapsulating description text, kind, and rendering. + +### The Type + +```haskell +data Description = Description + { descriptionText :: Text + , descriptionKind :: SchemaDescriptionKind -- Plain or Markdown + } +``` + +### Constructors + +- **`fromAttribute :: SchemaAttribute -> Maybe Description`** — extracts `attributeDescription` and enriches with metadata notes (DEPRECATED, WARNING, NOTE, computed). Preserves `attributeDescriptionKind`. Returns `Nothing` if all parts are empty. Replaces the current `buildDescription` in OptionBuilder. +- **`fromBlock :: SchemaBlock -> Maybe Description`** — extracts `blockDescription` and enriches with deprecated note when `blockDeprecated` is set. Preserves `blockDescriptionKind`. +- **`fromText :: Text -> SchemaDescriptionKind -> Description`** — simple constructor from raw text and kind. + +### Rendering + +```haskell +toNExpr :: Description -> NExpr +``` + +Rendering matrix: + +| Kind | Lines | Output | +|----------|--------|-------------------------------| +| Plain | Single | `mkStr text` | +| Plain | Multi | `mkIndentedStr text` | +| Markdown | Single | `lib.mdDoc "text"` | +| Markdown | Multi | `lib.mdDoc ''text''` | + +Returns an `NExpr` value, not a `Binding`. Callers construct the binding: + +```haskell +NamedVar (mkSelector "description") (toNExpr desc) nullPos +``` + +Metadata notes (DEPRECATED, WARNING, etc.) are plain text appended to the description. The original `descriptionKind` is preserved since markdown renders plain text correctly. + +## Integration + +### OptionBuilder + +- Remove internal `buildDescription :: SchemaAttribute -> Maybe Text`. +- Replace with `Description.fromAttribute` in `buildOption`. +- `buildOption` signature unchanged: `Text -> SchemaAttribute -> NExpr`. +- The multi-line/single-line string logic moves into `Description.toNExpr`. + +### ModuleGenerator + +**Block descriptions (nested blocks):** + +```haskell +case Description.fromBlock block of + Just desc -> Just $ NamedVar (mkSelector "description") (toNExpr desc) nullPos + Nothing -> Nothing +``` + +`fromBlock` adds a deprecated note when `blockDeprecated = Just True`. + +**Top-level resource/data source/provider descriptions:** + +Pull from the schema's root block description with fallback to current hardcoded strings: + +```haskell +descriptionBinding = + NamedVar (mkSelector "description") + (case schemaBlock schema >>= Description.fromBlock of + Just desc -> toNExpr desc + Nothing -> mkStr $ "Instances of " <> resourceType) + nullPos +``` + +Fallback strings: + +- Resource: `"Instances of "` +- Data source: `"Instances of data source"` +- Provider: `" provider configuration"` + +No signature changes needed — `Schema` already contains `SchemaBlock` with descriptions. + +## Testing + +### New: `DescriptionSpec.hs` + +- `fromAttribute` with plain/markdown descriptions +- `fromAttribute` metadata enrichment (deprecated, sensitive, writeOnly, computed) +- `fromBlock` with/without deprecated flag +- `fromBlock` with markdown kind +- `toNExpr` for all four rendering cases (plain/markdown x single/multi-line) + +### Updated: `OptionBuilderSpec.hs` + +- Existing tests pass unchanged (plain descriptions produce same output) +- New test: markdown-kind attribute generates `lib.mdDoc` wrapper + +### Updated: `ModuleGeneratorSpec.hs` + +- Block descriptions include deprecated note when `blockDeprecated = Just True` +- Top-level resource description uses schema block description +- Fallback to hardcoded description when schema block has no description + +## Decisions + +- **`lib.mdDoc` wrapping** only for `Markdown`-kind descriptions, not all descriptions. +- **Identity attributes** deferred to separate design effort. +- **Metadata notes** preserve original description kind (plain text appended to markdown is valid). From 1d876e9e852dfbafabb3e0b4cfaf4b174dcb19c1 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Tue, 10 Mar 2026 15:42:02 -0700 Subject: [PATCH 3/9] Add implementation plan for description gaps Four tasks: create Description module, integrate into OptionBuilder, integrate block descriptions in ModuleGenerator, integrate top-level descriptions in ModuleGenerator. TDD approach with complete code. --- ...6-03-10-description-gaps-implementation.md | 814 ++++++++++++++++++ 1 file changed, 814 insertions(+) create mode 100644 docs/plans/2026-03-10-description-gaps-implementation.md diff --git a/docs/plans/2026-03-10-description-gaps-implementation.md b/docs/plans/2026-03-10-description-gaps-implementation.md new file mode 100644 index 0000000..dd53f96 --- /dev/null +++ b/docs/plans/2026-03-10-description-gaps-implementation.md @@ -0,0 +1,814 @@ +# Description Gaps Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Create a unified `Description` type that handles markdown wrapping, block metadata enrichment, and top-level schema descriptions. + +**Architecture:** New `Description` module sits between `ProviderSchema.*` types and code generators (`OptionBuilder`, `ModuleGenerator`). It encapsulates description text + kind, enrichment with metadata notes, and rendering to `NExpr` (choosing `mkStr`/`mkIndentedStr`/`lib.mdDoc` wrapping). Replaces the internal `buildDescription` in OptionBuilder and the direct `mkStr` in ModuleGenerator. + +**Tech Stack:** Haskell (GHC2024), hnix (`Nix.Expr.Shorthands`, `Nix.Expr.Types`), Hspec + +**Design doc:** `docs/plans/2026-03-10-description-gaps-design.md` + +______________________________________________________________________ + +### Task 1: Create Description Module with Type and Constructors + +**Files:** + +- Create: `lib/TerranixCodegen/Description.hs` +- Create: `test/TerranixCodegen/DescriptionSpec.hs` +- Modify: `terranix-codegen.cabal:23-39` (add exposed module) +- Modify: `terranix-codegen.cabal:99-106` (add test module) + +**Step 1: Register new modules in cabal** + +Add `TerranixCodegen.Description` to library `exposed-modules` and `TerranixCodegen.DescriptionSpec` to test `other-modules` in `terranix-codegen.cabal`. The `cabal-gild` formatter will sort them. + +In library `exposed-modules` (around line 23), add: + +``` +TerranixCodegen.Description +``` + +In test `other-modules` (around line 99), add: + +``` +TerranixCodegen.DescriptionSpec +``` + +**Step 2: Write failing tests for fromAttribute, fromBlock, and toNExpr** + +Create `test/TerranixCodegen/DescriptionSpec.hs`: + +```haskell +module TerranixCodegen.DescriptionSpec (spec) where + +import Data.Text qualified as T +import Nix.TH (nix) +import Test.Hspec + +import TerranixCodegen.Description +import TerranixCodegen.ProviderSchema.Attribute +import TerranixCodegen.ProviderSchema.Block +import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..)) +import TestUtils (shouldMapTo) + +emptyAttr :: SchemaAttribute +emptyAttr = + SchemaAttribute + { attributeType = Nothing + , attributeNestedType = Nothing + , attributeDescription = Nothing + , attributeDescriptionKind = Nothing + , attributeDeprecated = Nothing + , attributeRequired = Nothing + , attributeOptional = Nothing + , attributeComputed = Nothing + , attributeSensitive = Nothing + , attributeWriteOnly = Nothing + } + +emptyBlock :: SchemaBlock +emptyBlock = + SchemaBlock + { blockAttributes = Nothing + , blockNestedBlocks = Nothing + , blockDescription = Nothing + , blockDescriptionKind = Nothing + , blockDeprecated = Nothing + } + +spec :: Spec +spec = do + describe "fromAttribute" $ do + it "extracts plain description" $ do + let attr = emptyAttr {attributeDescription = Just "hello"} + let Just desc = fromAttribute attr + descriptionText desc `shouldBe` "hello" + descriptionKind desc `shouldBe` Plain + + it "extracts markdown description" $ do + let attr = + emptyAttr + { attributeDescription = Just "hello" + , attributeDescriptionKind = Just Markdown + } + let Just desc = fromAttribute attr + descriptionText desc `shouldBe` "hello" + descriptionKind desc `shouldBe` Markdown + + it "defaults to Plain when kind not specified" $ do + let attr = emptyAttr {attributeDescription = Just "hello"} + let Just desc = fromAttribute attr + descriptionKind desc `shouldBe` Plain + + it "returns Nothing when no description or metadata" $ do + fromAttribute emptyAttr `shouldBe` Nothing + + it "enriches with deprecation note" $ do + let attr = + emptyAttr + { attributeDescription = Just "Old field" + , attributeDeprecated = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" + + it "enriches with sensitivity warning" $ do + let attr = + emptyAttr + { attributeDescription = Just "Secret" + , attributeSensitive = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "WARNING" + + it "enriches with write-only note" $ do + let attr = + emptyAttr + { attributeDescription = Just "Token" + , attributeWriteOnly = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "write-only" + + it "enriches with computed note for computed-only attributes" $ do + let attr = + emptyAttr + { attributeDescription = Just "ID" + , attributeComputed = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "computed by the provider" + + it "does not add computed note for optional+computed attributes" $ do + let attr = + emptyAttr + { attributeDescription = Just "IP" + , attributeOptional = Just True + , attributeComputed = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldBe` "IP" + + it "preserves markdown kind with metadata enrichment" $ do + let attr = + emptyAttr + { attributeDescription = Just "Old field" + , attributeDescriptionKind = Just Markdown + , attributeDeprecated = Just True + } + let Just desc = fromAttribute attr + descriptionKind desc `shouldBe` Markdown + + it "creates description from metadata alone" $ do + let attr = emptyAttr {attributeDeprecated = Just True} + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" + + describe "fromBlock" $ do + it "extracts plain block description" $ do + let block = emptyBlock {blockDescription = Just "A block"} + let Just desc = fromBlock block + descriptionText desc `shouldBe` "A block" + descriptionKind desc `shouldBe` Plain + + it "extracts markdown block description" $ do + let block = + emptyBlock + { blockDescription = Just "A block" + , blockDescriptionKind = Just Markdown + } + let Just desc = fromBlock block + descriptionKind desc `shouldBe` Markdown + + it "returns Nothing for empty block" $ do + fromBlock emptyBlock `shouldBe` Nothing + + it "enriches with deprecation note" $ do + let block = + emptyBlock + { blockDescription = Just "Old block" + , blockDeprecated = Just True + } + let Just desc = fromBlock block + descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" + + it "creates description from deprecated flag alone" $ do + let block = emptyBlock {blockDeprecated = Just True} + let Just desc = fromBlock block + descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" + + describe "toNExpr" $ do + it "renders plain single-line as regular string" $ do + toNExpr (fromText "hello" Plain) `shouldMapTo` [nix| "hello" |] + + it "renders markdown single-line with lib.mdDoc" $ do + toNExpr (fromText "hello" Markdown) `shouldMapTo` [nix| lib.mdDoc "hello" |] +``` + +**Step 3: Run tests to verify they fail** + +Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -20` +Expected: Compilation failure (`Could not find module 'TerranixCodegen.Description'`) + +**Step 4: Implement Description module** + +Create `lib/TerranixCodegen/Description.hs`: + +```haskell +module TerranixCodegen.Description ( + Description (..), + fromAttribute, + fromBlock, + fromText, + toNExpr, +) where + +import Data.Fix (Fix (..)) +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import Data.Text qualified as T +import Nix.Expr.Shorthands +import Nix.Expr.Types + +import TerranixCodegen.ProviderSchema.Attribute +import TerranixCodegen.ProviderSchema.Block +import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..)) + +-- | A description with text and format kind (plain or markdown). +data Description = Description + { descriptionText :: Text + , descriptionKind :: SchemaDescriptionKind + } + deriving stock (Show, Eq) + +-- | Create a Description from raw text and kind. +fromText :: Text -> SchemaDescriptionKind -> Description +fromText = Description + +{- | Build a Description from schema attribute metadata. + +Enriches with metadata notes: + - Deprecation warnings + - Sensitivity warnings + - Write-only notes + - Computed attribute notes + +Returns Nothing if all parts are empty. +-} +fromAttribute :: SchemaAttribute -> Maybe Description +fromAttribute attr + | T.null combinedDesc = Nothing + | otherwise = Just $ Description finalDesc kind + where + kind = fromMaybe Plain (attributeDescriptionKind attr) + + nonEmptyParts = filter (not . T.null) parts + combinedDesc = T.intercalate "\n\n" nonEmptyParts + + -- Add trailing newline only for multi-line descriptions + finalDesc + | length nonEmptyParts > 1 = combinedDesc <> "\n" + | otherwise = combinedDesc + + parts = + [ fromMaybe "" (attributeDescription attr) + , if fromMaybe False (attributeDeprecated attr) + then "DEPRECATED: This attribute is deprecated and may be removed in a future version." + else "" + , if fromMaybe False (attributeSensitive attr) + then "WARNING: This attribute contains sensitive information and will not be displayed in logs." + else "" + , if fromMaybe False (attributeWriteOnly attr) + then "NOTE: This attribute is write-only and will not be persisted in the Terraform state." + else "" + , if fromMaybe False (attributeComputed attr) + && not (fromMaybe False (attributeRequired attr)) + && not (fromMaybe False (attributeOptional attr)) + then "This value is computed by the provider." + else "" + ] + +{- | Build a Description from a schema block. + +Enriches with deprecation note when blockDeprecated is set. +Returns Nothing if no description or metadata. +-} +fromBlock :: SchemaBlock -> Maybe Description +fromBlock block + | T.null combinedDesc = Nothing + | otherwise = Just $ Description finalDesc kind + where + kind = fromMaybe Plain (blockDescriptionKind block) + + nonEmptyParts = filter (not . T.null) parts + combinedDesc = T.intercalate "\n\n" nonEmptyParts + + finalDesc + | length nonEmptyParts > 1 = combinedDesc <> "\n" + | otherwise = combinedDesc + + parts = + [ fromMaybe "" (blockDescription block) + , if fromMaybe False (blockDeprecated block) + then "DEPRECATED: This block is deprecated and may be removed in a future version." + else "" + ] + +{- | Render a Description to a NExpr value. + +Rendering matrix: + - Plain + single-line → mkStr text + - Plain + multi-line → mkIndentedStr text + - Markdown + single-line → lib.mdDoc "text" + - Markdown + multi-line → lib.mdDoc ''text'' +-} +toNExpr :: Description -> NExpr +toNExpr (Description text kind) = + case kind of + Plain + | isMultiLine -> mkIndentedStr 16 text + | otherwise -> mkStr text + Markdown + | isMultiLine -> mdDoc (mkIndentedStr 16 text) + | otherwise -> mdDoc (mkStr text) + where + isMultiLine = T.any (== '\n') text + mdDoc = mkApp (Fix $ NSelect Nothing (mkSym "lib") (mkSelector "mdDoc")) +``` + +**Step 5: Run tests to verify they pass** + +Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -40` +Expected: All tests pass, including new DescriptionSpec tests + +**Step 6: Commit** + +```bash +git add lib/TerranixCodegen/Description.hs test/TerranixCodegen/DescriptionSpec.hs terranix-codegen.cabal +git commit -m "feat: add Description type with metadata enrichment and mdDoc rendering" +``` + +______________________________________________________________________ + +### Task 2: Integrate Description into OptionBuilder + +**Files:** + +- Modify: `lib/TerranixCodegen/OptionBuilder.hs:1-17` (imports) +- Modify: `lib/TerranixCodegen/OptionBuilder.hs:62-72` (descriptionBinding) +- Modify: `lib/TerranixCodegen/OptionBuilder.hs:143-197` (remove buildDescription) +- Modify: `test/TerranixCodegen/OptionBuilderSpec.hs` (add markdown test) + +**Step 1: Add markdown-kind test to OptionBuilderSpec** + +Add to `OptionBuilderSpec.hs` in the `describe "buildOption"` block, after the "edge cases" describe block: + +```haskell + describe "markdown descriptions" $ do + it "wraps markdown description with lib.mdDoc" $ do + let attr = + emptyAttr + { attributeType = Just CtyString + , attributeDescription = Just "The AMI ID" + , attributeDescriptionKind = Just Markdown + , attributeRequired = Just True + } + buildOption "ami" attr + `shouldMapTo` [nix| + mkOption { + type = types.str; + description = lib.mdDoc "The AMI ID"; + } + |] + + it "wraps multi-line markdown description with lib.mdDoc" $ do + let attr = + emptyAttr + { attributeType = Just CtyString + , attributeDescription = Just "The AMI ID" + , attributeDescriptionKind = Just Markdown + , attributeComputed = Just True + } + buildOption "ami" attr + `shouldMapTo` [nix| + mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + The AMI ID + + This value is computed by the provider. + ''; + readOnly = true; + } + |] +``` + +Also add import for `SchemaDescriptionKind`: + +```haskell +import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..), SchemaNestingMode (..)) +``` + +**Step 2: Run tests to verify the new test fails** + +Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -20` +Expected: New markdown test fails (current code produces `mkStr` not `lib.mdDoc`) + +**Step 3: Update OptionBuilder to use Description module** + +In `lib/TerranixCodegen/OptionBuilder.hs`: + +Replace imports — remove `Data.Text qualified as T`, add Description import: + +```haskell +import Data.Fix (Fix (..)) +import Data.Map.Strict (Map) +import Data.Map.Strict qualified as Map +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import Nix.Expr.Shorthands +import Nix.Expr.Types + +import TerranixCodegen.Description qualified as Description +import TerranixCodegen.ProviderSchema.Attribute +import TerranixCodegen.ProviderSchema.Types (SchemaNestingMode (..)) +import TerranixCodegen.TypeMapper (mapCtyTypeToNixWithOptional) +``` + +Replace `descriptionBinding` (lines 62-72): + +```haskell + -- Description binding (if description exists) + descriptionBinding = + case Description.fromAttribute attr of + Just desc -> + Just $ + NamedVar + (mkSelector "description") + (Description.toNExpr desc) + nullPos + Nothing -> Nothing +``` + +Delete `buildDescription` function (lines 143-197). + +**Step 4: Run all tests to verify they pass** + +Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -40` +Expected: ALL tests pass (existing + new markdown tests) + +**Step 5: Commit** + +```bash +git add lib/TerranixCodegen/OptionBuilder.hs test/TerranixCodegen/OptionBuilderSpec.hs +git commit -m "refactor: use Description module in OptionBuilder" +``` + +______________________________________________________________________ + +### Task 3: Integrate Description into ModuleGenerator — Block Descriptions + +**Files:** + +- Modify: `lib/TerranixCodegen/ModuleGenerator.hs:1-22` (imports) +- Modify: `lib/TerranixCodegen/ModuleGenerator.hs:276-279` (descriptionBinding in blockTypeToBinding) +- Modify: `lib/TerranixCodegen/ModuleGenerator.hs:378-387` (remove emptyBlock) +- Modify: `test/TerranixCodegen/ModuleGeneratorSpec.hs` (add block description tests) + +**Step 1: Add block description tests to ModuleGeneratorSpec** + +Add to `ModuleGeneratorSpec.hs` after the existing `describe "blockToSubmodule"` block, inside `spec`: + +```haskell + describe "block descriptions" $ do + it "adds deprecation note to nested block description" $ do + let nestedBlock = + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("enabled", emptyAttr {attributeType = Just CtyBool, attributeRequired = Just True})] + , blockDescription = Just "Old monitoring config" + , blockDeprecated = Just True + } + blockType = + SchemaBlockType + { blockTypeNestingMode = Just NestingSingle + , blockTypeBlock = Just nestedBlock + , blockTypeMinItems = Nothing + , blockTypeMaxItems = Nothing + } + block = + emptyBlock + { blockNestedBlocks = + Just $ + Map.fromList [("monitoring", blockType)] + } + blockToSubmodule block + `shouldMapTo` [nix| + types.submodule { + options = { + monitoring = mkOption { + type = types.submodule { + options = { + enabled = mkOption { + type = types.bool; + }; + }; + }; + default = null; + description = '' + Old monitoring config + + DEPRECATED: This block is deprecated and may be removed in a future version. + ''; + }; + }; + } + |] + + it "wraps markdown block description with lib.mdDoc" $ do + let nestedBlock = + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("enabled", emptyAttr {attributeType = Just CtyBool, attributeRequired = Just True})] + , blockDescription = Just "Enable monitoring" + , blockDescriptionKind = Just Markdown + } + blockType = + SchemaBlockType + { blockTypeNestingMode = Just NestingSingle + , blockTypeBlock = Just nestedBlock + , blockTypeMinItems = Nothing + , blockTypeMaxItems = Nothing + } + block = + emptyBlock + { blockNestedBlocks = + Just $ + Map.fromList [("monitoring", blockType)] + } + blockToSubmodule block + `shouldMapTo` [nix| + types.submodule { + options = { + monitoring = mkOption { + type = types.submodule { + options = { + enabled = mkOption { + type = types.bool; + }; + }; + }; + default = null; + description = lib.mdDoc "Enable monitoring"; + }; + }; + } + |] +``` + +Also add import for `SchemaDescriptionKind`: + +```haskell +import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..), SchemaNestingMode (..)) +``` + +**Step 2: Run tests to verify the new tests fail** + +Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -20` +Expected: New tests fail (current code doesn't enrich block descriptions or handle markdown) + +**Step 3: Update ModuleGenerator block description handling** + +In `lib/TerranixCodegen/ModuleGenerator.hs`: + +Add import: + +```haskell +import TerranixCodegen.Description qualified as Description +``` + +Replace `descriptionBinding` in `blockTypeToBinding` (lines 276-279): + +```haskell + descriptionBinding = + case blockTypeBlock blockType >>= Description.fromBlock of + Just desc -> Just $ NamedVar (mkSelector "description") (Description.toNExpr desc) nullPos + Nothing -> Nothing +``` + +Remove the unused `emptyBlock` helper (lines 378-387). + +**Step 4: Run all tests to verify they pass** + +Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -40` +Expected: ALL tests pass + +**Step 5: Commit** + +```bash +git add lib/TerranixCodegen/ModuleGenerator.hs test/TerranixCodegen/ModuleGeneratorSpec.hs +git commit -m "feat: enrich block descriptions with metadata and mdDoc support" +``` + +______________________________________________________________________ + +### Task 4: Integrate Description into ModuleGenerator — Top-Level Descriptions + +**Files:** + +- Modify: `lib/TerranixCodegen/ModuleGenerator.hs:82-86` (resource descriptionBinding) +- Modify: `lib/TerranixCodegen/ModuleGenerator.hs:135-139` (data source descriptionBinding) +- Modify: `lib/TerranixCodegen/ModuleGenerator.hs:197-201` (provider descriptionBinding) +- Modify: `test/TerranixCodegen/ModuleGeneratorSpec.hs` (add top-level description tests) + +**Step 1: Add top-level description tests to ModuleGeneratorSpec** + +Add to `ModuleGeneratorSpec.hs` inside the existing `describe "generateResourceModule"` block: + +```haskell + it "uses schema block description for resource module" $ do + let schema = + emptySchema + { schemaBlock = + Just $ + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("name", emptyAttr {attributeType = Just CtyString, attributeRequired = Just True})] + , blockDescription = Just "Manages a compute instance" + } + } + generateResourceModule "aws" "aws_instance" schema + `shouldMapTo` [nix| + { lib, ... }: + with lib; + { + options.resource.aws_instance = mkOption { + type = types.attrsOf (types.submodule { + options = { + name = mkOption { + type = types.str; + }; + }; + }); + default = {}; + description = "Manages a compute instance"; + }; + } + |] +``` + +Add inside `describe "generateDataSourceModule"`: + +```haskell + it "uses schema block description for data source module" $ do + let schema = + emptySchema + { schemaBlock = + Just $ + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("name", emptyAttr {attributeType = Just CtyString, attributeRequired = Just True})] + , blockDescription = Just "Fetches AMI information" + } + } + generateDataSourceModule "aws" "aws_ami" schema + `shouldMapTo` [nix| + { lib, ... }: + with lib; + { + options.data.aws_ami = mkOption { + type = types.attrsOf (types.submodule { + options = { + name = mkOption { + type = types.str; + }; + }; + }); + default = {}; + description = "Fetches AMI information"; + }; + } + |] +``` + +Add inside `describe "generateProviderModule"`: + +```haskell + it "uses schema block description for provider module" $ do + let schema = + emptySchema + { schemaBlock = + Just $ + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("region", emptyAttr {attributeType = Just CtyString, attributeRequired = Just True})] + , blockDescription = Just "The AWS provider configuration" + } + } + generateProviderModule "aws" schema + `shouldMapTo` [nix| + { lib, ... }: + with lib; + { + options.provider.aws = mkOption { + type = types.attrsOf (types.submodule { + options = { + region = mkOption { + type = types.str; + }; + }; + }); + default = {}; + description = "The AWS provider configuration"; + }; + } + |] +``` + +**Step 2: Run tests to verify the new tests fail** + +Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -20` +Expected: New tests fail (current code uses hardcoded descriptions) + +**Step 3: Update top-level description handling** + +In `lib/TerranixCodegen/ModuleGenerator.hs`, replace the three `descriptionBinding` definitions: + +In `generateResourceModule` (lines 82-86): + +```haskell + descriptionBinding = + NamedVar + (mkSelector "description") + ( case schemaBlock schema >>= Description.fromBlock of + Just desc -> Description.toNExpr desc + Nothing -> mkStr $ "Instances of " <> resourceType + ) + nullPos +``` + +In `generateDataSourceModule` (lines 135-139): + +```haskell + descriptionBinding = + NamedVar + (mkSelector "description") + ( case schemaBlock schema >>= Description.fromBlock of + Just desc -> Description.toNExpr desc + Nothing -> mkStr $ "Instances of " <> dataSourceType <> " data source" + ) + nullPos +``` + +In `generateProviderModule` (lines 197-201): + +```haskell + descriptionBinding = + NamedVar + (mkSelector "description") + ( case schemaBlock schema >>= Description.fromBlock of + Just desc -> Description.toNExpr desc + Nothing -> mkStr $ providerName <> " provider configuration" + ) + nullPos +``` + +**Step 4: Run all tests to verify they pass** + +Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -40` +Expected: ALL tests pass (existing tests still use schemas with no block description, so fallbacks are used) + +**Step 5: Run nix flake check for full validation** + +Run: `nix flake check` +Expected: All checks pass (build, format, lint) + +**Step 6: Commit** + +```bash +git add lib/TerranixCodegen/ModuleGenerator.hs test/TerranixCodegen/ModuleGeneratorSpec.hs +git commit -m "feat: use schema descriptions for top-level module descriptions" +``` + +______________________________________________________________________ + +### Notes + +- **Existing tests are unaffected:** All current test schemas use `emptyBlock` with `blockDescription = Nothing`, so fallback descriptions are used and output is unchanged. +- **`blockTypeToOption` (exported, ModuleGenerator:298-329):** Not updated in this effort — it's a separate exported function that may have different consumers. Can be updated in a follow-up. +- **Indentation value 16:** Matches the existing `mkIndentedStr 16` convention in OptionBuilder. This controls Nix indented string formatting depth. +- **`lib.mdDoc` availability:** Generated modules use `{ lib, ... }: with lib;` wrapper, so both `lib.mdDoc` and bare `mdDoc` resolve. Using `lib.mdDoc` explicitly for clarity. From c058ddec895a3f8f4adfb568b9d0bc9e360d9d2e Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Tue, 10 Mar 2026 15:50:12 -0700 Subject: [PATCH 4/9] feat: add Description type with metadata enrichment and mdDoc rendering --- lib/TerranixCodegen/Description.hs | 119 +++++++++++++++++ terranix-codegen.cabal | 2 + test/TerranixCodegen/DescriptionSpec.hs | 164 ++++++++++++++++++++++++ 3 files changed, 285 insertions(+) create mode 100644 lib/TerranixCodegen/Description.hs create mode 100644 test/TerranixCodegen/DescriptionSpec.hs diff --git a/lib/TerranixCodegen/Description.hs b/lib/TerranixCodegen/Description.hs new file mode 100644 index 0000000..8588171 --- /dev/null +++ b/lib/TerranixCodegen/Description.hs @@ -0,0 +1,119 @@ +module TerranixCodegen.Description ( + Description (..), + fromAttribute, + fromBlock, + fromText, + toNExpr, +) where + +import Data.Fix (Fix (..)) +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import Data.Text qualified as T +import Nix.Expr.Shorthands +import Nix.Expr.Types (NExpr, NExprF (..)) + +import TerranixCodegen.ProviderSchema.Attribute +import TerranixCodegen.ProviderSchema.Block +import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..)) + +-- | A description with text and format kind (plain or markdown). +data Description = Description + { descriptionText :: Text + , descriptionKind :: SchemaDescriptionKind + } + deriving stock (Show, Eq) + +-- | Create a Description from raw text and kind. +fromText :: Text -> SchemaDescriptionKind -> Description +fromText = Description + +{- | Build a Description from schema attribute metadata. + +Enriches with metadata notes: + - Deprecation warnings + - Sensitivity warnings + - Write-only notes + - Computed attribute notes + +Returns Nothing if all parts are empty. +-} +fromAttribute :: SchemaAttribute -> Maybe Description +fromAttribute attr + | T.null combinedDesc = Nothing + | otherwise = Just $ Description finalDesc kind + where + kind = fromMaybe Plain (attributeDescriptionKind attr) + + nonEmptyParts = filter (not . T.null) parts + combinedDesc = T.intercalate "\n\n" nonEmptyParts + + -- Add trailing newline only for multi-line descriptions + finalDesc + | length nonEmptyParts > 1 = combinedDesc <> "\n" + | otherwise = combinedDesc + + parts = + [ fromMaybe "" (attributeDescription attr) + , if fromMaybe False (attributeDeprecated attr) + then "DEPRECATED: This attribute is deprecated and may be removed in a future version." + else "" + , if fromMaybe False (attributeSensitive attr) + then "WARNING: This attribute contains sensitive information and will not be displayed in logs." + else "" + , if fromMaybe False (attributeWriteOnly attr) + then "NOTE: This attribute is write-only and will not be persisted in the Terraform state." + else "" + , if fromMaybe False (attributeComputed attr) + && not (fromMaybe False (attributeRequired attr)) + && not (fromMaybe False (attributeOptional attr)) + then "This value is computed by the provider." + else "" + ] + +{- | Build a Description from a schema block. + +Enriches with deprecation note when blockDeprecated is set. +Returns Nothing if no description or metadata. +-} +fromBlock :: SchemaBlock -> Maybe Description +fromBlock block + | T.null combinedDesc = Nothing + | otherwise = Just $ Description finalDesc kind + where + kind = fromMaybe Plain (blockDescriptionKind block) + + nonEmptyParts = filter (not . T.null) parts + combinedDesc = T.intercalate "\n\n" nonEmptyParts + + finalDesc + | length nonEmptyParts > 1 = combinedDesc <> "\n" + | otherwise = combinedDesc + + parts = + [ fromMaybe "" (blockDescription block) + , if fromMaybe False (blockDeprecated block) + then "DEPRECATED: This block is deprecated and may be removed in a future version." + else "" + ] + +{- | Render a Description to a NExpr value. + +Rendering matrix: + - Plain + single-line -> mkStr text + - Plain + multi-line -> mkIndentedStr text + - Markdown + single-line -> lib.mdDoc "text" + - Markdown + multi-line -> lib.mdDoc ''text'' +-} +toNExpr :: Description -> NExpr +toNExpr (Description text kind) = + case kind of + Plain + | isMultiLine -> mkIndentedStr 16 text + | otherwise -> mkStr text + Markdown + | isMultiLine -> mdDoc (mkIndentedStr 16 text) + | otherwise -> mdDoc (mkStr text) + where + isMultiLine = T.any (== '\n') text + mdDoc = mkApp (Fix $ NSelect Nothing (mkSym "lib") (mkSelector "mdDoc")) diff --git a/terranix-codegen.cabal b/terranix-codegen.cabal index aa6759a..6329906 100644 --- a/terranix-codegen.cabal +++ b/terranix-codegen.cabal @@ -21,6 +21,7 @@ library -- cabal-gild: discover import: warnings exposed-modules: + TerranixCodegen.Description TerranixCodegen.FileOrganizer TerranixCodegen.ModuleGenerator TerranixCodegen.OptionBuilder @@ -97,6 +98,7 @@ test-suite terranix-codegen-tests type: exitcode-stdio-1.0 main-is: Main.hs other-modules: + TerranixCodegen.DescriptionSpec TerranixCodegen.FileOrganizerSpec TerranixCodegen.ModuleGeneratorSpec TerranixCodegen.OptionBuilderSpec diff --git a/test/TerranixCodegen/DescriptionSpec.hs b/test/TerranixCodegen/DescriptionSpec.hs new file mode 100644 index 0000000..8b19502 --- /dev/null +++ b/test/TerranixCodegen/DescriptionSpec.hs @@ -0,0 +1,164 @@ +module TerranixCodegen.DescriptionSpec (spec) where + +import Data.Text qualified as T +import Nix.TH (nix) +import Test.Hspec + +import TerranixCodegen.Description +import TerranixCodegen.ProviderSchema.Attribute +import TerranixCodegen.ProviderSchema.Block +import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..)) +import TestUtils (shouldMapTo) + +emptyAttr :: SchemaAttribute +emptyAttr = + SchemaAttribute + { attributeType = Nothing + , attributeNestedType = Nothing + , attributeDescription = Nothing + , attributeDescriptionKind = Nothing + , attributeDeprecated = Nothing + , attributeRequired = Nothing + , attributeOptional = Nothing + , attributeComputed = Nothing + , attributeSensitive = Nothing + , attributeWriteOnly = Nothing + } + +emptyBlock :: SchemaBlock +emptyBlock = + SchemaBlock + { blockAttributes = Nothing + , blockNestedBlocks = Nothing + , blockDescription = Nothing + , blockDescriptionKind = Nothing + , blockDeprecated = Nothing + } + +spec :: Spec +spec = do + describe "fromAttribute" $ do + it "extracts plain description" $ do + let attr = emptyAttr {attributeDescription = Just "hello"} + let Just desc = fromAttribute attr + descriptionText desc `shouldBe` "hello" + descriptionKind desc `shouldBe` Plain + + it "extracts markdown description" $ do + let attr = + emptyAttr + { attributeDescription = Just "hello" + , attributeDescriptionKind = Just Markdown + } + let Just desc = fromAttribute attr + descriptionText desc `shouldBe` "hello" + descriptionKind desc `shouldBe` Markdown + + it "defaults to Plain when kind not specified" $ do + let attr = emptyAttr {attributeDescription = Just "hello"} + let Just desc = fromAttribute attr + descriptionKind desc `shouldBe` Plain + + it "returns Nothing when no description or metadata" $ do + fromAttribute emptyAttr `shouldBe` Nothing + + it "enriches with deprecation note" $ do + let attr = + emptyAttr + { attributeDescription = Just "Old field" + , attributeDeprecated = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" + + it "enriches with sensitivity warning" $ do + let attr = + emptyAttr + { attributeDescription = Just "Secret" + , attributeSensitive = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "WARNING" + + it "enriches with write-only note" $ do + let attr = + emptyAttr + { attributeDescription = Just "Token" + , attributeWriteOnly = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "write-only" + + it "enriches with computed note for computed-only attributes" $ do + let attr = + emptyAttr + { attributeDescription = Just "ID" + , attributeComputed = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "computed by the provider" + + it "does not add computed note for optional+computed attributes" $ do + let attr = + emptyAttr + { attributeDescription = Just "IP" + , attributeOptional = Just True + , attributeComputed = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldBe` "IP" + + it "preserves markdown kind with metadata enrichment" $ do + let attr = + emptyAttr + { attributeDescription = Just "Old field" + , attributeDescriptionKind = Just Markdown + , attributeDeprecated = Just True + } + let Just desc = fromAttribute attr + descriptionKind desc `shouldBe` Markdown + + it "creates description from metadata alone" $ do + let attr = emptyAttr {attributeDeprecated = Just True} + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" + + describe "fromBlock" $ do + it "extracts plain block description" $ do + let block = emptyBlock {blockDescription = Just "A block"} + let Just desc = fromBlock block + descriptionText desc `shouldBe` "A block" + descriptionKind desc `shouldBe` Plain + + it "extracts markdown block description" $ do + let block = + emptyBlock + { blockDescription = Just "A block" + , blockDescriptionKind = Just Markdown + } + let Just desc = fromBlock block + descriptionKind desc `shouldBe` Markdown + + it "returns Nothing for empty block" $ do + fromBlock emptyBlock `shouldBe` Nothing + + it "enriches with deprecation note" $ do + let block = + emptyBlock + { blockDescription = Just "Old block" + , blockDeprecated = Just True + } + let Just desc = fromBlock block + descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" + + it "creates description from deprecated flag alone" $ do + let block = emptyBlock {blockDeprecated = Just True} + let Just desc = fromBlock block + descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" + + describe "toNExpr" $ do + it "renders plain single-line as regular string" $ do + toNExpr (fromText "hello" Plain) `shouldMapTo` [nix| "hello" |] + + it "renders markdown single-line with lib.mdDoc" $ do + toNExpr (fromText "hello" Markdown) `shouldMapTo` [nix| lib.mdDoc "hello" |] From de744b7031d9e642c9b1932d0abb580f19e997c7 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Tue, 10 Mar 2026 16:06:01 -0700 Subject: [PATCH 5/9] refactor: use Description module in OptionBuilder --- lib/TerranixCodegen/OptionBuilder.hs | 63 ++--------------------- test/TerranixCodegen/OptionBuilderSpec.hs | 41 ++++++++++++++- 2 files changed, 43 insertions(+), 61 deletions(-) diff --git a/lib/TerranixCodegen/OptionBuilder.hs b/lib/TerranixCodegen/OptionBuilder.hs index 71734a9..486f6aa 100644 --- a/lib/TerranixCodegen/OptionBuilder.hs +++ b/lib/TerranixCodegen/OptionBuilder.hs @@ -8,10 +8,10 @@ import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Maybe (fromMaybe) import Data.Text (Text) -import Data.Text qualified as T import Nix.Expr.Shorthands import Nix.Expr.Types +import TerranixCodegen.Description qualified as Description import TerranixCodegen.ProviderSchema.Attribute import TerranixCodegen.ProviderSchema.Types (SchemaNestingMode (..)) import TerranixCodegen.TypeMapper (mapCtyTypeToNixWithOptional) @@ -61,13 +61,12 @@ buildOption _attrName attr = -- Description binding (if description exists) descriptionBinding = - case buildDescription attr of + case Description.fromAttribute attr of Just desc -> Just $ NamedVar (mkSelector "description") - -- Use indented strings for multi-line, regular strings for single-line - (if T.any (== '\n') desc then mkIndentedStr 16 desc else mkStr desc) + (Description.toNExpr desc) nullPos Nothing -> Nothing @@ -140,62 +139,6 @@ buildDefault attr -- Otherwise no default | otherwise = Nothing -{- | Build a comprehensive description from schema metadata. - -Combines: - - Base description text - - Deprecation warnings - - Sensitivity warnings - - Write-only notes - - Computed attribute notes --} -buildDescription :: SchemaAttribute -> Maybe Text -buildDescription attr = - if T.null combinedDesc - then Nothing - else Just finalDesc - where - nonEmptyParts = filter (not . T.null) parts - combinedDesc = T.intercalate "\n\n" nonEmptyParts - - -- Add trailing newline only for multi-line descriptions (more than one part) - finalDesc = - if length nonEmptyParts > 1 - then combinedDesc <> "\n" - else combinedDesc - - parts = - [ baseDesc - , deprecationNote - , sensitiveNote - , writeOnlyNote - , computedNote - ] - - baseDesc = fromMaybe "" (attributeDescription attr) - - deprecationNote = - if fromMaybe False (attributeDeprecated attr) - then "DEPRECATED: This attribute is deprecated and may be removed in a future version." - else "" - - sensitiveNote = - if fromMaybe False (attributeSensitive attr) - then "WARNING: This attribute contains sensitive information and will not be displayed in logs." - else "" - - writeOnlyNote = - if fromMaybe False (attributeWriteOnly attr) - then "NOTE: This attribute is write-only and will not be persisted in the Terraform state." - else "" - - computedNote = - if fromMaybe False (attributeComputed attr) - && not (fromMaybe False (attributeRequired attr)) - && not (fromMaybe False (attributeOptional attr)) - then "This value is computed by the provider." - else "" - {- | Determine if an attribute should be marked as read-only. An attribute is read-only if it is computed but not required or optional. diff --git a/test/TerranixCodegen/OptionBuilderSpec.hs b/test/TerranixCodegen/OptionBuilderSpec.hs index 43e7c99..cf83354 100644 --- a/test/TerranixCodegen/OptionBuilderSpec.hs +++ b/test/TerranixCodegen/OptionBuilderSpec.hs @@ -7,7 +7,7 @@ import Test.Hspec import TerranixCodegen.OptionBuilder import TerranixCodegen.ProviderSchema.Attribute import TerranixCodegen.ProviderSchema.CtyType -import TerranixCodegen.ProviderSchema.Types (SchemaNestingMode (..)) +import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..), SchemaNestingMode (..)) import TestUtils (shouldMapTo) -- | Helper to create a minimal SchemaAttribute @@ -403,6 +403,45 @@ spec = do } |] + describe "markdown descriptions" $ do + it "wraps markdown description with lib.mdDoc" $ do + let attr = + emptyAttr + { attributeType = Just CtyString + , attributeDescription = Just "The AMI ID" + , attributeDescriptionKind = Just Markdown + , attributeRequired = Just True + } + buildOption "ami" attr + `shouldMapTo` [nix| + mkOption { + type = types.str; + description = lib.mdDoc "The AMI ID"; + } + |] + + it "wraps multi-line markdown description with lib.mdDoc" $ do + let attr = + emptyAttr + { attributeType = Just CtyString + , attributeDescription = Just "The AMI ID" + , attributeDescriptionKind = Just Markdown + , attributeComputed = Just True + } + buildOption "ami" attr + `shouldMapTo` [nix| + mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + The AMI ID + + This value is computed by the provider. + ''; + readOnly = true; + } + |] + describe "real-world examples" $ do it "handles AWS instance AMI attribute" $ do let attr = From 8c68b9e359a95b00aca8688e663288a786533823 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Tue, 10 Mar 2026 16:15:49 -0700 Subject: [PATCH 6/9] feat: enrich block descriptions with metadata and mdDoc support --- lib/TerranixCodegen/ModuleGenerator.hs | 16 +--- test/TerranixCodegen/ModuleGeneratorSpec.hs | 91 ++++++++++++++++++++- 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/lib/TerranixCodegen/ModuleGenerator.hs b/lib/TerranixCodegen/ModuleGenerator.hs index 203770a..31a0210 100644 --- a/lib/TerranixCodegen/ModuleGenerator.hs +++ b/lib/TerranixCodegen/ModuleGenerator.hs @@ -15,6 +15,7 @@ import Data.Text (Text) import Nix.Expr.Shorthands import Nix.Expr.Types +import TerranixCodegen.Description qualified as Description import TerranixCodegen.OptionBuilder (attributesToSubmodule, buildOption) import TerranixCodegen.ProviderSchema.Attribute import TerranixCodegen.ProviderSchema.Block @@ -274,8 +275,8 @@ blockTypeToBinding name blockType = Just NestingMap -> Just $ NamedVar (mkSelector "default") (Fix $ NSet NonRecursive []) nullPos Nothing -> Just $ NamedVar (mkSelector "default") mkNull nullPos -- Default to Single behavior descriptionBinding = - case blockDescription (fromMaybe emptyBlock (blockTypeBlock blockType)) of - Just desc -> Just $ NamedVar (mkSelector "description") (mkStr desc) nullPos + case blockTypeBlock blockType >>= Description.fromBlock of + Just desc -> Just $ NamedVar (mkSelector "description") (Description.toNExpr desc) nullPos Nothing -> Nothing {- | Apply a nesting mode wrapper to a submodule type. @@ -374,14 +375,3 @@ nixTypes name = mkSym "types" `mkSelect` name -- | Helper to build a select expression (attribute access) mkSelect :: NExpr -> Text -> NExpr mkSelect expr attr = Fix $ NSelect Nothing expr (mkSelector attr) - --- | Empty SchemaBlock for fallback cases -emptyBlock :: SchemaBlock -emptyBlock = - SchemaBlock - { blockAttributes = Nothing - , blockNestedBlocks = Nothing - , blockDescription = Nothing - , blockDescriptionKind = Nothing - , blockDeprecated = Nothing - } diff --git a/test/TerranixCodegen/ModuleGeneratorSpec.hs b/test/TerranixCodegen/ModuleGeneratorSpec.hs index eac2f38..9ca4eb6 100644 --- a/test/TerranixCodegen/ModuleGeneratorSpec.hs +++ b/test/TerranixCodegen/ModuleGeneratorSpec.hs @@ -9,7 +9,7 @@ import TerranixCodegen.ProviderSchema.Attribute import TerranixCodegen.ProviderSchema.Block import TerranixCodegen.ProviderSchema.CtyType import TerranixCodegen.ProviderSchema.Schema -import TerranixCodegen.ProviderSchema.Types (SchemaNestingMode (..)) +import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..), SchemaNestingMode (..)) import TestUtils (shouldMapTo) -- | Helper to create an empty SchemaAttribute @@ -277,6 +277,95 @@ spec = do } |] + describe "block descriptions" $ do + it "adds deprecation note to nested block description" $ do + let nestedBlock = + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("enabled", emptyAttr {attributeType = Just CtyBool, attributeRequired = Just True})] + , blockDescription = Just "Old monitoring config" + , blockDeprecated = Just True + } + blockType = + SchemaBlockType + { blockTypeNestingMode = Just NestingSingle + , blockTypeBlock = Just nestedBlock + , blockTypeMinItems = Nothing + , blockTypeMaxItems = Nothing + } + block = + emptyBlock + { blockNestedBlocks = + Just $ + Map.fromList [("monitoring", blockType)] + } + blockToSubmodule block + `shouldMapTo` [nix| + types.submodule { + options = { + monitoring = mkOption { + type = types.submodule { + options = { + enabled = mkOption { + type = types.bool; + }; + }; + }; + default = null; + description = '' + Old monitoring config + + DEPRECATED: This block is deprecated and may be removed in a future version. + ''; + }; + }; + } + |] + + it "wraps markdown block description with lib.mdDoc" $ do + let nestedBlock = + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("enabled", emptyAttr {attributeType = Just CtyBool, attributeRequired = Just True})] + , blockDescription = Just "Enable monitoring" + , blockDescriptionKind = Just Markdown + } + blockType = + SchemaBlockType + { blockTypeNestingMode = Just NestingSingle + , blockTypeBlock = Just nestedBlock + , blockTypeMinItems = Nothing + , blockTypeMaxItems = Nothing + } + block = + emptyBlock + { blockNestedBlocks = + Just $ + Map.fromList [("monitoring", blockType)] + } + blockToSubmodule block + `shouldMapTo` [nix| + types.submodule { + options = { + monitoring = mkOption { + type = types.submodule { + options = { + enabled = mkOption { + type = types.bool; + }; + }; + }; + default = null; + description = lib.mdDoc "Enable monitoring"; + }; + }; + } + |] + describe "generateResourceModule" $ do it "generates complete resource module with simple attributes" $ do let schema = From 4659779e5428a1b6ebbc1c6a7a5f2eb043539525 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Tue, 10 Mar 2026 16:21:51 -0700 Subject: [PATCH 7/9] feat: use schema descriptions for top-level module descriptions --- lib/TerranixCodegen/ModuleGenerator.hs | 15 +++- test/TerranixCodegen/ModuleGeneratorSpec.hs | 96 +++++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/lib/TerranixCodegen/ModuleGenerator.hs b/lib/TerranixCodegen/ModuleGenerator.hs index 31a0210..5c71b56 100644 --- a/lib/TerranixCodegen/ModuleGenerator.hs +++ b/lib/TerranixCodegen/ModuleGenerator.hs @@ -83,7 +83,10 @@ generateResourceModule _providerName resourceType schema = descriptionBinding = NamedVar (mkSelector "description") - (mkStr $ "Instances of " <> resourceType) + ( case schemaBlock schema >>= Description.fromBlock of + Just desc -> Description.toNExpr desc + Nothing -> mkStr $ "Instances of " <> resourceType + ) nullPos {- | Generate a complete NixOS module for a Terraform data source. @@ -136,7 +139,10 @@ generateDataSourceModule _providerName dataSourceType schema = descriptionBinding = NamedVar (mkSelector "description") - (mkStr $ "Instances of " <> dataSourceType <> " data source") + ( case schemaBlock schema >>= Description.fromBlock of + Just desc -> Description.toNExpr desc + Nothing -> mkStr $ "Instances of " <> dataSourceType <> " data source" + ) nullPos {- | Generate a complete NixOS module for a Terraform provider configuration. @@ -198,7 +204,10 @@ generateProviderModule providerName schema = descriptionBinding = NamedVar (mkSelector "description") - (mkStr $ providerName <> " provider configuration") + ( case schemaBlock schema >>= Description.fromBlock of + Just desc -> Description.toNExpr desc + Nothing -> mkStr $ providerName <> " provider configuration" + ) nullPos {- | Convert a SchemaBlock to a types.submodule expression. diff --git a/test/TerranixCodegen/ModuleGeneratorSpec.hs b/test/TerranixCodegen/ModuleGeneratorSpec.hs index 9ca4eb6..82397bb 100644 --- a/test/TerranixCodegen/ModuleGeneratorSpec.hs +++ b/test/TerranixCodegen/ModuleGeneratorSpec.hs @@ -463,6 +463,38 @@ spec = do } |] + it "uses schema block description for resource module" $ do + let schema = + emptySchema + { schemaBlock = + Just $ + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("name", emptyAttr {attributeType = Just CtyString, attributeRequired = Just True})] + , blockDescription = Just "Manages a compute instance" + } + } + generateResourceModule "aws" "aws_instance" schema + `shouldMapTo` [nix| + { lib, ... }: + with lib; + { + options.resource.aws_instance = mkOption { + type = types.attrsOf (types.submodule { + options = { + name = mkOption { + type = types.str; + }; + }; + }); + default = {}; + description = "Manages a compute instance"; + }; + } + |] + describe "generateDataSourceModule" $ do it "generates complete data source module" $ do let schema = @@ -495,6 +527,38 @@ spec = do } |] + it "uses schema block description for data source module" $ do + let schema = + emptySchema + { schemaBlock = + Just $ + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("name", emptyAttr {attributeType = Just CtyString, attributeRequired = Just True})] + , blockDescription = Just "Fetches AMI information" + } + } + generateDataSourceModule "aws" "aws_ami" schema + `shouldMapTo` [nix| + { lib, ... }: + with lib; + { + options.data.aws_ami = mkOption { + type = types.attrsOf (types.submodule { + options = { + name = mkOption { + type = types.str; + }; + }; + }); + default = {}; + description = "Fetches AMI information"; + }; + } + |] + describe "generateProviderModule" $ do it "generates complete provider configuration module" $ do let schema = @@ -532,3 +596,35 @@ spec = do }; } |] + + it "uses schema block description for provider module" $ do + let schema = + emptySchema + { schemaBlock = + Just $ + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("region", emptyAttr {attributeType = Just CtyString, attributeRequired = Just True})] + , blockDescription = Just "The AWS provider configuration" + } + } + generateProviderModule "aws" schema + `shouldMapTo` [nix| + { lib, ... }: + with lib; + { + options.provider.aws = mkOption { + type = types.attrsOf (types.submodule { + options = { + region = mkOption { + type = types.str; + }; + }; + }); + default = {}; + description = "The AWS provider configuration"; + }; + } + |] From dc1316ce88e0287b88cc38801476ea0456ac1bc3 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Tue, 10 Mar 2026 16:26:21 -0700 Subject: [PATCH 8/9] docs: update Haddock comments to reflect schema-based descriptions --- lib/TerranixCodegen/ModuleGenerator.hs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/TerranixCodegen/ModuleGenerator.hs b/lib/TerranixCodegen/ModuleGenerator.hs index 5c71b56..e687ce3 100644 --- a/lib/TerranixCodegen/ModuleGenerator.hs +++ b/lib/TerranixCodegen/ModuleGenerator.hs @@ -31,10 +31,13 @@ Creates a module with the structure: options.resource.{resourceType} = mkOption { type = types.attrsOf (types.submodule { options = {...}; }); default = {}; - description = "Instances of {resourceType}"; + description = ""; }; } +Uses the schema's block description when available, otherwise falls back to +"Instances of {resourceType}". + Example: generateResourceModule "aws" "aws_instance" schema -} @@ -154,9 +157,12 @@ Creates a module with the structure: options.provider.{providerName} = mkOption { type = types.attrsOf (types.submodule { options = {...}; }); default = {}; - description = "{providerName} provider configuration"; + description = ""; }; } + +Uses the schema's block description when available, otherwise falls back to +"{providerName} provider configuration". -} generateProviderModule :: Text -> Schema -> NExpr generateProviderModule providerName schema = From 924a045c16ba72147b8dde0b9003d1166ea56905 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Tue, 10 Mar 2026 16:29:56 -0700 Subject: [PATCH 9/9] chore: remove design doc and implementation plan --- .../2026-03-10-description-gaps-design.md | 122 --- ...6-03-10-description-gaps-implementation.md | 814 ------------------ 2 files changed, 936 deletions(-) delete mode 100644 docs/plans/2026-03-10-description-gaps-design.md delete mode 100644 docs/plans/2026-03-10-description-gaps-implementation.md diff --git a/docs/plans/2026-03-10-description-gaps-design.md b/docs/plans/2026-03-10-description-gaps-design.md deleted file mode 100644 index 7d7441d..0000000 --- a/docs/plans/2026-03-10-description-gaps-design.md +++ /dev/null @@ -1,122 +0,0 @@ -# Description Gaps Design - -## Problem - -Three gaps in how descriptions flow through the codegen pipeline: - -1. **`SchemaDescriptionKind` is parsed but unused** — markdown descriptions are treated as plain text. -1. **Block descriptions get no metadata enrichment** — `blockDeprecated` is ignored in generated output. -1. **Top-level module descriptions are hardcoded** — `"Instances of "` ignores the schema's actual block description. - -Identity attribute descriptions (gap 4) are deferred to a separate effort. - -## Approach: Unified `Description` Type - -New module `lib/TerranixCodegen/Description.hs` encapsulating description text, kind, and rendering. - -### The Type - -```haskell -data Description = Description - { descriptionText :: Text - , descriptionKind :: SchemaDescriptionKind -- Plain or Markdown - } -``` - -### Constructors - -- **`fromAttribute :: SchemaAttribute -> Maybe Description`** — extracts `attributeDescription` and enriches with metadata notes (DEPRECATED, WARNING, NOTE, computed). Preserves `attributeDescriptionKind`. Returns `Nothing` if all parts are empty. Replaces the current `buildDescription` in OptionBuilder. -- **`fromBlock :: SchemaBlock -> Maybe Description`** — extracts `blockDescription` and enriches with deprecated note when `blockDeprecated` is set. Preserves `blockDescriptionKind`. -- **`fromText :: Text -> SchemaDescriptionKind -> Description`** — simple constructor from raw text and kind. - -### Rendering - -```haskell -toNExpr :: Description -> NExpr -``` - -Rendering matrix: - -| Kind | Lines | Output | -|----------|--------|-------------------------------| -| Plain | Single | `mkStr text` | -| Plain | Multi | `mkIndentedStr text` | -| Markdown | Single | `lib.mdDoc "text"` | -| Markdown | Multi | `lib.mdDoc ''text''` | - -Returns an `NExpr` value, not a `Binding`. Callers construct the binding: - -```haskell -NamedVar (mkSelector "description") (toNExpr desc) nullPos -``` - -Metadata notes (DEPRECATED, WARNING, etc.) are plain text appended to the description. The original `descriptionKind` is preserved since markdown renders plain text correctly. - -## Integration - -### OptionBuilder - -- Remove internal `buildDescription :: SchemaAttribute -> Maybe Text`. -- Replace with `Description.fromAttribute` in `buildOption`. -- `buildOption` signature unchanged: `Text -> SchemaAttribute -> NExpr`. -- The multi-line/single-line string logic moves into `Description.toNExpr`. - -### ModuleGenerator - -**Block descriptions (nested blocks):** - -```haskell -case Description.fromBlock block of - Just desc -> Just $ NamedVar (mkSelector "description") (toNExpr desc) nullPos - Nothing -> Nothing -``` - -`fromBlock` adds a deprecated note when `blockDeprecated = Just True`. - -**Top-level resource/data source/provider descriptions:** - -Pull from the schema's root block description with fallback to current hardcoded strings: - -```haskell -descriptionBinding = - NamedVar (mkSelector "description") - (case schemaBlock schema >>= Description.fromBlock of - Just desc -> toNExpr desc - Nothing -> mkStr $ "Instances of " <> resourceType) - nullPos -``` - -Fallback strings: - -- Resource: `"Instances of "` -- Data source: `"Instances of data source"` -- Provider: `" provider configuration"` - -No signature changes needed — `Schema` already contains `SchemaBlock` with descriptions. - -## Testing - -### New: `DescriptionSpec.hs` - -- `fromAttribute` with plain/markdown descriptions -- `fromAttribute` metadata enrichment (deprecated, sensitive, writeOnly, computed) -- `fromBlock` with/without deprecated flag -- `fromBlock` with markdown kind -- `toNExpr` for all four rendering cases (plain/markdown x single/multi-line) - -### Updated: `OptionBuilderSpec.hs` - -- Existing tests pass unchanged (plain descriptions produce same output) -- New test: markdown-kind attribute generates `lib.mdDoc` wrapper - -### Updated: `ModuleGeneratorSpec.hs` - -- Block descriptions include deprecated note when `blockDeprecated = Just True` -- Top-level resource description uses schema block description -- Fallback to hardcoded description when schema block has no description - -## Decisions - -- **`lib.mdDoc` wrapping** only for `Markdown`-kind descriptions, not all descriptions. -- **Identity attributes** deferred to separate design effort. -- **Metadata notes** preserve original description kind (plain text appended to markdown is valid). diff --git a/docs/plans/2026-03-10-description-gaps-implementation.md b/docs/plans/2026-03-10-description-gaps-implementation.md deleted file mode 100644 index dd53f96..0000000 --- a/docs/plans/2026-03-10-description-gaps-implementation.md +++ /dev/null @@ -1,814 +0,0 @@ -# Description Gaps Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Create a unified `Description` type that handles markdown wrapping, block metadata enrichment, and top-level schema descriptions. - -**Architecture:** New `Description` module sits between `ProviderSchema.*` types and code generators (`OptionBuilder`, `ModuleGenerator`). It encapsulates description text + kind, enrichment with metadata notes, and rendering to `NExpr` (choosing `mkStr`/`mkIndentedStr`/`lib.mdDoc` wrapping). Replaces the internal `buildDescription` in OptionBuilder and the direct `mkStr` in ModuleGenerator. - -**Tech Stack:** Haskell (GHC2024), hnix (`Nix.Expr.Shorthands`, `Nix.Expr.Types`), Hspec - -**Design doc:** `docs/plans/2026-03-10-description-gaps-design.md` - -______________________________________________________________________ - -### Task 1: Create Description Module with Type and Constructors - -**Files:** - -- Create: `lib/TerranixCodegen/Description.hs` -- Create: `test/TerranixCodegen/DescriptionSpec.hs` -- Modify: `terranix-codegen.cabal:23-39` (add exposed module) -- Modify: `terranix-codegen.cabal:99-106` (add test module) - -**Step 1: Register new modules in cabal** - -Add `TerranixCodegen.Description` to library `exposed-modules` and `TerranixCodegen.DescriptionSpec` to test `other-modules` in `terranix-codegen.cabal`. The `cabal-gild` formatter will sort them. - -In library `exposed-modules` (around line 23), add: - -``` -TerranixCodegen.Description -``` - -In test `other-modules` (around line 99), add: - -``` -TerranixCodegen.DescriptionSpec -``` - -**Step 2: Write failing tests for fromAttribute, fromBlock, and toNExpr** - -Create `test/TerranixCodegen/DescriptionSpec.hs`: - -```haskell -module TerranixCodegen.DescriptionSpec (spec) where - -import Data.Text qualified as T -import Nix.TH (nix) -import Test.Hspec - -import TerranixCodegen.Description -import TerranixCodegen.ProviderSchema.Attribute -import TerranixCodegen.ProviderSchema.Block -import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..)) -import TestUtils (shouldMapTo) - -emptyAttr :: SchemaAttribute -emptyAttr = - SchemaAttribute - { attributeType = Nothing - , attributeNestedType = Nothing - , attributeDescription = Nothing - , attributeDescriptionKind = Nothing - , attributeDeprecated = Nothing - , attributeRequired = Nothing - , attributeOptional = Nothing - , attributeComputed = Nothing - , attributeSensitive = Nothing - , attributeWriteOnly = Nothing - } - -emptyBlock :: SchemaBlock -emptyBlock = - SchemaBlock - { blockAttributes = Nothing - , blockNestedBlocks = Nothing - , blockDescription = Nothing - , blockDescriptionKind = Nothing - , blockDeprecated = Nothing - } - -spec :: Spec -spec = do - describe "fromAttribute" $ do - it "extracts plain description" $ do - let attr = emptyAttr {attributeDescription = Just "hello"} - let Just desc = fromAttribute attr - descriptionText desc `shouldBe` "hello" - descriptionKind desc `shouldBe` Plain - - it "extracts markdown description" $ do - let attr = - emptyAttr - { attributeDescription = Just "hello" - , attributeDescriptionKind = Just Markdown - } - let Just desc = fromAttribute attr - descriptionText desc `shouldBe` "hello" - descriptionKind desc `shouldBe` Markdown - - it "defaults to Plain when kind not specified" $ do - let attr = emptyAttr {attributeDescription = Just "hello"} - let Just desc = fromAttribute attr - descriptionKind desc `shouldBe` Plain - - it "returns Nothing when no description or metadata" $ do - fromAttribute emptyAttr `shouldBe` Nothing - - it "enriches with deprecation note" $ do - let attr = - emptyAttr - { attributeDescription = Just "Old field" - , attributeDeprecated = Just True - } - let Just desc = fromAttribute attr - descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" - - it "enriches with sensitivity warning" $ do - let attr = - emptyAttr - { attributeDescription = Just "Secret" - , attributeSensitive = Just True - } - let Just desc = fromAttribute attr - descriptionText desc `shouldSatisfy` T.isInfixOf "WARNING" - - it "enriches with write-only note" $ do - let attr = - emptyAttr - { attributeDescription = Just "Token" - , attributeWriteOnly = Just True - } - let Just desc = fromAttribute attr - descriptionText desc `shouldSatisfy` T.isInfixOf "write-only" - - it "enriches with computed note for computed-only attributes" $ do - let attr = - emptyAttr - { attributeDescription = Just "ID" - , attributeComputed = Just True - } - let Just desc = fromAttribute attr - descriptionText desc `shouldSatisfy` T.isInfixOf "computed by the provider" - - it "does not add computed note for optional+computed attributes" $ do - let attr = - emptyAttr - { attributeDescription = Just "IP" - , attributeOptional = Just True - , attributeComputed = Just True - } - let Just desc = fromAttribute attr - descriptionText desc `shouldBe` "IP" - - it "preserves markdown kind with metadata enrichment" $ do - let attr = - emptyAttr - { attributeDescription = Just "Old field" - , attributeDescriptionKind = Just Markdown - , attributeDeprecated = Just True - } - let Just desc = fromAttribute attr - descriptionKind desc `shouldBe` Markdown - - it "creates description from metadata alone" $ do - let attr = emptyAttr {attributeDeprecated = Just True} - let Just desc = fromAttribute attr - descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" - - describe "fromBlock" $ do - it "extracts plain block description" $ do - let block = emptyBlock {blockDescription = Just "A block"} - let Just desc = fromBlock block - descriptionText desc `shouldBe` "A block" - descriptionKind desc `shouldBe` Plain - - it "extracts markdown block description" $ do - let block = - emptyBlock - { blockDescription = Just "A block" - , blockDescriptionKind = Just Markdown - } - let Just desc = fromBlock block - descriptionKind desc `shouldBe` Markdown - - it "returns Nothing for empty block" $ do - fromBlock emptyBlock `shouldBe` Nothing - - it "enriches with deprecation note" $ do - let block = - emptyBlock - { blockDescription = Just "Old block" - , blockDeprecated = Just True - } - let Just desc = fromBlock block - descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" - - it "creates description from deprecated flag alone" $ do - let block = emptyBlock {blockDeprecated = Just True} - let Just desc = fromBlock block - descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" - - describe "toNExpr" $ do - it "renders plain single-line as regular string" $ do - toNExpr (fromText "hello" Plain) `shouldMapTo` [nix| "hello" |] - - it "renders markdown single-line with lib.mdDoc" $ do - toNExpr (fromText "hello" Markdown) `shouldMapTo` [nix| lib.mdDoc "hello" |] -``` - -**Step 3: Run tests to verify they fail** - -Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -20` -Expected: Compilation failure (`Could not find module 'TerranixCodegen.Description'`) - -**Step 4: Implement Description module** - -Create `lib/TerranixCodegen/Description.hs`: - -```haskell -module TerranixCodegen.Description ( - Description (..), - fromAttribute, - fromBlock, - fromText, - toNExpr, -) where - -import Data.Fix (Fix (..)) -import Data.Maybe (fromMaybe) -import Data.Text (Text) -import Data.Text qualified as T -import Nix.Expr.Shorthands -import Nix.Expr.Types - -import TerranixCodegen.ProviderSchema.Attribute -import TerranixCodegen.ProviderSchema.Block -import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..)) - --- | A description with text and format kind (plain or markdown). -data Description = Description - { descriptionText :: Text - , descriptionKind :: SchemaDescriptionKind - } - deriving stock (Show, Eq) - --- | Create a Description from raw text and kind. -fromText :: Text -> SchemaDescriptionKind -> Description -fromText = Description - -{- | Build a Description from schema attribute metadata. - -Enriches with metadata notes: - - Deprecation warnings - - Sensitivity warnings - - Write-only notes - - Computed attribute notes - -Returns Nothing if all parts are empty. --} -fromAttribute :: SchemaAttribute -> Maybe Description -fromAttribute attr - | T.null combinedDesc = Nothing - | otherwise = Just $ Description finalDesc kind - where - kind = fromMaybe Plain (attributeDescriptionKind attr) - - nonEmptyParts = filter (not . T.null) parts - combinedDesc = T.intercalate "\n\n" nonEmptyParts - - -- Add trailing newline only for multi-line descriptions - finalDesc - | length nonEmptyParts > 1 = combinedDesc <> "\n" - | otherwise = combinedDesc - - parts = - [ fromMaybe "" (attributeDescription attr) - , if fromMaybe False (attributeDeprecated attr) - then "DEPRECATED: This attribute is deprecated and may be removed in a future version." - else "" - , if fromMaybe False (attributeSensitive attr) - then "WARNING: This attribute contains sensitive information and will not be displayed in logs." - else "" - , if fromMaybe False (attributeWriteOnly attr) - then "NOTE: This attribute is write-only and will not be persisted in the Terraform state." - else "" - , if fromMaybe False (attributeComputed attr) - && not (fromMaybe False (attributeRequired attr)) - && not (fromMaybe False (attributeOptional attr)) - then "This value is computed by the provider." - else "" - ] - -{- | Build a Description from a schema block. - -Enriches with deprecation note when blockDeprecated is set. -Returns Nothing if no description or metadata. --} -fromBlock :: SchemaBlock -> Maybe Description -fromBlock block - | T.null combinedDesc = Nothing - | otherwise = Just $ Description finalDesc kind - where - kind = fromMaybe Plain (blockDescriptionKind block) - - nonEmptyParts = filter (not . T.null) parts - combinedDesc = T.intercalate "\n\n" nonEmptyParts - - finalDesc - | length nonEmptyParts > 1 = combinedDesc <> "\n" - | otherwise = combinedDesc - - parts = - [ fromMaybe "" (blockDescription block) - , if fromMaybe False (blockDeprecated block) - then "DEPRECATED: This block is deprecated and may be removed in a future version." - else "" - ] - -{- | Render a Description to a NExpr value. - -Rendering matrix: - - Plain + single-line → mkStr text - - Plain + multi-line → mkIndentedStr text - - Markdown + single-line → lib.mdDoc "text" - - Markdown + multi-line → lib.mdDoc ''text'' --} -toNExpr :: Description -> NExpr -toNExpr (Description text kind) = - case kind of - Plain - | isMultiLine -> mkIndentedStr 16 text - | otherwise -> mkStr text - Markdown - | isMultiLine -> mdDoc (mkIndentedStr 16 text) - | otherwise -> mdDoc (mkStr text) - where - isMultiLine = T.any (== '\n') text - mdDoc = mkApp (Fix $ NSelect Nothing (mkSym "lib") (mkSelector "mdDoc")) -``` - -**Step 5: Run tests to verify they pass** - -Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -40` -Expected: All tests pass, including new DescriptionSpec tests - -**Step 6: Commit** - -```bash -git add lib/TerranixCodegen/Description.hs test/TerranixCodegen/DescriptionSpec.hs terranix-codegen.cabal -git commit -m "feat: add Description type with metadata enrichment and mdDoc rendering" -``` - -______________________________________________________________________ - -### Task 2: Integrate Description into OptionBuilder - -**Files:** - -- Modify: `lib/TerranixCodegen/OptionBuilder.hs:1-17` (imports) -- Modify: `lib/TerranixCodegen/OptionBuilder.hs:62-72` (descriptionBinding) -- Modify: `lib/TerranixCodegen/OptionBuilder.hs:143-197` (remove buildDescription) -- Modify: `test/TerranixCodegen/OptionBuilderSpec.hs` (add markdown test) - -**Step 1: Add markdown-kind test to OptionBuilderSpec** - -Add to `OptionBuilderSpec.hs` in the `describe "buildOption"` block, after the "edge cases" describe block: - -```haskell - describe "markdown descriptions" $ do - it "wraps markdown description with lib.mdDoc" $ do - let attr = - emptyAttr - { attributeType = Just CtyString - , attributeDescription = Just "The AMI ID" - , attributeDescriptionKind = Just Markdown - , attributeRequired = Just True - } - buildOption "ami" attr - `shouldMapTo` [nix| - mkOption { - type = types.str; - description = lib.mdDoc "The AMI ID"; - } - |] - - it "wraps multi-line markdown description with lib.mdDoc" $ do - let attr = - emptyAttr - { attributeType = Just CtyString - , attributeDescription = Just "The AMI ID" - , attributeDescriptionKind = Just Markdown - , attributeComputed = Just True - } - buildOption "ami" attr - `shouldMapTo` [nix| - mkOption { - type = types.nullOr types.str; - default = null; - description = lib.mdDoc '' - The AMI ID - - This value is computed by the provider. - ''; - readOnly = true; - } - |] -``` - -Also add import for `SchemaDescriptionKind`: - -```haskell -import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..), SchemaNestingMode (..)) -``` - -**Step 2: Run tests to verify the new test fails** - -Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -20` -Expected: New markdown test fails (current code produces `mkStr` not `lib.mdDoc`) - -**Step 3: Update OptionBuilder to use Description module** - -In `lib/TerranixCodegen/OptionBuilder.hs`: - -Replace imports — remove `Data.Text qualified as T`, add Description import: - -```haskell -import Data.Fix (Fix (..)) -import Data.Map.Strict (Map) -import Data.Map.Strict qualified as Map -import Data.Maybe (fromMaybe) -import Data.Text (Text) -import Nix.Expr.Shorthands -import Nix.Expr.Types - -import TerranixCodegen.Description qualified as Description -import TerranixCodegen.ProviderSchema.Attribute -import TerranixCodegen.ProviderSchema.Types (SchemaNestingMode (..)) -import TerranixCodegen.TypeMapper (mapCtyTypeToNixWithOptional) -``` - -Replace `descriptionBinding` (lines 62-72): - -```haskell - -- Description binding (if description exists) - descriptionBinding = - case Description.fromAttribute attr of - Just desc -> - Just $ - NamedVar - (mkSelector "description") - (Description.toNExpr desc) - nullPos - Nothing -> Nothing -``` - -Delete `buildDescription` function (lines 143-197). - -**Step 4: Run all tests to verify they pass** - -Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -40` -Expected: ALL tests pass (existing + new markdown tests) - -**Step 5: Commit** - -```bash -git add lib/TerranixCodegen/OptionBuilder.hs test/TerranixCodegen/OptionBuilderSpec.hs -git commit -m "refactor: use Description module in OptionBuilder" -``` - -______________________________________________________________________ - -### Task 3: Integrate Description into ModuleGenerator — Block Descriptions - -**Files:** - -- Modify: `lib/TerranixCodegen/ModuleGenerator.hs:1-22` (imports) -- Modify: `lib/TerranixCodegen/ModuleGenerator.hs:276-279` (descriptionBinding in blockTypeToBinding) -- Modify: `lib/TerranixCodegen/ModuleGenerator.hs:378-387` (remove emptyBlock) -- Modify: `test/TerranixCodegen/ModuleGeneratorSpec.hs` (add block description tests) - -**Step 1: Add block description tests to ModuleGeneratorSpec** - -Add to `ModuleGeneratorSpec.hs` after the existing `describe "blockToSubmodule"` block, inside `spec`: - -```haskell - describe "block descriptions" $ do - it "adds deprecation note to nested block description" $ do - let nestedBlock = - emptyBlock - { blockAttributes = - Just $ - Map.fromList - [("enabled", emptyAttr {attributeType = Just CtyBool, attributeRequired = Just True})] - , blockDescription = Just "Old monitoring config" - , blockDeprecated = Just True - } - blockType = - SchemaBlockType - { blockTypeNestingMode = Just NestingSingle - , blockTypeBlock = Just nestedBlock - , blockTypeMinItems = Nothing - , blockTypeMaxItems = Nothing - } - block = - emptyBlock - { blockNestedBlocks = - Just $ - Map.fromList [("monitoring", blockType)] - } - blockToSubmodule block - `shouldMapTo` [nix| - types.submodule { - options = { - monitoring = mkOption { - type = types.submodule { - options = { - enabled = mkOption { - type = types.bool; - }; - }; - }; - default = null; - description = '' - Old monitoring config - - DEPRECATED: This block is deprecated and may be removed in a future version. - ''; - }; - }; - } - |] - - it "wraps markdown block description with lib.mdDoc" $ do - let nestedBlock = - emptyBlock - { blockAttributes = - Just $ - Map.fromList - [("enabled", emptyAttr {attributeType = Just CtyBool, attributeRequired = Just True})] - , blockDescription = Just "Enable monitoring" - , blockDescriptionKind = Just Markdown - } - blockType = - SchemaBlockType - { blockTypeNestingMode = Just NestingSingle - , blockTypeBlock = Just nestedBlock - , blockTypeMinItems = Nothing - , blockTypeMaxItems = Nothing - } - block = - emptyBlock - { blockNestedBlocks = - Just $ - Map.fromList [("monitoring", blockType)] - } - blockToSubmodule block - `shouldMapTo` [nix| - types.submodule { - options = { - monitoring = mkOption { - type = types.submodule { - options = { - enabled = mkOption { - type = types.bool; - }; - }; - }; - default = null; - description = lib.mdDoc "Enable monitoring"; - }; - }; - } - |] -``` - -Also add import for `SchemaDescriptionKind`: - -```haskell -import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..), SchemaNestingMode (..)) -``` - -**Step 2: Run tests to verify the new tests fail** - -Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -20` -Expected: New tests fail (current code doesn't enrich block descriptions or handle markdown) - -**Step 3: Update ModuleGenerator block description handling** - -In `lib/TerranixCodegen/ModuleGenerator.hs`: - -Add import: - -```haskell -import TerranixCodegen.Description qualified as Description -``` - -Replace `descriptionBinding` in `blockTypeToBinding` (lines 276-279): - -```haskell - descriptionBinding = - case blockTypeBlock blockType >>= Description.fromBlock of - Just desc -> Just $ NamedVar (mkSelector "description") (Description.toNExpr desc) nullPos - Nothing -> Nothing -``` - -Remove the unused `emptyBlock` helper (lines 378-387). - -**Step 4: Run all tests to verify they pass** - -Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -40` -Expected: ALL tests pass - -**Step 5: Commit** - -```bash -git add lib/TerranixCodegen/ModuleGenerator.hs test/TerranixCodegen/ModuleGeneratorSpec.hs -git commit -m "feat: enrich block descriptions with metadata and mdDoc support" -``` - -______________________________________________________________________ - -### Task 4: Integrate Description into ModuleGenerator — Top-Level Descriptions - -**Files:** - -- Modify: `lib/TerranixCodegen/ModuleGenerator.hs:82-86` (resource descriptionBinding) -- Modify: `lib/TerranixCodegen/ModuleGenerator.hs:135-139` (data source descriptionBinding) -- Modify: `lib/TerranixCodegen/ModuleGenerator.hs:197-201` (provider descriptionBinding) -- Modify: `test/TerranixCodegen/ModuleGeneratorSpec.hs` (add top-level description tests) - -**Step 1: Add top-level description tests to ModuleGeneratorSpec** - -Add to `ModuleGeneratorSpec.hs` inside the existing `describe "generateResourceModule"` block: - -```haskell - it "uses schema block description for resource module" $ do - let schema = - emptySchema - { schemaBlock = - Just $ - emptyBlock - { blockAttributes = - Just $ - Map.fromList - [("name", emptyAttr {attributeType = Just CtyString, attributeRequired = Just True})] - , blockDescription = Just "Manages a compute instance" - } - } - generateResourceModule "aws" "aws_instance" schema - `shouldMapTo` [nix| - { lib, ... }: - with lib; - { - options.resource.aws_instance = mkOption { - type = types.attrsOf (types.submodule { - options = { - name = mkOption { - type = types.str; - }; - }; - }); - default = {}; - description = "Manages a compute instance"; - }; - } - |] -``` - -Add inside `describe "generateDataSourceModule"`: - -```haskell - it "uses schema block description for data source module" $ do - let schema = - emptySchema - { schemaBlock = - Just $ - emptyBlock - { blockAttributes = - Just $ - Map.fromList - [("name", emptyAttr {attributeType = Just CtyString, attributeRequired = Just True})] - , blockDescription = Just "Fetches AMI information" - } - } - generateDataSourceModule "aws" "aws_ami" schema - `shouldMapTo` [nix| - { lib, ... }: - with lib; - { - options.data.aws_ami = mkOption { - type = types.attrsOf (types.submodule { - options = { - name = mkOption { - type = types.str; - }; - }; - }); - default = {}; - description = "Fetches AMI information"; - }; - } - |] -``` - -Add inside `describe "generateProviderModule"`: - -```haskell - it "uses schema block description for provider module" $ do - let schema = - emptySchema - { schemaBlock = - Just $ - emptyBlock - { blockAttributes = - Just $ - Map.fromList - [("region", emptyAttr {attributeType = Just CtyString, attributeRequired = Just True})] - , blockDescription = Just "The AWS provider configuration" - } - } - generateProviderModule "aws" schema - `shouldMapTo` [nix| - { lib, ... }: - with lib; - { - options.provider.aws = mkOption { - type = types.attrsOf (types.submodule { - options = { - region = mkOption { - type = types.str; - }; - }; - }); - default = {}; - description = "The AWS provider configuration"; - }; - } - |] -``` - -**Step 2: Run tests to verify the new tests fail** - -Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -20` -Expected: New tests fail (current code uses hardcoded descriptions) - -**Step 3: Update top-level description handling** - -In `lib/TerranixCodegen/ModuleGenerator.hs`, replace the three `descriptionBinding` definitions: - -In `generateResourceModule` (lines 82-86): - -```haskell - descriptionBinding = - NamedVar - (mkSelector "description") - ( case schemaBlock schema >>= Description.fromBlock of - Just desc -> Description.toNExpr desc - Nothing -> mkStr $ "Instances of " <> resourceType - ) - nullPos -``` - -In `generateDataSourceModule` (lines 135-139): - -```haskell - descriptionBinding = - NamedVar - (mkSelector "description") - ( case schemaBlock schema >>= Description.fromBlock of - Just desc -> Description.toNExpr desc - Nothing -> mkStr $ "Instances of " <> dataSourceType <> " data source" - ) - nullPos -``` - -In `generateProviderModule` (lines 197-201): - -```haskell - descriptionBinding = - NamedVar - (mkSelector "description") - ( case schemaBlock schema >>= Description.fromBlock of - Just desc -> Description.toNExpr desc - Nothing -> mkStr $ providerName <> " provider configuration" - ) - nullPos -``` - -**Step 4: Run all tests to verify they pass** - -Run: `cabal test --enable-tests --test-show-details=direct 2>&1 | tail -40` -Expected: ALL tests pass (existing tests still use schemas with no block description, so fallbacks are used) - -**Step 5: Run nix flake check for full validation** - -Run: `nix flake check` -Expected: All checks pass (build, format, lint) - -**Step 6: Commit** - -```bash -git add lib/TerranixCodegen/ModuleGenerator.hs test/TerranixCodegen/ModuleGeneratorSpec.hs -git commit -m "feat: use schema descriptions for top-level module descriptions" -``` - -______________________________________________________________________ - -### Notes - -- **Existing tests are unaffected:** All current test schemas use `emptyBlock` with `blockDescription = Nothing`, so fallback descriptions are used and output is unchanged. -- **`blockTypeToOption` (exported, ModuleGenerator:298-329):** Not updated in this effort — it's a separate exported function that may have different consumers. Can be updated in a follow-up. -- **Indentation value 16:** Matches the existing `mkIndentedStr 16` convention in OptionBuilder. This controls Nix indented string formatting depth. -- **`lib.mdDoc` availability:** Generated modules use `{ lib, ... }: with lib;` wrapper, so both `lib.mdDoc` and bare `mdDoc` resolve. Using `lib.mdDoc` explicitly for clarity.