Skip to content

Commit

Permalink
feat: Detect Missing Packages or Unused Packages From gno.mod File (#…
Browse files Browse the repository at this point in the history
…61)

* mod

* mod file handler

* change lint error name

* fix ci

* universal position type

* fix

* Revert "universal position type"

This reverts commit 3d4ac96.

* fix weird error

* stored in mod but not imported
  • Loading branch information
notJoon authored Sep 4, 2024
1 parent ff2a6ab commit 4f77c4e
Show file tree
Hide file tree
Showing 9 changed files with 315 additions and 3 deletions.
12 changes: 11 additions & 1 deletion formatter/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const (
EmitFormat = "emit-format"
SliceBound = "slice-bounds-check"
Defers = "defer-issues"
MissingModPackage = "gno-mod-tidy"
)

const tabWidth = 8
Expand Down Expand Up @@ -69,6 +70,8 @@ func getFormatter(rule string) IssueFormatter {
return &SliceBoundsCheckFormatter{}
case Defers:
return &DefersFormatter{}
case MissingModPackage:
return &MissingModPackageFormatter{}
default:
return &GeneralIssueFormatter{}
}
Expand Down Expand Up @@ -154,7 +157,7 @@ func (b *IssueFormatterBuilder) AddUnderlineAndMessage() *IssueFormatterBuilder
b.result.WriteString(lineStyle.Sprintf("%s| ", padding))

if startLine <= 0 || startLine > len(b.snippet.Lines) || endLine <= 0 || endLine > len(b.snippet.Lines) {
b.result.WriteString(messageStyle.Sprintf("Error: Invalid line numbers\n"))
b.result.WriteString(messageStyle.Sprintf("%s\n\n", b.issue.Message))
return b
}

Expand All @@ -172,6 +175,13 @@ func (b *IssueFormatterBuilder) AddUnderlineAndMessage() *IssueFormatterBuilder
return b
}

func (b *IssueFormatterBuilder) AddMessage() *IssueFormatterBuilder {
b.result.WriteString(messageStyle.Sprint(b.issue.Message))
b.result.WriteString("\n\n")

return b
}

