Repository: mgechev/revive
Pull request #1244 (2025-02-20 08:00:53)

feature: add suport of validate struct tags in struct-tag rule
This PR extends struct-tag rule to check validate tags as defined at https://github.com/go-playground/validator (cf https://go.dev/wiki/Well-known-struct-tags#list-of-well-known-struct-tags)


Patch:
diff --git a/rule/struct_tag.go b/rule/struct_tag.go
index 520709d..3a0e4f2 100644
--- a/rule/struct_tag.go
+++ b/rule/struct_tag.go
@@ -107,6 +107,7 @@ const (
 	keyProtobuf     = "protobuf"
 	keyRequired     = "required"
 	keyURL          = "url"
+	keyValidate     = "validate"
 	keyXML          = "xml"
 	keyYAML         = "yaml"
 )
@@ -216,6 +217,12 @@ func (w lintStructTagRule) checkTaggedField(f *ast.Field) {
 			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 {
@@ -400,6 +407,59 @@ func (w lintStructTagRule) checkMapstructureTag(options []string) (string, bool)
 	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) 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 {
@@ -500,3 +560,154 @@ func (w lintStructTagRule) isUserDefined(key, opt string) bool {
 	}
 	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":               {},
+}


Test patch:
diff --git a/test/struct_tag_test.go b/test/struct_tag_test.go
index 5386f0e..3be021b 100644
--- a/test/struct_tag_test.go
+++ b/test/struct_tag_test.go
@@ -19,6 +19,7 @@ func TestStructTagWithUserOptions(t *testing.T) {
 			"url,myURLOption",
 			"datastore,myDatastoreOption",
 			"mapstructure,myMapstructureOption",
+			"validate,displayName",
 		},
 	})
 }
diff --git a/testdata/struct_tag.go b/testdata/struct_tag.go
index 3d4ec6b..045f4cd 100644
--- a/testdata/struct_tag.go
+++ b/testdata/struct_tag.go
@@ -146,3 +146,15 @@ type MapStruct struct {
 	Field1     string `mapstructure:",squash,reminder,omitempty"`
 	OtherField string `mapstructure:",unknownOption"` // MATCH /unknown option 'unknownOption' in Mapstructure tag/
 }
+
+type ValidateUser struct {
+	Username    string `validate:"required,min=3,max=32"`
+	Email       string `validate:"required,email"`
+	Password    string `validate:"required,min=8,max=32"`
+	Biography   string `validate:"min=0,max=1000"`
+	DisplayName string `validate:"displayName,min=3,max=32"` // MATCH /unknown option 'displayName' in validate tag/
+	Complex     string `validate:"gt=0,dive,keys,eq=1|eq=2,endkeys,required"`
+	BadComplex  string `validate:"gt=0,keys,eq=1|eq=2,endkeys,required"`              // MATCH /option 'keys' must follow a 'dive' option in validate tag/
+	BadComplex2 string `validate:"gt=0,dive,eq=1|eq=2,endkeys,required"`              // MATCH /option 'endkeys' without a previous 'keys' option in validate tag/
+	BadComplex3 string `validate:"gt=0,dive,keys,eq=1|eq=2,endkeys,endkeys,required"` // MATCH /option 'endkeys' without a previous 'keys' option in validate tag/
+}
diff --git a/testdata/struct_tag_user_options.go b/testdata/struct_tag_user_options.go
index cd07373..c9997bb 100644
--- a/testdata/struct_tag_user_options.go
+++ b/testdata/struct_tag_user_options.go
@@ -29,3 +29,15 @@ type MapStruct struct {
 	Field1     string `mapstructure:",squash,reminder,omitempty,myMapstructureOption"`
 	OtherField string `mapstructure:",unknownOption"` // MATCH /unknown option 'unknownOption' in Mapstructure tag/
 }
+
+type ValidateUser struct {
+	Username    string `validate:"required,min=3,max=32"`
+	Email       string `validate:"required,email"`
+	Password    string `validate:"required,min=8,max=32"`
+	Biography   string `validate:"min=0,max=1000"`
+	DisplayName string `validate:"displayName,min=3,max=32"`
+	Complex     string `validate:"gt=0,dive,keys,eq=1|eq=2,endkeys,required"`
+	BadComplex  string `validate:"gt=0,keys,eq=1|eq=2,endkeys,required"`              // MATCH /option 'keys' must follow a 'dive' option in validate tag/
+	BadComplex2 string `validate:"gt=0,dive,eq=1|eq=2,endkeys,required"`              // MATCH /option 'endkeys' without a previous 'keys' option in validate tag/
+	BadComplex3 string `validate:"gt=0,dive,keys,eq=1|eq=2,endkeys,endkeys,required"` // MATCH /option 'endkeys' without a previous 'keys' option in validate tag/
+}