feat: initial plan

This commit is contained in:
Hein
2026-06-23 16:59:50 +02:00
parent 90fe1a448b
commit 625ddc79a1
23 changed files with 3390 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
// Package config defines PgTidy's formatter (and, later, linter) configuration.
//
// Defaults encode the project house style reverse-engineered from the corpus.
// A future change will load/merge these from a discovered .pgtidy.yaml file.
package config
// Case controls keyword/identifier casing.
type Case string
const (
CaseUpper Case = "upper"
CaseLower Case = "lower"
// CasePreserve leaves the token text unchanged.
CasePreserve Case = "preserve"
)
// CommaStyle controls where separators sit in multi-line lists.
type CommaStyle string
const (
// CommaLeading puts the comma at the start of the continuation line
// (",col"), the house style.
CommaLeading CommaStyle = "leading"
// CommaTrailing puts the comma at the end of the preceding line ("col,").
CommaTrailing CommaStyle = "trailing"
)
// Style is the formatter configuration.
type Style struct {
// Indent is one indentation level (default two spaces).
Indent string
// Newline is the line terminator emitted by the formatter.
Newline string
// KeywordCase controls SQL keyword casing (types excluded — see TypeCase).
KeywordCase Case
// IdentCase controls unquoted identifier casing (quoted identifiers are
// never touched).
IdentCase Case
// TypeCase controls built-in type-name casing.
TypeCase Case
// Commas controls list separator placement.
Commas CommaStyle
}
// Default returns the house-style configuration.
func Default() Style {
return Style{
Indent: " ",
Newline: "\n",
KeywordCase: CaseUpper,
IdentCase: CaseLower,
TypeCase: CaseLower,
Commas: CommaLeading,
}
}
+173
View File
@@ -0,0 +1,173 @@
// Package cst defines PgTidy's concrete syntax tree.
//
// The CST is lossless: every lexer token (significant tokens and the trivia —
// whitespace/comments — attached to them) is reachable, so File.Source()
// reproduces the original input byte-for-byte. The parser builds structured
// nodes only where it is confident; everything else is captured verbatim in a
// Raw node. This makes "graceful degradation" a property of the data model
// rather than something the printer must remember to do.
package cst
import (
"strings"
"github.com/hein/pgtidy/pkg/lexer"
)
// Trivia is a run of whitespace/comment tokens preceding a significant token.
type Trivia []lexer.Token
// Tok is a significant (non-trivia) token together with the trivia that
// immediately precedes it in the source.
type Tok struct {
Lead Trivia
Tok lexer.Token
}
// Text returns the significant token's text.
func (t Tok) Text() string { return t.Tok.Text }
// Is reports whether the token is an unquoted word equal (case-insensitively)
// to kw.
func (t Tok) Is(kw string) bool {
return t.Tok.Kind == lexer.Ident && strings.EqualFold(t.Tok.Text, kw)
}
// Comments returns just the comment tokens from the leading trivia.
func (t Tok) Comments() []lexer.Token {
var out []lexer.Token
for _, tr := range t.Lead {
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
out = append(out, tr)
}
}
return out
}
// Attach converts a lossless lexer stream into significant tokens, each
// carrying its leading trivia, plus any trailing trivia before EOF.
func Attach(toks []lexer.Token) (sig []Tok, trailing Trivia) {
var lead Trivia
for _, t := range toks {
if t.Kind == lexer.EOF {
trailing = lead
break
}
if t.IsTrivia() {
lead = append(lead, t)
continue
}
sig = append(sig, Tok{Lead: lead, Tok: t})
lead = nil
}
return sig, trailing
}
// Node is any element of a File.
type Node interface {
appendTokens(*[]Tok)
}
// Tokens returns all significant tokens of a node in source order.
func Tokens(n Node) []Tok {
var t []Tok
n.appendTokens(&t)
return t
}
// File is the whole parsed input.
type File struct {
Items []Node
Trailing Trivia // trivia after the last significant token
}
// Source reconstructs the original input from the tree. With a correct parse
// this is byte-for-byte identical to the lexer input.
func (f *File) Source() string {
var toks []Tok
for _, it := range f.Items {
it.appendTokens(&toks)
}
var b strings.Builder
for _, t := range toks {
for _, tr := range t.Lead {
b.WriteString(tr.Text)
}
b.WriteString(t.Tok.Text)
}
for _, tr := range f.Trailing {
b.WriteString(tr.Text)
}
return b.String()
}
// Raw is an unstructured statement: a verbatim run of significant tokens. This
// is the graceful-degradation fallback for anything the parser does not (yet)
// structure.
type Raw struct {
Toks []Tok
}
func (r *Raw) appendTokens(out *[]Tok) { *out = append(*out, r.Toks...) }
// Param is one entry in a function/procedure parameter list, with the comma
// that separates it from the next entry (nil on the last parameter).
type Param struct {
Toks []Tok
Sep *Tok // trailing comma, nil if last
}
// CreateFunction is a parsed CREATE [OR REPLACE] FUNCTION|PROCEDURE statement.
//
// Field order matches source order; appendTokens emits them contiguously so the
// node round-trips exactly.
type CreateFunction struct {
Head []Tok // CREATE [OR REPLACE] FUNCTION|PROCEDURE
Name []Tok // name (possibly schema-qualified)
LParen Tok //
Params []Param // parameter list (may be empty)
RParen Tok //
Options [][]Tok // option clauses before the body (RETURNS/LANGUAGE/VOLATILE/...)
As *Tok // the AS keyword, if present
Body *Tok // the body token (dollar-quoted string or string), if present
Tail [][]Tok // option clauses after the body (rare)
Semi *Tok // terminating semicolon, if present
}
// IsProcedure reports whether this is a PROCEDURE (vs FUNCTION).
func (c *CreateFunction) IsProcedure() bool {
for _, t := range c.Head {
if t.Is("procedure") {
return true
}
}
return false
}
func (c *CreateFunction) appendTokens(out *[]Tok) {
*out = append(*out, c.Head...)
*out = append(*out, c.Name...)
*out = append(*out, c.LParen)
for _, p := range c.Params {
*out = append(*out, p.Toks...)
if p.Sep != nil {
*out = append(*out, *p.Sep)
}
}
*out = append(*out, c.RParen)
for _, cl := range c.Options {
*out = append(*out, cl...)
}
if c.As != nil {
*out = append(*out, *c.As)
}
if c.Body != nil {
*out = append(*out, *c.Body)
}
for _, cl := range c.Tail {
*out = append(*out, cl...)
}
if c.Semi != nil {
*out = append(*out, *c.Semi)
}
}
+310
View File
@@ -0,0 +1,310 @@
// Package format renders a cst.File back to source text in the configured
// house style.
//
// Scope (V1): CREATE FUNCTION/PROCEDURE headers are laid out to house style
// (one parameter per line with leading commas, each option clause on its own
// line, AS/$$ on their own lines). The PL/pgSQL body and any statement the
// parser left as cst.Raw are emitted verbatim — this upholds the safety
// invariants while body formatting is built out (task #4).
//
// Guarantees: formatting changes only trivia/layout (never literal/identifier
// semantics), and is idempotent.
package format
import (
"strings"
"github.com/hein/pgtidy/pkg/config"
"github.com/hein/pgtidy/pkg/cst"
"github.com/hein/pgtidy/pkg/lexer"
)
// File formats a parsed file with the given style.
func File(f *cst.File, st config.Style) string {
p := &printer{st: st}
for i, item := range f.Items {
toks := cst.Tokens(item)
if len(toks) == 0 {
continue
}
lead := toks[0].Lead
if i > 0 {
p.nl()
if hasBlankLine(lead) {
p.nl()
}
}
p.leadingComments(lead)
p.writeItem(item)
}
p.trailingComments(f.Trailing)
return ensureTrailingNewline(p.b.String(), st.Newline)
}
type printer struct {
st config.Style
b strings.Builder
}
func (p *printer) nl() { p.b.WriteString(p.st.Newline) }
func (p *printer) writeItem(n cst.Node) {
switch v := n.(type) {
case *cst.CreateFunction:
p.writeCreateFunction(v)
default:
p.b.WriteString(verbatimSpan(cst.Tokens(n)))
}
}
func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
// Safety: if the header carries comments we cannot confidently relocate,
// emit the whole statement verbatim rather than risk dropping them.
if headerHasComments(cf) {
p.b.WriteString(verbatimSpan(cst.Tokens(cf)))
return
}
head := p.inline(cf.Head)
name := p.inline(cf.Name)
p.b.WriteString(head)
if name != "" {
p.b.WriteString(" ")
p.b.WriteString(name)
}
p.b.WriteString("(")
first := p.st.Indent + " " // align item text one column past the comma
cont := p.st.Indent
for i, param := range cf.Params {
p.nl()
text := p.inline(param.Toks)
if i == 0 || p.st.Commas != config.CommaLeading {
p.b.WriteString(first)
p.b.WriteString(text)
if p.st.Commas == config.CommaTrailing && i < len(cf.Params)-1 {
p.b.WriteString(",")
}
} else {
p.b.WriteString(cont)
p.b.WriteString(",")
p.b.WriteString(text)
}
}
p.nl()
p.b.WriteString(")")
for _, clause := range cf.Options {
p.nl()
p.b.WriteString(p.inline(clause))
}
if cf.As != nil {
p.nl()
p.b.WriteString(p.inline([]cst.Tok{{Tok: cf.As.Tok}}))
}
if cf.Body != nil {
p.nl()
p.b.WriteString(cf.Body.Tok.Text) // body emitted verbatim (formatted later)
}
for _, clause := range cf.Tail {
p.nl()
p.b.WriteString(p.inline(clause))
}
if cf.Semi != nil {
p.b.WriteString(";")
}
}
// inline renders a run of tokens on one line, applying spacing and casing.
// If the run contains comment trivia it is emitted verbatim to avoid losing
// or misplacing the comments.
func (p *printer) inline(toks []cst.Tok) string {
if len(toks) == 0 {
return ""
}
// A comment on the first token's lead is the span's leading/separation
// comment, handled by the caller and never emitted here, so it does not
// force verbatim. Internal comments (on later tokens) do.
if anyComment(toks[1:]) {
return verbatimSpan(toks)
}
var b strings.Builder
for i, t := range toks {
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) {
b.WriteByte(' ')
}
b.WriteString(caseText(t.Tok, p.st))
}
return b.String()
}
func (p *printer) leadingComments(lead cst.Trivia) {
for _, tr := range lead {
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
p.b.WriteString(strings.TrimRight(tr.Text, " \t"))
p.nl()
}
}
}
func (p *printer) trailingComments(lead cst.Trivia) {
cs := commentsOf(lead)
for _, c := range cs {
p.nl()
p.b.WriteString(strings.TrimRight(c.Text, " \t"))
}
}
// --- spacing & casing ---
// tightOps are operators printed without surrounding spaces.
var tightOps = map[string]bool{"::": true, ":": true, "->": true, "->>": true}
func needSpace(a, b lexer.Token) bool {
// No space after.
switch a.Kind {
case lexer.LParen, lexer.LBracket, lexer.Dot:
return false
case lexer.Operator:
if tightOps[a.Text] {
return false
}
}
// No space before.
switch b.Kind {
case lexer.RParen, lexer.RBracket, lexer.Comma, lexer.Semicolon, lexer.Dot:
return false
case lexer.LParen:
switch a.Kind {
case lexer.Ident, lexer.QuotedIdent, lexer.RParen, lexer.RBracket, lexer.Param:
return false // function call / type modifier
}
case lexer.Operator:
if tightOps[b.Text] {
return false
}
}
return true
}
func caseText(t lexer.Token, st config.Style) string {
if t.Kind != lexer.Ident {
return t.Text // only unquoted words are re-cased
}
low := lowerASCII(t.Text)
switch {
case isTypeName(low):
return applyCase(t.Text, st.TypeCase)
case isKeyword(low):
return applyCase(t.Text, st.KeywordCase)
default:
return applyCase(t.Text, st.IdentCase)
}
}
func applyCase(s string, c config.Case) string {
switch c {
case config.CaseUpper:
return strings.ToUpper(s)
case config.CaseLower:
return strings.ToLower(s)
default:
return s
}
}
// --- helpers ---
// headerHasComments reports whether the function header carries comment trivia
// the formatter cannot confidently relocate. The first token's leading trivia
// is excluded: that is the statement's leading comment, which File() emits
// separately. The body token's own text is excluded too (it is emitted
// verbatim), but a comment in front of the body is caught.
func headerHasComments(cf *cst.CreateFunction) bool {
all := cst.Tokens(cf)
for i, t := range all {
if i == 0 {
continue
}
if t.Tok.Kind == lexer.Semicolon {
continue
}
if hasComment(t) {
return true
}
}
return false
}
func anyComment(toks []cst.Tok) bool {
for _, t := range toks {
if hasComment(t) {
return true
}
}
return false
}
func hasComment(t cst.Tok) bool {
for _, tr := range t.Lead {
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
return true
}
}
return false
}
func commentsOf(lead cst.Trivia) []lexer.Token {
var out []lexer.Token
for _, tr := range lead {
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
out = append(out, tr)
}
}
return out
}
// verbatimSpan emits tokens exactly as in source, excluding the leading trivia
// of the first token (separation is controlled by the caller).
func verbatimSpan(toks []cst.Tok) string {
var b strings.Builder
for i, t := range toks {
if i > 0 {
for _, tr := range t.Lead {
b.WriteString(tr.Text)
}
}
b.WriteString(t.Tok.Text)
}
return b.String()
}
// hasBlankLine reports whether leading whitespace trivia contains a blank line
// (two or more newlines), indicating the author wanted statements separated.
func hasBlankLine(lead cst.Trivia) bool {
n := 0
for _, tr := range lead {
if tr.Kind == lexer.Whitespace {
n += strings.Count(tr.Text, "\n")
}
}
return n >= 2
}
func ensureTrailingNewline(s, nl string) string {
s = strings.TrimRight(s, "\n")
if s == "" {
return s
}
return s + nl
}
func lowerASCII(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + 32
}
}
return string(b)
}
+118
View File
@@ -0,0 +1,118 @@
package format
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/hein/pgtidy/pkg/config"
"github.com/hein/pgtidy/pkg/lexer"
"github.com/hein/pgtidy/pkg/parser"
)
func format(src string) string {
return File(parser.Parse(src), config.Default())
}
func TestFormatHeaderGolden(t *testing.T) {
src := "--select * from dropall('resolvespec_login');\n" +
"create or replace function resolvespec_login(\n" +
"INOUT p_data jsonb, OUT p_success boolean, OUT p_error text)\n" +
"language plpgsql volatile security definer\n" +
"as $$\nbegin end;\n$$;\n"
want := "--select * from dropall('resolvespec_login');\n" +
"CREATE OR REPLACE FUNCTION resolvespec_login(\n" +
" INOUT p_data jsonb\n" +
" ,OUT p_success boolean\n" +
" ,OUT p_error text\n" +
")\n" +
"LANGUAGE plpgsql\n" +
"VOLATILE\n" +
"SECURITY DEFINER\n" +
"AS\n" +
"$$\nbegin end;\n$$;\n"
got := format(src)
if got != want {
t.Errorf("header format mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
func TestIdempotentSmall(t *testing.T) {
src := "create function f(a int,b text) returns void language sql as $$ select 1 $$;"
once := format(src)
twice := format(once)
if once != twice {
t.Errorf("not idempotent\n--- once ---\n%s\n--- twice ---\n%s", once, twice)
}
}
func TestCorpusIdempotentAndSafe(t *testing.T) {
dir := filepath.Join("..", "..", "testdata", "corpus")
entries, err := os.ReadDir(dir)
if err != nil {
t.Skipf("no corpus: %v", err)
}
var seen int
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pgsql") {
continue
}
seen++
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatal(err)
}
src := string(data)
once := format(src)
twice := format(once)
if once != twice {
t.Errorf("%s: not idempotent", e.Name())
}
if !semanticallyEqual(src, once) {
t.Errorf("%s: formatting changed semantics", e.Name())
}
}
if seen == 0 {
t.Skip("no corpus files")
}
t.Logf("formatted %d corpus files (idempotent + semantically equal)", seen)
}
// semanticallyEqual compares the non-trivia token streams of two sources,
// treating unquoted identifiers/keywords case-insensitively and everything
// else (strings, numbers, dollar bodies, operators, punctuation) exactly. This
// validates that formatting changed only layout/casing, never meaning.
func semanticallyEqual(a, b string) bool {
ta := significant(a)
tb := significant(b)
if len(ta) != len(tb) {
return false
}
for i := range ta {
if ta[i].Kind != tb[i].Kind {
return false
}
if ta[i].Kind == lexer.Ident {
if !strings.EqualFold(ta[i].Text, tb[i].Text) {
return false
}
} else if ta[i].Text != tb[i].Text {
return false
}
}
return true
}
func significant(src string) []lexer.Token {
var out []lexer.Token
for _, t := range lexer.Lex(src) {
if t.Kind == lexer.EOF || t.IsTrivia() {
continue
}
out = append(out, t)
}
return out
}
+55
View File
@@ -0,0 +1,55 @@
package format
// keywords are SQL / PL/pgSQL words the formatter may re-case via KeywordCase.
// Type-name words are deliberately excluded (see typeNames) so the house style
// can keep types lowercase while keywords are uppercased.
var keywords = words(`
add after all alter analyze and any array as asc atomic begin between by
called cascade case cast check close coalesce collate column commit
concurrently constraint create cross cube current_date current_time
current_timestamp current_user cursor declare default deferrable definer delete desc
distinct do drop each else elsif end except exception execute exists external
fetch filter first for foreach foreign from full function get grant group
grouping having if ilike immutable in index inner inout insert intersect into
into invoker is join key language last leakproof left like limit localtime
localtimestamp loop materialized natural new next no not nothing notify null
nulls of off offset old on only open or order out outer over overriding
parallel partition perform precision primary procedure raise
references refresh rename replace reset restrict return returning returns
revoke right rollback row rows safe schema secdef security select sequence set
setof some stable stacked strict table temp temporary then to transaction
trigger truncate union unique unsafe update using vacuum values variadic view
volatile when where while window with within
`)
// typeNames are built-in/common type words kept lowercase by the house style.
var typeNames = words(`
bigint bigserial bit bool boolean box bytea char character cidr circle citext
date daterange decimal double float float4 float8 hstore inet int int2 int4
int8 integer interval json jsonb line lseg macaddr macaddr8 money numeric oid
path pg_lsn point polygon real serial serial2 serial4 serial8 smallint
smallserial text time timestamp timestamptz timetz tsquery tsrange tstzrange
tsvector uuid varbit varchar xml
`)
func words(s string) map[string]bool {
m := make(map[string]bool)
w := ""
for _, r := range s {
if r == ' ' || r == '\n' || r == '\t' || r == '\r' {
if w != "" {
m[w] = true
w = ""
}
continue
}
w += string(r)
}
if w != "" {
m[w] = true
}
return m
}
func isKeyword(lower string) bool { return keywords[lower] }
func isTypeName(lower string) bool { return typeNames[lower] }
+370
View File
@@ -0,0 +1,370 @@
package lexer
import "strings"
// opChars are the characters PostgreSQL allows in operator names.
const opChars = "+-*/<>=~!@#%^&|`?"
// opSpecial is the subset whose presence lets an operator end in + or -.
const opSpecial = "~!@#%^&|`?"
// Lex tokenizes src into a lossless token stream. The concatenation of every
// returned token's Text equals src. The final token is always EOF (empty Text).
func Lex(src string) []Token {
l := &lexer{src: src, line: 1, col: 1}
return l.run()
}
type lexer struct {
src string
pos int
line int
col int
out []Token
// position of the token currently being scanned, captured each iteration
tokOff, tokLine, tokCol int
}
func (l *lexer) run() []Token {
n := len(l.src)
for l.pos < n {
l.tokOff, l.tokLine, l.tokCol = l.pos, l.line, l.col
start := l.pos
c := l.src[l.pos]
switch {
case isSpace(c):
l.scanWhile(isSpace)
l.emit(Whitespace, start)
case c == '-' && l.peek(1) == '-':
l.scanLineComment()
l.emit(LineComment, start)
case c == '/' && l.peek(1) == '*':
l.scanBlockComment()
l.emit(BlockComment, start)
case c == '\'':
l.scanString('\'', false)
l.emit(String, start)
case c == '"':
l.scanString('"', false)
l.emit(QuotedIdent, start)
case c == '$':
l.scanDollar(start)
case c == '(':
l.advance(1)
l.emit(LParen, start)
case c == ')':
l.advance(1)
l.emit(RParen, start)
case c == '[':
l.advance(1)
l.emit(LBracket, start)
case c == ']':
l.advance(1)
l.emit(RBracket, start)
case c == ',':
l.advance(1)
l.emit(Comma, start)
case c == ';':
l.advance(1)
l.emit(Semicolon, start)
case c == ':':
// :: (cast), := (assign), or bare : (slice).
if l.peek(1) == ':' || l.peek(1) == '=' {
l.advance(2)
} else {
l.advance(1)
}
l.emit(Operator, start)
case c == '.':
if isDigit(l.peek(1)) {
l.scanNumber()
l.emit(Number, start)
} else {
l.advance(1)
l.emit(Dot, start)
}
case isDigit(c):
l.scanNumber()
l.emit(Number, start)
case isIdentStart(c):
l.scanWord(start)
case strings.IndexByte(opChars, c) >= 0:
l.scanOperator()
l.emit(Operator, start)
default:
l.advance(1)
l.emit(Unknown, start)
}
}
l.out = append(l.out, Token{Kind: EOF, Off: l.pos, Line: l.line, Col: l.col})
return l.out
}
// scanWord handles identifiers and the typed-string prefixes E' B' X' U&' / U&".
func (l *lexer) scanWord(start int) {
c := l.src[l.pos]
switch c {
case 'E', 'e':
if l.peek(1) == '\'' {
l.advance(1)
l.scanString('\'', true)
l.emit(EscapeString, start)
return
}
case 'B', 'b':
if l.peek(1) == '\'' {
l.advance(1)
l.scanString('\'', false)
l.emit(BitString, start)
return
}
case 'X', 'x':
if l.peek(1) == '\'' {
l.advance(1)
l.scanString('\'', false)
l.emit(HexString, start)
return
}
case 'U', 'u':
if l.peek(1) == '&' && (l.peek(2) == '\'' || l.peek(2) == '"') {
q := l.peek(2)
l.advance(2)
l.scanString(q, false)
l.emit(UnicodeString, start)
return
}
}
l.scanWhile(isIdentCont)
l.emit(Ident, start)
}
// scanDollar handles dollar-quoted strings ($tag$...$tag$), positional
// parameters ($1), and a lone $.
func (l *lexer) scanDollar(start int) {
if tag, ok := l.dollarTag(); ok {
// Opening delimiter is "$tag$"; find the matching close.
open := "$" + tag + "$"
l.advance(len(open))
if idx := strings.Index(l.src[l.pos:], open); idx >= 0 {
l.advance(idx + len(open))
} else {
l.advance(len(l.src) - l.pos) // unterminated: consume to EOF
}
l.emit(DollarString, start)
return
}
if isDigit(l.peek(1)) {
l.advance(1)
l.scanWhile(isDigit)
l.emit(Param, start)
return
}
l.advance(1)
l.emit(Unknown, start)
}
// dollarTag checks whether the current $ begins a dollar-quote opening
// delimiter and, if so, returns the (possibly empty) tag.
func (l *lexer) dollarTag() (string, bool) {
// src[pos] == '$'
j := l.pos + 1
n := len(l.src)
if j < n && l.src[j] == '$' {
return "", true // "$$"
}
k := j
if k < n && isIdentStart(l.src[k]) {
k++
// Tag chars follow identifier rules but exclude '$' (which closes the tag).
for k < n && isIdentCont(l.src[k]) && l.src[k] != '$' {
k++
}
if k < n && l.src[k] == '$' {
return l.src[j:k], true
}
}
return "", false
}
func (l *lexer) scanLineComment() {
// Consume until newline (exclusive); the newline is whitespace.
for l.pos < len(l.src) && l.src[l.pos] != '\n' {
l.advance(1)
}
}
func (l *lexer) scanBlockComment() {
l.advance(2) // /*
depth := 1
for l.pos < len(l.src) && depth > 0 {
if l.src[l.pos] == '/' && l.peek(1) == '*' {
l.advance(2)
depth++
} else if l.src[l.pos] == '*' && l.peek(1) == '/' {
l.advance(2)
depth--
} else {
l.advance(1)
}
}
}
// scanString consumes a quoted run beginning at the current quote character.
// A doubled quote (”) is an embedded quote; with backslash=true a backslash
// escapes the next byte (escape-string syntax).
func (l *lexer) scanString(quote byte, backslash bool) {
l.advance(1) // opening quote
for l.pos < len(l.src) {
c := l.src[l.pos]
if backslash && c == '\\' && l.pos+1 < len(l.src) {
l.advance(2)
continue
}
if c == quote {
if l.peek(1) == quote {
l.advance(2) // doubled quote
continue
}
l.advance(1) // closing quote
return
}
l.advance(1)
}
}
func (l *lexer) scanNumber() {
// Non-decimal integer literals: 0x.., 0o.., 0b..
if l.src[l.pos] == '0' {
switch l.peek(1) {
case 'x', 'X', 'o', 'O', 'b', 'B':
l.advance(2)
l.scanWhile(isHexOrSep)
return
}
}
l.scanWhile(isDigitOrSep)
if l.pos < len(l.src) && l.src[l.pos] == '.' {
l.advance(1)
l.scanWhile(isDigitOrSep)
}
if c := l.cur(); c == 'e' || c == 'E' {
if p := l.peek(1); isDigit(p) || ((p == '+' || p == '-') && isDigit(l.peek(2))) {
l.advance(1)
if l.cur() == '+' || l.cur() == '-' {
l.advance(1)
}
l.scanWhile(isDigit)
}
}
}
// scanOperator consumes a run of operator characters, applying PostgreSQL's
// rule that a multi-character operator may not end in + or - unless it also
// contains one of ~ ! @ # % ^ & | ` ?.
func (l *lexer) scanOperator() {
start := l.pos
hasSpecial := false
for l.pos < len(l.src) {
c := l.src[l.pos]
if strings.IndexByte(opChars, c) < 0 {
break
}
if c == '-' && l.peek(1) == '-' {
break // comment start
}
if c == '/' && l.peek(1) == '*' {
break // comment start
}
if strings.IndexByte(opSpecial, c) >= 0 {
hasSpecial = true
}
l.advance(1)
}
if !hasSpecial {
for l.pos-1 > start {
last := l.src[l.pos-1]
if last != '+' && last != '-' {
break
}
l.backup()
}
}
}
// --- low-level helpers ---
func (l *lexer) cur() byte {
if l.pos < len(l.src) {
return l.src[l.pos]
}
return 0
}
func (l *lexer) peek(k int) byte {
if l.pos+k < len(l.src) {
return l.src[l.pos+k]
}
return 0
}
func (l *lexer) scanWhile(pred func(byte) bool) {
for l.pos < len(l.src) && pred(l.src[l.pos]) {
l.advance(1)
}
}
// advance moves forward k bytes, maintaining line/col.
func (l *lexer) advance(k int) {
for i := 0; i < k && l.pos < len(l.src); i++ {
if l.src[l.pos] == '\n' {
l.line++
l.col = 1
} else {
l.col++
}
l.pos++
}
}
// backup moves back one byte. Only used within scanOperator, which never
// spans a newline, so column bookkeeping is safe.
func (l *lexer) backup() {
l.pos--
l.col--
}
// emit appends a token covering src[start:pos], using the start position
// captured at the top of the scan loop.
func (l *lexer) emit(kind Kind, start int) {
l.out = append(l.out, Token{
Kind: kind,
Text: l.src[start:l.pos],
Off: l.tokOff,
Line: l.tokLine,
Col: l.tokCol,
})
}
func isSpace(c byte) bool {
switch c {
case ' ', '\t', '\n', '\r', '\v', '\f':
return true
}
return false
}
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
func isDigitOrSep(c byte) bool { return isDigit(c) || c == '_' }
func isHexOrSep(c byte) bool {
return isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || c == '_'
}
func isIdentStart(c byte) bool {
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c >= 0x80
}
func isIdentCont(c byte) bool {
return isIdentStart(c) || isDigit(c) || c == '$'
}
+146
View File
@@ -0,0 +1,146 @@
package lexer
import (
"os"
"path/filepath"
"strings"
"testing"
)
// emit reconstructs the source from a token stream.
func emit(toks []Token) string {
var b strings.Builder
for _, t := range toks {
b.WriteString(t.Text)
}
return b.String()
}
func TestRoundTripSmall(t *testing.T) {
cases := []string{
"",
"SELECT 1;",
"select * from t where a = b;",
"-- a comment\nSELECT 1",
"/* block /* nested */ still */ SELECT 1",
"SELECT 'it''s', E'a\\nb', $$dollar$$, $tag$x$tag$, $1;",
"a->>'b'::text",
"x := y + 1;",
"a=-b",
"SELECT 1.5, .5, 1e10, 0xFF, 1_000;",
"arr[1:2]",
"\"Quoted Ident\".col",
"U&'d\\0061t'",
}
for _, src := range cases {
got := emit(Lex(src))
if got != src {
t.Errorf("round-trip mismatch\n in: %q\nout: %q", src, got)
}
}
}
func TestKinds(t *testing.T) {
toks := nonEOF(Lex("a->>'b'::text"))
want := []Kind{Ident, Operator, String, Operator, Ident}
if len(toks) != len(want) {
t.Fatalf("got %d tokens, want %d: %v", len(toks), len(want), toks)
}
for i, k := range want {
if toks[i].Kind != k {
t.Errorf("token %d: got %s, want %s (text %q)", i, toks[i].Kind, k, toks[i].Text)
}
}
}
func TestOperatorTrailingRule(t *testing.T) {
// "=-" must split into "=" and "-" (no special char, can't end in -).
toks := nonEOF(Lex("a=-b"))
if len(toks) != 4 || toks[1].Text != "=" || toks[2].Text != "-" {
t.Fatalf("a=-b mis-lexed: %v", toks)
}
// "@-" keeps trailing - because @ is special.
toks = nonEOF(Lex("a@-b"))
if toks[1].Text != "@-" {
t.Fatalf("@- should stay one operator, got %q", toks[1].Text)
}
}
func TestDollarQuote(t *testing.T) {
toks := nonEOF(Lex("$func$ body $$ inner $func$"))
if len(toks) != 1 || toks[0].Kind != DollarString {
t.Fatalf("dollar quote not single token: %v", toks)
}
}
func TestLineColumns(t *testing.T) {
toks := nonEOF(Lex("ab\n cd"))
// ab(1,1) ws cd(2,3)
if toks[0].Line != 1 || toks[0].Col != 1 {
t.Errorf("ab at %d:%d, want 1:1", toks[0].Line, toks[0].Col)
}
cd := toks[len(toks)-1]
if cd.Text != "cd" || cd.Line != 2 || cd.Col != 3 {
t.Errorf("cd at %d:%d, want 2:3", cd.Line, cd.Col)
}
}
// TestCorpusRoundTrip is the core lossless invariant: lexing then re-emitting
// every real-world .pgsql fixture must reproduce it byte-for-byte.
func TestCorpusRoundTrip(t *testing.T) {
dir := filepath.Join("..", "..", "testdata", "corpus")
entries, err := os.ReadDir(dir)
if err != nil {
t.Skipf("no corpus dir: %v", err)
}
var seen int
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pgsql") {
continue
}
seen++
path := filepath.Join(dir, e.Name())
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
src := string(data)
if got := emit(Lex(src)); got != src {
t.Errorf("%s: round-trip mismatch (len in=%d out=%d)", e.Name(), len(src), len(got))
reportFirstDiff(t, e.Name(), src, got)
}
}
if seen == 0 {
t.Skip("corpus dir has no .pgsql files")
}
t.Logf("round-tripped %d corpus files", seen)
}
func reportFirstDiff(t *testing.T, name, a, b string) {
t.Helper()
n := len(a)
if len(b) < n {
n = len(b)
}
for i := 0; i < n; i++ {
if a[i] != b[i] {
lo := i - 20
if lo < 0 {
lo = 0
}
t.Logf("%s: first diff at byte %d\n in: %q\n out: %q", name, i, a[lo:min(i+20, len(a))], b[lo:min(i+20, len(b))])
return
}
}
}
func nonEOF(toks []Token) []Token {
var out []Token
for _, t := range toks {
if t.Kind == EOF {
continue
}
out = append(out, t)
}
return out
}
+102
View File
@@ -0,0 +1,102 @@
// Package lexer implements a lossless lexer for PostgreSQL SQL and PL/pgSQL.
//
// "Lossless" means every byte of the input is represented by exactly one token,
// including whitespace and comments. Concatenating the Text of all tokens in
// order reproduces the original source byte-for-byte:
//
// emit(Lex(src)) == src
//
// This property is the foundation of the formatter: comments and whitespace are
// first-class tokens (trivia) so the parser/printer can preserve them.
package lexer
// Kind classifies a token.
type Kind int
const (
EOF Kind = iota
// Trivia — insignificant to the grammar but preserved losslessly.
Whitespace
LineComment // -- ... (up to, not including, the newline)
BlockComment // /* ... */ (nestable)
// Words.
Ident // unquoted identifier or keyword (keyword-ness resolved later)
QuotedIdent // "..."
// Literals.
String // '...'
EscapeString // E'...'
BitString // B'...'
HexString // X'...'
UnicodeString // U&'...' or U&"..."
DollarString // $tag$...$tag$
Number // 123, 1.5, .5, 1e10, 0xff, 1_000
Param // $1, $2
// Operators (incl. ::, :=, :, ->, ->>, and op-char runs).
Operator
// Structural punctuation.
LParen
RParen
LBracket
RBracket
Comma
Semicolon
Dot
Unknown // a byte that fits no other category
)
var kindNames = map[Kind]string{
EOF: "EOF",
Whitespace: "Whitespace",
LineComment: "LineComment",
BlockComment: "BlockComment",
Ident: "Ident",
QuotedIdent: "QuotedIdent",
String: "String",
EscapeString: "EscapeString",
BitString: "BitString",
HexString: "HexString",
UnicodeString: "UnicodeString",
DollarString: "DollarString",
Number: "Number",
Param: "Param",
Operator: "Operator",
LParen: "LParen",
RParen: "RParen",
LBracket: "LBracket",
RBracket: "RBracket",
Comma: "Comma",
Semicolon: "Semicolon",
Dot: "Dot",
Unknown: "Unknown",
}
func (k Kind) String() string {
if s, ok := kindNames[k]; ok {
return s
}
return "Kind(?)"
}
// Token is a single lexical unit covering Text == src[Off:Off+len(Text)].
type Token struct {
Kind Kind
Text string
Off int // byte offset of the first byte
Line int // 1-based line of the first byte
Col int // 1-based byte column of the first byte
}
// IsTrivia reports whether the token is whitespace or a comment.
func (t Token) IsTrivia() bool {
switch t.Kind {
case Whitespace, LineComment, BlockComment:
return true
}
return false
}
+263
View File
@@ -0,0 +1,263 @@
// Package parser builds a cst.File from PostgreSQL source.
//
// The parser is intentionally partial: it splits input into statements and
// structures the constructs the formatter currently understands (today:
// CREATE FUNCTION/PROCEDURE). Anything else is preserved verbatim as a cst.Raw
// node. This guarantees the parser never loses or corrupts input — see the
// round-trip test.
package parser
import (
"github.com/hein/pgtidy/pkg/cst"
"github.com/hein/pgtidy/pkg/lexer"
)
// Parse lexes and parses src into a lossless cst.File.
func Parse(src string) *cst.File {
sig, trailing := cst.Attach(lexer.Lex(src))
f := &cst.File{Trailing: trailing}
for _, stmt := range splitStatements(sig) {
f.Items = append(f.Items, parseStatement(stmt))
}
return f
}
// splitStatements breaks the significant-token stream into statements at
// top-level (paren-depth 0) semicolons. The terminating semicolon is included
// in the statement it ends.
func splitStatements(sig []cst.Tok) [][]cst.Tok {
var stmts [][]cst.Tok
var cur []cst.Tok
depth := 0
for _, t := range sig {
cur = append(cur, t)
switch t.Tok.Kind {
case lexer.LParen:
depth++
case lexer.RParen:
if depth > 0 {
depth--
}
case lexer.Semicolon:
if depth == 0 {
stmts = append(stmts, cur)
cur = nil
}
}
}
if len(cur) > 0 {
stmts = append(stmts, cur)
}
return stmts
}
func parseStatement(stmt []cst.Tok) cst.Node {
if isCreateFunction(stmt) {
if cf, ok := parseCreateFunction(stmt); ok {
return cf
}
}
return &cst.Raw{Toks: stmt}
}
// isCreateFunction reports whether stmt begins with CREATE ... FUNCTION|PROCEDURE.
func isCreateFunction(stmt []cst.Tok) bool {
if len(stmt) == 0 || !stmt[0].Is("create") {
return false
}
for i := 1; i < len(stmt) && i < 4; i++ {
if stmt[i].Is("function") || stmt[i].Is("procedure") {
return true
}
}
return false
}
// parseCreateFunction structures a CREATE FUNCTION/PROCEDURE statement. It
// returns ok=false (so the caller falls back to Raw) if the shape is not what
// it expects.
func parseCreateFunction(stmt []cst.Tok) (*cst.CreateFunction, bool) {
cf := &cst.CreateFunction{}
i := 0
// Head: CREATE [OR REPLACE] (FUNCTION|PROCEDURE)
if i >= len(stmt) || !stmt[i].Is("create") {
return nil, false
}
i++
if i+1 < len(stmt) && stmt[i].Is("or") && stmt[i+1].Is("replace") {
i += 2
}
if i >= len(stmt) || !(stmt[i].Is("function") || stmt[i].Is("procedure")) {
return nil, false
}
i++
cf.Head = stmt[:i]
// Name: everything up to the opening '(' of the parameter list.
nameStart := i
for i < len(stmt) && stmt[i].Tok.Kind != lexer.LParen {
i++
}
if i >= len(stmt) {
return nil, false // no parameter list
}
cf.Name = stmt[nameStart:i]
cf.LParen = stmt[i]
i++
// Parameters: up to the matching ')'.
params, rparen, ok := parseParamList(stmt, i)
if !ok {
return nil, false
}
cf.Params = params
cf.RParen = stmt[rparen]
i = rparen + 1
// Trailing semicolon (handle now so it isn't swept into options/tail).
end := len(stmt)
if end > 0 && stmt[end-1].Tok.Kind == lexer.Semicolon {
cf.Semi = &stmt[end-1]
end--
}
// Options up to AS; then the body; then any tail options.
asIdx := indexOfKeyword(stmt, i, end, "as")
if asIdx < 0 {
// No AS clause (e.g. RETURN / BEGIN ATOMIC bodies). Keep options
// structured but leave the body unstructured.
cf.Options = splitClauses(stmt[i:end])
return cf, true
}
cf.Options = splitClauses(stmt[i:asIdx])
cf.As = &stmt[asIdx]
bi := asIdx + 1
if bi < end && isBodyToken(stmt[bi]) {
cf.Body = &stmt[bi]
bi++
}
if bi < end {
cf.Tail = splitClauses(stmt[bi:end])
}
return cf, true
}
// parseParamList parses comma-separated parameters starting at index `start`
// (the token after '('). It returns the parameters, the index of the matching
// ')', and ok.
func parseParamList(stmt []cst.Tok, start int) ([]cst.Param, int, bool) {
var params []cst.Param
depth := 0
itemStart := start
i := start
flush := func(endExclusive int, sep *cst.Tok) {
toks := stmt[itemStart:endExclusive]
if len(toks) == 0 && sep == nil {
return // empty parameter list ()
}
params = append(params, cst.Param{Toks: toks, Sep: sep})
}
for ; i < len(stmt); i++ {
switch stmt[i].Tok.Kind {
case lexer.LParen, lexer.LBracket:
depth++
case lexer.RParen:
if depth == 0 {
// Closing the parameter list.
flush(i, nil)
return params, i, true
}
depth--
case lexer.RBracket:
if depth > 0 {
depth--
}
case lexer.Comma:
if depth == 0 {
sep := &stmt[i]
flush(i, sep)
itemStart = i + 1
}
}
}
return nil, 0, false // unbalanced
}
// splitClauses groups a run of option tokens into clauses, each beginning at a
// recognized clause keyword (at paren depth 0). Tokens before the first
// recognized keyword, if any, are attached to a leading clause so nothing is
// dropped.
func splitClauses(toks []cst.Tok) [][]cst.Tok {
if len(toks) == 0 {
return nil
}
var clauses [][]cst.Tok
depth := 0
start := 0
for i, t := range toks {
switch t.Tok.Kind {
case lexer.LParen, lexer.LBracket:
depth++
case lexer.RParen, lexer.RBracket:
if depth > 0 {
depth--
}
}
if i > start && depth == 0 && isClauseStarter(t) {
clauses = append(clauses, toks[start:i])
start = i
}
}
clauses = append(clauses, toks[start:])
return clauses
}
var clauseStarters = map[string]bool{
"returns": true, "language": true, "transform": true, "window": true,
"immutable": true, "stable": true, "volatile": true, "leakproof": true,
"not": true, "called": true, "strict": true, "external": true,
"security": true, "parallel": true, "cost": true, "rows": true,
"support": true, "set": true, "as": true,
}
func isClauseStarter(t cst.Tok) bool {
if t.Tok.Kind != lexer.Ident {
return false
}
return clauseStarters[lowerASCII(t.Tok.Text)]
}
func isBodyToken(t cst.Tok) bool {
return t.Tok.Kind == lexer.DollarString || t.Tok.Kind == lexer.String
}
// indexOfKeyword returns the index in [from,to) of the first top-level (paren
// depth 0) token equal to kw, or -1.
func indexOfKeyword(stmt []cst.Tok, from, to int, kw string) int {
depth := 0
for i := from; i < to; i++ {
switch stmt[i].Tok.Kind {
case lexer.LParen, lexer.LBracket:
depth++
case lexer.RParen, lexer.RBracket:
if depth > 0 {
depth--
}
}
if depth == 0 && stmt[i].Is(kw) {
return i
}
}
return -1
}
func lowerASCII(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + 32
}
}
return string(b)
}
+124
View File
@@ -0,0 +1,124 @@
package parser
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/hein/pgtidy/pkg/cst"
)
func TestParseRoundTripSmall(t *testing.T) {
cases := []string{
"",
"SELECT 1;",
"-- lead\nSELECT 1; SELECT 2;",
"CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$;",
"create or replace function s.g(a int, b text default 'x') returns void language plpgsql as $$ begin end; $$;\n",
"INSERT INTO t (a,b) VALUES (1,2); -- trailing\n",
}
for _, src := range cases {
if got := Parse(src).Source(); got != src {
t.Errorf("round-trip mismatch\n in: %q\nout: %q", src, got)
}
}
}
func TestParseCreateFunctionShape(t *testing.T) {
src := "CREATE OR REPLACE FUNCTION resolvespec_login(\n" +
" INOUT p_data jsonb\n" +
" ,OUT p_success boolean\n" +
" ,OUT p_error text\n" +
")\nLANGUAGE plpgsql VOLATILE\nSECURITY DEFINER\nAS\n$$\nbegin end;\n$$;\n"
f := Parse(src)
if len(f.Items) != 1 {
t.Fatalf("want 1 item, got %d", len(f.Items))
}
cf, ok := f.Items[0].(*cst.CreateFunction)
if !ok {
t.Fatalf("want *CreateFunction, got %T", f.Items[0])
}
if cf.IsProcedure() {
t.Error("should be FUNCTION not PROCEDURE")
}
if len(cf.Params) != 3 {
t.Fatalf("want 3 params, got %d", len(cf.Params))
}
// Leading-comma style: in source the comma precedes the next param, but the
// parser associates each comma as the separator after the preceding param.
if cf.Params[0].Sep == nil || cf.Params[1].Sep == nil || cf.Params[2].Sep != nil {
t.Errorf("param separators wrong: %v %v %v",
cf.Params[0].Sep != nil, cf.Params[1].Sep != nil, cf.Params[2].Sep != nil)
}
if cf.Body == nil || !strings.Contains(cf.Body.Text(), "begin") {
t.Errorf("body not captured: %+v", cf.Body)
}
if cf.As == nil {
t.Error("AS not captured")
}
// Options should include a LANGUAGE clause, a VOLATILE clause and a SECURITY clause.
var langs, vols, secs int
for _, cl := range cf.Options {
if len(cl) == 0 {
continue
}
switch {
case cl[0].Is("language"):
langs++
case cl[0].Is("volatile"):
vols++
case cl[0].Is("security"):
secs++
}
}
if langs != 1 || vols != 1 || secs != 1 {
t.Errorf("clause split wrong: language=%d volatile=%d security=%d", langs, vols, secs)
}
}
func TestParseFallbackRaw(t *testing.T) {
f := Parse("SELECT a, b FROM t WHERE c = 1;")
if len(f.Items) != 1 {
t.Fatalf("want 1 item, got %d", len(f.Items))
}
if _, ok := f.Items[0].(*cst.Raw); !ok {
t.Fatalf("want *Raw, got %T", f.Items[0])
}
}
// TestCorpusRoundTrip proves the parser is lossless on real-world input: the
// reconstructed source must equal the original byte-for-byte.
func TestCorpusRoundTrip(t *testing.T) {
dir := filepath.Join("..", "..", "testdata", "corpus")
entries, err := os.ReadDir(dir)
if err != nil {
t.Skipf("no corpus dir: %v", err)
}
var seen, fns int
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pgsql") {
continue
}
seen++
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatal(err)
}
src := string(data)
f := Parse(src)
if got := f.Source(); got != src {
t.Errorf("%s: round-trip mismatch (len in=%d out=%d)", e.Name(), len(src), len(got))
}
for _, it := range f.Items {
if _, ok := it.(*cst.CreateFunction); ok {
fns++
}
}
}
if seen == 0 {
t.Skip("no corpus files")
}
t.Logf("round-tripped %d corpus files; structured %d CREATE FUNCTION/PROCEDURE", seen, fns)
}