feat(format): implement PL/pgSQL body formatting

* add formatBody and formatBodyInner functions for DECLARE section
* update needSpace to handle LBracket correctly
* enhance semanticallyEqual to compare dollar-quoted bodies
* add test data for broken layout scenarios
This commit is contained in:
2026-06-23 21:15:27 +02:00
parent 625ddc79a1
commit 6492ab35b7
7 changed files with 454 additions and 18 deletions
+2
View File
@@ -163,3 +163,5 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
temp/*
dist/*
+182
View File
@@ -0,0 +1,182 @@
package format
import (
"strings"
"github.com/hein/pgtidy/pkg/config"
"github.com/hein/pgtidy/pkg/cst"
"github.com/hein/pgtidy/pkg/lexer"
)
// formatBody applies house-style formatting to a PL/pgSQL dollar-quoted body
// token. Currently only the DECLARE section is formatted; the rest is verbatim.
func formatBody(bodyText string, st config.Style) string {
open, inner, close, ok := splitDollarQuote(bodyText)
if !ok {
return bodyText
}
return open + formatBodyInner(inner, st) + close
}
// splitDollarQuote splits a dollar-quoted token (e.g. "$$...\n$$" or
// "$S$...$S$") into (open tag, inner text, close tag). The open and close tags
// are the same string; the last occurrence in s is taken as the close tag.
func splitDollarQuote(s string) (open, inner, close string, ok bool) {
if len(s) == 0 || s[0] != '$' {
return
}
end := strings.Index(s[1:], "$")
if end < 0 {
return
}
openLen := end + 2
open = s[:openLen]
closeStart := strings.LastIndex(s, open)
if closeStart < openLen {
return
}
inner = s[openLen:closeStart]
close = s[closeStart:]
ok = true
return
}
func formatBodyInner(inner string, st config.Style) string {
sig, _ := cst.Attach(lexer.Lex(inner))
nl := st.Newline
// Find DECLARE at depth 0.
declareIdx := -1
for i, t := range sig {
if t.Tok.Kind == lexer.Ident && lowerASCII(t.Tok.Text) == "declare" {
declareIdx = i
break
}
}
if declareIdx < 0 {
return inner
}
// Find BEGIN at depth 0 after DECLARE.
beginIdx := -1
depth := 0
for i := declareIdx + 1; i < len(sig); i++ {
switch sig[i].Tok.Kind {
case lexer.LParen, lexer.LBracket:
depth++
case lexer.RParen, lexer.RBracket:
if depth > 0 {
depth--
}
}
if depth == 0 && sig[i].Tok.Kind == lexer.Ident && lowerASCII(sig[i].Tok.Text) == "begin" {
beginIdx = i
break
}
}
if beginIdx < 0 {
return inner
}
var b strings.Builder
// Emit verbatim up to and including DECLARE (keyword-cased).
for i := 0; i <= declareIdx; i++ {
t := sig[i]
for _, tr := range t.Lead {
b.WriteString(tr.Text)
}
if i == declareIdx {
b.WriteString(applyCase(t.Tok.Text, st.KeywordCase))
} else {
b.WriteString(t.Tok.Text)
}
}
b.WriteString(nl)
// Format each variable declaration in the DECLARE section.
formatDeclareVars(&b, sig[declareIdx+1:beginIdx], st)
// Emit BEGIN and everything after it verbatim from the original source.
// The newline before BEGIN is supplied by the last declaration's line end.
b.WriteString(inner[sig[beginIdx].Tok.Off:])
return b.String()
}
// formatDeclareVars writes each variable declaration as a single indented line.
// Comments in the leading trivia of a declaration's first token are preserved
// on their own lines. If a declaration carries mid-body comments it is emitted
// verbatim to avoid losing them.
func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) {
nl := st.Newline
indent := st.Indent
depth := 0
var cur []cst.Tok
var preComments []string
emit := func() {
if len(cur) == 0 {
return
}
for _, c := range preComments {
b.WriteString(indent)
b.WriteString(c)
b.WriteString(nl)
}
preComments = nil
// Graceful degradation: mid-declaration comments stay verbatim.
if anyComment(cur[1:]) {
b.WriteString(indent)
b.WriteString(verbatimSpan(cur))
b.WriteString(nl)
cur = nil
return
}
body := cur
hasSemi := len(body) > 0 && body[len(body)-1].Tok.Kind == lexer.Semicolon
if hasSemi {
body = body[:len(body)-1]
}
b.WriteString(indent)
for i, t := range body {
if i > 0 && needSpace(body[i-1].Tok, t.Tok) {
b.WriteByte(' ')
}
b.WriteString(caseText(t.Tok, st))
}
if hasSemi {
b.WriteString(";")
}
b.WriteString(nl)
cur = nil
}
for _, t := range toks {
if len(cur) == 0 {
for _, tr := range t.Lead {
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
preComments = append(preComments, strings.TrimRight(tr.Text, " \t"))
}
}
}
switch t.Tok.Kind {
case lexer.LParen, lexer.LBracket:
depth++
case lexer.RParen, lexer.RBracket:
if depth > 0 {
depth--
}
}
cur = append(cur, t)
if t.Tok.Kind == lexer.Semicolon && depth == 0 {
emit()
}
}
emit()
}
+6 -1
View File
@@ -104,7 +104,7 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
} }
if cf.Body != nil { if cf.Body != nil {
p.nl() p.nl()
p.b.WriteString(cf.Body.Tok.Text) // body emitted verbatim (formatted later) p.b.WriteString(formatBody(cf.Body.Tok.Text, p.st))
} }
for _, clause := range cf.Tail { for _, clause := range cf.Tail {
p.nl() p.nl()
@@ -179,6 +179,11 @@ func needSpace(a, b lexer.Token) bool {
case lexer.Ident, lexer.QuotedIdent, lexer.RParen, lexer.RBracket, lexer.Param: case lexer.Ident, lexer.QuotedIdent, lexer.RParen, lexer.RBracket, lexer.Param:
return false // function call / type modifier 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: case lexer.Operator:
if tightOps[b.Text] { if tightOps[b.Text] {
return false return false
+14 -4
View File
@@ -83,8 +83,9 @@ func TestCorpusIdempotentAndSafe(t *testing.T) {
// semanticallyEqual compares the non-trivia token streams of two sources, // semanticallyEqual compares the non-trivia token streams of two sources,
// treating unquoted identifiers/keywords case-insensitively and everything // treating unquoted identifiers/keywords case-insensitively and everything
// else (strings, numbers, dollar bodies, operators, punctuation) exactly. This // else (strings, numbers, operators, punctuation) exactly. Dollar-quoted body
// validates that formatting changed only layout/casing, never meaning. // tokens are compared recursively so body whitespace normalization does not
// trigger a false failure.
func semanticallyEqual(a, b string) bool { func semanticallyEqual(a, b string) bool {
ta := significant(a) ta := significant(a)
tb := significant(b) tb := significant(b)
@@ -95,13 +96,22 @@ func semanticallyEqual(a, b string) bool {
if ta[i].Kind != tb[i].Kind { if ta[i].Kind != tb[i].Kind {
return false return false
} }
if ta[i].Kind == lexer.Ident { switch ta[i].Kind {
case lexer.Ident:
if !strings.EqualFold(ta[i].Text, tb[i].Text) { if !strings.EqualFold(ta[i].Text, tb[i].Text) {
return false return false
} }
} else if ta[i].Text != tb[i].Text { case lexer.DollarString:
_, innerA, _, okA := splitDollarQuote(ta[i].Text)
_, innerB, _, okB := splitDollarQuote(tb[i].Text)
if okA != okB || (okA && !semanticallyEqual(innerA, innerB)) {
return false return false
} }
default:
if ta[i].Text != tb[i].Text {
return false
}
}
} }
return true return true
} }
+198
View File
@@ -0,0 +1,198 @@
--select * from dropall('resolvespec_login');
CREATE
OR REPLACE FUNCTION resolvespec_login(
INOUT p_data jsonb
,OUT p_success boolean
,OUT p_error text
)
LANGUAGE plpgsql VOLATILE
SECURITY DEFINER
AS
$$
DECLARE
--Error Handling--
m_funcname text = 'resolvespec_login';
m_errmsg
text;
m_errcontext
text;
m_errdetail
text;
m_errhint
text;
m_errstate
text;
m_retval
integer;
--Error Handling--
m_rid_user
integer;
m_rid_hub
integer;
m_pass_hashed
citext[];
m_session
jsonb;
m_allow_hash_auth
boolean;
BEGIN
m_allow_hash_auth
= _try_integer( p_data->'claims'->>'rid_user',0) > 0;
create
extension if not exists pgcrypto;
perform
log_event(m_funcname,format('API Login username: %s hh=%s claims: %s',p_data->>'username',m_allow_hash_auth,p_data->'claims'), bt_enum('eventlog','local notice'));
select h.rid_hub
from public.user h
where h.usercode = p_data ->>'username'
into m_rid_hub;
if
m_rid_hub is null
and exists (select 1 from information_schema.tables t where t.table_schema = 'public' and t.table_name = 'users')
then
select u.rid_hub, u.rid_user
from public.users u
where u.rid_user = _try_integer(p_data - > 'claims' ->>'rid_user', 0) into m_rid_hub,m_rid_user;
end if;
if
m_rid_hub is null
then
raise exception 'Invalid username / password';
end if;
m_pass_hashed
= array[encode(digest(format('%s:%s',p_data->>'username',p_data->>'password'), 'sha512'), 'hex')
,encode(digest(format('%s:%s',p_data->>'username',p_data->>'password'), 'md5'), 'hex')
,encode(digest(format('%s',p_data->>'password'), 'md5'), 'hex')
]::citext[];
if
m_allow_hash_auth
then
m_pass_hashed := m_pass_hashed || array[
p_data->>'password'
]::citext[];
end if;
--select $A${"meta": null, "claims": {"rid_user": 30000024}, "password": "c4ca4238a0b923820dcc509a6f75849b", "username": "SUPPORT"}$A$::jsonb->>'password'
--c4ca4238a0b923820dcc509a6f75849b
--select * from v_eventlog
if
exists (
select 1
from information_schema.tables t
where t.table_schema = 'public'
and t.table_name = 'users'
)
then
if not exists (
select 1
from public.user h
left outer join public.users usr on usr.rid_hub = h.rid_hub
where h.rid_hub = m_rid_hub
and (
h.password = any (m_pass_hashed)
and nv(h.password) <> ''
or usr.password = any(m_pass_hashed)
and nv(usr.password) <> ''
)
)
then
raise exception 'Password incorrect';
end if;
elsif
not exists (
select 1
from public.user h
where h.rid_hub = m_rid_hub
and h.password = any(m_pass_hashed)
and nv(h.password) <> ''
)
then
raise exception 'Password incorrect';
end if;
if
_try_bool(p_data->'jsonvalue'->>'issecurity',false)
and not exists (
select h.rid_hub
from public.user h
inner join public.user_all_parents(m_rid_hub) p on p.parent_rid_hub = h.rid_hub
where h.hubname ilike '%Access Control%'
)
then
raise exception 'Cannot login with security mode. User must be in Access Control group';
end if;
with newsession as (
insert
into core._loginsession (createtm, modifytm, rid_user, usertable, sessionid, token, useragent, location,
ipaddress, expiretm, jsonvalue)
select now(),
now(),
m_rid_hub,
'hub',
newid(),
newid(),
p_data ->>'user-agent', p_data->>'fromurl'
, p_data->>'host', (now() + '31 days':: interval), p_data->'jsonvalue'
returning *
)
select to_jsonb(newsession)
from newsession into m_session;
if
_try_integer(m_session->>'rid_user',0) > 0
then
update public.user u
set jsonvalue = _jsonb_object_cat(u.jsonvalue, jsonb_build_object('lastlogin', to_char(now(), 'YYYY-MM-DD HH24:mi:SS')))
where u.rid_hub = m_rid_hub;
end if;
select jsonb_build_object('token', m_session ->>'token'
, 'session', m_session ->>'session'
, 'user', _jsonb_object_cat(jsonb_build_object(
'user_id', h.rid_hub
, 'username', h.usercode
, 'email', null
, 'user_level', 0
, 'roles', jsonb_build_array()
, 'session_id', m_session ->>'sessionid'
, 'token', m_session ->>'token'
, 'session_rid', _try_integer(m_session ->>'id')
),
(select jsonb_build_object('program_user_table', r.tablename, 'program_user_id',
_try_integer(r.key, 0))
from public.user_tableinfo(m_rid_hub) r)
)
, 'expires_in', 86400
)
from public.user h
where h.rid_hub = m_rid_hub into p_data;
p_success
= true;
EXCEPTION
WHEN others THEN
GET STACKED DIAGNOSTICS
m_errmsg = MESSAGE_TEXT
,m_errcontext = PG_EXCEPTION_CONTEXT
,m_errdetail = PG_EXCEPTION_DETAIL
,m_errhint = PG_EXCEPTION_HINT
,m_errstate = RETURNED_SQLSTATE;
p_error
:= get_err_msg(m_funcname, m_errmsg, m_errcontext, m_errdetail, m_errhint, m_errstate);
p_success
:= false;
END;
$$;
+50 -11
View File
@@ -38,22 +38,61 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started
- **Status:** all tests pass. - **Status:** all tests pass.
- _Still TODO (later): DML/other-DDL structuring (currently Raw) for full formatting._ - _Still TODO (later): DML/other-DDL structuring (currently Raw) for full formatting._
### PL/pgSQL body parser ← NEXT (the remaining V1 piece) ### 🚧 PL/pgSQL body parser ← NEXT (the remaining V1 piece)
- Parse `$$ ... $$` bodies: DECLARE/BEGIN/END, IF/ELSIF/ELSE/END IF, LOOP/FOR/WHILE,
CASE, assignments (`:=` / `=`), RAISE, nested SQL statements, EXCEPTION blocks. #### ✅ DECLARE section — `pkg/format/body.go`
- The crux for stored-procedure formatting (primary use case). Currently the body is - `formatBody` splits the dollar-quote tag, calls `formatBodyInner`.
emitted verbatim; this milestone formats inside it. Reuse `inline`/spacing/casing from - `formatBodyInner` locates `DECLARE` and `BEGIN` at depth 0, formats the declare block,
`pkg/format` and keep verbatim fallback for unparsable constructs. then emits `BEGIN` onwards verbatim.
- `formatDeclareVars`: each variable declaration collapsed to one line
(` name type [= expr];`), `--Block--` comment markers preserved on their own lines,
mid-declaration block comments trigger verbatim fallback.
- `needSpace` fixed for `LBracket` — no space before `[` after ident/closing bracket
(fixes `citext[]`, array subscripts).
- `semanticallyEqual` in tests updated to recurse into dollar-quoted body tokens so
whitespace normalization inside the body does not falsely fail the semantic check.
- Added `testdata/corpus/test_a_broken.pgsql` — a CRLF corpus file with intentionally
broken layout (split-line variables + split-line body statements) used as a formatting
target; `testdata/corpus/test_a.pgsql` is the golden output.
- **Status:** all tests pass; DECLARE section formats correctly.
#### ⬜ Body statement formatter — `pkg/format/body.go` (next step)
Two-phase formatter for the `BEGIN … END` block:
1. **Block-depth tracker** — recognise `BEGIN`/`END`, `IF`/`THEN`/`ELSIF`/`ELSE`/`END IF`,
`LOOP`/`END LOOP`, `FOR`/`END LOOP`, `CASE`/`END CASE`, `EXCEPTION`/`WHEN` to maintain
current indent depth (`depth × 2` spaces).
2. **Split-line join** — within each statement (tokens up to `;` at depth 0), a physical
line whose first non-whitespace token is at column 0 is a broken continuation; join it to
the preceding line with a single space. Exceptions: SQL clause keywords (`FROM`, `WHERE`,
`INTO`, `HAVING`, `GROUP`, `ORDER`, `RETURNING`) stay on their own line.
3. **Base-indent normalisation** — after joining, each logical line is re-emitted at
`depth × 2` spaces, stripping its original leading whitespace and replacing it with the
computed indent. Internal relative indentation of multi-line expressions is preserved.
4. **Blank-line preservation** — blank lines between statements in the original are kept
(they carry author intent about logical grouping).
5. **Verbatim fallback** — any construct the formatter cannot classify cleanly passes
through verbatim, upholding invariant #4.
Acceptance: `pgtidy fmt testdata/corpus/test_a_broken.pgsql` produces output matching
`testdata/corpus/test_a.pgsql` (new golden-file test).
### ✅ Printer + style config — `pkg/format`, `pkg/config` ### ✅ Printer + style config — `pkg/format`, `pkg/config`
- `pkg/config`: `Style` struct + `Default()` = house style (UPPERCASE keywords, lowercase - `pkg/config`: `Style` struct + `Default()` = house style (UPPERCASE keywords, lowercase
types, 2-space indent, leading commas, spacing rules). types, 2-space indent, leading commas, spacing rules).
- `pkg/format`: formats CREATE FUNCTION/PROCEDURE **headers** to house style (params - `pkg/format`: formats CREATE FUNCTION/PROCEDURE **headers** to house style (params
one-per-line leading-comma, option clauses each on own line, AS/`$$` own lines); body and one-per-line leading-comma, option clauses each on own line, AS/`$$` own lines); DECLARE
`Raw` statements emitted verbatim. Spacing engine (`needSpace`, tight ops `:: : -> ->>`) section formatted (see body parser entry); `Raw` statements emitted verbatim.
+ casing (`keywords`/`typeNames` sets). Comment-safety: verbatim fallback if a header Spacing engine (`needSpace`, tight ops `:: : -> ->>`, array `[]`) + casing
carries comments it cannot relocate. (`keywords`/`typeNames` sets). Comment-safety: verbatim fallback if a header carries
- Tests: golden header, idempotence, corpus idempotence + **semantic equivalence**. comments it cannot relocate.
- `pkg/format/body.go`: DECLARE section formatter (see body parser entry).
- Tests: golden header, idempotence, corpus idempotence + **semantic equivalence**
(updated to recurse into dollar-quoted body tokens).
- _Note: not a full Wadler Doc-IR yet — fixed-layout printer. Doc-IR for width-based - _Note: not a full Wadler Doc-IR yet — fixed-layout printer. Doc-IR for width-based
expression wrapping can come when DML structuring lands._ expression wrapping can come when DML structuring lands._