1
0
mirror of https://github.com/mgechev/revive.git synced 2025-11-23 22:04:49 +02:00
Files
revive/rule/struct_tag.go

735 lines
18 KiB
Go

package rule
import (
"fmt"
"go/ast"
"strconv"
"strings"
"github.com/fatih/structtag"
"github.com/mgechev/revive/lint"
)
// StructTagRule lints struct tags.
type StructTagRule struct {
userDefined map[string][]string // map: key -> []option
}
// Configure validates the rule configuration, and configures the rule accordingly.
//
// Configuration implements the [lint.ConfigurableRule] interface.
func (r *StructTagRule) Configure(arguments lint.Arguments) error {
if len(arguments) == 0 {
return nil
}
err := checkNumberOfArguments(1, arguments, r.Name())
if err != nil {
return err
}
r.userDefined = make(map[string][]string, len(arguments))
for _, arg := range arguments {
item, ok := arg.(string)
if !ok {
return fmt.Errorf("invalid argument to the %s rule. Expecting a string, got %v (of type %T)", r.Name(), arg, arg)
}
parts := strings.Split(item, ",")
if len(parts) < 2 {
return fmt.Errorf("invalid argument to the %s rule. Expecting a string of the form key[,option]+, got %s", r.Name(), item)
}
key := strings.TrimSpace(parts[0])
for i := 1; i < len(parts); i++ {
option := strings.TrimSpace(parts[i])
r.userDefined[key] = append(r.userDefined[key], option)
}
}
return nil
}
// Apply applies the rule to given file.
func (r *StructTagRule) Apply(file *lint.File, _ lint.Arguments) []lint.Failure {
var failures []lint.Failure
onFailure := func(failure lint.Failure) {
failures = append(failures, failure)
}
w := lintStructTagRule{
onFailure: onFailure,
userDefined: r.userDefined,
isAtLeastGo124: file.Pkg.IsAtLeastGo124(),
}
ast.Walk(w, file.AST)
return failures
}
// Name returns the rule name.
func (*StructTagRule) Name() string {
return "struct-tag"
}
type lintStructTagRule struct {
onFailure func(lint.Failure)
userDefined map[string][]string // map: key -> []option
usedTagNbr map[int]bool // list of used tag numbers
usedTagName map[string]bool // list of used tag keys
isAtLeastGo124 bool
}
func (w lintStructTagRule) Visit(node ast.Node) ast.Visitor {
switch n := node.(type) {
case *ast.StructType:
isEmptyStruct := n.Fields == nil || n.Fields.NumFields() < 1
if isEmptyStruct {
return nil // skip empty structs
}
w.usedTagNbr = map[int]bool{}
w.usedTagName = map[string]bool{}
for _, f := range n.Fields.List {
if f.Tag != nil {
w.checkTaggedField(f)
}
}
}
return w
}
const (
keyASN1 = "asn1"
keyBSON = "bson"
keyDatastore = "datastore"
keyDefault = "default"
keyJSON = "json"
keyMapstructure = "mapstructure"
keyProtobuf = "protobuf"
keyRequired = "required"
keyTOML = "toml"
keyURL = "url"
keyValidate = "validate"
keyXML = "xml"
keyYAML = "yaml"
)
func (w lintStructTagRule) checkTagNameIfNeed(tag *structtag.Tag) (string, bool) {
isUnnamedTag := tag.Name == "" || tag.Name == "-"
if isUnnamedTag {
return "", true
}
switch tag.Key {
case keyBSON, keyJSON, keyXML, keyYAML, keyProtobuf:
default:
return "", true
}
tagName := w.getTagName(tag)
if tagName == "" {
return "", true // No tag name found
}
// We concat the key and name as the mapping key here
// to allow the same tag name in different tag type.
key := tag.Key + ":" + tagName
if _, ok := w.usedTagName[key]; ok {
return fmt.Sprintf("duplicate tag name: '%s'", tagName), false
}
w.usedTagName[key] = true
return "", true
}
func (lintStructTagRule) getTagName(tag *structtag.Tag) string {
switch tag.Key {
case keyProtobuf:
for _, option := range tag.Options {
if tagName, found := strings.CutPrefix(option, "name="); found {
return tagName
}
}
return "" // protobuf tag lacks 'name' option
default:
return tag.Name
}
}
// checkTaggedField checks the tag of the given field.
// precondition: the field has a tag
func (w lintStructTagRule) checkTaggedField(f *ast.Field) {
if len(f.Names) > 0 && !f.Names[0].IsExported() {
w.addFailure(f, "tag on not-exported field "+f.Names[0].Name)
}
tags, err := structtag.Parse(strings.Trim(f.Tag.Value, "`"))
if err != nil || tags == nil {
w.addFailure(f.Tag, "malformed tag")
return
}
for _, tag := range tags.Tags() {
if msg, ok := w.checkTagNameIfNeed(tag); !ok {
w.addFailure(f.Tag, msg)
}
switch key := tag.Key; key {
case keyASN1:
msg, ok := w.checkASN1Tag(f.Type, tag)
if !ok {
w.addFailure(f.Tag, msg)
}
case keyBSON:
msg, ok := w.checkBSONTag(tag.Options)
if !ok {
w.addFailure(f.Tag, msg)
}
case keyDatastore:
msg, ok := w.checkDatastoreTag(tag.Options)
if !ok {
w.addFailure(f.Tag, msg)
}
case keyDefault:
if !w.typeValueMatch(f.Type, tag.Name) {
w.addFailure(f.Tag, "field's type and default value's type mismatch")
}
case keyJSON:
msg, ok := w.checkJSONTag(tag.Name, tag.Options)
if !ok {
w.addFailure(f.Tag, msg)
}
case keyMapstructure:
msg, ok := w.checkMapstructureTag(tag.Options)
if !ok {
w.addFailure(f.Tag, msg)
}
case keyProtobuf:
msg, ok := w.checkProtobufTag(tag)
if !ok {
w.addFailure(f.Tag, msg)
}
case keyRequired:
if tag.Name != "true" && tag.Name != "false" {
w.addFailure(f.Tag, "required should be 'true' or 'false'")
}
case keyTOML:
msg, ok := w.checkTOMLTag(tag.Options)
if !ok {
w.addFailure(f.Tag, msg)
}
case keyURL:
msg, ok := w.checkURLTag(tag.Options)
if !ok {
w.addFailure(f.Tag, msg)
}
case keyValidate:
opts := append([]string{tag.Name}, tag.Options...)
msg, ok := w.checkValidateTag(opts)
if !ok {
w.addFailure(f.Tag, msg)
}
case keyXML:
msg, ok := w.checkXMLTag(tag.Options)
if !ok {
w.addFailure(f.Tag, msg)
}
case keyYAML:
msg, ok := w.checkYAMLTag(tag.Options)
if !ok {
w.addFailure(f.Tag, msg)
}
default:
// unknown key
}
}
}
func (w lintStructTagRule) checkASN1Tag(t ast.Expr, tag *structtag.Tag) (string, bool) {
checkList := append(tag.Options, tag.Name)
for _, opt := range checkList {
switch opt {
case "application", "explicit", "generalized", "ia5", "omitempty", "optional", "set", "utf8":
default:
if strings.HasPrefix(opt, "tag:") {
parts := strings.Split(opt, ":")
tagNumber := parts[1]
number, err := strconv.Atoi(tagNumber)
if err != nil {
return fmt.Sprintf("ASN1 tag must be a number, got '%s'", tagNumber), false
}
if w.usedTagNbr[number] {
return fmt.Sprintf("duplicated tag number %v", number), false
}
w.usedTagNbr[number] = true
continue
}
if strings.HasPrefix(opt, "default:") {
parts := strings.Split(opt, ":")
if len(parts) < 2 {
return "malformed default for ASN1 tag", false
}
if !w.typeValueMatch(t, parts[1]) {
return "field's type and default value's type mismatch", false
}
continue
}
if w.isUserDefined(keyASN1, opt) {
continue
}
return fmt.Sprintf("unknown option '%s' in ASN1 tag", opt), false
}
}
return "", true
}
func (w lintStructTagRule) checkBSONTag(options []string) (string, bool) {
for _, opt := range options {
switch opt {
case "inline", "minsize", "omitempty":
default:
if w.isUserDefined(keyBSON, opt) {
continue
}
return fmt.Sprintf("unknown option '%s' in BSON tag", opt), false
}
}
return "", true
}
func (w lintStructTagRule) checkJSONTag(name string, options []string) (string, bool) {
for _, opt := range options {
switch opt {
case "omitempty", "string":
case "":
// special case for JSON key "-"
if name != "-" {
return "option can not be empty in JSON tag", false
}
case "omitzero":
if w.isAtLeastGo124 {
continue
}
fallthrough
default:
if w.isUserDefined(keyJSON, opt) {
continue
}
return fmt.Sprintf("unknown option '%s' in JSON tag", opt), false
}
}
return "", true
}
func (w lintStructTagRule) checkXMLTag(options []string) (string, bool) {
for _, opt := range options {
switch opt {
case "any", "attr", "cdata", "chardata", "comment", "innerxml", "omitempty", "typeattr":
default:
if w.isUserDefined(keyXML, opt) {
continue
}
return fmt.Sprintf("unknown option '%s' in XML tag", opt), false
}
}
return "", true
}
func (w lintStructTagRule) checkYAMLTag(options []string) (string, bool) {
for _, opt := range options {
switch opt {
case "flow", "inline", "omitempty":
default:
if w.isUserDefined(keyYAML, opt) {
continue
}
return fmt.Sprintf("unknown option '%s' in YAML tag", opt), false
}
}
return "", true
}
func (w lintStructTagRule) checkURLTag(options []string) (string, bool) {
var delimiter = ""
for _, opt := range options {
switch opt {
case "int", "omitempty", "numbered", "brackets":
case "unix", "unixmilli", "unixnano": // TODO : check that the field is of type time.Time
case "comma", "semicolon", "space":
if delimiter == "" {
delimiter = opt
continue
}
return fmt.Sprintf("can not set both '%s' and '%s' as delimiters in URL tag", opt, delimiter), false
default:
if w.isUserDefined(keyURL, opt) {
continue
}
return fmt.Sprintf("unknown option '%s' in URL tag", opt), false
}
}
return "", true
}
func (w lintStructTagRule) checkDatastoreTag(options []string) (string, bool) {
for _, opt := range options {
switch opt {
case "flatten", "noindex", "omitempty":
default:
if w.isUserDefined(keyDatastore, opt) {
continue
}
return fmt.Sprintf("unknown option '%s' in Datastore tag", opt), false
}
}
return "", true
}
func (w lintStructTagRule) checkMapstructureTag(options []string) (string, bool) {
for _, opt := range options {
switch opt {
case "omitempty", "reminder", "squash":
default:
if w.isUserDefined(keyMapstructure, opt) {
continue
}
return fmt.Sprintf("unknown option '%s' in Mapstructure tag", opt), false
}
}
return "", true
}
func (w lintStructTagRule) checkValidateTag(options []string) (string, bool) {
previousOption := ""
seenKeysOption := false
for _, opt := range options {
switch opt {
case "keys":
if previousOption != "dive" {
return "option 'keys' must follow a 'dive' option in validate tag", false
}
seenKeysOption = true
case "endkeys":
if !seenKeysOption {
return "option 'endkeys' without a previous 'keys' option in validate tag", false
}
seenKeysOption = false
default:
parts := strings.Split(opt, "|")
errMsg, ok := w.checkValidateOptionsAlternatives(parts)
if !ok {
return errMsg, false
}
}
previousOption = opt
}
return "", true
}
func (w lintStructTagRule) checkTOMLTag(options []string) (string, bool) {
for _, opt := range options {
switch opt {
case "omitempty":
default:
if w.isUserDefined(keyTOML, opt) {
continue
}
return fmt.Sprintf("unknown option '%s' in TOML tag", opt), false
}
}
return "", true
}
func (w lintStructTagRule) checkValidateOptionsAlternatives(alternatives []string) (string, bool) {
for _, alternative := range alternatives {
alternative := strings.TrimSpace(alternative)
parts := strings.Split(alternative, "=")
switch len(parts) {
case 1:
badOpt, ok := areValidateOpts(parts[0])
if ok || w.isUserDefined(keyValidate, badOpt) {
continue
}
return fmt.Sprintf("unknown option '%s' in validate tag", badOpt), false
case 2:
lhs := parts[0]
_, ok := validateLHS[lhs]
if ok || w.isUserDefined(keyValidate, lhs) {
continue
}
return fmt.Sprintf("unknown option '%s' in validate tag", lhs), false
default:
return fmt.Sprintf("malformed options '%s' in validate tag, not expected more than one '='", alternative), false
}
}
return "", true
}
func (lintStructTagRule) typeValueMatch(t ast.Expr, val string) bool {
tID, ok := t.(*ast.Ident)
if !ok {
return true
}
typeMatches := true
switch tID.Name {
case "bool":
typeMatches = val == "true" || val == "false"
case "float64":
_, err := strconv.ParseFloat(val, 64)
typeMatches = err == nil
case "int":
_, err := strconv.ParseInt(val, 10, 64)
typeMatches = err == nil
case "string":
case "nil":
default:
// unchecked type
}
return typeMatches
}
func (w lintStructTagRule) checkProtobufTag(tag *structtag.Tag) (string, bool) {
// check name
switch tag.Name {
case "bytes", "fixed32", "fixed64", "group", "varint", "zigzag32", "zigzag64":
// do nothing
default:
return fmt.Sprintf("invalid protobuf tag name '%s'", tag.Name), false
}
// check options
seenOptions := map[string]bool{}
for _, opt := range tag.Options {
if number, err := strconv.Atoi(opt); err == nil {
_, alreadySeen := w.usedTagNbr[number]
if alreadySeen {
return fmt.Sprintf("duplicated tag number %v", number), false
}
w.usedTagNbr[number] = true
continue // option is an integer
}
switch {
case opt == "opt" || opt == "proto3" || opt == "rep" || opt == "req":
// do nothing
case strings.Contains(opt, "="):
o := strings.Split(opt, "=")[0]
_, alreadySeen := seenOptions[o]
if alreadySeen {
return fmt.Sprintf("protobuf tag has duplicated option '%s'", o), false
}
seenOptions[o] = true
continue
}
}
_, hasName := seenOptions["name"]
if !hasName {
return "protobuf tag lacks mandatory option 'name'", false
}
for k := range seenOptions {
switch k {
case "name", "json":
// do nothing
default:
if w.isUserDefined(keyProtobuf, k) {
continue
}
return fmt.Sprintf("unknown option '%s' in protobuf tag", k), false
}
}
return "", true
}
func (w lintStructTagRule) addFailure(n ast.Node, msg string) {
w.onFailure(lint.Failure{
Node: n,
Failure: msg,
Confidence: 1,
})
}
func (w lintStructTagRule) isUserDefined(key, opt string) bool {
if w.userDefined == nil {
return false
}
options := w.userDefined[key]
for _, o := range options {
if opt == o {
return true
}
}
return false
}
func areValidateOpts(opts string) (string, bool) {
parts := strings.Split(opts, "|")
for _, opt := range parts {
_, ok := validateSingleOptions[opt]
if !ok {
return opt, false
}
}
return "", true
}
var validateSingleOptions = map[string]struct{}{
"alpha": {},
"alphanum": {},
"alphanumunicode": {},
"alphaunicode": {},
"ascii": {},
"base32": {},
"base64": {},
"base64url": {},
"bcp47_language_tag": {},
"boolean": {},
"bic": {},
"btc_addr": {},
"btc_addr_bech32": {},
"cidr": {},
"cidrv4": {},
"cidrv6": {},
"country_code": {},
"credit_card": {},
"cron": {},
"cve": {},
"datauri": {},
"dir": {},
"dirpath": {},
"dive": {},
"dns_rfc1035_label": {},
"e164": {},
"email": {},
"eth_addr": {},
"file": {},
"filepath": {},
"fqdn": {},
"hexadecimal": {},
"hexcolor": {},
"hostname": {},
"hostname_port": {},
"hostname_rfc1123": {},
"hsl": {},
"hsla": {},
"html": {},
"html_encoded": {},
"image": {},
"ip": {},
"ip4_addr": {},
"ip6_addr": {},
"ip_addr": {},
"ipv4": {},
"ipv6": {},
"isbn": {},
"isbn10": {},
"isbn13": {},
"isdefault": {},
"iso3166_1_alpha2": {},
"iso3166_1_alpha3": {},
"iscolor": {},
"json": {},
"jwt": {},
"latitude": {},
"longitude": {},
"lowercase": {},
"luhn_checksum": {},
"mac": {},
"mongodb": {},
"mongodb_connection_string": {},
"multibyte": {},
"nostructlevel": {},
"number": {},
"numeric": {},
"omitempty": {},
"printascii": {},
"required": {},
"rgb": {},
"rgba": {},
"semver": {},
"ssn": {},
"structonly": {},
"tcp_addr": {},
"tcp4_addr": {},
"tcp6_addr": {},
"timezone": {},
"udp4_addr": {},
"udp6_addr": {},
"ulid": {},
"unique": {},
"unix_addr": {},
"uppercase": {},
"uri": {},
"url": {},
"url_encoded": {},
"urn_rfc2141": {},
"uuid": {},
"uuid3": {},
"uuid4": {},
"uuid5": {},
}
var validateLHS = map[string]struct{}{
"contains": {},
"containsany": {},
"containsfield": {},
"containsrune": {},
"datetime": {},
"endsnotwith": {},
"endswith": {},
"eq": {},
"eqfield": {},
"eqcsfield": {},
"excluded_if": {},
"excluded_unless": {},
"excludes": {},
"excludesall": {},
"excludesfield": {},
"excludesrune": {},
"gt": {},
"gtcsfield": {},
"gtecsfield": {},
"len": {},
"lt": {},
"lte": {},
"ltcsfield": {},
"ltecsfield": {},
"max": {},
"min": {},
"ne": {},
"necsfield": {},
"oneof": {},
"oneofci": {},
"required_if": {},
"required_unless": {},
"required_with": {},
"required_with_all": {},
"required_without": {},
"required_without_all": {},
"spicedb": {},
"startsnotwith": {},
"startswith": {},
"unique": {},
}