hclbuilder

# `hclbuilder`
Make HCL changes explicit
A Go library for incrementally modifying and formatting HCL
github.com/magodo/hclbuilder

layout: center —

The acceptance-test problem

An example of the test ```go func TestAccExampleResource_update(t *testing.T) { data := data := acceptance.BuildTestData() r := ExampleResource{} data.ResourceTest(t, r, []acceptance.TestStep{ { Config: r.basic(data), Check: acceptance.ComposeTestCheckFunc( check.That(data.ResourceName).ExistsInAzure(r), ), }, data.ImportStep("admin_password"), { Config: r.update(data), Check: acceptance.ComposeTestCheckFunc( check.That(data.ResourceName).ExistsInAzure(r), ), }, data.ImportStep("admin_password"), }) } ```

layout: two-cols-header layoutClass: gap-6 —

The acceptance-test problem

Please compare the tests scripts below and find what actually changed

::left::

<<< @/snippets/test_basic.go go

::right::

<<< @/snippets/test_update.go go

layout: two-cols layoutClass: gap-12 —

The acceptance-test problem

Terraform provider acceptance tests require every TestStep to receive a complete configuration string.

But adjacent steps may change only one or two fields.

**The test's intent gets buried:** - duplicated HCL drifts apart - helper flags create hidden branches - reviewers compare large strings by eye

::right::

```go Steps: []resource.TestStep{ {Config: configInitial()}, {Config: configUpdated()}, {Config: configWithoutTags()}, } ```
or
```go Steps: []resource.TestStep{ {Config: config(false, false)}, {Config: config(true, false)}, {Config: config(true, true)}, } ```
What did each step actually change?

layout: center class: text-center —

Start once. Clone. Describe the delta.

base HCL
→
`Clone()`
→
targeted edits
→
formatted HCL
The source code becomes a readable history of the configuration.

layout: two-cols layoutClass: gap-8 —

A step reads like a diff

resource "example_widget" "test" {
  name = "initial"

  settings = {
    retries = 3
  }

  tags = {
    environment = "test"
  }
}

::right::

```go {all|1-3|5-6|8-9} base := hclbuilder.New(source) updated := base.Clone(). SetExpressionAt( `[resource.example_widget.test].name`, `"renamed"`). SetExpressionAt( `[resource.example_widget.test].settings.retries`, `5`) withoutTags := updated.Clone(). RemoveAt( `[resource.example_widget.test].tags`) ```
Every builder still produces the complete config expected by the test framework.

layout: two-cols layoutClass: gap-10 —

Use every version directly

resource.Test(t, resource.TestCase{
  // ...
  Steps: []resource.TestStep{
    {Config: base.BuildString()},
    {Config: updated.BuildString()},
    {Config: withoutTags.BuildString()},
  },
})
`Build()` returns formatted `[]byte`.
`BuildString()` returns the same result as a `string`.

::right::

| Builder | Intent | |---|---| | `base` | create the widget | | `updated` | rename it; change retries | | `withoutTags` | remove tags |
No string diff required to understand the scenario.

Addresses follow the HCL structure

```hcl resource "server" "web" { # [resource.server.web] size = "small" # .size metadata = { # .metadata owner = "team-a" # .owner } disk { # .[disk] mount = "/data" # .mount } } ```
[resource.server.web].size
an attribute in a labeled block
[resource.server.web].metadata.owner
an item in a nested object
[resource.server.web].[disk].mount
an attribute in a nested block

layout: two-cols layoutClass: gap-12 —

Address grammar

[block-type(.label)*(.index)?].key.index
[service.api] first matching block
[service.api.1] second matching block
[service."1"] numeric label, not index
objects.0.name tuple traversal

::right::

### Reading an address ```text [resource.aws_instance.web] .tags .Name ```
blocktype + labels key`tags` attribute key`Name` object item
An omitted block index defaults to `0`.

layout: two-cols layoutClass: gap-10 —

API style 1 — operate at an address

Use concise, chainable operations when the edit is easy to name.

builder.
  SetExpressionAt(
    `[resource.server.test].size`,
    `"large"`).
  SetExpressionAt(
    `[resource.server.test].metadata.region`,
    `"west"`).
  AppendBlockAt(
    `[resource.server.test]`,
    `lifecycle { prevent_destroy = true }`).
  RemoveAt(
    `[resource.server.test].legacy`)

::right::

SetExpressionAt
set an attribute or object item
AppendBlockAt
append to a file or block body
RemoveAt
remove an attribute, object item, or block

layout: two-cols layoutClass: gap-10 —

API style 2 — select, then modify

At resolves a node and passes its typed builder to a callback.

builder.At(
  `[resource.server.test].metadata`,
  func(node hclbuilder.Builder) {
    node.AsObject().
      SetItem("owner", `"team-b"`).
      SetItem("region", `"west"`).
      RemoveItem("legacy")
  },
)
Useful for several edits to one node, or for node-specific operations.

::right::

```text Builder ├── AsFile() → *FileBuilder ├── AsBlock() → *BlockBuilder ├── AsObject() → *ObjectBuilder └── AsTuple() → *TupleBuilder ```
The direct and callback styles are complementary—and can be mixed on one builder.

layout: center —

Not string replacement

HCL source
`[]byte`
→
parse
`hclwrite`
→
resolve address
file / block / object / tuple
→
mutate node
parsed expressions & blocks
→
format
complete HCL
Edits target syntax nodes; output is normalized by the HCL formatter.

layout: two-cols layoutClass: gap-14 —

Try it

# Add the library
go get github.com/magodo/hclbuilder
Until the relevant HCL changes are merged:
go mod edit \
  -replace=github.com/hashicorp/hcl/v2=\
github.com/magodo/hcl/v2@dev

go mod tidy

::right::

```go package main import "github.com/magodo/hclbuilder" func main() { result := hclbuilder.New( []byte(`name = "before"`), ). SetExpressionAt("name", `"after"`). BuildString() println(result) } ```
name = "after"

layout: center class: text-center —

Make each test step say what changed.

Parse once · clone freely · edit structurally · build complete HCL
github.com/magodo/hclbuilder
Questions?