diff --git a/helper/runner.go b/helper/runner.go index 3cc1d4f..4859aeb 100644 --- a/helper/runner.go +++ b/helper/runner.go @@ -5,6 +5,7 @@ import ( "os" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/terraform-linters/tflint-plugin-sdk/hclext" "github.com/terraform-linters/tflint-plugin-sdk/terraform/addrs" "github.com/terraform-linters/tflint-plugin-sdk/tflint" @@ -124,6 +125,52 @@ func (r *Runner) GetFiles() (map[string]*hcl.File, error) { return r.files, nil } +type nativeWalker struct { + walker tflint.ExprWalker +} + +func (w *nativeWalker) Enter(node hclsyntax.Node) hcl.Diagnostics { + if expr, ok := node.(hcl.Expression); ok { + return w.walker.Enter(expr) + } + return nil +} + +func (w *nativeWalker) Exit(node hclsyntax.Node) hcl.Diagnostics { + if expr, ok := node.(hcl.Expression); ok { + return w.walker.Exit(expr) + } + return nil +} + +// WalkExpressions traverses expressions in all files by the passed walker. +func (r *Runner) WalkExpressions(walker tflint.ExprWalker) hcl.Diagnostics { + diags := hcl.Diagnostics{} + for _, file := range r.files { + if body, ok := file.Body.(*hclsyntax.Body); ok { + walkDiags := hclsyntax.Walk(body, &nativeWalker{walker: walker}) + diags = diags.Extend(walkDiags) + continue + } + + // In JSON syntax, everything can be walked as an attribute. + attrs, jsonDiags := file.Body.JustAttributes() + if jsonDiags.HasErrors() { + diags = diags.Extend(jsonDiags) + continue + } + + for _, attr := range attrs { + enterDiags := walker.Enter(attr.Expr) + diags = diags.Extend(enterDiags) + exitDiags := walker.Exit(attr.Expr) + diags = diags.Extend(exitDiags) + } + } + + return diags +} + // DecodeRuleConfig extracts the rule's configuration into the given value func (r *Runner) DecodeRuleConfig(name string, ret interface{}) error { schema := hclext.ImpliedBodySchema(ret) diff --git a/helper/runner_test.go b/helper/runner_test.go index af5c4a3..4ad727f 100644 --- a/helper/runner_test.go +++ b/helper/runner_test.go @@ -280,6 +280,221 @@ func Test_GetModuleContent_json(t *testing.T) { } } +func TestWalkExpressions(t *testing.T) { + tests := []struct { + name string + files map[string]string + walked []hcl.Range + }{ + { + name: "resource", + files: map[string]string{ + "resource.tf": ` +resource "null_resource" "test" { + key = "foo" +}`, + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 9}, End: hcl.Pos{Line: 3, Column: 14}}, + {Start: hcl.Pos{Line: 3, Column: 10}, End: hcl.Pos{Line: 3, Column: 13}}, + }, + }, + { + name: "data source", + files: map[string]string{ + "data.tf": ` +data "null_dataresource" "test" { + key = "foo" +}`, + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 9}, End: hcl.Pos{Line: 3, Column: 14}}, + {Start: hcl.Pos{Line: 3, Column: 10}, End: hcl.Pos{Line: 3, Column: 13}}, + }, + }, + { + name: "module call", + files: map[string]string{ + "module.tf": ` +module "m" { + source = "./module" + key = "foo" +}`, + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 12}, End: hcl.Pos{Line: 3, Column: 22}}, + {Start: hcl.Pos{Line: 3, Column: 13}, End: hcl.Pos{Line: 3, Column: 21}}, + {Start: hcl.Pos{Line: 4, Column: 12}, End: hcl.Pos{Line: 4, Column: 17}}, + {Start: hcl.Pos{Line: 4, Column: 13}, End: hcl.Pos{Line: 4, Column: 16}}, + }, + }, + { + name: "provider config", + files: map[string]string{ + "provider.tf": ` +provider "p" { + key = "foo" +}`, + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 9}, End: hcl.Pos{Line: 3, Column: 14}}, + {Start: hcl.Pos{Line: 3, Column: 10}, End: hcl.Pos{Line: 3, Column: 13}}, + }, + }, + { + name: "locals", + files: map[string]string{ + "locals.tf": ` +locals { + key = "foo" +}`, + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 9}, End: hcl.Pos{Line: 3, Column: 14}}, + {Start: hcl.Pos{Line: 3, Column: 10}, End: hcl.Pos{Line: 3, Column: 13}}, + }, + }, + { + name: "output", + files: map[string]string{ + "output.tf": ` +output "o" { + value = "foo" +}`, + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 11}, End: hcl.Pos{Line: 3, Column: 16}}, + {Start: hcl.Pos{Line: 3, Column: 12}, End: hcl.Pos{Line: 3, Column: 15}}, + }, + }, + { + name: "resource with block", + files: map[string]string{ + "resource.tf": ` +resource "null_resource" "test" { + key = "foo" + + lifecycle { + ignore_changes = [key] + } +}`, + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 9}, End: hcl.Pos{Line: 3, Column: 14}}, + {Start: hcl.Pos{Line: 3, Column: 10}, End: hcl.Pos{Line: 3, Column: 13}}, + {Start: hcl.Pos{Line: 6, Column: 22}, End: hcl.Pos{Line: 6, Column: 27}}, + {Start: hcl.Pos{Line: 6, Column: 23}, End: hcl.Pos{Line: 6, Column: 26}}, + }, + }, + { + name: "resource json", + files: map[string]string{ + "resource.tf.json": ` +{ + "resource": { + "null_resource": { + "test": { + "key": "foo", + "nested": { + "key": "foo" + }, + "list": [{ + "key": "foo" + }] + } + } + } +}`, + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 15}, End: hcl.Pos{Line: 15, Column: 4}}, + }, + }, + { + name: "multiple files", + files: map[string]string{ + "main.tf": ` +provider "aws" { + region = "us-east-1" + + assume_role { + role_arn = "arn:aws:iam::123412341234:role/ExampleRole" + } +}`, + "main_override.tf": ` +provider "aws" { + region = "us-east-1" + + assume_role { + role_arn = null + } +}`, + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 12}, End: hcl.Pos{Line: 3, Column: 23}, Filename: "main.tf"}, + {Start: hcl.Pos{Line: 3, Column: 13}, End: hcl.Pos{Line: 3, Column: 22}, Filename: "main.tf"}, + {Start: hcl.Pos{Line: 6, Column: 16}, End: hcl.Pos{Line: 6, Column: 60}, Filename: "main.tf"}, + {Start: hcl.Pos{Line: 6, Column: 17}, End: hcl.Pos{Line: 6, Column: 59}, Filename: "main.tf"}, + {Start: hcl.Pos{Line: 3, Column: 12}, End: hcl.Pos{Line: 3, Column: 23}, Filename: "main_override.tf"}, + {Start: hcl.Pos{Line: 3, Column: 13}, End: hcl.Pos{Line: 3, Column: 22}, Filename: "main_override.tf"}, + {Start: hcl.Pos{Line: 6, Column: 16}, End: hcl.Pos{Line: 6, Column: 20}, Filename: "main_override.tf"}, + }, + }, + { + name: "nested attributes", + files: map[string]string{ + "data.tf": ` +data "terraform_remote_state" "remote_state" { + backend = "remote" + + config = { + organization = "Organization" + workspaces = { + name = "${var.environment}" + } + } +}`, + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 13}, End: hcl.Pos{Line: 3, Column: 21}}, + {Start: hcl.Pos{Line: 3, Column: 14}, End: hcl.Pos{Line: 3, Column: 20}}, + {Start: hcl.Pos{Line: 5, Column: 12}, End: hcl.Pos{Line: 10, Column: 4}}, + {Start: hcl.Pos{Line: 6, Column: 5}, End: hcl.Pos{Line: 6, Column: 17}}, + {Start: hcl.Pos{Line: 6, Column: 20}, End: hcl.Pos{Line: 6, Column: 34}}, + {Start: hcl.Pos{Line: 6, Column: 21}, End: hcl.Pos{Line: 6, Column: 33}}, + {Start: hcl.Pos{Line: 7, Column: 5}, End: hcl.Pos{Line: 7, Column: 15}}, + {Start: hcl.Pos{Line: 7, Column: 18}, End: hcl.Pos{Line: 9, Column: 6}}, + {Start: hcl.Pos{Line: 8, Column: 7}, End: hcl.Pos{Line: 8, Column: 11}}, + {Start: hcl.Pos{Line: 8, Column: 14}, End: hcl.Pos{Line: 8, Column: 34}}, + {Start: hcl.Pos{Line: 8, Column: 17}, End: hcl.Pos{Line: 8, Column: 32}}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runner := TestRunner(t, test.files) + + walked := []hcl.Range{} + diags := runner.WalkExpressions(tflint.ExprWalkFunc(func(expr hcl.Expression) hcl.Diagnostics { + walked = append(walked, expr.Range()) + return nil + })) + if diags.HasErrors() { + t.Fatal(diags) + } + opts := cmp.Options{ + cmpopts.IgnoreFields(hcl.Range{}, "Filename"), + cmpopts.IgnoreFields(hcl.Pos{}, "Byte"), + cmpopts.SortSlices(func(x, y hcl.Range) bool { return x.String() > y.String() }), + } + if diff := cmp.Diff(walked, test.walked, opts); diff != "" { + t.Error(diff) + } + }) + } +} + func Test_DecodeRuleConfig(t *testing.T) { files := map[string]string{ ".tflint.hcl": ` diff --git a/plugin/plugin2host/client.go b/plugin/plugin2host/client.go index 59ba460..3e0ed29 100644 --- a/plugin/plugin2host/client.go +++ b/plugin/plugin2host/client.go @@ -165,6 +165,72 @@ func (c *GRPCClient) GetFiles() (map[string]*hcl.File, error) { return files, nil } +type nativeWalker struct { + walker tflint.ExprWalker +} + +func (w *nativeWalker) Enter(node hclsyntax.Node) hcl.Diagnostics { + if expr, ok := node.(hcl.Expression); ok { + return w.walker.Enter(expr) + } + return nil +} + +func (w *nativeWalker) Exit(node hclsyntax.Node) hcl.Diagnostics { + if expr, ok := node.(hcl.Expression); ok { + return w.walker.Exit(expr) + } + return nil +} + +// WalkExpressions traverses expressions in all files by the passed walker. +// Note that it behaves differently in native HCL syntax and JSON syntax. +// +// In the HCL syntax, `var.foo` and `var.bar` in `[var.foo, var.bar]` are +// also passed to the walker. In other words, it traverses expressions recursively. +// To avoid redundant checks, the walker should check the kind of expression. +// +// In the JSON syntax, only an expression of an attribute seen from the top +// level of the file is passed. In other words, it doesn't traverse expressions +// recursively. This is a limitation of JSON syntax. +func (c *GRPCClient) WalkExpressions(walker tflint.ExprWalker) hcl.Diagnostics { + files, err := c.GetFiles() + if err != nil { + return hcl.Diagnostics{ + { + Severity: hcl.DiagError, + Summary: "failed to call GetFiles()", + Detail: err.Error(), + }, + } + } + + diags := hcl.Diagnostics{} + for _, file := range files { + if body, ok := file.Body.(*hclsyntax.Body); ok { + walkDiags := hclsyntax.Walk(body, &nativeWalker{walker: walker}) + diags = diags.Extend(walkDiags) + continue + } + + // In JSON syntax, everything can be walked as an attribute. + attrs, jsonDiags := file.Body.JustAttributes() + if jsonDiags.HasErrors() { + diags = diags.Extend(jsonDiags) + continue + } + + for _, attr := range attrs { + enterDiags := walker.Enter(attr.Expr) + diags = diags.Extend(enterDiags) + exitDiags := walker.Exit(attr.Expr) + diags = diags.Extend(exitDiags) + } + } + + return diags +} + // DecodeRuleConfig guesses the schema of the rule config from the passed interface and sends the schema to GRPC server. // Content retrieved based on the schema is decoded into the passed interface. func (c *GRPCClient) DecodeRuleConfig(name string, ret interface{}) error { diff --git a/plugin/plugin2host/plugin2host_test.go b/plugin/plugin2host/plugin2host_test.go index 49e2daa..ff03622 100644 --- a/plugin/plugin2host/plugin2host_test.go +++ b/plugin/plugin2host/plugin2host_test.go @@ -899,6 +899,222 @@ resource "aws_s3_bucket" "bar" { } } +func TestWalkExpressions(t *testing.T) { + tests := []struct { + name string + files map[string][]byte + walked []hcl.Range + }{ + { + name: "resource", + files: map[string][]byte{ + "resource.tf": []byte(` +resource "null_resource" "test" { + key = "foo" +}`), + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 9}, End: hcl.Pos{Line: 3, Column: 14}}, + {Start: hcl.Pos{Line: 3, Column: 10}, End: hcl.Pos{Line: 3, Column: 13}}, + }, + }, + { + name: "data source", + files: map[string][]byte{ + "data.tf": []byte(` +data "null_dataresource" "test" { + key = "foo" +}`), + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 9}, End: hcl.Pos{Line: 3, Column: 14}}, + {Start: hcl.Pos{Line: 3, Column: 10}, End: hcl.Pos{Line: 3, Column: 13}}, + }, + }, + { + name: "module call", + files: map[string][]byte{ + "module.tf": []byte(` +module "m" { + source = "./module" + key = "foo" +}`), + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 12}, End: hcl.Pos{Line: 3, Column: 22}}, + {Start: hcl.Pos{Line: 3, Column: 13}, End: hcl.Pos{Line: 3, Column: 21}}, + {Start: hcl.Pos{Line: 4, Column: 12}, End: hcl.Pos{Line: 4, Column: 17}}, + {Start: hcl.Pos{Line: 4, Column: 13}, End: hcl.Pos{Line: 4, Column: 16}}, + }, + }, + { + name: "provider config", + files: map[string][]byte{ + "provider.tf": []byte(` +provider "p" { + key = "foo" +}`), + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 9}, End: hcl.Pos{Line: 3, Column: 14}}, + {Start: hcl.Pos{Line: 3, Column: 10}, End: hcl.Pos{Line: 3, Column: 13}}, + }, + }, + { + name: "locals", + files: map[string][]byte{ + "locals.tf": []byte(` +locals { + key = "foo" +}`), + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 9}, End: hcl.Pos{Line: 3, Column: 14}}, + {Start: hcl.Pos{Line: 3, Column: 10}, End: hcl.Pos{Line: 3, Column: 13}}, + }, + }, + { + name: "output", + files: map[string][]byte{ + "output.tf": []byte(` +output "o" { + value = "foo" +}`), + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 11}, End: hcl.Pos{Line: 3, Column: 16}}, + {Start: hcl.Pos{Line: 3, Column: 12}, End: hcl.Pos{Line: 3, Column: 15}}, + }, + }, + { + name: "resource with block", + files: map[string][]byte{ + "resource.tf": []byte(` +resource "null_resource" "test" { + key = "foo" + + lifecycle { + ignore_changes = [key] + } +}`), + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 9}, End: hcl.Pos{Line: 3, Column: 14}}, + {Start: hcl.Pos{Line: 3, Column: 10}, End: hcl.Pos{Line: 3, Column: 13}}, + {Start: hcl.Pos{Line: 6, Column: 22}, End: hcl.Pos{Line: 6, Column: 27}}, + {Start: hcl.Pos{Line: 6, Column: 23}, End: hcl.Pos{Line: 6, Column: 26}}, + }, + }, + { + name: "resource json", + files: map[string][]byte{ + "resource.tf.json": []byte(` +{ + "resource": { + "null_resource": { + "test": { + "key": "foo", + "nested": { + "key": "foo" + }, + "list": [{ + "key": "foo" + }] + } + } + } +}`), + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 15}, End: hcl.Pos{Line: 15, Column: 4}}, + }, + }, + { + name: "multiple files", + files: map[string][]byte{ + "main.tf": []byte(` +provider "aws" { + region = "us-east-1" + + assume_role { + role_arn = "arn:aws:iam::123412341234:role/ExampleRole" + } +}`), + "main_override.tf": []byte(` +provider "aws" { + region = "us-east-1" + + assume_role { + role_arn = null + } +}`), + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 12}, End: hcl.Pos{Line: 3, Column: 23}, Filename: "main.tf"}, + {Start: hcl.Pos{Line: 3, Column: 13}, End: hcl.Pos{Line: 3, Column: 22}, Filename: "main.tf"}, + {Start: hcl.Pos{Line: 6, Column: 16}, End: hcl.Pos{Line: 6, Column: 60}, Filename: "main.tf"}, + {Start: hcl.Pos{Line: 6, Column: 17}, End: hcl.Pos{Line: 6, Column: 59}, Filename: "main.tf"}, + {Start: hcl.Pos{Line: 3, Column: 12}, End: hcl.Pos{Line: 3, Column: 23}, Filename: "main_override.tf"}, + {Start: hcl.Pos{Line: 3, Column: 13}, End: hcl.Pos{Line: 3, Column: 22}, Filename: "main_override.tf"}, + {Start: hcl.Pos{Line: 6, Column: 16}, End: hcl.Pos{Line: 6, Column: 20}, Filename: "main_override.tf"}, + }, + }, + { + name: "nested attributes", + files: map[string][]byte{ + "data.tf": []byte(` +data "terraform_remote_state" "remote_state" { + backend = "remote" + + config = { + organization = "Organization" + workspaces = { + name = "${var.environment}" + } + } +}`), + }, + walked: []hcl.Range{ + {Start: hcl.Pos{Line: 3, Column: 13}, End: hcl.Pos{Line: 3, Column: 21}}, + {Start: hcl.Pos{Line: 3, Column: 14}, End: hcl.Pos{Line: 3, Column: 20}}, + {Start: hcl.Pos{Line: 5, Column: 12}, End: hcl.Pos{Line: 10, Column: 4}}, + {Start: hcl.Pos{Line: 6, Column: 5}, End: hcl.Pos{Line: 6, Column: 17}}, + {Start: hcl.Pos{Line: 6, Column: 20}, End: hcl.Pos{Line: 6, Column: 34}}, + {Start: hcl.Pos{Line: 6, Column: 21}, End: hcl.Pos{Line: 6, Column: 33}}, + {Start: hcl.Pos{Line: 7, Column: 5}, End: hcl.Pos{Line: 7, Column: 15}}, + {Start: hcl.Pos{Line: 7, Column: 18}, End: hcl.Pos{Line: 9, Column: 6}}, + {Start: hcl.Pos{Line: 8, Column: 7}, End: hcl.Pos{Line: 8, Column: 11}}, + {Start: hcl.Pos{Line: 8, Column: 14}, End: hcl.Pos{Line: 8, Column: 34}}, + {Start: hcl.Pos{Line: 8, Column: 17}, End: hcl.Pos{Line: 8, Column: 32}}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + getFilesImpl := func() map[string][]byte { return test.files } + client := startTestGRPCServer(t, newMockServer(mockServerImpl{getFiles: getFilesImpl})) + + walked := []hcl.Range{} + diags := client.WalkExpressions(tflint.ExprWalkFunc(func(expr hcl.Expression) hcl.Diagnostics { + walked = append(walked, expr.Range()) + return nil + })) + if diags.HasErrors() { + t.Fatal(diags) + } + opts := cmp.Options{ + cmpopts.IgnoreFields(hcl.Range{}, "Filename"), + cmpopts.IgnoreFields(hcl.Pos{}, "Byte"), + cmpopts.SortSlices(func(x, y hcl.Range) bool { return x.String() > y.String() }), + } + if diff := cmp.Diff(walked, test.walked, opts); diff != "" { + t.Error(diff) + } + }) + } +} + func TestDecodeRuleConfig(t *testing.T) { // default error check helper neverHappend := func(err error) bool { return err != nil } diff --git a/tflint/interface.go b/tflint/interface.go index 4bc4deb..6532c9d 100644 --- a/tflint/interface.go +++ b/tflint/interface.go @@ -108,6 +108,28 @@ type Runner interface { // This is low level API for accessing information such as comments and syntax. GetFiles() (map[string]*hcl.File, error) + // WalkExpressions traverses expressions in all files by the passed walker. + // The walker can be passed any structure that satisfies the `tflint.ExprWalker` + // interface, or a `tflint.ExprWalkFunc`. Example of passing function: + // + // ``` + // runner.WalkExpressions(tflint.ExprWalkFunc(func (expr hcl.Expression) hcl.Diagnostics { + // // Write code here + // })) + // ``` + // + // If you pass ExprWalkFunc, the function will be called for every expression. + // Note that it behaves differently in native HCL syntax and JSON syntax. + // + // In the HCL syntax, `var.foo` and `var.bar` in `[var.foo, var.bar]` are + // also passed to the walker. In other words, it traverses expressions recursively. + // To avoid redundant checks, the walker should check the kind of expression. + // + // In the JSON syntax, only an expression of an attribute seen from the top + // level of the file is passed. In other words, it doesn't traverse expressions + // recursively. This is a limitation of JSON syntax. + WalkExpressions(walker ExprWalker) hcl.Diagnostics + // DecodeRuleConfig fetches the rule's configuration and reflects the result in the 2nd argument. // The argument is expected to be a pointer to a structure tagged with hclext: // diff --git a/tflint/walker.go b/tflint/walker.go new file mode 100644 index 0000000..2eb8022 --- /dev/null +++ b/tflint/walker.go @@ -0,0 +1,25 @@ +package tflint + +import ( + "github.com/hashicorp/hcl/v2" +) + +// ExprWalker is an interface used with WalkExpressions. +type ExprWalker interface { + Enter(expr hcl.Expression) hcl.Diagnostics + Exit(expr hcl.Expression) hcl.Diagnostics +} + +// ExprWalkFunc is the callback signature for WalkExpressions. +// This satisfies the ExprWalker interface. +type ExprWalkFunc func(expr hcl.Expression) hcl.Diagnostics + +// Enter is a function of ExprWalker that invokes itself on the passed expression. +func (f ExprWalkFunc) Enter(expr hcl.Expression) hcl.Diagnostics { + return f(expr) +} + +// Exit is one of ExprWalker's functions, noop here +func (f ExprWalkFunc) Exit(expr hcl.Expression) hcl.Diagnostics { + return nil +}