Skip to content
Merged
88 changes: 88 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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).
119 changes: 119 additions & 0 deletions lib/TerranixCodegen/Description.hs
Original file line number Diff line number Diff line change
@@ -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"))
41 changes: 23 additions & 18 deletions lib/TerranixCodegen/ModuleGenerator.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,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 = "<schema block description or fallback>";
};
}

Uses the schema's block description when available, otherwise falls back to
"Instances of {resourceType}".

Example:
generateResourceModule "aws" "aws_instance" schema
-}
Expand Down Expand Up @@ -82,7 +86,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.
Expand Down Expand Up @@ -135,7 +142,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.
Expand All @@ -147,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 = "<schema block description or fallback>";
};
}

Uses the schema's block description when available, otherwise falls back to
"{providerName} provider configuration".
-}
generateProviderModule :: Text -> Schema -> NExpr
generateProviderModule providerName schema =
Expand Down Expand Up @@ -197,7 +210,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.
Expand Down Expand Up @@ -274,8 +290,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.
Expand Down Expand Up @@ -374,14 +390,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
}
63 changes: 3 additions & 60 deletions lib/TerranixCodegen/OptionBuilder.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Loading