- 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)
383 lines
9.4 KiB
Go
383 lines
9.4 KiB
Go
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]
|
||
}
|