Skip to content

Commit 63d5d8a

Browse files
authored
Report structured errors in JUnit and CheckStyle XML formatters (#2407)
1 parent 38cab05 commit 63d5d8a

File tree

8 files changed

+331
-38
lines changed

8 files changed

+331
-38
lines changed

formatter/checkstyle.go

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ package formatter
33
import (
44
"encoding/xml"
55
"fmt"
6+
"slices"
7+
"strings"
68

9+
hcl "github.com/hashicorp/hcl/v2"
10+
sdk "github.com/terraform-linters/tflint-plugin-sdk/tflint"
711
"github.com/terraform-linters/tflint/tflint"
812
)
913

@@ -53,9 +57,38 @@ func (f *Formatter) checkstylePrint(issues tflint.Issues, appErr error, sources
5357
}
5458
}
5559

60+
for _, cherr := range f.checkstyleErrors(appErr) {
61+
filename := cherr.Source
62+
if filename == "" {
63+
filename = applicationErrorSource
64+
}
65+
if file, exists := files[filename]; exists {
66+
file.Errors = append(file.Errors, cherr)
67+
} else {
68+
files[filename] = &checkstyleFile{
69+
Name: filename,
70+
Errors: []*checkstyleError{cherr},
71+
}
72+
}
73+
}
74+
75+
filenames := make([]string, 0, len(files))
76+
for filename := range files {
77+
filenames = append(filenames, filename)
78+
}
79+
slices.SortFunc(filenames, func(a, b string) int {
80+
if a == applicationErrorSource {
81+
return -1
82+
}
83+
if b == applicationErrorSource {
84+
return 1
85+
}
86+
return strings.Compare(a, b)
87+
})
88+
5689
ret := &checkstyle{}
57-
for _, file := range files {
58-
ret.Files = append(ret.Files, file)
90+
for _, filename := range filenames {
91+
ret.Files = append(ret.Files, files[filename])
5992
}
6093

6194
out, err := xml.MarshalIndent(ret, "", " ")
@@ -64,8 +97,26 @@ func (f *Formatter) checkstylePrint(issues tflint.Issues, appErr error, sources
6497
}
6598
fmt.Fprint(f.Stdout, xml.Header)
6699
fmt.Fprint(f.Stdout, string(out))
100+
}
67101

68-
if appErr != nil {
69-
f.prettyPrintErrors(appErr, sources, false)
70-
}
102+
func (f *Formatter) checkstyleErrors(err error) []*checkstyleError {
103+
return mapErrors(err, errorMapper[*checkstyleError]{
104+
diagnostic: func(diag *hcl.Diagnostic) *checkstyleError {
105+
return &checkstyleError{
106+
Source: diag.Summary,
107+
Line: diag.Subject.Start.Line,
108+
Column: diag.Subject.Start.Column,
109+
Severity: fromHclSeverity(diag.Severity),
110+
Message: diag.Detail,
111+
}
112+
},
113+
error: func(err error) *checkstyleError {
114+
return &checkstyleError{
115+
Source: applicationErrorSource,
116+
Severity: toSeverity(sdk.ERROR),
117+
Message: err.Error(),
118+
Rule: applicationErrorSource,
119+
}
120+
},
121+
})
71122
}

formatter/checkstyle_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package formatter
22

