Skip to content

Commit 9d87656

Browse files
authored
Support custom forbid reason messages (#11)
In order to allow users to communicate intent to collaborators, optionally embed custom messages into each forbidden pattern. The syntax is as follows: `identifier(# message goes here)?` Example: `^fmt\.Errorf(# Please don't use this!)?$` Regular expressions containing custom messages are effectively identical to ones that don't, because the sub-expression containing it is marked optional with a `?`. All this commit does is parse out any recognized custom message, and place it prominently in the tool's output. The regular expression itself is omitted from the tool's output when a custom message is specified.
1 parent e0c18fe commit 9d87656

File tree

5 files changed

+127
-10
lines changed

5 files changed

+127
-10
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ A larger set of interesting patterns might include:
2222
* `^fmt\.Errorf$` -- forbid Errorf in favor of using github.com/pkg/errors
2323
* `^ginkgo\.F[A-Z].*$` -- forbid ginkgo focused commands (used for debug issues)
2424
* `^spew\.Dump$` -- forbid dumping detailed data to stdout
25+
* `^fmt\.Errorf(# please use github.com/pkg/errors)?$` -- forbid Errorf, with a custom message
2526

2627
Note that the linter has no knowledge of what packages were actually imported, so aliased imports will match these patterns.
2728

forbidigo/forbidigo.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@ type UsedIssue struct {
2424
identifier string
2525
pattern string
2626
position token.Position
27+
customMsg string
2728
}
2829

2930
func (a UsedIssue) Details() string {
30-
return fmt.Sprintf("use of `%s` forbidden by pattern `%s`", a.identifier, a.pattern)
31+
explanation := fmt.Sprintf(` because %q`, a.customMsg)
32+
if a.customMsg == "" {
33+
explanation = fmt.Sprintf(" by pattern `%s`", a.pattern)
34+
}
35+
return fmt.Sprintf("use of `%s` forbidden", a.identifier) + explanation
3136
}
3237

3338
func (a UsedIssue) Position() token.Position {
@@ -36,13 +41,13 @@ func (a UsedIssue) Position() token.Position {
3641

3742
func (a UsedIssue) String() string { return toString(a) }
3843

39-
func toString(i Issue) string {
44+
func toString(i UsedIssue) string {
4045
return fmt.Sprintf("%s at %s", i.Details(), i.Position())
4146
}
4247

4348
type Linter struct {
4449
cfg config
45-
patterns []*regexp.Regexp
50+
patterns []*pattern
4651
}
4752

4853
func DefaultPatterns() []string {
@@ -65,13 +70,13 @@ func NewLinter(patterns []string, options ...Option) (*Linter, error) {
6570
if len(patterns) == 0 {
6671
patterns = DefaultPatterns()
6772
}
68-
compiledPatterns := make([]*regexp.Regexp, 0, len(patterns))
69-
for _, p := range patterns {
70-
re, err := regexp.Compile(p)
73+
compiledPatterns := make([]*pattern, 0, len(patterns))
74+
for _, ptrn := range patterns {
75+
p, err := parse(ptrn)
7176
if err != nil {
72-
return nil, fmt.Errorf("unable to compile pattern `%s`: %s", p, err)
77+
return nil, err
7378
}
74-
compiledPatterns = append(compiledPatterns, re)
79+
compiledPatterns = append(compiledPatterns, p)
7580
}
7681
return &Linter{
7782
cfg: cfg,
@@ -158,11 +163,12 @@ func (v *visitor) Visit(node ast.Node) ast.Visitor {
158163
return v
159164
}
160165
for _, p := range v.linter.patterns {
161-
if p.MatchString(v.textFor(node)) && !v.permit(node) {
166+
if p.pattern.MatchString(v.textFor(node)) && !v.permit(node) {
162167
v.issues = append(v.issues, UsedIssue{
163168
identifier: v.textFor(node),
164-
pattern: p.String(),
169+
pattern: p.pattern.String(),
165170
position: v.fset.Position(node.Pos()),
171+
customMsg: p.msg,
166172
})
167173
}
168174
}

forbidigo/forbidigo_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ func foo() {
1919
}`, "use of `fmt.Printf` forbidden by pattern `fmt\\.Printf` at testing.go:5:2")
2020
})
2121

22+
t.Run("displays custom messages", func(t *testing.T) {
23+
linter, _ := NewLinter([]string{`^fmt\.Printf(# a custom message)?$`})
24+
expectIssues(t, linter, `
25+
package bar
26+
27+
func foo() {
28+
fmt.Printf("here i am")
29+
}`, "use of `fmt.Printf` forbidden because \"a custom message\" at testing.go:5:2")
30+
})
31+
2232
t.Run("it doesn't require a package on the identifier", func(t *testing.T) {
2333
linter, _ := NewLinter([]string{`Printf`})
2434
expectIssues(t, linter, `

forbidigo/patterns.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package forbidigo
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"regexp/syntax"
7+
"strings"
8+
)
9+
10+
type pattern struct {
11+
pattern *regexp.Regexp
12+
msg string
13+
}
14+
15+
func parse(ptrn string) (*pattern, error) {
16+
ptrnRe, err := regexp.Compile(ptrn)
17+
if err != nil {
18+
return nil, fmt.Errorf("unable to compile pattern `%s`: %s", ptrn, err)
19+
}
20+
re, err := syntax.Parse(ptrn, syntax.Perl)
21+
if err != nil {
22+
return nil, fmt.Errorf("unable to parse pattern `%s`: %s", ptrn, err)
23+
}
24+
msg := extractComment(re)
25+
return &pattern{pattern: ptrnRe, msg: msg}, nil
26+
}
27+
28+
// Traverse the leaf submatches in the regex tree and extract a comment, if any
29+
// is present.
30+
func extractComment(re *syntax.Regexp) string {
31+
for _, sub := range re.Sub {
32+
if len(sub.Sub) > 0 {
33+
if comment := extractComment(sub); comment != "" {
34+
return comment
35+
}
36+
}
37+
subStr := sub.String()
38+
if strings.HasPrefix(subStr, "#") {
39+
return strings.TrimSpace(strings.TrimPrefix(subStr, "#"))
40+
}
41+
}
42+
return ""
43+
}

forbidigo/patterns_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package forbidigo
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestParseValidPatterns(t *testing.T) {
11+
for _, tc := range []struct {
12+
name string
13+
ptrn string
14+
expectedComment string
15+
}{
16+
{
17+
name: "simple expression, no comment",
18+
ptrn: `fmt\.Errorf`,
19+
},
20+
{
21+
name: "anchored expression, no comment",
22+
ptrn: `^fmt\.Errorf$`,
23+
},
24+
{
25+
name: "contains multiple subexpression, with comment",
26+
ptrn: `(f)mt\.Errorf(# a comment)?`,
27+
expectedComment: "a comment",
28+
},
29+
{
30+
name: "simple expression with comment",
31+
ptrn: `fmt\.Println(# Please don't use this!)?`,
32+
expectedComment: "Please don't use this!",
33+
},
34+
{
35+
name: "deeply nested expression with comment",
36+
ptrn: `fmt\.Println((((# Please don't use this!))))?`,
37+
expectedComment: "Please don't use this!",
38+
},
39+
{
40+
name: "anchored expression with comment",
41+
ptrn: `^fmt\.Println(# Please don't use this!)?$`,
42+
expectedComment: "Please don't use this!",
43+
},
44+
} {
45+
t.Run(tc.name, func(t *testing.T) {
46+
ptrn, err := parse(tc.ptrn)
47+
require.Nil(t, err)
48+
assert.Equal(t, tc.ptrn, ptrn.pattern.String())
49+
assert.Equal(t, tc.expectedComment, ptrn.msg)
50+
})
51+
}
52+
}
53+
54+
func TestParseInvalidPattern_ReturnsError(t *testing.T) {
55+
_, err := parse(`fmt\`)
56+
assert.NotNil(t, err)
57+
}

0 commit comments

Comments
 (0)