feat(format): add PL/pgSQL body formatting and tests
* Implemented formatting for BEGIN...END blocks in PL/pgSQL. * Added logic to handle indentation and blank lines. * Introduced tests for broken formatting cases.
This commit is contained in:
+204
-4
@@ -8,8 +8,19 @@ import (
|
||||
"github.com/hein/pgtidy/pkg/lexer"
|
||||
)
|
||||
|
||||
// sqlClauseKw: col-0 lines at paren-depth 0 starting with these keywords stay
|
||||
// as separate logical lines rather than being joined to the previous line.
|
||||
var sqlClauseKw = map[string]bool{
|
||||
"from": true, "where": true, "into": true, "having": true,
|
||||
"group": true, "order": true, "returning": true, "set": true,
|
||||
"on": true, "join": true, "left": true, "right": true,
|
||||
"inner": true, "outer": true, "cross": true, "full": true,
|
||||
"union": true, "intersect": true, "except": true,
|
||||
"select": true, "with": true,
|
||||
}
|
||||
|
||||
// formatBody applies house-style formatting to a PL/pgSQL dollar-quoted body
|
||||
// token. Currently only the DECLARE section is formatted; the rest is verbatim.
|
||||
// token.
|
||||
func formatBody(bodyText string, st config.Style) string {
|
||||
open, inner, close, ok := splitDollarQuote(bodyText)
|
||||
if !ok {
|
||||
@@ -97,9 +108,8 @@ func formatBodyInner(inner string, st config.Style) string {
|
||||
// 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:])
|
||||
// Format the BEGIN…END block.
|
||||
b.WriteString(formatBodyStatements(inner[sig[beginIdx].Tok.Off:], st))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -180,3 +190,193 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) {
|
||||
}
|
||||
emit()
|
||||
}
|
||||
|
||||
// bline is one logical line within an accumulated statement.
|
||||
type bline struct {
|
||||
text string // content without leading whitespace
|
||||
indent string // original leading whitespace
|
||||
}
|
||||
|
||||
// formatBodyStatements formats the BEGIN…END block of a PL/pgSQL body.
|
||||
// text must begin at the 'B' of BEGIN and include the final END (with optional ;).
|
||||
//
|
||||
// Algorithm:
|
||||
// 1. Physical lines are collected into logical lines, joining col-0 broken
|
||||
// continuations (the "split-line join" from the spec).
|
||||
// 2. Block depth is tracked via PL/pgSQL structural keywords so each statement
|
||||
// is indented to depth × st.Indent.
|
||||
// 3. After EXCEPTION the formatter switches to verbatim-indent mode (original
|
||||
// leading whitespace is preserved) to avoid conflicts between styles that
|
||||
// put WHEN at col-0 vs indented.
|
||||
// 4. Blank-line counts from the original are preserved.
|
||||
func formatBodyStatements(text string, st config.Style) string {
|
||||
nl := st.Newline
|
||||
normalised := strings.ReplaceAll(text, "\r\n", "\n")
|
||||
rawLines := strings.Split(normalised, "\n")
|
||||
|
||||
var (
|
||||
result strings.Builder
|
||||
stmt []bline
|
||||
parenDepth int
|
||||
blockDepth int // 0=col-0 (BEGIN/END/EXCEPTION), 1=body, 2=nested…
|
||||
inException bool
|
||||
pendingBlanks int
|
||||
depthInc bool // increment blockDepth after next flush
|
||||
)
|
||||
|
||||
flush := func() {
|
||||
if len(stmt) == 0 {
|
||||
return
|
||||
}
|
||||
for i := 0; i < pendingBlanks; i++ {
|
||||
result.WriteString(nl)
|
||||
}
|
||||
pendingBlanks = 0
|
||||
|
||||
fw := lowerASCII(firstBodyKeyword(stmt[0].text))
|
||||
|
||||
if inException {
|
||||
// Verbatim-indent mode: preserve original leading whitespace.
|
||||
for _, ll := range stmt {
|
||||
result.WriteString(ll.indent)
|
||||
result.WriteString(ll.text)
|
||||
result.WriteString(nl)
|
||||
}
|
||||
stmt = nil
|
||||
if depthInc {
|
||||
blockDepth++
|
||||
depthInc = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
effectiveDepth := blockDepth
|
||||
switch fw {
|
||||
case "end":
|
||||
blockDepth--
|
||||
if blockDepth < 0 {
|
||||
blockDepth = 0
|
||||
}
|
||||
effectiveDepth = blockDepth
|
||||
case "else", "elsif", "elseif":
|
||||
// Emit at one level up; blockDepth unchanged so the body continues
|
||||
// at the same depth (THEN will re-apply depthInc for elsif).
|
||||
if blockDepth > 0 {
|
||||
effectiveDepth = blockDepth - 1
|
||||
}
|
||||
case "exception":
|
||||
inException = true
|
||||
effectiveDepth = 0
|
||||
}
|
||||
|
||||
baseIndent := strings.Repeat(st.Indent, effectiveDepth)
|
||||
|
||||
for i, ll := range stmt {
|
||||
if i == 0 || ll.indent == "" {
|
||||
result.WriteString(baseIndent)
|
||||
} else {
|
||||
result.WriteString(ll.indent)
|
||||
}
|
||||
result.WriteString(ll.text)
|
||||
result.WriteString(nl)
|
||||
}
|
||||
|
||||
if depthInc {
|
||||
blockDepth++
|
||||
depthInc = false
|
||||
}
|
||||
stmt = nil
|
||||
}
|
||||
|
||||
for _, rawLine := range rawLines {
|
||||
line := strings.TrimRight(rawLine, "\r")
|
||||
indent := leadingWhitespace(line)
|
||||
stripped := line[len(indent):]
|
||||
|
||||
if stripped == "" {
|
||||
flush()
|
||||
pendingBlanks++
|
||||
continue
|
||||
}
|
||||
|
||||
fw := lowerASCII(firstBodyKeyword(stripped))
|
||||
isColZero := indent == ""
|
||||
// Only join when we are at paren-depth 0; content inside parens (e.g.
|
||||
// inside a CTE subquery) should not be merged across lines.
|
||||
joinToPrev := isColZero && parenDepth == 0 && len(stmt) > 0 && !sqlClauseKw[fw]
|
||||
|
||||
if joinToPrev {
|
||||
last := &stmt[len(stmt)-1]
|
||||
last.text = strings.TrimRight(last.text, " \t") + " " + stripped
|
||||
} else {
|
||||
stmt = append(stmt, bline{text: stripped, indent: indent})
|
||||
}
|
||||
|
||||
// Scan tokens to track paren depth and detect flush triggers.
|
||||
var lastD0Kw string
|
||||
for _, tok := range lexer.Lex(stripped) {
|
||||
if tok.IsTrivia() || tok.Kind == lexer.EOF {
|
||||
continue
|
||||
}
|
||||
switch tok.Kind {
|
||||
case lexer.LParen, lexer.LBracket:
|
||||
parenDepth++
|
||||
case lexer.RParen, lexer.RBracket:
|
||||
if parenDepth > 0 {
|
||||
parenDepth--
|
||||
}
|
||||
case lexer.Semicolon:
|
||||
if parenDepth == 0 {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
if parenDepth == 0 && tok.Kind == lexer.Ident {
|
||||
lastD0Kw = lowerASCII(tok.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// Structural keywords at the end of a line (paren depth 0) trigger a
|
||||
// flush and possibly a block-depth change.
|
||||
if parenDepth == 0 && len(stmt) > 0 {
|
||||
switch lastD0Kw {
|
||||
case "then", "loop", "begin":
|
||||
// ELSIF/ELSEIF headers end with THEN but must NOT increment depth
|
||||
// (blockDepth is already at the right level for the body).
|
||||
fw0 := lowerASCII(firstBodyKeyword(stmt[0].text))
|
||||
if fw0 != "elsif" && fw0 != "elseif" {
|
||||
depthInc = true
|
||||
}
|
||||
flush()
|
||||
case "else", "exception":
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flush()
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// firstBodyKeyword returns the text of the first identifier token in s
|
||||
// (lowercased), or "" if the first significant token is not an identifier.
|
||||
func firstBodyKeyword(s string) string {
|
||||
for _, tok := range lexer.Lex(s) {
|
||||
if tok.IsTrivia() || tok.Kind == lexer.EOF {
|
||||
continue
|
||||
}
|
||||
if tok.Kind == lexer.Ident {
|
||||
return tok.Text
|
||||
}
|
||||
return "" // first significant token is non-ident
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// leadingWhitespace returns the leading space/tab prefix of s.
|
||||
func leadingWhitespace(s string) string {
|
||||
i := 0
|
||||
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
|
||||
i++
|
||||
}
|
||||
return s[:i]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user