// 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" "github.com/hein/pgtidy/pkg/diagnostics" "github.com/hein/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 }