diff --git a/internal/align/terraform.go b/internal/align/terraform.go index c9da875..c42a25a 100644 --- a/internal/align/terraform.go +++ b/internal/align/terraform.go @@ -2,10 +2,13 @@ package align import ( + "fmt" "sort" "strings" + "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" + ihcl "github.com/oferchen/hclalign/internal/hcl" ) type terraformStrategy struct{} @@ -16,10 +19,9 @@ func (terraformStrategy) Name() string { return "terraform" } // are sorted alphabetically. Nested blocks are sorted by type and labels while // preserving the ordering of their contents. If a required_providers block is // present, the provider entries inside it are also sorted alphabetically. -func (terraformStrategy) Align(block *hclwrite.Block, _ *Options) error { +func (terraformStrategy) Align(block *hclwrite.Block, opts *Options) error { body := block.Body() - // Sort provider entries within required_providers blocks for _, nb := range body.Blocks() { if nb.Type() == "required_providers" { attrs := nb.Body().Attributes() @@ -34,31 +36,154 @@ func (terraformStrategy) Align(block *hclwrite.Block, _ *Options) error { } } - // Order top-level blocks by type then labels + attrs := body.Attributes() blocks := body.Blocks() - sort.SliceStable(blocks, func(i, j int) bool { - bi, bj := blocks[i], blocks[j] + + canonical := []string{"required_version", "required_providers", "experiments", "cloud", "backend"} + canonSet := make(map[string]struct{}, len(canonical)) + for _, n := range canonical { + canonSet[n] = struct{}{} + } + + if opts != nil && opts.Strict { + var missing []string + for _, n := range canonical { + if _, ok := attrs[n]; ok { + continue + } + found := false + for _, b := range blocks { + if b.Type() == n { + found = true + break + } + } + if !found { + missing = append(missing, n) + } + } + if len(missing) > 0 { + sort.Strings(missing) + return fmt.Errorf("terraform: missing attributes or blocks: %s", strings.Join(missing, ", ")) + } + var unknown []string + for name := range attrs { + if _, ok := canonSet[name]; !ok { + unknown = append(unknown, name) + } + } + for _, b := range blocks { + if _, ok := canonSet[b.Type()]; !ok { + unknown = append(unknown, b.Type()) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return fmt.Errorf("terraform: unknown attributes or blocks: %s", strings.Join(unknown, ", ")) + } + } + + tokens := body.BuildTokens(nil) + newline := ihcl.DetectLineEnding(tokens) + trailingComma := ihcl.HasTrailingComma(tokens) + + attrTokens := map[string]ihcl.AttrTokens{} + for name, attr := range attrs { + attrTokens[name] = ihcl.ExtractAttrTokens(attr) + body.RemoveAttribute(name) + } + + var reqProviders, cloudBlock, backendBlock *hclwrite.Block + otherBlocks := make([]*hclwrite.Block, 0, len(blocks)) + for _, b := range blocks { + body.RemoveBlock(b) + switch b.Type() { + case "required_providers": + reqProviders = b + case "cloud": + cloudBlock = b + case "backend": + backendBlock = b + default: + otherBlocks = append(otherBlocks, b) + } + } + + sort.SliceStable(otherBlocks, func(i, j int) bool { + bi, bj := otherBlocks[i], otherBlocks[j] if bi.Type() != bj.Type() { return bi.Type() < bj.Type() } return strings.Join(bi.Labels(), "\x00") < strings.Join(bj.Labels(), "\x00") }) - for _, b := range body.Blocks() { - body.RemoveBlock(b) + + otherAttrNames := make([]string, 0, len(attrTokens)) + for name := range attrTokens { + if name != "required_version" && name != "experiments" { + otherAttrNames = append(otherAttrNames, name) + } } - for _, b := range blocks { - body.AppendBlock(b) + sort.Strings(otherAttrNames) + + type item struct { + name string + block *hclwrite.Block + isAttr bool } - // Gather and order attributes - attrs := body.Attributes() - names := make([]string, 0, len(attrs)) - for name := range attrs { - names = append(names, name) + var items []item + if _, ok := attrTokens["required_version"]; ok { + items = append(items, item{name: "required_version", isAttr: true}) + } + if reqProviders != nil { + items = append(items, item{block: reqProviders}) + } + if _, ok := attrTokens["experiments"]; ok { + items = append(items, item{name: "experiments", isAttr: true}) + } + if cloudBlock != nil { + items = append(items, item{block: cloudBlock}) + } + if backendBlock != nil { + items = append(items, item{block: backendBlock}) + } + for _, name := range otherAttrNames { + items = append(items, item{name: name, isAttr: true}) + } + for _, b := range otherBlocks { + items = append(items, item{block: b}) } - sort.Strings(names) - return reorderBlock(block, names) + body.Clear() + if len(items) > 0 { + body.AppendUnstructuredTokens(hclwrite.Tokens{ + &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: newline}, + }) + } + for _, it := range items { + if it.isAttr { + tok := attrTokens[it.name] + body.AppendUnstructuredTokens(tok.LeadTokens) + body.SetAttributeRaw(it.name, tok.ExprTokens) + } else { + body.AppendUnstructuredTokens(hclwrite.Tokens{ + &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: newline}, + }) + body.AppendBlock(it.block) + } + } + if trailingComma && len(items) > 0 { + body.AppendUnstructuredTokens(hclwrite.Tokens{ + &hclwrite.Token{Type: hclsyntax.TokenComma, Bytes: []byte(",")}, + }) + } + toks := body.BuildTokens(nil) + if len(toks) > 0 && toks[len(toks)-1].Type != hclsyntax.TokenNewline { + body.AppendUnstructuredTokens(hclwrite.Tokens{ + &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: newline}, + }) + } + return nil } func init() { Register(terraformStrategy{}) } diff --git a/tests/cases/terraform/aligned.tf b/tests/cases/terraform/aligned.tf index 330526c..9f69b18 100644 --- a/tests/cases/terraform/aligned.tf +++ b/tests/cases/terraform/aligned.tf @@ -1,14 +1,6 @@ terraform { required_version = ">= 1.0" - backend "s3" { - region = "us-east-1" - } - - cloud { - organization = "hashicorp" - } - required_providers { aws = { version = "~> 4.0" @@ -19,4 +11,12 @@ terraform { version = "~> 3.0" } } + + cloud { + organization = "hashicorp" + } + + backend "s3" { + region = "us-east-1" + } } diff --git a/tests/cases/terraform/out.tf b/tests/cases/terraform/out.tf index 330526c..9f69b18 100644 --- a/tests/cases/terraform/out.tf +++ b/tests/cases/terraform/out.tf @@ -1,14 +1,6 @@ terraform { required_version = ">= 1.0" - backend "s3" { - region = "us-east-1" - } - - cloud { - organization = "hashicorp" - } - required_providers { aws = { version = "~> 4.0" @@ -19,4 +11,12 @@ terraform { version = "~> 3.0" } } + + cloud { + organization = "hashicorp" + } + + backend "s3" { + region = "us-east-1" + } }