Skip to content
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
271 changes: 271 additions & 0 deletions fixer/fixer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
package fixer

import (
"bytes"
"fmt"
"go/ast"
"go/format"
"go/parser"
"go/token"
"os"
"strings"

"github.com/unsaid-dev/goperf/rules"
)

// Fix represents an auto-fixable change
type Fix struct {
File string
Line int
Original string
Fixed string
Rule string
Applied bool
}

// Fixer handles automatic code fixes
type Fixer struct {
DryRun bool
Verbose bool
}

// NewFixer creates a new fixer
func NewFixer(dryRun, verbose bool) *Fixer {
return &Fixer{
DryRun: dryRun,
Verbose: verbose,
}
}

// FixIssues attempts to fix the given issues
func (f *Fixer) FixIssues(issues []rules.Issue) []Fix {
var fixes []Fix

// Group issues by file
byFile := make(map[string][]rules.Issue)
for _, issue := range issues {
byFile[issue.File] = append(byFile[issue.File], issue)
}

for file, fileIssues := range byFile {
fileFixes := f.fixFile(file, fileIssues)
fixes = append(fixes, fileFixes...)
}

return fixes
}

func (f *Fixer) fixFile(filename string, issues []rules.Issue) []Fix {
var fixes []Fix

src, err := os.ReadFile(filename)
if err != nil {
return fixes
}

fset := token.NewFileSet()
astFile, err := parser.ParseFile(fset, filename, src, parser.ParseComments)
if err != nil {
return fixes
}

modified := false
lines := strings.Split(string(src), "\n")

for _, issue := range issues {
fix := f.attemptFix(issue, astFile, fset, lines)
if fix != nil {
fixes = append(fixes, *fix)
if !f.DryRun && fix.Fixed != "" {
modified = true
}
}
}

if modified && !f.DryRun {
// Format and write back
var buf bytes.Buffer
if err := format.Node(&buf, fset, astFile); err == nil {
os.WriteFile(filename, buf.Bytes(), 0644)

Check failure on line 89 in fixer/fixer.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `os.WriteFile` is not checked (errcheck)
}
}

return fixes
}

func (f *Fixer) attemptFix(issue rules.Issue, file *ast.File, fset *token.FileSet, lines []string) *Fix {
switch issue.Rule {
case "string-concat-loop":
return f.fixStringConcat(issue, file, fset, lines)
case "unpreallocated-slice":
return f.fixUnpreallocatedSlice(issue, file, fset, lines)
case "missing-body-close":
return f.fixMissingBodyClose(issue, file, fset, lines)
case "context-leak":
return f.fixContextLeak(issue, file, fset, lines)
default:
// Return suggestion-only fix
return &Fix{
File: issue.File,
Line: issue.Line,
Original: getLine(lines, issue.Line),
Fixed: "", // No auto-fix available
Rule: issue.Rule,
Applied: false,
}
}
}

func (f *Fixer) fixStringConcat(issue rules.Issue, file *ast.File, fset *token.FileSet, lines []string) *Fix {
// Find the function containing this line
line := issue.Line
original := getLine(lines, line)

// Suggest using strings.Builder
fix := &Fix{
File: issue.File,
Line: line,
Original: original,
Rule: issue.Rule,
Applied: false,
}

// Generate suggestion (actual AST modification is complex)
fix.Fixed = "// TODO: Replace with strings.Builder\n" +
"// var b strings.Builder\n" +
"// for ... { b.WriteString(s) }\n" +
"// result := b.String()"

return fix
}

func (f *Fixer) fixUnpreallocatedSlice(issue rules.Issue, file *ast.File, fset *token.FileSet, lines []string) *Fix {
line := issue.Line
original := getLine(lines, line)

fix := &Fix{
File: issue.File,
Line: line,
Original: original,
Rule: issue.Rule,
Applied: false,
}

// Extract slice name from message
msg := issue.Message
start := strings.Index(msg, "'")
end := strings.LastIndex(msg, "'")
if start >= 0 && end > start {
sliceName := msg[start+1 : end]
fix.Fixed = fmt.Sprintf("%s = make([]T, 0, expectedSize) // Preallocate %s", sliceName, sliceName)
}

return fix
}

