package lint import ( "fmt" "regexp" "strings" pg_query "github.com/pganalyze/pg_query_go/v6" "git.warky.dev/wdevs/pgtidy/pkg/diagnostics" "git.warky.dev/wdevs/pgtidy/pkg/pgast" ) // reSnakeCase matches valid snake_case identifiers: lowercase letters, digits, // and underscores, starting with a letter or underscore. var reSnakeCase = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`) func isSnakeCase(s string) bool { return reSnakeCase.MatchString(s) } // NAM001 — table names must be snake_case. type ruleNAM001 struct{} func (ruleNAM001) ID() string { return "NAM001" } func (ruleNAM001) Severity() diagnostics.Severity { return diagnostics.SeverityHint } func (ruleNAM001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic { var out []diagnostics.Diagnostic for _, raw := range stmts { cs, ok := raw.Stmt.GetNode().(*pg_query.Node_CreateStmt) if !ok { continue } name := cs.CreateStmt.Relation.Relname if name == "" || isSnakeCase(name) { continue } line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation))) out = append(out, diagnostics.Diagnostic{ RuleID: "NAM001", Severity: diagnostics.SeverityHint, Message: fmt.Sprintf("table name %q is not snake_case (expected %q)", name, toSnakeCase(name)), Line: line, Col: col, }) } return out } // NAM002 — column names in CREATE TABLE must be snake_case. type ruleNAM002 struct{} func (ruleNAM002) ID() string { return "NAM002" } func (ruleNAM002) Severity() diagnostics.Severity { return diagnostics.SeverityHint } func (ruleNAM002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic { var out []diagnostics.Diagnostic for _, raw := range stmts { cs, ok := raw.Stmt.GetNode().(*pg_query.Node_CreateStmt) if !ok { continue } for _, elt := range cs.CreateStmt.TableElts { cd, ok := elt.GetNode().(*pg_query.Node_ColumnDef) if !ok { continue } name := cd.ColumnDef.Colname if name == "" || isSnakeCase(name) { continue } line, col := pgast.LocationToLineCol(src, int(cd.ColumnDef.Location)) out = append(out, diagnostics.Diagnostic{ RuleID: "NAM002", Severity: diagnostics.SeverityHint, Message: fmt.Sprintf("column name %q is not snake_case (expected %q)", name, toSnakeCase(name)), Line: line, Col: col, }) } } return out } // NAM003 — function/procedure names must be snake_case. type ruleNAM003 struct{} func (ruleNAM003) ID() string { return "NAM003" } func (ruleNAM003) Severity() diagnostics.Severity { return diagnostics.SeverityHint } func (ruleNAM003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic { var out []diagnostics.Diagnostic for _, raw := range stmts { cf, ok := raw.Stmt.GetNode().(*pg_query.Node_CreateFunctionStmt) if !ok { continue } // Funcname is a list of String nodes: [schema, funcname] or [funcname]. name := lastStringNode(cf.CreateFunctionStmt.Funcname) if name == "" || isSnakeCase(name) { continue } line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation))) out = append(out, diagnostics.Diagnostic{ RuleID: "NAM003", Severity: diagnostics.SeverityHint, Message: fmt.Sprintf("function name %q is not snake_case (expected %q)", name, toSnakeCase(name)), Line: line, Col: col, }) } return out } // lastStringNode returns the string value of the last node in a name-list // (schema-qualified names like [schema, name] → name). func lastStringNode(nodes []*pg_query.Node) string { for i := len(nodes) - 1; i >= 0; i-- { sv, ok := nodes[i].GetNode().(*pg_query.Node_String_) if ok { return sv.String_.Sval } } return "" } // toSnakeCase converts a CamelCase or mixed-case identifier to snake_case as a // suggested fix shown in the diagnostic message. var reWordBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`) func toSnakeCase(s string) string { s = reWordBoundary.ReplaceAllString(s, "${1}_${2}") return strings.ToLower(s) }