diff --git a/README.md b/README.md index ae065fb47..85e38b395 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ Application Options: --minimum-failure-severity=[error|warning|notice] Sets minimum severity level for exiting with a non-zero error code --color Enable colorized output --no-color Disable colorized output + --fix Fix issues automatically Help Options: -h, --help Show this help message diff --git a/cmd/inspect.go b/cmd/inspect.go index 90706becf..1a12e002c 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -2,10 +2,12 @@ package cmd import ( "fmt" + "io" "os" "path/filepath" "strings" + "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" "github.com/spf13/afero" "github.com/terraform-linters/tflint-plugin-sdk/hclext" @@ -27,6 +29,7 @@ func (cli *CLI) inspect(opts Options, args []string) int { } issues := tflint.Issues{} + changes := map[string][]byte{} for _, wd := range workingDirs { err := cli.withinChangedDir(wd, func() error { @@ -62,11 +65,16 @@ func (cli *CLI) inspect(opts Options, args []string) int { for i, file := range filterFiles { filterFiles[i] = filepath.Join(wd, file) } - moduleIssues, err := cli.inspectModule(opts, targetDir, filterFiles) + + moduleIssues, moduleChanges, err := cli.inspectModule(opts, targetDir, filterFiles) if err != nil { return err } issues = append(issues, moduleIssues...) + for path, source := range moduleChanges { + changes[path] = source + } + return nil }) if err != nil { @@ -91,8 +99,16 @@ func (cli *CLI) inspect(opts Options, args []string) int { force = cli.config.Force } + cli.formatter.Fix = opts.Fix cli.formatter.Print(issues, nil, cli.sources) + if opts.Fix { + if err := writeChanges(changes); err != nil { + cli.formatter.Print(tflint.Issues{}, err, cli.sources) + return ExitCodeError + } + } + if len(issues) > 0 && !force && exceedsMinimumFailure(issues, opts.MinimumFailureSeverity) { return ExitCodeIssuesFound } @@ -143,75 +159,113 @@ func processArgs(args []string) (string, []string, error) { return dir, filterFiles, nil } -func (cli *CLI) inspectModule(opts Options, dir string, filterFiles []string) (tflint.Issues, error) { +func (cli *CLI) inspectModule(opts Options, dir string, filterFiles []string) (tflint.Issues, map[string][]byte, error) { issues := tflint.Issues{} + changes := map[string][]byte{} var err error // Setup config cli.config, err = tflint.LoadConfig(afero.Afero{Fs: afero.NewOsFs()}, opts.Config) if err != nil { - return tflint.Issues{}, fmt.Errorf("Failed to load TFLint config; %w", err) + return issues, changes, fmt.Errorf("Failed to load TFLint config; %w", err) } cli.config.Merge(opts.toConfig()) // Setup loader cli.loader, err = terraform.NewLoader(afero.Afero{Fs: afero.NewOsFs()}, cli.originalWorkingDir) if err != nil { - return tflint.Issues{}, fmt.Errorf("Failed to prepare loading; %w", err) + return issues, changes, fmt.Errorf("Failed to prepare loading; %w", err) } if opts.Recursive && !cli.loader.IsConfigDir(dir) { // Ignore non-module directories in recursive mode - return tflint.Issues{}, nil + return issues, changes, nil } // Setup runners runners, err := cli.setupRunners(opts, dir) if err != nil { - return tflint.Issues{}, err + return issues, changes, err } rootRunner := runners[len(runners)-1] // Launch plugin processes - rulesetPlugin, err := launchPlugins(cli.config) + rulesetPlugin, err := launchPlugins(cli.config, opts.Fix) if rulesetPlugin != nil { defer rulesetPlugin.Clean() } if err != nil { - return tflint.Issues{}, err + return issues, changes, err } - // Run inspection + // Check preconditions + sdkVersions := map[string]*version.Version{} for name, ruleset := range rulesetPlugin.RuleSets { sdkVersion, err := ruleset.SDKVersion() if err != nil { if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented { // SDKVersion endpoint is available in tflint-plugin-sdk v0.14+. - return tflint.Issues{}, fmt.Errorf(`Plugin "%s" SDK version is incompatible. Compatible versions: %s`, name, plugin.SDKVersionConstraints) + return issues, changes, fmt.Errorf(`Plugin "%s" SDK version is incompatible. Compatible versions: %s`, name, plugin.SDKVersionConstraints) } else { - return tflint.Issues{}, fmt.Errorf(`Failed to get plugin "%s" SDK version; %w`, name, err) + return issues, changes, fmt.Errorf(`Failed to get plugin "%s" SDK version; %w`, name, err) } } if !plugin.SDKVersionConstraints.Check(sdkVersion) { - return tflint.Issues{}, fmt.Errorf(`Plugin "%s" SDK version (%s) is incompatible. Compatible versions: %s`, name, sdkVersion, plugin.SDKVersionConstraints) + return issues, changes, fmt.Errorf(`Plugin "%s" SDK version (%s) is incompatible. Compatible versions: %s`, name, sdkVersion, plugin.SDKVersionConstraints) + } + sdkVersions[name] = sdkVersion + } + + // Run inspection + // + // Repeat an inspection until there are no more changes or the limit is reached, + // in case an autofix introduces new issues. + for loop := 1; ; loop++ { + if loop > 10 { + return issues, changes, fmt.Errorf(`Reached the limit of autofix attempts, and the changes made by the autofix will not be applied. This may be due to the following reasons: + +1. The autofix is making changes that do not fix the issue. +2. The autofix is continuing to introduce new issues. + +By setting TFLINT_LOG=trace, you can confirm the changes made by the autofix and start troubleshooting.`) + } + + for name, ruleset := range rulesetPlugin.RuleSets { + for _, runner := range runners { + err = ruleset.Check(plugin.NewGRPCServer(runner, rootRunner, cli.loader.Files(), sdkVersions[name])) + if err != nil { + return issues, changes, fmt.Errorf("Failed to check ruleset; %w", err) + } + } } + changesInAttempt := map[string][]byte{} for _, runner := range runners { - err = ruleset.Check(plugin.NewGRPCServer(runner, rootRunner, cli.loader.Files(), sdkVersion)) - if err != nil { - return tflint.Issues{}, fmt.Errorf("Failed to check ruleset; %w", err) + for _, issue := range runner.LookupIssues(filterFiles...) { + // On the second attempt, only fixable issues are appended to avoid duplicates. + if loop == 1 || issue.Fixable { + issues = append(issues, issue) + } + } + runner.Issues = tflint.Issues{} + + for path, source := range runner.LookupChanges(filterFiles...) { + changesInAttempt[path] = source + changes[path] = source } + runner.ClearChanges() } - } - for _, runner := range runners { - issues = append(issues, runner.LookupIssues(filterFiles...)...) + if !opts.Fix || len(changesInAttempt) == 0 { + break + } } + // Set module sources to CLI for path, source := range cli.loader.Sources() { cli.sources[path] = source } - return issues, nil + return issues, changes, nil } func (cli *CLI) setupRunners(opts Options, dir string) ([]*tflint.Runner, error) { @@ -260,7 +314,7 @@ func (cli *CLI) setupRunners(opts Options, dir string) ([]*tflint.Runner, error) return append(runners, runner), nil } -func launchPlugins(config *tflint.Config) (*plugin.Plugin, error) { +func launchPlugins(config *tflint.Config, fix bool) (*plugin.Plugin, error) { // Lookup plugins rulesetPlugin, err := plugin.Discovery(config) if err != nil { @@ -269,6 +323,7 @@ func launchPlugins(config *tflint.Config) (*plugin.Plugin, error) { rulesets := []tflint.RuleSet{} pluginConf := config.ToPluginConfig() + pluginConf.Fix = fix // Check version constraints and apply a config to plugins for name, ruleset := range rulesetPlugin.RuleSets { @@ -316,6 +371,28 @@ func launchPlugins(config *tflint.Config) (*plugin.Plugin, error) { return rulesetPlugin, nil } +func writeChanges(changes map[string][]byte) error { + fs := afero.NewOsFs() + for path, source := range changes { + f, err := fs.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("Failed to apply autofixes; failed to open %s: %w", path, err) + } + + n, err := f.Write(source) + if err == nil && n < len(source) { + err = io.ErrShortWrite + } + if err1 := f.Close(); err == nil { + err = err1 + } + if err != nil { + return fmt.Errorf("Failed to apply autofixes; failed to write source code to %s: %w", path, err) + } + } + return nil +} + // Checks if the given issues contain severities above or equal to the given minimum failure opt. Defaults to true if an error occurs func exceedsMinimumFailure(issues tflint.Issues, minimumFailureOpt string) bool { if minimumFailureOpt != "" { diff --git a/cmd/option.go b/cmd/option.go index 7129e05f0..620eb92ef 100644 --- a/cmd/option.go +++ b/cmd/option.go @@ -30,6 +30,7 @@ type Options struct { MinimumFailureSeverity string `long:"minimum-failure-severity" description:"Sets minimum severity level for exiting with a non-zero error code" choice:"error" choice:"warning" choice:"notice"` Color bool `long:"color" description:"Enable colorized output"` NoColor bool `long:"no-color" description:"Disable colorized output"` + Fix bool `long:"fix" description:"Fix issues automatically"` ActAsBundledPlugin bool `long:"act-as-bundled-plugin" hidden:"true"` } diff --git a/docs/user-guide/README.md b/docs/user-guide/README.md index 3dd90a28a..7d9c98b33 100644 --- a/docs/user-guide/README.md +++ b/docs/user-guide/README.md @@ -10,6 +10,7 @@ This guide describes the various features of TFLint for end users. - [Switching working directory](working-directory.md) - [Module Inspection](module-inspection.md) - [Annotations](annotations.md) +- [Autofix](autofix.md) - [Compatibility with Terraform](compatibility.md) - [Environment Variables](./environment_variables.md) - [Editor Integration](editor-integration.md) diff --git a/docs/user-guide/autofix.md b/docs/user-guide/autofix.md new file mode 100644 index 000000000..b80cc2788 --- /dev/null +++ b/docs/user-guide/autofix.md @@ -0,0 +1,33 @@ +# Autofix + +Some issues reported by TFLint can be auto-fixable. Auto-fixable issues are marked as "Fixable" as follows: + +```console +$ tflint +1 issue(s) found: + +Warning: [Fixable] Single line comments should begin with # (terraform_comment_syntax) + + on main.tf line 1: + 1: // locals values + 2: locals { + +``` + +When run with the `--fix` option, TFLint will fix issues automatically. + +```console +$ tflint --fix +1 issue(s) found: + +Warning: [Fixed] Single line comments should begin with # (terraform_comment_syntax) + + on main.tf line 1: + 1: // locals values + 2: locals { + +``` + +Please note that not all issues are fixable. The rule must support autofix. + +If autofix is applied, it will automatically format the entire file. As a result, unrelated ranges may change. diff --git a/formatter/formatter.go b/formatter/formatter.go index 065f136d3..e8aed2ab5 100644 --- a/formatter/formatter.go +++ b/formatter/formatter.go @@ -14,6 +14,7 @@ type Formatter struct { Stdout io.Writer Stderr io.Writer Format string + Fix bool NoColor bool } diff --git a/formatter/pretty.go b/formatter/pretty.go index f3ed2b6af..74b76a810 100644 --- a/formatter/pretty.go +++ b/formatter/pretty.go @@ -35,14 +35,28 @@ func (f *Formatter) prettyPrint(issues tflint.Issues, err error, sources map[str } func (f *Formatter) prettyPrintIssueWithSource(issue *tflint.Issue, sources map[string][]byte) { + message := issue.Message + if issue.Fixable { + if f.Fix { + message = "[Fixed] " + message + } else { + message = "[Fixable] " + message + } + } + fmt.Fprintf( f.Stdout, "%s: %s (%s)\n\n", - colorSeverity(issue.Rule.Severity()), colorBold(issue.Message), issue.Rule.Name(), + colorSeverity(issue.Rule.Severity()), colorBold(message), issue.Rule.Name(), ) fmt.Fprintf(f.Stdout, " on %s line %d:\n", issue.Range.Filename, issue.Range.Start.Line) - src := sources[issue.Range.Filename] + var src []byte + if issue.Source != nil { + src = issue.Source + } else { + src = sources[issue.Range.Filename] + } if src == nil { fmt.Fprintf(f.Stdout, " (source code not available)\n") diff --git a/formatter/pretty_test.go b/formatter/pretty_test.go index 7627e5013..1c358dd01 100644 --- a/formatter/pretty_test.go +++ b/formatter/pretty_test.go @@ -18,6 +18,7 @@ func Test_prettyPrint(t *testing.T) { cases := []struct { Name string Issues tflint.Issues + Fix bool Error error Sources map[string][]byte Stdout string @@ -93,6 +94,91 @@ Error: test (test_rule) Reference: https://github.com +`, + }, + { + Name: "fixable", + Issues: tflint.Issues{ + { + Rule: &testRule{}, + Message: "test", + Fixable: true, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 4, Byte: 3}, + }, + }, + }, + Sources: map[string][]byte{ + "test.tf": []byte("foo = 1"), + }, + Stdout: `1 issue(s) found: + +Error: [Fixable] test (test_rule) + + on test.tf line 1: + 1: foo = 1 + +Reference: https://github.com + +`, + }, + { + Name: "fixed", + Issues: tflint.Issues{ + { + Rule: &testRule{}, + Message: "test", + Fixable: true, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 4, Byte: 3}, + }, + }, + }, + Fix: true, + Sources: map[string][]byte{ + "test.tf": []byte("foo = 1"), + }, + Stdout: `1 issue(s) found: + +Error: [Fixed] test (test_rule) + + on test.tf line 1: + 1: foo = 1 + +Reference: https://github.com + +`, + }, + { + Name: "issue with source", + Issues: tflint.Issues{ + { + Rule: &testRule{}, + Message: "test", + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 4, Byte: 3}, + }, + Source: []byte("bar = 1"), + }, + }, + Sources: map[string][]byte{ + "test.tf": []byte("foo = 1"), + }, + Stdout: `1 issue(s) found: + +Error: test (test_rule) + + on test.tf line 1: + 1: bar = 1 + +Reference: https://github.com + `, }, { @@ -107,7 +193,7 @@ Reference: https://github.com t.Run(tc.Name, func(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - formatter := &Formatter{Stdout: stdout, Stderr: stderr} + formatter := &Formatter{Stdout: stdout, Stderr: stderr, Fix: tc.Fix} formatter.prettyPrint(tc.Issues, tc.Error, tc.Sources) diff --git a/go.mod b/go.mod index 3c9e10bbc..2c677655f 100644 --- a/go.mod +++ b/go.mod @@ -24,8 +24,8 @@ require ( github.com/sourcegraph/go-lsp v0.0.0-20200429204803-219e11d77f5d github.com/sourcegraph/jsonrpc2 v0.2.0 github.com/spf13/afero v1.9.5 - github.com/terraform-linters/tflint-plugin-sdk v0.16.1 - github.com/terraform-linters/tflint-ruleset-terraform v0.3.0 + github.com/terraform-linters/tflint-plugin-sdk v0.16.2-0.20230605170513-64de942491dc + github.com/terraform-linters/tflint-ruleset-terraform v0.3.1-0.20230608133521-d55898e6e6f7 github.com/xeipuuv/gojsonschema v1.2.0 github.com/zclconf/go-cty v1.13.2 github.com/zclconf/go-cty-yaml v1.0.3 diff --git a/go.sum b/go.sum index 93ef16262..0b2632153 100644 --- a/go.sum +++ b/go.sum @@ -455,10 +455,10 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/terraform-linters/tflint-plugin-sdk v0.16.1 h1:fBfLL8KzP3pkQrNp3iQxaGoKBoMo2sFYoqmhuo6yc+A= -github.com/terraform-linters/tflint-plugin-sdk v0.16.1/go.mod h1:ltxVy04PRwptL6P/Ugz2ZeTNclYapClrLn/kVFXJGzo= -github.com/terraform-linters/tflint-ruleset-terraform v0.3.0 h1:80R40X4h6WgfUyrnVmts2VPr/QQ+ujXsN9OAJ+yJMxk= -github.com/terraform-linters/tflint-ruleset-terraform v0.3.0/go.mod h1:ylnh9GPSG4iAC/ekt/BuD4ASudNYgkgV5oP4bFBUBkE= +github.com/terraform-linters/tflint-plugin-sdk v0.16.2-0.20230605170513-64de942491dc h1:z0PQWOWfWOYps2Oo7nT/v9XASazFZITnMA+oGWZzB78= +github.com/terraform-linters/tflint-plugin-sdk v0.16.2-0.20230605170513-64de942491dc/go.mod h1:ltxVy04PRwptL6P/Ugz2ZeTNclYapClrLn/kVFXJGzo= +github.com/terraform-linters/tflint-ruleset-terraform v0.3.1-0.20230608133521-d55898e6e6f7 h1:2xEp15qLFCUaDiDqjQE7+SzrBRtr+XTGECDLcbtJMdk= +github.com/terraform-linters/tflint-ruleset-terraform v0.3.1-0.20230608133521-d55898e6e6f7/go.mod h1:qmRHJdsLiqYBiz+MQ/JVS5NTZRtgckDSyR2vduTVSwk= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= diff --git a/integrationtest/autofix/autofix_test.go b/integrationtest/autofix/autofix_test.go new file mode 100644 index 000000000..5e86f1691 --- /dev/null +++ b/integrationtest/autofix/autofix_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "io/fs" + "log" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/terraform-linters/tflint/cmd" + "github.com/terraform-linters/tflint/formatter" + "github.com/terraform-linters/tflint/tflint" +) + +func TestMain(m *testing.M) { + log.SetOutput(io.Discard) + os.Exit(m.Run()) +} + +func TestIntegration(t *testing.T) { + cases := []struct { + Name string + Command string + Env map[string]string + Dir string + }{ + { + Name: "simple fix", + Command: "./tflint --format json --fix", + Dir: "simple", + }, + { + Name: "multiple fix in a file", + Command: "./tflint --format json --fix", + Dir: "multiple_fix", + }, + { + Name: "ignore by annotation", + Command: "./tflint --format json --fix", + Dir: "ignore_by_annotation", + }, + { + Name: "multiple fix by multiple rules", + Command: "./tflint --format json --fix", + Dir: "fix_by_multiple_rules", + }, + { + Name: "conflict fix by multiple rules", + Command: "./tflint --format json --fix", + Dir: "conflict_fix", + }, + { + Name: "fix in multiple files", + Command: "./tflint --format json --fix", + Dir: "multiple_files", + }, + { + Name: "module inspection", + Command: "./tflint --module --format json --fix", + Dir: "module", + }, + { + Name: "--chdir", + Command: "./tflint --chdir=dir --format json --fix", + Dir: "chdir", + }, + { + Name: "--filter", + Command: "./tflint --format json --fix --filter=main.tf", + Dir: "filter", + }, + } + + // Disable the bundled plugin because the `os.Executable()` is go(1) in the tests + tflint.DisableBundledPlugin = true + defer func() { + tflint.DisableBundledPlugin = false + }() + + dir, _ := os.Getwd() + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + testDir := filepath.Join(dir, tc.Dir) + + defer func() { + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + }() + if err := os.Chdir(testDir); err != nil { + t.Fatal(err) + } + + tfFiles := map[string][]byte{} + err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error { + if info.IsDir() { + return nil + } + if strings.HasSuffix(path, ".tf") { + sources, err := os.ReadFile(path) + if err != nil { + return err + } + tfFiles[path] = sources + } + return nil + }) + if err != nil { + t.Fatal(err) + } + defer func() { + // restore original files + for path := range tfFiles { + if err := os.WriteFile(path, tfFiles[path], 0644); err != nil { + t.Fatal(err) + } + } + }() + + resultFile := "result.json" + if runtime.GOOS == "windows" && IsWindowsResultExist() { + resultFile = "result_windows.json" + } + + if tc.Env != nil { + for k, v := range tc.Env { + t.Setenv(k, v) + } + } + + outStream, errStream := new(bytes.Buffer), new(bytes.Buffer) + cli, err := cmd.NewCLI(outStream, errStream) + if err != nil { + t.Fatal(err) + } + args := strings.Split(tc.Command, " ") + + cli.Run(args) + + b, err := os.ReadFile(filepath.Join(testDir, resultFile)) + if err != nil { + t.Fatal(err) + } + + var expected *formatter.JSONOutput + if err := json.Unmarshal(b, &expected); err != nil { + t.Fatal(err) + } + + var got *formatter.JSONOutput + if err := json.Unmarshal(outStream.Bytes(), &got); err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(got, expected); diff != "" { + t.Fatal(diff) + } + + // test autofixed files + for path := range tfFiles { + _, err := os.Stat(path + ".fixed") + if os.IsNotExist(err) { + // should be unchanged + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(string(got), string(tfFiles[path])); diff != "" { + t.Fatal(diff) + } + } else if err == nil { + // should be changed + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want, err := os.ReadFile(path + ".fixed") + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(string(got), string(want)); diff != "" { + t.Fatal(diff) + } + } else { + t.Fatal(err) + } + } + }) + } +} + +func IsWindowsResultExist() bool { + _, err := os.Stat("result_windows.json") + return !os.IsNotExist(err) +} diff --git a/integrationtest/autofix/chdir/dir/.tflint.hcl b/integrationtest/autofix/chdir/dir/.tflint.hcl new file mode 100644 index 000000000..e19f589dd --- /dev/null +++ b/integrationtest/autofix/chdir/dir/.tflint.hcl @@ -0,0 +1,3 @@ +plugin "testing" { + enabled = true +} diff --git a/integrationtest/autofix/chdir/dir/main.tf b/integrationtest/autofix/chdir/dir/main.tf new file mode 100644 index 000000000..2478b3f77 --- /dev/null +++ b/integrationtest/autofix/chdir/dir/main.tf @@ -0,0 +1 @@ +// autofixed diff --git a/integrationtest/autofix/chdir/dir/main.tf.fixed b/integrationtest/autofix/chdir/dir/main.tf.fixed new file mode 100644 index 000000000..8c7082e89 --- /dev/null +++ b/integrationtest/autofix/chdir/dir/main.tf.fixed @@ -0,0 +1 @@ +# autofixed diff --git a/integrationtest/autofix/chdir/result.json b/integrationtest/autofix/chdir/result.json new file mode 100644 index 000000000..ddaa27e19 --- /dev/null +++ b/integrationtest/autofix/chdir/result.json @@ -0,0 +1,25 @@ +{ + "issues": [ + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "dir/main.tf", + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 2, + "column": 1 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integrationtest/autofix/chdir/result_windows.json b/integrationtest/autofix/chdir/result_windows.json new file mode 100644 index 000000000..e323b2516 --- /dev/null +++ b/integrationtest/autofix/chdir/result_windows.json @@ -0,0 +1,25 @@ +{ + "issues": [ + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "dir\\main.tf", + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 2, + "column": 1 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integrationtest/autofix/conflict_fix/.tflint.hcl b/integrationtest/autofix/conflict_fix/.tflint.hcl new file mode 100644 index 000000000..e19f589dd --- /dev/null +++ b/integrationtest/autofix/conflict_fix/.tflint.hcl @@ -0,0 +1,3 @@ +plugin "testing" { + enabled = true +} diff --git a/integrationtest/autofix/conflict_fix/main.tf b/integrationtest/autofix/conflict_fix/main.tf new file mode 100644 index 000000000..49c080361 --- /dev/null +++ b/integrationtest/autofix/conflict_fix/main.tf @@ -0,0 +1,4 @@ +// autofixed +resource "aws_instance" "autofixed_foo" { + instance_type = "[AUTO_FIXED]" +} diff --git a/integrationtest/autofix/conflict_fix/main.tf.fixed b/integrationtest/autofix/conflict_fix/main.tf.fixed new file mode 100644 index 000000000..123bf3892 --- /dev/null +++ b/integrationtest/autofix/conflict_fix/main.tf.fixed @@ -0,0 +1,4 @@ +# autofixed +resource "aws_instance" "autofixed_foo" { + instance_type = "t2.micro" # autofixed +} diff --git a/integrationtest/autofix/conflict_fix/result.json b/integrationtest/autofix/conflict_fix/result.json new file mode 100644 index 000000000..5474583d1 --- /dev/null +++ b/integrationtest/autofix/conflict_fix/result.json @@ -0,0 +1,85 @@ +{ + "issues": [ + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "main.tf", + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 2, + "column": 1 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is [AUTO_FIXED]", + "range": { + "filename": "main.tf", + "start": { + "line": 3, + "column": 19 + }, + "end": { + "line": 3, + "column": 33 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_autofix_conflict", + "severity": "error", + "link": "" + }, + "message": "instance type is [AUTO_FIXED]", + "range": { + "filename": "main.tf", + "start": { + "line": 3, + "column": 19 + }, + "end": { + "line": 3, + "column": 33 + } + }, + "callers": [] + }, + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "main.tf", + "start": { + "line": 3, + "column": 30 + }, + "end": { + "line": 4, + "column": 1 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integrationtest/autofix/filter/.tflint.hcl b/integrationtest/autofix/filter/.tflint.hcl new file mode 100644 index 000000000..e19f589dd --- /dev/null +++ b/integrationtest/autofix/filter/.tflint.hcl @@ -0,0 +1,3 @@ +plugin "testing" { + enabled = true +} diff --git a/integrationtest/autofix/filter/main.tf b/integrationtest/autofix/filter/main.tf new file mode 100644 index 000000000..2478b3f77 --- /dev/null +++ b/integrationtest/autofix/filter/main.tf @@ -0,0 +1 @@ +// autofixed diff --git a/integrationtest/autofix/filter/main.tf.fixed b/integrationtest/autofix/filter/main.tf.fixed new file mode 100644 index 000000000..8c7082e89 --- /dev/null +++ b/integrationtest/autofix/filter/main.tf.fixed @@ -0,0 +1 @@ +# autofixed diff --git a/integrationtest/autofix/filter/result.json b/integrationtest/autofix/filter/result.json new file mode 100644 index 000000000..91a4f7c1d --- /dev/null +++ b/integrationtest/autofix/filter/result.json @@ -0,0 +1,25 @@ +{ + "issues": [ + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "main.tf", + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 2, + "column": 1 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integrationtest/autofix/filter/template.tf b/integrationtest/autofix/filter/template.tf new file mode 100644 index 000000000..2478b3f77 --- /dev/null +++ b/integrationtest/autofix/filter/template.tf @@ -0,0 +1 @@ +// autofixed diff --git a/integrationtest/autofix/fix_by_multiple_rules/.tflint.hcl b/integrationtest/autofix/fix_by_multiple_rules/.tflint.hcl new file mode 100644 index 000000000..e19f589dd --- /dev/null +++ b/integrationtest/autofix/fix_by_multiple_rules/.tflint.hcl @@ -0,0 +1,3 @@ +plugin "testing" { + enabled = true +} diff --git a/integrationtest/autofix/fix_by_multiple_rules/main.tf b/integrationtest/autofix/fix_by_multiple_rules/main.tf new file mode 100644 index 000000000..0c23d9e2d --- /dev/null +++ b/integrationtest/autofix/fix_by_multiple_rules/main.tf @@ -0,0 +1,5 @@ +locals { + foo = 1 + autofix_removed = 2 + bar = 3 // autofixed +} diff --git a/integrationtest/autofix/fix_by_multiple_rules/main.tf.fixed b/integrationtest/autofix/fix_by_multiple_rules/main.tf.fixed new file mode 100644 index 000000000..f0258a876 --- /dev/null +++ b/integrationtest/autofix/fix_by_multiple_rules/main.tf.fixed @@ -0,0 +1,4 @@ +locals { + foo = 1 + bar = 3 # autofixed +} diff --git a/integrationtest/autofix/fix_by_multiple_rules/result.json b/integrationtest/autofix/fix_by_multiple_rules/result.json new file mode 100644 index 000000000..5d055d736 --- /dev/null +++ b/integrationtest/autofix/fix_by_multiple_rules/result.json @@ -0,0 +1,45 @@ +{ + "issues": [ + { + "rule": { + "name": "terraform_autofix_remove_local", + "severity": "error", + "link": "" + }, + "message": "Do not use \"autofix_removed\" local value", + "range": { + "filename": "main.tf", + "start": { + "line": 3, + "column": 3 + }, + "end": { + "line": 3, + "column": 22 + } + }, + "callers": [] + }, + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "main.tf", + "start": { + "line": 3, + "column": 11 + }, + "end": { + "line": 4, + "column": 1 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integrationtest/autofix/ignore_by_annotation/.tflint.hcl b/integrationtest/autofix/ignore_by_annotation/.tflint.hcl new file mode 100644 index 000000000..e19f589dd --- /dev/null +++ b/integrationtest/autofix/ignore_by_annotation/.tflint.hcl @@ -0,0 +1,3 @@ +plugin "testing" { + enabled = true +} diff --git a/integrationtest/autofix/ignore_by_annotation/main.tf b/integrationtest/autofix/ignore_by_annotation/main.tf new file mode 100644 index 000000000..9e251e80e --- /dev/null +++ b/integrationtest/autofix/ignore_by_annotation/main.tf @@ -0,0 +1,3 @@ +# tflint-ignore: terraform_autofix_comment +// autofixed +// autofixed diff --git a/integrationtest/autofix/ignore_by_annotation/main.tf.fixed b/integrationtest/autofix/ignore_by_annotation/main.tf.fixed new file mode 100644 index 000000000..aa4a2fee9 --- /dev/null +++ b/integrationtest/autofix/ignore_by_annotation/main.tf.fixed @@ -0,0 +1,3 @@ +# tflint-ignore: terraform_autofix_comment +// autofixed +# autofixed diff --git a/integrationtest/autofix/ignore_by_annotation/result.json b/integrationtest/autofix/ignore_by_annotation/result.json new file mode 100644 index 000000000..941474d20 --- /dev/null +++ b/integrationtest/autofix/ignore_by_annotation/result.json @@ -0,0 +1,25 @@ +{ + "issues": [ + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "main.tf", + "start": { + "line": 3, + "column": 1 + }, + "end": { + "line": 4, + "column": 1 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integrationtest/autofix/module/.terraform/modules/modules.json b/integrationtest/autofix/module/.terraform/modules/modules.json new file mode 100644 index 000000000..06bc0e9bd --- /dev/null +++ b/integrationtest/autofix/module/.terraform/modules/modules.json @@ -0,0 +1 @@ +{"Modules":[{"Key":"","Source":"","Dir":"."},{"Key":"instances","Source":"./module","Dir":"module"}]} \ No newline at end of file diff --git a/integrationtest/autofix/module/.tflint.hcl b/integrationtest/autofix/module/.tflint.hcl new file mode 100644 index 000000000..e19f589dd --- /dev/null +++ b/integrationtest/autofix/module/.tflint.hcl @@ -0,0 +1,3 @@ +plugin "testing" { + enabled = true +} diff --git a/integrationtest/autofix/module/main.tf b/integrationtest/autofix/module/main.tf new file mode 100644 index 000000000..9cb0bcb6e --- /dev/null +++ b/integrationtest/autofix/module/main.tf @@ -0,0 +1,9 @@ +resource "aws_instance" "autofixed_literal" { + instance_type = "[AUTO_FIXED]" +} + +module "instances" { + source = "./module" + + input = "[AUTO_FIXED]" +} diff --git a/integrationtest/autofix/module/main.tf.fixed b/integrationtest/autofix/module/main.tf.fixed new file mode 100644 index 000000000..5bff01577 --- /dev/null +++ b/integrationtest/autofix/module/main.tf.fixed @@ -0,0 +1,9 @@ +resource "aws_instance" "autofixed_literal" { + instance_type = "t2.micro" # autofixed +} + +module "instances" { + source = "./module" + + input = "[AUTO_FIXED]" +} diff --git a/integrationtest/autofix/module/module/main.tf b/integrationtest/autofix/module/module/main.tf new file mode 100644 index 000000000..9431a96bc --- /dev/null +++ b/integrationtest/autofix/module/module/main.tf @@ -0,0 +1,9 @@ +variable "input" {} + +resource "aws_instance" "autofixed_literal" { + instance_type = "[AUTO_FIXED]" +} + +resource "aws_instance" "autofixed_variable" { + instance_type = var.input +} diff --git a/integrationtest/autofix/module/result.json b/integrationtest/autofix/module/result.json new file mode 100644 index 000000000..791fe2a6c --- /dev/null +++ b/integrationtest/autofix/module/result.json @@ -0,0 +1,151 @@ +{ + "issues": [ + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is [AUTO_FIXED]", + "range": { + "filename": "main.tf", + "start": { + "line": 2, + "column": 19 + }, + "end": { + "line": 2, + "column": 33 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_autofix_conflict", + "severity": "error", + "link": "" + }, + "message": "instance type is [AUTO_FIXED]", + "range": { + "filename": "main.tf", + "start": { + "line": 2, + "column": 19 + }, + "end": { + "line": 2, + "column": 33 + } + }, + "callers": [] + }, + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "main.tf", + "start": { + "line": 2, + "column": 30 + }, + "end": { + "line": 3, + "column": 1 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is [AUTO_FIXED]", + "range": { + "filename": "main.tf", + "start": { + "line": 8, + "column": 11 + }, + "end": { + "line": 8, + "column": 25 + } + }, + "callers": [ + { + "filename": "main.tf", + "start": { + "line": 8, + "column": 11 + }, + "end": { + "line": 8, + "column": 25 + } + }, + { + "filename": "module/main.tf", + "start": { + "line": 8, + "column": 19 + }, + "end": { + "line": 8, + "column": 28 + } + } + ] + }, + { + "rule": { + "name": "aws_instance_autofix_conflict", + "severity": "error", + "link": "" + }, + "message": "instance type is [AUTO_FIXED]", + "range": { + "filename": "main.tf", + "start": { + "line": 8, + "column": 11 + }, + "end": { + "line": 8, + "column": 25 + } + }, + "callers": [ + { + "filename": "main.tf", + "start": { + "line": 8, + "column": 11 + }, + "end": { + "line": 8, + "column": 25 + } + }, + { + "filename": "module/main.tf", + "start": { + "line": 8, + "column": 19 + }, + "end": { + "line": 8, + "column": 28 + } + } + ] + } + ], + "errors": [] +} diff --git a/integrationtest/autofix/module/result_windows.json b/integrationtest/autofix/module/result_windows.json new file mode 100644 index 000000000..21f06fabe --- /dev/null +++ b/integrationtest/autofix/module/result_windows.json @@ -0,0 +1,151 @@ +{ + "issues": [ + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is [AUTO_FIXED]", + "range": { + "filename": "main.tf", + "start": { + "line": 2, + "column": 19 + }, + "end": { + "line": 2, + "column": 33 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_autofix_conflict", + "severity": "error", + "link": "" + }, + "message": "instance type is [AUTO_FIXED]", + "range": { + "filename": "main.tf", + "start": { + "line": 2, + "column": 19 + }, + "end": { + "line": 2, + "column": 33 + } + }, + "callers": [] + }, + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "main.tf", + "start": { + "line": 2, + "column": 30 + }, + "end": { + "line": 3, + "column": 1 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is [AUTO_FIXED]", + "range": { + "filename": "main.tf", + "start": { + "line": 8, + "column": 11 + }, + "end": { + "line": 8, + "column": 25 + } + }, + "callers": [ + { + "filename": "main.tf", + "start": { + "line": 8, + "column": 11 + }, + "end": { + "line": 8, + "column": 25 + } + }, + { + "filename": "module\\main.tf", + "start": { + "line": 8, + "column": 19 + }, + "end": { + "line": 8, + "column": 28 + } + } + ] + }, + { + "rule": { + "name": "aws_instance_autofix_conflict", + "severity": "error", + "link": "" + }, + "message": "instance type is [AUTO_FIXED]", + "range": { + "filename": "main.tf", + "start": { + "line": 8, + "column": 11 + }, + "end": { + "line": 8, + "column": 25 + } + }, + "callers": [ + { + "filename": "main.tf", + "start": { + "line": 8, + "column": 11 + }, + "end": { + "line": 8, + "column": 25 + } + }, + { + "filename": "module\\main.tf", + "start": { + "line": 8, + "column": 19 + }, + "end": { + "line": 8, + "column": 28 + } + } + ] + } + ], + "errors": [] +} diff --git a/integrationtest/autofix/multiple_files/.tflint.hcl b/integrationtest/autofix/multiple_files/.tflint.hcl new file mode 100644 index 000000000..e19f589dd --- /dev/null +++ b/integrationtest/autofix/multiple_files/.tflint.hcl @@ -0,0 +1,3 @@ +plugin "testing" { + enabled = true +} diff --git a/integrationtest/autofix/multiple_files/main.tf b/integrationtest/autofix/multiple_files/main.tf new file mode 100644 index 000000000..2478b3f77 --- /dev/null +++ b/integrationtest/autofix/multiple_files/main.tf @@ -0,0 +1 @@ +// autofixed diff --git a/integrationtest/autofix/multiple_files/main.tf.fixed b/integrationtest/autofix/multiple_files/main.tf.fixed new file mode 100644 index 000000000..8c7082e89 --- /dev/null +++ b/integrationtest/autofix/multiple_files/main.tf.fixed @@ -0,0 +1 @@ +# autofixed diff --git a/integrationtest/autofix/multiple_files/result.json b/integrationtest/autofix/multiple_files/result.json new file mode 100644 index 000000000..4fe2142b1 --- /dev/null +++ b/integrationtest/autofix/multiple_files/result.json @@ -0,0 +1,45 @@ +{ + "issues": [ + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "main.tf", + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 2, + "column": 1 + } + }, + "callers": [] + }, + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "template.tf", + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 2, + "column": 1 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integrationtest/autofix/multiple_files/template.tf b/integrationtest/autofix/multiple_files/template.tf new file mode 100644 index 000000000..2478b3f77 --- /dev/null +++ b/integrationtest/autofix/multiple_files/template.tf @@ -0,0 +1 @@ +// autofixed diff --git a/integrationtest/autofix/multiple_files/template.tf.fixed b/integrationtest/autofix/multiple_files/template.tf.fixed new file mode 100644 index 000000000..8c7082e89 --- /dev/null +++ b/integrationtest/autofix/multiple_files/template.tf.fixed @@ -0,0 +1 @@ +# autofixed diff --git a/integrationtest/autofix/multiple_fix/.tflint.hcl b/integrationtest/autofix/multiple_fix/.tflint.hcl new file mode 100644 index 000000000..e19f589dd --- /dev/null +++ b/integrationtest/autofix/multiple_fix/.tflint.hcl @@ -0,0 +1,3 @@ +plugin "testing" { + enabled = true +} diff --git a/integrationtest/autofix/multiple_fix/main.tf b/integrationtest/autofix/multiple_fix/main.tf new file mode 100644 index 000000000..d409141db --- /dev/null +++ b/integrationtest/autofix/multiple_fix/main.tf @@ -0,0 +1,2 @@ +// autofixed +// autofixed diff --git a/integrationtest/autofix/multiple_fix/main.tf.fixed b/integrationtest/autofix/multiple_fix/main.tf.fixed new file mode 100644 index 000000000..fc3f64114 --- /dev/null +++ b/integrationtest/autofix/multiple_fix/main.tf.fixed @@ -0,0 +1,2 @@ +# autofixed +# autofixed diff --git a/integrationtest/autofix/multiple_fix/result.json b/integrationtest/autofix/multiple_fix/result.json new file mode 100644 index 000000000..1a9a5a21a --- /dev/null +++ b/integrationtest/autofix/multiple_fix/result.json @@ -0,0 +1,45 @@ +{ + "issues": [ + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "main.tf", + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 2, + "column": 1 + } + }, + "callers": [] + }, + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "main.tf", + "start": { + "line": 2, + "column": 1 + }, + "end": { + "line": 3, + "column": 1 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integrationtest/autofix/simple/.tflint.hcl b/integrationtest/autofix/simple/.tflint.hcl new file mode 100644 index 000000000..e19f589dd --- /dev/null +++ b/integrationtest/autofix/simple/.tflint.hcl @@ -0,0 +1,3 @@ +plugin "testing" { + enabled = true +} diff --git a/integrationtest/autofix/simple/main.tf b/integrationtest/autofix/simple/main.tf new file mode 100644 index 000000000..2478b3f77 --- /dev/null +++ b/integrationtest/autofix/simple/main.tf @@ -0,0 +1 @@ +// autofixed diff --git a/integrationtest/autofix/simple/main.tf.fixed b/integrationtest/autofix/simple/main.tf.fixed new file mode 100644 index 000000000..8c7082e89 --- /dev/null +++ b/integrationtest/autofix/simple/main.tf.fixed @@ -0,0 +1 @@ +# autofixed diff --git a/integrationtest/autofix/simple/result.json b/integrationtest/autofix/simple/result.json new file mode 100644 index 000000000..91a4f7c1d --- /dev/null +++ b/integrationtest/autofix/simple/result.json @@ -0,0 +1,25 @@ +{ + "issues": [ + { + "rule": { + "name": "terraform_autofix_comment", + "severity": "error", + "link": "" + }, + "message": "Use \"# autofixed\" instead of \"// autofixed\"", + "range": { + "filename": "main.tf", + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 2, + "column": 1 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integrationtest/cli/cli_test.go b/integrationtest/cli/cli_test.go index 4a9d2282b..ddd17bd75 100644 --- a/integrationtest/cli/cli_test.go +++ b/integrationtest/cli/cli_test.go @@ -236,7 +236,7 @@ func TestIntegration(t *testing.T) { command: "./tflint", dir: "check_errors", status: cmd.ExitCodeError, - stderr: "Failed to check `aws_cloudformation_stack_error` rule: an error occurred in Check", + stderr: `failed to check "aws_cloudformation_stack_error" rule: an error occurred in Check`, }, { name: "files arguments", diff --git a/integrationtest/inspection/enable-required-config-rule-by-cli/result.json b/integrationtest/inspection/enable-required-config-rule-by-cli/result.json index 0c8ec7692..b7038a762 100644 --- a/integrationtest/inspection/enable-required-config-rule-by-cli/result.json +++ b/integrationtest/inspection/enable-required-config-rule-by-cli/result.json @@ -2,7 +2,7 @@ "issues": [], "errors": [ { - "message": "Failed to check ruleset; Failed to check `aws_s3_bucket_with_config_example` rule: This rule cannot be enabled with the `--enable-rule` option because it lacks the required configuration", + "message": "Failed to check ruleset; failed to check \"aws_s3_bucket_with_config_example\" rule: This rule cannot be enabled with the `--enable-rule` option because it lacks the required configuration", "severity": "error" } ] diff --git a/integrationtest/inspection/rule-required-config/result.json b/integrationtest/inspection/rule-required-config/result.json index 13ec26d60..06b4e8e8c 100644 --- a/integrationtest/inspection/rule-required-config/result.json +++ b/integrationtest/inspection/rule-required-config/result.json @@ -2,7 +2,7 @@ "issues": [], "errors": [ { - "message": "Failed to check ruleset; Failed to check `aws_s3_bucket_with_config_example` rule: .tflint.hcl:5,42-42: Missing required argument; The argument \"name\" is required, but no definition was found.", + "message": "Failed to check ruleset; failed to check \"aws_s3_bucket_with_config_example\" rule: .tflint.hcl:5,42-42: Missing required argument; The argument \"name\" is required, but no definition was found.", "severity": "error" } ] diff --git a/plugin/server.go b/plugin/server.go index c491bb016..91f218201 100644 --- a/plugin/server.go +++ b/plugin/server.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/go-version" hcl "github.com/hashicorp/hcl/v2" "github.com/terraform-linters/tflint-plugin-sdk/hclext" + "github.com/terraform-linters/tflint-plugin-sdk/plugin/plugin2host" sdk "github.com/terraform-linters/tflint-plugin-sdk/tflint" "github.com/terraform-linters/tflint/terraform" "github.com/terraform-linters/tflint/tflint" @@ -22,6 +23,8 @@ type GRPCServer struct { clientSDKVersion *version.Version } +var _ plugin2host.Server = (*GRPCServer)(nil) + // NewGRPCServer initializes a gRPC server for plugins. func NewGRPCServer(runner *tflint.Runner, rootRunner *tflint.Runner, files map[string]*hcl.File, sdkVersion *version.Version) *GRPCServer { return &GRPCServer{runner: runner, rootRunner: rootRunner, files: files, clientSDKVersion: sdkVersion} @@ -70,6 +73,11 @@ func (s *GRPCServer) GetModuleContent(bodyS *hclext.BodySchema, opts sdk.GetModu // GetFile returns the hcl.File based on passed the file name. func (s *GRPCServer) GetFile(name string) (*hcl.File, error) { + // Considering that autofix has been applied, prioritize returning the value of runner.Files(). + if file, exists := s.runner.Files()[name]; exists { + return file, nil + } + // If the file is not found in the current module, it may be in other modules (e.g. root module). return s.files[name], nil } @@ -180,22 +188,40 @@ func (s *GRPCServer) EvaluateExpr(expr hcl.Expression, opts sdk.EvaluateExprOpti } // EmitIssue stores an issue in the server based on passed rule, message, and location. -// If the range associated with the issue is an expression, it propagates to the runner -// that the issue found in that expression. This allows you to determine if the issue was caused -// by a module argument in the case of module inspection. -func (s *GRPCServer) EmitIssue(rule sdk.Rule, message string, location hcl.Range) error { +func (s *GRPCServer) EmitIssue(rule sdk.Rule, message string, location hcl.Range, fixable bool) (bool, error) { + // If the issue range represents an expression, it is emitted based on that context. + // This is important for module inspection that emits issues for module arguments included in the expression. + expr, err := s.getExprFromRange(location) + if err != nil { + // If the range does not represent an expression, just emit it without context. + return s.runner.EmitIssue(rule, message, location, fixable), nil + } + + var applied bool + err = s.runner.WithExpressionContext(expr, func() error { + applied = s.runner.EmitIssue(rule, message, location, fixable) + return nil + }) + return applied, err +} + +func (s *GRPCServer) getExprFromRange(location hcl.Range) (hcl.Expression, error) { file := s.runner.File(location.Filename) if file == nil { - s.runner.EmitIssue(rule, message, location) - return nil + return nil, errors.New("file not found") } expr, diags := hclext.ParseExpression(location.SliceBytes(file.Bytes), location.Filename, location.Start) if diags.HasErrors() { - s.runner.EmitIssue(rule, message, location) - return nil + return nil, diags } - return s.runner.WithExpressionContext(expr, func() error { - s.runner.EmitIssue(rule, message, location) - return nil - }) + return expr, nil +} + +// ApplyChanges applies the autofix changes to the runner. +func (s *GRPCServer) ApplyChanges(changes map[string][]byte) error { + diags := s.runner.ApplyChanges(changes) + if diags.HasErrors() { + return diags + } + return nil } diff --git a/plugin/server_test.go b/plugin/server_test.go index 44ae7b820..1946cc091 100644 --- a/plugin/server_test.go +++ b/plugin/server_test.go @@ -241,33 +241,11 @@ resource "aws_instance" "bar" { } func TestGetFile(t *testing.T) { - runner := tflint.TestRunner(t, map[string]string{ - "test1.tf": ` -resource "aws_instance" "foo" { - instance_type = "t2.micro" -}`, - "test2.tf": ` -resource "aws_instance" "bar" { - instance_type = "m5.2xlarge" -}`, - }) - rootRunner := tflint.TestRunner(t, map[string]string{ - "test_on_root1.tf": ` -resource "aws_instance" "foo" { - instance_type = "t2.nano" -}`, - }) - files := runner.Files() - for name, file := range rootRunner.Files() { - files[name] = file - } - - server := NewGRPCServer(runner, rootRunner, files, SDKVersion) - tests := []struct { - Name string - Arg string - Want string + Name string + Arg string + Changes map[string][]byte + Want string }{ { Name: "get test1.tf", @@ -296,12 +274,53 @@ resource "aws_instance" "bar" { Want: ` resource "aws_instance" "foo" { instance_type = "t2.nano" +}`, + }, + { + Name: "get autofixed file", + Arg: "test1.tf", + Changes: map[string][]byte{ + "test1.tf": []byte(` +resource "aws_instance" "foo" { + instance_type = "t3.nano" +}`), + }, + Want: ` +resource "aws_instance" "foo" { + instance_type = "t3.nano" }`, }, } for _, test := range tests { t.Run(test.Name, func(t *testing.T) { + runner := tflint.TestRunner(t, map[string]string{ + "test1.tf": ` +resource "aws_instance" "foo" { + instance_type = "t2.micro" +}`, + "test2.tf": ` +resource "aws_instance" "bar" { + instance_type = "m5.2xlarge" +}`, + }) + rootRunner := tflint.TestRunner(t, map[string]string{ + "test_on_root1.tf": ` +resource "aws_instance" "foo" { + instance_type = "t2.nano" +}`, + }) + files := runner.Files() + for name, file := range rootRunner.Files() { + files[name] = file + } + + server := NewGRPCServer(runner, rootRunner, files, SDKVersion) + + if diags := runner.ApplyChanges(test.Changes); diags.HasErrors() { + t.Fatal(diags) + } + file, err := server.GetFile(test.Arg) if err != nil { t.Fatalf("failed to call GetFile: %s", err) @@ -707,27 +726,27 @@ resource "aws_instance" "foo" { tests := []struct { Name string - Args func() (sdk.Rule, string, hcl.Range) + Args func() (sdk.Rule, string, hcl.Range, bool) Want int }{ { Name: "on expr", - Args: func() (sdk.Rule, string, hcl.Range) { - return &testRule{}, "error", exprRange + Args: func() (sdk.Rule, string, hcl.Range, bool) { + return &testRule{}, "error", exprRange, false }, Want: 1, }, { Name: "on non-expr", - Args: func() (sdk.Rule, string, hcl.Range) { - return &testRule{}, "error", resourceDefRange + Args: func() (sdk.Rule, string, hcl.Range, bool) { + return &testRule{}, "error", resourceDefRange, false }, Want: 1, }, { Name: "on another file", - Args: func() (sdk.Rule, string, hcl.Range) { - return &testRule{}, "error", hcl.Range{Filename: "not_found.tf"} + Args: func() (sdk.Rule, string, hcl.Range, bool) { + return &testRule{}, "error", hcl.Range{Filename: "not_found.tf"}, false }, Want: 1, }, @@ -739,7 +758,7 @@ resource "aws_instance" "foo" { server := NewGRPCServer(runner, nil, runner.Files(), SDKVersion) - err := server.EmitIssue(test.Args()) + _, err := server.EmitIssue(test.Args()) if err != nil { t.Fatalf("failed to call EmitIssue: %s", err) } @@ -750,3 +769,54 @@ resource "aws_instance" "foo" { }) } } + +func TestApplyChanges(t *testing.T) { + tests := []struct { + name string + files map[string]string + changes map[string][]byte + want map[string][]byte + }{ + { + name: "change file", + files: map[string]string{ + "main.tf": ` +resource "aws_instance" "foo" { + instance_type = "t2.micro" +}`, + "variables.tf": `variable "foo" {}`, + }, + changes: map[string][]byte{ + "main.tf": []byte(` +resource "aws_instance" "foo" { + instance_type = "t3.nano" +}`), + }, + want: map[string][]byte{ + "main.tf": []byte(` +resource "aws_instance" "foo" { + instance_type = "t3.nano" +}`), + "variables.tf": []byte(`variable "foo" {}`), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runner := tflint.TestRunner(t, test.files) + + server := NewGRPCServer(runner, nil, runner.Files(), SDKVersion) + + err := server.ApplyChanges(test.changes) + if err != nil { + t.Fatalf("failed to call ApplyChanges: %s", err) + } + + got := server.GetFiles(sdk.SelfModuleCtxType) + if diff := cmp.Diff(test.want, got); diff != "" { + t.Errorf(diff) + } + }) + } +} diff --git a/plugin/stub-generator/sources/customrulesettesting/custom/ruleset.go b/plugin/stub-generator/sources/customrulesettesting/custom/ruleset.go index a4fb6f5fb..720c22592 100644 --- a/plugin/stub-generator/sources/customrulesettesting/custom/ruleset.go +++ b/plugin/stub-generator/sources/customrulesettesting/custom/ruleset.go @@ -1,8 +1,6 @@ package custom import ( - "fmt" - "github.com/terraform-linters/tflint-plugin-sdk/hclext" "github.com/terraform-linters/tflint-plugin-sdk/tflint" ) @@ -26,16 +24,6 @@ func (r *RuleSet) ApplyConfig(body *hclext.BodyContent) error { return nil } -func (r *RuleSet) Check(rr tflint.Runner) error { - runner, err := NewRunner(rr, r.config) - if err != nil { - return err - } - - for _, rule := range r.EnabledRules { - if err := rule.Check(runner); err != nil { - return fmt.Errorf("Failed to check `%s` rule: %s", rule.Name(), err) - } - } - return nil +func (r *RuleSet) NewRunner(runner tflint.Runner) (tflint.Runner, error) { + return NewRunner(runner, r.config) } diff --git a/plugin/stub-generator/sources/testing/main.go b/plugin/stub-generator/sources/testing/main.go index 8dcd78710..bfd71dd60 100644 --- a/plugin/stub-generator/sources/testing/main.go +++ b/plugin/stub-generator/sources/testing/main.go @@ -24,6 +24,9 @@ func main() { rules.NewLocalsJustAttributesExampleRule(), rules.NewAwsIAMRoleExampleRule(), rules.NewTestingAssertionsExampleRule(), + rules.NewTerraformAutofixRemoveLocalRule(), // should be former than terraform_autofix_comment because this rule changes the line number + rules.NewTerraformAutofixCommentRule(), + rules.NewAwsInstanceAutofixConflictRule(), // should be later than terraform_autofix_comment because this rule adds an issue for terraform_autofix_comment }, }, }) diff --git a/plugin/stub-generator/sources/testing/rules/aws_instance_autofix_conflict.go b/plugin/stub-generator/sources/testing/rules/aws_instance_autofix_conflict.go new file mode 100644 index 000000000..c3277a533 --- /dev/null +++ b/plugin/stub-generator/sources/testing/rules/aws_instance_autofix_conflict.go @@ -0,0 +1,76 @@ +package rules + +import ( + "fmt" + + "github.com/terraform-linters/tflint-plugin-sdk/hclext" + "github.com/terraform-linters/tflint-plugin-sdk/tflint" +) + +// AwsInstanceAutofixConflict checks whether ... +type AwsInstanceAutofixConflict struct { + tflint.DefaultRule +} + +// NewAwsInstanceAutofixConflictRule returns a new rule +func NewAwsInstanceAutofixConflictRule() *AwsInstanceAutofixConflict { + return &AwsInstanceAutofixConflict{} +} + +// Name returns the rule name +func (r *AwsInstanceAutofixConflict) Name() string { + return "aws_instance_autofix_conflict" +} + +// Enabled returns whether the rule is enabled by default +func (r *AwsInstanceAutofixConflict) Enabled() bool { + return true +} + +// Severity returns the rule severity +func (r *AwsInstanceAutofixConflict) Severity() tflint.Severity { + return tflint.ERROR +} + +// Link returns the rule reference link +func (r *AwsInstanceAutofixConflict) Link() string { + return "" +} + +// Check checks whether ... +func (r *AwsInstanceAutofixConflict) Check(runner tflint.Runner) error { + resources, err := runner.GetResourceContent("aws_instance", &hclext.BodySchema{ + Attributes: []hclext.AttributeSchema{{Name: "instance_type"}}, + }, nil) + if err != nil { + return err + } + + for _, resource := range resources.Blocks { + attribute, exists := resource.Body.Attributes["instance_type"] + if !exists { + continue + } + + err := runner.EvaluateExpr(attribute.Expr, func(instanceType string) error { + if instanceType != "[AUTO_FIXED]" { + return nil + } + + return runner.EmitIssueWithFix( + r, + fmt.Sprintf("instance type is %s", instanceType), + attribute.Expr.Range(), + func(f tflint.Fixer) error { + // Add a new issue for terraform_autofix_comment rule + return f.ReplaceText(attribute.Expr.Range(), `"t2.micro" // autofixed`) + }, + ) + }, nil) + if err != nil { + return err + } + } + + return nil +} diff --git a/plugin/stub-generator/sources/testing/rules/terraform_autofix_comment.go b/plugin/stub-generator/sources/testing/rules/terraform_autofix_comment.go new file mode 100644 index 000000000..893e07640 --- /dev/null +++ b/plugin/stub-generator/sources/testing/rules/terraform_autofix_comment.go @@ -0,0 +1,90 @@ +package rules + +import ( + "runtime" + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/terraform-linters/tflint-plugin-sdk/tflint" +) + +// TerraformAutofixComment checks whether ... +type TerraformAutofixComment struct { + tflint.DefaultRule +} + +// NewTerraformAutofixCommentRule returns a new rule +func NewTerraformAutofixCommentRule() *TerraformAutofixComment { + return &TerraformAutofixComment{} +} + +// Name returns the rule name +func (r *TerraformAutofixComment) Name() string { + return "terraform_autofix_comment" +} + +// Enabled returns whether the rule is enabled by default +func (r *TerraformAutofixComment) Enabled() bool { + return true +} + +// Severity returns the rule severity +func (r *TerraformAutofixComment) Severity() tflint.Severity { + return tflint.ERROR +} + +// Link returns the rule reference link +func (r *TerraformAutofixComment) Link() string { + return "" +} + +// Check checks whether ... +func (r *TerraformAutofixComment) Check(runner tflint.Runner) error { + files, err := runner.GetFiles() + if err != nil { + return err + } + + for name, file := range files { + if strings.HasSuffix(name, ".tf.json") { + continue + } + + tokens, diags := hclsyntax.LexConfig(file.Bytes, name, hcl.InitialPos) + if diags.HasErrors() { + return diags + } + + for _, token := range tokens { + if token.Type != hclsyntax.TokenComment { + continue + } + + if string(token.Bytes) == "// autofixed"+newLine() { + if err := runner.EmitIssueWithFix( + r, + `Use "# autofixed" instead of "// autofixed"`, + token.Range, + func(f tflint.Fixer) error { + return f.ReplaceText( + f.RangeTo("// autofixed", name, token.Range.Start), + "# autofixed", + ) + }, + ); err != nil { + return err + } + } + } + } + + return nil +} + +func newLine() string { + if runtime.GOOS == "windows" { + return "\r\n" + } + return "\n" +} diff --git a/plugin/stub-generator/sources/testing/rules/terraform_autofix_remove_local.go b/plugin/stub-generator/sources/testing/rules/terraform_autofix_remove_local.go new file mode 100644 index 000000000..2efd84057 --- /dev/null +++ b/plugin/stub-generator/sources/testing/rules/terraform_autofix_remove_local.go @@ -0,0 +1,80 @@ +package rules + +import ( + "github.com/hashicorp/hcl/v2" + "github.com/terraform-linters/tflint-plugin-sdk/tflint" +) + +// TerraformAutofixRemoveLocal checks whether ... +type TerraformAutofixRemoveLocal struct { + tflint.DefaultRule +} + +// NewTerraformAutofixRemoveLocalRule returns a new rule +func NewTerraformAutofixRemoveLocalRule() *TerraformAutofixRemoveLocal { + return &TerraformAutofixRemoveLocal{} +} + +// Name returns the rule name +func (r *TerraformAutofixRemoveLocal) Name() string { + return "terraform_autofix_remove_local" +} + +// Enabled returns whether the rule is enabled by default +func (r *TerraformAutofixRemoveLocal) Enabled() bool { + return true +} + +// Severity returns the rule severity +func (r *TerraformAutofixRemoveLocal) Severity() tflint.Severity { + return tflint.ERROR +} + +// Link returns the rule reference link +func (r *TerraformAutofixRemoveLocal) Link() string { + return "" +} + +// Check checks whether ... +func (r *TerraformAutofixRemoveLocal) Check(runner tflint.Runner) error { + files, err := runner.GetFiles() + if err != nil { + return err + } + + diags := hcl.Diagnostics{} + for _, file := range files { + content, _, schemaDiags := file.Body.PartialContent(&hcl.BodySchema{ + Blocks: []hcl.BlockHeaderSchema{{Type: "locals"}}, + }) + diags = diags.Extend(schemaDiags) + if schemaDiags.HasErrors() { + continue + } + + for _, block := range content.Blocks { + attrs, localsDiags := block.Body.JustAttributes() + diags = diags.Extend(localsDiags) + if localsDiags.HasErrors() { + continue + } + + for name, attr := range attrs { + if name == "autofix_removed" { + if err := runner.EmitIssueWithFix( + r, + `Do not use "autofix_removed" local value`, + attr.Range, + func(f tflint.Fixer) error { + return f.RemoveAttribute(attr) + }, + ); err != nil { + return err + } + } + } + } + } + + return nil +} diff --git a/terraform/module.go b/terraform/module.go index 1c197d1f8..e9f90eac4 100644 --- a/terraform/module.go +++ b/terraform/module.go @@ -5,6 +5,8 @@ import ( "strings" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + hcljson "github.com/hashicorp/hcl/v2/json" "github.com/terraform-linters/tflint-plugin-sdk/hclext" ) @@ -19,8 +21,8 @@ type Module struct { Sources map[string][]byte Files map[string]*hcl.File - primaries []*hcl.File - overrides []*hcl.File + primaries map[string]*hcl.File + overrides map[string]*hcl.File } func NewEmptyModule() *Module { @@ -35,8 +37,8 @@ func NewEmptyModule() *Module { Sources: map[string][]byte{}, Files: map[string]*hcl.File{}, - primaries: []*hcl.File{}, - overrides: []*hcl.File{}, + primaries: map[string]*hcl.File{}, + overrides: map[string]*hcl.File{}, } } @@ -73,6 +75,42 @@ func (m *Module) build() hcl.Diagnostics { return diags } +// Rebuild rebuilds the module from the passed sources. +// The main purpose of this is to apply autofixes in the module. +func (m *Module) Rebuild(sources map[string][]byte) hcl.Diagnostics { + if len(sources) == 0 { + return nil + } + var diags hcl.Diagnostics + + for path, source := range sources { + var file *hcl.File + var d hcl.Diagnostics + if strings.HasSuffix(path, ".json") { + file, d = hcljson.Parse(source, path) + } else { + file, d = hclsyntax.ParseConfig(source, path, hcl.InitialPos) + } + if d.HasErrors() { + diags = diags.Extend(d) + continue + } + + m.Sources[path] = source + m.Files[path] = file + if _, exists := m.primaries[path]; exists { + m.primaries[path] = file + } + if _, exists := m.overrides[path]; exists { + m.overrides[path] = file + } + } + + d := m.build() + diags = diags.Extend(d) + return diags +} + // PartialContent extracts body content from Terraform configurations based on the passed schema. // Basically, this function is a wrapper for hclext.PartialContent, but in some ways it reproduces // Terraform language semantics. diff --git a/terraform/module_test.go b/terraform/module_test.go index 98d4f308f..20f472e42 100644 --- a/terraform/module_test.go +++ b/terraform/module_test.go @@ -1,6 +1,7 @@ package terraform import ( + "bytes" "os" "testing" @@ -12,6 +13,179 @@ import ( "github.com/terraform-linters/tflint-plugin-sdk/hclext" ) +func TestRebuild(t *testing.T) { + tests := []struct { + name string + module *Module + sources map[string][]byte + want *Module + }{ + { + name: "HCL native files", + module: &Module{ + SourceDir: ".", + Variables: map[string]*Variable{"foo": {Name: "foo"}}, + primaries: map[string]*hcl.File{ + "main.tf": {Bytes: []byte(`variable "foo" { default = 1 }`), Body: hcl.EmptyBody()}, + }, + overrides: map[string]*hcl.File{ + "main_override.tf": {Bytes: []byte(`variable "foo" { default = 2 }`), Body: hcl.EmptyBody()}, + "override.tf": {Bytes: []byte(`variable "foo" { default = 3 }`), Body: hcl.EmptyBody()}, + }, + Sources: map[string][]byte{ + "main.tf": []byte(`variable "foo" { default = 1 }`), + "main_override.tf": []byte(`variable "foo" { default = 2 }`), + "override.tf": []byte(`variable "foo" { default = 3 }`), + }, + Files: map[string]*hcl.File{ + "main.tf": {Bytes: []byte(`variable "foo" { default = 1 }`), Body: hcl.EmptyBody()}, + "main_override.tf": {Bytes: []byte(`variable "foo" { default = 2 }`), Body: hcl.EmptyBody()}, + "override.tf": {Bytes: []byte(`variable "foo" { default = 3 }`), Body: hcl.EmptyBody()}, + }, + }, + sources: map[string][]byte{ + "main.tf": []byte(` +variable "foo" { default = 1 } +variable "bar" { default = "bar" } +`), + "main_override.tf": []byte(` +variable "foo" { default = 2 } +variable "bar" { default = "baz" } +`), + }, + want: &Module{ + SourceDir: ".", + Variables: map[string]*Variable{"foo": {Name: "foo"}, "bar": {Name: "bar"}}, + primaries: map[string]*hcl.File{ + "main.tf": { + Bytes: []byte(` +variable "foo" { default = 1 } +variable "bar" { default = "bar" } +`), + Body: hcl.EmptyBody(), + }, + }, + overrides: map[string]*hcl.File{ + "main_override.tf": { + Bytes: []byte(` +variable "foo" { default = 2 } +variable "bar" { default = "baz" } +`), + Body: hcl.EmptyBody(), + }, + "override.tf": {Bytes: []byte(`variable "foo" { default = 3 }`), Body: hcl.EmptyBody()}, + }, + Sources: map[string][]byte{ + "main.tf": []byte(` +variable "foo" { default = 1 } +variable "bar" { default = "bar" } +`), + "main_override.tf": []byte(` +variable "foo" { default = 2 } +variable "bar" { default = "baz" } +`), + "override.tf": []byte(`variable "foo" { default = 3 }`), + }, + Files: map[string]*hcl.File{ + "main.tf": { + Bytes: []byte(` +variable "foo" { default = 1 } +variable "bar" { default = "bar" } +`), + Body: hcl.EmptyBody(), + }, + "main_override.tf": { + Bytes: []byte(` +variable "foo" { default = 2 } +variable "bar" { default = "baz" } +`), + Body: hcl.EmptyBody(), + }, + "override.tf": {Bytes: []byte(`variable "foo" { default = 3 }`), Body: hcl.EmptyBody()}, + }, + }, + }, + { + name: "HCL JSON files", + module: &Module{ + SourceDir: ".", + Variables: map[string]*Variable{"foo": {Name: "foo"}}, + primaries: map[string]*hcl.File{ + "main.tf.json": {Bytes: []byte(`{"variable": {"foo": {"default": 1}}}`), Body: hcl.EmptyBody()}, + }, + overrides: map[string]*hcl.File{ + "main_override.tf.json": {Bytes: []byte(`{"variable": {"foo": {"default": 2}}}`), Body: hcl.EmptyBody()}, + "override.tf.json": {Bytes: []byte(`{"variable": {"foo": {"default": 3}}}`), Body: hcl.EmptyBody()}, + }, + Sources: map[string][]byte{ + "main.tf.json": []byte(`{"variable": {"foo": {"default": 1}}}`), + "main_override.tf.json": []byte(`{"variable": {"foo": {"default": 2}}}`), + "override.tf.json": []byte(`{"variable": {"foo": {"default": 3}}}`), + }, + Files: map[string]*hcl.File{ + "main.tf.json": {Bytes: []byte(`{"variable": {"foo": {"default": 1}}}`), Body: hcl.EmptyBody()}, + "main_override.tf.json": {Bytes: []byte(`{"variable": {"foo": {"default": 2}}}`), Body: hcl.EmptyBody()}, + "override.tf.json": {Bytes: []byte(`{"variable": {"foo": {"default": 3}}}`), Body: hcl.EmptyBody()}, + }, + }, + sources: map[string][]byte{ + "main.tf.json": []byte(`{"variable": {"foo": {"default": 1}, "bar": {"default": "bar"}}}`), + "main_override.tf.json": []byte(`{"variable": {"foo": {"default": 2}, "bar": {"default": "baz"}}}`), + }, + want: &Module{ + SourceDir: ".", + Variables: map[string]*Variable{"foo": {Name: "foo"}, "bar": {Name: "bar"}}, + primaries: map[string]*hcl.File{ + "main.tf.json": {Bytes: []byte(`{"variable": {"foo": {"default": 1}, "bar": {"default": "bar"}}}`), Body: hcl.EmptyBody()}, + }, + overrides: map[string]*hcl.File{ + "main_override.tf.json": {Bytes: []byte(`{"variable": {"foo": {"default": 2}, "bar": {"default": "baz"}}}`), Body: hcl.EmptyBody()}, + "override.tf.json": {Bytes: []byte(`{"variable": {"foo": {"default": 3}}}`), Body: hcl.EmptyBody()}, + }, + Sources: map[string][]byte{ + "main.tf.json": []byte(`{"variable": {"foo": {"default": 1}, "bar": {"default": "bar"}}}`), + "main_override.tf.json": []byte(`{"variable": {"foo": {"default": 2}, "bar": {"default": "baz"}}}`), + "override.tf.json": []byte(`{"variable": {"foo": {"default": 3}}}`), + }, + Files: map[string]*hcl.File{ + "main.tf.json": {Bytes: []byte(`{"variable": {"foo": {"default": 1}, "bar": {"default": "bar"}}}`), Body: hcl.EmptyBody()}, + "main_override.tf.json": {Bytes: []byte(`{"variable": {"foo": {"default": 2}, "bar": {"default": "baz"}}}`), Body: hcl.EmptyBody()}, + "override.tf.json": {Bytes: []byte(`{"variable": {"foo": {"default": 3}}}`), Body: hcl.EmptyBody()}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + diags := test.module.Rebuild(test.sources) + if diags.HasErrors() { + t.Fatalf("unexpected error: %s", diags.Error()) + } + + opt := cmp.Comparer(func(x, y *hcl.File) bool { + return bytes.Equal(x.Bytes, y.Bytes) + }) + + if diff := cmp.Diff(test.want.Sources, test.module.Sources); diff != "" { + t.Errorf("sources mismatch:\n%s", diff) + } + if diff := cmp.Diff(test.want.Files, test.module.Files, opt); diff != "" { + t.Errorf("files mismatch:\n%s", diff) + } + if diff := cmp.Diff(test.want.primaries, test.module.primaries, opt); diff != "" { + t.Errorf("primaries mismatch:\n%s", diff) + } + if diff := cmp.Diff(test.want.overrides, test.module.overrides, opt); diff != "" { + t.Errorf("overrides mismatch:\n%s", diff) + } + if len(test.want.Variables) != len(test.module.Variables) { + t.Errorf("variables count mismatch: want %d, got %d", len(test.want.Variables), len(test.module.Variables)) + } + }) + } +} + func TestPartialContent(t *testing.T) { tests := []struct { name string diff --git a/terraform/parser.go b/terraform/parser.go index 0efa4de9d..513095766 100644 --- a/terraform/parser.go +++ b/terraform/parser.go @@ -65,10 +65,8 @@ func (p *Parser) LoadConfigDir(baseDir, dir string) (*Module, hcl.Diagnostics) { } mod := NewEmptyModule() - mod.primaries = make([]*hcl.File, len(primaries)) - mod.overrides = make([]*hcl.File, len(overrides)) - for i, path := range primaries { + for _, path := range primaries { f, loadDiags := p.loadHCLFile(baseDir, path) diags = diags.Extend(loadDiags) if loadDiags.HasErrors() { @@ -76,11 +74,11 @@ func (p *Parser) LoadConfigDir(baseDir, dir string) (*Module, hcl.Diagnostics) { } realPath := filepath.Join(baseDir, path) - mod.primaries[i] = f + mod.primaries[realPath] = f mod.Sources[realPath] = f.Bytes mod.Files[realPath] = f } - for i, path := range overrides { + for _, path := range overrides { f, loadDiags := p.loadHCLFile(baseDir, path) diags = diags.Extend(loadDiags) if loadDiags.HasErrors() { @@ -88,7 +86,7 @@ func (p *Parser) LoadConfigDir(baseDir, dir string) (*Module, hcl.Diagnostics) { } realPath := filepath.Join(baseDir, path) - mod.overrides[i] = f + mod.overrides[realPath] = f mod.Sources[realPath] = f.Bytes mod.Files[realPath] = f } diff --git a/terraform/parser_test.go b/terraform/parser_test.go index 1e30d2025..03b8b746c 100644 --- a/terraform/parser_test.go +++ b/terraform/parser_test.go @@ -32,12 +32,12 @@ func TestLoadConfigDir(t *testing.T) { dir: ".", want: &Module{ SourceDir: ".", - primaries: []*hcl.File{ - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: "main.tf"}})}, + primaries: map[string]*hcl.File{ + "main.tf": {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: "main.tf"}})}, }, - overrides: []*hcl.File{ - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: "main_override.tf"}})}, - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: "override.tf"}})}, + overrides: map[string]*hcl.File{ + "main_override.tf": {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: "main_override.tf"}})}, + "override.tf": {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: "override.tf"}})}, }, Sources: map[string][]byte{ "main.tf": {}, @@ -62,12 +62,12 @@ func TestLoadConfigDir(t *testing.T) { dir: ".", want: &Module{ SourceDir: ".", - primaries: []*hcl.File{ - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: "main.tf.json"}})}, + primaries: map[string]*hcl.File{ + "main.tf.json": {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: "main.tf.json"}})}, }, - overrides: []*hcl.File{ - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: "main_override.tf.json"}})}, - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: "override.tf.json"}})}, + overrides: map[string]*hcl.File{ + "main_override.tf.json": {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: "main_override.tf.json"}})}, + "override.tf.json": {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: "override.tf.json"}})}, }, Sources: map[string][]byte{ "main.tf.json": []byte("{}"), @@ -92,12 +92,12 @@ func TestLoadConfigDir(t *testing.T) { dir: ".", want: &Module{ SourceDir: ".", - primaries: []*hcl.File{ - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("foo", "main.tf")}})}, + primaries: map[string]*hcl.File{ + filepath.Join("foo", "main.tf"): {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("foo", "main.tf")}})}, }, - overrides: []*hcl.File{ - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("foo", "main_override.tf")}})}, - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("foo", "override.tf")}})}, + overrides: map[string]*hcl.File{ + filepath.Join("foo", "main_override.tf"): {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("foo", "main_override.tf")}})}, + filepath.Join("foo", "override.tf"): {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("foo", "override.tf")}})}, }, Sources: map[string][]byte{ filepath.Join("foo", "main.tf"): {}, @@ -122,12 +122,12 @@ func TestLoadConfigDir(t *testing.T) { dir: "bar", want: &Module{ SourceDir: "bar", - primaries: []*hcl.File{ - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("bar", "main.tf")}})}, + primaries: map[string]*hcl.File{ + filepath.Join("bar", "main.tf"): {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("bar", "main.tf")}})}, }, - overrides: []*hcl.File{ - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("bar", "main_override.tf")}})}, - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("bar", "override.tf")}})}, + overrides: map[string]*hcl.File{ + filepath.Join("bar", "main_override.tf"): {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("bar", "main_override.tf")}})}, + filepath.Join("bar", "override.tf"): {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("bar", "override.tf")}})}, }, Sources: map[string][]byte{ filepath.Join("bar", "main.tf"): {}, @@ -152,12 +152,12 @@ func TestLoadConfigDir(t *testing.T) { dir: "bar", want: &Module{ SourceDir: "bar", - primaries: []*hcl.File{ - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("foo", "bar", "main.tf")}})}, + primaries: map[string]*hcl.File{ + filepath.Join("foo", "bar", "main.tf"): {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("foo", "bar", "main.tf")}})}, }, - overrides: []*hcl.File{ - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("foo", "bar", "main_override.tf")}})}, - {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("foo", "bar", "override.tf")}})}, + overrides: map[string]*hcl.File{ + filepath.Join("foo", "bar", "main_override.tf"): {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("foo", "bar", "main_override.tf")}})}, + filepath.Join("foo", "bar", "override.tf"): {Body: hcltest.MockBody(&hcl.BodyContent{MissingItemRange: hcl.Range{Filename: filepath.Join("foo", "bar", "override.tf")}})}, }, Sources: map[string][]byte{ filepath.Join("foo", "bar", "main.tf"): {}, @@ -192,29 +192,27 @@ func TestLoadConfigDir(t *testing.T) { t.Errorf("SourceDir: want=%s, got=%s", test.want.SourceDir, mod.SourceDir) } - opt := cmpopts.SortSlices(func(x, y string) bool { return x > y }) - - primaries := make([]string, len(mod.primaries)) + primaries := map[string]string{} for i, f := range mod.primaries { primaries[i] = f.Body.MissingItemRange().Filename } - primariesWant := make([]string, len(test.want.primaries)) + primariesWant := map[string]string{} for i, f := range test.want.primaries { primariesWant[i] = f.Body.MissingItemRange().Filename } - if diff := cmp.Diff(primaries, primariesWant, opt); diff != "" { + if diff := cmp.Diff(primaries, primariesWant); diff != "" { t.Errorf(diff) } - overrides := make([]string, len(mod.overrides)) + overrides := map[string]string{} for i, f := range mod.overrides { overrides[i] = f.Body.MissingItemRange().Filename } - overridesWant := make([]string, len(test.want.overrides)) + overridesWant := map[string]string{} for i, f := range test.want.overrides { overridesWant[i] = f.Body.MissingItemRange().Filename } - if diff := cmp.Diff(overrides, overridesWant, opt); diff != "" { + if diff := cmp.Diff(overrides, overridesWant); diff != "" { t.Errorf(diff) } @@ -230,6 +228,7 @@ func TestLoadConfigDir(t *testing.T) { for name := range test.want.Files { filesWant = append(filesWant, name) } + opt := cmpopts.SortSlices(func(x, y string) bool { return x > y }) if diff := cmp.Diff(files, filesWant, opt); diff != "" { t.Errorf(diff) } diff --git a/tflint/issue.go b/tflint/issue.go index 70c1dcc53..49a767eae 100644 --- a/tflint/issue.go +++ b/tflint/issue.go @@ -13,7 +13,13 @@ type Issue struct { Rule Rule Message string Range hcl.Range + Fixable bool Callers []hcl.Range + + // Source is the source code of the file where the issue was found. + // Usually this is the same as the originally loaded source, + // but it may be a different if rewritten by autofixes. + Source []byte } // Issues is an alias for the map of Issue diff --git a/tflint/runner.go b/tflint/runner.go index 498412cc0..dd4873d59 100644 --- a/tflint/runner.go +++ b/tflint/runner.go @@ -25,6 +25,7 @@ type Runner struct { config *Config currentExpr hcl.Expression modVars map[string]*moduleVariable + changes map[string][]byte } // Rule is interface for building the issue @@ -66,6 +67,7 @@ func NewRunner(originalWorkingDir string, c *Config, ants map[string]Annotations Ctx: ctx, annotations: ants, config: c, + changes: map[string][]byte{}, } return runner, nil @@ -186,6 +188,23 @@ func (r *Runner) LookupIssues(files ...string) Issues { return issues } +// LookupChanges returns changes according to the received files +func (r *Runner) LookupChanges(files ...string) map[string][]byte { + if len(files) == 0 { + return r.changes + } + + changes := make(map[string][]byte) + for path, source := range r.changes { + for _, file := range files { + if filepath.Clean(file) == filepath.Clean(path) { + changes[path] = source + } + } + } + return changes +} + // File returns the raw *hcl.File representation of a Terraform configuration at the specified path, // or nil if there path does not match any configuration. func (r *Runner) File(path string) *hcl.File { @@ -208,23 +227,35 @@ func (r *Runner) Sources() map[string][]byte { return r.TFConfig.Module.Sources } -// EmitIssue builds an issue and accumulates it -func (r *Runner) EmitIssue(rule Rule, message string, location hcl.Range) { +// EmitIssue builds an issue and accumulates it. +// Returns true if the issue was not ignored by annotations. +func (r *Runner) EmitIssue(rule Rule, message string, location hcl.Range, fixable bool) bool { if r.TFConfig.Path.IsRoot() { - r.emitIssue(&Issue{ + return r.emitIssue(&Issue{ Rule: rule, Message: message, Range: location, + Fixable: fixable, + Source: r.Sources()[location.Filename], }) } else { - for _, modVar := range r.listModuleVars(r.currentExpr) { - r.emitIssue(&Issue{ + modVars := r.listModuleVars(r.currentExpr) + // Returns true only if all issues have not been ignored in module inspection. + allApplied := len(modVars) > 0 + for _, modVar := range modVars { + applied := r.emitIssue(&Issue{ Rule: rule, Message: message, Range: modVar.DeclRange, + Fixable: false, // Issues for module inspection are always not fixable. Callers: append(modVar.callers(), location), + Source: r.Sources()[modVar.DeclRange.Filename], }) + if !applied { + allApplied = false + } } + return allApplied } } @@ -246,16 +277,38 @@ func (r *Runner) ConfigSources() map[string][]byte { return r.config.Sources() } -func (r *Runner) emitIssue(issue *Issue) { +// ApplyChanges saves the changes and applies them to the Terraform module. +func (r *Runner) ApplyChanges(changes map[string][]byte) hcl.Diagnostics { + if len(changes) == 0 { + return nil + } + + diags := r.TFConfig.Module.Rebuild(changes) + if diags.HasErrors() { + return diags + } + for path, source := range changes { + r.changes[path] = source + } + return nil +} + +// ClearChanges clears changes +func (r *Runner) ClearChanges() { + r.changes = map[string][]byte{} +} + +func (r *Runner) emitIssue(issue *Issue) bool { if annotations, ok := r.annotations[issue.Range.Filename]; ok { for _, annotation := range annotations { if annotation.IsAffected(issue) { log.Printf("[INFO] %s (%s) is ignored by %s", issue.Range.String(), issue.Rule.Name(), annotation.String()) - return + return false } } } r.Issues = append(r.Issues, issue) + return true } func (r *Runner) listModuleVars(expr hcl.Expression) []*moduleVariable { diff --git a/tflint/runner_test.go b/tflint/runner_test.go index 2659c1773..16798a0f0 100644 --- a/tflint/runner_test.go +++ b/tflint/runner_test.go @@ -388,6 +388,50 @@ func Test_LookupIssues(t *testing.T) { } } +func TestLookupChanges(t *testing.T) { + tests := []struct { + name string + in string + changes map[string][]byte + want map[string][]byte + }{ + { + name: "multiple files", + in: "main.tf", + changes: map[string][]byte{ + "main.tf": []byte("foo = 1"), + "resource.tf": []byte("bar = 2"), + }, + want: map[string][]byte{ + "main.tf": []byte("foo = 1"), + }, + }, + { + name: "path normalization", + in: "./main.tf", + changes: map[string][]byte{ + "main.tf": []byte("foo = 1"), + }, + want: map[string][]byte{ + "main.tf": []byte("foo = 1"), + }, + }, + } + + runner := TestRunner(t, map[string]string{}) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runner.changes = test.changes + + got := runner.LookupChanges(test.in) + if diff := cmp.Diff(test.want, got); diff != "" { + t.Fatal(diff) + } + }) + } +} + type testRule struct{} func (r *testRule) Name() string { @@ -401,13 +445,34 @@ func (r *testRule) Link() string { } func Test_EmitIssue(t *testing.T) { + sources := map[string]string{ + "test.tf": "foo = 1", + "module.tf": "bar = 2", + } + + parseExpr := func(in string) hcl.Expression { + expr, diags := hclsyntax.ParseExpression([]byte(in), "", hcl.InitialPos) + if diags.HasErrors() { + t.Fatal(diags) + } + return expr + } + + type moduleConfig struct { + currentExpr hcl.Expression + variables map[string]*moduleVariable + } + cases := []struct { Name string Rule Rule Message string Location hcl.Range + Fixable bool Annotations map[string]Annotations + Module *moduleConfig Expected Issues + Applied bool }{ { Name: "basic", @@ -426,8 +491,34 @@ func Test_EmitIssue(t *testing.T) { Filename: "test.tf", Start: hcl.Pos{Line: 1}, }, + Source: []byte("foo = 1"), + }, + }, + Applied: true, + }, + { + Name: "fixable", + Rule: &testRule{}, + Message: "This is test message", + Location: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1}, + }, + Fixable: true, + Annotations: map[string]Annotations{}, + Expected: Issues{ + { + Rule: &testRule{}, + Message: "This is test message", + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1}, + }, + Fixable: true, + Source: []byte("foo = 1"), }, }, + Applied: true, }, { Name: "ignore", @@ -452,17 +543,240 @@ func Test_EmitIssue(t *testing.T) { }, }, Expected: Issues{}, + Applied: false, + }, + { + Name: "module", + Rule: &testRule{}, + Message: "This is test message", + Location: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1}, + }, + Module: &moduleConfig{ + currentExpr: parseExpr("var.foo"), + variables: map[string]*moduleVariable{ + "foo": {Root: true, DeclRange: hcl.Range{Filename: "module.tf", Start: hcl.Pos{Line: 1}}}, + }, + }, + Expected: Issues{ + { + Rule: &testRule{}, + Message: "This is test message", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 1}, + }, + Callers: []hcl.Range{ + {Filename: "module.tf", Start: hcl.Pos{Line: 1}}, + {Filename: "test.tf", Start: hcl.Pos{Line: 1}}, + }, + Source: []byte("bar = 2"), + }, + }, + Applied: true, + }, + { + Name: "no variables in module", + Rule: &testRule{}, + Message: "This is test message", + Location: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1}, + }, + Module: &moduleConfig{ + currentExpr: parseExpr(`"foo"`), + variables: map[string]*moduleVariable{}, + }, + Expected: Issues{}, + Applied: false, + }, + { + Name: "multiple variables in module", + Rule: &testRule{}, + Message: "This is test message", + Location: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1}, + }, + Module: &moduleConfig{ + currentExpr: parseExpr(`"${var.foo}-${var.bar}"`), + variables: map[string]*moduleVariable{ + "foo": {Root: true, DeclRange: hcl.Range{Filename: "module.tf", Start: hcl.Pos{Line: 1}}}, + "bar": {Root: true, DeclRange: hcl.Range{Filename: "module.tf", Start: hcl.Pos{Line: 3}}}, + }, + }, + Expected: Issues{ + { + Rule: &testRule{}, + Message: "This is test message", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 1}, + }, + Callers: []hcl.Range{ + {Filename: "module.tf", Start: hcl.Pos{Line: 1}}, + {Filename: "test.tf", Start: hcl.Pos{Line: 1}}, + }, + Source: []byte("bar = 2"), + }, + { + Rule: &testRule{}, + Message: "This is test message", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 3}, + }, + Callers: []hcl.Range{ + {Filename: "module.tf", Start: hcl.Pos{Line: 3}}, + {Filename: "test.tf", Start: hcl.Pos{Line: 1}}, + }, + Source: []byte("bar = 2"), + }, + }, + Applied: true, + }, + { + Name: "ignored multiple variables in module", + Rule: &testRule{}, + Message: "This is test message", + Location: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1}, + }, + Module: &moduleConfig{ + currentExpr: parseExpr(`"${var.foo}-${var.bar}"`), + variables: map[string]*moduleVariable{ + "foo": {Root: true, DeclRange: hcl.Range{Filename: "module.tf", Start: hcl.Pos{Line: 1}}}, + "bar": {Root: true, DeclRange: hcl.Range{Filename: "module.tf", Start: hcl.Pos{Line: 3}}}, + }, + }, + Annotations: map[string]Annotations{ + "module.tf": { + { + Content: "test_rule", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 1}, + }, + }, + }, + }, + }, + Expected: Issues{ + { + Rule: &testRule{}, + Message: "This is test message", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 3}, + }, + Callers: []hcl.Range{ + {Filename: "module.tf", Start: hcl.Pos{Line: 3}}, + {Filename: "test.tf", Start: hcl.Pos{Line: 1}}, + }, + Source: []byte("bar = 2"), + }, + }, + Applied: false, + }, + { + Name: "fixable in module", + Rule: &testRule{}, + Message: "This is test message", + Location: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1}, + }, + Fixable: true, + Module: &moduleConfig{ + currentExpr: parseExpr("var.foo"), + variables: map[string]*moduleVariable{ + "foo": {Root: true, DeclRange: hcl.Range{Filename: "module.tf", Start: hcl.Pos{Line: 1}}}, + }, + }, + Expected: Issues{ + { + Rule: &testRule{}, + Message: "This is test message", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 1}, + }, + Callers: []hcl.Range{ + {Filename: "module.tf", Start: hcl.Pos{Line: 1}}, + {Filename: "test.tf", Start: hcl.Pos{Line: 1}}, + }, + Fixable: false, + Source: []byte("bar = 2"), + }, + }, + Applied: true, }, } for _, tc := range cases { - runner := testRunnerWithAnnotations(t, map[string]string{}, tc.Annotations) + t.Run(tc.Name, func(t *testing.T) { + runner := testRunnerWithAnnotations(t, sources, tc.Annotations) + if tc.Module != nil { + runner.TFConfig.Path = []string{"module", "module1"} + runner.currentExpr = tc.Module.currentExpr + runner.modVars = tc.Module.variables + } - runner.EmitIssue(tc.Rule, tc.Message, tc.Location) + got := runner.EmitIssue(tc.Rule, tc.Message, tc.Location, tc.Fixable) + if got != tc.Applied { + t.Fatalf("expected %v, got %v", tc.Applied, got) + } - if !cmp.Equal(runner.Issues, tc.Expected) { - t.Fatalf("Failed `%s` test: diff=%s", tc.Name, cmp.Diff(runner.Issues, tc.Expected)) - } + if diff := cmp.Diff(runner.Issues.Sort(), tc.Expected); diff != "" { + t.Fatalf(diff) + } + }) + } +} + +func TestApplyChanges(t *testing.T) { + tests := []struct { + name string + files map[string]string + changes map[string][]byte + want map[string][]byte + }{ + { + name: "apply changes", + files: map[string]string{ + "main.tf": `variable "foo" {}`, + "variables.tf": `variable "bar" {}`, + }, + changes: map[string][]byte{ + "variables.tf": []byte(`variable "bar" { type = string }`), + }, + want: map[string][]byte{ + "main.tf": []byte(`variable "foo" {}`), + "variables.tf": []byte(`variable "bar" { type = string }`), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runner := TestRunner(t, test.files) + + diags := runner.ApplyChanges(test.changes) + if diags.HasErrors() { + t.Fatal(diags) + } + + if diff := cmp.Diff(test.want, runner.Sources()); diff != "" { + t.Fatal(diff) + } + if diff := cmp.Diff(test.changes, runner.changes); diff != "" { + t.Fatal(diff) + } + }) } }