Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

basic lint engine implementation #1

Merged
merged 2 commits into from
Jul 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module github.com/gnoswap-labs/lint

go 1.22.2

require github.com/stretchr/testify v1.9.0

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
124 changes: 124 additions & 0 deletions lint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package lint

import (
"fmt"
"go/ast"
"go/token"
"os"
"strings"
)

// SourceCode stores the content of a source code file.
type SourceCode struct {
Lines []string
}

// ReadSourceFile reads the content of a file and returns it as a `SourceCode` struct.
func ReadSourceCode(filename string) (*SourceCode, error) {
content, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
lines := strings.Split(string(content), "\n")
return &SourceCode{Lines: lines}, nil
}

// Issue represents a lint issue found in the code base.
type Issue struct {
Rule string
Filename string
Start token.Position
End token.Position
Message string
}

// Rule is the interface that wraps the basic Check method.
//
// Check examines an AST node and returns true if the rule is violated,
// along with a message describing the issue.
type Rule interface {
Check(fset *token.FileSet, node ast.Node) (bool, string)
}

// Engine manages a set of lint rules and runs them on AST nodes.
type Engine struct {
rules map[string]Rule
}

// NewEngine manages a new lint engine with an empty set of rules.
func NewEngine() *Engine {
return &Engine{
rules: make(map[string]Rule),
}
}

// AddRule adds a new rule to the engine with the given name.
func (e *Engine) AddRule(name string, rule Rule) {
e.rules[name] = rule
}

// Run applies all the rules to the given AST file and returns a slice of issues.
func (e *Engine) Run(fset *token.FileSet, f *ast.File) []Issue {
var issues []Issue

ast.Inspect(f, func(node ast.Node) bool {
for name, rule := range e.rules {
if ok, message := rule.Check(fset, node); ok {
start := fset.Position(node.Pos())
end := fset.Position(node.End())
issues = append(issues, Issue{
Rule: name,
Filename: start.Filename,
Start: start,
End: end,
Message: message,
})
}
}
return true
})

return issues
}

func FormatIssuesWithArrows(issues []Issue, sourceCode *SourceCode) string {
var builder strings.Builder
for _, issue := range issues {
// Write issue location and message
builder.WriteString(fmt.Sprintf("%s:%d:%d: %s: %s\n",
issue.Filename, issue.Start.Line, issue.Start.Column, issue.Rule, issue.Message))

// Write the problematic line
line := sourceCode.Lines[issue.Start.Line-1]
builder.WriteString(line + "\n")

// Calculate the visual column, considering tabs
visualStartColumn := calculateVisualColumn(line, issue.Start.Column)
visualEndColumn := calculateVisualColumn(line, issue.End.Column)

// Write the arrow pointing to the issue
builder.WriteString(strings.Repeat(" ", visualStartColumn-1))
arrowLength := visualEndColumn - visualStartColumn
if arrowLength < 1 {
arrowLength = 1
}
builder.WriteString(strings.Repeat("^", arrowLength))
builder.WriteString("\n")
}
return builder.String()
}

func calculateVisualColumn(line string, column int) int {
visualColumn := 0
for i, ch := range line {
if i+1 >= column {
break
}
if ch == '\t' {
visualColumn += 8 - (visualColumn % 8)
} else {
visualColumn++
}
}
return visualColumn
}
64 changes: 64 additions & 0 deletions lint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package lint

import (
"go/parser"
"go/token"
"strings"
"testing"

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

func TestLintRule(t *testing.T) {
tests := []struct {
name string
code string
ruleName string
expected bool
}{
{
name: "Detect empty if statement",
code: "package main\n\nfunc main() {\n\tif true {}\n}",
ruleName: "no-empty-if",
expected: true,
},
{
name: "No empty if statement",
code: "package main\n\nfunc main() {\n\tif true { println(\"Hello\") }\n}",
ruleName: "no-empty-if",
expected: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "test.go", tt.code, 0)
if err != nil {
t.Fatalf("failed to parse code: %v", err)
}

engine := NewEngine()
engine.AddRule(tt.ruleName, NoEmptyIfRule{})

issues := engine.Run(fset, f)

if tt.expected && len(issues) == 0 {
t.Errorf("expected to find issues, but found none")
}
if !tt.expected && len(issues) > 0 {
t.Errorf("expected to find no issues, but found %d", len(issues))
}

if len(issues) > 0 {
sourceCode := &SourceCode{Lines: strings.Split(tt.code, "\n")}
formattedIssues := FormatIssuesWithArrows(issues, sourceCode)
t.Logf("Found issues with arrows:\n%s", formattedIssues)

assert.Contains(t, formattedIssues, "no-empty-if: empty if statement")
assert.Contains(t, formattedIssues, "if true {}")
assert.Contains(t, formattedIssues, "^^^^^^^^^^")
}
})
}
}
20 changes: 20 additions & 0 deletions rules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package lint

import (
"go/ast"
"go/token"
)

// Define linting rules

// NoEmptyIfRule checks for empty if statements.
type NoEmptyIfRule struct{}

func (r NoEmptyIfRule) Check(fset *token.FileSet, node ast.Node) (bool, string) {
if ifStmt, ok := node.(*ast.IfStmt); ok {
if len(ifStmt.Body.List) == 0 {
return true, "empty if statement"
}
}
return false, ""
}