func (f *Fixer) fixMissingBodyClose(issue rules.Issue, file *ast.File, fset *token.FileSet, lines []string) *Fix {
line := issue.Line
original := getLine(lines, line)

// Find the variable name from the message
msg := issue.Message
start := strings.Index(msg, "'")
end := strings.LastIndex(msg, "'")

varName := "resp"
if start >= 0 && end > start {
varName = msg[start+1 : end]
}

fix := &Fix{
File: issue.File,
Line: line,
Original: original,
Rule: issue.Rule,
Applied: false,
Fixed: fmt.Sprintf("defer %s.Body.Close()", varName),
}

return fix
}

func (f *Fixer) fixContextLeak(issue rules.Issue, file *ast.File, fset *token.FileSet, lines []string) *Fix {
line := issue.Line
original := getLine(lines, line)

// Extract cancel function name from message
msg := issue.Message
start := strings.Index(msg, "'")
end := strings.LastIndex(msg, "'")

cancelName := "cancel"
if start >= 0 && end > start {
cancelName = msg[start+1 : end]
}

fix := &Fix{
File: issue.File,
Line: line,
Original: original,
Rule: issue.Rule,
Applied: false,
Fixed: fmt.Sprintf("defer %s()", cancelName),
}

return fix
}

func getLine(lines []string, lineNum int) string {
if lineNum > 0 && lineNum <= len(lines) {
return lines[lineNum-1]
}
return ""
}

// PrintFixes displays the fixes in a readable format
func PrintFixes(fixes []Fix, dryRun bool) {
if len(fixes) == 0 {
fmt.Println("No auto-fixes available for the detected issues.")
return
}

if dryRun {
fmt.Println("=== DRY RUN: Suggested fixes (no files modified) ===\n")

Check failure on line 233 in fixer/fixer.go

View workflow job for this annotation

GitHub Actions / lint

printf: `fmt.Println` arg list ends with redundant newline (govet)

Check failure on line 233 in fixer/fixer.go

View workflow job for this annotation

GitHub Actions / test (1.22)

fmt.Println arg list ends with redundant newline

Check failure on line 233 in fixer/fixer.go

View workflow job for this annotation

GitHub Actions / test (1.23)

fmt.Println arg list ends with redundant newline

Check failure on line 233 in fixer/fixer.go

View workflow job for this annotation

GitHub Actions / test (1.21)

fmt.Println arg list ends with redundant newline
} else {
fmt.Println("=== Applied fixes ===\n")

Check failure on line 235 in fixer/fixer.go

View workflow job for this annotation

GitHub Actions / lint

printf: `fmt.Println` arg list ends with redundant newline (govet)

Check failure on line 235 in fixer/fixer.go

View workflow job for this annotation

GitHub Actions / test (1.22)

fmt.Println arg list ends with redundant newline

Check failure on line 235 in fixer/fixer.go

View workflow job for this annotation

GitHub Actions / test (1.23)

fmt.Println arg list ends with redundant newline

Check failure on line 235 in fixer/fixer.go

View workflow job for this annotation

GitHub Actions / test (1.21)

fmt.Println arg list ends with redundant newline
}

for _, fix := range fixes {
fmt.Printf("File: %s:%d\n", fix.File, fix.Line)
fmt.Printf("Rule: %s\n", fix.Rule)
if fix.Original != "" {
fmt.Printf("Original: %s\n", strings.TrimSpace(fix.Original))
}
if fix.Fixed != "" {
fmt.Printf("Fix: %s\n", fix.Fixed)
} else {
fmt.Println("Fix: Manual intervention required - see issue suggestion")
}
fmt.Println()
}
}

// GenerateDiff creates a unified diff for review
func GenerateDiff(fixes []Fix) string {
var buf bytes.Buffer

for _, fix := range fixes {
if fix.Fixed == "" {
continue
}

buf.WriteString(fmt.Sprintf("--- a/%s\n", fix.File))
buf.WriteString(fmt.Sprintf("+++ b/%s\n", fix.File))
buf.WriteString(fmt.Sprintf("@@ -%d,1 +%d,1 @@\n", fix.Line, fix.Line))
buf.WriteString(fmt.Sprintf("-%s\n", fix.Original))
buf.WriteString(fmt.Sprintf("+%s\n", fix.Fixed))
buf.WriteString("\n")
}

return buf.String()
}
60 changes: 53 additions & 7 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,21 @@
"path/filepath"
"strings"

"github.com/unsaid-dev/goperf/fixer"
"github.com/unsaid-dev/goperf/reporter"
"github.com/unsaid-dev/goperf/rules"
)