func (b *IssueFormatterBuilder) AddSuggestion() *IssueFormatterBuilder {
if b.issue.Suggestion == "" {
return b
Expand Down
16 changes: 16 additions & 0 deletions formatter/missing_mod_pacakge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package formatter

import (
"github.com/gnoswap-labs/tlin/internal"
tt "github.com/gnoswap-labs/tlin/internal/types"
)

type MissingModPackageFormatter struct{}

func (f *MissingModPackageFormatter) Format(issue tt.Issue, snippet *internal.SourceCode) string {
builder := NewIssueFormatterBuilder(issue, snippet)
return builder.
AddHeader(errorHeader).
AddMessage().
Build()
}
29 changes: 28 additions & 1 deletion internal/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func (e *Engine) registerDefaultRules() {
&RepeatedRegexCompilationRule{},
&UselessBreakRule{},
&DeferRule{},
&MissingModPackageRule{},
)
}

Expand All @@ -55,6 +56,10 @@ func (e *Engine) AddRule(rule LintRule) {

// Run applies all lint rules to the given file and returns a slice of Issues.
func (e *Engine) Run(filename string) ([]tt.Issue, error) {
if strings.HasSuffix(filename, ".mod") {
return e.runModCheck(filename)
}

tempFile, err := e.prepareFile(filename)
if err != nil {
return nil, err
Expand Down Expand Up @@ -109,12 +114,29 @@ func (e *Engine) IgnoreRule(rule string) {
}

func (e *Engine) prepareFile(filename string) (string, error) {
if strings.HasSuffix(filename, "gno") {
if strings.HasSuffix(filename, ".gno") {
return createTempGoFile(filename)
}
return filename, nil
}

func (e *Engine) runModCheck(filename string) ([]tt.Issue, error) {
var allIssues []tt.Issue
for _, rule := range e.rules {
if e.ignoredRules[rule.Name()] {
continue
}
if modRule, ok := rule.(ModRule); ok {
issues, err := modRule.CheckMod(filename)
if err != nil {
return nil, fmt.Errorf("error checking .mod file: %w", err)
}
allIssues = append(allIssues, issues...)
}
}
return allIssues, nil
}

func (e *Engine) cleanupTemp(temp string) {
if temp != "" && strings.HasPrefix(filepath.Base(temp), "temp_") {
_ = os.Remove(temp)
Expand Down Expand Up @@ -177,3 +199,8 @@ func ReadSourceCode(filename string) (*SourceCode, error) {
lines := strings.Split(string(content), "\n")
return &SourceCode{Lines: lines}, nil
}

type ModRule interface {
LintRule
CheckMod(filename string) ([]tt.Issue, error)
}
102 changes: 102 additions & 0 deletions internal/lints/missing_package_mod.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package lints

import (
"bufio"
"fmt"
"go/ast"
"go/token"
"os"
"path/filepath"
"strings"

tt "github.com/gnoswap-labs/tlin/internal/types"
)

func DetectMissingModPackage(filename string, node *ast.File, fset *token.FileSet) ([]tt.Issue, error) {
dir := filepath.Dir(filename)
modFile := filepath.Join(dir, "gno.mod")

requiredPackages, err := extractImports(node)
if err != nil {
return nil, fmt.Errorf("failed to extract imports: %w", err)
}

declaredPackages, err := extractDeclaredPackages(modFile)
if err != nil {
return nil, fmt.Errorf("failed to extract declared packages: %w", err)
}

var issues []tt.Issue

var unusedPackages []string
for pkg := range declaredPackages {
if _, ok := requiredPackages[pkg]; !ok {
unusedPackages = append(unusedPackages, pkg)
}
}

if len(unusedPackages) > 0 {
issue := tt.Issue{
Rule: "gno-mod-tidy",
Filename: modFile,
Start: token.Position{Filename: modFile},
End: token.Position{Filename: modFile},
Message: fmt.Sprintf("Packages %s are declared in gno.mod file but not imported.\nRun `gno mod tidy`", strings.Join(unusedPackages, ", ")),
}
issues = append(issues, issue)
}

for pkg := range requiredPackages {
if !declaredPackages[pkg] {
issue := tt.Issue{
Rule: "gno-mod-tidy",
Filename: modFile,
Start: token.Position{Filename: modFile},
End: token.Position{Filename: modFile},
Message: fmt.Sprintf("Package %s is imported but not declared in gno.mod file. Please consider to remove.\nRun `gno mod tidy`", pkg),
}
issues = append(issues, issue)
}
}

return issues, nil
}

func extractImports(node *ast.File) (map[string]bool, error) {
imports := make(map[string]bool)
for _, imp := range node.Imports {
if imp.Path != nil {
path := strings.Trim(imp.Path.Value, "\"")
if strings.HasPrefix(path, "gno.land/p/") || strings.HasPrefix(path, "gno.land/r/") {
imports[path] = true
}
}
}
return imports, nil
}

func extractDeclaredPackages(modFile string) (map[string]bool, error) {
file, err := os.Open(modFile)
if err != nil {
return nil, err
}
defer file.Close()

packages := make(map[string]bool)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "gno.land/p/") || strings.HasPrefix(line, "gno.land/r/") {
parts := strings.Fields(line)
if len(parts) >= 2 {
packages[parts[0]] = true
}
}
}

if err := scanner.Err(); err != nil {
return nil, err
}

return packages, nil
}
124 changes: 124 additions & 0 deletions internal/lints/missing_package_mod_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package lints

import (
"go/parser"
"go/token"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestDetectMissingPackageInMod(t *testing.T) {
tests := []struct {
name string
gnoContent string
modContent string
expectedIssues int
}{
{
name: "No missing packages",
gnoContent: `
package foo
import (
"gno.land/p/demo/avl"
"gno.land/r/demo/users"
)
func SomeFunc() {}
`,
modContent: `
module foo
require (
gno.land/p/demo/avl v0.0.0-latest
gno.land/r/demo/users v0.0.0-latest
)
`,
expectedIssues: 0,
},
{
name: "One missing package",
gnoContent: `
package foo
import (
"gno.land/p/demo/avl"
"gno.land/r/demo/users"
)
func Foo() {}
`,
modContent: `
module foo
require (
gno.land/p/demo/avl v0.0.0-latest
)
`,
expectedIssues: 1,
},
{
name: "Multiple missing packages",
gnoContent: `
package bar
import (
"gno.land/p/demo/avl"
"gno.land/r/demo/users"
"gno.land/p/demo/ufmt"
)
func main() {}
`,
modContent: `
module bar
require (
gno.land/p/demo/avl v0.0.0-latest
)
`,
expectedIssues: 2,
},
{
name: "declared but not imported",
gnoContent: `
package bar
func main() {}
`,
modContent: `
module bar
require (
gno.land/p/demo/avl v0.0.0-latest
)
`,
expectedIssues: 1,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

gnoFile := filepath.Join(tmpDir, "main.gno")
err = os.WriteFile(gnoFile, []byte(tt.gnoContent), 0644)
require.NoError(t, err)

modFile := filepath.Join(tmpDir, "gno.mod")
err = os.WriteFile(modFile, []byte(tt.modContent), 0644)
require.NoError(t, err)

fset := token.NewFileSet()
node, err := parser.ParseFile(fset, gnoFile, nil, parser.ParseComments)
require.NoError(t, err)

issues, err := DetectMissingModPackage(gnoFile, node, fset)

require.NoError(t, err)
assert.Len(t, issues, tt.expectedIssues)

for _, issue := range issues {
assert.Equal(t, "gno-mod-tidy", issue.Rule)
assert.Equal(t, modFile, issue.Filename)
println(issue.String())
}
})
}
}
10 changes: 10 additions & 0 deletions internal/rule_set.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ func (r *DeferRule) Name() string {
return "defer-issues"
}

type MissingModPackageRule struct{}

func (r *MissingModPackageRule) Check(filename string, node *ast.File, fset *token.FileSet) ([]tt.Issue, error) {
return lints.DetectMissingModPackage(filename, node, fset)
}

func (r *MissingModPackageRule) Name() string {
return "gno-mod-tidy"
}

// -----------------------------------------------------------------------------
// Regex related rules

Expand Down
11 changes: 10 additions & 1 deletion internal/types/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package types

import "go/token"
import (
"fmt"
"go/token"
)

// Issue represents a lint issue found in the code base.
type Issue struct {
Expand All @@ -14,3 +17,9 @@ type Issue struct {
End token.Position
Confidence float64 // 0.0 to 1.0
}

func (i Issue) String() string {
return fmt.Sprintf(
"rule: %s, filename: %s, message: %s, start: %s, end: %s, confidence: %.2f",
i.Rule, i.Filename, i.Message, i.Start, i.End, i.Confidence)
}
9 changes: 9 additions & 0 deletions testdata/missing_mod/foo.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package foo

import (
"gno.land/p/demo/ufmt"
)

func main() {
ufmt.Println("Hello, World!")
}
5 changes: 5 additions & 0 deletions testdata/missing_mod/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module foo

require (
gno.land/p/demo/avl v0.0.0-latest
)

0 comments on commit 4f77c4e

Please sign in to comment.