Skip to content

Commit 8b78fe1

Browse files
ajitpratap0Ajit Pratap Singhclaude
authored
feat: SQL Linting Rules Engine (FEAT-002) - Phase 1a (#111)
* feat: implement SQL linting rules engine (FEAT-002) - Phase 1a 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 <noreply@anthropic.com> * 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 --------- Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini.local> Co-authored-by: Claude <noreply@anthropic.com>
1 parent a2e8adc commit 8b78fe1

8 files changed

Lines changed: 927 additions & 0 deletions

File tree

cmd/gosqlx/cmd/lint.go

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/ajitpratap0/GoSQLX/pkg/linter"
10+
"github.com/ajitpratap0/GoSQLX/pkg/linter/rules/whitespace"
11+
)
12+
13+
var (
14+
lintRecursive bool
15+
lintPattern string
16+
lintAutoFix bool
17+
lintMaxLength int
18+
lintFailOnWarn bool
19+
)
20+
21+
// lintCmd represents the lint command
22+
var lintCmd = &cobra.Command{
23+
Use: "lint [file...]",
24+
Short: "Check SQL code for style and quality issues",
25+
Long: `Lint SQL files to detect style and quality issues.
26+
27+
The linter checks for common issues like:
28+
• L001: Trailing whitespace at end of lines
29+
• L002: Mixed tabs and spaces for indentation
30+
• L005: Lines exceeding maximum length
31+
32+
Examples:
33+
gosqlx lint query.sql # Lint single file
34+
gosqlx lint query1.sql query2.sql # Lint multiple files
35+
gosqlx lint "*.sql" # Lint all SQL files (with quotes)
36+
gosqlx lint -r ./queries/ # Recursively lint directory
37+
gosqlx lint --auto-fix query.sql # Auto-fix violations where possible
38+
gosqlx lint --max-length 120 query.sql # Set maximum line length
39+
40+
Pipeline/Stdin Examples:
41+
echo "SELECT * FROM users" | gosqlx lint # Lint from stdin
42+
cat query.sql | gosqlx lint # Pipe file contents
43+
gosqlx lint - # Explicit stdin marker
44+
gosqlx lint < query.sql # Input redirection
45+
46+
Exit Codes:
47+
0 - No violations found
48+
1 - Errors or warnings found (warnings only if --fail-on-warn is set)`,
49+
Args: cobra.MinimumNArgs(0),
50+
RunE: lintRun,
51+
}
52+
53+
func lintRun(cmd *cobra.Command, args []string) error {
54+
// Handle stdin input
55+
if ShouldReadFromStdin(args) {
56+
return lintFromStdin(cmd)
57+
}
58+
59+
// Validate that we have file arguments if not using stdin
60+
if len(args) == 0 {
61+
return fmt.Errorf("no input provided: specify file paths or pipe SQL via stdin")
62+
}
63+
64+
// Create linter with default rules
65+
l := createLinter()
66+
67+
// Process files or directories
68+
var result linter.Result
69+
if lintRecursive {
70+
// Process directories recursively
71+
for _, path := range args {
72+
r := l.LintDirectory(path, lintPattern)
73+
result.Files = append(result.Files, r.Files...)
74+
result.TotalFiles += r.TotalFiles
75+
result.TotalViolations += r.TotalViolations
76+
}
77+
} else {
78+
// Process individual files
79+
result = l.LintFiles(args)
80+
}
81+
82+
// Display results
83+
output := linter.FormatResult(result)
84+
fmt.Fprint(cmd.OutOrStdout(), output)
85+
86+
// Apply auto-fix if requested
87+
if lintAutoFix && result.TotalViolations > 0 {
88+
fmt.Fprintln(cmd.OutOrStdout(), "\nApplying auto-fixes...")
89+
fixCount := 0
90+
91+
for _, fileResult := range result.Files {
92+
if len(fileResult.Violations) == 0 {
93+
continue
94+
}
95+
96+
// Read file content
97+
content, err := os.ReadFile(fileResult.Filename)
98+
if err != nil {
99+
fmt.Fprintf(cmd.ErrOrStderr(), "Error reading %s: %v\n", fileResult.Filename, err)
100+
continue
101+
}
102+
103+
fixed := string(content)
104+
modified := false
105+
106+
// Apply fixes from each rule
107+
for _, rule := range l.Rules() {
108+
if !rule.CanAutoFix() {
109+
continue
110+
}
111+
112+
fixedContent, err := rule.Fix(fixed, fileResult.Violations)
113+
if err != nil {
114+
continue
115+
}
116+
117+
if fixedContent != fixed {
118+
fixed = fixedContent
119+
modified = true
120+
}
121+
}
122+
123+
// Write back if modified
124+
if modified {
125+
if err := os.WriteFile(fileResult.Filename, []byte(fixed), 0600); err != nil {
126+
fmt.Fprintf(cmd.ErrOrStderr(), "Error writing %s: %v\n", fileResult.Filename, err)
127+
continue
128+
}
129+
fixCount++
130+
fmt.Fprintf(cmd.OutOrStdout(), "Fixed: %s\n", fileResult.Filename)
131+
}
132+
}
133+
134+
fmt.Fprintf(cmd.OutOrStdout(), "\nAuto-fixed %d file(s)\n", fixCount)
135+
}
136+
137+
// Exit with error code if there were violations
138+
errorCount := 0
139+
warningCount := 0
140+
for _, fileResult := range result.Files {
141+
for _, violation := range fileResult.Violations {
142+
if violation.Severity == linter.SeverityError {
143+
errorCount++
144+
} else if violation.Severity == linter.SeverityWarning {
145+
warningCount++
146+
}
147+
}
148+
}
149+
150+
// Exit with error if there are errors, or warnings with fail-on-warn flag
151+
if errorCount > 0 || (lintFailOnWarn && warningCount > 0) {
152+
os.Exit(1)
153+
}
154+
155+
return nil
156+
}
157+
158+
// lintFromStdin handles linting from stdin input
159+
func lintFromStdin(cmd *cobra.Command) error {
160+
// Read from stdin
161+
content, err := ReadFromStdin()
162+
if err != nil {
163+
return fmt.Errorf("failed to read from stdin: %w", err)
164+
}
165+
166+
// Validate stdin content
167+
if err := ValidateStdinInput(content); err != nil {
168+
return fmt.Errorf("stdin validation failed: %w", err)
169+
}
170+
171+
// Create linter
172+
l := createLinter()
173+
174+
// Lint the content
175+
result := l.LintString(string(content), "stdin")
176+
177+
// Display results
178+
fmt.Fprintf(cmd.OutOrStdout(), "Linting stdin input:\n\n")
179+
180+
if len(result.Violations) == 0 {
181+
fmt.Fprintln(cmd.OutOrStdout(), "No violations found.")
182+
return nil
183+
}
184+
185+
fmt.Fprintf(cmd.OutOrStdout(), "Found %d violation(s):\n\n", len(result.Violations))
186+
for i, violation := range result.Violations {
187+
fmt.Fprintf(cmd.OutOrStdout(), "%d. %s\n", i+1, linter.FormatViolation(violation))
188+
}
189+
190+
// Apply auto-fix if requested
191+
if lintAutoFix {
192+
fmt.Fprintln(cmd.OutOrStdout(), "\nAuto-fixed output:")
193+
fixed := string(content)
194+
for _, rule := range l.Rules() {
195+
if rule.CanAutoFix() {
196+
fixedContent, err := rule.Fix(fixed, result.Violations)
197+
if err == nil && fixedContent != fixed {
198+
fixed = fixedContent
199+
}
200+
}
201+
}
202+
fmt.Fprintln(cmd.OutOrStdout(), fixed)
203+
}
204+
205+
// Exit with error code if there were violations
206+
errorCount := 0
207+
warningCount := 0
208+
for _, violation := range result.Violations {
209+
if violation.Severity == linter.SeverityError {
210+
errorCount++
211+
} else if violation.Severity == linter.SeverityWarning {
212+
warningCount++
213+
}
214+
}
215+
216+
if errorCount > 0 || (lintFailOnWarn && warningCount > 0) {
217+
os.Exit(1)
218+
}
219+
220+
return nil
221+
}
222+
223+
// createLinter creates a new linter instance with configured rules
224+
func createLinter() *linter.Linter {
225+
return linter.New(
226+
whitespace.NewTrailingWhitespaceRule(),
227+
whitespace.NewMixedIndentationRule(),
228+
whitespace.NewLongLinesRule(lintMaxLength),
229+
)
230+
}
231+
232+
func init() {
233+
rootCmd.AddCommand(lintCmd)
234+
235+
lintCmd.Flags().BoolVarP(&lintRecursive, "recursive", "r", false, "recursively process directories")
236+
lintCmd.Flags().StringVarP(&lintPattern, "pattern", "p", "*.sql", "file pattern for recursive processing")
237+
lintCmd.Flags().BoolVar(&lintAutoFix, "auto-fix", false, "automatically fix violations where possible")
238+
lintCmd.Flags().IntVar(&lintMaxLength, "max-length", 100, "maximum line length (L005 rule)")
239+
lintCmd.Flags().BoolVar(&lintFailOnWarn, "fail-on-warn", false, "exit with error code on warnings")
240+
}

