From 1dbde450f0f8fad3328f92d964ad8dad1ad8151b Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Wed, 12 Feb 2025 22:10:10 +0900 Subject: [PATCH 1/2] refactor --- cmd/cfg.go | 79 ++++++++ cmd/cmd_test.go | 295 ++++++++++++++++++++++++++++ cmd/cyclo.go | 57 ++++++ cmd/fix.go | 61 ++++++ cmd/init.go | 55 ++++++ cmd/lint.go | 130 +++++++++++++ cmd/root.go | 53 +++++ cmd/tlin/main.go | 310 +---------------------------- cmd/tlin/main_test.go | 440 ++++++------------------------------------ go.mod | 3 + go.sum | 9 + 11 files changed, 800 insertions(+), 692 deletions(-) create mode 100644 cmd/cfg.go create mode 100644 cmd/cmd_test.go create mode 100644 cmd/cyclo.go create mode 100644 cmd/fix.go create mode 100644 cmd/init.go create mode 100644 cmd/lint.go create mode 100644 cmd/root.go diff --git a/cmd/cfg.go b/cmd/cfg.go new file mode 100644 index 0000000..c27b8e2 --- /dev/null +++ b/cmd/cfg.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "context" + "fmt" + "github.com/gnolang/tlin/internal/analysis/cfg" + "github.com/spf13/cobra" + "go.uber.org/zap" + "go/ast" + "go/parser" + "go/token" + "os" + "strings" +) + +// variable for flags +var ( + funcName string + output string +) + +var cfgCmd = &cobra.Command{ + Use: "cfg [paths...]", + Short: "Run control flow graph analysis", + Long: `Outputs the Control Flow Graph (CFG) of the specified function or generates a GraphViz file. +Example) tlin cfg --func MyFunction *.go`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + fmt.Println("error: Please provide file or directory paths") + os.Exit(1) + } + // timeout is a global variable declared in root.go + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + runCFGAnalysis(ctx, logger, args, funcName, output) + }, +} + +func init() { + cfgCmd.Flags().StringVar(&funcName, "func", "", "Function name for CFG analysis") + cfgCmd.Flags().StringVarP(&output, "output", "o", "", "Output path for rendered GraphViz file") +} + +func runCFGAnalysis(_ context.Context, logger *zap.Logger, paths []string, funcName string, output string) { + functionFound := false + for _, path := range paths { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + logger.Error("Failed to parse file", zap.String("path", path), zap.Error(err)) + continue + } + for _, decl := range f.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok { + if fn.Name.Name == funcName { + cfgGraph := cfg.FromFunc(fn) + var buf strings.Builder + cfgGraph.PrintDot(&buf, fset, func(n ast.Stmt) string { return "" }) + if output != "" { + err := cfg.RenderToGraphVizFile([]byte(buf.String()), output) + if err != nil { + logger.Error("Failed to render CFG to GraphViz file", zap.Error(err)) + } else { + fmt.Printf("GraphViz file created: %s\n", output) + } + } else { + fmt.Printf("CFG for function %s in file %s:\n%s\n", funcName, path, buf.String()) + } + functionFound = true + return + } + } + } + } + + if !functionFound { + fmt.Printf("Function not found: %s\n", funcName) + } +} diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go new file mode 100644 index 0000000..c0110b3 --- /dev/null +++ b/cmd/cmd_test.go @@ -0,0 +1,295 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "go/token" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + tt "github.com/gnolang/tlin/internal/types" + "github.com/gnolang/tlin/lint" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +type mockLintEngine struct { + mock.Mock +} + +func (m *mockLintEngine) Run(filePath string) ([]tt.Issue, error) { + args := m.Called(filePath) + return args.Get(0).([]tt.Issue), args.Error(1) +} + +func (m *mockLintEngine) RunSource(source []byte) ([]tt.Issue, error) { + args := m.Called(source) + return args.Get(0).([]tt.Issue), args.Error(1) +} + +func (m *mockLintEngine) IgnoreRule(rule string) { + m.Called(rule) +} + +func (m *mockLintEngine) IgnorePath(path string) { + m.Called(path) +} + +func setupMockEngine(expectedIssues []tt.Issue, filePath string) *mockLintEngine { + mockEngine := new(mockLintEngine) + mockEngine.On("Run", filePath).Return(expectedIssues, nil) + return mockEngine +} + +func TestInitConfigurationFile(t *testing.T) { + t.Parallel() + tempDir, err := os.MkdirTemp("", "init-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + configPath := filepath.Join(tempDir, ".tlin.yaml") + + err = initConfigurationFile(configPath) + assert.NoError(t, err) + + _, err = os.Stat(configPath) + assert.NoError(t, err) + + content, err := os.ReadFile(configPath) + assert.NoError(t, err) + + expectedConfig := lint.Config{ + Name: "tlin", + Rules: map[string]tt.ConfigRule{}, + } + config := &lint.Config{} + yaml.Unmarshal(content, config) + + assert.Equal(t, expectedConfig, *config) +} + +func TestRunCFGAnalysis(t *testing.T) { + t.Parallel() + logger, _ := zap.NewProduction() + + testCode := `package main // 1 + // 2 +func mainFunc() { // 3 + x := 1 // 4 + if x > 0 { // 5 + x = 2 // 6 + } else { // 7 + x = 3 // 8 + } // 9 +} // 10 + // 11 +func targetFunc() { // 12 + y := 10 // 13 + for i := 0; i < 5; i++ { // 14 + y += i // 15 + } // 16 +} // 17 + // 18 +func ignoredFunc() { // 19 + z := "hello" // 20 + println(z) // 21 +} // 22 +` + tempFile := createTempFileWithContent(t, testCode) + defer os.Remove(tempFile) + + ctx := context.Background() + + output := captureOutput(t, func() { + runCFGAnalysis(ctx, logger, []string{tempFile}, "targetFunc", "") + }) + + assert.Contains(t, output, "CFG for function targetFunc in file") + assert.Contains(t, output, "digraph mgraph") + assert.Contains(t, output, "\"for loop") + assert.Contains(t, output, "\"assignment") + assert.NotContains(t, output, "mainFunc") + assert.NotContains(t, output, "ignoredFunc") + + t.Logf("output: %s", output) + + output = captureOutput(t, func() { + runCFGAnalysis(ctx, logger, []string{tempFile}, "nonExistentFunc", "") + }) + + assert.Contains(t, output, "Function not found: nonExistentFunc") +} + +const sliceRangeIssueExample = `package main + +func main() { + slice := []int{1, 2, 3} + _ = slice[:len(slice)] +} +` + +func TestRunAutoFix(t *testing.T) { + logger, _ := zap.NewProduction() + ctx := context.Background() + + tempDir, err := os.MkdirTemp("", "autofix-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + testFile := filepath.Join(tempDir, "test.go") + err = os.WriteFile(testFile, []byte(sliceRangeIssueExample), 0o644) + assert.NoError(t, err) + + expectedIssues := []tt.Issue{ + { + Rule: "simplify-slice-range", + Filename: testFile, + Message: "unnecessary use of len() in slice expression, can be simplified", + Start: token.Position{Line: 5, Column: 5}, + End: token.Position{Line: 5, Column: 24}, + Suggestion: "_ = slice[:]", + Confidence: 0.9, + }, + } + + mockEngine := setupMockEngine(expectedIssues, testFile) + + output := captureOutput(t, func() { + runAutoFix(ctx, logger, mockEngine, []string{testFile}, false, 0.8) + }) + + content, err := os.ReadFile(testFile) + assert.NoError(t, err) + + expectedContent := `package main + +func main() { + slice := []int{1, 2, 3} + _ = slice[:] +} +` + assert.Equal(t, expectedContent, string(content)) + assert.Contains(t, output, "Fixed issues in") + + // dry run test + err = os.WriteFile(testFile, []byte(sliceRangeIssueExample), 0o644) + assert.NoError(t, err) + + output = captureOutput(t, func() { + runAutoFix(ctx, logger, mockEngine, []string{testFile}, true, 0.8) + }) + + content, err = os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, sliceRangeIssueExample, string(content)) + assert.Contains(t, output, "Would fix issue in") +} + +func TestRunJsonOutput(t *testing.T) { + if os.Getenv("BE_CRASHER") != "1" { + cmd := exec.Command(os.Args[0], "-test.run=TestRunJsonOutput") + cmd.Env = append(os.Environ(), "BE_CRASHER=1") + output, err := cmd.CombinedOutput() // stdout and stderr capture + if e, ok := err.(*exec.ExitError); ok && !e.Success() { + tempDir := string(bytes.TrimRight(output, "\n")) + defer os.RemoveAll(tempDir) + + // check if issues are written + jsonOutput := filepath.Join(tempDir, "output.json") + content, err := os.ReadFile(jsonOutput) + assert.NoError(t, err) + + var actualContent map[string][]tt.Issue + err = json.Unmarshal(content, &actualContent) + assert.NoError(t, err) + + assert.Len(t, actualContent, 1) + for filename, issues := range actualContent { + assert.True(t, strings.HasSuffix(filename, "test.go")) + assert.Len(t, issues, 1) + issue := issues[0] + assert.Equal(t, "simplify-slice-range", issue.Rule) + assert.Equal(t, "unnecessary use of len() in slice expression, can be simplified", issue.Message) + assert.Equal(t, "_ = slice[:]", issue.Suggestion) + assert.Equal(t, 0.9, issue.Confidence) + assert.Equal(t, 5, issue.Start.Line) + assert.Equal(t, 5, issue.Start.Column) + assert.Equal(t, 5, issue.End.Line) + assert.Equal(t, 24, issue.End.Column) + assert.Equal(t, tt.SeverityError, issue.Severity) + } + + return + } + t.Fatalf("process failed with error %v, expected exit status 1", err) + } + + logger, _ := zap.NewProduction() + ctx := context.Background() + + tempDir, err := os.MkdirTemp("", "json-test") + assert.NoError(t, err) + fmt.Println(tempDir) + + testFile := filepath.Join(tempDir, "test.go") + err = os.WriteFile(testFile, []byte(sliceRangeIssueExample), 0o644) + assert.NoError(t, err) + + expectedIssues := []tt.Issue{ + { + Rule: "simplify-slice-range", + Filename: testFile, + Message: "unnecessary use of len() in slice expression, can be simplified", + Start: token.Position{Line: 5, Column: 5}, + End: token.Position{Line: 5, Column: 24}, + Suggestion: "_ = slice[:]", + Confidence: 0.9, + }, + } + + mockEngine := setupMockEngine(expectedIssues, testFile) + + jsonOutput := filepath.Join(tempDir, "output.json") + runNormalLintProcess(ctx, logger, mockEngine, []string{testFile}, true, jsonOutput) +} + +func createTempFileWithContent(t *testing.T, content string) string { + t.Helper() + tempFile, err := os.CreateTemp("", "test*.go") + assert.NoError(t, err) + defer tempFile.Close() + + _, err = tempFile.Write([]byte(content)) + assert.NoError(t, err) + + return tempFile.Name() +} + +var mu sync.Mutex + +func captureOutput(t *testing.T, f func()) string { + t.Helper() + mu.Lock() + defer mu.Unlock() + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + f() + + w.Close() + os.Stdout = oldStdout + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() +} diff --git a/cmd/cyclo.go b/cmd/cyclo.go new file mode 100644 index 0000000..61371d6 --- /dev/null +++ b/cmd/cyclo.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + tt "github.com/gnolang/tlin/internal/types" + "github.com/gnolang/tlin/lint" +) + +// cyclo command flags +var ( + threshold int + cycloJsonOutput bool + outputPath string +) + +var cycloCmd = &cobra.Command{ + Use: "cyclo [paths...]", + Short: "Run cyclomatic complexity analysis", + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + fmt.Println("error: Please provide file or directory paths") + os.Exit(1) + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + runCyclomaticComplexityAnalysis(ctx, logger, args, threshold, cycloJsonOutput, outputPath) + }, +} + +func init() { + cycloCmd.Flags().IntVar(&threshold, "threshold", 10, "Cyclomatic complexity threshold") + cycloCmd.Flags().BoolVar(&cycloJsonOutput, "json", false, "Output issues in JSON format") + cycloCmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output path (when using JSON)") +} + +func runCyclomaticComplexityAnalysis(ctx context.Context, logger *zap.Logger, paths []string, threshold int, isJson bool, jsonOutput string) { + 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)) + os.Exit(1) + } + + printIssues(logger, issues, isJson, jsonOutput) + + if len(issues) > 0 { + os.Exit(1) + } +} diff --git a/cmd/fix.go b/cmd/fix.go new file mode 100644 index 0000000..2ee7e66 --- /dev/null +++ b/cmd/fix.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/gnolang/tlin/internal/fixer" + "github.com/gnolang/tlin/lint" +) + +var ( + dryRun bool + confidenceThreshold float64 +) + +var fixCmd = &cobra.Command{ + Use: "fix [paths...]", + Short: "Automatically fix issues", + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + fmt.Println("error: Please provide file or directory paths") + os.Exit(1) + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // initialize the lint engine + engine, err := lint.New(".", nil, cfgFile) + if err != nil { + logger.Fatal("Failed to initialize lint engine", zap.Error(err)) + } + + runAutoFix(ctx, logger, engine, args, dryRun, confidenceThreshold) + }, +} + +func init() { + fixCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Run in dry-run mode (show fixes without applying them)") + fixCmd.Flags().Float64Var(&confidenceThreshold, "confidence", 0.75, "Confidence threshold for auto-fixing (0.0 to 1.0)") +} + +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 := lint.ProcessPath(ctx, logger, engine, path, lint.ProcessFile) + if err != nil { + logger.Error("error processing path", zap.String("path", path), zap.Error(err)) + continue + } + + err = fix.Fix(path, issues) + if err != nil { + logger.Error("error fixing issues", zap.String("path", path), zap.Error(err)) + } + } +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..c089031 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "fmt" + + "os" + + tt "github.com/gnolang/tlin/internal/types" + "github.com/gnolang/tlin/lint" + "github.com/spf13/cobra" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +// initCmd: tlin init +var initCmd = &cobra.Command{ + Use: "init", + Short: "Initialize a new linter configuration file", + Run: func(cmd *cobra.Command, args []string) { + if err := initConfigurationFile(cfgFile); err != nil { + logger.Error("Error initializing config file", zap.Error(err)) + return + } + fmt.Printf("Configuration file created/updated: %s\n", cfgFile) + }, +} + +func initConfigurationFile(configurationPath string) error { + if configurationPath == "" { + configurationPath = ".tlin.yaml" + } + + // Create a yaml file with rules + config := lint.Config{ + Name: "tlin", + Rules: map[string]tt.ConfigRule{}, + } + d, err := yaml.Marshal(config) + if err != nil { + return err + } + + f, err := os.Create(configurationPath) + if err != nil { + return err + } + defer f.Close() + + _, err = f.Write(d) + if err != nil { + return err + } + + return nil +} diff --git a/cmd/lint.go b/cmd/lint.go new file mode 100644 index 0000000..9614822 --- /dev/null +++ b/cmd/lint.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/gnolang/tlin/formatter" + "github.com/gnolang/tlin/internal" + tt "github.com/gnolang/tlin/internal/types" + "github.com/gnolang/tlin/lint" +) + +var ( + ignoreRules string + ignorePaths string + lintJsonOutput bool + outPath string +) + +var lintCmd = &cobra.Command{ + Use: "lint [paths...]", + Short: "Run the normal lint process", + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + fmt.Println("error: Please provide file or directory paths") + os.Exit(1) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + engine, err := lint.New(".", nil, cfgFile) + if err != nil { + logger.Fatal("Failed to initialize lint engine", zap.Error(err)) + } + + if ignoreRules != "" { + rules := strings.Split(ignoreRules, ",") + for _, rule := range rules { + engine.IgnoreRule(strings.TrimSpace(rule)) + } + } + + if ignorePaths != "" { + paths := strings.Split(ignorePaths, ",") + for _, path := range paths { + engine.IgnorePath(strings.TrimSpace(path)) + } + } + + runNormalLintProcess(ctx, logger, engine, args, lintJsonOutput, outPath) + }, +} + +func init() { + lintCmd.Flags().StringVar(&ignoreRules, "ignore", "", "Comma-separated list of lint rules to ignore") + lintCmd.Flags().StringVar(&ignorePaths, "ignore-paths", "", "Comma-separated list of paths to ignore") + lintCmd.Flags().BoolVar(&lintJsonOutput, "json", false, "Output issues in JSON format") + lintCmd.Flags().StringVarP(&outPath, "output", "o", "", "Output path (when using JSON)") +} + +func runNormalLintProcess(ctx context.Context, logger *zap.Logger, engine lint.LintEngine, paths []string, isJson bool, 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) + } + + printIssues(logger, issues, isJson, jsonOutput) + + if len(issues) > 0 { + os.Exit(1) + } +} + +func printIssues(logger *zap.Logger, issues []tt.Issue, isJson bool, jsonOutput string) { + issuesByFile := make(map[string][]tt.Issue) + for _, issue := range issues { + issuesByFile[issue.Filename] = append(issuesByFile[issue.Filename], issue) + } + + sortedFiles := make([]string, 0, len(issuesByFile)) + for filename := range issuesByFile { + sortedFiles = append(sortedFiles, filename) + } + sort.Strings(sortedFiles) + + if !isJson { + // text output + for _, filename := range sortedFiles { + fileIssues := issuesByFile[filename] + sourceCode, err := internal.ReadSourceCode(filename) + if err != nil { + logger.Error("Error reading source file", zap.String("file", filename), zap.Error(err)) + continue + } + output := formatter.GenerateFormattedIssue(fileIssues, sourceCode) + fmt.Println(output) + } + } else { + // JSON output + d, err := json.Marshal(issuesByFile) + if err != nil { + logger.Error("Error marshalling issues to JSON", zap.Error(err)) + return + } + if jsonOutput == "" { + fmt.Println(string(d)) + } else { + f, err := os.Create(jsonOutput) + if err != nil { + logger.Error("Error creating JSON output file", zap.Error(err)) + return + } + defer f.Close() + _, err = f.Write(d) + if err != nil { + logger.Error("Error writing JSON output file", zap.Error(err)) + return + } + } + } +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..ce1b374 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "os" + "time" + + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +var ( + cfgFile string + timeout time.Duration + + logger *zap.Logger +) + +var rootCmd = &cobra.Command{ + Use: "tlin", + Short: "tlin is a linter for Gno code", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + var err error + logger, err = zap.NewProduction() + if err != nil { + return err + } + return nil + }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + if logger != nil { + logger.Sync() + } + }, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func init() { + // global flags for the root command + rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", ".tlin.yaml", "Path to the linter configuration file") + rootCmd.PersistentFlags().DurationVar(&timeout, "timeout", 5*time.Minute, "Set a timeout for the linter") + + // register subcommands + rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(cfgCmd) + rootCmd.AddCommand(cycloCmd) + rootCmd.AddCommand(fixCmd) + rootCmd.AddCommand(lintCmd) +} diff --git a/cmd/tlin/main.go b/cmd/tlin/main.go index 5e0d9b4..5db6489 100644 --- a/cmd/tlin/main.go +++ b/cmd/tlin/main.go @@ -1,313 +1,7 @@ package main -import ( - "context" - "encoding/json" - "flag" - "fmt" - "go/ast" - "go/parser" - "go/token" - "os" - "sort" - "strings" - "time" - - "github.com/gnolang/tlin/formatter" - "github.com/gnolang/tlin/internal" - "github.com/gnolang/tlin/internal/analysis/cfg" - "github.com/gnolang/tlin/internal/fixer" - tt "github.com/gnolang/tlin/internal/types" - "github.com/gnolang/tlin/lint" - "go.uber.org/zap" - "gopkg.in/yaml.v3" -) - -const ( - defaultTimeout = 5 * time.Minute - defaultConfidenceThreshold = 0.75 -) - -type Config struct { - IgnoreRules string - FuncName string - Output string - ConfigurationPath string - Paths []string - Timeout time.Duration - CyclomaticThreshold int - ConfidenceThreshold float64 - CyclomaticComplexity bool - CFGAnalysis bool - AutoFix bool - DryRun bool - JsonOutput bool - Init bool - IgnorePaths string -} +import "github.com/gnolang/tlin/cmd" func main() { - logger, _ := zap.NewProduction() - defer logger.Sync() - - config := parseFlags(os.Args[1:]) - - ctx, cancel := context.WithTimeout(context.Background(), config.Timeout) - defer cancel() - - if config.Init { - err := initConfigurationFile(config.ConfigurationPath) - if err != nil { - logger.Error("Error initializing config file", zap.Error(err)) - os.Exit(1) - } - return - } - - engine, err := lint.New(".", nil, config.ConfigurationPath) - if err != nil { - logger.Fatal("Failed to initialize lint engine", zap.Error(err)) - } - - if config.IgnoreRules != "" { - rules := strings.Split(config.IgnoreRules, ",") - for _, rule := range rules { - engine.IgnoreRule(strings.TrimSpace(rule)) - } - } - - if config.IgnorePaths != "" { - paths := strings.Split(config.IgnorePaths, ",") - for _, path := range paths { - engine.IgnorePath(strings.TrimSpace(path)) - } - } - - if config.CFGAnalysis { - runWithTimeout(ctx, func() { - runCFGAnalysis(ctx, logger, config.Paths, config.FuncName, config.Output) - }) - } else if config.CyclomaticComplexity { - runWithTimeout(ctx, func() { - runCyclomaticComplexityAnalysis(ctx, logger, config.Paths, config.CyclomaticThreshold, config.JsonOutput, config.Output) - }) - } else if config.AutoFix { - runWithTimeout(ctx, func() { - runAutoFix(ctx, logger, engine, config.Paths, config.DryRun, config.ConfidenceThreshold) - }) - } else { - runWithTimeout(ctx, func() { - runNormalLintProcess(ctx, logger, engine, config.Paths, config.JsonOutput, config.Output) - }) - } -} - -func parseFlags(args []string) Config { - flagSet := flag.NewFlagSet("tlin", flag.ExitOnError) - config := Config{} - - flagSet.DurationVar(&config.Timeout, "timeout", defaultTimeout, "Set a timeout for the linter. example: 1s, 1m, 1h") - flagSet.BoolVar(&config.CyclomaticComplexity, "cyclo", false, "Run cyclomatic complexity analysis") - flagSet.IntVar(&config.CyclomaticThreshold, "threshold", 10, "Cyclomatic complexity threshold") - flagSet.StringVar(&config.IgnoreRules, "ignore", "", "Comma-separated list of lint rules to ignore") - flagSet.StringVar(&config.IgnorePaths, "ignore-paths", "", "Comma-separated list of paths to ignore") - flagSet.BoolVar(&config.CFGAnalysis, "cfg", false, "Run control flow graph analysis") - flagSet.StringVar(&config.FuncName, "func", "", "Function name for CFG analysis") - flagSet.BoolVar(&config.AutoFix, "fix", false, "Automatically fix issues") - flagSet.StringVar(&config.Output, "o", "", "Output path") - flagSet.BoolVar(&config.DryRun, "dry-run", false, "Run in dry-run mode (show fixes without applying them)") - flagSet.BoolVar(&config.JsonOutput, "json", false, "Output issues in JSON format") - flagSet.Float64Var(&config.ConfidenceThreshold, "confidence", defaultConfidenceThreshold, "Confidence threshold for auto-fixing (0.0 to 1.0)") - flagSet.BoolVar(&config.Init, "init", false, "Initialize a new linter configuration file") - flagSet.StringVar(&config.ConfigurationPath, "c", ".tlin.yaml", "Path to the linter configuration file") - - err := flagSet.Parse(args) - if err != nil { - fmt.Println("Error parsing flags:", err) - os.Exit(1) - } - - config.Paths = flagSet.Args() - if !config.Init && len(config.Paths) == 0 { - fmt.Println("error: Please provide file or directory paths") - os.Exit(1) - } - - return config -} - -func runWithTimeout(ctx context.Context, f func()) { - done := make(chan struct{}) - go func() { - f() - close(done) - }() - - select { - case <-ctx.Done(): - fmt.Println("Linter timed out") - os.Exit(1) - case <-done: - return - } -} - -func runNormalLintProcess(ctx context.Context, logger *zap.Logger, engine lint.LintEngine, paths []string, isJson bool, 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) - } - - printIssues(logger, issues, isJson, jsonOutput) - - if len(issues) > 0 { - os.Exit(1) - } -} - -func runCyclomaticComplexityAnalysis(ctx context.Context, logger *zap.Logger, paths []string, threshold int, isJson bool, jsonOutput string) { - 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)) - os.Exit(1) - } - - printIssues(logger, issues, isJson, jsonOutput) - - if len(issues) > 0 { - os.Exit(1) - } -} - -func runCFGAnalysis(_ context.Context, logger *zap.Logger, paths []string, funcName string, output string) { - functionFound := false - for _, path := range paths { - fset := token.NewFileSet() - f, err := parser.ParseFile(fset, path, nil, 0) - if err != nil { - logger.Error("Failed to parse file", zap.String("path", path), zap.Error(err)) - continue - } - - for _, decl := range f.Decls { - if fn, ok := decl.(*ast.FuncDecl); ok { - if fn.Name.Name == funcName { - cfgGraph := cfg.FromFunc(fn) - var buf strings.Builder - cfgGraph.PrintDot(&buf, fset, func(n ast.Stmt) string { return "" }) - if output != "" { - err := cfg.RenderToGraphVizFile([]byte(buf.String()), output) - if err != nil { - logger.Error("Failed to render CFG to GraphViz file", zap.Error(err)) - } - } else { - fmt.Printf("CFG for function %s in file %s:\n%s\n", funcName, path, buf.String()) - } - functionFound = true - return - } - } - } - } - - if !functionFound { - fmt.Printf("Function not found: %s\n", funcName) - } -} - -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 := lint.ProcessPath(ctx, logger, engine, path, lint.ProcessFile) - if err != nil { - logger.Error("error processing path", zap.String("path", path), zap.Error(err)) - continue - } - - err = fix.Fix(path, issues) - if err != nil { - logger.Error("error fixing issues", zap.String("path", path), zap.Error(err)) - } - } -} - -func initConfigurationFile(configurationPath string) error { - if configurationPath == "" { - configurationPath = ".tlin.yaml" - } - - // Create a yaml file with rules - config := lint.Config{ - Name: "tlin", - Rules: map[string]tt.ConfigRule{}, - } - d, err := yaml.Marshal(config) - if err != nil { - return err - } - - f, err := os.Create(configurationPath) - if err != nil { - return err - } - - defer f.Close() - - _, err = f.Write(d) - if err != nil { - return err - } - - return nil -} - -func printIssues(logger *zap.Logger, issues []tt.Issue, isJson bool, jsonOutput string) { - issuesByFile := make(map[string][]tt.Issue) - for _, issue := range issues { - issuesByFile[issue.Filename] = append(issuesByFile[issue.Filename], issue) - } - - sortedFiles := make([]string, 0, len(issuesByFile)) - for filename := range issuesByFile { - sortedFiles = append(sortedFiles, filename) - } - sort.Strings(sortedFiles) - - if !isJson { - for _, filename := range sortedFiles { - fileIssues := issuesByFile[filename] - sourceCode, err := internal.ReadSourceCode(filename) - if err != nil { - logger.Error("Error reading source file", zap.String("file", filename), zap.Error(err)) - continue - } - output := formatter.GenerateFormattedIssue(fileIssues, sourceCode) - fmt.Println(output) - } - } else { - d, err := json.Marshal(issuesByFile) - if err != nil { - logger.Error("Error marshalling issues to JSON", zap.Error(err)) - return - } - if jsonOutput == "" { - fmt.Println(string(d)) - } else { - f, err := os.Create(jsonOutput) - if err != nil { - logger.Error("Error creating JSON output file", zap.Error(err)) - return - } - defer f.Close() - _, err = f.Write(d) - if err != nil { - logger.Error("Error writing JSON output file", zap.Error(err)) - return - } - } - } + cmd.Execute() } diff --git a/cmd/tlin/main_test.go b/cmd/tlin/main_test.go index f1656ef..6290f28 100644 --- a/cmd/tlin/main_test.go +++ b/cmd/tlin/main_test.go @@ -1,403 +1,75 @@ package main import ( - "bytes" - "context" - "encoding/json" "fmt" - "go/token" - "io" + "github.com/stretchr/testify/assert" "os" "os/exec" "path/filepath" - "strings" - "sync" "testing" - "time" - - tt "github.com/gnolang/tlin/internal/types" - "github.com/gnolang/tlin/lint" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "go.uber.org/zap" - "gopkg.in/yaml.v3" ) -type mockLintEngine struct { - mock.Mock -} - -func (m *mockLintEngine) Run(filePath string) ([]tt.Issue, error) { - args := m.Called(filePath) - return args.Get(0).([]tt.Issue), args.Error(1) -} - -func (m *mockLintEngine) RunSource(source []byte) ([]tt.Issue, error) { - args := m.Called(source) - return args.Get(0).([]tt.Issue), args.Error(1) -} - -func (m *mockLintEngine) IgnoreRule(rule string) { - m.Called(rule) -} - -func (m *mockLintEngine) IgnorePath(path string) { - m.Called(path) -} - -func setupMockEngine(expectedIssues []tt.Issue, filePath string) *mockLintEngine { - mockEngine := new(mockLintEngine) - mockEngine.On("Run", filePath).Return(expectedIssues, nil) - return mockEngine -} - -func TestParseFlags(t *testing.T) { - t.Parallel() - tests := []struct { - name string - args []string - expected Config - }{ - { - name: "AutoFix", - args: []string{"-fix", "file.go"}, - expected: Config{ - AutoFix: true, - Paths: []string{"file.go"}, - ConfidenceThreshold: defaultConfidenceThreshold, - ConfigurationPath: ".tlin.yaml", - }, - }, - { - name: "AutoFix with DryRun", - args: []string{"-fix", "-dry-run", "file.go"}, - expected: Config{ - AutoFix: true, - DryRun: true, - Paths: []string{"file.go"}, - ConfidenceThreshold: defaultConfidenceThreshold, - ConfigurationPath: ".tlin.yaml", - }, - }, - { - name: "AutoFix with custom confidence", - args: []string{"-fix", "-confidence", "0.9", "file.go"}, - expected: Config{ - AutoFix: true, - Paths: []string{"file.go"}, - ConfidenceThreshold: 0.9, - ConfigurationPath: ".tlin.yaml", - }, - }, - { - name: "JsonOutput", - args: []string{"-json", "file.go"}, - expected: Config{ - Paths: []string{"file.go"}, - JsonOutput: true, - ConfidenceThreshold: defaultConfidenceThreshold, - ConfigurationPath: ".tlin.yaml", - }, - }, - { - name: "Output", - args: []string{"-o", "output.svg", "file.go"}, - expected: Config{ - Paths: []string{"file.go"}, - Output: "output.svg", - ConfidenceThreshold: defaultConfidenceThreshold, - ConfigurationPath: ".tlin.yaml", - }, - }, - { - name: "Configuration File", - args: []string{"-c", "config.yaml", "file.go"}, - expected: Config{ - Paths: []string{"file.go"}, - ConfidenceThreshold: defaultConfidenceThreshold, - ConfigurationPath: "config.yaml", - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - config := parseFlags(tt.args) - - assert.Equal(t, tt.expected.AutoFix, config.AutoFix) - assert.Equal(t, tt.expected.DryRun, config.DryRun) - assert.Equal(t, tt.expected.ConfidenceThreshold, config.ConfidenceThreshold) - assert.Equal(t, tt.expected.Paths, config.Paths) - assert.Equal(t, tt.expected.JsonOutput, config.JsonOutput) - assert.Equal(t, tt.expected.Output, config.Output) - assert.Equal(t, tt.expected.ConfigurationPath, config.ConfigurationPath) - }) - } -} - -func TestRunWithTimeout(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - done := make(chan bool) - go func() { - runWithTimeout(ctx, func() { - time.Sleep(1 * time.Second) - done <- true - }) - }() - - select { - case <-done: - // no problem - case <-ctx.Done(): - t.Fatal("function unexpectedly timed out") - } -} - -func TestInitConfigurationFile(t *testing.T) { - t.Parallel() - tempDir, err := os.MkdirTemp("", "init-test") - assert.NoError(t, err) - defer os.RemoveAll(tempDir) - - configPath := filepath.Join(tempDir, ".tlin.yaml") - - err = initConfigurationFile(configPath) - assert.NoError(t, err) - - _, err = os.Stat(configPath) - assert.NoError(t, err) - - content, err := os.ReadFile(configPath) - assert.NoError(t, err) - - expectedConfig := lint.Config{ - Name: "tlin", - Rules: map[string]tt.ConfigRule{}, - } - config := &lint.Config{} - yaml.Unmarshal(content, config) - - assert.Equal(t, expectedConfig, *config) -} - -func TestRunCFGAnalysis(t *testing.T) { - t.Parallel() - logger, _ := zap.NewProduction() - - testCode := `package main // 1 - // 2 -func mainFunc() { // 3 - x := 1 // 4 - if x > 0 { // 5 - x = 2 // 6 - } else { // 7 - x = 3 // 8 - } // 9 -} // 10 - // 11 -func targetFunc() { // 12 - y := 10 // 13 - for i := 0; i < 5; i++ { // 14 - y += i // 15 - } // 16 -} // 17 - // 18 -func ignoredFunc() { // 19 - z := "hello" // 20 - println(z) // 21 -} // 22 -` - tempFile := createTempFileWithContent(t, testCode) - defer os.Remove(tempFile) - - ctx := context.Background() - - output := captureOutput(t, func() { - runCFGAnalysis(ctx, logger, []string{tempFile}, "targetFunc", "") - }) - - assert.Contains(t, output, "CFG for function targetFunc in file") - assert.Contains(t, output, "digraph mgraph") - assert.Contains(t, output, "\"for loop") - assert.Contains(t, output, "\"assignment") - assert.NotContains(t, output, "mainFunc") - assert.NotContains(t, output, "ignoredFunc") - - t.Logf("output: %s", output) - - output = captureOutput(t, func() { - runCFGAnalysis(ctx, logger, []string{tempFile}, "nonExistentFunc", "") - }) - - assert.Contains(t, output, "Function not found: nonExistentFunc") -} - -const sliceRangeIssueExample = `package main - -func main() { - slice := []int{1, 2, 3} - _ = slice[:len(slice)] -} -` - -func TestRunAutoFix(t *testing.T) { - logger, _ := zap.NewProduction() - ctx := context.Background() - - tempDir, err := os.MkdirTemp("", "autofix-test") - assert.NoError(t, err) - defer os.RemoveAll(tempDir) +// TestMainFunction_Example demonstrates how to perform integration testing +// of the main function. It uses a parent-child process approach to test +// the actual main function execution. +func TestMainFunction_Example(t *testing.T) { + // Test runner is the parent process + // Fork into child process (= actual main call) + if os.Getenv("TEST_MAIN_EXAMPLE") != "1" { + // 1) Child process invocation section + // Create temporary directory for testing + tempDir, err := os.MkdirTemp("", "main-test-example") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Example of executing "tlin init" subcommand + cmd := exec.Command(os.Args[0], "-test.run=TestMainFunction_Example") + cmd.Env = append(os.Environ(), "TEST_MAIN_EXAMPLE=1") + cmd.Env = append(cmd.Env, fmt.Sprintf("TEST_TMP_DIR=%s", tempDir)) + + // Actual subcommand arguments + // Following `os.Args[0]`, we could add "init" etc. + // However, here we'll use a trick to make the child process + // use mainTestArgs, so we can pass it via environment + // variables instead of command line arguments. + output, err := cmd.CombinedOutput() + + // Error occurs here if child process calls `os.Exit(1)` + exitError, _ := err.(*exec.ExitError) + if exitError != nil && exitError.ExitCode() != 0 { + t.Fatalf("process failed with exit code %d: %s", + exitError.ExitCode(), string(output)) + } - testFile := filepath.Join(tempDir, "test.go") - err = os.WriteFile(testFile, []byte(sliceRangeIssueExample), 0o644) - assert.NoError(t, err) + // Verification logic for output (= stdout+stderr) + t.Logf("child process output: %s", output) - expectedIssues := []tt.Issue{ - { - Rule: "simplify-slice-range", - Filename: testFile, - Message: "unnecessary use of len() in slice expression, can be simplified", - Start: token.Position{Line: 5, Column: 5}, - End: token.Position{Line: 5, Column: 24}, - Suggestion: "_ = slice[:]", - Confidence: 0.9, - }, + // Example: verify if config file was created properly + configPath := filepath.Join(tempDir, ".tlin.yaml") + _, statErr := os.Stat(configPath) + assert.NoError(t, statErr, "config file must exist after init command") + return } - mockEngine := setupMockEngine(expectedIssues, testFile) - - output := captureOutput(t, func() { - runAutoFix(ctx, logger, mockEngine, []string{testFile}, false, 0.8) - }) - - content, err := os.ReadFile(testFile) - assert.NoError(t, err) - - expectedContent := `package main - -func main() { - slice := []int{1, 2, 3} - _ = slice[:] -} -` - assert.Equal(t, expectedContent, string(content)) - assert.Contains(t, output, "Fixed issues in") - - // dry run test - err = os.WriteFile(testFile, []byte(sliceRangeIssueExample), 0o644) - assert.NoError(t, err) - - output = captureOutput(t, func() { - runAutoFix(ctx, logger, mockEngine, []string{testFile}, true, 0.8) - }) - - content, err = os.ReadFile(testFile) - assert.NoError(t, err) - assert.Equal(t, sliceRangeIssueExample, string(content)) - assert.Contains(t, output, "Would fix issue in") -} - -func TestRunJsonOutput(t *testing.T) { - if os.Getenv("BE_CRASHER") != "1" { - cmd := exec.Command(os.Args[0], "-test.run=TestRunJsonOutput") - cmd.Env = append(os.Environ(), "BE_CRASHER=1") - output, err := cmd.CombinedOutput() // stdout and stderr capture - if e, ok := err.(*exec.ExitError); ok && !e.Success() { - tempDir := string(bytes.TrimRight(output, "\n")) - defer os.RemoveAll(tempDir) - - // check if issues are written - jsonOutput := filepath.Join(tempDir, "output.json") - content, err := os.ReadFile(jsonOutput) - assert.NoError(t, err) - - var actualContent map[string][]tt.Issue - err = json.Unmarshal(content, &actualContent) - assert.NoError(t, err) - - assert.Len(t, actualContent, 1) - for filename, issues := range actualContent { - assert.True(t, strings.HasSuffix(filename, "test.go")) - assert.Len(t, issues, 1) - issue := issues[0] - assert.Equal(t, "simplify-slice-range", issue.Rule) - assert.Equal(t, "unnecessary use of len() in slice expression, can be simplified", issue.Message) - assert.Equal(t, "_ = slice[:]", issue.Suggestion) - assert.Equal(t, 0.9, issue.Confidence) - assert.Equal(t, 5, issue.Start.Line) - assert.Equal(t, 5, issue.Start.Column) - assert.Equal(t, 5, issue.End.Line) - assert.Equal(t, 24, issue.End.Column) - assert.Equal(t, tt.SeverityError, issue.Severity) - } - - return - } - t.Fatalf("process failed with error %v, expected exit status 1", err) + // 2) Child process section + // Direct call to `main()` happens here + // Assuming execution of "tlin init" command + // Get temporary directory path from `TEST_TMP_DIR` + tempDir := os.Getenv("TEST_TMP_DIR") + if tempDir == "" { + fmt.Println("TEST_TMP_DIR not set") + os.Exit(1) } - logger, _ := zap.NewProduction() - ctx := context.Background() - - tempDir, err := os.MkdirTemp("", "json-test") - assert.NoError(t, err) - fmt.Println(tempDir) - - testFile := filepath.Join(tempDir, "test.go") - err = os.WriteFile(testFile, []byte(sliceRangeIssueExample), 0o644) - assert.NoError(t, err) - - expectedIssues := []tt.Issue{ - { - Rule: "simplify-slice-range", - Filename: testFile, - Message: "unnecessary use of len() in slice expression, can be simplified", - Start: token.Position{Line: 5, Column: 5}, - End: token.Position{Line: 5, Column: 24}, - Suggestion: "_ = slice[:]", - Confidence: 0.9, - }, + // Can simulate actual CLI arguments + // Example: tlin init --config= + os.Args = []string{ + "tlin", // fake argv[0] + "init", // actual subcommand + "--config", filepath.Join(tempDir, ".tlin.yaml"), } - mockEngine := setupMockEngine(expectedIssues, testFile) - - jsonOutput := filepath.Join(tempDir, "output.json") - runNormalLintProcess(ctx, logger, mockEngine, []string{testFile}, true, jsonOutput) -} - -func createTempFileWithContent(t *testing.T, content string) string { - t.Helper() - tempFile, err := os.CreateTemp("", "test*.go") - assert.NoError(t, err) - defer tempFile.Close() - - _, err = tempFile.Write([]byte(content)) - assert.NoError(t, err) - - return tempFile.Name() -} - -var mu sync.Mutex - -func captureOutput(t *testing.T, f func()) string { - t.Helper() - mu.Lock() - defer mu.Unlock() - - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - f() - - w.Close() - os.Stdout = oldStdout - var buf bytes.Buffer - io.Copy(&buf, r) - return buf.String() + main() + // After execution, `os.Exit(0)` returns to parent process + os.Exit(0) } diff --git a/go.mod b/go.mod index 151e42e..604f190 100644 --- a/go.mod +++ b/go.mod @@ -15,8 +15,11 @@ require ( github.com/flopp/go-findfont v0.1.0 // indirect github.com/fogleman/gg v1.3.0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/spf13/cobra v1.8.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tetratelabs/wazero v1.8.1 // indirect go.uber.org/multierr v1.10.0 // indirect diff --git a/go.sum b/go.sum index 80c8fb6..6784684 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= @@ -18,6 +19,8 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF0 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -27,6 +30,12 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= From d1f669118dd4292b32d8228876356a05eee364f0 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Wed, 12 Feb 2025 22:32:40 +0900 Subject: [PATCH 2/2] support old behaviour --- README.md | 1 - cmd/cfg.go | 7 ++++--- cmd/init.go | 1 - cmd/root.go | 40 +++++++++++++++------------------------- cmd/tlin/main_test.go | 3 ++- 5 files changed, 21 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 8718063..d584ff1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ Advance Linter for go-like grammar languages. -![GitHub Workflow Status](https://img.shields.io/github/workflow/status/gnolang/tlin/CI?label=build) ![License](https://img.shields.io/badge/License-MIT-blue.svg) ## Introduction diff --git a/cmd/cfg.go b/cmd/cfg.go index c27b8e2..e0042ed 100644 --- a/cmd/cfg.go +++ b/cmd/cfg.go @@ -3,14 +3,15 @@ package cmd import ( "context" "fmt" - "github.com/gnolang/tlin/internal/analysis/cfg" - "github.com/spf13/cobra" - "go.uber.org/zap" "go/ast" "go/parser" "go/token" "os" "strings" + + "github.com/gnolang/tlin/internal/analysis/cfg" + "github.com/spf13/cobra" + "go.uber.org/zap" ) // variable for flags diff --git a/cmd/init.go b/cmd/init.go index c089031..8a47023 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" tt "github.com/gnolang/tlin/internal/types" diff --git a/cmd/root.go b/cmd/root.go index ce1b374..64fe8ef 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,7 +1,6 @@ package cmd import ( - "os" "time" "github.com/spf13/cobra" @@ -16,38 +15,29 @@ var ( ) var rootCmd = &cobra.Command{ - Use: "tlin", - Short: "tlin is a linter for Gno code", - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - var err error - logger, err = zap.NewProduction() - if err != nil { - return err - } - return nil - }, - PersistentPostRun: func(cmd *cobra.Command, args []string) { - if logger != nil { - logger.Sync() + Use: "tlin [paths...]", + Short: "tlin - a powerful linting tool with multiple subcommands", + TraverseChildren: true, // Prioritize subcommands + Run: func(cmd *cobra.Command, args []string) { + // no subcommand + if len(args) == 0 { + // display help when only 'tlin' is entered + _ = cmd.Help() + return } + // Format: tlin [path1 path2 ...] => behaves like the lint subcommand + lintCmd.Run(lintCmd, args) }, } -func Execute() { - if err := rootCmd.Execute(); err != nil { - os.Exit(1) - } +func Execute() error { + return rootCmd.Execute() } func init() { - // global flags for the root command - rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", ".tlin.yaml", "Path to the linter configuration file") - rootCmd.PersistentFlags().DurationVar(&timeout, "timeout", 5*time.Minute, "Set a timeout for the linter") - - // register subcommands rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(lintCmd) + rootCmd.AddCommand(fixCmd) rootCmd.AddCommand(cfgCmd) rootCmd.AddCommand(cycloCmd) - rootCmd.AddCommand(fixCmd) - rootCmd.AddCommand(lintCmd) } diff --git a/cmd/tlin/main_test.go b/cmd/tlin/main_test.go index 6290f28..8a8f930 100644 --- a/cmd/tlin/main_test.go +++ b/cmd/tlin/main_test.go @@ -2,11 +2,12 @@ package main import ( "fmt" - "github.com/stretchr/testify/assert" "os" "os/exec" "path/filepath" "testing" + + "github.com/stretchr/testify/assert" ) // TestMainFunction_Example demonstrates how to perform integration testing