feat(format): skip non-plpgsql bodies; keep literals and embedded code safe
- 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
This commit is contained in:
+249
-74
@@ -1,6 +1,9 @@
|
||||
package format
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/parser"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/config"
|
||||
@@ -109,6 +112,16 @@ func formatBodyInner(inner string, st config.Style) string {
|
||||
// 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))
|
||||
|
||||
@@ -395,22 +408,57 @@ type bline struct {
|
||||
// 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 /* … */ block comment.
|
||||
// Those lines are comment content, not code: they must be carried verbatim
|
||||
// with the comment's opening line, never split off and reindented as if
|
||||
// they were statements of their own.
|
||||
inBlockComment := make([]bool, len(rawLines))
|
||||
// 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) {
|
||||
if t.Kind != lexer.BlockComment {
|
||||
switch t.Kind {
|
||||
case lexer.BlockComment, lexer.String, lexer.EscapeString, lexer.BitString, lexer.DollarString:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
n := strings.Count(t.Text, "\n")
|
||||
start := t.Line - 1 // lexer Line is 1-based within normalised
|
||||
for k := 1; k <= n && start+k < len(inBlockComment); k++ {
|
||||
inBlockComment[start+k] = true
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,18 +552,105 @@ func formatBodyStatements(text string, st config.Style) string {
|
||||
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 inBlockComment[j] {
|
||||
// Verbatim continuation of a multi-line block comment: glue it to the
|
||||
// bline holding the comment's opening line.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -562,6 +697,12 @@ func formatBodyStatements(text string, st config.Style) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -569,70 +710,16 @@ func formatBodyStatements(text string, st config.Style) string {
|
||||
stmt = append(stmt, bline{text: stripped, indent: indent})
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
// 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()
|
||||
@@ -841,3 +928,91 @@ func leadingWhitespace(s string) string {
|
||||
}
|
||||
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]`)
|
||||
|
||||
Reference in New Issue
Block a user