Skip to content

Commit aebfa05

Browse files
authored
fix: more accurate unused import detection (#30)
1 parent 94ff0b0 commit aebfa05

File tree

6 files changed

+125
-151
lines changed

6 files changed

+125
-151
lines changed

formatter/general.go

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,28 +43,19 @@ func (f *GeneralIssueFormatter) Format(
4343
padding := strings.Repeat(" ", len(lineNumberStr)-1)
4444
result.WriteString(lineStyle.Sprintf(" %s|\n", padding))
4545

46-
// line := expandTabs(snippet.Lines[issue.Start.Line-1])
47-
// result.WriteString(lineStyle.Sprintf("%d | ", issue.Start.Line))
48-
// result.WriteString(line + "\n")
49-
50-
// visualColumn := calculateVisualColumn(line, issue.Start.Column)
51-
// result.WriteString(lineStyle.Sprintf(" %s| ", padding))
52-
// result.WriteString(strings.Repeat(" ", visualColumn))
53-
// result.WriteString(messageStyle.Sprintf("^ %s\n\n", issue.Message))
54-
5546
if len(snippet.Lines) > 0 {
56-
line := expandTabs(snippet.Lines[lineIndex])
57-
result.WriteString(lineStyle.Sprintf("%d | ", issue.Start.Line))
58-
result.WriteString(line + "\n")
47+
line := expandTabs(snippet.Lines[lineIndex])
48+
result.WriteString(lineStyle.Sprintf("%d | ", issue.Start.Line))
49+
result.WriteString(line + "\n")
5950

60-
visualColumn := calculateVisualColumn(line, issue.Start.Column)
61-
result.WriteString(lineStyle.Sprintf(" %s| ", padding))
62-
result.WriteString(strings.Repeat(" ", visualColumn))
63-
result.WriteString(messageStyle.Sprintf("^ %s\n\n", issue.Message))
64-
} else {
65-
result.WriteString(messageStyle.Sprintf("Unable to display line. File might be empty.\n"))
66-
result.WriteString(messageStyle.Sprintf("Issue: %s\n\n", issue.Message))
67-
}
51+
visualColumn := calculateVisualColumn(line, issue.Start.Column)
52+
result.WriteString(lineStyle.Sprintf(" %s| ", padding))
53+
result.WriteString(strings.Repeat(" ", visualColumn))
54+
result.WriteString(messageStyle.Sprintf("^ %s\n\n", issue.Message))
55+
} else {
56+
result.WriteString(messageStyle.Sprintf("Unable to display line. File might be empty.\n"))
57+
result.WriteString(messageStyle.Sprintf("Issue: %s\n\n", issue.Message))
58+
}
6859

6960
return result.String()
7061
}

