Skip to content

Commit a638ed6

Browse files
Add multiple scopes support to string-format rule (#1009)
* Add multiple scopes support to string-format rule * Add new parsing rule * Fix example
1 parent 24a70cd commit a638ed6

File tree

3 files changed

+142
-58
lines changed

3 files changed

+142
-58
lines changed

RULES_DESCRIPTIONS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,7 @@ This is geared towards user facing applications where string literals are often
759759
760760
_Configuration_: Each argument is a slice containing 2-3 strings: a scope, a regex, and an optional error message.
761761
762-
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]`).
762+
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`).
763763
764764
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:
765765
@@ -776,6 +776,7 @@ Example:
776776
["core.WriteError[1].Message", "/^([^A-Z]|$)/", "must not start with a capital letter"],
777777
["fmt.Errorf[0]", "/(^|[^\\.!?])$/", "must not end in punctuation"],
778778
["panic", "/^[^\\n]*$/", "must not contain line breaks"],
779+
["fmt.Errorf[0],core.WriteError[1].Message", "!/^.*%w.*$/", "must not contain '%w'"],
779780
]
780781
```
781782

rule/string-format.go

Lines changed: 80 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"go/token"
77
"regexp"
88
"strconv"
9+
"strings"
910

1011
"github.com/mgechev/revive/lint"
1112
)
@@ -66,12 +67,14 @@ type lintStringFormatRule struct {
6667

6768
type stringFormatSubrule struct {
6869
parent *lintStringFormatRule
69-
scope stringFormatSubruleScope
70+
scopes stringFormatSubruleScopes
7071
regexp *regexp.Regexp
7172
negated bool
7273
errorMessage string
7374
}
7475

76+
type stringFormatSubruleScopes []*stringFormatSubruleScope
77+
7578
type stringFormatSubruleScope struct {
7679
funcName string // Function name the rule is scoped to
7780
argument int // (optional) Which argument in calls to the function is checked against the rule (the first argument is checked by default)
@@ -90,18 +93,18 @@ var parseStringFormatScope = regexp.MustCompile(
9093

9194
func (w *lintStringFormatRule) parseArguments(arguments lint.Arguments) {
9295
for i, argument := range arguments {
93-
scope, regex, negated, errorMessage := w.parseArgument(argument, i)
96+
scopes, regex, negated, errorMessage := w.parseArgument(argument, i)
9497
w.rules = append(w.rules, stringFormatSubrule{
9598
parent: w,
96-
scope: scope,
99+
scopes: scopes,
97100
regexp: regex,
98101
negated: negated,
99102
errorMessage: errorMessage,
100103
})
101104
}
102105
}
103106

104-
func (w lintStringFormatRule) parseArgument(argument any, ruleNum int) (scope stringFormatSubruleScope, regex *regexp.Regexp, negated bool, errorMessage string) {
107+
func (w lintStringFormatRule) parseArgument(argument any, ruleNum int) (scopes stringFormatSubruleScopes, regex *regexp.Regexp, negated bool, errorMessage string) {
105108
g, ok := argument.([]any) // Cast to generic slice first
106109
if !ok {
107110
w.configError("argument is not a slice", ruleNum, 0)
@@ -125,26 +128,39 @@ func (w lintStringFormatRule) parseArgument(argument any, ruleNum int) (scope st
125128
w.configError("regex is too small (regexes should begin and end with '/')", ruleNum, 1)
126129
}
127130

128-
// Parse rule scope
129-
scope = stringFormatSubruleScope{}
130-
matches := parseStringFormatScope.FindStringSubmatch(rule[0])
131-
if matches == nil {
132-
// The rule's scope didn't match the parsing regex at all, probably a configuration error
133-
w.parseError("unable to parse rule scope", ruleNum, 0)
134-
} else if len(matches) != 4 {
135-
// The rule's scope matched the parsing regex, but an unexpected number of submatches was returned, probably a bug
136-
w.parseError(fmt.Sprintf("unexpected number of submatches when parsing scope: %d, expected 4", len(matches)), ruleNum, 0)
137-
}
138-
scope.funcName = matches[1]
139-
if len(matches[2]) > 0 {
140-
var err error
141-
scope.argument, err = strconv.Atoi(matches[2])
142-
if err != nil {
143-
w.parseError("unable to parse argument number in rule scope", ruleNum, 0)
131+
// Parse rule scopes
132+
rawScopes := strings.Split(rule[0], ",")
133+
134+
scopes = make([]*stringFormatSubruleScope, 0, len(rawScopes))
135+
for scopeNum, rawScope := range rawScopes {
136+
rawScope = strings.TrimSpace(rawScope)
137+
138+
if len(rawScope) == 0 {
139+
w.parseScopeError("empty scope in rule scopes:", ruleNum, 0, scopeNum)
144140
}
145-
}
146-
if len(matches[3]) > 0 {
147-
scope.field = matches[3]
141+
142+
scope := stringFormatSubruleScope{}
143+
matches := parseStringFormatScope.FindStringSubmatch(rawScope)
144+
if matches == nil {
145+
// The rule's scope didn't match the parsing regex at all, probably a configuration error
146+
w.parseScopeError("unable to parse rule scope", ruleNum, 0, scopeNum)
147+
} else if len(matches) != 4 {
148+
// The rule's scope matched the parsing regex, but an unexpected number of submatches was returned, probably a bug
149+
w.parseScopeError(fmt.Sprintf("unexpected number of submatches when parsing scope: %d, expected 4", len(matches)), ruleNum, 0, scopeNum)
150+
}
151+
scope.funcName = matches[1]
152+
if len(matches[2]) > 0 {
153+
var err error
154+
scope.argument, err = strconv.Atoi(matches[2])
155+
if err != nil {
156+
w.parseScopeError("unable to parse argument number in rule scope", ruleNum, 0, scopeNum)
157+
}
158+
}
159+
if len(matches[3]) > 0 {
160+
scope.field = matches[3]
161+
}
162+
163+
scopes = append(scopes, &scope)
148164
}
149165

150166
// 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
162178
if len(rule) == 3 {
163179
errorMessage = rule[2]
164180
}
165-
return scope, regex, negated, errorMessage
181+
return scopes, regex, negated, errorMessage
166182
}
167183

168184
// Report an invalid config, this is specifically the user's fault
@@ -175,6 +191,11 @@ func (lintStringFormatRule) parseError(msg string, ruleNum, option int) {
175191
panic(fmt.Sprintf("failed to parse configuration for string-format: %s [argument %d, option %d]", msg, ruleNum, option))
176192
}
177193

194+
// Report a general scope config parsing failure, this may be the user's fault, but it isn't known for certain
195+
func (lintStringFormatRule) parseScopeError(msg string, ruleNum, option, scopeNum int) {
196+
panic(fmt.Sprintf("failed to parse configuration for string-format: %s [argument %d, option %d, scope index %d]", msg, ruleNum, option, scopeNum))
197+
}
198+
178199
// #endregion
179200

180201
// #region Node traversal
@@ -193,8 +214,10 @@ func (w lintStringFormatRule) Visit(node ast.Node) ast.Visitor {
193214
}
194215

195216
for _, rule := range w.rules {
196-
if rule.scope.funcName == callName {
197-
rule.Apply(call)
217+
for _, scope := range rule.scopes {
218+
if scope.funcName == callName {
219+
rule.Apply(call)
220+
}
198221
}
199222
}
200223

@@ -230,45 +253,47 @@ func (lintStringFormatRule) getCallName(call *ast.CallExpr) (callName string, ok
230253

231254
// Apply a single format rule to a call expression (should be done after verifying the that the call expression matches the rule's scope)
232255
func (r *stringFormatSubrule) Apply(call *ast.CallExpr) {
233-
if len(call.Args) <= r.scope.argument {
234-
return
235-
}
236-
237-
arg := call.Args[r.scope.argument]
238-
var lit *ast.BasicLit
239-
if len(r.scope.field) > 0 {
240-
// Try finding the scope's Field, treating arg as a composite literal
241-
composite, ok := arg.(*ast.CompositeLit)
242-
if !ok {
256+
for _, scope := range r.scopes {
257+
if len(call.Args) <= scope.argument {
243258
return
244259
}
245-
for _, el := range composite.Elts {
246-
kv, ok := el.(*ast.KeyValueExpr)
260+
261+
arg := call.Args[scope.argument]
262+
var lit *ast.BasicLit
263+
if len(scope.field) > 0 {
264+
// Try finding the scope's Field, treating arg as a composite literal
265+
composite, ok := arg.(*ast.CompositeLit)
247266
if !ok {
248-
continue
267+
return
249268
}
250-
key, ok := kv.Key.(*ast.Ident)
251-
if !ok || key.Name != r.scope.field {
252-
continue
269+
for _, el := range composite.Elts {
270+
kv, ok := el.(*ast.KeyValueExpr)
271+
if !ok {
272+
continue
273+
}
274+
key, ok := kv.Key.(*ast.Ident)
275+
if !ok || key.Name != scope.field {
276+
continue
277+
}
278+
279+
// 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
280+
lit, ok = kv.Value.(*ast.BasicLit)
281+
if !ok || lit.Kind != token.STRING {
282+
return
283+
}
253284
}
254-
255-
// 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
256-
lit, ok = kv.Value.(*ast.BasicLit)
285+
} else {
286+
var ok bool
287+
// Treat arg as a string literal
288+
lit, ok = arg.(*ast.BasicLit)
257289
if !ok || lit.Kind != token.STRING {
258290
return
259291
}
260292
}
261-
} else {
262-
var ok bool
263-
// Treat arg as a string literal
264-
lit, ok = arg.(*ast.BasicLit)
265-
if !ok || lit.Kind != token.STRING {
266-
return
267-
}
293+
// Unquote the string literal before linting
294+
unquoted := lit.Value[1 : len(lit.Value)-1]
295+
r.lintMessage(unquoted, lit)
268296
}
269-
// Unquote the string literal before linting
270-
unquoted := lit.Value[1 : len(lit.Value)-1]
271-
r.lintMessage(unquoted, lit)
272297
}
273298

274299
func (r *stringFormatSubrule) lintMessage(s string, node ast.Node) {

test/string-format_test.go

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ func TestStringFormatArgumentParsing(t *testing.T) {
7676
[]any{
7777
"1.a",
7878
"//"}},
79-
expectedError: stringPtr("failed to parse configuration for string-format: unable to parse rule scope [argument 0, option 0]")},
79+
expectedError: stringPtr("failed to parse configuration for string-format: unable to parse rule scope [argument 0, option 0, scope index 0]")},
8080
{
8181
name: "Bad Regex",
8282
config: lint.Arguments{
@@ -98,7 +98,65 @@ func TestStringFormatArgumentParsing(t *testing.T) {
9898
config: lint.Arguments{
9999
[]any{
100100
"some_pkg._some_function_name[5].some_member",
101-
"//"}}}}
101+
"//"}}},
102+
{
103+
name: "Underscores in Multiple Scopes",
104+
config: lint.Arguments{
105+
[]any{
106+
"fmt.Errorf[0],core.WriteError[1].Message",
107+
"//"}}},
108+
{
109+
name: "', ' Delimiter",
110+
config: lint.Arguments{
111+
[]any{
112+
"abc, mt.Errorf",
113+
"//"}}},
114+
{
115+
name: "' ,' Delimiter",
116+
config: lint.Arguments{
117+
[]any{
118+
"abc ,mt.Errorf",
119+
"//"}}},
120+
{
121+
name: "', ' Delimiter",
122+
config: lint.Arguments{
123+
[]any{
124+
"abc, mt.Errorf",
125+
"//"}}},
126+
{
127+
name: "', ' Delimiter",
128+
config: lint.Arguments{
129+
[]any{
130+
"abc, mt.Errorf",
131+
"//"}}},
132+
{
133+
name: "Empty Middle Scope",
134+
config: lint.Arguments{
135+
[]any{
136+
"abc, ,mt.Errorf",
137+
"//"}},
138+
expectedError: stringPtr("failed to parse configuration for string-format: empty scope in rule scopes: [argument 0, option 0, scope index 1]")},
139+
{
140+
name: "Empty First Scope",
141+
config: lint.Arguments{
142+
[]any{
143+
",mt.Errorf",
144+
"//"}},
145+
expectedError: stringPtr("failed to parse configuration for string-format: empty scope in rule scopes: [argument 0, option 0, scope index 0]")},
146+
{
147+
name: "Bad First Scope",
148+
config: lint.Arguments{
149+
[]any{
150+
"1.a,fmt.Errorf[0]",
151+
"//"}},
152+
expectedError: stringPtr("failed to parse configuration for string-format: unable to parse rule scope [argument 0, option 0, scope index 0]")},
153+
{
154+
name: "Bad Second Scope",
155+
config: lint.Arguments{
156+
[]any{
157+
"fmt.Errorf[0],1.a",
158+
"//"}},
159+
expectedError: stringPtr("failed to parse configuration for string-format: unable to parse rule scope [argument 0, option 0, scope index 1]")}}
102160

103161
for _, a := range tests {
104162
t.Run(a.name, func(t *testing.T) {

0 commit comments

Comments
 (0)