diff --git a/converter.go b/converter.go new file mode 100644 index 0000000..6005f5b --- /dev/null +++ b/converter.go @@ -0,0 +1,116 @@ +package tagliatelle + +import ( + "fmt" + "strings" + + "github.com/ettle/strcase" +) + +// https://github.com/dominikh/go-tools/blob/v0.5.1/config/config.go#L167-L175 +// +//nolint:gochecknoglobals // For now I'll accept this, but I think will refactor to use a structure. +var staticcheckInitialisms = map[string]bool{ + "AMQP": true, + "DB": true, + "GID": true, + "LHS": false, + "RHS": false, + "RTP": true, + "SIP": true, + "TS": true, +} + +// Converter is the signature of a case converter. +type Converter func(s string) string + +// ConverterCallback allows to abstract `getSimpleConverter` and `ruleToConverter`. +type ConverterCallback func() (Converter, error) + +func getSimpleConverter(c string) (Converter, error) { + switch c { + case "camel": + return strcase.ToCamel, nil + case "pascal": + return strcase.ToPascal, nil + case "kebab": + return strcase.ToKebab, nil + case "snake": + return strcase.ToSnake, nil + case "goCamel": + return strcase.ToGoCamel, nil + case "goPascal": + return strcase.ToGoPascal, nil + case "goKebab": + return strcase.ToGoKebab, nil + case "goSnake": + return strcase.ToGoSnake, nil + case "upperSnake": + return strcase.ToSNAKE, nil + case "header": + return toHeader, nil + case "upper": + return strings.ToUpper, nil + case "lower": + return strings.ToLower, nil + default: + return nil, fmt.Errorf("unsupported case: %s", c) + } +} + +func toHeader(s string) string { + return strcase.ToCase(s, strcase.TitleCase, '-') +} + +func ruleToConverter(rule ExtendedRule) (Converter, error) { + if rule.ExtraInitialisms { + for k, v := range staticcheckInitialisms { + if _, found := rule.InitialismOverrides[k]; found { + continue + } + + rule.InitialismOverrides[k] = v + } + } + + caser := strcase.NewCaser(strings.HasPrefix(rule.Case, "go"), rule.InitialismOverrides, nil) + + switch strings.ToLower(strings.TrimPrefix(rule.Case, "go")) { + case "camel": + return caser.ToCamel, nil + + case "pascal": + return caser.ToPascal, nil + + case "kebab": + return caser.ToKebab, nil + + case "snake": + return caser.ToSnake, nil + + case "uppersnake": + return caser.ToSNAKE, nil + + case "header": + return toHeaderCase(caser), nil + + case "upper": + return func(s string) string { + return caser.ToCase(s, strcase.UpperCase, 0) + }, nil + + case "lower": + return func(s string) string { + return caser.ToCase(s, strcase.LowerCase, 0) + }, nil + + default: + return nil, fmt.Errorf("unsupported case: %s", rule.Case) + } +} + +func toHeaderCase(caser *strcase.Caser) Converter { + return func(s string) string { + return caser.ToCase(s, strcase.TitleCase, '-') + } +} diff --git a/go.mod b/go.mod index 60fb726..b559e70 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/ldez/tagliatelle go 1.22.0 require ( - github.com/ettle/strcase v0.2.0 + github.com/ettle/strcase v0.2.1-0.20230114185658-e5db6a6becf3 github.com/hashicorp/go-immutable-radix/v2 v2.1.0 golang.org/x/tools v0.27.0 ) diff --git a/go.sum b/go.sum index cfee3e8..39de705 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= -github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/ettle/strcase v0.2.1-0.20230114185658-e5db6a6becf3 h1:UveVPOxnOkBoelw2vLZTm+L0Z7fe/nz5d6JlY+A3QdE= +github.com/ettle/strcase v0.2.1-0.20230114185658-e5db6a6becf3/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= diff --git a/readme.md b/readme.md index f343387..cb7e33a 100644 --- a/readme.md +++ b/readme.md @@ -97,15 +97,14 @@ type Foo struct { } ``` -## What this tool is about +## What this linter is about -This tool is about validating tags according to rules you define. -The tool also allows to fix tags according to the rules you defined. +This linter is about validating tags according to rules you define. +The linter also allows to fix tags according to the rules you defined. -This tool is not intended to validate the fact a tag in valid or not. -To do that, you can use `go vet`, or use [golangci-lint](https://golangci-lint.run) ["go vet"](https://golangci-lint.run/usage/linters/#govet) linter. +This linter is not intended to validate the fact a tag in valid or not. -## How to use the tool +## How to use the linter ### As a golangci-lint linter @@ -114,9 +113,9 @@ Define the rules, you want via your [golangci-lint](https://golangci-lint.run) c ```yaml linters-settings: tagliatelle: - # Check the struct tag name case. + # Checks the struct tag name case. case: - # Define the association between tag name and case. + # Defines the association between tag name and case. # Any struct tag name can be used. # Supported string cases: # - `camel` @@ -142,7 +141,38 @@ linters-settings: env: upperSnake envconfig: upperSnake whatever: snake - # Use the struct field name to check the name of the struct tag. + # Defines the association between tag name and case. + # Important: the `extended-rules` overrides `rules`. + # Default: empty + extended-rules: + json: + # Supported string cases: + # - `camel` + # - `pascal` + # - `kebab` + # - `snake` + # - `upperSnake` + # - `goCamel` + # - `goPascal` + # - `goKebab` + # - `goSnake` + # - `header` + # - `lower` + # - `header` + # + # Required + case: camel + # Adds 'AMQP', 'DB', 'GID', 'RTP', 'SIP', 'TS' to initialisms, + # and removes 'LHS', 'RHS' from initialisms. + # Default: true + extra-initialisms: false + # Defines initialism additions and overrides. + # Default: empty + initialism-overrides: + DB: true # add a new initialism + LHS: false # disable a default initialism. + # ... + # Uses the struct field name to check the name of the struct tag. # Default: false use-field-name: true # The field names to ignore. @@ -161,6 +191,9 @@ linters-settings: rules: json: snake xml: pascal + # Default: empty or the same as the default/root configuration. + extended-rules: + # same options as the base `extended-rules`. # Default: false (WARNING: it doesn't follow the default/root configuration) use-field-name: true # The field names to ignore. @@ -171,6 +204,7 @@ linters-settings: # Ignore the package (takes precedence over all other configurations). # Default: false ignore: true + ``` #### Examples @@ -251,9 +285,9 @@ Here are the default rules for the well known and used tags, when using tagliate ### Custom Rules -The tool is not limited to the tags used in example, you can use it to validate any tag. +The linter is not limited to the tags used in example, **you can use it to validate any tag**. -You can add your own tag, for example `whatever` and tells the tool you want to use `kebab`. +You can add your own tag, for example `whatever` and tells the linter you want to use `kebab`. This option is only available via [golangci-lint](https://golangci-lint.run). diff --git a/tagliatelle.go b/tagliatelle.go index aa2b24b..99c7da2 100644 --- a/tagliatelle.go +++ b/tagliatelle.go @@ -13,7 +13,6 @@ import ( "slices" "strings" - "github.com/ettle/strcase" iradix "github.com/hashicorp/go-immutable-radix/v2" "golang.org/x/tools/go/analysis" "golang.org/x/tools/go/analysis/passes/inspect" @@ -35,26 +34,32 @@ type Overrides struct { // Base shared configuration between rules. type Base struct { Rules map[string]string + ExtendedRules map[string]ExtendedRule UseFieldName bool IgnoredFields []string Ignore bool } +// ExtendedRule allows to customize rules. +type ExtendedRule struct { + Case string + ExtraInitialisms bool + InitialismOverrides map[string]bool +} + // New creates an analyzer. func New(config Config) *analysis.Analyzer { return &analysis.Analyzer{ Name: "tagliatelle", Doc: "Checks the struct tags.", Run: func(pass *analysis.Pass) (any, error) { - if len(config.Rules) == 0 && len(config.Overrides) == 0 { + if len(config.Rules) == 0 && len(config.ExtendedRules) == 0 && len(config.Overrides) == 0 { return nil, nil } return run(pass, config) }, - Requires: []*analysis.Analyzer{ - inspect.Analyzer, - }, + Requires: []*analysis.Analyzer{inspect.Analyzer}, } } @@ -109,60 +114,74 @@ func analyze(pass *analysis.Pass, config Base, n *ast.StructType, field *ast.Fie return } + cleanRules(config) + if slices.Contains(config.IgnoredFields, fieldName) { return } + for key, extRule := range config.ExtendedRules { + report(pass, config, key, extRule.Case, fieldName, n, field, func() (Converter, error) { + return ruleToConverter(extRule) + }) + } + for key, convName := range config.Rules { - if convName == "" { - continue - } + report(pass, config, key, convName, fieldName, n, field, func() (Converter, error) { + return getSimpleConverter(convName) + }) + } +} - value, flags, ok := lookupTagValue(field.Tag, key) - if !ok { - // skip when no struct tag for the key - continue - } +func report(pass *analysis.Pass, config Base, key, convName, fieldName string, n *ast.StructType, field *ast.Field, fn ConverterCallback) { + if convName == "" { + return + } - if value == "-" { - // skip when skipped :) - continue - } + value, flags, ok := lookupTagValue(field.Tag, key) + if !ok { + // skip when no struct tag for the key + return + } - // TODO(ldez): need to be rethink. - // tagliatelle should try to remain neutral in terms of format. - if key == "xml" && strings.ContainsAny(value, ">:") { - // ignore XML names than contains path - continue - } + if value == "-" { + // skip when skipped :) + return + } - // TODO(ldez): need to be rethink. - // This is an exception because of a bug. - // https://github.com/ldez/tagliatelle/issues/8 - // For now, tagliatelle should try to remain neutral in terms of format. - if hasTagFlag(flags, "inline") { - // skip for inline children (no name to lint) - continue - } + // TODO(ldez): need to be rethink. + // tagliatelle should try to remain neutral in terms of format. + if key == "xml" && strings.ContainsAny(value, ">:") { + // ignore XML names than contains path + return + } - if value == "" { - value = fieldName - } + // TODO(ldez): need to be rethink. + // This is an exception because of a bug. + // https://github.com/ldez/tagliatelle/issues/8 + // For now, tagliatelle should try to remain neutral in terms of format. + if hasTagFlag(flags, "inline") { + // skip for inline children (no name to lint) + return + } - converter, err := getConverter(convName) - if err != nil { - pass.Reportf(n.Pos(), "%s(%s): %v", key, convName, err) - continue - } + if value == "" { + value = fieldName + } - expected := value - if config.UseFieldName { - expected = fieldName - } + converter, err := fn() + if err != nil { + pass.Reportf(n.Pos(), "%s(%s): %v", key, convName, err) + return + } - if value != converter(expected) { - pass.Reportf(field.Tag.Pos(), "%s(%s): got '%s' want '%s'", key, convName, value, converter(expected)) - } + expected := value + if config.UseFieldName { + expected = fieldName + } + + if value != converter(expected) { + pass.Reportf(field.Tag.Pos(), "%s(%s): got '%s' want '%s'", key, convName, value, converter(expected)) } } @@ -222,48 +241,14 @@ func hasTagFlag(flags []string, query string) bool { return false } -func getConverter(c string) (func(s string) string, error) { - switch c { - case "camel": - return strcase.ToCamel, nil - case "pascal": - return strcase.ToPascal, nil - case "kebab": - return strcase.ToKebab, nil - case "snake": - return strcase.ToSnake, nil - case "goCamel": - return strcase.ToGoCamel, nil - case "goPascal": - return strcase.ToGoPascal, nil - case "goKebab": - return strcase.ToGoKebab, nil - case "goSnake": - return strcase.ToGoSnake, nil - case "header": - return toHeader, nil - case "upper": - return strings.ToUpper, nil - case "upperSnake": - return strcase.ToSNAKE, nil - case "lower": - return strings.ToLower, nil - default: - return nil, fmt.Errorf("unsupported case: %s", c) - } -} - -func toHeader(s string) string { - return strcase.ToCase(s, strcase.TitleCase, '-') -} - func createRadixTree(config Config, modPath string) *iradix.Tree[Base] { r := iradix.New[Base]() defaultRule := Base{ - Rules: copyMap(config.Rules), - UseFieldName: config.UseFieldName, - Ignore: config.Ignore, + Rules: maps.Clone(config.Rules), + ExtendedRules: maps.Clone(config.ExtendedRules), + UseFieldName: config.UseFieldName, + Ignore: config.Ignore, } defaultRule.IgnoredFields = append(defaultRule.IgnoredFields, config.IgnoredFields...) @@ -284,13 +269,21 @@ func createRadixTree(config Config, modPath string) *iradix.Tree[Base] { } // Copy the rules from the base. - c.Rules = copyMap(config.Rules) + c.Rules = maps.Clone(config.Rules) // Overrides the rule from the base. for k, v := range override.Rules { c.Rules[k] = v } + // Copy the extended rules from the base. + c.ExtendedRules = maps.Clone(config.ExtendedRules) + + // Overrides the extended rule from the base. + for k, v := range override.ExtendedRules { + c.ExtendedRules[k] = v + } + key := path.Join(modPath, override.Package) if filepath.Base(modPath) == override.Package { key = modPath @@ -302,8 +295,8 @@ func createRadixTree(config Config, modPath string) *iradix.Tree[Base] { return r } -func copyMap[K, V comparable](m map[K]V) map[K]V { - c := make(map[K]V) - maps.Copy(c, m) - return c +func cleanRules(config Base) { + for k := range config.ExtendedRules { + delete(config.Rules, k) + } } diff --git a/tagliatelle_test.go b/tagliatelle_test.go index a1a5e97..7d944dc 100644 --- a/tagliatelle_test.go +++ b/tagliatelle_test.go @@ -155,6 +155,58 @@ func TestAnalyzer(t *testing.T) { }}, }, }, + { + desc: "Extended rules", + dir: "extended", + patterns: []string{"example.com/fake/extended/..."}, + cfg: tagliatelle.Config{ + Base: tagliatelle.Base{ + ExtendedRules: map[string]tagliatelle.ExtendedRule{ + "json": { + Case: "goCamel", + InitialismOverrides: map[string]bool{ + "DB": true, + "URL": true, + }, + }, + "sample": { + Case: "goCamel", + }, + "yaml": { + Case: "goSnake", + InitialismOverrides: map[string]bool{ + "DB": true, + }, + }, + }, + UseFieldName: true, + }, + }, + }, + { + desc: "Extended rules overrides base rules", + dir: "extended", + patterns: []string{"example.com/fake/extended/..."}, + cfg: tagliatelle.Config{ + Base: tagliatelle.Base{ + Rules: map[string]string{ + "json": "snake", + }, + ExtendedRules: map[string]tagliatelle.ExtendedRule{ + "json": { + Case: "goCamel", + InitialismOverrides: map[string]bool{ + "DB": true, + }, + }, + "sample": { + Case: "goCamel", + }, + }, + UseFieldName: true, + }, + }, + }, } t.Setenv("GOPROXY", "off") diff --git a/testdata/src/example.com/extended/extended.go b/testdata/src/example.com/extended/extended.go new file mode 100644 index 0000000..dad0972 --- /dev/null +++ b/testdata/src/example.com/extended/extended.go @@ -0,0 +1,21 @@ +package extended + +type Foo struct { + MyDB string `json:"myDb"` // want `json\(goCamel\): got 'myDb' want 'myDB'` + Dbase string `json:"dbase"` + + VirtualIPv4 string `yaml:"virtual_i_pv4"` // just to illustrate the current limitations: I think that 'virtual_ip_v4' is expected. + VirtualIPv6 string `json:"virtualIPv6"` + + DocURLs string `json:"docUrLs"` // just to illustrate the current limitations: I think that 'docURLs' is expected. +} + +type Bar struct { + MyDB string `json:"myDB"` + Dbase string `json:"dbase"` +} + +type FooBar struct { + MyDB string `sample:"myDb"` // base rule. + Dbase string `sample:"dbase"` +} diff --git a/testdata/src/example.com/extended/go.mod b/testdata/src/example.com/extended/go.mod new file mode 100644 index 0000000..a52986f --- /dev/null +++ b/testdata/src/example.com/extended/go.mod @@ -0,0 +1,3 @@ +module example.com/fake/extended + +go 1.22.0