internal/engine.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import (
1111

1212
// Engine manages the linting process.
1313
type Engine struct {
14-
SymbolTable *SymbolTable
15-
rules []LintRule
16-
ignoredRules map[string]bool
14+
SymbolTable *SymbolTable
15+
rules []LintRule
16+
ignoredRules map[string]bool
1717
}
1818

1919
// NewEngine creates a new lint engine.

internal/lints/gno_analyzer.go

Lines changed: 34 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"go/parser"
77
"go/token"
88
"os"
9-
"path/filepath"
109
"strings"
1110

1211
tt "github.com/gnoswap-labs/lint/internal/types"
@@ -17,38 +16,22 @@ const (
1716
GNO_STD_PACKAGE = "std"
1817
)
1918

20-
// Dependency represents an imported package and its usage status.
2119
type Dependency struct {
2220
ImportPath string
2321
IsGno bool
2422
IsUsed bool
25-
IsIgnored bool // alias with `_` should be ignored
23+
IsIgnored bool // aliased as `_`
2624
}
2725

28-
type (
29-
// Dependencies is a map of import paths to their Dependency information.
30-
Dependencies map[string]*Dependency
26+
type Dependencies map[string]*Dependency
3127

32-
// FileMap is a map of filenames to their parsed AST representation.
33-
FileMap map[string]*ast.File
34-
)
35-
36-
// Package represents a Go/Gno package with its name and files.
37-
type Package struct {
38-
Name string
39-
Files FileMap
40-
}
41-
42-
// DetectGnoPackageImports analyzes the given file for Gno package imports and returns any issues found.
4328
func DetectGnoPackageImports(filename string) ([]tt.Issue, error) {
44-
dir := filepath.Dir(filename)
45-
46-
pkg, deps, err := analyzePackage(dir)
29+
file, deps, err := analyzeFile(filename)
4730
if err != nil {
48-
return nil, fmt.Errorf("error analyzing package: %w", err)
31+
return nil, fmt.Errorf("error analyzing file: %w", err)
4932
}
5033

51-
issues := runGnoPackageLinter(pkg, deps)
34+
issues := runGnoPackageLinter(file, deps)
5235

5336
for i := range issues {
5437
issues[i].Filename = filename
@@ -57,83 +40,52 @@ func DetectGnoPackageImports(filename string) ([]tt.Issue, error) {
5740
return issues, nil
5841
}
5942

60-
// parses all gno files and collect their imports and usage.
61-
func analyzePackage(dir string) (*Package, Dependencies, error) {
62-
pkg := &Package{
63-
Files: make(FileMap),
43+
func analyzeFile(filename string) (*ast.File, Dependencies, error) {
44+
content, err := os.ReadFile(filename)
45+
if err != nil {
46+
return nil, nil, err
6447
}
65-
deps := make(Dependencies)
6648

67-
files, err := filepath.Glob(filepath.Join(dir, "*.gno"))
49+
fset := token.NewFileSet()
50+
file, err := parser.ParseFile(fset, filename, content, parser.ParseComments)
6851
if err != nil {
6952
return nil, nil, err
7053
}
7154

72-
// 1. Parse all file contents and collect dependencies
73-
for _, file := range files {
74-
f, err := parseFile(file)
75-
if err != nil {
76-
return nil, nil, err
77-
}
78-
79-
pkg.Files[file] = f
80-
if pkg.Name == "" {
81-
pkg.Name = f.Name.Name
82-
}
83-
84-
for _, imp := range f.Imports {
85-
impPath := strings.Trim(imp.Path.Value, `"`)
86-
if _, exists := deps[impPath]; !exists {
87-
deps[impPath] = &Dependency{
88-
ImportPath: impPath,
89-
IsGno: isGnoPackage(impPath),
90-
IsUsed: false,
91-
IsIgnored: imp.Name != nil && imp.Name.Name == "_",
92-
}
93-
}
55+
deps := make(Dependencies)
56+
for _, imp := range file.Imports {
57+
impPath := strings.Trim(imp.Path.Value, `"`)
58+
deps[impPath] = &Dependency{
59+
ImportPath: impPath,
60+
IsGno: isGnoPackage(impPath),
61+
IsUsed: false,
62+
IsIgnored: imp.Name != nil && imp.Name.Name == "_",
9463
}
9564
}
9665

97-
// 2. Determine which dependencies are used
98-
for _, file := range pkg.Files {
99-
ast.Inspect(file, func(n ast.Node) bool {
100-
switch x := n.(type) {
101-
case *ast.SelectorExpr:
102-
if ident, ok := x.X.(*ast.Ident); ok {
103-
for _, imp := range file.Imports {
104-
if imp.Name != nil && imp.Name.Name == ident.Name {
105-
deps[strings.Trim(imp.Path.Value, `"`)].IsUsed = true
106-
} else if lastPart := getLastPart(strings.Trim(imp.Path.Value, `"`)); lastPart == ident.Name {
107-
deps[strings.Trim(imp.Path.Value, `"`)].IsUsed = true
108-
}
66+
// Determine which dependencies are used in this file
67+
ast.Inspect(file, func(n ast.Node) bool {
68+
switch x := n.(type) {
69+
case *ast.SelectorExpr:
70+
if ident, ok := x.X.(*ast.Ident); ok {
71+
for _, imp := range file.Imports {
72+
if imp.Name != nil && imp.Name.Name == ident.Name {
73+
deps[strings.Trim(imp.Path.Value, `"`)].IsUsed = true
74+
} else if lastPart := getLastPart(strings.Trim(imp.Path.Value, `"`)); lastPart == ident.Name {
75+
deps[strings.Trim(imp.Path.Value, `"`)].IsUsed = true
10976
}
11077
}
11178
}
112-
return true
113-
})
114-
}
79+
}
80+
return true
81+
})
11582

116-
return pkg, deps, nil
83+
return file, deps, nil
11784
}
11885

119-
func runGnoPackageLinter(pkg *Package, deps Dependencies) []tt.Issue {
86+
func runGnoPackageLinter(_ *ast.File, deps Dependencies) []tt.Issue {
12087
var issues []tt.Issue
12188

122-
for _, file := range pkg.Files {
123-
ast.Inspect(file, func(n ast.Node) bool {
124-
switch x := n.(type) {
125-
case *ast.SelectorExpr:
126-
// check unused imports
127-
if ident, ok := x.X.(*ast.Ident); ok {
128-
if dep, exists := deps[ident.Name]; exists {
129-
dep.IsUsed = true
130-
}
131-
}
132-
}
133-
return true
134-
})
135-
}
136-
13789
for impPath, dep := range deps {
13890
if !dep.IsUsed && !dep.IsIgnored {
13991
issue := tt.Issue{
@@ -151,16 +103,6 @@ func isGnoPackage(importPath string) bool {
151103
return strings.HasPrefix(importPath, GNO_PKG_PREFIX) || importPath == GNO_STD_PACKAGE
152104
}
153105

154-
func parseFile(filename string) (*ast.File, error) {
155-
content, err := os.ReadFile(filename)
156-
if err != nil {
157-
return nil, err
158-
}
159-
160-
fset := token.NewFileSet()
161-
return parser.ParseFile(fset, filename, content, parser.ParseComments)
162-
}
163-
164106
func getLastPart(path string) string {
165107
parts := strings.Split(path, "/")
166108
return parts[len(parts)-1]

internal/lints/gno_analyzer_test.go

Lines changed: 67 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -15,46 +15,78 @@ func TestRunLinter(t *testing.T) {
1515

1616
testDir := filepath.Join(filepath.Dir(current), "..", "..", "testdata", "pkg")
1717

18-
pkg, deps, err := analyzePackage(testDir)
19-
require.NoError(t, err)
20-
require.NotNil(t, pkg)
21-
require.NotNil(t, deps)
22-
23-
issues := runGnoPackageLinter(pkg, deps)
24-
25-
expectedIssues := []struct {
26-
rule string
27-
message string
18+
tests := []struct {
19+
filename string
20+
expectedIssues []struct {
21+
rule string
22+
message string
23+
}
24+
expectedDeps map[string]struct {
25+
isGno bool
26+
isUsed bool
27+
isIgnored bool
28+
}
2829
}{
29-
{"unused-import", "unused import: strings"},
30+
{
31+
filename: filepath.Join(testDir, "pkg0.gno"),
32+
expectedIssues: []struct {
33+
rule string
34+
message string
35+
}{
36+
{"unused-import", "unused import: strings"},
37+
},
38+
expectedDeps: map[string]struct {
39+
isGno bool
40+
isUsed bool
41+
isIgnored bool
42+
}{
43+
"fmt": {false, true, false},
44+
"gno.land/p/demo/ufmt": {true, true, false},
45+
"strings": {false, false, false},
46+
"std": {true, true, false},
47+
"gno.land/p/demo/diff": {true, false, true},
48+
},
49+
},
50+
{
51+
filename: filepath.Join(testDir, "pkg1.gno"),
52+
expectedIssues: []struct {
53+
rule string
54+
message string
55+
}{},
56+
expectedDeps: map[string]struct {
57+
isGno bool
58+
isUsed bool
59+
isIgnored bool
60+
}{
61+
"strings": {false, true, false},
62+
},
63+
},
3064
}
3165

32-
assert.Equal(t, len(expectedIssues), len(issues), "Number of issues doesn't match expected")
66+
for _, tc := range tests {
67+
t.Run(filepath.Base(tc.filename), func(t *testing.T) {
68+
file, deps, err := analyzeFile(tc.filename)
69+
require.NoError(t, err)
70+
require.NotNil(t, file)
3371

34-
for i, expected := range expectedIssues {
35-
assert.Equal(t, expected.rule, issues[i].Rule, "Rule doesn't match for issue %d", i)
36-
assert.Contains(t, issues[i].Message, expected.message, "Message doesn't match for issue %d", i)
37-
}
72+
issues := runGnoPackageLinter(file, deps)
3873

39-
expectedDeps := map[string]struct {
40-
isGno bool
41-
isUsed bool
42-
isIgnored bool
43-
}{
44-
"fmt": {false, true, false},
45-
"gno.land/p/demo/ufmt": {true, true, false},
46-
"strings": {false, false, false},
47-
"std": {true, true, false},
48-
"gno.land/p/demo/diff": {true, false, true},
49-
}
74+
assert.Equal(t, len(tc.expectedIssues), len(issues), "Number of issues doesn't match expected for %s", tc.filename)
5075

51-
for importPath, expected := range expectedDeps {
52-
dep, exists := deps[importPath]
53-
assert.True(t, exists, "Dependency %s not found", importPath)
54-
if exists {
55-
assert.Equal(t, expected.isGno, dep.IsGno, "IsGno mismatch for %s", importPath)
56-
assert.Equal(t, expected.isUsed, dep.IsUsed, "IsUsed mismatch for %s", importPath)
57-
assert.Equal(t, expected.isIgnored, dep.IsIgnored, "IsIgnored mismatch for %s", importPath)
58-
}
76+
for i, expected := range tc.expectedIssues {
77+
assert.Equal(t, expected.rule, issues[i].Rule, "Rule doesn't match for issue %d in %s", i, tc.filename)
78+
assert.Contains(t, issues[i].Message, expected.message, "Message doesn't match for issue %d in %s", i, tc.filename)
79+
}
80+
81+
for importPath, expected := range tc.expectedDeps {
82+
dep, exists := deps[importPath]
83+
assert.True(t, exists, "Dependency %s not found in %s", importPath, tc.filename)
84+
if exists {
85+
assert.Equal(t, expected.isGno, dep.IsGno, "IsGno mismatch for %s in %s", importPath, tc.filename)
86+
assert.Equal(t, expected.isUsed, dep.IsUsed, "IsUsed mismatch for %s in %s", importPath, tc.filename)
87+
assert.Equal(t, expected.isIgnored, dep.IsIgnored, "IsIgnored mismatch for %s in %s", importPath, tc.filename)
88+
}
89+
}
90+
})
5991
}
6092
}

internal/lints/loop_allocation.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func DetectLoopAllocation(filename string) ([]tt.Issue, error) {
2525
case *ast.CallExpr:
2626
if isAllocationFunction(innerNode) {
2727
issues = append(issues, tt.Issue{
28-
Rule: "loop-allocation",
28+
Rule: "loop-allocation",
2929
Message: "Potential unnecessary allocation inside loop",
3030
Start: fset.Position(innerNode.Pos()),
3131
End: fset.Position(innerNode.End()),

testdata/pkg/pkg1.gno

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package main
2+
3+
import (
4+
"strings"
5+
)
6+
7+
func main() {
8+
strings.Contains("foo", "o")
9+
}

0 commit comments

Comments
 (0)