diff --git a/cmd/tlin/main.go b/cmd/tlin/main.go index 243a791..1033e87 100644 --- a/cmd/tlin/main.go +++ b/cmd/tlin/main.go @@ -9,7 +9,6 @@ import ( "go/parser" "go/token" "os" - "path/filepath" "sort" "strings" "time" @@ -18,8 +17,8 @@ import ( "github.com/gnoswap-labs/tlin/internal" "github.com/gnoswap-labs/tlin/internal/analysis/cfg" "github.com/gnoswap-labs/tlin/internal/fixer" - "github.com/gnoswap-labs/tlin/internal/lints" tt "github.com/gnoswap-labs/tlin/internal/types" + "github.com/gnoswap-labs/tlin/lint" "go.uber.org/zap" ) @@ -42,11 +41,6 @@ type Config struct { ConfidenceThreshold float64 } -type LintEngine interface { - Run(filePath string) ([]tt.Issue, error) - IgnoreRule(rule string) -} - func main() { logger, _ := zap.NewProduction() defer logger.Sync() @@ -56,7 +50,7 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), config.Timeout) defer cancel() - engine, err := internal.NewEngine(".") + engine, err := internal.NewEngine(".", nil) if err != nil { logger.Fatal("Failed to initialize lint engine", zap.Error(err)) } @@ -133,8 +127,8 @@ func runWithTimeout(ctx context.Context, f func()) { } } -func runNormalLintProcess(ctx context.Context, logger *zap.Logger, engine LintEngine, paths []string, jsonOutput string) { - issues, err := processFiles(ctx, logger, engine, paths, processFile) +func runNormalLintProcess(ctx context.Context, logger *zap.Logger, engine lint.LintEngine, paths []string, jsonOutput string) { + issues, err := lint.ProcessFiles(ctx, logger, engine, paths, lint.ProcessFile) if err != nil { logger.Error("Error processing files", zap.Error(err)) os.Exit(1) @@ -148,8 +142,8 @@ func runNormalLintProcess(ctx context.Context, logger *zap.Logger, engine LintEn } func runCyclomaticComplexityAnalysis(ctx context.Context, logger *zap.Logger, paths []string, threshold int, jsonOutput string) { - issues, err := processFiles(ctx, logger, nil, paths, func(_ LintEngine, path string) ([]tt.Issue, error) { - return processCyclomaticComplexity(path, threshold) + issues, err := lint.ProcessFiles(ctx, logger, nil, paths, func(_ lint.LintEngine, path string) ([]tt.Issue, error) { + return lint.ProcessCyclomaticComplexity(path, threshold) }) if err != nil { logger.Error("Error processing files for cyclomatic complexity", zap.Error(err)) @@ -192,11 +186,11 @@ func runCFGAnalysis(_ context.Context, logger *zap.Logger, paths []string, funcN } } -func runAutoFix(ctx context.Context, logger *zap.Logger, engine LintEngine, paths []string, dryRun bool, confidenceThreshold float64) { +func runAutoFix(ctx context.Context, logger *zap.Logger, engine lint.LintEngine, paths []string, dryRun bool, confidenceThreshold float64) { fix := fixer.New(dryRun, confidenceThreshold) for _, path := range paths { - issues, err := processPath(ctx, logger, engine, path, processFile) + issues, err := lint.ProcessPath(ctx, logger, engine, path, lint.ProcessFile) if err != nil { logger.Error("error processing path", zap.String("path", path), zap.Error(err)) continue @@ -209,64 +203,6 @@ func runAutoFix(ctx context.Context, logger *zap.Logger, engine LintEngine, path } } -func processFiles(ctx context.Context, logger *zap.Logger, engine LintEngine, paths []string, processor func(LintEngine, string) ([]tt.Issue, error)) ([]tt.Issue, error) { - var allIssues []tt.Issue - for _, path := range paths { - issues, err := processPath(ctx, logger, engine, path, processor) - if err != nil { - logger.Error("Error processing path", zap.String("path", path), zap.Error(err)) - return nil, err - } - allIssues = append(allIssues, issues...) - } - - return allIssues, nil -} - -func processPath(_ context.Context, logger *zap.Logger, engine LintEngine, path string, processor func(LintEngine, string) ([]tt.Issue, error)) ([]tt.Issue, error) { - info, err := os.Stat(path) - if err != nil { - return nil, fmt.Errorf("error accessing %s: %w", path, err) - } - - var issues []tt.Issue - if info.IsDir() { - err = filepath.Walk(path, func(filePath string, fileInfo os.FileInfo, err error) error { - if err != nil { - return err - } - if !fileInfo.IsDir() && hasDesiredExtension(filePath) { - fileIssues, err := processor(engine, filePath) - if err != nil { - logger.Error("Error processing file", zap.String("file", filePath), zap.Error(err)) - } else { - issues = append(issues, fileIssues...) - } - } - return nil - }) - if err != nil { - return nil, fmt.Errorf("error walking directory %s: %w", path, err) - } - } else if hasDesiredExtension(path) { - fileIssues, err := processor(engine, path) - if err != nil { - return nil, err - } - issues = append(issues, fileIssues...) - } - - return issues, nil -} - -func processFile(engine LintEngine, filePath string) ([]tt.Issue, error) { - return engine.Run(filePath) -} - -func processCyclomaticComplexity(path string, threshold int) ([]tt.Issue, error) { - return lints.DetectHighCyclomaticComplexity(path, threshold) -} - func printIssues(logger *zap.Logger, issues []tt.Issue, jsonOutput string) { issuesByFile := make(map[string][]tt.Issue) for _, issue := range issues { @@ -287,7 +223,7 @@ func printIssues(logger *zap.Logger, issues []tt.Issue, jsonOutput string) { logger.Error("Error reading source file", zap.String("file", filename), zap.Error(err)) continue } - output := formatter.GenetateFormattedIssue(fileIssues, sourceCode) + output := formatter.GenerateFormattedIssue(fileIssues, sourceCode) fmt.Println(output) } } else { @@ -309,7 +245,3 @@ func printIssues(logger *zap.Logger, issues []tt.Issue, jsonOutput string) { } } } - -func hasDesiredExtension(path string) bool { - return filepath.Ext(path) == ".go" || filepath.Ext(path) == ".gno" -} diff --git a/cmd/tlin/main_test.go b/cmd/tlin/main_test.go index c43f0a5..4ff29dc 100644 --- a/cmd/tlin/main_test.go +++ b/cmd/tlin/main_test.go @@ -29,6 +29,11 @@ func (m *mockLintEngine) Run(filePath string) ([]types.Issue, error) { return args.Get(0).([]types.Issue), args.Error(1) } +func (m *mockLintEngine) RunSource(source []byte) ([]types.Issue, error) { + args := m.Called(source) + return args.Get(0).([]types.Issue), args.Error(1) +} + func (m *mockLintEngine) IgnoreRule(rule string) { m.Called(rule) } @@ -100,116 +105,6 @@ func TestParseFlags(t *testing.T) { } } -func TestProcessFile(t *testing.T) { - t.Parallel() - expectedIssues := []types.Issue{ - { - Rule: "test-rule", - Filename: "test.go", - Start: token.Position{Filename: "test.go", Offset: 0, Line: 1, Column: 1}, - End: token.Position{Filename: "test.go", Offset: 10, Line: 1, Column: 11}, - Message: "Test issue", - }, - } - mockEngine := setupMockEngine(expectedIssues, "test.go") - - issues, err := processFile(mockEngine, "test.go") - - assert.NoError(t, err) - assert.Equal(t, expectedIssues, issues) - mockEngine.AssertExpectations(t) -} - -func TestProcessPath(t *testing.T) { - t.Parallel() - logger, _ := zap.NewProduction() - ctx := context.Background() - - tempDir, err := os.MkdirTemp("", "test") - assert.NoError(t, err) - defer os.RemoveAll(tempDir) - - paths := createTempFiles(t, tempDir, "test1.go", "test2.go") - - expectedIssues := []types.Issue{ - { - Rule: "rule1", - Filename: paths[0], - Start: token.Position{Filename: paths[0], Offset: 0, Line: 1, Column: 1}, - End: token.Position{Filename: paths[0], Offset: 10, Line: 1, Column: 11}, - Message: "Test issue 1", - }, - { - Rule: "rule2", - Filename: paths[1], - Start: token.Position{Filename: paths[1], Offset: 0, Line: 1, Column: 1}, - End: token.Position{Filename: paths[1], Offset: 10, Line: 1, Column: 11}, - Message: "Test issue 2", - }, - } - - mockEngine := new(mockLintEngine) - mockEngine.On("Run", paths[0]).Return([]types.Issue{expectedIssues[0]}, nil) - mockEngine.On("Run", paths[1]).Return([]types.Issue{expectedIssues[1]}, nil) - - issues, err := processPath(ctx, logger, mockEngine, tempDir, processFile) - - assert.NoError(t, err) - assert.Len(t, issues, 2) - assert.Contains(t, issues, expectedIssues[0]) - assert.Contains(t, issues, expectedIssues[1]) - mockEngine.AssertExpectations(t) -} - -func TestProcessFiles(t *testing.T) { - t.Parallel() - logger, _ := zap.NewProduction() - ctx := context.Background() - - tempDir, err := os.MkdirTemp("", "test") - assert.NoError(t, err) - defer os.RemoveAll(tempDir) - - paths := createTempFiles(t, tempDir, "test1.go", "test2.go") - - expectedIssues := []types.Issue{ - { - Rule: "rule1", - Filename: paths[0], - Start: token.Position{Filename: paths[0], Offset: 0, Line: 1, Column: 1}, - End: token.Position{Filename: paths[0], Offset: 10, Line: 1, Column: 11}, - Message: "Test issue 1", - }, - { - Rule: "rule2", - Filename: paths[1], - Start: token.Position{Filename: paths[1], Offset: 0, Line: 1, Column: 1}, - End: token.Position{Filename: paths[1], Offset: 10, Line: 1, Column: 11}, - Message: "Test issue 2", - }, - } - - mockEngine := new(mockLintEngine) - mockEngine.On("Run", paths[0]).Return([]types.Issue{expectedIssues[0]}, nil) - mockEngine.On("Run", paths[1]).Return([]types.Issue{expectedIssues[1]}, nil) - - issues, err := processFiles(ctx, logger, mockEngine, paths, processFile) - - assert.NoError(t, err) - assert.Len(t, issues, 2) - assert.Contains(t, issues, expectedIssues[0]) - assert.Contains(t, issues, expectedIssues[1]) - mockEngine.AssertExpectations(t) -} - -func TestHasDesiredExtension(t *testing.T) { - t.Parallel() - assert.True(t, hasDesiredExtension("test.go")) - assert.True(t, hasDesiredExtension("test.gno")) - assert.False(t, hasDesiredExtension("test.txt")) - assert.False(t, hasDesiredExtension("test")) -} - func TestRunWithTimeout(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) diff --git a/formatter/builder.go b/formatter/builder.go index e0d0989..51065d5 100644 --- a/formatter/builder.go +++ b/formatter/builder.go @@ -39,9 +39,9 @@ type IssueFormatter interface { Format(issue tt.Issue, snippet *internal.SourceCode) string } -// GenetateFormattedIssue formats a slice of issues into a human-readable string. +// GenerateFormattedIssue formats a slice of issues into a human-readable string. // It uses the appropriate formatter for each issue based on its rule. -func GenetateFormattedIssue(issues []tt.Issue, snippet *internal.SourceCode) string { +func GenerateFormattedIssue(issues []tt.Issue, snippet *internal.SourceCode) string { var builder strings.Builder for _, issue := range issues { // builder.WriteString(formatIssueHeader(issue)) diff --git a/formatter/formatter_test.go b/formatter/formatter_test.go index ceb9587..3a28477 100644 --- a/formatter/formatter_test.go +++ b/formatter/formatter_test.go @@ -55,7 +55,7 @@ error: empty-if ` - result := GenetateFormattedIssue(issues, code) + result := GenerateFormattedIssue(issues, code) assert.Equal(t, expected, result, "Formatted output does not match expected") @@ -87,7 +87,7 @@ error: empty-if ` - resultWithTabs := GenetateFormattedIssue(issues, sourceCodeWithTabs) + resultWithTabs := GenerateFormattedIssue(issues, sourceCodeWithTabs) assert.Equal(t, expectedWithTabs, resultWithTabs, "Formatted output with tabs does not match expected") } @@ -156,7 +156,7 @@ error: example ` - result := GenetateFormattedIssue(issues, code) + result := GenerateFormattedIssue(issues, code) assert.Equal(t, expected, result, "Formatted output with multiple digit line numbers does not match expected") } @@ -211,7 +211,7 @@ The code inside the 'else' block has been moved outside, as it will only be exec ` - result := GenetateFormattedIssue(issues, code) + result := GenerateFormattedIssue(issues, code) t.Logf("result: %s", result) assert.Equal(t, expected, result, "Formatted output does not match expected for unnecessary else") } diff --git a/internal/engine.go b/internal/engine.go index 4f97302..101947e 100644 --- a/internal/engine.go +++ b/internal/engine.go @@ -20,8 +20,8 @@ type Engine struct { } // NewEngine creates a new lint engine. -func NewEngine(rootDir string) (*Engine, error) { - st, err := BuildSymbolTable(rootDir) +func NewEngine(rootDir string, source []byte) (*Engine, error) { + st, err := BuildSymbolTable(rootDir, source) if err != nil { return nil, fmt.Errorf("error building symbol table: %w", err) } @@ -67,7 +67,7 @@ func (e *Engine) Run(filename string) ([]tt.Issue, error) { } defer e.cleanupTemp(tempFile) - node, fset, err := lints.ParseFile(tempFile) + node, fset, err := lints.ParseFile(tempFile, nil) if err != nil { return nil, fmt.Errorf("error parsing file: %w", err) } @@ -107,6 +107,41 @@ func (e *Engine) Run(filename string) ([]tt.Issue, error) { return filtered, nil } +// Run applies all lint rules to the given source and returns a slice of Issues. +func (e *Engine) RunSource(source []byte) ([]tt.Issue, error) { + node, fset, err := lints.ParseFile("", source) + if err != nil { + return nil, fmt.Errorf("error parsing content: %w", err) + } + + var wg sync.WaitGroup + var mu sync.Mutex + + var allIssues []tt.Issue + for _, rule := range e.rules { + wg.Add(1) + go func(r LintRule) { + defer wg.Done() + if e.ignoredRules[rule.Name()] { + return + } + issues, err := rule.Check("", node, fset) + if err != nil { + return + } + + mu.Lock() + allIssues = append(allIssues, issues...) + mu.Unlock() + }(rule) + } + wg.Wait() + + filtered := e.filterUndefinedIssues(allIssues) + + return filtered, nil +} + func (e *Engine) IgnoreRule(rule string) { if e.ignoredRules == nil { e.ignoredRules = make(map[string]bool) diff --git a/internal/engine_test.go b/internal/engine_test.go index 7f2f632..441b300 100644 --- a/internal/engine_test.go +++ b/internal/engine_test.go @@ -33,7 +33,24 @@ func TestNewEngine(t *testing.T) { tempDir := createTempDir(t, "engine_test") - engine, err := NewEngine(tempDir) + engine, err := NewEngine(tempDir, nil) + assert.NoError(t, err) + assert.NotNil(t, engine) + assert.NotNil(t, engine.SymbolTable) + assert.NotEmpty(t, engine.rules) +} + +func TestNewEngineContent(t *testing.T) { + t.Parallel() + + fileContent := `package test +type TestStruct struct {} +func TestFunc() {} +var TestVar int +func (ts TestStruct) TestMethod() {} +` + + engine, err := NewEngine("", []byte(fileContent)) assert.NoError(t, err) assert.NotNil(t, engine) assert.NotNil(t, engine.SymbolTable) @@ -151,7 +168,7 @@ func BenchmarkRun(b *testing.B) { _, currentFile, _, _ := runtime.Caller(0) testDataDir := filepath.Join(filepath.Dir(currentFile), "../testdata") - engine, err := NewEngine(testDataDir) + engine, err := NewEngine(testDataDir, nil) if err != nil { b.Fatalf("failed to create engine: %v", err) } diff --git a/internal/lints/default_golangci.go b/internal/lints/default_golangci.go index 7e93b4f..a8d7014 100644 --- a/internal/lints/default_golangci.go +++ b/internal/lints/default_golangci.go @@ -11,9 +11,15 @@ import ( tt "github.com/gnoswap-labs/tlin/internal/types" ) -func ParseFile(filename string) (*ast.File, *token.FileSet, error) { +func ParseFile(filename string, content []byte) (*ast.File, *token.FileSet, error) { fset := token.NewFileSet() - node, err := parser.ParseFile(fset, filename, nil, parser.ParseComments) + var node *ast.File + var err error + if content == nil { + node, err = parser.ParseFile(fset, filename, nil, parser.ParseComments) + } else { + node, err = parser.ParseFile(fset, filename, content, parser.ParseComments) + } if err != nil { return nil, nil, err } diff --git a/internal/lints/lint_test.go b/internal/lints/lint_test.go index 1acfd7d..7dd199f 100644 --- a/internal/lints/lint_test.go +++ b/internal/lints/lint_test.go @@ -85,7 +85,7 @@ func main() { err = os.WriteFile(tmpfile, []byte(tt.code), 0o644) require.NoError(t, err) - node, fset, err := ParseFile(tmpfile) + node, fset, err := ParseFile(tmpfile, nil) require.NoError(t, err) issues, err := DetectUnnecessarySliceLength(tmpfile, node, fset) @@ -189,7 +189,7 @@ func example() { err = os.WriteFile(tmpfile, []byte(tt.code), 0o644) require.NoError(t, err) - node, fset, err := ParseFile(tmpfile) + node, fset, err := ParseFile(tmpfile, nil) require.NoError(t, err) issues, err := DetectUnnecessaryConversions(tmpfile, node, fset) @@ -309,7 +309,7 @@ func main() { err = os.WriteFile(tmpfile, []byte(tt.code), 0o644) require.NoError(t, err) - node, fset, err := ParseFile(tmpfile) + node, fset, err := ParseFile(tmpfile, nil) require.NoError(t, err) issues, err := DetectLoopAllocation(tmpfile, node, fset) @@ -372,7 +372,7 @@ func TestDetectEmitFormat(t *testing.T) { err = os.WriteFile(tmpfile, content, 0o644) require.NoError(t, err) - node, fset, err := ParseFile(tmpfile) + node, fset, err := ParseFile(tmpfile, nil) require.NoError(t, err) issues, err := DetectEmitFormat(tmpfile, node, fset) diff --git a/internal/symbol_table.go b/internal/symbol_table.go index 33f73ca..1e28ac6 100644 --- a/internal/symbol_table.go +++ b/internal/symbol_table.go @@ -39,30 +39,40 @@ type SymbolTable struct { symbols map[string]SymbolInfo } -func BuildSymbolTable(rootDir string) (*SymbolTable, error) { +func BuildSymbolTable(rootDir string, source []byte) (*SymbolTable, error) { st := &SymbolTable{symbols: make(map[string]SymbolInfo)} - err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if !d.IsDir() && (strings.HasSuffix(path, ".go") || strings.HasSuffix(path, ".gno")) { - if err := st.parseFile(path); err != nil { + if source != nil { + st.parseFile("", source) + } else { + err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { return err } + if !d.IsDir() && (strings.HasSuffix(path, ".go") || strings.HasSuffix(path, ".gno")) { + if err := st.parseFile(path, nil); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err } - return nil - }) - if err != nil { - return nil, err } return st, nil } -func (st *SymbolTable) parseFile(path string) error { +func (st *SymbolTable) parseFile(path string, source []byte) error { fset := token.NewFileSet() - node, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + var node *ast.File + var err error + if source == nil { + node, err = parser.ParseFile(fset, path, nil, parser.ParseComments) + } else { + node, err = parser.ParseFile(fset, path, source, parser.ParseComments) + } if err != nil { return err } diff --git a/internal/symbol_table_test.go b/internal/symbol_table_test.go index 47d031c..8a0b84c 100644 --- a/internal/symbol_table_test.go +++ b/internal/symbol_table_test.go @@ -36,7 +36,7 @@ func AnotherFunc() {} err = os.WriteFile(file2Path, []byte(file2Content), 0o644) require.NoError(t, err) - st, err := BuildSymbolTable(tmpDir) + st, err := BuildSymbolTable(tmpDir, nil) require.NoError(t, err) tests := []struct { @@ -95,7 +95,7 @@ var Var%d int require.NoError(t, err) } - st, err := BuildSymbolTable(tmpDir) + st, err := BuildSymbolTable(tmpDir, nil) require.NoError(t, err) var wg sync.WaitGroup @@ -110,6 +110,23 @@ var Var%d int wg.Wait() } +func TestSingleFileSymbolTable(t *testing.T) { + fileContent := `package test +type TestStruct struct {} +func TestFunc() {} +var TestVar int +func (ts TestStruct) TestMethod() {} +` + + st, err := BuildSymbolTable("", []byte(fileContent)) + require.NoError(t, err) + + assert.True(t, st.IsDefined("test.TestStruct")) + assert.True(t, st.IsDefined("test.TestFunc")) + assert.True(t, st.IsDefined("test.TestVar")) + assert.True(t, st.IsDefined("test.TestMethod")) +} + func BenchmarkBuildSymbolTable(b *testing.B) { tmpDir, err := os.MkdirTemp("", "benchmark-symboltable") require.NoError(b, err) @@ -128,7 +145,7 @@ type Struct%d struct{} b.ResetTimer() for i := 0; i < b.N; i++ { - _, err := BuildSymbolTable(tmpDir) + _, err := BuildSymbolTable(tmpDir, nil) if err != nil { b.Fatal(err) } @@ -151,7 +168,7 @@ type Struct%d struct{} require.NoError(b, err) } - st, err := BuildSymbolTable(tmpDir) + st, err := BuildSymbolTable(tmpDir, nil) require.NoError(b, err) b.ResetTimer() diff --git a/lint/lint.go b/lint/lint.go new file mode 100644 index 0000000..c18a965 --- /dev/null +++ b/lint/lint.go @@ -0,0 +1,102 @@ +package lint + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/gnoswap-labs/tlin/internal/lints" + tt "github.com/gnoswap-labs/tlin/internal/types" + "go.uber.org/zap" +) + +type LintEngine interface { + Run(filePath string) ([]tt.Issue, error) + RunSource(source []byte) ([]tt.Issue, error) + IgnoreRule(rule string) +} + +func ProcessSources(ctx context.Context, logger *zap.Logger, engine LintEngine, sources [][]byte, processor func(LintEngine, []byte) ([]tt.Issue, error)) ([]tt.Issue, error) { + var allIssues []tt.Issue + for i, source := range sources { + issues, err := processor(engine, source) + if err != nil { + if logger != nil { + logger.Error("Error processing source", zap.Int("source", i), zap.Error(err)) + } + return nil, err + } + allIssues = append(allIssues, issues...) + } + + return allIssues, nil +} + +func ProcessFiles(ctx context.Context, logger *zap.Logger, engine LintEngine, paths []string, processor func(LintEngine, string) ([]tt.Issue, error)) ([]tt.Issue, error) { + var allIssues []tt.Issue + for _, path := range paths { + issues, err := ProcessPath(ctx, logger, engine, path, processor) + if err != nil { + if logger != nil { + logger.Error("Error processing path", zap.String("path", path), zap.Error(err)) + } + return nil, err + } + allIssues = append(allIssues, issues...) + } + + return allIssues, nil +} + +func ProcessPath(_ context.Context, logger *zap.Logger, engine LintEngine, path string, processor func(LintEngine, string) ([]tt.Issue, error)) ([]tt.Issue, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("error accessing %s: %w", path, err) + } + + var issues []tt.Issue + if info.IsDir() { + err = filepath.Walk(path, func(filePath string, fileInfo os.FileInfo, err error) error { + if err != nil { + return err + } + if !fileInfo.IsDir() && hasDesiredExtension(filePath) { + fileIssues, err := processor(engine, filePath) + if err != nil && logger != nil { + logger.Error("Error processing file", zap.String("file", filePath), zap.Error(err)) + } else { + issues = append(issues, fileIssues...) + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("error walking directory %s: %w", path, err) + } + } else if hasDesiredExtension(path) { + fileIssues, err := processor(engine, path) + if err != nil { + return nil, err + } + issues = append(issues, fileIssues...) + } + + return issues, nil +} + +func ProcessCyclomaticComplexity(path string, threshold int) ([]tt.Issue, error) { + return lints.DetectHighCyclomaticComplexity(path, threshold) +} + +func ProcessFile(engine LintEngine, filePath string) ([]tt.Issue, error) { + return engine.Run(filePath) +} + +func ProcessSource(engine LintEngine, source []byte) ([]tt.Issue, error) { + return engine.RunSource(source) +} + +func hasDesiredExtension(path string) bool { + return filepath.Ext(path) == ".go" || filepath.Ext(path) == ".gno" +} diff --git a/lint/lint_test.go b/lint/lint_test.go new file mode 100644 index 0000000..d393c2b --- /dev/null +++ b/lint/lint_test.go @@ -0,0 +1,220 @@ +package lint + +import ( + "context" + "go/token" + "os" + "path/filepath" + "testing" + + "github.com/gnoswap-labs/tlin/internal/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "go.uber.org/zap" +) + +type mockLintEngine struct { + mock.Mock +} + +func (m *mockLintEngine) Run(filePath string) ([]types.Issue, error) { + args := m.Called(filePath) + return args.Get(0).([]types.Issue), args.Error(1) +} + +func (m *mockLintEngine) RunSource(source []byte) ([]types.Issue, error) { + args := m.Called(source) + return args.Get(0).([]types.Issue), args.Error(1) +} + +func (m *mockLintEngine) IgnoreRule(rule string) { + m.Called(rule) +} + +func setupMockEngine(expectedIssues []types.Issue, filePath string) *mockLintEngine { + mockEngine := new(mockLintEngine) + mockEngine.On("Run", filePath).Return(expectedIssues, nil) + return mockEngine +} + +func setupSourceMockEngine(expectedIssues []types.Issue, content []byte) *mockLintEngine { + mockEngine := new(mockLintEngine) + mockEngine.On("RunSource", content).Return(expectedIssues, nil) + return mockEngine +} + +func TestProcessFile(t *testing.T) { + t.Parallel() + expectedIssues := []types.Issue{ + { + Rule: "test-rule", + Filename: "test.go", + Start: token.Position{Filename: "test.go", Offset: 0, Line: 1, Column: 1}, + End: token.Position{Filename: "test.go", Offset: 10, Line: 1, Column: 11}, + Message: "Test issue", + }, + } + mockEngine := setupMockEngine(expectedIssues, "test.go") + + issues, err := ProcessFile(mockEngine, "test.go") + + assert.NoError(t, err) + assert.Equal(t, expectedIssues, issues) + mockEngine.AssertExpectations(t) +} + +func TestProcessSource(t *testing.T) { + t.Parallel() + expectedIssues := []types.Issue{ + { + Rule: "test-rule", + Filename: "", + Start: token.Position{Filename: "", Offset: 0, Line: 1, Column: 1}, + End: token.Position{Filename: "", Offset: 10, Line: 1, Column: 11}, + Message: "Test issue", + }, + } + mockEngine := setupSourceMockEngine(expectedIssues, []byte("package main")) + + issues, err := ProcessSource(mockEngine, []byte("package main")) + + assert.NoError(t, err) + assert.Equal(t, expectedIssues, issues) + mockEngine.AssertExpectations(t) +} + +func TestProcessPath(t *testing.T) { + t.Parallel() + logger, _ := zap.NewProduction() + ctx := context.Background() + + tempDir, err := os.MkdirTemp("", "test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + paths := createTempFiles(t, tempDir, "test1.go", "test2.go") + + expectedIssues := []types.Issue{ + { + Rule: "rule1", + Filename: paths[0], + Start: token.Position{Filename: paths[0], Offset: 0, Line: 1, Column: 1}, + End: token.Position{Filename: paths[0], Offset: 10, Line: 1, Column: 11}, + Message: "Test issue 1", + }, + { + Rule: "rule2", + Filename: paths[1], + Start: token.Position{Filename: paths[1], Offset: 0, Line: 1, Column: 1}, + End: token.Position{Filename: paths[1], Offset: 10, Line: 1, Column: 11}, + Message: "Test issue 2", + }, + } + + mockEngine := new(mockLintEngine) + mockEngine.On("Run", paths[0]).Return([]types.Issue{expectedIssues[0]}, nil) + mockEngine.On("Run", paths[1]).Return([]types.Issue{expectedIssues[1]}, nil) + + issues, err := ProcessPath(ctx, logger, mockEngine, tempDir, ProcessFile) + + assert.NoError(t, err) + assert.Len(t, issues, 2) + assert.Contains(t, issues, expectedIssues[0]) + assert.Contains(t, issues, expectedIssues[1]) + mockEngine.AssertExpectations(t) +} + +func TestProcessFiles(t *testing.T) { + t.Parallel() + logger, _ := zap.NewProduction() + ctx := context.Background() + + tempDir, err := os.MkdirTemp("", "test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + paths := createTempFiles(t, tempDir, "test1.go", "test2.go") + + expectedIssues := []types.Issue{ + { + Rule: "rule1", + Filename: paths[0], + Start: token.Position{Filename: paths[0], Offset: 0, Line: 1, Column: 1}, + End: token.Position{Filename: paths[0], Offset: 10, Line: 1, Column: 11}, + Message: "Test issue 1", + }, + { + Rule: "rule2", + Filename: paths[1], + Start: token.Position{Filename: paths[1], Offset: 0, Line: 1, Column: 1}, + End: token.Position{Filename: paths[1], Offset: 10, Line: 1, Column: 11}, + Message: "Test issue 2", + }, + } + + mockEngine := new(mockLintEngine) + mockEngine.On("Run", paths[0]).Return([]types.Issue{expectedIssues[0]}, nil) + mockEngine.On("Run", paths[1]).Return([]types.Issue{expectedIssues[1]}, nil) + + issues, err := ProcessFiles(ctx, logger, mockEngine, paths, ProcessFile) + + assert.NoError(t, err) + assert.Len(t, issues, 2) + assert.Contains(t, issues, expectedIssues[0]) + assert.Contains(t, issues, expectedIssues[1]) + mockEngine.AssertExpectations(t) +} + +func TestProcessSources(t *testing.T) { + t.Parallel() + logger, _ := zap.NewProduction() + ctx := context.Background() + + expectedIssues := []types.Issue{ + { + Rule: "rule1", + Filename: "", + Start: token.Position{Filename: "", Offset: 0, Line: 1, Column: 1}, + End: token.Position{Filename: "", Offset: 10, Line: 1, Column: 11}, + Message: "Test issue 1", + }, + { + Rule: "rule2", + Filename: "", + Start: token.Position{Filename: "", Offset: 0, Line: 1, Column: 1}, + End: token.Position{Filename: "", Offset: 10, Line: 1, Column: 11}, + Message: "Test issue 2", + }, + } + + mockEngine := new(mockLintEngine) + mockEngine.On("RunSource", []byte("package main1")).Return([]types.Issue{expectedIssues[0]}, nil) + mockEngine.On("RunSource", []byte("package main2")).Return([]types.Issue{expectedIssues[1]}, nil) + + issues, err := ProcessSources(ctx, logger, mockEngine, [][]byte{[]byte("package main1"), []byte("package main2")}, ProcessSource) + + assert.NoError(t, err) + assert.Len(t, issues, 2) + assert.Contains(t, issues, expectedIssues[0]) + assert.Contains(t, issues, expectedIssues[1]) + mockEngine.AssertExpectations(t) +} + +func TestHasDesiredExtension(t *testing.T) { + t.Parallel() + assert.True(t, hasDesiredExtension("test.go")) + assert.True(t, hasDesiredExtension("test.gno")) + assert.False(t, hasDesiredExtension("test.txt")) + assert.False(t, hasDesiredExtension("test")) +} + +func createTempFiles(t *testing.T, dir string, fileNames ...string) []string { + var paths []string + for _, fileName := range fileNames { + filePath := filepath.Join(dir, fileName) + _, err := os.Create(filePath) + assert.NoError(t, err) + paths = append(paths, filePath) + } + return paths +}