diff --git a/README.md b/README.md index 3f3a253..b24cf05 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,24 @@ The same can be done via the `-fn=` flag when using `musttag` musttag -fn="github.com/hashicorp/hcl/v2/hclsimple.DecodeFile:hcl:2" ./... ``` +### Verbose Output + +Verbose output includes the field name path and position. +To enable verbose output, you can set `verbose` field in `.golangci.yml`. + +```yaml +linters-settings: + musttag: + # Output verbose information, like the field name path and position. + verbose: true +``` + +The same can be done via the `-verbose` flag when using `musttag` standalone: + +```shell +musttag -verbose ./... +``` + [1]: https://github.com/uber-go/guide/blob/master/style.md#use-field-tags-in-marshaled-structs [2]: https://pkg.go.dev/encoding/json [3]: https://pkg.go.dev/encoding/xml diff --git a/cmd/musttag/main.go b/cmd/musttag/main.go index 5843349..dc05059 100644 --- a/cmd/musttag/main.go +++ b/cmd/musttag/main.go @@ -6,8 +6,9 @@ import ( "os" "runtime" - "go-simpler.org/musttag" "golang.org/x/tools/go/analysis/singlechecker" + + "go-simpler.org/musttag" ) var version = "dev" // injected at build time. @@ -15,7 +16,7 @@ var version = "dev" // injected at build time. func main() { // override the builtin -V flag. flag.Var(versionFlag{}, "V", "print version and exit") - singlechecker.Main(musttag.New()) + singlechecker.Main(musttag.New(&musttag.Settings{})) } type versionFlag struct{} diff --git a/musttag.go b/musttag.go index 7be684f..1005cab 100644 --- a/musttag.go +++ b/musttag.go @@ -7,6 +7,7 @@ import ( "go/ast" "go/types" "reflect" + "slices" "strconv" "strings" @@ -27,17 +28,27 @@ type Func struct { ifaceWhitelist []string } +// Settings contains the configuration for the musttag analyzer. +type Settings struct { + Funcs []Func // Custom functions to report. + Verbose bool // Output verbose information, like the field name path and position. +} + // New creates a new musttag analyzer. -// To report a custom function, provide its description as [Func]. -func New(funcs ...Func) *analysis.Analyzer { - var flagFuncs []Func +// To report a custom function, provide its description as [Settings.Funcs]. +func New(settings *Settings) *analysis.Analyzer { + if settings == nil { + settings = &Settings{} + } + flagSettings := &Settings{} + return &analysis.Analyzer{ Name: "musttag", Doc: "enforce field tags in (un)marshaled structs", - Flags: flags(&flagFuncs), + Flags: flags(flagSettings), Requires: []*analysis.Analyzer{inspect.Analyzer}, Run: func(pass *analysis.Pass) (any, error) { - l := len(builtins) + len(funcs) + len(flagFuncs) + l := len(builtins) + len(settings.Funcs) + len(flagSettings.Funcs) allFuncs := make(map[string]Func, l) merge := func(slice []Func) { @@ -46,20 +57,20 @@ func New(funcs ...Func) *analysis.Analyzer { } } merge(builtins) - merge(funcs) - merge(flagFuncs) + merge(settings.Funcs) + merge(flagSettings.Funcs) mainModule, err := getMainModule() if err != nil { return nil, err } - return run(pass, mainModule, allFuncs) + return run(pass, mainModule, allFuncs, settings.Verbose || flagSettings.Verbose) }, } } -func flags(funcs *[]Func) flag.FlagSet { +func flags(settings *Settings) flag.FlagSet { fs := flag.NewFlagSet("musttag", flag.ContinueOnError) fs.Func("fn", "report a custom function (name:tag:arg-pos)", func(s string) error { parts := strings.Split(s, ":") @@ -70,17 +81,18 @@ func flags(funcs *[]Func) flag.FlagSet { if err != nil { return err } - *funcs = append(*funcs, Func{ + settings.Funcs = append(settings.Funcs, Func{ Name: parts[0], Tag: parts[1], ArgPos: pos, }) return nil }) + fs.BoolVar(&settings.Verbose, "verbose", false, "verbose output") return *fs } -func run(pass *analysis.Pass, mainModule string, funcs map[string]Func) (_ any, err error) { +func run(pass *analysis.Pass, mainModule string, funcs map[string]Func, verbose bool) (_ any, err error) { visit := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) filter := []ast.Node{(*ast.CallExpr)(nil)} @@ -124,13 +136,25 @@ func run(pass *analysis.Pass, mainModule string, funcs map[string]Func) (_ any, seenTypes: make(map[string]struct{}), ifaceWhitelist: fn.ifaceWhitelist, imports: pass.Pkg.Imports(), + reports: make(map[*types.Var]string), } - if valid := checker.checkType(typ, fn.Tag); valid { - return // nothing to report. + checker.checkType(typ, fn.Tag, "") + if len(checker.reports) == 0 { + return // no reports. } - pass.Reportf(arg.Pos(), "the given struct should be annotated with the `%s` tag", fn.Tag) + reportStr := fmt.Sprintf("the given struct should be annotated with the `%s` tag", fn.Tag) + if verbose { + var reportStrs []string + for field, fieldPath := range checker.reports { + reportStrs = append(reportStrs, fmt.Sprintf("%s.%s (%s)", fieldPath, field.Name(), pass.Fset.Position(field.Pos()).String())) + } + slices.Sort(reportStrs) + reportStr = fmt.Sprintf("%s: %s", reportStr, strings.Join(reportStrs, ", ")) + } + + pass.Reportf(arg.Pos(), reportStr) }) return nil, err @@ -141,20 +165,21 @@ type checker struct { seenTypes map[string]struct{} ifaceWhitelist []string imports []*types.Package + reports map[*types.Var]string } -func (c *checker) checkType(typ types.Type, tag string) bool { +func (c *checker) checkType(typ types.Type, tag, fieldPath string) { if _, ok := c.seenTypes[typ.String()]; ok { - return true // already checked. + return // already checked. } c.seenTypes[typ.String()] = struct{}{} styp, ok := c.parseStruct(typ) if !ok { - return true // not a struct. + return // not a struct. } - return c.checkStruct(styp, tag) + c.checkStruct(styp, tag, fieldPath) } // recursively unwrap a type until we get to an underlying @@ -205,7 +230,7 @@ func (c *checker) parseStruct(typ types.Type) (*types.Struct, bool) { } } -func (c *checker) checkStruct(styp *types.Struct, tag string) (valid bool) { +func (c *checker) checkStruct(styp *types.Struct, tag, fieldPath string) { for i := 0; i < styp.NumFields(); i++ { field := styp.Field(i) if !field.Exported() { @@ -216,7 +241,7 @@ func (c *checker) checkStruct(styp *types.Struct, tag string) (valid bool) { if !ok { // tag is not required for embedded types; see issue #12. if !field.Embedded() { - return false + c.reports[field] = fieldPath } } @@ -225,12 +250,8 @@ func (c *checker) checkStruct(styp *types.Struct, tag string) (valid bool) { continue } - if valid := c.checkType(field.Type(), tag); !valid { - return false - } + c.checkType(field.Type(), tag, fmt.Sprintf("%s.%s", fieldPath, field.Name())) } - - return true } func implementsInterface(typ types.Type, ifaces []string, imports []*types.Package) bool { diff --git a/musttag_test.go b/musttag_test.go index 930c03c..f8aa1de 100644 --- a/musttag_test.go +++ b/musttag_test.go @@ -17,29 +17,35 @@ func TestAnalyzer(t *testing.T) { setupModules(t, testdata) t.Run("tests", func(t *testing.T) { - analyzer := New( - Func{Name: "example.com/custom.Marshal", Tag: "custom", ArgPos: 0}, - Func{Name: "example.com/custom.Unmarshal", Tag: "custom", ArgPos: 1}, - ) + analyzer := New(&Settings{ + Funcs: []Func{ + {Name: "example.com/custom.Marshal", Tag: "custom", ArgPos: 0}, + {Name: "example.com/custom.Unmarshal", Tag: "custom", ArgPos: 1}, + }, + Verbose: true, + }) analysistest.Run(t, testdata, analyzer, "tests") }) t.Run("bad Func.ArgPos", func(t *testing.T) { - analyzer := New( - Func{Name: "encoding/json.Marshal", Tag: "json", ArgPos: 10}, - ) + analyzer := New(&Settings{ + Funcs: []Func{ + {Name: "encoding/json.Marshal", Tag: "json", ArgPos: 10}, + }, + Verbose: true, + }) err := analysistest.Run(nopT{}, testdata, analyzer, "tests")[0].Err assert.Equal[E](t, err.Error(), "musttag: Func.ArgPos cannot be 10: encoding/json.Marshal accepts only 1 argument(s)") }) } func TestFlags(t *testing.T) { - analyzer := New() + analyzer := New(&Settings{}) analyzer.Flags.Usage = func() {} analyzer.Flags.SetOutput(io.Discard) t.Run("ok", func(t *testing.T) { - err := analyzer.Flags.Parse([]string{"-fn=test.Test:test:0"}) + err := analyzer.Flags.Parse([]string{"-fn=test.Test:test:0", "-verbose=true"}) assert.NoErr[E](t, err) }) diff --git a/testdata/src/tests/tests.go b/testdata/src/tests/tests.go index 25461a1..b7effba 100644 --- a/testdata/src/tests/tests.go +++ b/testdata/src/tests/tests.go @@ -191,3 +191,39 @@ func interfaceSliceType() { json.MarshalIndent(withMarshallableSlice, "", "") json.NewEncoder(nil).Encode(withMarshallableSlice) } + +func fieldPath() { + type NestedB struct { + NestedBNoTagField string + } + type NestedA struct { + NestedAField NestedB `json:"NestedAField"` + NestedANoTagField string + } + type Foo struct { + FieldA NestedA `json:"FieldA"` + } + var foo Foo + json.Marshal(foo) // want "the given struct should be annotated with the `json` tag: \\.FieldA\\.NestedAField\\.NestedBNoTagField \\([^)]+\\), \\.FieldA\\.NestedANoTagField \\([^)]+\\)" + json.Marshal(&foo) // want "the given struct should be annotated with the `json` tag: \\.FieldA\\.NestedAField\\.NestedBNoTagField \\([^)]+\\), \\.FieldA\\.NestedANoTagField \\([^)]+\\)" + json.Marshal(Foo{}) // want "the given struct should be annotated with the `json` tag: \\.FieldA\\.NestedAField\\.NestedBNoTagField \\([^)]+\\), \\.FieldA\\.NestedANoTagField \\([^)]+\\)" + json.Marshal(&Foo{}) // want "the given struct should be annotated with the `json` tag: \\.FieldA\\.NestedAField\\.NestedBNoTagField \\([^)]+\\), \\.FieldA\\.NestedANoTagField \\([^)]+\\)" +} + +type FieldPathNestedBar struct { + BarA *FieldPathNestedFoo `json:"BarA"` + BarB string +} + +type FieldPathNestedFoo struct { + FooA *FieldPathNestedBar `json:"FooA"` + FooB string +} + +func fieldPathNested() { + var foo FieldPathNestedFoo + json.Marshal(foo) // want "the given struct should be annotated with the `json` tag: \\.FooA\\.BarB \\([^)]+\\), \\.FooB \\([^)]+\\)" + json.Marshal(&foo) // want "the given struct should be annotated with the `json` tag: \\.FooA\\.BarB \\([^)]+\\), \\.FooB \\([^)]+\\)" + json.Marshal(FieldPathNestedFoo{}) // want "the given struct should be annotated with the `json` tag: \\.FooA\\.BarB \\([^)]+\\), \\.FooB \\([^)]+\\)" + json.Marshal(&FieldPathNestedFoo{}) // want "the given struct should be annotated with the `json` tag: \\.FooA\\.BarB \\([^)]+\\), \\.FooB \\([^)]+\\)" +}