523 lines
13 KiB
Go
523 lines
13 KiB
Go
// 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"
|
|
|
|
"git.warky.dev/wdevs/pgtidy/pkg/config"
|
|
"git.warky.dev/wdevs/pgtidy/pkg/cst"
|
|
"git.warky.dev/wdevs/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)
|
|
case *cst.Raw:
|
|
switch {
|
|
case isDMLStart(v.Toks):
|
|
p.b.WriteString(formatDML(v.Toks, p.st))
|
|
case isDoBlock(v.Toks):
|
|
p.b.WriteString(formatDoBlock(v.Toks, p.st))
|
|
default:
|
|
p.b.WriteString(verbatimSpan(v.Toks))
|
|
}
|
|
default:
|
|
p.b.WriteString(verbatimSpan(cst.Tokens(n)))
|
|
}
|
|
}
|
|
|
|
// isDoBlock reports whether toks is a DO $$ ... $$ statement.
|
|
func isDoBlock(toks []cst.Tok) bool {
|
|
for _, t := range toks {
|
|
if t.Tok.Kind == lexer.Ident {
|
|
return lowerASCII(t.Tok.Text) == "do"
|
|
}
|
|
if !t.Tok.IsTrivia() {
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// formatDoBlock formats a DO $$ ... $$ block by applying formatBody to the
|
|
// dollar-quoted string and emitting DO + newline + formatted body.
|
|
func formatDoBlock(toks []cst.Tok, st config.Style) string {
|
|
// Find the DO keyword, the dollar-string body, and the optional semicolon.
|
|
var doTok, bodyTok *cst.Tok
|
|
hasSemi := false
|
|
for i := range toks {
|
|
t := &toks[i]
|
|
if t.Tok.IsTrivia() || t.Tok.Kind == lexer.EOF {
|
|
continue
|
|
}
|
|
low := lowerASCII(t.Tok.Text)
|
|
if t.Tok.Kind == lexer.Ident && low == "do" && doTok == nil {
|
|
doTok = t
|
|
continue
|
|
}
|
|
if doTok != nil && t.Tok.Kind == lexer.DollarString && bodyTok == nil {
|
|
bodyTok = t
|
|
continue
|
|
}
|
|
if t.Tok.Kind == lexer.Semicolon {
|
|
hasSemi = true
|
|
}
|
|
}
|
|
if doTok == nil || bodyTok == nil {
|
|
return verbatimSpan(toks)
|
|
}
|
|
|
|
nl := st.Newline
|
|
var b strings.Builder
|
|
b.WriteString(applyCase(doTok.Tok.Text, st.KeywordCase))
|
|
b.WriteString(nl)
|
|
b.WriteString(formatBody(bodyTok.Tok.Text, st))
|
|
if hasSemi {
|
|
b.WriteString(";")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
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.
|
|
// We still format the body dollar-string independently since it is self-contained.
|
|
if headerHasComments(cf) {
|
|
p.b.WriteString(verbatimSpanFormatBody(cst.Tokens(cf), cf.Body, p.st))
|
|
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("(")
|
|
|
|
// Build formatted param texts first so we can measure widths.
|
|
paramTexts := make([]string, len(cf.Params))
|
|
for i, param := range cf.Params {
|
|
paramTexts[i] = p.inline(param.Toks)
|
|
}
|
|
|
|
// align_param_types: pad param names so type columns align.
|
|
if p.st.AlignParamTypes && len(cf.Params) > 1 {
|
|
paramTexts = alignParamTypes(paramTexts)
|
|
}
|
|
|
|
first := p.st.Indent + " " // align item text one column past the comma
|
|
cont := p.st.Indent
|
|
for i, text := range paramTexts {
|
|
param := cf.Params[i]
|
|
p.nl()
|
|
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)
|
|
}
|
|
// Emit trailing inline comment from the separator (e.g. --description after param).
|
|
if param.Sep != nil {
|
|
for _, tr := range param.Sep.Lead {
|
|
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
|
|
p.b.WriteByte(' ')
|
|
p.b.WriteString(strings.TrimRight(tr.Text, " \t"))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
p.nl()
|
|
p.b.WriteString(")")
|
|
|
|
for _, clause := range cf.Options {
|
|
p.nl()
|
|
p.b.WriteString(p.inline(clause))
|
|
}
|
|
if cf.As != nil {
|
|
// routine_as_wrap: when false, AS stays on the same line as the last option.
|
|
if p.st.RoutineAsWrap {
|
|
p.nl()
|
|
} else {
|
|
p.b.WriteByte(' ')
|
|
}
|
|
p.b.WriteString(p.inline([]cst.Tok{{Tok: cf.As.Tok}}))
|
|
}
|
|
if cf.Body != nil {
|
|
p.nl()
|
|
p.b.WriteString(formatBody(cf.Body.Tok.Text, p.st))
|
|
}
|
|
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(' ')
|
|
}
|
|
var prev lexer.Token
|
|
if i > 0 {
|
|
prev = toks[i-1].Tok
|
|
}
|
|
nextIsLParen := i+1 < len(toks) && toks[i+1].Tok.Kind == lexer.LParen
|
|
b.WriteString(caseTextCtx(t.Tok, prev, nextIsLParen, 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 {
|
|
text := strings.TrimRight(strings.ReplaceAll(tr.Text, "\r", ""), " \t")
|
|
p.b.WriteString(text)
|
|
p.nl()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *printer) trailingComments(lead cst.Trivia) {
|
|
cs := commentsOf(lead)
|
|
for _, c := range cs {
|
|
p.nl()
|
|
p.b.WriteString(strings.TrimRight(strings.ReplaceAll(c.Text, "\r", ""), " \t"))
|
|
}
|
|
}
|
|
|
|
// --- spacing & casing ---
|
|
|
|
// tightOps are operators printed without surrounding spaces.
|
|
var tightOps = map[string]bool{"::": true, ":": true, "->": true, "->>": true}
|
|
|
|
// parenKws are keywords that always take a space before '(' because they
|
|
// introduce a subquery or a bracketed clause, not a function-call argument list.
|
|
var parenKws = map[string]bool{
|
|
"as": true, "exists": true, "in": true, "not": true,
|
|
"between": true, "like": true, "ilike": true, "similar": 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:
|
|
// Keywords like AS/EXISTS/IN introduce subqueries or clause groups,
|
|
// not function calls — always separate with a space.
|
|
if a.Kind == lexer.Ident && parenKws[lowerASCII(a.Text)] {
|
|
return true
|
|
}
|
|
return false // function call / type modifier
|
|
}
|
|
case lexer.LBracket:
|
|
switch a.Kind {
|
|
case lexer.Ident, lexer.QuotedIdent, lexer.RParen, lexer.RBracket:
|
|
return false // array subscript / array type modifier
|
|
}
|
|
case lexer.Operator:
|
|
if tightOps[b.Text] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func caseText(t lexer.Token, st config.Style) string {
|
|
return caseTextCtx(t, lexer.Token{}, false, st)
|
|
}
|
|
|
|
// caseTextCtx applies casing with context: prev is the preceding significant
|
|
// token, nextIsLParen indicates the next significant token is '('.
|
|
func caseTextCtx(t lexer.Token, prev lexer.Token, nextIsLParen bool, st config.Style) string {
|
|
if t.Kind != lexer.Ident {
|
|
return t.Text
|
|
}
|
|
low := lowerASCII(t.Text)
|
|
switch {
|
|
case isTypeName(low):
|
|
return applyCase(t.Text, st.TypeCase)
|
|
case isKeyword(low):
|
|
return applyCase(t.Text, st.KeywordCase)
|
|
case nextIsLParen && isBuiltinFunc(low):
|
|
return applyCase(t.Text, st.BuiltinCase)
|
|
case prev.Kind == lexer.Ident && lowerASCII(prev.Text) == "as":
|
|
return applyCase(t.Text, st.AliasCase)
|
|
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. Param separator (Sep) comments
|
|
// are excluded — those are trailing inline comments on param lines that the
|
|
// formatter emits explicitly after each param text. The first token's leading
|
|
// trivia and the body token are also excluded.
|
|
func headerHasComments(cf *cst.CreateFunction) bool {
|
|
// Build a set of Sep token offsets so we can skip them.
|
|
sepOffsets := make(map[int]bool, len(cf.Params))
|
|
for _, p := range cf.Params {
|
|
if p.Sep != nil {
|
|
sepOffsets[p.Sep.Tok.Off] = true
|
|
}
|
|
}
|
|
all := cst.Tokens(cf)
|
|
for i, t := range all {
|
|
if i == 0 || t.Tok.Kind == lexer.Semicolon {
|
|
continue
|
|
}
|
|
if sepOffsets[t.Tok.Off] {
|
|
continue // Sep comments handled separately
|
|
}
|
|
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()
|
|
}
|
|
|
|
// verbatimSpanFormatBody emits toks verbatim but replaces bodyTok's text with
|
|
// formatBody output. Used when the function header has comments we cannot
|
|
// safely relocate but the body can still be independently formatted.
|
|
// If bodyTok is nil the function is identical to verbatimSpan.
|
|
func verbatimSpanFormatBody(toks []cst.Tok, bodyTok *cst.Tok, st config.Style) string {
|
|
var b strings.Builder
|
|
for i, t := range toks {
|
|
if i > 0 {
|
|
for _, tr := range t.Lead {
|
|
b.WriteString(tr.Text)
|
|
}
|
|
}
|
|
if bodyTok != nil && t.Tok.Kind == lexer.DollarString && t.Tok.Off == bodyTok.Tok.Off {
|
|
b.WriteString(formatBody(t.Tok.Text, st))
|
|
} else {
|
|
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)
|
|
}
|
|
|
|
// alignParamTypes pads param names so the type column aligns across all params.
|
|
// Expected format per param: "[mode] name type [DEFAULT expr]".
|
|
// Mode keywords (IN/OUT/INOUT/VARIADIC) are detected and skipped.
|
|
// Params without a type are passed through unchanged.
|
|
func alignParamTypes(params []string) []string {
|
|
type pp struct{ mode, name, rest string }
|
|
parsed := make([]pp, len(params))
|
|
maxNameW := 0
|
|
|
|
modeKws := map[string]bool{"in": true, "out": true, "inout": true, "variadic": true}
|
|
|
|
for i, s := range params {
|
|
fields := strings.Fields(s)
|
|
if len(fields) < 2 {
|
|
parsed[i].rest = s
|
|
continue
|
|
}
|
|
nameIdx := 0
|
|
if modeKws[lowerASCII(fields[0])] {
|
|
nameIdx = 1
|
|
}
|
|
if nameIdx >= len(fields) || nameIdx+1 >= len(fields) {
|
|
// No type field — keep verbatim.
|
|
parsed[i].rest = s
|
|
continue
|
|
}
|
|
if nameIdx > 0 {
|
|
parsed[i].mode = fields[0]
|
|
}
|
|
parsed[i].name = fields[nameIdx]
|
|
parsed[i].rest = strings.Join(fields[nameIdx+1:], " ")
|
|
if len(parsed[i].name) > maxNameW {
|
|
maxNameW = len(parsed[i].name)
|
|
}
|
|
}
|
|
|
|
if maxNameW == 0 {
|
|
return params
|
|
}
|
|
|
|
out := make([]string, len(params))
|
|
for i, p := range parsed {
|
|
if p.name == "" {
|
|
out[i] = params[i]
|
|
continue
|
|
}
|
|
var b strings.Builder
|
|
if p.mode != "" {
|
|
b.WriteString(p.mode)
|
|
b.WriteByte(' ')
|
|
}
|
|
b.WriteString(p.name)
|
|
// Pad name to (maxNameW+1) so the type column starts at a consistent offset.
|
|
pad := maxNameW + 1 - len(p.name)
|
|
for k := 0; k < pad; k++ {
|
|
b.WriteByte(' ')
|
|
}
|
|
b.WriteString(p.rest)
|
|
out[i] = b.String()
|
|
}
|
|
return out
|
|
}
|