e88d32f281
- pkg/lsp: JSON-RPC 2.0 LSP server (formatting, diagnostics, codeAction quick-fixes) - cmd/pgtidy: lsp and config subcommands - pkg/diagnostics: TextFix struct for byte-range autofixes - pkg/lint: MIG001/MIG003 autofixes, ApplyFixes helper, --fix flag on lint command - editors/vscode: TypeScript extension with LanguageClient, showVersion/showConfig/formatDocument commands, logo - editors/datagrip: Gradle JetBrains plugin via LSP4IJ, pluginIcon - .goreleaser.yaml, .github/workflows: CI + release pipeline - Makefile: snapshot, release, vscode-compile, vscode-package targets - go.mod + all imports: module path updated to git.warky.dev/wdevs/pgtidy - assets: logo files (256px, 128px, 1024px, ico)
120 lines
3.1 KiB
Go
120 lines
3.1 KiB
Go
// Package lint provides the rule engine and built-in rule packs for PgTidy.
|
|
//
|
|
// Usage:
|
|
//
|
|
// eng := lint.New()
|
|
// diags, err := eng.Check(sql, filename)
|
|
package lint
|
|
|
|
import (
|
|
"sort"
|
|
|
|
pg_query "github.com/pganalyze/pg_query_go/v6"
|
|
|
|
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
|
|
"git.warky.dev/wdevs/pgtidy/pkg/pgast"
|
|
)
|
|
|
|
// Rule is implemented by each lint rule.
|
|
type Rule interface {
|
|
// ID returns the stable rule identifier (e.g. "MIG001").
|
|
ID() string
|
|
// Severity returns the default severity for findings from this rule.
|
|
Severity() diagnostics.Severity
|
|
// Check inspects the parsed statement list and returns any findings.
|
|
// src is the original SQL string, used for location lookups.
|
|
Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic
|
|
}
|
|
|
|
// Engine runs a set of rules against SQL input.
|
|
type Engine struct {
|
|
rules []Rule
|
|
}
|
|
|
|
// New returns an Engine loaded with all built-in rules.
|
|
func New() *Engine {
|
|
e := &Engine{}
|
|
e.registerAll()
|
|
return e
|
|
}
|
|
|
|
// Custom returns an empty Engine; use Register to add rules.
|
|
func Custom() *Engine {
|
|
return &Engine{}
|
|
}
|
|
|
|
func (e *Engine) registerAll() {
|
|
e.Register(ruleMIG001{})
|
|
e.Register(ruleMIG002{})
|
|
e.Register(ruleMIG003{})
|
|
e.Register(ruleCOR001{})
|
|
e.Register(ruleCOR002{})
|
|
e.Register(ruleCOR003{})
|
|
e.Register(ruleNAM001{})
|
|
e.Register(ruleNAM002{})
|
|
e.Register(ruleNAM003{})
|
|
}
|
|
|
|
// Register adds a rule to the engine.
|
|
func (e *Engine) Register(r Rule) {
|
|
e.rules = append(e.rules, r)
|
|
}
|
|
|
|
// Rules returns a copy of the registered rule slice (for testing/inspection).
|
|
func (e *Engine) Rules() []Rule {
|
|
out := make([]Rule, len(e.rules))
|
|
copy(out, e.rules)
|
|
return out
|
|
}
|
|
|
|
// Check parses sql and runs all registered rules. Diagnostics are sorted by
|
|
// (line, col, ruleID). A parse error is returned as a single diagnostic with
|
|
// ruleID "PARSE".
|
|
func (e *Engine) Check(sql, file string) ([]diagnostics.Diagnostic, error) {
|
|
result, err := pgast.Parse(sql)
|
|
if err != nil {
|
|
line, col := parseErrLocation(err, sql)
|
|
return []diagnostics.Diagnostic{{
|
|
RuleID: "PARSE",
|
|
Severity: diagnostics.SeverityError,
|
|
Message: err.Error(),
|
|
File: file,
|
|
Line: line,
|
|
Col: col,
|
|
}}, nil
|
|
}
|
|
|
|
var all []diagnostics.Diagnostic
|
|
for _, r := range e.rules {
|
|
found := r.Check(result.Stmts, sql)
|
|
for i := range found {
|
|
found[i].File = file
|
|
}
|
|
all = append(all, found...)
|
|
}
|
|
|
|
sort.Slice(all, func(i, j int) bool {
|
|
if all[i].Line != all[j].Line {
|
|
return all[i].Line < all[j].Line
|
|
}
|
|
if all[i].Col != all[j].Col {
|
|
return all[i].Col < all[j].Col
|
|
}
|
|
return all[i].RuleID < all[j].RuleID
|
|
})
|
|
return all, nil
|
|
}
|
|
|
|
// parseErrLocation extracts a (line, col) from a pg_query parse error.
|
|
// The error message format is "syntax error at or near ... (location N)" where
|
|
// N is a 1-based character offset.
|
|
func parseErrLocation(err error, sql string) (line, col int) {
|
|
// pg_query errors expose Location via a type assertion.
|
|
type locErr interface{ GetCursorPos() int }
|
|
if le, ok := err.(locErr); ok {
|
|
pos := le.GetCursorPos() - 1 // convert to 0-based
|
|
return pgast.LocationToLineCol(sql, pos)
|
|
}
|
|
return 1, 1
|
|
}
|