33
import (
44
"bytes"
5+
"fmt"
56
"testing"
67

78
hcl "github.com/hashicorp/hcl/v2"
@@ -39,6 +40,41 @@ func Test_checkstylePrint(t *testing.T) {
3940
<file name="test.tf">
4041
<error source="test_rule" line="1" column="1" severity="error" message="test" link="https://github.com" rule="test_rule"></error>
4142
</file>
43+
</checkstyle>`,
44+
},
45+
{
46+
Name: "error only",
47+
Issues: tflint.Issues{},
48+
Error: fmt.Errorf("Failed to check ruleset"),
49+
Stdout: `<?xml version="1.0" encoding="UTF-8"?>
50+
<checkstyle>
51+
<file name="(application)">
52+
<error source="(application)" line="0" column="0" severity="error" message="Failed to check ruleset" link="" rule="(application)"></error>
53+
</file>
54+
</checkstyle>`,
55+
},
56+
{
57+
Name: "issues and errors",
58+
Issues: tflint.Issues{
59+
{
60+
Rule: &testRule{},
61+
Message: "test",
62+
Range: hcl.Range{
63+
Filename: "test.tf",
64+
Start: hcl.Pos{Line: 1, Column: 1, Byte: 0},
65+
End: hcl.Pos{Line: 1, Column: 4, Byte: 3},
66+
},
67+
},
68+
},
69+
Error: fmt.Errorf("Failed to check ruleset"),
70+
Stdout: `<?xml version="1.0" encoding="UTF-8"?>
71+
<checkstyle>
72+
<file name="(application)">
73+
<error source="(application)" line="0" column="0" severity="error" message="Failed to check ruleset" link="" rule="(application)"></error>
74+
</file>
75+
<file name="test.tf">
76+
<error source="test_rule" line="1" column="1" severity="error" message="test" link="https://github.com" rule="test_rule"></error>
77+
</file>
4278
</checkstyle>`,
4379
},
4480
}

formatter/errors.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package formatter
2+
3+
import (
4+
"errors"
5+
6+
hcl "github.com/hashicorp/hcl/v2"
7+
)
8+
9+
type errorMapper[T any] struct {
10+
diagnostic func(*hcl.Diagnostic) T
11+
error func(error) T
12+
}
13+
14+
func mapErrors[T any](err error, mapper errorMapper[T]) []T {
15+
if err == nil {
16+
return []T{}
17+
}
18+
19+
if errs, ok := err.(interface{ Unwrap() []error }); ok {
20+
var results []T
21+
for _, e := range errs.Unwrap() {
22+
results = append(results, mapErrors(e, mapper)...)
23+
}
24+
return results
25+
}
26+
27+
var diags hcl.Diagnostics
28+
if errors.As(err, &diags) {
29+
results := make([]T, len(diags))
30+
for i, diag := range diags {
31+
results[i] = mapper.diagnostic(diag)
32+
}
33+
return results
34+
}
35+
36+
return []T{mapper.error(err)}
37+
}

formatter/errors_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package formatter
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"testing"
7+
8+
hcl "github.com/hashicorp/hcl/v2"
9+
)
10+
11+
func Test_mapErrors(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
err error
15+
expected []string
16+
}{
17+
{
18+
name: "nil error",
19+
err: nil,
20+
expected: []string{},
21+
},
22+
{
23+
name: "single error",
24+
err: fmt.Errorf("test error"),
25+
expected: []string{"error: test error"},
26+
},
27+
{
28+
name: "joined errors",
29+
err: errors.Join(
30+
fmt.Errorf("error 1"),
31+
fmt.Errorf("error 2"),
32+
),
33+
expected: []string{"error: error 1", "error: error 2"},
34+
},
35+
{
36+
name: "hcl diagnostics",
37+
err: hcl.Diagnostics{
38+
{
39+
Severity: hcl.DiagError,
40+
Summary: "test summary",
41+
Detail: "test detail",
42+
Subject: &hcl.Range{
43+
Filename: "test.tf",
44+
Start: hcl.Pos{Line: 1, Column: 1},
45+
End: hcl.Pos{Line: 1, Column: 5},
46+
},
47+
},
48+
},
49+
expected: []string{"diagnostic: test summary - test detail"},
50+
},
51+
{
52+
name: "mixed errors",
53+
err: errors.Join(
54+
fmt.Errorf("generic error"),
55+
hcl.Diagnostics{
56+
{
57+
Severity: hcl.DiagError,
58+
Summary: "hcl error",
59+
Detail: "detail",
60+
Subject: &hcl.Range{
61+
Filename: "test.tf",
62+
Start: hcl.Pos{Line: 1, Column: 1},
63+
End: hcl.Pos{Line: 1, Column: 5},
64+
},
65+
},
66+
},
67+
),
68+
expected: []string{"error: generic error", "diagnostic: hcl error - detail"},
69+
},
70+
}
71+
72+
for _, tt := range tests {
73+
t.Run(tt.name, func(t *testing.T) {
74+
results := mapErrors(tt.err, errorMapper[string]{
75+
diagnostic: func(diag *hcl.Diagnostic) string {
76+
return fmt.Sprintf("diagnostic: %s - %s", diag.Summary, diag.Detail)
77+
},
78+
error: func(err error) string {
79+
return fmt.Sprintf("error: %s", err.Error())
80+
},
81+
})
82+
83+
if len(results) != len(tt.expected) {
84+
t.Fatalf("expected %d results, got %d", len(tt.expected), len(results))
85+
}
86+
87+
for i, expected := range tt.expected {
88+
if results[i] != expected {
89+
t.Errorf("result[%d]: expected %q, got %q", i, expected, results[i])
90+
}
91+
}
92+
})
93+
}
94+
}

formatter/formatter.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"github.com/terraform-linters/tflint/tflint"
1212
)
1313

14+
const applicationErrorSource = "(application)"
15+
1416
// Formatter outputs appropriate results to stdout and stderr depending on the format
1517
type Formatter struct {
1618
Stdout io.Writer

formatter/json.go

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package formatter
22

33
import (
44
"encoding/json"
5-
"errors"
65
"fmt"
76

87
"github.com/hashicorp/hcl/v2"
@@ -91,25 +90,9 @@ func (f *Formatter) jsonPrint(issues tflint.Issues, appErr error) {
9190
}
9291

9392
func (f *Formatter) jsonErrors(err error) []JSONError {
94-
if err == nil {
95-
return []JSONError{}
96-
}
97-
98-
// errors.Join
99-
if errs, ok := err.(interface{ Unwrap() []error }); ok {
100-
ret := []JSONError{}
101-
for _, err := range errs.Unwrap() {
102-
ret = append(ret, f.jsonErrors(err)...)
103-
}
104-
return ret
105-
}
106-
107-
// hcl.Diagnostics
108-
var diags hcl.Diagnostics
109-
if errors.As(err, &diags) {
110-
ret := make([]JSONError, len(diags))
111-
for idx, diag := range diags {
112-
ret[idx] = JSONError{
93+
return mapErrors(err, errorMapper[JSONError]{
94+
diagnostic: func(diag *hcl.Diagnostic) JSONError {
95+
return JSONError{
11396
Severity: fromHclSeverity(diag.Severity),
11497
Summary: diag.Summary,
11598
Message: diag.Detail,
@@ -119,12 +102,12 @@ func (f *Formatter) jsonErrors(err error) []JSONError {
119102
End: JSONPos{Line: diag.Subject.End.Line, Column: diag.Subject.End.Column},
120103
},
121104
}
122-
}
123-
return ret
124-
}
125-
126-
return []JSONError{{
127-
Severity: toSeverity(sdk.ERROR),
128-
Message: err.Error(),
129-
}}
105+
},
106+
error: func(err error) JSONError {
107+
return JSONError{
108+
Severity: toSeverity(sdk.ERROR),
109+
Message: err.Error(),
110+
}
111+
},
112+
})
130113
}

0 commit comments

Comments
 (0)