Skip to content

Commit b875e92

Browse files
authored
Introduce autofix API (#254)
1 parent 30d991e commit b875e92

22 files changed

+4465
-484
lines changed

helper/runner.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/hashicorp/hcl/v2"
1010
"github.com/hashicorp/hcl/v2/hclsyntax"
1111
"github.com/terraform-linters/tflint-plugin-sdk/hclext"
12+
"github.com/terraform-linters/tflint-plugin-sdk/internal"
1213
"github.com/terraform-linters/tflint-plugin-sdk/terraform/addrs"
1314
"github.com/terraform-linters/tflint-plugin-sdk/tflint"
1415
"github.com/zclconf/go-cty/cty"
@@ -21,8 +22,10 @@ type Runner struct {
2122
Issues Issues
2223

2324
files map[string]*hcl.File
25+
sources map[string][]byte
2426
config Config
2527
variables map[string]*Variable
28+
fixer *internal.Fixer
2629
}
2730

2831
// Variable is an implementation of variables in Terraform language
@@ -318,6 +321,25 @@ func (r *Runner) EmitIssue(rule tflint.Rule, message string, location hcl.Range)
318321
return nil
319322
}
320323

324+
// EmitIssueWithFix adds an issue and invoke fix.
325+
func (r *Runner) EmitIssueWithFix(rule tflint.Rule, message string, location hcl.Range, fixFunc func(f tflint.Fixer) error) error {
326+
r.fixer.StashChanges()
327+
if err := fixFunc(r.fixer); err != nil {
328+
if errors.Is(err, tflint.ErrFixNotSupported) {
329+
r.fixer.PopChangesFromStash()
330+
return r.EmitIssue(rule, message, location)
331+
}
332+
return err
333+
}
334+
return r.EmitIssue(rule, message, location)
335+
}
336+
337+
// Changes returns formatted changes by the fixer.
338+
func (r *Runner) Changes() map[string][]byte {
339+
r.fixer.FormatChanges()
340+
return r.fixer.Changes()
341+
}
342+
321343
// EnsureNoError is a method that simply runs a function if there is no error.
322344
//
323345
// Deprecated: Use EvaluateExpr with a function callback. e.g. EvaluateExpr(expr, func (val T) error {}, ...)
@@ -331,7 +353,12 @@ func (r *Runner) EnsureNoError(err error, proc func() error) error {
331353
// NewLocalRunner initialises a new test runner.
332354
// Internal use only.
333355
func NewLocalRunner(files map[string]*hcl.File, issues Issues) *Runner {
334-
return &Runner{files: map[string]*hcl.File{}, variables: map[string]*Variable{}, Issues: issues}
356+
return &Runner{
357+
files: map[string]*hcl.File{},
358+
sources: map[string][]byte{},
359+
variables: map[string]*Variable{},
360+
Issues: issues,
361+
}
335362
}
336363

337364
// AddLocalFile adds a new file to the current mapped files.
@@ -342,6 +369,7 @@ func (r *Runner) AddLocalFile(name string, file *hcl.File) bool {
342369
}
343370

344371
r.files[name] = file
372+
r.sources[name] = file.Bytes
345373
return true
346374
}
347375

@@ -365,6 +393,7 @@ func (r *Runner) initFromFiles() error {
365393
}
366394
}
367395
}
396+
r.fixer = internal.NewFixer(r.sources)
368397

369398
return nil
370399
}

helper/runner_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package helper
22

33
import (
4+
"errors"
45
"testing"
56

67
"github.com/google/go-cmp/cmp"
@@ -644,6 +645,187 @@ resource "aws_instance" "foo" {
644645
}
645646
}
646647