var (
rulesFlag = flag.String("rules", "all", "Comma-separated rules to run: algorithm,allocation,database,concurrency,io,cache,all")
formatFlag = flag.String("format", "console", "Output format: console, json")
rulesFlag = flag.String("rules", "all", "Comma-separated rules to run: algorithm,allocation,database,concurrency,io,cache,context,memory,benchmark,all")
formatFlag = flag.String("format", "console", "Output format: console, json, diff")
failOnFlag = flag.String("fail-on", "", "Exit with code 1 if issues at this level or higher: low, medium, high, critical")
contextFlag = flag.Int("context", 3, "Lines of context to show around issues")
ignoreFlag = flag.String("ignore", "", "Comma-separated paths to ignore")
verboseFlag = flag.Bool("verbose", false, "Show verbose output")
versionFlag = flag.Bool("version", false, "Show version")
fixFlag = flag.Bool("fix", false, "Automatically fix issues where possible")
dryRunFlag = flag.Bool("dry-run", false, "Show fixes without applying them (use with --fix)")
)

var version = "0.1.0"
Expand All @@ -38,24 +41,36 @@
goperf --rules=algorithm ./internal/ # Only algorithm rules
goperf --format=json ./... # JSON output for CI
goperf --fail-on=high ./... # Exit 1 if high+ issues
goperf --fix --dry-run ./... # Preview auto-fixes
goperf --fix ./... # Apply auto-fixes

Flags:
`)
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, `
Rule Categories:
algorithm - O(n²) loops, repeated linear searches
allocation - Unpreallocated slices, string concatenation
database - N+1 queries, SQL in loops
allocation - Unpreallocated slices, string concatenation, interface boxing
database - N+1 queries, SQL in loops, connection pool issues
concurrency - Lock contention, unbuffered channels
io - Unbuffered I/O, sequential operations
cache - Repeated computations, missing memoization
io - Unbuffered I/O, HTTP body handling, response buffering
cache - Repeated regex/template compilation, JSON schema in loops
context - Missing timeouts, context leaks, context.Background in handlers
memory - Large struct copying, pprof in hot paths, heap escapes
benchmark - Functions with performance patterns that need benchmarks

Severity Levels:
critical - Will cause production issues
high - Significant performance impact
medium - Moderate impact, should fix
low - Minor optimization opportunity

Auto-Fix Support:
The following rules support auto-fix:
- string-concat-loop → strings.Builder suggestion
- unpreallocated-slice → make() with capacity
- missing-body-close → defer Body.Close()
- context-leak → defer cancel()
`)
}
flag.Parse()
Expand Down Expand Up @@ -105,11 +120,42 @@
// Run analysis
issues := analyzer.Analyze(files)

// Handle fix mode
if *fixFlag {
f := fixer.NewFixer(*dryRunFlag, *verboseFlag)
fixes := f.FixIssues(issues)

if *formatFlag == "diff" {
fmt.Println(fixer.GenerateDiff(fixes))
} else {
fixer.PrintFixes(fixes, *dryRunFlag)
}

// Still show summary
if len(issues) > 0 {
fmt.Printf("\nTotal issues found: %d\n", len(issues))
fixable := 0
for _, fix := range fixes {
if fix.Fixed != "" {
fixable++
}
}
fmt.Printf("Auto-fixable: %d\n", fixable)
}
return
}

// Report results
var rep reporter.Reporter
switch *formatFlag {
case "json":
rep = &reporter.JSONReporter{}
case "diff":
// Generate diff output even without --fix
f := fixer.NewFixer(true, *verboseFlag)
fixes := f.FixIssues(issues)
fmt.Println(fixer.GenerateDiff(fixes))
return
default:
rep = &reporter.ConsoleReporter{Context: *contextFlag}
}
Expand All @@ -130,7 +176,7 @@

func parseRules(rulesStr string) []string {
if rulesStr == "all" {
return []string{"algorithm", "allocation", "database", "concurrency", "io", "cache"}
return []string{"algorithm", "allocation", "database", "concurrency", "io", "cache", "context", "memory", "benchmark"}
}
return strings.Split(rulesStr, ",")
}
Expand Down Expand Up @@ -219,7 +265,7 @@
Low int `json:"low"`
}

func toJSON(issues []rules.Issue) string {

Check failure on line 268 in main.go

View workflow job for this annotation

GitHub Actions / lint

func `toJSON` is unused (unused)
summary := Summary{Total: len(issues)}
for _, issue := range issues {
switch issue.Severity {
Expand Down
Loading
Loading