diff --git a/docs/user-guide/annotations.md b/docs/user-guide/annotations.md index 5f99fa4d2..8a8052bbf 100644 --- a/docs/user-guide/annotations.md +++ b/docs/user-guide/annotations.md @@ -54,3 +54,28 @@ resource "aws_instance" "foo" { instance_type = "t10.2xlarge" } ``` + +To disable an entire file, you can also use the `tflint-ignore-file` annotation: + +```hcl +# tflint-ignore-file: aws_instance_invalid_type + +resource "aws_instance" "foo" { + instance_type = "t1.2xlarge" +} +``` + +This annotation is valid only at the top of the file. The following cannot be used and will result in an error: + +```hcl +resource "aws_instance" "foo" { + # tflint-ignore-file: aws_instance_invalid_type + instance_type = "t1.2xlarge" +} +``` + +```hcl +resource "aws_instance" "foo" { # tflint-ignore-file: aws_instance_invalid_type + instance_type = "t1.2xlarge" +} +``` diff --git a/tflint/annotation.go b/tflint/annotation.go index 963d31283..3d95d388e 100644 --- a/tflint/annotation.go +++ b/tflint/annotation.go @@ -10,15 +10,13 @@ import ( "github.com/hashicorp/hcl/v2/hclsyntax" ) -var annotationPattern = regexp.MustCompile(`tflint-ignore: ([^\n*/#]+)`) - // Annotation represents comments with special meaning in TFLint -type Annotation struct { - Content string - Token hclsyntax.Token +type Annotation interface { + IsAffected(*Issue) bool + String() string } -// Annotations is slice of Annotation +// Annotations is a slice of Annotation type Annotations []Annotation // NewAnnotations find annotations from the passed tokens and return that list. @@ -35,21 +33,49 @@ func NewAnnotations(path string, file *hcl.File) (Annotations, hcl.Diagnostics) continue } - match := annotationPattern.FindStringSubmatch(string(token.Bytes)) - if len(match) != 2 { + // tflint-ignore annotation + match := lineAnnotationPattern.FindStringSubmatch(string(token.Bytes)) + if len(match) == 2 { + ret = append(ret, &LineAnnotation{ + Content: strings.TrimSpace(match[1]), + Token: token, + }) + continue + } + + // tflint-ignore-file annotation + match = fileAnnotationPattern.FindStringSubmatch(string(token.Bytes)) + if len(match) == 2 { + if !(token.Range.Start.Line == 1 && token.Range.Start.Column == 1) { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "tflint-ignore-file annotation must be written at the top of file", + Detail: fmt.Sprintf("tflint-ignore-file annotation is written at line %d, column %d", token.Range.Start.Line, token.Range.Start.Column), + Subject: token.Range.Ptr(), + }) + continue + } + ret = append(ret, &FileAnnotation{ + Content: strings.TrimSpace(match[1]), + Token: token, + }) continue } - ret = append(ret, Annotation{ - Content: strings.TrimSpace(match[1]), - Token: token, - }) } return ret, diags } +var lineAnnotationPattern = regexp.MustCompile(`tflint-ignore: ([^\n*/#]+)`) + +// LineAnnotation is an annotation for ignoring issues in a line +type LineAnnotation struct { + Content string + Token hclsyntax.Token +} + // IsAffected checks if the passed issue is affected with the annotation -func (a *Annotation) IsAffected(issue *Issue) bool { +func (a *LineAnnotation) IsAffected(issue *Issue) bool { if a.Token.Range.Filename != issue.Range.Filename { return false } @@ -71,6 +97,36 @@ func (a *Annotation) IsAffected(issue *Issue) bool { } // String returns the string representation of the annotation -func (a *Annotation) String() string { - return fmt.Sprintf("annotation:%s (%s)", a.Content, a.Token.Range.String()) +func (a *LineAnnotation) String() string { + return fmt.Sprintf("tflint-ignore: %s (%s)", a.Content, a.Token.Range.String()) +} + +var fileAnnotationPattern = regexp.MustCompile(`tflint-ignore-file: ([^\n*/#]+)`) + +// FileAnnotation is an annotation for ignoring issues in a file +type FileAnnotation struct { + Content string + Token hclsyntax.Token +} + +// IsAffected checks if the passed issue is affected with the annotation +func (a *FileAnnotation) IsAffected(issue *Issue) bool { + if a.Token.Range.Filename != issue.Range.Filename { + return false + } + + rules := strings.Split(a.Content, ",") + for i, rule := range rules { + rules[i] = strings.TrimSpace(rule) + } + + if slices.Contains(rules, issue.Rule.Name()) || slices.Contains(rules, "all") { + return true + } + return false +} + +// String returns the string representation of the annotation +func (a *FileAnnotation) String() string { + return fmt.Sprintf("tflint-ignore-file: %s (%s)", a.Content, a.Token.Range.String()) } diff --git a/tflint/annotation_test.go b/tflint/annotation_test.go index 1d6522184..da65a5cf3 100644 --- a/tflint/annotation_test.go +++ b/tflint/annotation_test.go @@ -10,97 +10,206 @@ import ( ) func Test_NewAnnotations(t *testing.T) { - src := ` + tests := []struct { + name string + src string + want Annotations + diags string + }{ + { + name: "annotation starting with #", + src: ` resource "aws_instance" "foo" { - /* tflint-ignore: aws_instance_invalid_type, terraform_deprecated_syntax */ - instance_type = "t2.micro" // tflint-ignore: aws_instance_invalid_type # tflint-ignore: aws_instance_invalid_type - iam_instance_profile = "foo" # This is also comment + instance_type = "t2.micro" # This is also comment +}`, + want: Annotations{ + &LineAnnotation{ + Content: "aws_instance_invalid_type", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Bytes: []byte("# tflint-ignore: aws_instance_invalid_type\n"), + Range: hcl.Range{ + Filename: "resource.tf", + Start: hcl.Pos{Line: 3, Column: 3}, + End: hcl.Pos{Line: 4, Column: 1}, + }, + }, + }, + }, + }, + { + name: "annotation starting with //", + src: ` +resource "aws_instance" "foo" { // This is also comment - instance_type_reason = "t2.micro" // tflint-ignore: aws_instance_invalid_type // With reason - # tflint-ignore: aws_instance_invalid_type # With reason - iam_instance_profile_reason = "foo" # This is also comment -}` - - file, diags := hclsyntax.ParseConfig([]byte(src), "resource.tf", hcl.Pos{Byte: 0, Line: 1, Column: 1}) - if diags.HasErrors() { - t.Fatal(diags) - } - ret, diags := NewAnnotations("resource.tf", file) - if diags.HasErrors() { - t.Fatal(diags) - } - - expected := Annotations{ + instance_type = "t2.micro" // tflint-ignore: aws_instance_invalid_type +}`, + want: Annotations{ + &LineAnnotation{ + Content: "aws_instance_invalid_type", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Bytes: []byte("// tflint-ignore: aws_instance_invalid_type\n"), + Range: hcl.Range{ + Filename: "resource.tf", + Start: hcl.Pos{Line: 4, Column: 30}, + End: hcl.Pos{Line: 5, Column: 1}, + }, + }, + }, + }, + }, { - Content: "aws_instance_invalid_type, terraform_deprecated_syntax", - Token: hclsyntax.Token{ - Type: hclsyntax.TokenComment, - Bytes: []byte("/* tflint-ignore: aws_instance_invalid_type, terraform_deprecated_syntax */"), - Range: hcl.Range{ - Filename: "resource.tf", - Start: hcl.Pos{Line: 3, Column: 3}, - End: hcl.Pos{Line: 3, Column: 78}, + name: "annotation starting with /*", + src: ` +resource "aws_instance" "foo" { + /* tflint-ignore: aws_instance_invalid_type */ + instance_type = "t2.micro" /* This is also comment */ +}`, + want: Annotations{ + &LineAnnotation{ + Content: "aws_instance_invalid_type", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Bytes: []byte("/* tflint-ignore: aws_instance_invalid_type */"), + Range: hcl.Range{ + Filename: "resource.tf", + Start: hcl.Pos{Line: 3, Column: 3}, + End: hcl.Pos{Line: 3, Column: 49}, + }, + }, }, }, }, { - Content: "aws_instance_invalid_type", - Token: hclsyntax.Token{ - Type: hclsyntax.TokenComment, - Bytes: []byte("// tflint-ignore: aws_instance_invalid_type\n"), - Range: hcl.Range{ - Filename: "resource.tf", - Start: hcl.Pos{Line: 4, Column: 30}, - End: hcl.Pos{Line: 5, Column: 1}, + name: "ignoring multiple rules", + src: ` +resource "aws_instance" "foo" { + /* tflint-ignore: aws_instance_invalid_type, terraform_deprecated_syntax */ + instance_type = "t2.micro" +}`, + want: Annotations{ + &LineAnnotation{ + Content: "aws_instance_invalid_type, terraform_deprecated_syntax", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Bytes: []byte("/* tflint-ignore: aws_instance_invalid_type, terraform_deprecated_syntax */"), + Range: hcl.Range{ + Filename: "resource.tf", + Start: hcl.Pos{Line: 3, Column: 3}, + End: hcl.Pos{Line: 3, Column: 78}, + }, + }, }, }, }, { - Content: "aws_instance_invalid_type", - Token: hclsyntax.Token{ - Type: hclsyntax.TokenComment, - Bytes: []byte("# tflint-ignore: aws_instance_invalid_type\n"), - Range: hcl.Range{ - Filename: "resource.tf", - Start: hcl.Pos{Line: 5, Column: 3}, - End: hcl.Pos{Line: 6, Column: 1}, + name: "with reason starting with //", + src: ` +resource "aws_instance" "foo" { + instance_type = "t2.micro" // tflint-ignore: aws_instance_invalid_type // With reason +}`, + want: Annotations{ + &LineAnnotation{ + Content: "aws_instance_invalid_type", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Bytes: []byte("// tflint-ignore: aws_instance_invalid_type // With reason\n"), + Range: hcl.Range{ + Filename: "resource.tf", + Start: hcl.Pos{Line: 3, Column: 30}, + End: hcl.Pos{Line: 4, Column: 1}, + }, + }, }, }, }, { - Content: "aws_instance_invalid_type", - Token: hclsyntax.Token{ - Type: hclsyntax.TokenComment, - Bytes: []byte("// tflint-ignore: aws_instance_invalid_type // With reason\n"), - Range: hcl.Range{ - Filename: "resource.tf", - Start: hcl.Pos{Line: 8, Column: 37}, - End: hcl.Pos{Line: 9, Column: 1}, + name: "with reason starting with #", + src: ` +resource "aws_instance" "foo" { + # tflint-ignore: aws_instance_invalid_type # With reason + instance_type = "t2.micro" +}`, + want: Annotations{ + &LineAnnotation{ + Content: "aws_instance_invalid_type", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Bytes: []byte("# tflint-ignore: aws_instance_invalid_type # With reason\n"), + Range: hcl.Range{ + Filename: "resource.tf", + Start: hcl.Pos{Line: 3, Column: 3}, + End: hcl.Pos{Line: 4, Column: 1}, + }, + }, }, }, }, { - Content: "aws_instance_invalid_type", - Token: hclsyntax.Token{ - Type: hclsyntax.TokenComment, - Bytes: []byte("# tflint-ignore: aws_instance_invalid_type # With reason\n"), - Range: hcl.Range{ - Filename: "resource.tf", - Start: hcl.Pos{Line: 9, Column: 3}, - End: hcl.Pos{Line: 10, Column: 1}, + name: "tflint-ignore-file annotation", + src: `# tflint-ignore-file: aws_instance_invalid_type +resource "aws_instance" "foo" { + instance_type = "t2.micro" +}`, + want: Annotations{ + &FileAnnotation{ + Content: "aws_instance_invalid_type", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Bytes: []byte("# tflint-ignore-file: aws_instance_invalid_type\n"), + Range: hcl.Range{ + Filename: "resource.tf", + Start: hcl.Pos{Line: 1, Column: 1}, + End: hcl.Pos{Line: 2, Column: 1}, + }, + }, }, }, }, + { + name: "tflint-ignore-file annotation outside the first line", + src: ` +resource "aws_instance" "foo" { + # tflint-ignore-file: aws_instance_invalid_type + instance_type = "t2.micro" +}`, + want: Annotations{}, + diags: "resource.tf:3,3-4,1: tflint-ignore-file annotation must be written at the top of file; tflint-ignore-file annotation is written at line 3, column 3", + }, + { + name: "tflint-ignore-file annotation outside the first column", + src: `resource "aws_instance" "foo" { # tflint-ignore-file: aws_instance_invalid_type + instance_type = "t2.micro" +}`, + want: Annotations{}, + diags: "resource.tf:1,33-2,1: tflint-ignore-file annotation must be written at the top of file; tflint-ignore-file annotation is written at line 1, column 33", + }, } - opts := cmpopts.IgnoreFields(hcl.Pos{}, "Byte") - if !cmp.Equal(expected, ret, opts) { - t.Fatalf("Test failed. Diff: %s", cmp.Diff(expected, ret, opts)) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + file, diags := hclsyntax.ParseConfig([]byte(test.src), "resource.tf", hcl.InitialPos) + if diags.HasErrors() { + t.Fatal(diags) + } + got, diags := NewAnnotations("resource.tf", file) + if diags.HasErrors() || test.diags != "" { + if diags.Error() != test.diags { + t.Errorf("want=%s, got=%s", test.diags, diags.Error()) + } + } + + opts := cmpopts.IgnoreFields(hcl.Pos{}, "Byte") + if diff := cmp.Diff(test.want, got, opts); diff != "" { + t.Errorf(diff) + } + }) } } -func Test_IsAffected(t *testing.T) { +func TestLineAnnotation_IsAffected(t *testing.T) { issue := &Issue{ Rule: &testRule{}, Message: "Test rule", @@ -112,12 +221,12 @@ func Test_IsAffected(t *testing.T) { tests := []struct { Name string - Annotation Annotation + Annotation *LineAnnotation Expected bool }{ { Name: "affected (same line)", - Annotation: Annotation{ + Annotation: &LineAnnotation{ Content: "test_rule", Token: hclsyntax.Token{ Type: hclsyntax.TokenComment, @@ -131,7 +240,7 @@ func Test_IsAffected(t *testing.T) { }, { Name: "affected (above line)", - Annotation: Annotation{ + Annotation: &LineAnnotation{ Content: "test_rule", Token: hclsyntax.Token{ Type: hclsyntax.TokenComment, @@ -145,7 +254,7 @@ func Test_IsAffected(t *testing.T) { }, { Name: "affected (multiple rules)", - Annotation: Annotation{ + Annotation: &LineAnnotation{ Content: "other_rule, test_rule", Token: hclsyntax.Token{ Type: hclsyntax.TokenComment, @@ -159,7 +268,7 @@ func Test_IsAffected(t *testing.T) { }, { Name: "not affected (multiple rules)", - Annotation: Annotation{ + Annotation: &LineAnnotation{ Content: "other_rule_a, other_rule_b", Token: hclsyntax.Token{ Type: hclsyntax.TokenComment, @@ -173,7 +282,7 @@ func Test_IsAffected(t *testing.T) { }, { Name: "not affected (under line)", - Annotation: Annotation{ + Annotation: &LineAnnotation{ Content: "test_rule", Token: hclsyntax.Token{ Type: hclsyntax.TokenComment, @@ -187,7 +296,7 @@ func Test_IsAffected(t *testing.T) { }, { Name: "not affected (another filename)", - Annotation: Annotation{ + Annotation: &LineAnnotation{ Content: "test_rule", Token: hclsyntax.Token{ Type: hclsyntax.TokenComment, @@ -201,7 +310,7 @@ func Test_IsAffected(t *testing.T) { }, { Name: "not affected (another rule)", - Annotation: Annotation{ + Annotation: &LineAnnotation{ Content: "test_another_rule", Token: hclsyntax.Token{ Type: hclsyntax.TokenComment, @@ -215,7 +324,7 @@ func Test_IsAffected(t *testing.T) { }, { Name: "affected (all)", - Annotation: Annotation{ + Annotation: &LineAnnotation{ Content: "all", Token: hclsyntax.Token{ Type: hclsyntax.TokenComment, @@ -238,3 +347,108 @@ func Test_IsAffected(t *testing.T) { }) } } + +func TestFileAnnotation_IsAffected(t *testing.T) { + issue := &Issue{ + Rule: &testRule{}, + Message: "Test rule", + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2}, + }, + } + + tests := []struct { + Name string + Annotation *FileAnnotation + Expected bool + }{ + { + Name: "affected", + Annotation: &FileAnnotation{ + Content: "test_rule", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Range: hcl.Range{ + Filename: "test.tf", + }, + }, + }, + Expected: true, + }, + { + Name: "not affected (another filename)", + Annotation: &FileAnnotation{ + Content: "test_rule", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Range: hcl.Range{ + Filename: "test2.tf", + }, + }, + }, + Expected: false, + }, + { + Name: "affected (multiple rules)", + Annotation: &FileAnnotation{ + Content: "other_rule, test_rule", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Range: hcl.Range{ + Filename: "test.tf", + }, + }, + }, + Expected: true, + }, + { + Name: "not affected (multiple rules)", + Annotation: &FileAnnotation{ + Content: "other_rule_a, other_rule_b", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Range: hcl.Range{ + Filename: "test.tf", + }, + }, + }, + Expected: false, + }, + { + Name: "not affected (another rule)", + Annotation: &FileAnnotation{ + Content: "test_another_rule", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Range: hcl.Range{ + Filename: "test.tf", + }, + }, + }, + Expected: false, + }, + { + Name: "affected (all)", + Annotation: &FileAnnotation{ + Content: "all", + Token: hclsyntax.Token{ + Type: hclsyntax.TokenComment, + Range: hcl.Range{ + Filename: "test.tf", + }, + }, + }, + Expected: true, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + got := test.Annotation.IsAffected(issue) + if got != test.Expected { + t.Fatalf("want=%t, got=%t", test.Expected, got) + } + }) + } +} diff --git a/tflint/runner_test.go b/tflint/runner_test.go index 16798a0f0..3159624e3 100644 --- a/tflint/runner_test.go +++ b/tflint/runner_test.go @@ -530,7 +530,7 @@ func Test_EmitIssue(t *testing.T) { }, Annotations: map[string]Annotations{ "test.tf": { - { + &LineAnnotation{ Content: "test_rule", Token: hclsyntax.Token{ Type: hclsyntax.TokenComment, @@ -653,7 +653,7 @@ func Test_EmitIssue(t *testing.T) { }, Annotations: map[string]Annotations{ "module.tf": { - { + &LineAnnotation{ Content: "test_rule", Token: hclsyntax.Token{ Type: hclsyntax.TokenComment,