Files
PgTidy/pkg/lint/rules_naming.go
warkanum d1150a7eea
CI / Test (push) Successful in 50s
CI / Build snapshot (push) Failing after 1m12s
feat(lsp): add textDocument/rangeFormatting support
Advertise DocumentRangeFormattingProvider in server capabilities and
implement rangeFormat: formats the complete document, then returns an
edit covering only the minimal changed-line region that overlaps the
client's selection. Also includes gofmt alignment fixes across lint
and format packages.
2026-06-28 16:28:27 +02:00

136 lines
4.0 KiB
Go

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)
}