Skip to content

Commit eb85a22

Browse files
feat (#25): add support for custom Go rules (#145)
Signed-off-by: Sourya Vatsyayan <sourya@deepsource.io>
1 parent 0c1187f commit eb85a22

File tree

22 files changed

+1023
-271
lines changed

22 files changed

+1023
-271
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*.dylib
1010
cmd/globstar/globstar
1111
bin/
12+
custom-analyzer
1213

1314
# Test binary, built with `go test -c`
1415
*.test

analysis/analyzer.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ var defaultIgnoreDirs = []string{
8989
"__pycache__",
9090
".idea",
9191
".vitepress",
92-
".globstar", // may contain test files
9392
}
9493

9594
func RunAnalyzers(path string, analyzers []*Analyzer) ([]*Issue, error) {

analysis/issue.go

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,25 +23,26 @@ type Issue struct {
2323
Id *string
2424
}
2525

26-
func (i *Issue) AsJson() ([]byte, error) {
27-
type location struct {
28-
Row int `json:"row"`
29-
Column int `json:"column"`
30-
}
26+
type location struct {
27+
Row int `json:"row"`
28+
Column int `json:"column"`
29+
}
3130

32-
type position struct {
33-
Filename string `json:"filename"`
34-
Start location `json:"start"`
35-
End location `json:"end"`
36-
}
31+
type position struct {
32+
Filename string `json:"filename"`
33+
Start location `json:"start"`
34+
End location `json:"end"`
35+
}
3736

38-
type issueJson struct {
39-
Category Category `json:"category"`
40-
Severity Severity `json:"severity"`
41-
Message string `json:"message"`
42-
Range position `json:"range"`
43-
Id string `json:"id"`
44-
}
37+
type issueJson struct {
38+
Category Category `json:"category"`
39+
Severity Severity `json:"severity"`
40+
Message string `json:"message"`
41+
Range position `json:"range"`
42+
Id string `json:"id"`
43+
}
44+
45+
func (i *Issue) AsJson() ([]byte, error) {
4546
issue := issueJson{
4647
Category: i.Category,
4748
Severity: i.Severity,
@@ -66,3 +67,29 @@ func (i *Issue) AsJson() ([]byte, error) {
6667
func (i *Issue) AsText() ([]byte, error) {
6768
return []byte(fmt.Sprintf("%s:%d:%d:%s", i.Filepath, int(i.Node.Range().StartPoint.Row)+1, i.Node.Range().StartPoint.Column, i.Message)), nil
6869
}
70+
71+
func IssueFromJson(jsonData []byte) (*Issue, error) {
72+
var issue issueJson
73+
err := json.Unmarshal(jsonData, &issue)
74+
if err != nil {
75+
return nil, err
76+
}
77+
78+
return &Issue{
79+
Category: issue.Category,
80+
Severity: issue.Severity,
81+
Message: issue.Message,
82+
Filepath: issue.Range.Filename,
83+
Node: nil,
84+
Id: &issue.Id,
85+
}, nil
86+
}
87+
88+
func IssueAsTextFromJson(jsonData []byte) ([]byte, error) {
89+
var issue issueJson
90+
err := json.Unmarshal(jsonData, &issue)
91+
if err != nil {
92+
return nil, err
93+
}
94+
return []byte(fmt.Sprintf("%s:%d:%d:%s", issue.Range.Filename, issue.Range.Start.Row, issue.Range.Start.Column, issue.Message)), nil
95+
}

analysis/testrunner.go

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
package analysis
2+
3+
import (
4+
"fmt"
5+
"io/fs"
6+
"path/filepath"
7+
"regexp"
8+
"sort"
9+
"strings"
10+
11+
sitter "github.com/smacker/go-tree-sitter"
12+
)
13+
14+
func verifyIssues(expectedIssues, raisedIssues *map[string]map[int][]string) string {
15+
var diffBuilder strings.Builder
16+
17+
// Compare files
18+
for filePath, expectedFileIssues := range *expectedIssues {
19+
raisedFileIssues, exists := (*raisedIssues)[filePath]
20+
if !exists {
21+
diffBuilder.WriteString(fmt.Sprintf("\nFile: %s\n", filePath))
22+
diffBuilder.WriteString(" Expected issues but found none\n")
23+
continue
24+
}
25+
26+
// Compare line numbers in each file
27+
for line, expectedMessages := range expectedFileIssues {
28+
raisedMessages, exists := raisedFileIssues[line]
29+
if !exists {
30+
diffBuilder.WriteString(fmt.Sprintf("\nFile: %s, Line: %d\n", filePath, line))
31+
diffBuilder.WriteString(" Expected:\n")
32+
for _, msg := range expectedMessages {
33+
diffBuilder.WriteString(fmt.Sprintf(" - %s\n", msg))
34+
}
35+
diffBuilder.WriteString(" Got: no issues\n")
36+
continue
37+
}
38+
39+
// Compare messages at each line
40+
if !messagesEqual(expectedMessages, raisedMessages) {
41+
diffBuilder.WriteString(fmt.Sprintf("\nFile: %s, Line: %d\n", filePath, line))
42+
diffBuilder.WriteString(" Expected:\n")
43+
for _, msg := range expectedMessages {
44+
diffBuilder.WriteString(fmt.Sprintf(" - %s\n", msg))
45+
}
46+
diffBuilder.WriteString(" Got:\n")
47+
for _, msg := range raisedMessages {
48+
diffBuilder.WriteString(fmt.Sprintf(" - %s\n", msg))
49+
}
50+
}
51+
}
52+
53+
// Check for unexpected issues
54+
for line, raisedMessages := range raisedFileIssues {
55+
if _, exists := expectedFileIssues[line]; !exists {
56+
diffBuilder.WriteString(fmt.Sprintf("\nFile: %s, Line: %d\n", filePath, line))
57+
diffBuilder.WriteString(" Expected: no issues\n")
58+
diffBuilder.WriteString(" Got:\n")
59+
for _, msg := range raisedMessages {
60+
diffBuilder.WriteString(fmt.Sprintf(" - %s\n", msg))
61+
}
62+
}
63+
}
64+
}
65+
66+
// Check for issues in unexpected files
67+
for filePath, raisedFileIssues := range *raisedIssues {
68+
if _, exists := (*expectedIssues)[filePath]; !exists {
69+
diffBuilder.WriteString(fmt.Sprintf("\nUnexpected file with issues: %s\n", filePath))
70+
for line, messages := range raisedFileIssues {
71+
diffBuilder.WriteString(fmt.Sprintf(" Line %d:\n", line))
72+
for _, msg := range messages {
73+
diffBuilder.WriteString(fmt.Sprintf(" - %s\n", msg))
74+
}
75+
}
76+
}
77+
}
78+
79+
return diffBuilder.String()
80+
}
81+
82+
// Helper function to compare two slices of messages
83+
func messagesEqual(expected, actual []string) bool {
84+
if len(expected) != len(actual) {
85+
return false
86+
}
87+
sort.Strings(expected)
88+
sort.Strings(actual)
89+
return slicesEqual(expected, actual)
90+
}
91+
92+
// Helper function to compare two sorted slices
93+
func slicesEqual(a, b []string) bool {
94+
for i := range a {
95+
if a[i] != "" && a[i] != b[i] {
96+
return false
97+
}
98+
}
99+
return true
100+
}
101+
102+
func getExpectedIssuesInDir(testDir string) (map[string]map[int][]string, error) {
103+
// map of test file path to map of line number to issue message
104+
// {"file.test.ext": {1: {"issue1 message"}, {"issue2 message"}}}
105+
expectedIssues := make(map[string]map[int][]string)
106+
err := filepath.Walk(testDir, func(path string, info fs.FileInfo, err error) error {
107+
if err != nil {
108+
return nil
109+
}
110+
111+
if info.IsDir() {
112+
return nil
113+
}
114+
115+
if !strings.HasSuffix(path, fmt.Sprintf(".test%s", filepath.Ext(path))) {
116+
return nil
117+
}
118+
119+
// load the pragmas (<commentIdentifier> <expect-error>) from the test file
120+
file, err := ParseFile(path)
121+
if err != nil {
122+
// skip the file if it can't be parsed
123+
return nil
124+
}
125+
126+
query, err := sitter.NewQuery([]byte("(comment) @pragma"), file.Language.Grammar())
127+
if err != nil {
128+
return nil
129+
}
130+
131+
expectedIssues[path] = getExpectedIssuesInFile(file, query)
132+
133+
return nil
134+
})
135+
if err != nil {
136+
return expectedIssues, err
137+
}
138+
139+
return expectedIssues, nil
140+
}
141+
142+
func getExpectedIssuesInFile(file *ParseResult, query *sitter.Query) map[int][]string {
143+
commentIdentifier := GetEscapedCommentIdentifierFromPath(file.FilePath)
144+
145+
pattern := fmt.Sprintf(`^%s\s+<expect-error>\s*(?P<message>.*)$`, commentIdentifier)
146+
pragmaRegexp := regexp.MustCompile(pattern)
147+
148+
expectedIssues := map[int][]string{}
149+
cursor := sitter.NewQueryCursor()
150+
cursor.Exec(query, file.Ast)
151+
for {
152+
m, ok := cursor.NextMatch()
153+
154+
if !ok {
155+
break
156+
}
157+
158+
for _, capture := range m.Captures {
159+
captureName := query.CaptureNameForId(capture.Index)
160+
if captureName != "pragma" {
161+
continue
162+
}
163+
expectedLine := -1
164+
pragma := capture.Node.Content(file.Source)
165+
prevNode := capture.Node.PrevSibling()
166+
if prevNode != nil && (prevNode.EndPoint().Row == capture.Node.StartPoint().Row) {
167+
// if the comment is on the same line as the troublesome code,
168+
// the line number of the issue is the same as the line number of the comment
169+
expectedLine = int(prevNode.StartPoint().Row) + 1
170+
} else {
171+
// +2 because the pragma is on the line above the expected issue,
172+
// and the line number is 0-indexed
173+
expectedLine = int(capture.Node.StartPoint().Row) + 2
174+
}
175+
matches := pragmaRegexp.FindAllStringSubmatch(pragma, -1)
176+
if matches == nil {
177+
continue
178+
}
179+
180+
message := ""
181+
for _, match := range matches {
182+
for i, group := range pragmaRegexp.SubexpNames() {
183+
if i == 0 || group == "" {
184+
continue
185+
}
186+
187+
if group == "message" {
188+
message = match[i]
189+
}
190+
}
191+
192+
if _, ok := expectedIssues[expectedLine]; !ok {
193+
expectedIssues[expectedLine] = []string{}
194+
}
195+
196+
expectedIssues[expectedLine] = append(expectedIssues[expectedLine], message)
197+
}
198+
}
199+
}
200+
return expectedIssues
201+
}
202+
203+
func RunAnalyzerTests(testDir string, analyzers []*Analyzer) (string, string, bool, error) {
204+
log := strings.Builder{}
205+
206+
passed := true
207+
expectedIssues, err := getExpectedIssuesInDir(testDir)
208+
if err != nil {
209+
err = fmt.Errorf("error getting expected issues in dir %s: %v", testDir, err)
210+
return "", "", false, err
211+
}
212+
213+
raisedIssues, err := RunAnalyzers(testDir, analyzers)
214+
if err != nil {
215+
err = fmt.Errorf("error running tests on dir %s: %v", testDir, err)
216+
return "", "", false, err
217+
}
218+
219+
analyzerIssueMap := make(map[string]int)
220+
for _, analyzer := range analyzers {
221+
analyzerIssueMap[analyzer.Name] = 0
222+
}
223+
224+
raisedIssuesMap := make(map[string]map[int][]string)
225+
for _, issue := range raisedIssues {
226+
analyzerIssueMap[*issue.Id]++
227+
228+
if _, ok := raisedIssuesMap[issue.Filepath]; !ok {
229+
raisedIssuesMap[issue.Filepath] = make(map[int][]string)
230+
}
231+
232+
line := int(issue.Node.Range().StartPoint.Row + 1)
233+
if _, ok := raisedIssuesMap[issue.Filepath][line]; !ok {
234+
raisedIssuesMap[issue.Filepath][line] = []string{}
235+
}
236+
237+
raisedIssuesMap[issue.Filepath][line] = append(raisedIssuesMap[issue.Filepath][line], fmt.Sprintf("%s: %s", *issue.Id, issue.Message))
238+
}
239+
240+
for analyzerId, issueCount := range analyzerIssueMap {
241+
if issueCount == 0 {
242+
log.Write([]byte(fmt.Sprintf(" No tests found for analyzer %s\n", analyzerId)))
243+
passed = false
244+
} else {
245+
log.Write([]byte(fmt.Sprintf(" Running tests for analyzer %s\n", analyzerId)))
246+
}
247+
}
248+
249+
// verify issues raised are as expected from the test files
250+
diff := verifyIssues(&expectedIssues, &raisedIssuesMap)
251+
if diff != "" {
252+
passed = false
253+
}
254+
255+
return diff, log.String(), passed, nil
256+
}

0 commit comments

Comments
 (0)