1
0
mirror of https://github.com/mgechev/revive.git synced 2025-02-09 13:37:14 +02:00

Add multiple scopes support to string-format rule (#1009)

* Add multiple scopes support to string-format rule

* Add new parsing rule

* Fix example
This commit is contained in:
Trifun 2024-08-09 22:01:17 +03:00 committed by GitHub
parent 24a70cdf18
commit a638ed6e24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 144 additions and 60 deletions

View File

@ -759,7 +759,7 @@ This is geared towards user facing applications where string literals are often
_Configuration_: Each argument is a slice containing 2-3 strings: a scope, a regex, and an optional error message.
1. The first string defines a **scope**. This controls which string literals the regex will apply to, and is defined as a function argument. It must contain at least a function name (`core.WriteError`). Scopes may optionally contain a number specifying which argument in the function to check (`core.WriteError[1]`), as well as a struct field (`core.WriteError[1].Message`, only works for top level fields). Function arguments are counted starting at 0, so `[0]` would refer to the first argument, `[1]` would refer to the second, etc. If no argument number is provided, the first argument will be used (same as `[0]`).
1. The first string defines a **scope**. This controls which string literals the regex will apply to, and is defined as a function argument. It must contain at least a function name (`core.WriteError`). Scopes may optionally contain a number specifying which argument in the function to check (`core.WriteError[1]`), as well as a struct field (`core.WriteError[1].Message`, only works for top level fields). Function arguments are counted starting at 0, so `[0]` would refer to the first argument, `[1]` would refer to the second, etc. If no argument number is provided, the first argument will be used (same as `[0]`). You can use multiple scopes to one regex. Split them by `,` (`core.WriteError,fmt.Errorf`).
2. The second string is a **regular expression** (beginning and ending with a `/` character), which will be used to check the string literals in the scope. The default semantics is "_strings matching the regular expression are OK_". If you need to inverse the semantics you can add a `!` just before the first `/`. Examples:
@ -776,6 +776,7 @@ Example:
["core.WriteError[1].Message", "/^([^A-Z]|$)/", "must not start with a capital letter"],
["fmt.Errorf[0]", "/(^|[^\\.!?])$/", "must not end in punctuation"],
["panic", "/^[^\\n]*$/", "must not contain line breaks"],
["fmt.Errorf[0],core.WriteError[1].Message", "!/^.*%w.*$/", "must not contain '%w'"],
]
```

View File

@ -6,6 +6,7 @@ import (
"go/token"
"regexp"
"strconv"
"strings"
"github.com/mgechev/revive/lint"
)
@ -66,12 +67,14 @@ type lintStringFormatRule struct {
type stringFormatSubrule struct {
parent *lintStringFormatRule
scope stringFormatSubruleScope
scopes stringFormatSubruleScopes
regexp *regexp.Regexp
negated bool
errorMessage string
}
type stringFormatSubruleScopes []*stringFormatSubruleScope
type stringFormatSubruleScope struct {
funcName string // Function name the rule is scoped to
argument int // (optional) Which argument in calls to the function is checked against the rule (the first argument is checked by default)
@ -90,10 +93,10 @@ var parseStringFormatScope = regexp.MustCompile(
func (w *lintStringFormatRule) parseArguments(arguments lint.Arguments) {
for i, argument := range arguments {
scope, regex, negated, errorMessage := w.parseArgument(argument, i)
scopes, regex, negated, errorMessage := w.parseArgument(argument, i)
w.rules = append(w.rules, stringFormatSubrule{
parent: w,
scope: scope,
scopes: scopes,
regexp: regex,
negated: negated,
errorMessage: errorMessage,
@ -101,7 +104,7 @@ func (w *lintStringFormatRule) parseArguments(arguments lint.Arguments) {
}
}
func (w lintStringFormatRule) parseArgument(argument any, ruleNum int) (scope stringFormatSubruleScope, regex *regexp.Regexp, negated bool, errorMessage string) {
func (w lintStringFormatRule) parseArgument(argument any, ruleNum int) (scopes stringFormatSubruleScopes, regex *regexp.Regexp, negated bool, errorMessage string) {
g, ok := argument.([]any) // Cast to generic slice first
if !ok {
w.configError("argument is not a slice", ruleNum, 0)
@ -125,26 +128,39 @@ func (w lintStringFormatRule) parseArgument(argument any, ruleNum int) (scope st
w.configError("regex is too small (regexes should begin and end with '/')", ruleNum, 1)
}
// Parse rule scope
scope = stringFormatSubruleScope{}
matches := parseStringFormatScope.FindStringSubmatch(rule[0])
if matches == nil {
// The rule's scope didn't match the parsing regex at all, probably a configuration error
w.parseError("unable to parse rule scope", ruleNum, 0)
} else if len(matches) != 4 {
// The rule's scope matched the parsing regex, but an unexpected number of submatches was returned, probably a bug
w.parseError(fmt.Sprintf("unexpected number of submatches when parsing scope: %d, expected 4", len(matches)), ruleNum, 0)
}
scope.funcName = matches[1]
if len(matches[2]) > 0 {
var err error
scope.argument, err = strconv.Atoi(matches[2])
if err != nil {
w.parseError("unable to parse argument number in rule scope", ruleNum, 0)
// Parse rule scopes
rawScopes := strings.Split(rule[0], ",")
scopes = make([]*stringFormatSubruleScope, 0, len(rawScopes))
for scopeNum, rawScope := range rawScopes {
rawScope = strings.TrimSpace(rawScope)
if len(rawScope) == 0 {
w.parseScopeError("empty scope in rule scopes:", ruleNum, 0, scopeNum)
}
}
if len(matches[3]) > 0 {
scope.field = matches[3]
scope := stringFormatSubruleScope{}
matches := parseStringFormatScope.FindStringSubmatch(rawScope)
if matches == nil {
// The rule's scope didn't match the parsing regex at all, probably a configuration error
w.parseScopeError("unable to parse rule scope", ruleNum, 0, scopeNum)
} else if len(matches) != 4 {
// The rule's scope matched the parsing regex, but an unexpected number of submatches was returned, probably a bug
w.parseScopeError(fmt.Sprintf("unexpected number of submatches when parsing scope: %d, expected 4", len(matches)), ruleNum, 0, scopeNum)
}
scope.funcName = matches[1]
if len(matches[2]) > 0 {
var err error
scope.argument, err = strconv.Atoi(matches[2])
if err != nil {
w.parseScopeError("unable to parse argument number in rule scope", ruleNum, 0, scopeNum)
}
}
if len(matches[3]) > 0 {
scope.field = matches[3]
}
scopes = append(scopes, &scope)
}
// Strip / characters from the beginning and end of rule[1] before compiling
@ -162,7 +178,7 @@ func (w lintStringFormatRule) parseArgument(argument any, ruleNum int) (scope st
if len(rule) == 3 {
errorMessage = rule[2]
}
return scope, regex, negated, errorMessage
return scopes, regex, negated, errorMessage
}
// Report an invalid config, this is specifically the user's fault
@ -175,6 +191,11 @@ func (lintStringFormatRule) parseError(msg string, ruleNum, option int) {
panic(fmt.Sprintf("failed to parse configuration for string-format: %s [argument %d, option %d]", msg, ruleNum, option))
}
// Report a general scope config parsing failure, this may be the user's fault, but it isn't known for certain
func (lintStringFormatRule) parseScopeError(msg string, ruleNum, option, scopeNum int) {
panic(fmt.Sprintf("failed to parse configuration for string-format: %s [argument %d, option %d, scope index %d]", msg, ruleNum, option, scopeNum))
}
// #endregion
// #region Node traversal
@ -193,8 +214,10 @@ func (w lintStringFormatRule) Visit(node ast.Node) ast.Visitor {
}
for _, rule := range w.rules {
if rule.scope.funcName == callName {
rule.Apply(call)
for _, scope := range rule.scopes {
if scope.funcName == callName {
rule.Apply(call)
}
}
}
@ -230,45 +253,47 @@ func (lintStringFormatRule) getCallName(call *ast.CallExpr) (callName string, ok
// Apply a single format rule to a call expression (should be done after verifying the that the call expression matches the rule's scope)
func (r *stringFormatSubrule) Apply(call *ast.CallExpr) {
if len(call.Args) <= r.scope.argument {
return
}
arg := call.Args[r.scope.argument]
var lit *ast.BasicLit
if len(r.scope.field) > 0 {
// Try finding the scope's Field, treating arg as a composite literal
composite, ok := arg.(*ast.CompositeLit)
if !ok {
for _, scope := range r.scopes {
if len(call.Args) <= scope.argument {
return
}
for _, el := range composite.Elts {
kv, ok := el.(*ast.KeyValueExpr)
if !ok {
continue
}
key, ok := kv.Key.(*ast.Ident)
if !ok || key.Name != r.scope.field {
continue
}
// We're now dealing with the exact field in the rule's scope, so if anything fails, we can safely return instead of continuing the loop
lit, ok = kv.Value.(*ast.BasicLit)
arg := call.Args[scope.argument]
var lit *ast.BasicLit
if len(scope.field) > 0 {
// Try finding the scope's Field, treating arg as a composite literal
composite, ok := arg.(*ast.CompositeLit)
if !ok {
return
}
for _, el := range composite.Elts {
kv, ok := el.(*ast.KeyValueExpr)
if !ok {
continue
}
key, ok := kv.Key.(*ast.Ident)
if !ok || key.Name != scope.field {
continue
}
// We're now dealing with the exact field in the rule's scope, so if anything fails, we can safely return instead of continuing the loop
lit, ok = kv.Value.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return
}
}
} else {
var ok bool
// Treat arg as a string literal
lit, ok = arg.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return
}
}
} else {
var ok bool
// Treat arg as a string literal
lit, ok = arg.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return
}
// Unquote the string literal before linting
unquoted := lit.Value[1 : len(lit.Value)-1]
r.lintMessage(unquoted, lit)
}
// Unquote the string literal before linting
unquoted := lit.Value[1 : len(lit.Value)-1]
r.lintMessage(unquoted, lit)
}
func (r *stringFormatSubrule) lintMessage(s string, node ast.Node) {

View File

@ -76,7 +76,7 @@ func TestStringFormatArgumentParsing(t *testing.T) {
[]any{
"1.a",
"//"}},
expectedError: stringPtr("failed to parse configuration for string-format: unable to parse rule scope [argument 0, option 0]")},
expectedError: stringPtr("failed to parse configuration for string-format: unable to parse rule scope [argument 0, option 0, scope index 0]")},
{
name: "Bad Regex",
config: lint.Arguments{
@ -98,7 +98,65 @@ func TestStringFormatArgumentParsing(t *testing.T) {
config: lint.Arguments{
[]any{
"some_pkg._some_function_name[5].some_member",
"//"}}}}
"//"}}},
{
name: "Underscores in Multiple Scopes",
config: lint.Arguments{
[]any{
"fmt.Errorf[0],core.WriteError[1].Message",
"//"}}},
{
name: "', ' Delimiter",
config: lint.Arguments{
[]any{
"abc, mt.Errorf",
"//"}}},
{
name: "' ,' Delimiter",
config: lint.Arguments{
[]any{
"abc ,mt.Errorf",
"//"}}},
{
name: "', ' Delimiter",
config: lint.Arguments{
[]any{
"abc, mt.Errorf",
"//"}}},
{
name: "', ' Delimiter",
config: lint.Arguments{
[]any{
"abc, mt.Errorf",
"//"}}},
{
name: "Empty Middle Scope",
config: lint.Arguments{
[]any{
"abc, ,mt.Errorf",
"//"}},
expectedError: stringPtr("failed to parse configuration for string-format: empty scope in rule scopes: [argument 0, option 0, scope index 1]")},
{
name: "Empty First Scope",
config: lint.Arguments{
[]any{
",mt.Errorf",
"//"}},
expectedError: stringPtr("failed to parse configuration for string-format: empty scope in rule scopes: [argument 0, option 0, scope index 0]")},
{
name: "Bad First Scope",
config: lint.Arguments{
[]any{
"1.a,fmt.Errorf[0]",
"//"}},
expectedError: stringPtr("failed to parse configuration for string-format: unable to parse rule scope [argument 0, option 0, scope index 0]")},
{
name: "Bad Second Scope",
config: lint.Arguments{
[]any{
"fmt.Errorf[0],1.a",
"//"}},
expectedError: stringPtr("failed to parse configuration for string-format: unable to parse rule scope [argument 0, option 0, scope index 1]")}}
for _, a := range tests {
t.Run(a.name, func(t *testing.T) {