# `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
→
→
resolve address
file / block / object / tuple
→
mutate node
parsed expressions & blocks
→
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
Questions?