Files
PgTidy/pkg/format/body.go
T
warkanum e88d32f281
CI / Test (push) Failing after 47s
CI / Build snapshot (push) Has been skipped
feat: add LSP server, VSCode + DataGrip extensions, release infra, autofix
- pkg/lsp: JSON-RPC 2.0 LSP server (formatting, diagnostics, codeAction quick-fixes)
- cmd/pgtidy: lsp and config subcommands
- pkg/diagnostics: TextFix struct for byte-range autofixes
- pkg/lint: MIG001/MIG003 autofixes, ApplyFixes helper, --fix flag on lint command
- editors/vscode: TypeScript extension with LanguageClient, showVersion/showConfig/formatDocument commands, logo
- editors/datagrip: Gradle JetBrains plugin via LSP4IJ, pluginIcon
- .goreleaser.yaml, .github/workflows: CI + release pipeline
- Makefile: snapshot, release, vscode-compile, vscode-package targets
- go.mod + all imports: module path updated to git.warky.dev/wdevs/pgtidy
- assets: logo files (256px, 128px, 1024px, ico)
2026-06-28 12:48:28 +02:00

383 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package format
import (
"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).
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)
// 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 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()
}
// 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]
}