Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement verbose #89

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ The same can be done via the `-fn=<name:tag:arg-pos>` 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
Expand Down
5 changes: 3 additions & 2 deletions cmd/musttag/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@ 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.

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{}
Expand Down
71 changes: 46 additions & 25 deletions musttag.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"go/ast"
"go/types"
"reflect"
"slices"
"strconv"
"strings"

Expand All @@ -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) {
Expand All @@ -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, ":")
Expand All @@ -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)}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -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
}
}

Expand All @@ -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 {
Expand Down
24 changes: 15 additions & 9 deletions musttag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
36 changes: 36 additions & 0 deletions testdata/src/tests/tests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 \\([^)]+\\)"
}
Loading