648+
func Test_EmitIssueWithFix(t *testing.T) {
649+
// default error check helper
650+
neverHappend := func(err error) bool { return err != nil }
651+
652+
tests := []struct {
653+
name string
654+
src string
655+
rng hcl.Range
656+
fix func(tflint.Fixer) error
657+
want Issues
658+
fixed string
659+
errCheck func(error) bool
660+
}{
661+
{
662+
name: "with fix",
663+
src: `
664+
resource "aws_instance" "foo" {
665+
instance_type = "t2.micro"
666+
}`,
667+
rng: hcl.Range{
668+
Filename: "main.tf",
669+
Start: hcl.Pos{Line: 3, Column: 19, Byte: 51},
670+
End: hcl.Pos{Line: 3, Column: 29, Byte: 61},
671+
},
672+
fix: func(fixer tflint.Fixer) error {
673+
return fixer.ReplaceText(
674+
hcl.Range{
675+
Filename: "main.tf",
676+
Start: hcl.Pos{Line: 3, Column: 19, Byte: 51},
677+
End: hcl.Pos{Line: 3, Column: 29, Byte: 61},
678+
},
679+
`"t3.micro"`,
680+
)
681+
},
682+
want: Issues{
683+
{
684+
Rule: &dummyRule{},
685+
Message: "issue found",
686+
Range: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 3, Column: 19}, End: hcl.Pos{Line: 3, Column: 29}},
687+
},
688+
},
689+
fixed: `
690+
resource "aws_instance" "foo" {
691+
instance_type = "t3.micro"
692+
}`,
693+
errCheck: neverHappend,
694+
},
695+
{
696+
name: "autofix is not supported",
697+
src: `
698+
resource "aws_instance" "foo" {
699+
instance_type = "t2.micro"
700+
}`,
701+
rng: hcl.Range{
702+
Filename: "main.tf",
703+
Start: hcl.Pos{Line: 3, Column: 19, Byte: 51},
704+
End: hcl.Pos{Line: 3, Column: 29, Byte: 61},
705+
},
706+
fix: func(fixer tflint.Fixer) error {
707+
if err := fixer.ReplaceText(
708+
hcl.Range{
709+
Filename: "main.tf",
710+
Start: hcl.Pos{Line: 3, Column: 19, Byte: 51},
711+
End: hcl.Pos{Line: 3, Column: 29, Byte: 61},
712+
},
713+
`"t3.micro"`,
714+
); err != nil {
715+
return err
716+
}
717+
return tflint.ErrFixNotSupported
718+
},
719+
want: Issues{
720+
{
721+
Rule: &dummyRule{},
722+
Message: "issue found",
723+
Range: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 3, Column: 19}, End: hcl.Pos{Line: 3, Column: 29}},
724+
},
725+
},
726+
errCheck: neverHappend,
727+
},
728+
{
729+
name: "other errors",
730+
src: `
731+
resource "aws_instance" "foo" {
732+
instance_type = "t2.micro"
733+
}`,
734+
rng: hcl.Range{
735+
Filename: "main.tf",
736+
Start: hcl.Pos{Line: 3, Column: 19, Byte: 51},
737+
End: hcl.Pos{Line: 3, Column: 29, Byte: 61},
738+
},
739+
fix: func(fixer tflint.Fixer) error {
740+
if err := fixer.ReplaceText(
741+
hcl.Range{
742+
Filename: "main.tf",
743+
Start: hcl.Pos{Line: 3, Column: 19, Byte: 51},
744+
End: hcl.Pos{Line: 3, Column: 29, Byte: 61},
745+
},
746+
`"t3.micro"`,
747+
); err != nil {
748+
return err
749+
}
750+
return errors.New("unexpected error")
751+
},
752+
want: Issues{},
753+
fixed: `
754+
resource "aws_instance" "foo" {
755+
instance_type = "t3.micro"
756+
}`,
757+
errCheck: func(err error) bool {
758+
return err == nil && err.Error() != "unexpected error"
759+
},
760+
},
761+
}
762+
763+
for _, test := range tests {
764+
t.Run(test.name, func(t *testing.T) {
765+
runner := TestRunner(t, map[string]string{"main.tf": test.src})
766+
767+
err := runner.EmitIssueWithFix(&dummyRule{}, "issue found", test.rng, test.fix)
768+
if test.errCheck(err) {
769+
t.Fatal(err)
770+
}
771+
772+
opt := cmpopts.IgnoreFields(hcl.Pos{}, "Byte")
773+
if diff := cmp.Diff(test.want, runner.Issues, opt); diff != "" {
774+
t.Fatal(diff)
775+
}
776+
if diff := cmp.Diff(test.fixed, string(runner.Changes()["main.tf"]), opt); diff != "" {
777+
t.Fatal(diff)
778+
}
779+
})
780+
}
781+
}
782+
783+
func TestChanges(t *testing.T) {
784+
tests := []struct {
785+
name string
786+
src string
787+
fix func(tflint.Fixer) error
788+
want string
789+
}{
790+
{
791+
name: "changes",
792+
src: `
793+
locals {
794+
foo = "bar"
795+
}`,
796+
fix: func(fixer tflint.Fixer) error {
797+
return fixer.InsertTextBefore(
798+
hcl.Range{
799+
Filename: "main.tf",
800+
Start: hcl.Pos{Byte: 12},
801+
End: hcl.Pos{Byte: 15},
802+
},
803+
"bar = \"baz\"\n",
804+
)
805+
},
806+
want: `
807+
locals {
808+
bar = "baz"
809+
foo = "bar"
810+
}`,
811+
},
812+
}
813+
814+
for _, test := range tests {
815+
t.Run(test.name, func(t *testing.T) {
816+
runner := TestRunner(t, map[string]string{"main.tf": test.src})
817+
818+
if err := test.fix(runner.fixer); err != nil {
819+
t.Fatal(err)
820+
}
821+
822+
if diff := cmp.Diff(test.want, string(runner.Changes()["main.tf"])); diff != "" {
823+
t.Fatal(diff)
824+
}
825+
})
826+
}
827+
}
828+
647829
func Test_EnsureNoError(t *testing.T) {
648830
runner := TestRunner(t, map[string]string{})
649831

helper/testing.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import (
1717
// TestRunner returns a mock Runner for testing.
1818
// You can pass the map of file names and their contents in the second argument.
1919
func TestRunner(t *testing.T, files map[string]string) *Runner {
20+
t.Helper()
21+
2022
runner := NewLocalRunner(map[string]*hcl.File{}, Issues{})
2123
parser := hclparse.NewParser()
2224

@@ -50,25 +52,42 @@ func TestRunner(t *testing.T, files map[string]string) *Runner {
5052
}
5153

5254
// AssertIssues is an assertion helper for comparing issues.
53-
func AssertIssues(t *testing.T, expected Issues, actual Issues) {
55+
func AssertIssues(t *testing.T, want Issues, got Issues) {
56+
t.Helper()
57+
5458
opts := []cmp.Option{
5559
// Byte field will be ignored because it's not important in tests such as positions
5660
cmpopts.IgnoreFields(hcl.Pos{}, "Byte"),
5761
ruleComparer(),
5862
}
59-
if !cmp.Equal(expected, actual, opts...) {
60-
t.Fatalf("Expected issues are not matched:\n %s\n", cmp.Diff(expected, actual, opts...))
63+
if diff := cmp.Diff(want, got, opts...); diff != "" {
64+
t.Fatalf("Expected issues are not matched:\n %s\n", diff)
6165
}
6266
}
6367

6468
// AssertIssuesWithoutRange is an assertion helper for comparing issues except for range.
65-
func AssertIssuesWithoutRange(t *testing.T, expected Issues, actual Issues) {
69+
func AssertIssuesWithoutRange(t *testing.T, want Issues, got Issues) {
70+
t.Helper()
71+
6672
opts := []cmp.Option{
6773
cmpopts.IgnoreFields(Issue{}, "Range"),
6874
ruleComparer(),
6975
}
70-
if !cmp.Equal(expected, actual, opts...) {
71-
t.Fatalf("Expected issues are not matched:\n %s\n", cmp.Diff(expected, actual, opts...))
76+
if diff := cmp.Diff(want, got, opts...); diff != "" {
77+
t.Fatalf("Expected issues are not matched:\n %s\n", diff)
78+
}
79+
}
80+
81+
// AssertChanges is an assertion helper for comparing autofix changes.
82+
func AssertChanges(t *testing.T, want map[string]string, got map[string][]byte) {
83+
t.Helper()
84+
85+
sources := make(map[string]string)
86+
for name, src := range got {
87+
sources[name] = string(src)
88+
}
89+
if diff := cmp.Diff(want, sources); diff != "" {
90+
t.Fatalf("Expected changes are not matched:\n %s\n", diff)
7291
}
7392
}
7493

0 commit comments

Comments
 (0)