feat: initial plan
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] }
|
||||
Reference in New Issue
Block a user