Skip to content

Commit 78ac644

Browse files
committed
Add custom linter for validating struct tags
This makes sure yaml and jsons truct tags stay in sync. Signed-off-by: Brian Goff <[email protected]>
1 parent d4b9388 commit 78ac644

File tree

6 files changed

+277
-0
lines changed

6 files changed

+277
-0
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ jobs:
5353
with:
5454
version: v1.61
5555
args: --timeout=30m
56+
- name: custom linters
57+
run: go run ./cmd/lint ./...
5658
- name: validate generated files
5759
run: |
5860
go generate || exit $?

cmd/lint/main.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package main
2+
3+
import (
4+
"github.com/Azure/dalec/linters"
5+
"golang.org/x/tools/go/analysis/singlechecker"
6+
)
7+
8+
func main() {
9+
singlechecker.Main(linters.YamlJSONTagsMatch)
10+
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ require (
2525
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
2626
golang.org/x/sync v0.10.0
2727
golang.org/x/sys v0.28.0
28+
golang.org/x/tools v0.26.0
2829
google.golang.org/grpc v1.68.2
2930
gotest.tools/v3 v3.5.2
3031
)
@@ -88,6 +89,7 @@ require (
8889
go.opentelemetry.io/otel/trace v1.31.0 // indirect
8990
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
9091
golang.org/x/crypto v0.31.0 // indirect
92+
golang.org/x/mod v0.21.0 // indirect
9193
golang.org/x/net v0.33.0 // indirect
9294
golang.org/x/text v0.21.0 // indirect
9395
golang.org/x/time v0.6.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
259259
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
260260
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
261261
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
262+
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
263+
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
262264
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
263265
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
264266
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

linters/yaml_json_tags.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package linters
2+
3+
import (
4+
"go/ast"
5+
"strings"
6+
7+
"golang.org/x/tools/go/analysis"
8+
)
9+
10+
var YamlJSONTagsMatch = &analysis.Analyzer{
11+
Name: "yaml_json_names_match",
12+
Doc: "check that struct tags for json and yaml use the same name",
13+
Run: structTagLinter{}.Run,
14+
}
15+
16+
type structTagLinter struct{}
17+
18+
func (l structTagLinter) Run(pass *analysis.Pass) (interface{}, error) {
19+
for _, file := range pass.Files {
20+
ast.Inspect(file, func(n ast.Node) bool {
21+
switch x := n.(type) {
22+
case *ast.TypeSpec:
23+
if structType, ok := x.Type.(*ast.StructType); ok {
24+
l.checkStructTags(structType, pass)
25+
}
26+
}
27+
return true
28+
})
29+
}
30+
return nil, nil
31+
}
32+
33+
func (structTagLinter) checkStructTags(structType *ast.StructType, pass *analysis.Pass) {
34+
for _, field := range structType.Fields.List {
35+
if field.Tag != nil {
36+
tag := field.Tag.Value
37+
38+
v := getYamlJSONNames(tag)
39+
40+
var checkTags bool
41+
if v[0] != "" || v[1] != "" {
42+
checkTags = true
43+
}
44+
45+
if checkTags && v[0] != v[1] {
46+
pass.Reportf(field.Pos(), "mismatch in struct tags: json=%s, yaml=%s", v[0], v[1])
47+
}
48+
}
49+
}
50+
}
51+
52+
func getYamlJSONNames(tag string) [2]string {
53+
const (
54+
yaml = "yaml"
55+
json = "json"
56+
)
57+
58+
tag = strings.Trim(tag, "`")
59+
60+
var out [2]string
61+
for _, tag := range strings.Fields(tag) {
62+
key, tag, _ := strings.Cut(tag, ":")
63+
64+
value := strings.Trim(tag, `"`)
65+
66+
switch key {
67+
case json:
68+
t, _, _ := strings.Cut(value, ",")
69+
out[0] = t
70+
case yaml:
71+
t, _, _ := strings.Cut(value, ",")
72+
out[1] = t
73+
}
74+
75+
if out[0] != "" && out[1] != "" {
76+
break
77+
}
78+
}
79+
80+
return out
81+
}

linters/yaml_json_tags_test.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package linters
2+
3+
import (
4+
"go/ast"
5+
"go/parser"
6+
"go/token"
7+
"testing"
8+
9+
"golang.org/x/tools/go/analysis"
10+
"gotest.tools/v3/assert"
11+
"gotest.tools/v3/assert/cmp"
12+
)
13+
14+
func TestCheckStructTags(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
src string
18+
expected []string
19+
}{
20+
{
21+
name: "matching tags",
22+
src: `
23+
package test
24+
type Test struct {
25+
Field1 string ` + "`json:\"field1\" yaml:\"field1\"`" + `
26+
}
27+
`,
28+
expected: nil,
29+
},
30+
{
31+
name: "mismatched tags",
32+
src: `
33+
package test
34+
type Test struct {
35+
Field1 string ` + "`json:\"field1\" yaml:\"field2\"`" + `
36+
}
37+
`,
38+
expected: []string{"mismatch in struct tags: json=field1, yaml=field2"},
39+
},
40+
{
41+
name: "missing json tag",
42+
src: `
43+
package test
44+
type Test struct {
45+
Field1 string ` + "`yaml:\"field1\"`" + `
46+
}
47+
`,
48+
expected: []string{"mismatch in struct tags: json=, yaml=field1"},
49+
},
50+
{
51+
name: "missing yaml tag",
52+
src: `
53+
package test
54+
type Test struct {
55+
Field1 string ` + "`json:\"field1\"`" + `
56+
}
57+
`,
58+
expected: []string{"mismatch in struct tags: json=field1, yaml="},
59+
},
60+
{
61+
name: "no tags",
62+
src: `
63+
package test
64+
type Test struct {
65+
Field1 string
66+
}
67+
`,
68+
expected: nil,
69+
},
70+
{
71+
name: "extra spaces",
72+
src: `
73+
package test
74+
type Test struct {
75+
Field1 string ` + "`json:\"field1\" yaml:\"field1\"`" + `
76+
}
77+
`,
78+
expected: nil,
79+
},
80+
{
81+
name: "reversed order",
82+
src: `
83+
package test
84+
type Test struct {
85+
Field1 string ` + "`yaml:\"field1\" json:\"field1\"`" + `
86+
}
87+
`,
88+
expected: nil,
89+
},
90+
{
91+
name: "extra spaces and mismatched tags",
92+
src: `
93+
package test
94+
type Test struct {
95+
Field1 string ` + "`json:\"field1\" yaml:\"field2\"`" + `
96+
}
97+
`,
98+
expected: []string{"mismatch in struct tags: json=field1, yaml=field2"},
99+
},
100+
}
101+
102+
for _, tt := range tests {
103+
t.Run(tt.name, func(t *testing.T) {
104+
fset := token.NewFileSet()
105+
node, err := parser.ParseFile(fset, "test.go", tt.src, parser.ParseComments)
106+
if err != nil {
107+
t.Fatalf("failed to parse source: %v", err)
108+
}
109+
110+
var reports []string
111+
pass := &analysis.Pass{
112+
Fset: fset,
113+
Files: []*ast.File{node},
114+
Report: func(d analysis.Diagnostic) {
115+
reports = append(reports, d.Message)
116+
},
117+
}
118+
119+
linter := structTagLinter{}
120+
_, err = linter.Run(pass)
121+
assert.NilError(t, err)
122+
123+
assert.Assert(t, cmp.Len(reports, len(tt.expected)))
124+
assert.Assert(t, cmp.DeepEqual(reports, tt.expected))
125+
})
126+
}
127+
}
128+
129+
func TestGetYamlJSONNames(t *testing.T) {
130+
tests := []struct {
131+
tag string
132+
expected [2]string
133+
}{
134+
{
135+
tag: "`json:\"field1\" yaml:\"field1\"`",
136+
expected: [2]string{"field1", "field1"},
137+
},
138+
{
139+
tag: "`json:\"field1\" yaml:\"field2\"`",
140+
expected: [2]string{"field1", "field2"},
141+
},
142+
{
143+
tag: "`json:\"field1\"`",
144+
expected: [2]string{"field1", ""},
145+
},
146+
{
147+
tag: "`yaml:\"field1\"`",
148+
expected: [2]string{"", "field1"},
149+
},
150+
{
151+
tag: "`json:\"field1,omitempty\" yaml:\"field1\"`",
152+
expected: [2]string{"field1", "field1"},
153+
},
154+
{
155+
tag: "`json:\"field1\" yaml:\"field1,omitempty\"`",
156+
expected: [2]string{"field1", "field1"},
157+
},
158+
{
159+
tag: "`json:\"field1\" yaml:\"field1\"`",
160+
expected: [2]string{"field1", "field1"},
161+
},
162+
{
163+
tag: "`yaml:\"field1\" json:\"field1\"`",
164+
expected: [2]string{"field1", "field1"},
165+
},
166+
{
167+
tag: "`json:\"field1\" yaml:\"field2\"`",
168+
expected: [2]string{"field1", "field2"},
169+
},
170+
}
171+
172+
for _, tt := range tests {
173+
t.Run(tt.tag, func(t *testing.T) {
174+
result := getYamlJSONNames(tt.tag)
175+
if result != tt.expected {
176+
t.Errorf("expected %v, got %v", tt.expected, result)
177+
}
178+
})
179+
}
180+
}

0 commit comments

Comments
 (0)