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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user