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..7037a39 100644 --- a/readme.md +++ b/readme.md @@ -142,6 +142,43 @@ linters-settings: env: upperSnake envconfig: upperSnake whatever: snake + # Define 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. + # remove 'LHS', 'RHS' from initialisms. + # Default: true + extra-initialisms: false + # Defines initialism overrides. + # To disable a default initialism use `false`. + # Default: empty + initialism-overrides: + AMQP: true + DB: true + GID: true + LHS: false + RHS: false + RTP: true + SIP: true + TS: true # Use the struct field name to check the name of the struct tag. # Default: false use-field-name: true @@ -171,6 +208,7 @@ linters-settings: # Ignore the package (takes precedence over all other configurations). # Default: false ignore: true + ``` #### Examples diff --git a/tagliatelle.go b/tagliatelle.go index aa2b24b..5e1d62c 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: copyMap(config.Rules), + ExtendedRules: copyMap(config.ExtendedRules), + UseFieldName: config.UseFieldName, + Ignore: config.Ignore, } defaultRule.IgnoredFields = append(defaultRule.IgnoredFields, config.IgnoredFields...) @@ -291,6 +276,14 @@ func createRadixTree(config Config, modPath string) *iradix.Tree[Base] { c.Rules[k] = v } + // Copy the extended rules from the base. + c.ExtendedRules = copyMap(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,14 @@ func createRadixTree(config Config, modPath string) *iradix.Tree[Base] { return r } -func copyMap[K, V comparable](m map[K]V) map[K]V { +func copyMap[K comparable, V any](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