examples/linter-example/main.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/ajitpratap0/GoSQLX/pkg/linter"
7+
"github.com/ajitpratap0/GoSQLX/pkg/linter/rules/whitespace"
8+
)
9+
10+
func main() {
11+
// Example SQL with various linting issues
12+
sql := `SELECT id, name, email
13+
FROM users
14+
WHERE active = true
15+
AND created_at > '2024-01-01'
16+
ORDER BY name
17+
18+
`
19+
20+
fmt.Println("SQL Linting Example")
21+
fmt.Println("===================")
22+
fmt.Println("Input SQL:")
23+
fmt.Println(sql)
24+
fmt.Println("\n" + string(make([]byte, 80)) + "\n")
25+
26+
// Create linter with rules
27+
l := linter.New(
28+
whitespace.NewTrailingWhitespaceRule(),
29+
whitespace.NewMixedIndentationRule(),
30+
whitespace.NewLongLinesRule(80),
31+
)
32+
33+
// Lint the SQL
34+
result := l.LintString(sql, "example.sql")
35+
36+
// Display results
37+
fmt.Printf("Found %d violation(s):\n\n", len(result.Violations))
38+
39+
for i, violation := range result.Violations {
40+
fmt.Printf("%d. %s\n", i+1, linter.FormatViolation(violation))
41+
}
42+
43+
// Test auto-fix
44+
if len(result.Violations) > 0 {
45+
fmt.Println("\nAttempting auto-fix...")
46+
47+
for _, rule := range l.Rules() {
48+
if rule.CanAutoFix() {
49+
fixed, err := rule.Fix(sql, result.Violations)
50+
if err == nil && fixed != sql {
51+
fmt.Printf("\nFixed by %s (%s):\n", rule.Name(), rule.ID())
52+
fmt.Println(fixed)
53+
sql = fixed
54+
}
55+
}
56+
}
57+
}
58+
}

