Skip to content

Commit ec11eee

Browse files
authored
Introduce autofix (#1755)
1 parent 51776b5 commit ec11eee

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+2217
-147
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ Application Options:
141141
--minimum-failure-severity=[error|warning|notice] Sets minimum severity level for exiting with a non-zero error code
142142
--color Enable colorized output
143143
--no-color Disable colorized output
144+
--fix Fix issues automatically
144145
145146
Help Options:
146147
-h, --help Show this help message

cmd/inspect.go

+97-20
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package cmd
22

33
import (
44
"fmt"
5+
"io"
56
"os"
67
"path/filepath"
78
"strings"
89

10+
"github.com/hashicorp/go-version"
911
"github.com/hashicorp/hcl/v2"
1012
"github.com/spf13/afero"
1113
"github.com/terraform-linters/tflint-plugin-sdk/hclext"
@@ -27,6 +29,7 @@ func (cli *CLI) inspect(opts Options, args []string) int {
2729
}
2830

2931
issues := tflint.Issues{}
32+
changes := map[string][]byte{}
3033

3134
for _, wd := range workingDirs {
3235
err := cli.withinChangedDir(wd, func() error {
@@ -62,11 +65,16 @@ func (cli *CLI) inspect(opts Options, args []string) int {
6265
for i, file := range filterFiles {
6366
filterFiles[i] = filepath.Join(wd, file)
6467
}
65-
moduleIssues, err := cli.inspectModule(opts, targetDir, filterFiles)
68+
69+
moduleIssues, moduleChanges, err := cli.inspectModule(opts, targetDir, filterFiles)
6670
if err != nil {
6771
return err
6872
}
6973
issues = append(issues, moduleIssues...)
74+
for path, source := range moduleChanges {
75+
changes[path] = source
76+
}
77+
7078
return nil
7179
})
7280
if err != nil {
@@ -91,8 +99,16 @@ func (cli *CLI) inspect(opts Options, args []string) int {
9199
force = cli.config.Force
92100
}
93101

102+
cli.formatter.Fix = opts.Fix
94103
cli.formatter.Print(issues, nil, cli.sources)
95104

105+
if opts.Fix {
106+
if err := writeChanges(changes); err != nil {
107+
cli.formatter.Print(tflint.Issues{}, err, cli.sources)
108+
return ExitCodeError
109+
}
110+
}
111+
96112
if len(issues) > 0 && !force && exceedsMinimumFailure(issues, opts.MinimumFailureSeverity) {
97113
return ExitCodeIssuesFound
98114
}
@@ -143,75 +159,113 @@ func processArgs(args []string) (string, []string, error) {
143159
return dir, filterFiles, nil
144160
}
145161

146-
func (cli *CLI) inspectModule(opts Options, dir string, filterFiles []string) (tflint.Issues, error) {
162+
func (cli *CLI) inspectModule(opts Options, dir string, filterFiles []string) (tflint.Issues, map[string][]byte, error) {
147163
issues := tflint.Issues{}
164+
changes := map[string][]byte{}
148165
var err error
149166

150167
// Setup config
151168
cli.config, err = tflint.LoadConfig(afero.Afero{Fs: afero.NewOsFs()}, opts.Config)
152169
if err != nil {
153-
return tflint.Issues{}, fmt.Errorf("Failed to load TFLint config; %w", err)
170+
return issues, changes, fmt.Errorf("Failed to load TFLint config; %w", err)
154171
}
155172
cli.config.Merge(opts.toConfig())
156173

157174
// Setup loader
158175
cli.loader, err = terraform.NewLoader(afero.Afero{Fs: afero.NewOsFs()}, cli.originalWorkingDir)
159176
if err != nil {
160-
return tflint.Issues{}, fmt.Errorf("Failed to prepare loading; %w", err)
177+
return issues, changes, fmt.Errorf("Failed to prepare loading; %w", err)
161178
}
162179
if opts.Recursive && !cli.loader.IsConfigDir(dir) {
163180
// Ignore non-module directories in recursive mode
164-
return tflint.Issues{}, nil
181+
return issues, changes, nil
165182
}
166183

167184
// Setup runners
168185
runners, err := cli.setupRunners(opts, dir)
169186
if err != nil {
170-
return tflint.Issues{}, err
187+
return issues, changes, err
171188
}
172189
rootRunner := runners[len(runners)-1]
173190

174191
// Launch plugin processes
175-
rulesetPlugin, err := launchPlugins(cli.config)
192+
rulesetPlugin, err := launchPlugins(cli.config, opts.Fix)
176193
if rulesetPlugin != nil {
177194
defer rulesetPlugin.Clean()
178195
}
179196
if err != nil {
180-
return tflint.Issues{}, err
197+
return issues, changes, err
181198
}
182199

183-
// Run inspection
200+
// Check preconditions
201+
sdkVersions := map[string]*version.Version{}
184202
for name, ruleset := range rulesetPlugin.RuleSets {
185203
sdkVersion, err := ruleset.SDKVersion()
186204
if err != nil {
187205
if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented {
188206
// SDKVersion endpoint is available in tflint-plugin-sdk v0.14+.
189-
return tflint.Issues{}, fmt.Errorf(`Plugin "%s" SDK version is incompatible. Compatible versions: %s`, name, plugin.SDKVersionConstraints)
207+
return issues, changes, fmt.Errorf(`Plugin "%s" SDK version is incompatible. Compatible versions: %s`, name, plugin.SDKVersionConstraints)
190208
} else {
191-
return tflint.Issues{}, fmt.Errorf(`Failed to get plugin "%s" SDK version; %w`, name, err)
209+
return issues, changes, fmt.Errorf(`Failed to get plugin "%s" SDK version; %w`, name, err)
192210
}
193211
}
194212
if !plugin.SDKVersionConstraints.Check(sdkVersion) {
195-
return tflint.Issues{}, fmt.Errorf(`Plugin "%s" SDK version (%s) is incompatible. Compatible versions: %s`, name, sdkVersion, plugin.SDKVersionConstraints)
213+
return issues, changes, fmt.Errorf(`Plugin "%s" SDK version (%s) is incompatible. Compatible versions: %s`, name, sdkVersion, plugin.SDKVersionConstraints)
214+
}
215+
sdkVersions[name] = sdkVersion
216+
}
217+
218+
// Run inspection
219+
//
220+
// Repeat an inspection until there are no more changes or the limit is reached,
221+
// in case an autofix introduces new issues.
222+
for loop := 1; ; loop++ {
223+
if loop > 10 {
224+
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:
225+
226+
1. The autofix is making changes that do not fix the issue.
227+
2. The autofix is continuing to introduce new issues.
228+
229+
By setting TFLINT_LOG=trace, you can confirm the changes made by the autofix and start troubleshooting.`)
230+
}
231+
232+
for name, ruleset := range rulesetPlugin.RuleSets {
233+
for _, runner := range runners {
234+
err = ruleset.Check(plugin.NewGRPCServer(runner, rootRunner, cli.loader.Files(), sdkVersions[name]))
235+
if err != nil {
236+
return issues, changes, fmt.Errorf("Failed to check ruleset; %w", err)
237+
}
238+
}
196239
}
197240

241+
changesInAttempt := map[string][]byte{}
198242
for _, runner := range runners {
199-
err = ruleset.Check(plugin.NewGRPCServer(runner, rootRunner, cli.loader.Files(), sdkVersion))
200-
if err != nil {
201-
return tflint.Issues{}, fmt.Errorf("Failed to check ruleset; %w", err)
243+
for _, issue := range runner.LookupIssues(filterFiles...) {
244+
// On the second attempt, only fixable issues are appended to avoid duplicates.
245+
if loop == 1 || issue.Fixable {
246+
issues = append(issues, issue)
247+
}
248+
}
249+
runner.Issues = tflint.Issues{}
250+
251+
for path, source := range runner.LookupChanges(filterFiles...) {
252+
changesInAttempt[path] = source
253+
changes[path] = source
202254
}
255+
runner.ClearChanges()
203256
}
204-
}
205257

206-
for _, runner := range runners {
207-
issues = append(issues, runner.LookupIssues(filterFiles...)...)
258+
if !opts.Fix || len(changesInAttempt) == 0 {
259+
break
260+
}
208261
}
262+
209263
// Set module sources to CLI
210264
for path, source := range cli.loader.Sources() {
211265
cli.sources[path] = source
212266
}
213267

214-
return issues, nil
268+
return issues, changes, nil
215269
}
216270

217271
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)
260314
return append(runners, runner), nil
261315
}
262316

263-
func launchPlugins(config *tflint.Config) (*plugin.Plugin, error) {
317+
func launchPlugins(config *tflint.Config, fix bool) (*plugin.Plugin, error) {
264318
// Lookup plugins
265319
rulesetPlugin, err := plugin.Discovery(config)
266320
if err != nil {
@@ -269,6 +323,7 @@ func launchPlugins(config *tflint.Config) (*plugin.Plugin, error) {
269323

270324
rulesets := []tflint.RuleSet{}
271325
pluginConf := config.ToPluginConfig()
326+
pluginConf.Fix = fix
272327

273328
// Check version constraints and apply a config to plugins
274329
for name, ruleset := range rulesetPlugin.RuleSets {
@@ -316,6 +371,28 @@ func launchPlugins(config *tflint.Config) (*plugin.Plugin, error) {
316371
return rulesetPlugin, nil
317372
}
318373

374+
func writeChanges(changes map[string][]byte) error {
375+
fs := afero.NewOsFs()
376+
for path, source := range changes {
377+
f, err := fs.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644)
378+
if err != nil {
379+
return fmt.Errorf("Failed to apply autofixes; failed to open %s: %w", path, err)
380+
}
381+
382+
n, err := f.Write(source)
383+
if err == nil && n < len(source) {
384+
err = io.ErrShortWrite
385+
}
386+
if err1 := f.Close(); err == nil {
387+
err = err1
388+
}
389+
if err != nil {
390+
return fmt.Errorf("Failed to apply autofixes; failed to write source code to %s: %w", path, err)
391+
}
392+
}
393+
return nil
394+
}
395+
319396
// Checks if the given issues contain severities above or equal to the given minimum failure opt. Defaults to true if an error occurs
320397
func exceedsMinimumFailure(issues tflint.Issues, minimumFailureOpt string) bool {
321398
if minimumFailureOpt != "" {

cmd/option.go

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type Options struct {
3030
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"`
3131
Color bool `long:"color" description:"Enable colorized output"`
3232
NoColor bool `long:"no-color" description:"Disable colorized output"`
33+
Fix bool `long:"fix" description:"Fix issues automatically"`
3334
ActAsBundledPlugin bool `long:"act-as-bundled-plugin" hidden:"true"`
3435
}
3536

docs/user-guide/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This guide describes the various features of TFLint for end users.
1010
- [Switching working directory](working-directory.md)
1111
- [Module Inspection](module-inspection.md)
1212
- [Annotations](annotations.md)
13+
- [Autofix](autofix.md)
1314
- [Compatibility with Terraform](compatibility.md)
1415
- [Environment Variables](./environment_variables.md)
1516
- [Editor Integration](editor-integration.md)

docs/user-guide/autofix.md

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Autofix
2+
3+
Some issues reported by TFLint can be auto-fixable. Auto-fixable issues are marked as "Fixable" as follows:
4+
5+
```console
6+
$ tflint
7+
1 issue(s) found:
8+
9+
Warning: [Fixable] Single line comments should begin with # (terraform_comment_syntax)
10+
11+
on main.tf line 1:
12+
1: // locals values
13+
2: locals {
14+
15+
```
16+
17+
When run with the `--fix` option, TFLint will fix issues automatically.
18+
19+
```console
20+
$ tflint --fix
21+
1 issue(s) found:
22+
23+
Warning: [Fixed] Single line comments should begin with # (terraform_comment_syntax)
24+
25+
on main.tf line 1:
26+
1: // locals values
27+
2: locals {
28+
29+
```
30+
31+
Please note that not all issues are fixable. The rule must support autofix.
32+
33+
If autofix is applied, it will automatically format the entire file. As a result, unrelated ranges may change.

formatter/formatter.go

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type Formatter struct {
1414
Stdout io.Writer
1515
Stderr io.Writer
1616
Format string
17+
Fix bool
1718
NoColor bool
1819
}
1920

formatter/pretty.go

+16-2
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,28 @@ func (f *Formatter) prettyPrint(issues tflint.Issues, err error, sources map[str
3535
}
3636

3737
func (f *Formatter) prettyPrintIssueWithSource(issue *tflint.Issue, sources map[string][]byte) {
38+
message := issue.Message
39+
if issue.Fixable {
40+
if f.Fix {
41+
message = "[Fixed] " + message
42+
} else {
43+
message = "[Fixable] " + message
44+
}
45+
}
46+
3847
fmt.Fprintf(
3948
f.Stdout,
4049
"%s: %s (%s)\n\n",
41-
colorSeverity(issue.Rule.Severity()), colorBold(issue.Message), issue.Rule.Name(),
50+
colorSeverity(issue.Rule.Severity()), colorBold(message), issue.Rule.Name(),
4251
)
4352
fmt.Fprintf(f.Stdout, " on %s line %d:\n", issue.Range.Filename, issue.Range.Start.Line)
4453

45-
src := sources[issue.Range.Filename]
54+
var src []byte
55+
if issue.Source != nil {
56+
src = issue.Source
57+
} else {
58+
src = sources[issue.Range.Filename]
59+
}
4660

4761
if src == nil {
4862
fmt.Fprintf(f.Stdout, " (source code not available)\n")

0 commit comments

Comments
 (0)