195 lines
4.6 KiB
Go
195 lines
4.6 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
|
|
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
|
|
"git.warky.dev/wdevs/pgtidy/pkg/lint"
|
|
)
|
|
|
|
// cmdLint implements `pgtidy lint`. Reads one or more SQL files (or stdin) and
|
|
// reports lint findings to stdout. Exits non-zero when any findings are found.
|
|
//
|
|
// Output format (one line per finding):
|
|
//
|
|
// file:line:col: [RULEID] severity: message
|
|
func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|
var (
|
|
only []string // --only=ID,ID rule filter
|
|
fix bool
|
|
jsonOutput bool
|
|
files []string
|
|
)
|
|
for _, a := range args {
|
|
switch {
|
|
case a == "-h" || a == "--help":
|
|
lintUsage(stdout)
|
|
return 0
|
|
case a == "--fix":
|
|
fix = true
|
|
case a == "--json":
|
|
jsonOutput = true
|
|
case strings.HasPrefix(a, "--only="):
|
|
ids := strings.Split(strings.TrimPrefix(a, "--only="), ",")
|
|
for _, id := range ids {
|
|
if id = strings.TrimSpace(id); id != "" {
|
|
only = append(only, strings.ToUpper(id))
|
|
}
|
|
}
|
|
case len(a) > 1 && a[0] == '-':
|
|
_, _ = fmt.Fprintf(stderr, "pgtidy lint: unknown flag %q\n", a)
|
|
return 2
|
|
default:
|
|
files = append(files, a)
|
|
}
|
|
}
|
|
|
|
eng := lint.New()
|
|
|
|
check := func(sql, file string) ([]diagnostics.Diagnostic, error) {
|
|
diags, err := eng.Check(sql, file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(only) > 0 {
|
|
filtered := diags[:0]
|
|
for _, d := range diags {
|
|
for _, id := range only {
|
|
if d.RuleID == id {
|
|
filtered = append(filtered, d)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
diags = filtered
|
|
}
|
|
return diags, nil
|
|
}
|
|
|
|
var jsonDiags []diagnostics.Diagnostic
|
|
|
|
reportDiags := func(diags []diagnostics.Diagnostic) {
|
|
if jsonOutput {
|
|
jsonDiags = append(jsonDiags, diags...)
|
|
return
|
|
}
|
|
for _, d := range diags {
|
|
loc := d.File
|
|
if d.Line > 0 {
|
|
loc = fmt.Sprintf("%s:%d:%d", d.File, d.Line, d.Col)
|
|
}
|
|
if loc == "" {
|
|
loc = "stdin"
|
|
}
|
|
_, _ = fmt.Fprintf(stdout, "%s: [%s] %s: %s\n", loc, d.RuleID, d.Severity, d.Message)
|
|
}
|
|
}
|
|
|
|
found := false
|
|
|
|
if len(files) == 0 {
|
|
src, err := io.ReadAll(stdin)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(stderr, "pgtidy: reading stdin: %v\n", err)
|
|
return 2
|
|
}
|
|
diags, err := check(string(src), "")
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
|
|
return 2
|
|
}
|
|
if fix && !jsonOutput {
|
|
fixed := lint.ApplyFixes(string(src), diags)
|
|
if fixed != string(src) {
|
|
_, _ = io.WriteString(stdout, fixed)
|
|
return 0
|
|
}
|
|
}
|
|
reportDiags(diags)
|
|
if len(diags) > 0 {
|
|
found = true
|
|
}
|
|
} else {
|
|
for _, path := range files {
|
|
src, err := os.ReadFile(path)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
|
|
return 2
|
|
}
|
|
diags, err := check(string(src), path)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
|
|
return 2
|
|
}
|
|
if fix && !jsonOutput {
|
|
fixed := lint.ApplyFixes(string(src), diags)
|
|
if fixed != string(src) {
|
|
if err := os.WriteFile(path, []byte(fixed), 0o644); err != nil {
|
|
_, _ = fmt.Fprintf(stderr, "pgtidy: writing %s: %v\n", path, err)
|
|
return 2
|
|
}
|
|
// Re-check to report any remaining unfixed diagnostics.
|
|
diags, err = check(fixed, path)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
|
|
return 2
|
|
}
|
|
}
|
|
}
|
|
reportDiags(diags)
|
|
if len(diags) > 0 {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if jsonOutput {
|
|
if jsonDiags == nil {
|
|
jsonDiags = []diagnostics.Diagnostic{}
|
|
}
|
|
enc := json.NewEncoder(stdout)
|
|
if err := enc.Encode(jsonDiags); err != nil {
|
|
_, _ = fmt.Fprintf(stderr, "pgtidy: encoding json: %v\n", err)
|
|
return 2
|
|
}
|
|
}
|
|
|
|
if found {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func lintUsage(w io.Writer) {
|
|
_, _ = fmt.Fprint(w, `Usage: pgtidy lint [flags] [file ...]
|
|
|
|
Read SQL from files (or stdin) and report lint findings.
|
|
|
|
Flags:
|
|
--fix Apply autofixes for fixable rules (MIG001, MIG003) and rewrite files
|
|
--json Emit findings as a JSON array instead of text (ignores --fix)
|
|
--only=ID,... comma-separated rule IDs to enable (default: all rules)
|
|
-h, --help show this help
|
|
|
|
Rules:
|
|
MIG001 CREATE INDEX without CONCURRENTLY blocks writes
|
|
MIG002 ALTER TABLE ADD COLUMN NOT NULL with no DEFAULT may rewrite table
|
|
MIG003 ALTER TABLE ADD CONSTRAINT without NOT VALID validates all rows
|
|
COR001 SELECT * is fragile; enumerate columns explicitly
|
|
COR002 UPDATE without WHERE modifies every row
|
|
COR003 DELETE without WHERE removes every row
|
|
NAM001 Table name is not snake_case
|
|
NAM002 Column name is not snake_case
|
|
NAM003 Function/procedure name is not snake_case
|
|
|
|
Exit codes:
|
|
0 no findings
|
|
1 one or more findings
|
|
2 error (file not found, parse error, bad flag)
|
|
`)
|
|
}
|