pkg/linter/context.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package linter
2+
3+
import (
4+
"strings"
5+
6+
"github.com/ajitpratap0/GoSQLX/pkg/models"
7+
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
8+
)
9+
10+
// Context provides all information needed for linting
11+
type Context struct {
12+
// Source SQL content
13+
SQL string
14+
15+
// SQL split into lines for convenience
16+
Lines []string
17+
18+
// Tokenization results (if available)
19+
Tokens []models.TokenWithSpan
20+
21+
// Parsing results (if available)
22+
AST *ast.AST
23+
ParseErr error
24+
25+
// File metadata
26+
Filename string
27+
}
28+
29+
// NewContext creates a new linting context
30+
func NewContext(sql string, filename string) *Context {
31+
lines := strings.Split(sql, "\n")
32+
33+
return &Context{
34+
SQL: sql,
35+
Lines: lines,
36+
Filename: filename,
37+
}
38+
}
39+
40+
// WithTokens adds tokenization results to the context
41+
func (c *Context) WithTokens(tokens []models.TokenWithSpan) *Context {
42+
c.Tokens = tokens
43+
return c
44+
}
45+
46+
// WithAST adds parsing results to the context
47+
func (c *Context) WithAST(astObj *ast.AST, err error) *Context {
48+
c.AST = astObj
49+
c.ParseErr = err
50+
return c
51+
}
52+
53+
// GetLine returns a specific line (1-indexed)
54+
// Returns empty string if line number is out of bounds
55+
func (c *Context) GetLine(lineNum int) string {
56+
if lineNum < 1 || lineNum > len(c.Lines) {
57+
return ""
58+
}
59+
return c.Lines[lineNum-1]
60+
}
61+
62+
// GetLineCount returns the total number of lines
63+
func (c *Context) GetLineCount() int {
64+
return len(c.Lines)
65+
}

0 commit comments

Comments
 (0)