feat(format): default output to dist/examples house style
CI / Test (push) Successful in 1m12s
CI / Build (push) Successful in 30s

Align the formatter defaults and layout with the hand-formatted reference
procedures in dist/examples so a clean `pgtidy fmt` produces the house style.

config.Default():
- align_param_types: false (no type-column alignment in param lists)
- plpgsql_declare_align_type / plpgsql_declare_align_eq: true

Formatter:
- routine header: leading-comma params at column 0, first param at one
  indent, RETURNS/LANGUAGE/volatility/SECURITY each indented one level
- %type / %rowtype printed tight (isPctTypeBoundary)
- DECLARE = / := / DEFAULT column padded only to the widest declaration
  that carries an assignment
- WHERE continuations in body UPDATE/DELETE: AND/OR aligned with WHERE
- EXCEPTION aligned to its enclosing BEGIN; column-0 comment continuations
  kept flush-left

Safety gate:
- SemanticallyEqual tolerates CRLF vs LF inside string literals (normNL);
  the formatter re-emits all layout with st.Newline, so a \r\n inside a
  multi-line string literal is normalisation, not a code change. This was
  why action_init and event_exec_func previously refused to format.

Corpus:
- add the four CRLF reference files as idempotence/safety fixtures
- regenerate test_a and test_mm_proc goldens

