From d0fceca83e4764223343e362da5e6ffc01688ee5 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 17 Nov 2025 21:08:06 +0530 Subject: [PATCH 1/2] feat: implement SQL linting rules engine (FEAT-002) - Phase 1a MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements core linting infrastructure with 3 whitespace rules: ## New Features - **Linting Engine**: Extensible rule-based architecture for SQL code quality - Rule interface with Check() and Fix() methods - Violation tracking with severity levels (error, warning, info) - Context-aware linting with SQL, tokens, and AST access - Auto-fix capability for rule violations - **CLI Integration**: New `gosqlx lint` command - File, directory, and stdin support - Recursive directory processing with glob patterns - Auto-fix mode (--auto-fix flag) - Configurable maximum line length - Exit codes for CI/CD integration - **Whitespace Rules** (3/10 Phase 1 rules): - L001: Trailing Whitespace (auto-fix supported) - L002: Mixed Indentation (auto-fix supported, converts tabs to spaces) - L005: Long Lines (configurable max length, default 100) ## Architecture pkg/linter/ ├── rule.go # Rule interface, Violation, BaseRule ├── context.go # Linting context with SQL/tokens/AST ├── linter.go # Main linter engine └── rules/whitespace/ ├── trailing_whitespace.go # L001 rule ├── mixed_indentation.go # L002 rule └── long_lines.go # L005 rule ## Usage Examples # Lint single file gosqlx lint query.sql # Auto-fix violations gosqlx lint --auto-fix query.sql # Lint directory recursively gosqlx lint -r ./queries/ # Lint from stdin echo "SELECT * FROM users" | gosqlx lint # Set custom line length gosqlx lint --max-length 120 query.sql ## Testing - Example program in examples/linter-example/ - Verified violation detection and auto-fix functionality - Tested CLI integration (file, directory, stdin) - Exit code validation for CI/CD workflows ## Next Steps (Phase 1b-c) - L003: Consecutive blank lines - L004: Indentation depth validation - L006: SELECT column alignment - L007: Reserved word capitalization - L008: Comma placement style - L009: Aliasing consistency - L010: Redundant whitespace 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd/gosqlx/cmd/lint.go | 240 ++++++++++++++++++ examples/linter-example/main.go | 58 +++++ pkg/linter/context.go | 65 +++++ pkg/linter/linter.go | 203 +++++++++++++++ pkg/linter/rule.go | 96 +++++++ pkg/linter/rules/whitespace/long_lines.go | 77 ++++++ .../rules/whitespace/mixed_indentation.go | 118 +++++++++ .../rules/whitespace/trailing_whitespace.go | 70 +++++ 8 files changed, 927 insertions(+) create mode 100644 cmd/gosqlx/cmd/lint.go create mode 100644 examples/linter-example/main.go create mode 100644 pkg/linter/context.go create mode 100644 pkg/linter/linter.go create mode 100644 pkg/linter/rule.go create mode 100644 pkg/linter/rules/whitespace/long_lines.go create mode 100644 pkg/linter/rules/whitespace/mixed_indentation.go create mode 100644 pkg/linter/rules/whitespace/trailing_whitespace.go diff --git a/cmd/gosqlx/cmd/lint.go b/cmd/gosqlx/cmd/lint.go new file mode 100644 index 00000000..0ed9998a --- /dev/null +++ b/cmd/gosqlx/cmd/lint.go @@ -0,0 +1,240 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/ajitpratap0/GoSQLX/pkg/linter" + "github.com/ajitpratap0/GoSQLX/pkg/linter/rules/whitespace" +) + +var ( + lintRecursive bool + lintPattern string + lintAutoFix bool + lintMaxLength int + lintFailOnWarn bool +) + +// lintCmd represents the lint command +var lintCmd = &cobra.Command{ + Use: "lint [file...]", + Short: "Check SQL code for style and quality issues", + Long: `Lint SQL files to detect style and quality issues. + +The linter checks for common issues like: + • L001: Trailing whitespace at end of lines + • L002: Mixed tabs and spaces for indentation + • L005: Lines exceeding maximum length + +Examples: + gosqlx lint query.sql # Lint single file + gosqlx lint query1.sql query2.sql # Lint multiple files + gosqlx lint "*.sql" # Lint all SQL files (with quotes) + gosqlx lint -r ./queries/ # Recursively lint directory + gosqlx lint --auto-fix query.sql # Auto-fix violations where possible + gosqlx lint --max-length 120 query.sql # Set maximum line length + +Pipeline/Stdin Examples: + echo "SELECT * FROM users" | gosqlx lint # Lint from stdin + cat query.sql | gosqlx lint # Pipe file contents + gosqlx lint - # Explicit stdin marker + gosqlx lint < query.sql # Input redirection + +Exit Codes: + 0 - No violations found + 1 - Errors or warnings found (warnings only if --fail-on-warn is set)`, + Args: cobra.MinimumNArgs(0), + RunE: lintRun, +} + +func lintRun(cmd *cobra.Command, args []string) error { + // Handle stdin input + if ShouldReadFromStdin(args) { + return lintFromStdin(cmd) + } + + // Validate that we have file arguments if not using stdin + if len(args) == 0 { + return fmt.Errorf("no input provided: specify file paths or pipe SQL via stdin") + } + + // Create linter with default rules + l := createLinter() + + // Process files or directories + var result linter.Result + if lintRecursive { + // Process directories recursively + for _, path := range args { + r := l.LintDirectory(path, lintPattern) + result.Files = append(result.Files, r.Files...) + result.TotalFiles += r.TotalFiles + result.TotalViolations += r.TotalViolations + } + } else { + // Process individual files + result = l.LintFiles(args) + } + + // Display results + output := linter.FormatResult(result) + fmt.Fprint(cmd.OutOrStdout(), output) + + // Apply auto-fix if requested + if lintAutoFix && result.TotalViolations > 0 { + fmt.Fprintln(cmd.OutOrStdout(), "\nApplying auto-fixes...") + fixCount := 0 + + for _, fileResult := range result.Files { + if len(fileResult.Violations) == 0 { + continue + } + + // Read file content + content, err := os.ReadFile(fileResult.Filename) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error reading %s: %v\n", fileResult.Filename, err) + continue + } + + fixed := string(content) + modified := false + + // Apply fixes from each rule + for _, rule := range l.Rules() { + if !rule.CanAutoFix() { + continue + } + + fixedContent, err := rule.Fix(fixed, fileResult.Violations) + if err != nil { + continue + } + + if fixedContent != fixed { + fixed = fixedContent + modified = true + } + } + + // Write back if modified + if modified { + if err := os.WriteFile(fileResult.Filename, []byte(fixed), 0600); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error writing %s: %v\n", fileResult.Filename, err) + continue + } + fixCount++ + fmt.Fprintf(cmd.OutOrStdout(), "Fixed: %s\n", fileResult.Filename) + } + } + + fmt.Fprintf(cmd.OutOrStdout(), "\nAuto-fixed %d file(s)\n", fixCount) + } + + // Exit with error code if there were violations + errorCount := 0 + warningCount := 0 + for _, fileResult := range result.Files { + for _, violation := range fileResult.Violations { + if violation.Severity == linter.SeverityError { + errorCount++ + } else if violation.Severity == linter.SeverityWarning { + warningCount++ + } + } + } + + // Exit with error if there are errors, or warnings with fail-on-warn flag + if errorCount > 0 || (lintFailOnWarn && warningCount > 0) { + os.Exit(1) + } + + return nil +} + +// lintFromStdin handles linting from stdin input +func lintFromStdin(cmd *cobra.Command) error { + // Read from stdin + content, err := ReadFromStdin() + if err != nil { + return fmt.Errorf("failed to read from stdin: %w", err) + } + + // Validate stdin content + if err := ValidateStdinInput(content); err != nil { + return fmt.Errorf("stdin validation failed: %w", err) + } + + // Create linter + l := createLinter() + + // Lint the content + result := l.LintString(string(content), "stdin") + + // Display results + fmt.Fprintf(cmd.OutOrStdout(), "Linting stdin input:\n\n") + + if len(result.Violations) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No violations found.") + return nil + } + + fmt.Fprintf(cmd.OutOrStdout(), "Found %d violation(s):\n\n", len(result.Violations)) + for i, violation := range result.Violations { + fmt.Fprintf(cmd.OutOrStdout(), "%d. %s\n", i+1, linter.FormatViolation(violation)) + } + + // Apply auto-fix if requested + if lintAutoFix { + fmt.Fprintln(cmd.OutOrStdout(), "\nAuto-fixed output:") + fixed := string(content) + for _, rule := range l.Rules() { + if rule.CanAutoFix() { + fixedContent, err := rule.Fix(fixed, result.Violations) + if err == nil && fixedContent != fixed { + fixed = fixedContent + } + } + } + fmt.Fprintln(cmd.OutOrStdout(), fixed) + } + + // Exit with error code if there were violations + errorCount := 0 + warningCount := 0 + for _, violation := range result.Violations { + if violation.Severity == linter.SeverityError { + errorCount++ + } else if violation.Severity == linter.SeverityWarning { + warningCount++ + } + } + + if errorCount > 0 || (lintFailOnWarn && warningCount > 0) { + os.Exit(1) + } + + return nil +} + +// createLinter creates a new linter instance with configured rules +func createLinter() *linter.Linter { + return linter.New( + whitespace.NewTrailingWhitespaceRule(), + whitespace.NewMixedIndentationRule(), + whitespace.NewLongLinesRule(lintMaxLength), + ) +} + +func init() { + rootCmd.AddCommand(lintCmd) + + lintCmd.Flags().BoolVarP(&lintRecursive, "recursive", "r", false, "recursively process directories") + lintCmd.Flags().StringVarP(&lintPattern, "pattern", "p", "*.sql", "file pattern for recursive processing") + lintCmd.Flags().BoolVar(&lintAutoFix, "auto-fix", false, "automatically fix violations where possible") + lintCmd.Flags().IntVar(&lintMaxLength, "max-length", 100, "maximum line length (L005 rule)") + lintCmd.Flags().BoolVar(&lintFailOnWarn, "fail-on-warn", false, "exit with error code on warnings") +} diff --git a/examples/linter-example/main.go b/examples/linter-example/main.go new file mode 100644 index 00000000..d9ca706a --- /dev/null +++ b/examples/linter-example/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + + "github.com/ajitpratap0/GoSQLX/pkg/linter" + "github.com/ajitpratap0/GoSQLX/pkg/linter/rules/whitespace" +) + +func main() { + // Example SQL with various linting issues + sql := `SELECT id, name, email +FROM users + WHERE active = true + AND created_at > '2024-01-01' +ORDER BY name + +` + + fmt.Println("SQL Linting Example") + fmt.Println("===================") + fmt.Println("Input SQL:") + fmt.Println(sql) + fmt.Println("\n" + string(make([]byte, 80, 80)) + "\n") + + // Create linter with rules + l := linter.New( + whitespace.NewTrailingWhitespaceRule(), + whitespace.NewMixedIndentationRule(), + whitespace.NewLongLinesRule(80), + ) + + // Lint the SQL + result := l.LintString(sql, "example.sql") + + // Display results + fmt.Printf("Found %d violation(s):\n\n", len(result.Violations)) + + for i, violation := range result.Violations { + fmt.Printf("%d. %s\n", i+1, linter.FormatViolation(violation)) + } + + // Test auto-fix + if len(result.Violations) > 0 { + fmt.Println("\nAttempting auto-fix...") + + for _, rule := range l.Rules() { + if rule.CanAutoFix() { + fixed, err := rule.Fix(sql, result.Violations) + if err == nil && fixed != sql { + fmt.Printf("\nFixed by %s (%s):\n", rule.Name(), rule.ID()) + fmt.Println(fixed) + sql = fixed + } + } + } + } +} diff --git a/pkg/linter/context.go b/pkg/linter/context.go new file mode 100644 index 00000000..4f4370bb --- /dev/null +++ b/pkg/linter/context.go @@ -0,0 +1,65 @@ +package linter + +import ( + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +// Context provides all information needed for linting +type Context struct { + // Source SQL content + SQL string + + // SQL split into lines for convenience + Lines []string + + // Tokenization results (if available) + Tokens []models.TokenWithSpan + + // Parsing results (if available) + AST *ast.AST + ParseErr error + + // File metadata + Filename string +} + +// NewContext creates a new linting context +func NewContext(sql string, filename string) *Context { + lines := strings.Split(sql, "\n") + + return &Context{ + SQL: sql, + Lines: lines, + Filename: filename, + } +} + +// WithTokens adds tokenization results to the context +func (c *Context) WithTokens(tokens []models.TokenWithSpan) *Context { + c.Tokens = tokens + return c +} + +// WithAST adds parsing results to the context +func (c *Context) WithAST(astObj *ast.AST, err error) *Context { + c.AST = astObj + c.ParseErr = err + return c +} + +// GetLine returns a specific line (1-indexed) +// Returns empty string if line number is out of bounds +func (c *Context) GetLine(lineNum int) string { + if lineNum < 1 || lineNum > len(c.Lines) { + return "" + } + return c.Lines[lineNum-1] +} + +// GetLineCount returns the total number of lines +func (c *Context) GetLineCount() int { + return len(c.Lines) +} diff --git a/pkg/linter/linter.go b/pkg/linter/linter.go new file mode 100644 index 00000000..4509d058 --- /dev/null +++ b/pkg/linter/linter.go @@ -0,0 +1,203 @@ +package linter + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" + "github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer" +) + +// Result represents the linting result for one or more files +type Result struct { + Files []FileResult + TotalFiles int + TotalViolations int +} + +// FileResult represents linting results for a single file +type FileResult struct { + Filename string + Violations []Violation + Error error +} + +// Linter performs SQL linting with configurable rules +type Linter struct { + rules []Rule +} + +// New creates a new linter with the given rules +func New(rules ...Rule) *Linter { + return &Linter{ + rules: rules, + } +} + +// Rules returns the list of rules configured for this linter +func (l *Linter) Rules() []Rule { + return l.rules +} + +// LintFile lints a single SQL file +func (l *Linter) LintFile(filename string) FileResult { + // Read file + content, err := os.ReadFile(filename) + if err != nil { + return FileResult{ + Filename: filename, + Error: fmt.Errorf("failed to read file: %w", err), + } + } + + return l.LintString(string(content), filename) +} + +// LintString lints SQL content provided as a string +func (l *Linter) LintString(sql string, filename string) FileResult { + result := FileResult{ + Filename: filename, + Violations: []Violation{}, + } + + // Create linting context + ctx := NewContext(sql, filename) + + // Attempt tokenization (best effort - some rules don't need it) + tkz := tokenizer.GetTokenizer() + defer tokenizer.PutTokenizer(tkz) + + tokens, tokenErr := tkz.Tokenize([]byte(sql)) + if tokenErr == nil { + ctx.WithTokens(tokens) + + // Attempt parsing (best effort - some rules are token-only) + convertedTokens, convErr := parser.ConvertTokensForParser(tokens) + if convErr == nil { + p := parser.NewParser() + astObj, parseErr := p.Parse(convertedTokens) + ctx.WithAST(astObj, parseErr) + } + } + + // Run all rules + for _, rule := range l.rules { + violations, err := rule.Check(ctx) + if err != nil { + result.Error = fmt.Errorf("rule %s failed: %w", rule.ID(), err) + return result + } + result.Violations = append(result.Violations, violations...) + } + + return result +} + +// LintFiles lints multiple files +func (l *Linter) LintFiles(filenames []string) Result { + result := Result{ + Files: make([]FileResult, 0, len(filenames)), + TotalFiles: len(filenames), + } + + for _, filename := range filenames { + fileResult := l.LintFile(filename) + result.Files = append(result.Files, fileResult) + result.TotalViolations += len(fileResult.Violations) + } + + return result +} + +// LintDirectory recursively lints all SQL files in a directory +func (l *Linter) LintDirectory(dir string, pattern string) Result { + var files []string + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() { + matched, matchErr := filepath.Match(pattern, filepath.Base(path)) + if matchErr != nil { + return matchErr + } + if matched { + files = append(files, path) + } + } + + return nil + }) + + if err != nil { + return Result{ + Files: []FileResult{{ + Filename: dir, + Error: fmt.Errorf("failed to walk directory: %w", err), + }}, + } + } + + return l.LintFiles(files) +} + +// FormatViolation returns a formatted string representation of a violation +func FormatViolation(v Violation) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("[%s] %s at line %d, column %d\n", + v.Rule, v.RuleName, v.Location.Line, v.Location.Column)) + sb.WriteString(fmt.Sprintf(" Severity: %s\n", v.Severity)) + sb.WriteString(fmt.Sprintf(" %s\n", v.Message)) + + if v.Line != "" { + sb.WriteString(fmt.Sprintf("\n %4d | %s\n", v.Location.Line, v.Line)) + // Add indicator for column position + if v.Location.Column > 0 { + sb.WriteString(" | ") + sb.WriteString(strings.Repeat(" ", v.Location.Column-1)) + sb.WriteString("^\n") + } + } + + if v.Suggestion != "" { + sb.WriteString(fmt.Sprintf("\n Suggestion: %s\n", v.Suggestion)) + } + + return sb.String() +} + +// FormatResult returns a formatted string representation of linting results +func FormatResult(result Result) string { + var sb strings.Builder + + for _, fileResult := range result.Files { + if fileResult.Error != nil { + sb.WriteString(fmt.Sprintf("\n%s: ERROR: %v\n", fileResult.Filename, fileResult.Error)) + continue + } + + if len(fileResult.Violations) == 0 { + continue + } + + sb.WriteString(fmt.Sprintf("\n%s: %d violation(s)\n", fileResult.Filename, len(fileResult.Violations))) + sb.WriteString(strings.Repeat("=", 80) + "\n") + + for _, violation := range fileResult.Violations { + sb.WriteString(FormatViolation(violation)) + sb.WriteString("\n") + } + } + + // Summary + sb.WriteString(fmt.Sprintf("\n%s\n", strings.Repeat("=", 80))) + sb.WriteString(fmt.Sprintf("Total files: %d\n", result.TotalFiles)) + sb.WriteString(fmt.Sprintf("Total violations: %d\n", result.TotalViolations)) + + return sb.String() +} diff --git a/pkg/linter/rule.go b/pkg/linter/rule.go new file mode 100644 index 00000000..fbe6da30 --- /dev/null +++ b/pkg/linter/rule.go @@ -0,0 +1,96 @@ +package linter + +import ( + "github.com/ajitpratap0/GoSQLX/pkg/models" +) + +// Severity represents the severity level of a lint violation +type Severity string + +const ( + SeverityError Severity = "error" + SeverityWarning Severity = "warning" + SeverityInfo Severity = "info" +) + +// Violation represents a single linting rule violation +type Violation struct { + Rule string // Rule ID (e.g., "L001") + RuleName string // Human-readable rule name + Severity Severity // Severity level + Message string // Violation description + Location models.Location // Position in source (1-based) + Line string // The actual line content + Suggestion string // How to fix the violation + CanAutoFix bool // Whether this violation can be auto-fixed +} + +// Rule defines the interface for all linting rules +type Rule interface { + // ID returns the unique rule identifier (e.g., "L001") + ID() string + + // Name returns the human-readable rule name + Name() string + + // Description returns a description of what the rule checks + Description() string + + // Severity returns the default severity level for this rule + Severity() Severity + + // Check performs the rule check and returns violations + Check(ctx *Context) ([]Violation, error) + + // CanAutoFix returns whether this rule supports auto-fixing + CanAutoFix() bool + + // Fix applies automatic fixes if supported + // Returns the fixed content or an error + Fix(content string, violations []Violation) (string, error) +} + +// BaseRule provides common functionality for rules +type BaseRule struct { + id string + name string + description string + severity Severity + canAutoFix bool +} + +// NewBaseRule creates a new base rule +func NewBaseRule(id, name, description string, severity Severity, canAutoFix bool) BaseRule { + return BaseRule{ + id: id, + name: name, + description: description, + severity: severity, + canAutoFix: canAutoFix, + } +} + +// ID returns the rule ID +func (r BaseRule) ID() string { + return r.id +} + +// Name returns the rule name +func (r BaseRule) Name() string { + return r.name +} + +// Description returns the rule description +func (r BaseRule) Description() string { + return r.description +} + +// Severity returns the rule severity +func (r BaseRule) Severity() Severity { + return r.severity +} + +// CanAutoFix returns whether auto-fix is supported +func (r BaseRule) CanAutoFix() bool { + return r.canAutoFix +} diff --git a/pkg/linter/rules/whitespace/long_lines.go b/pkg/linter/rules/whitespace/long_lines.go new file mode 100644 index 00000000..347d155c --- /dev/null +++ b/pkg/linter/rules/whitespace/long_lines.go @@ -0,0 +1,77 @@ +package whitespace + +import ( + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/linter" + "github.com/ajitpratap0/GoSQLX/pkg/models" +) + +// LongLinesRule checks for lines exceeding maximum length +type LongLinesRule struct { + linter.BaseRule + MaxLength int +} + +// NewLongLinesRule creates a new L005 rule instance +func NewLongLinesRule(maxLength int) *LongLinesRule { + if maxLength <= 0 { + maxLength = 100 // Default to 100 characters + } + + return &LongLinesRule{ + BaseRule: linter.NewBaseRule( + "L005", + "Long Lines", + "Lines should not exceed maximum length for readability", + linter.SeverityInfo, + false, // Auto-fix not supported (requires semantic understanding) + ), + MaxLength: maxLength, + } +} + +// Check performs the long lines check +func (r *LongLinesRule) Check(ctx *linter.Context) ([]linter.Violation, error) { + violations := []linter.Violation{} + + for lineNum, line := range ctx.Lines { + lineLength := len(line) + + // Skip empty lines + if lineLength == 0 { + continue + } + + // Skip comment-only lines (optional - could be configurable) + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "--") || strings.HasPrefix(trimmed, "/*") { + continue + } + + if lineLength > r.MaxLength { + violations = append(violations, linter.Violation{ + Rule: r.ID(), + RuleName: r.Name(), + Severity: r.Severity(), + Message: "Line exceeds maximum length", + Location: models.Location{Line: lineNum + 1, Column: r.MaxLength + 1}, + Line: line, + Suggestion: func() string { + return "Split this line into multiple lines (current: " + + string(rune(lineLength)) + " chars, max: " + + string(rune(r.MaxLength)) + ")" + }(), + CanAutoFix: false, + }) + } + } + + return violations, nil +} + +// Fix is not supported for long lines (requires semantic understanding) +func (r *LongLinesRule) Fix(content string, violations []linter.Violation) (string, error) { + // No automatic fix available + return content, nil +} diff --git a/pkg/linter/rules/whitespace/mixed_indentation.go b/pkg/linter/rules/whitespace/mixed_indentation.go new file mode 100644 index 00000000..e6e0c5f3 --- /dev/null +++ b/pkg/linter/rules/whitespace/mixed_indentation.go @@ -0,0 +1,118 @@ +package whitespace + +import ( + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/linter" + "github.com/ajitpratap0/GoSQLX/pkg/models" +) + +// MixedIndentationRule checks for mixed tabs and spaces in indentation +type MixedIndentationRule struct { + linter.BaseRule +} + +// NewMixedIndentationRule creates a new L002 rule instance +func NewMixedIndentationRule() *MixedIndentationRule { + return &MixedIndentationRule{ + BaseRule: linter.NewBaseRule( + "L002", + "Mixed Indentation", + "Inconsistent use of tabs and spaces for indentation", + linter.SeverityError, + true, // Supports auto-fix (convert to spaces) + ), + } +} + +// Check performs the mixed indentation check +func (r *MixedIndentationRule) Check(ctx *linter.Context) ([]linter.Violation, error) { + violations := []linter.Violation{} + + // Track the first indentation type we encounter + var firstIndentType string // "tab" or "space" + + for lineNum, line := range ctx.Lines { + if len(line) == 0 { + continue + } + + // Get leading whitespace + leadingWhitespace := getLeadingWhitespace(line) + if len(leadingWhitespace) == 0 { + continue + } + + // Check what type of indentation this line uses + hasTabs := strings.Contains(leadingWhitespace, "\t") + hasSpaces := strings.Contains(leadingWhitespace, " ") + + // Mixed tabs and spaces on same line + if hasTabs && hasSpaces { + violations = append(violations, linter.Violation{ + Rule: r.ID(), + RuleName: r.Name(), + Severity: r.Severity(), + Message: "Line mixes tabs and spaces for indentation", + Location: models.Location{Line: lineNum + 1, Column: 1}, + Line: line, + Suggestion: "Use either tabs or spaces consistently for indentation (spaces recommended)", + CanAutoFix: true, + }) + continue + } + + // Track first indentation type and check consistency + currentType := "" + if hasTabs { + currentType = "tab" + } else if hasSpaces { + currentType = "space" + } + + if currentType != "" { + if firstIndentType == "" { + firstIndentType = currentType + } else if firstIndentType != currentType { + violations = append(violations, linter.Violation{ + Rule: r.ID(), + RuleName: r.Name(), + Severity: r.Severity(), + Message: "Inconsistent indentation: file uses both tabs and spaces", + Location: models.Location{Line: lineNum + 1, Column: 1}, + Line: line, + Suggestion: "Use " + firstIndentType + "s consistently throughout the file", + CanAutoFix: true, + }) + } + } + } + + return violations, nil +} + +// Fix converts all indentation to spaces (4 spaces per tab) +func (r *MixedIndentationRule) Fix(content string, violations []linter.Violation) (string, error) { + lines := strings.Split(content, "\n") + + for i, line := range lines { + // Replace tabs with 4 spaces in leading whitespace only + leadingWhitespace := getLeadingWhitespace(line) + if len(leadingWhitespace) > 0 { + fixed := strings.ReplaceAll(leadingWhitespace, "\t", " ") + lines[i] = fixed + strings.TrimLeft(line, " \t") + } + } + + return strings.Join(lines, "\n"), nil +} + +// getLeadingWhitespace returns the leading whitespace of a line +func getLeadingWhitespace(line string) string { + for i, char := range line { + if char != ' ' && char != '\t' { + return line[:i] + } + } + return line // Entire line is whitespace +} diff --git a/pkg/linter/rules/whitespace/trailing_whitespace.go b/pkg/linter/rules/whitespace/trailing_whitespace.go new file mode 100644 index 00000000..01576e05 --- /dev/null +++ b/pkg/linter/rules/whitespace/trailing_whitespace.go @@ -0,0 +1,70 @@ +package whitespace + +import ( + "strings" + "unicode" + + "github.com/ajitpratap0/GoSQLX/pkg/linter" + "github.com/ajitpratap0/GoSQLX/pkg/models" +) + +// TrailingWhitespaceRule checks for unnecessary trailing whitespace +type TrailingWhitespaceRule struct { + linter.BaseRule +} + +// NewTrailingWhitespaceRule creates a new L001 rule instance +func NewTrailingWhitespaceRule() *TrailingWhitespaceRule { + return &TrailingWhitespaceRule{ + BaseRule: linter.NewBaseRule( + "L001", + "Trailing Whitespace", + "Unnecessary trailing whitespace at end of lines", + linter.SeverityWarning, + true, // Supports auto-fix + ), + } +} + +// Check performs the trailing whitespace check +func (r *TrailingWhitespaceRule) Check(ctx *linter.Context) ([]linter.Violation, error) { + violations := []linter.Violation{} + + for lineNum, line := range ctx.Lines { + // Check if line has trailing whitespace + if len(line) == 0 { + continue + } + + lastChar := rune(line[len(line)-1]) + if unicode.IsSpace(lastChar) && lastChar != '\n' && lastChar != '\r' { + // Find the column where trailing whitespace starts + trimmed := strings.TrimRight(line, " \t") + column := len(trimmed) + 1 + + violations = append(violations, linter.Violation{ + Rule: r.ID(), + RuleName: r.Name(), + Severity: r.Severity(), + Message: "Line has trailing whitespace", + Location: models.Location{Line: lineNum + 1, Column: column}, + Line: line, + Suggestion: "Remove trailing spaces or tabs from the end of the line", + CanAutoFix: true, + }) + } + } + + return violations, nil +} + +// Fix removes trailing whitespace from all lines +func (r *TrailingWhitespaceRule) Fix(content string, violations []linter.Violation) (string, error) { + lines := strings.Split(content, "\n") + + for i, line := range lines { + lines[i] = strings.TrimRight(line, " \t") + } + + return strings.Join(lines, "\n"), nil +} From f953bcbb18636c4eb910e924f4c67b47acee9ed1 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 17 Nov 2025 21:12:07 +0530 Subject: [PATCH 2/2] fix: resolve staticcheck S1019 warning in linter example - Change make([]byte, 80, 80) to make([]byte, 80) - Redundant capacity argument not needed when length and capacity are equal - Fixes staticcheck S1019 warning in CI lint check --- examples/linter-example/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/linter-example/main.go b/examples/linter-example/main.go index d9ca706a..4e48fe1e 100644 --- a/examples/linter-example/main.go +++ b/examples/linter-example/main.go @@ -21,7 +21,7 @@ ORDER BY name fmt.Println("===================") fmt.Println("Input SQL:") fmt.Println(sql) - fmt.Println("\n" + string(make([]byte, 80, 80)) + "\n") + fmt.Println("\n" + string(make([]byte, 80)) + "\n") // Create linter with rules l := linter.New(