- Only restyle bodies for plpgsql/sql; skip pl* (plpython3u, plperl, ...), c and internal - Keep LANGUAGE clause on DO blocks - Carry multi-line string and dollar-quoted literals verbatim in bodies and DML - Format dollar-quoted literals that contain code (declare/begin/select/...), never format() templates - Fix code joined onto -- comments, early flush after raise exception, dropped comment before BEGIN - Regenerate test_mm_proc golden; document in README, plan, todo
1019 lines
28 KiB
Go
1019 lines
28 KiB
Go
package format
|
||
|
||
import (
|
||
"git.warky.dev/wdevs/pgtidy/pkg/parser"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
|
||
"git.warky.dev/wdevs/pgtidy/pkg/config"
|
||
"git.warky.dev/wdevs/pgtidy/pkg/cst"
|
||
"git.warky.dev/wdevs/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.
|
||
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).
|
||
// Normalize CRLF in trivia so the output always uses st.Newline.
|
||
for i := 0; i <= declareIdx; i++ {
|
||
t := sig[i]
|
||
for _, tr := range t.Lead {
|
||
b.WriteString(strings.ReplaceAll(tr.Text, "\r\n", nl))
|
||
}
|
||
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)
|
||
|
||
// Comments between the last declaration and BEGIN live in BEGIN's leading
|
||
// trivia; keep them, one per line, at declaration indent.
|
||
for _, tr := range sig[beginIdx].Lead {
|
||
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
|
||
b.WriteString(st.Indent)
|
||
b.WriteString(strings.TrimRight(strings.ReplaceAll(tr.Text, "\r", ""), " \t"))
|
||
b.WriteString(nl)
|
||
}
|
||
}
|
||
|
||
// Format the BEGIN…END block.
|
||
b.WriteString(formatBodyStatements(inner[sig[beginIdx].Tok.Off:], st))
|
||
|
||
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 decls [][]cst.Tok
|
||
var cur []cst.Tok
|
||
var preCommentSets [][]string
|
||
var curPreComments []string
|
||
|
||
collect := func() {
|
||
if len(cur) == 0 {
|
||
return
|
||
}
|
||
decls = append(decls, cur)
|
||
preCommentSets = append(preCommentSets, curPreComments)
|
||
cur = nil
|
||
curPreComments = nil
|
||
}
|
||
|
||
for _, t := range toks {
|
||
if len(cur) == 0 {
|
||
for _, tr := range t.Lead {
|
||
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
|
||
text := strings.TrimRight(strings.ReplaceAll(tr.Text, "\r", ""), " \t")
|
||
curPreComments = append(curPreComments, text)
|
||
}
|
||
}
|
||
}
|
||
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 {
|
||
collect()
|
||
}
|
||
}
|
||
collect()
|
||
|
||
// Compute alignment widths when requested.
|
||
var nameColW, typeColW int
|
||
if st.PlpgsqlDeclareAlignType || st.PlpgsqlDeclareAlignEq {
|
||
for _, decl := range decls {
|
||
if anyComment(decl[1:]) {
|
||
continue
|
||
}
|
||
body := decl
|
||
if len(body) > 0 && body[len(body)-1].Tok.Kind == lexer.Semicolon {
|
||
body = body[:len(body)-1]
|
||
}
|
||
nw, tw := declareNameTypeWidth(body, st)
|
||
if nw > nameColW {
|
||
nameColW = nw
|
||
}
|
||
// 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
|
||
}
|
||
}
|
||
}
|
||
|
||
for i, cur := range decls {
|
||
for _, c := range preCommentSets[i] {
|
||
b.WriteString(indent)
|
||
b.WriteString(c)
|
||
b.WriteString(nl)
|
||
}
|
||
// Graceful degradation: mid-declaration comments stay verbatim.
|
||
if anyComment(cur[1:]) {
|
||
b.WriteString(indent)
|
||
b.WriteString(verbatimSpan(cur))
|
||
b.WriteString(nl)
|
||
continue
|
||
}
|
||
|
||
body := cur
|
||
hasSemi := len(body) > 0 && body[len(body)-1].Tok.Kind == lexer.Semicolon
|
||
if hasSemi {
|
||
body = body[:len(body)-1]
|
||
}
|
||
b.WriteString(indent)
|
||
if (st.PlpgsqlDeclareAlignType || st.PlpgsqlDeclareAlignEq) && nameColW > 0 {
|
||
writeDeclareAligned(b, body, st, nameColW, typeColW)
|
||
} else {
|
||
for j, t := range body {
|
||
if j > 0 && needSpace(body[j-1].Tok, t.Tok) && !isPctTypeBoundary(body, j) {
|
||
b.WriteByte(' ')
|
||
}
|
||
b.WriteString(caseText(t.Tok, st))
|
||
}
|
||
}
|
||
if hasSemi {
|
||
b.WriteString(";")
|
||
}
|
||
b.WriteString(nl)
|
||
}
|
||
}
|
||
|
||
// 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]].
|
||
func declareNameTypeWidth(body []cst.Tok, st config.Style) (nameW, typeW int) {
|
||
if len(body) < 2 {
|
||
return 0, 0
|
||
}
|
||
// name is always the first token.
|
||
name := caseText(body[0].Tok, st)
|
||
nameW = len(name)
|
||
|
||
// type runs from body[1] until we hit := / DEFAULT / = at depth 0.
|
||
var typeTokens []cst.Tok
|
||
depth := 0
|
||
for _, t := range body[1:] {
|
||
switch t.Tok.Kind {
|
||
case lexer.LParen, lexer.LBracket:
|
||
depth++
|
||
case lexer.RParen, lexer.RBracket:
|
||
if depth > 0 {
|
||
depth--
|
||
}
|
||
}
|
||
if depth == 0 {
|
||
low := lowerASCII(t.Tok.Text)
|
||
if t.Tok.Kind == lexer.Operator && (t.Tok.Text == ":=" || t.Tok.Text == "=") {
|
||
break
|
||
}
|
||
if t.Tok.Kind == lexer.Ident && low == "default" {
|
||
break
|
||
}
|
||
}
|
||
typeTokens = append(typeTokens, t)
|
||
}
|
||
var tb strings.Builder
|
||
for j, t := range typeTokens {
|
||
if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) && !isPctTypeBoundary(typeTokens, j) {
|
||
tb.WriteByte(' ')
|
||
}
|
||
tb.WriteString(caseText(t.Tok, st))
|
||
}
|
||
typeW = len(tb.String())
|
||
return nameW, typeW
|
||
}
|
||
|
||
// writeDeclareAligned writes a single DECLARE variable with aligned columns.
|
||
func writeDeclareAligned(b *strings.Builder, body []cst.Tok, st config.Style, nameColW, typeColW int) {
|
||
if len(body) == 0 {
|
||
return
|
||
}
|
||
name := caseText(body[0].Tok, st)
|
||
b.WriteString(name)
|
||
if len(body) == 1 {
|
||
return
|
||
}
|
||
|
||
// Pad name to nameColW if align_type is requested.
|
||
if st.PlpgsqlDeclareAlignType {
|
||
pad := nameColW - len(name)
|
||
for k := 0; k < pad; k++ {
|
||
b.WriteByte(' ')
|
||
}
|
||
}
|
||
|
||
// Collect type tokens.
|
||
var typeTokens, restTokens []cst.Tok
|
||
depth := 0
|
||
pastType := false
|
||
for _, t := range body[1:] {
|
||
switch t.Tok.Kind {
|
||
case lexer.LParen, lexer.LBracket:
|
||
depth++
|
||
case lexer.RParen, lexer.RBracket:
|
||
if depth > 0 {
|
||
depth--
|
||
}
|
||
}
|
||
if !pastType && depth == 0 {
|
||
low := lowerASCII(t.Tok.Text)
|
||
if (t.Tok.Kind == lexer.Operator && (t.Tok.Text == ":=" || t.Tok.Text == "=")) ||
|
||
(t.Tok.Kind == lexer.Ident && low == "default") {
|
||
pastType = true
|
||
restTokens = append(restTokens, t)
|
||
continue
|
||
}
|
||
}
|
||
if pastType {
|
||
restTokens = append(restTokens, t)
|
||
} else {
|
||
typeTokens = append(typeTokens, t)
|
||
}
|
||
}
|
||
|
||
var typeStr strings.Builder
|
||
for j, t := range typeTokens {
|
||
if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) && !isPctTypeBoundary(typeTokens, j) {
|
||
typeStr.WriteByte(' ')
|
||
}
|
||
typeStr.WriteString(caseText(t.Tok, st))
|
||
}
|
||
typeRendered := typeStr.String()
|
||
|
||
b.WriteByte(' ')
|
||
b.WriteString(typeRendered)
|
||
|
||
if len(restTokens) > 0 {
|
||
// Pad type to typeColW if align_eq is requested.
|
||
if st.PlpgsqlDeclareAlignEq {
|
||
pad := typeColW - len(typeRendered)
|
||
for k := 0; k < pad; k++ {
|
||
b.WriteByte(' ')
|
||
}
|
||
}
|
||
for j, t := range restTokens {
|
||
prev := restTokens[0].Tok
|
||
if j > 0 {
|
||
prev = restTokens[j-1].Tok
|
||
}
|
||
if j == 0 || needSpace(prev, t.Tok) {
|
||
b.WriteByte(' ')
|
||
}
|
||
b.WriteString(caseText(t.Tok, st))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 (capped by PlpgsqlMaxBlankLines).
|
||
func formatBodyStatements(text string, st config.Style) string {
|
||
nl := st.Newline
|
||
text = formatEmbeddedLiterals(text, st)
|
||
normalised := strings.ReplaceAll(text, "\r\n", "\n")
|
||
rawLines := strings.Split(normalised, "\n")
|
||
|
||
// Mark the continuation lines of every multi-line token whose interior is
|
||
// not code: /* … */ block comments and every kind of string literal,
|
||
// including dollar-quoted ones (a dollar quote is just another way of
|
||
// quoting a string, whatever its tag, and its content may be any language).
|
||
// Those lines are token content, not statements: they must be carried
|
||
// verbatim with the token's opening line, never split off and reindented.
|
||
// On the line where such a token ends, tail holds the code that follows it
|
||
// so paren depth and statement terminators are still tracked.
|
||
inVerbatim := make([]bool, len(rawLines))
|
||
tail := make([]string, len(rawLines))
|
||
tailCol := make([]int, len(rawLines)) // column where tail[i] starts
|
||
cut := make([]int, len(rawLines)) // column where a multi-line token opens on this line, or -1
|
||
for i := range cut {
|
||
cut[i] = -1
|
||
}
|
||
lineStart := make([]int, len(rawLines))
|
||
for i, off := 0, 0; i < len(rawLines); i++ {
|
||
lineStart[i] = off
|
||
off += len(rawLines[i]) + 1
|
||
}
|
||
lineOf := func(off int) int {
|
||
return sort.Search(len(lineStart), func(i int) bool { return lineStart[i] > off }) - 1
|
||
}
|
||
for _, t := range lexer.Lex(normalised) {
|
||
switch t.Kind {
|
||
case lexer.BlockComment, lexer.String, lexer.EscapeString, lexer.BitString, lexer.DollarString:
|
||
default:
|
||
continue
|
||
}
|
||
n := strings.Count(t.Text, "\n")
|
||
if n == 0 || !literalTerminated(t) {
|
||
continue
|
||
}
|
||
start := lineOf(t.Off)
|
||
if cut[start] < 0 {
|
||
cut[start] = t.Off - lineStart[start]
|
||
}
|
||
for k := 1; k <= n && start+k < len(inVerbatim); k++ {
|
||
inVerbatim[start+k] = true
|
||
}
|
||
if start+n < len(tail) {
|
||
rest := normalised[t.Off+len(t.Text):]
|
||
if i := strings.IndexByte(rest, '\n'); i >= 0 {
|
||
rest = rest[:i]
|
||
}
|
||
tail[start+n] = rest
|
||
tailCol[start+n] = t.Off + len(t.Text) - lineStart[start+n]
|
||
}
|
||
}
|
||
|
||
maxBlanks := st.PlpgsqlMaxBlankLines
|
||
if maxBlanks < 0 {
|
||
maxBlanks = 0
|
||
}
|
||
|
||
var (
|
||
result strings.Builder
|
||
stmt []bline
|
||
parenDepth int
|
||
blockDepth int // 0=col-0 (BEGIN/END/EXCEPTION), 1=body, 2=nested…
|
||
caseDepth int // depth of open CASE…END expressions (WHEN…THEN is not a block opener)
|
||
inException bool
|
||
pendingBlanks int
|
||
depthInc bool // increment blockDepth after next flush
|
||
)
|
||
|
||
flush := func() {
|
||
if len(stmt) == 0 {
|
||
return
|
||
}
|
||
blanks := pendingBlanks
|
||
if blanks > maxBlanks {
|
||
blanks = maxBlanks
|
||
}
|
||
for i := 0; i < blanks; 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":
|
||
if blockDepth > 0 {
|
||
effectiveDepth = blockDepth - 1
|
||
}
|
||
case "exception":
|
||
inException = true
|
||
// 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)
|
||
|
||
// plpgsql_if_then_newline: when false, THEN stays on the same line as
|
||
// the condition. When true (default) it's already on its own logical line.
|
||
stmtLines := stmt
|
||
if !st.PlpgsqlIfThenNewline && fw == "if" {
|
||
stmtLines = joinThenToCondition(stmt)
|
||
}
|
||
|
||
formattedLines := formatBodyStmtLines(stmtLines, baseIndent, st)
|
||
for _, line := range formattedLines {
|
||
result.WriteString(line)
|
||
result.WriteString(nl)
|
||
}
|
||
|
||
if depthInc {
|
||
blockDepth++
|
||
depthInc = false
|
||
}
|
||
stmt = nil
|
||
}
|
||
|
||
scanLine := func(text string, openLit bool) {
|
||
var lastD0Kw string
|
||
for _, tok := range lexer.Lex(text) {
|
||
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 {
|
||
// plpgsql_loop_collapse: fold empty FOR … LOOP END LOOP; to one line.
|
||
if st.PlpgsqlLoopCollapse && len(stmt) > 0 {
|
||
collapsed, ok := tryCollapseLoop(stmt, st)
|
||
if ok {
|
||
stmt = []bline{{text: collapsed, indent: ""}}
|
||
}
|
||
}
|
||
flush()
|
||
}
|
||
}
|
||
if parenDepth == 0 && tok.Kind == lexer.Ident {
|
||
lastD0Kw = lowerASCII(tok.Text)
|
||
switch lastD0Kw {
|
||
case "case":
|
||
caseDepth++
|
||
case "end":
|
||
if caseDepth > 0 {
|
||
caseDepth--
|
||
}
|
||
}
|
||
} else if parenDepth == 0 {
|
||
// A non-keyword token (string, operator, …) ends the run of
|
||
// keywords, so a line like `raise exception 'x'` does not end
|
||
// in the keyword EXCEPTION.
|
||
lastD0Kw = ""
|
||
}
|
||
}
|
||
|
||
if openLit {
|
||
lastD0Kw = "" // the line ends inside a literal, not on a keyword
|
||
}
|
||
if parenDepth == 0 && len(stmt) > 0 {
|
||
switch lastD0Kw {
|
||
case "then":
|
||
// A THEN ending a CASE…WHEN branch is not a PL/pgSQL block
|
||
// opener; only one matching END closes the whole CASE, so
|
||
// treating each WHEN…THEN as a block open would permanently
|
||
// inflate blockDepth.
|
||
if caseDepth == 0 {
|
||
fw0 := lowerASCII(firstBodyKeyword(stmt[0].text))
|
||
if fw0 != "elsif" && fw0 != "elseif" {
|
||
depthInc = true
|
||
}
|
||
flush()
|
||
}
|
||
case "loop", "begin":
|
||
fw0 := lowerASCII(firstBodyKeyword(stmt[0].text))
|
||
if fw0 != "elsif" && fw0 != "elseif" {
|
||
depthInc = true
|
||
}
|
||
flush()
|
||
case "else", "exception":
|
||
if caseDepth > 0 {
|
||
break
|
||
}
|
||
flush()
|
||
}
|
||
}
|
||
}
|
||
|
||
for j, rawLine := range rawLines {
|
||
line := strings.TrimRight(rawLine, "\r")
|
||
|
||
if inVerbatim[j] {
|
||
// Verbatim continuation of a multi-line comment or literal: glue it
|
||
// to the bline holding the token's opening line.
|
||
if len(stmt) > 0 {
|
||
last := &stmt[len(stmt)-1]
|
||
last.text += "\n" + line
|
||
} else {
|
||
stmt = append(stmt, bline{text: line})
|
||
}
|
||
if tail[j] != "" {
|
||
// A second literal may open later on this same line; scan only
|
||
// the code between the two.
|
||
tt, openLit := tail[j], false
|
||
if c := cut[j] - tailCol[j]; cut[j] >= 0 && c < len(tt) {
|
||
if c < 0 {
|
||
c = 0
|
||
}
|
||
tt, openLit = tt[:c], true
|
||
}
|
||
scanLine(tt, openLit)
|
||
}
|
||
continue
|
||
}
|
||
|
||
indent := leadingWhitespace(line)
|
||
stripped := line[len(indent):]
|
||
|
||
if stripped == "" {
|
||
flush()
|
||
pendingBlanks++
|
||
continue
|
||
}
|
||
|
||
fw := lowerASCII(firstBodyKeyword(stripped))
|
||
isColZero := indent == ""
|
||
joinToPrev := isColZero && parenDepth == 0 && len(stmt) > 0 && !sqlClauseKw[fw]
|
||
// A col-0 comment-only line is its own thing: never glue it onto the
|
||
// previous line — doing so buries a code line's trailing text in a
|
||
// comment and collapses consecutive -- comment lines into one.
|
||
if joinToPrev && len(significantBodyTokens(stripped)) == 0 {
|
||
joinToPrev = false
|
||
}
|
||
// Don't join a col-0 continuation to a comment-only preceding bline:
|
||
// the comment has no structural keyword so `continue ;` at col-0 would
|
||
// disappear into the comment text and be invisible to the lexer.
|
||
if joinToPrev {
|
||
if lowerASCII(firstBodyKeyword(stmt[len(stmt)-1].text)) == "" {
|
||
joinToPrev = false
|
||
}
|
||
}
|
||
// Pre-flush pending comment-only blines before adding a new non-comment
|
||
// non-joined bline. Without this, a comment + `end if;` end up in the
|
||
// same stmt, `fw` comes from the comment (empty string), depth is never
|
||
// decremented, and the formatter diverges on the second pass.
|
||
if !joinToPrev && fw != "" && len(stmt) > 0 {
|
||
allComments := true
|
||
for _, ll := range stmt {
|
||
if lowerASCII(firstBodyKeyword(ll.text)) != "" {
|
||
allComments = false
|
||
break
|
||
}
|
||
}
|
||
if allComments {
|
||
flush()
|
||
}
|
||
}
|
||
|
||
// Likewise never join onto a line that ends in a -- comment: the joined
|
||
// code would become part of the comment text.
|
||
if joinToPrev && endsInLineComment(stmt[len(stmt)-1].text) {
|
||
joinToPrev = false
|
||
}
|
||
|
||
if joinToPrev {
|
||
last := &stmt[len(stmt)-1]
|
||
last.text = strings.TrimRight(last.text, " \t") + " " + stripped
|
||
} else {
|
||
stmt = append(stmt, bline{text: stripped, indent: indent})
|
||
}
|
||
|
||
// A multi-line literal that opens on this line is scanned only up to
|
||
// its opening quote; the rest is token content, not code.
|
||
scanText, openLit := stripped, false
|
||
if c := cut[j] - len(indent); cut[j] >= 0 && c < len(scanText) {
|
||
if c < 0 {
|
||
c = 0
|
||
}
|
||
scanText, openLit = scanText[:c], true
|
||
}
|
||
scanLine(scanText, openLit)
|
||
}
|
||
|
||
flush()
|
||
return result.String()
|
||
}
|
||
|
||
// formatBodyStmtLines formats one flushed PL/pgSQL statement at its contextual
|
||
// base indent. Multi-line UPDATE/DELETE statements inside PL/pgSQL get their
|
||
// top-level SET/WHERE/AND/OR clauses realigned under the statement while nested
|
||
// subqueries keep their original indentation. Non-DML statements keep
|
||
// continuation indentation, except that standalone structural keywords such as
|
||
// THEN are aligned with the block opener.
|
||
func formatBodyStmtLines(lines []bline, baseIndent string, st config.Style) []string {
|
||
if len(lines) == 0 {
|
||
return nil
|
||
}
|
||
|
||
if looksLikeMultiLineBodyDML(lines) {
|
||
return reindentBodyDML(lines, baseIndent, st)
|
||
}
|
||
|
||
out := make([]string, 0, len(lines))
|
||
for i, ll := range lines {
|
||
text := ll.text
|
||
indent := baseIndent
|
||
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)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func looksLikeMultiLineBodyDML(lines []bline) bool {
|
||
if len(lines) < 2 {
|
||
return false
|
||
}
|
||
kw := lowerASCII(firstBodyKeyword(lines[0].text))
|
||
return kw == "update" || kw == "delete"
|
||
}
|
||
|
||
func reindentBodyDML(lines []bline, baseIndent string, st config.Style) []string {
|
||
out := make([]string, 0, len(lines)+1)
|
||
afterWhere := false
|
||
parenDepth := 0
|
||
for i, ll := range lines {
|
||
text := strings.TrimRight(ll.text, " ")
|
||
lineDepth := parenDepth
|
||
kw := lowerASCII(firstBodyKeyword(text))
|
||
if afterWhere && lineDepth == 0 && kw != "and" && kw != "or" {
|
||
out = append(out, baseIndent+st.Indent+st.Indent+strings.TrimSpace(text))
|
||
afterWhere = false
|
||
updateBodyParenDepth(text, &parenDepth)
|
||
continue
|
||
}
|
||
if lineDepth == 0 && (kw == "set" || kw == "where" || kw == "values" || kw == "returning") {
|
||
if kw == "where" {
|
||
whereText := strings.TrimSpace(text)
|
||
fields := strings.Fields(whereText)
|
||
nextKw := ""
|
||
if i+1 < len(lines) {
|
||
nextKw = lowerASCII(firstBodyKeyword(lines[i+1].text))
|
||
}
|
||
if len(fields) > 1 && (nextKw == "and" || nextKw == "or") {
|
||
out = append(out, baseIndent+fields[0])
|
||
out = append(out, baseIndent+st.Indent+st.Indent+strings.TrimSpace(whereText[len(fields[0]):]))
|
||
afterWhere = false
|
||
continue
|
||
}
|
||
afterWhere = len(fields) == 1
|
||
}
|
||
out = append(out, baseIndent+strings.TrimSpace(text))
|
||
continue
|
||
}
|
||
if lineDepth == 0 && (kw == "and" || kw == "or") {
|
||
// 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
|
||
}
|
||
if i == 0 {
|
||
out = append(out, baseIndent+strings.TrimSpace(text))
|
||
} else if ll.indent != "" {
|
||
out = append(out, ll.indent+strings.TrimSpace(text))
|
||
} else {
|
||
out = append(out, baseIndent+strings.TrimSpace(text))
|
||
}
|
||
afterWhere = false
|
||
updateBodyParenDepth(text, &parenDepth)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func updateBodyParenDepth(s string, depth *int) {
|
||
for _, tok := range lexer.Lex(s) {
|
||
switch tok.Kind {
|
||
case lexer.LParen, lexer.LBracket:
|
||
(*depth)++
|
||
case lexer.RParen, lexer.RBracket:
|
||
if *depth > 0 {
|
||
(*depth)--
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func significantBodyTokens(s string) []cst.Tok {
|
||
var toks []cst.Tok
|
||
for _, tok := range lexer.Lex(s) {
|
||
if tok.IsTrivia() || tok.Kind == lexer.EOF {
|
||
continue
|
||
}
|
||
toks = append(toks, cst.Tok{Tok: tok})
|
||
}
|
||
return toks
|
||
}
|
||
|
||
func isStandaloneBodyKeyword(s string, kws ...string) bool {
|
||
toks := significantBodyTokens(s)
|
||
if len(toks) != 1 || toks[0].Tok.Kind != lexer.Ident {
|
||
return false
|
||
}
|
||
low := lowerASCII(toks[0].Tok.Text)
|
||
for _, kw := range kws {
|
||
if low == kw {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// joinThenToCondition merges a THEN line (on its own bline) into the preceding
|
||
// condition line when plpgsql_if_then_newline is false.
|
||
func joinThenToCondition(lines []bline) []bline {
|
||
out := make([]bline, 0, len(lines))
|
||
for i, ll := range lines {
|
||
if i > 0 && strings.EqualFold(strings.TrimSpace(ll.text), "then") {
|
||
out[len(out)-1].text = strings.TrimRight(out[len(out)-1].text, " \t") + " THEN"
|
||
} else {
|
||
out = append(out, ll)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// tryCollapseLoop tries to collapse an empty loop body to one line.
|
||
// Detects: FOR … LOOP\n (empty or only blanks)\nEND LOOP;
|
||
// Returns the collapsed line and true on success.
|
||
func tryCollapseLoop(lines []bline, st config.Style) (string, bool) {
|
||
if len(lines) < 2 {
|
||
return "", false
|
||
}
|
||
first := strings.TrimSpace(lines[0].text)
|
||
last := strings.TrimSpace(lines[len(lines)-1].text)
|
||
firstLow := lowerASCII(first)
|
||
lastLow := lowerASCII(last)
|
||
|
||
// Check last line is END LOOP; or LOOP (for WHILE/FOR empty bodies that end with LOOP).
|
||
if !strings.HasPrefix(lastLow, "end loop") && lastLow != "end loop;" {
|
||
return "", false
|
||
}
|
||
// Check middle lines are all empty.
|
||
for _, mid := range lines[1 : len(lines)-1] {
|
||
if strings.TrimSpace(mid.text) != "" {
|
||
return "", false
|
||
}
|
||
}
|
||
// Check first line ends with LOOP.
|
||
if !strings.HasSuffix(firstLow, "loop") {
|
||
return "", false
|
||
}
|
||
_ = st
|
||
_ = firstLow
|
||
// Collapse to: <header> END LOOP;
|
||
return strings.TrimRight(first, " \t") + " " + strings.ToUpper(last), true
|
||
}
|
||
|
||
// 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]
|
||
}
|
||
|
||
// endsInLineComment reports whether s ends with a -- line comment.
|
||
func endsInLineComment(s string) bool {
|
||
toks := lexer.Lex(s)
|
||
for i := len(toks) - 1; i >= 0; i-- {
|
||
switch toks[i].Kind {
|
||
case lexer.EOF, lexer.Whitespace:
|
||
continue
|
||
}
|
||
return toks[i].Kind == lexer.LineComment
|
||
}
|
||
return false
|
||
}
|
||
|
||
// literalTerminated reports whether a string or block-comment token is closed.
|
||
// Slices of nested dollar-quoted text can end mid-literal; an unterminated
|
||
// token there is a lexing artefact, not real literal content.
|
||
func literalTerminated(t lexer.Token) bool {
|
||
if t.Kind == lexer.BlockComment {
|
||
return strings.HasSuffix(t.Text, "*/") && len(t.Text) >= 4
|
||
}
|
||
if t.Kind == lexer.DollarString {
|
||
_, _, _, ok := splitDollarQuote(t.Text)
|
||
return ok
|
||
}
|
||
return len(t.Text) >= 2 && strings.HasSuffix(t.Text, "'")
|
||
}
|
||
|
||
// formatEmbeddedLiterals restyles every dollar-quoted literal in text whose
|
||
// content looks like code (see formatEmbedded). Everything else is untouched.
|
||
func formatEmbeddedLiterals(text string, st config.Style) string {
|
||
if !strings.Contains(text, "$") {
|
||
return text
|
||
}
|
||
var b strings.Builder
|
||
for _, t := range lexer.Lex(text) {
|
||
if t.Kind == lexer.DollarString {
|
||
b.WriteString(formatEmbedded(t.Text, st))
|
||
} else {
|
||
b.WriteString(t.Text)
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// formatEmbedded formats a dollar-quoted literal as code when its content
|
||
// starts like code: DECLARE/BEGIN is formatted as a PL/pgSQL block and
|
||
// SELECT/INSERT/UPDATE/DELETE/WITH as DML. Any other content (SQL fragments,
|
||
// prose, other languages) is left exactly as written, as is the literal if the
|
||
// formatted result is not provably equivalent.
|
||
func formatEmbedded(lit string, st config.Style) string {
|
||
open, inner, close, ok := splitDollarQuote(lit)
|
||
if !ok {
|
||
return lit
|
||
}
|
||
// format()-style templates (%s, %I, %L, %1$s) are text, not SQL: restyling
|
||
// would split the placeholders, and the token-level safety gate can't see it.
|
||
if formatPlaceholder.MatchString(inner) {
|
||
return lit
|
||
}
|
||
var first string
|
||
for _, t := range lexer.Lex(inner) {
|
||
if t.IsTrivia() || t.Kind == lexer.EOF {
|
||
continue
|
||
}
|
||
if t.Kind == lexer.Ident {
|
||
first = lowerASCII(t.Text)
|
||
}
|
||
break
|
||
}
|
||
var res string
|
||
switch first {
|
||
case "declare", "begin":
|
||
res = formatBodyInner(inner, st)
|
||
case "select", "insert", "update", "delete", "with":
|
||
lead := inner[:len(inner)-len(strings.TrimLeft(inner, " \t\r\n"))]
|
||
trail := inner[len(strings.TrimRight(inner, " \t\r\n")):]
|
||
res = lead + strings.TrimSpace(File(parser.Parse(inner), st)) + trail
|
||
default:
|
||
return lit
|
||
}
|
||
if res == inner || !SemanticallyEqual(inner, res) || CommentsPreserved(inner, res) != nil {
|
||
return lit
|
||
}
|
||
return open + res + close
|
||
}
|
||
|
||
var formatPlaceholder = regexp.MustCompile(`%(\d+\$)?-?\d*[sILlx]`)
|