FOR...LOOP body indentation keeps the existing +1 convention (LOOP aligned
with FOR); the dist/examples use +2, so loop-body regions differ by
whitespace only.
This commit is contained in:
Hein
2026-09-10 14:54:20 +02:00
parent 94d776e3de
commit 83b215fd25
17 changed files with 3740 additions and 147 deletions
+49 -7
View File
@@ -177,7 +177,10 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) {
if nw > nameColW {
nameColW = nw
}
if tw > typeColW {
// typeColW drives the '='/':='/DEFAULT column (align_eq only), so
// only declarations that actually carry an assignment participate —
// a bare "name type;" must not widen it.
if tw > typeColW && declHasAssignment(body) {
typeColW = tw
}
}
@@ -207,7 +210,7 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) {
writeDeclareAligned(b, body, st, nameColW, typeColW)
} else {
for j, t := range body {
if j > 0 && needSpace(body[j-1].Tok, t.Tok) {
if j > 0 && needSpace(body[j-1].Tok, t.Tok) && !isPctTypeBoundary(body, j) {
b.WriteByte(' ')
}
b.WriteString(caseText(t.Tok, st))
@@ -220,6 +223,32 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) {
}
}
// declHasAssignment reports whether a DECLARE variable body (name type … ) has
// a default assignment ( := / = / DEFAULT ) at paren depth 0.
func declHasAssignment(body []cst.Tok) bool {
depth := 0
for _, t := range body {
switch t.Tok.Kind {
case lexer.LParen, lexer.LBracket:
depth++
case lexer.RParen, lexer.RBracket:
if depth > 0 {
depth--
}
}
if depth != 0 {
continue
}
if t.Tok.Kind == lexer.Operator && (t.Tok.Text == ":=" || t.Tok.Text == "=") {
return true
}
if t.Tok.Kind == lexer.Ident && lowerASCII(t.Tok.Text) == "default" {
return true
}
}
return false
}
// declareNameTypeWidth returns the rendered width of the name and type portions
// of a DECLARE variable declaration (without the default assignment).
// Format is: [name type [:= default]] or [name type [DEFAULT default]].
@@ -256,7 +285,7 @@ func declareNameTypeWidth(body []cst.Tok, st config.Style) (nameW, typeW int) {
}
var tb strings.Builder
for j, t := range typeTokens {
if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) {
if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) && !isPctTypeBoundary(typeTokens, j) {
tb.WriteByte(' ')
}
tb.WriteString(caseText(t.Tok, st))
@@ -315,7 +344,7 @@ func writeDeclareAligned(b *strings.Builder, body []cst.Tok, st config.Style, na
var typeStr strings.Builder
for j, t := range typeTokens {
if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) {
if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) && !isPctTypeBoundary(typeTokens, j) {
typeStr.WriteByte(' ')
}
typeStr.WriteString(caseText(t.Tok, st))
@@ -429,7 +458,12 @@ func formatBodyStatements(text string, st config.Style) string {
}
case "exception":
inException = true
effectiveDepth = 0
// EXCEPTION belongs to its nearest enclosing BEGIN, so align it one
// level in from the current block body (col 0 for the outermost).
effectiveDepth = blockDepth - 1
if effectiveDepth < 0 {
effectiveDepth = 0
}
}
baseIndent := strings.Repeat(st.Indent, effectiveDepth)
@@ -589,7 +623,13 @@ func formatBodyStmtLines(lines []bline, baseIndent string, st config.Style) []st
for i, ll := range lines {
text := ll.text
indent := baseIndent
if i > 0 && ll.indent != "" && !isStandaloneBodyKeyword(ll.text, "then", "else", "elsif", "elseif") {
switch {
case i > 0 && ll.indent == "" && len(significantBodyTokens(text)) == 0:
// A column-0 comment line trailing a multi-line (commented-out)
// statement is a continuation the author left flush-left — keep it
// there rather than re-indenting it to block depth.
indent = ""
case i > 0 && ll.indent != "" && !isStandaloneBodyKeyword(ll.text, "then", "else", "elsif", "elseif"):
indent = ll.indent
}
out = append(out, indent+text)
@@ -639,7 +679,9 @@ func reindentBodyDML(lines []bline, baseIndent string, st config.Style) []string
continue
}
if lineDepth == 0 && (kw == "and" || kw == "or") {
out = append(out, baseIndent+st.Indent+strings.TrimSpace(text))
// House style: AND/OR line up with the WHERE keyword; the first
// predicate is indented two levels under it.
out = append(out, baseIndent+strings.TrimSpace(text))
afterWhere = false
updateBodyParenDepth(text, &parenDepth)
continue
+1 -1
View File
@@ -462,7 +462,7 @@ func dmlInline(toks []cst.Tok, st config.Style) string {
}
var b strings.Builder
for i, t := range toks {
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) {
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) && !isPctTypeBoundary(toks, i) {
b.WriteByte(' ')
}
// Space after comma in calls: func(a, b) vs func(a,b).
+31 -5
View File
@@ -147,8 +147,9 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
paramTexts = alignParamTypes(paramTexts)
}
first := p.st.Indent + " " // align item text one column past the comma
cont := p.st.Indent
// House style: first parameter indented one level; leading-comma
// continuation lines carry the comma at column 0 followed by one space.
first := p.st.Indent
for i, text := range paramTexts {
param := cf.Params[i]
p.nl()
@@ -159,8 +160,7 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
p.b.WriteString(",")
}
} else {
p.b.WriteString(cont)
p.b.WriteString(",")
p.b.WriteString(", ")
p.b.WriteString(text)
}
// Emit trailing inline comment from the separator (e.g. --description after param).
@@ -179,6 +179,7 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
for _, clause := range cf.Options {
p.nl()
p.b.WriteString(p.st.Indent)
p.b.WriteString(p.inline(clause))
}
if cf.As != nil {
@@ -218,7 +219,7 @@ func (p *printer) inline(toks []cst.Tok) string {
}
var b strings.Builder
for i, t := range toks {
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) {
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) && !isPctTypeBoundary(toks, i) {
b.WriteByte(' ')
}
var prev lexer.Token
@@ -254,6 +255,31 @@ func (p *printer) trailingComments(lead cst.Trivia) {
// tightOps are operators printed without surrounding spaces.
var tightOps = map[string]bool{"::": true, ":": true, "->": true, "->>": true}
// isPctTypeBoundary reports whether the gap between toks[i-1] and toks[i] sits
// inside a %TYPE / %ROWTYPE modifier (e.g. core.tbl%rowtype), which is printed
// tight like "::" rather than as the modulo operator.
func isPctTypeBoundary(toks []cst.Tok, i int) bool {
if i <= 0 || i >= len(toks) {
return false
}
isPct := func(t lexer.Token) bool { return t.Kind == lexer.Operator && t.Text == "%" }
isTypeWord := func(t lexer.Token) bool {
if t.Kind != lexer.Ident {
return false
}
l := lowerASCII(t.Text)
return l == "type" || l == "rowtype"
}
prev, cur := toks[i-1].Tok, toks[i].Tok
if isPct(prev) && isTypeWord(cur) {
return true // space after %
}
if isPct(cur) && i+1 < len(toks) && isTypeWord(toks[i+1].Tok) {
return true // space before %
}
return false
}
// 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{
+10 -10
View File
@@ -23,13 +23,13 @@ func TestFormatHeaderGolden(t *testing.T) {
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" +
" INOUT p_data jsonb\n" +
", OUT p_success boolean\n" +
", OUT p_error text\n" +
")\n" +
"LANGUAGE plpgsql\n" +
"VOLATILE\n" +
"SECURITY DEFINER\n" +
" LANGUAGE plpgsql\n" +
" VOLATILE\n" +
" SECURITY DEFINER\n" +
"AS\n" +
"$$\nbegin end;\n$$;\n"
@@ -130,8 +130,8 @@ func TestFormatIssue1PLpgSQLIndenting(t *testing.T) {
want := "CREATE FUNCTION f(\n" +
")\n" +
"RETURNS void\n" +
"LANGUAGE plpgsql\n" +
" RETURNS void\n" +
" LANGUAGE plpgsql\n" +
"AS\n" +
"$$\n" +
"DECLARE\n" +
@@ -144,14 +144,14 @@ func TestFormatIssue1PLpgSQLIndenting(t *testing.T) {
" set status = 'done'\n" +
" where\n" +
" u.rid_process = r_lp.rid_process\n" +
" and nv(u.status) <> 'done';\n" +
" and nv(u.status) <> 'done';\n" +
" elsif r_lp.total > 0\n" +
" then\n" +
" update core.process u\n" +
" set status = 'open'\n" +
" where\n" +
" u.rid_process = r_lp.rid_process\n" +
" and nv(u.status) <> 'open';\n" +
" and nv(u.status) <> 'open';\n" +
"\n" +
" end if;\n" +
"$$;\n"
+12
View File
@@ -32,6 +32,14 @@ func SemanticallyEqual(a, b string) bool {
if !strings.EqualFold(ta[i].Text, tb[i].Text) {
return false
}
case lexer.String, lexer.EscapeString, lexer.BitString, lexer.HexString, lexer.UnicodeString:
// A CRLF vs LF difference inside a multi-line string literal is a
// line-ending normalisation, not a change of code content — the
// formatter always re-emits layout with st.Newline. Compare the
// literal modulo \r\n ↔ \n.
if normNL(ta[i].Text) != normNL(tb[i].Text) {
return false
}
case lexer.DollarString:
_, innerA, _, okA := splitDollarQuote(ta[i].Text)
_, innerB, _, okB := splitDollarQuote(tb[i].Text)
@@ -47,6 +55,10 @@ func SemanticallyEqual(a, b string) bool {
return true
}
// normNL collapses CRLF to LF so string literals compare independent of the
// source file's line-ending convention.
func normNL(s string) string { return strings.ReplaceAll(s, "\r\n", "\n") }
// significantTokens lexes src and returns its tokens excluding EOF and trivia
// (whitespace/comments).
func significantTokens(src string) []lexer.Token {