Replace the bare SemanticallyEqual call in every frontend (CLI fmt, LSP
formatting + rangeFormatting) with format.VerifySafe, which runs four
checks before any formatted output is emitted:
- semantic equivalence - the code token stream is unchanged
- comment preservation - no -- or /* */ comment is dropped, merged,
split, reordered, or reworded (line endings / indentation normalised
away; recurses into dollar-quoted bodies)
- structural balance - the () [] and BEGIN/CASE/IF/LOOP...END nesting
profile matches, ignoring anything inside a comment or a string
- idempotence - a second format pass would not change it
On failure the CLI now prints the specific reason and keeps the original.
The comment check surfaced two real formatter bugs, both fixed in
formatBodyStatements:
- multi-line /* */ comments inside a PL/pgSQL body had their interior
lines re-split and reindented as if they were statements; they are
now tracked and carried verbatim with the opening line
- a column-0 -- line was glued onto the preceding line by the
split-line-join, which merged consecutive comment lines into one
Regenerate testdata/corpus/test_mm_proc.pgsql (was carrying the mangled
output). TestCorpusIdempotentAndSafe now runs the full VerifySafe bundle;
add safety_test.go with targeted cases.
203 lines
5.7 KiB
Go
203 lines
5.7 KiB
Go
package format
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.warky.dev/wdevs/pgtidy/pkg/config"
|
|
"git.warky.dev/wdevs/pgtidy/pkg/parser"
|
|
)
|
|
|
|
func format(src string) string {
|
|
return File(parser.Parse(src), config.Default())
|
|
}
|
|
|
|
func TestFormatHeaderGolden(t *testing.T) {
|
|
src := "--select * from dropall('resolvespec_login');\n" +
|
|
"create or replace function resolvespec_login(\n" +
|
|
"INOUT p_data jsonb, OUT p_success boolean, OUT p_error text)\n" +
|
|
"language plpgsql volatile security definer\n" +
|
|
"as $$\nbegin end;\n$$;\n"
|
|
|
|
want := "--select * from dropall('resolvespec_login');\n" +
|
|
"CREATE OR REPLACE FUNCTION resolvespec_login(\n" +
|
|
" INOUT p_data jsonb\n" +
|
|
", OUT p_success boolean\n" +
|
|
", OUT p_error text\n" +
|
|
")\n" +
|
|
" LANGUAGE plpgsql\n" +
|
|
" VOLATILE\n" +
|
|
" SECURITY DEFINER\n" +
|
|
"AS\n" +
|
|
"$$\nbegin end;\n$$;\n"
|
|
|
|
got := format(src)
|
|
if got != want {
|
|
t.Errorf("header format mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestIdempotentSmall(t *testing.T) {
|
|
src := "create function f(a int,b text) returns void language sql as $$ select 1 $$;"
|
|
once := format(src)
|
|
twice := format(once)
|
|
if once != twice {
|
|
t.Errorf("not idempotent\n--- once ---\n%s\n--- twice ---\n%s", once, twice)
|
|
}
|
|
}
|
|
|
|
func TestFormatBodyBroken(t *testing.T) {
|
|
dir := filepath.Join("..", "..", "testdata", "corpus")
|
|
brokenData, err := os.ReadFile(filepath.Join(dir, "test_a_broken.pgsql"))
|
|
if err != nil {
|
|
t.Skipf("no test_a_broken.pgsql: %v", err)
|
|
}
|
|
goldenData, err := os.ReadFile(filepath.Join(dir, "test_a.pgsql"))
|
|
if err != nil {
|
|
t.Skipf("no test_a.pgsql: %v", err)
|
|
}
|
|
|
|
got := format(string(brokenData))
|
|
want := string(goldenData)
|
|
if got != want {
|
|
t.Errorf("format(test_a_broken) != test_a.pgsql\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
|
}
|
|
twice := format(got)
|
|
if twice != got {
|
|
t.Errorf("format(test_a_broken) is not idempotent")
|
|
}
|
|
}
|
|
|
|
func TestFormatMmProcBroken(t *testing.T) {
|
|
dir := filepath.Join("..", "..", "testdata", "corpus")
|
|
brokenData, err := os.ReadFile(filepath.Join(dir, "test_mm_proc_broken.pgsql"))
|
|
if err != nil {
|
|
t.Skipf("no test_mm_proc_broken.pgsql: %v", err)
|
|
}
|
|
goldenData, err := os.ReadFile(filepath.Join(dir, "test_mm_proc.pgsql"))
|
|
if err != nil {
|
|
t.Skipf("no test_mm_proc.pgsql: %v", err)
|
|
}
|
|
|
|
got := format(string(brokenData))
|
|
want := string(goldenData)
|
|
if got != want {
|
|
// Find and report the first differing line.
|
|
gotLines := strings.Split(got, "\n")
|
|
wantLines := strings.Split(want, "\n")
|
|
for i := 0; i < len(gotLines) && i < len(wantLines); i++ {
|
|
if gotLines[i] != wantLines[i] {
|
|
t.Errorf("format(test_mm_proc_broken) != test_mm_proc.pgsql at line %d\n got: %q\n want: %q", i+1, gotLines[i], wantLines[i])
|
|
break
|
|
}
|
|
}
|
|
if len(gotLines) != len(wantLines) {
|
|
t.Errorf("format(test_mm_proc_broken): got %d lines, want %d lines", len(gotLines), len(wantLines))
|
|
}
|
|
}
|
|
twice := format(got)
|
|
if twice != got {
|
|
t.Errorf("format(test_mm_proc_broken) is not idempotent")
|
|
}
|
|
if !semanticallyEqual(string(brokenData), got) {
|
|
t.Errorf("format(test_mm_proc_broken) changed semantics")
|
|
}
|
|
}
|
|
|
|
func TestFormatIssue1PLpgSQLIndenting(t *testing.T) {
|
|
src := "CREATE FUNCTION f() RETURNS void LANGUAGE plpgsql AS $$\n" +
|
|
"DECLARE\n" +
|
|
" r_lp record;\n" +
|
|
"BEGIN\n" +
|
|
" if r_lp.total > 0\n" +
|
|
" and r_lp.totaldone >= r_lp.total\n" +
|
|
" then\n" +
|
|
" update core.process u\n" +
|
|
" set status = 'done'\n" +
|
|
" where u.rid_process = r_lp.rid_process\n" +
|
|
" and nv(u.status) <> 'done';\n" +
|
|
" elsif r_lp.total > 0\n" +
|
|
" then\n" +
|
|
" update core.process u\n" +
|
|
" set status = 'open'\n" +
|
|
" where u.rid_process = r_lp.rid_process\n" +
|
|
" and nv(u.status) <> 'open';\n" +
|
|
"\n" +
|
|
" end if;\n" +
|
|
"$$;\n"
|
|
|
|
want := "CREATE FUNCTION f(\n" +
|
|
")\n" +
|
|
" RETURNS void\n" +
|
|
" LANGUAGE plpgsql\n" +
|
|
"AS\n" +
|
|
"$$\n" +
|
|
"DECLARE\n" +
|
|
" r_lp record;\n" +
|
|
"BEGIN\n" +
|
|
" if r_lp.total > 0\n" +
|
|
" and r_lp.totaldone >= r_lp.total\n" +
|
|
" then\n" +
|
|
" update core.process u\n" +
|
|
" set status = 'done'\n" +
|
|
" where\n" +
|
|
" u.rid_process = r_lp.rid_process\n" +
|
|
" and nv(u.status) <> 'done';\n" +
|
|
" elsif r_lp.total > 0\n" +
|
|
" then\n" +
|
|
" update core.process u\n" +
|
|
" set status = 'open'\n" +
|
|
" where\n" +
|
|
" u.rid_process = r_lp.rid_process\n" +
|
|
" and nv(u.status) <> 'open';\n" +
|
|
"\n" +
|
|
" end if;\n" +
|
|
"$$;\n"
|
|
|
|
got := format(src)
|
|
if got != want {
|
|
t.Errorf("issue #1 PL/pgSQL indenting\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
|
}
|
|
checkDML(t, "issue #1 PL/pgSQL indenting", got)
|
|
if !semanticallyEqual(src, got) {
|
|
t.Errorf("issue #1 PL/pgSQL indenting changed semantics")
|
|
}
|
|
}
|
|
|
|
func TestCorpusIdempotentAndSafe(t *testing.T) {
|
|
dir := filepath.Join("..", "..", "testdata", "corpus")
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Skipf("no corpus: %v", err)
|
|
}
|
|
var seen int
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pgsql") || strings.HasSuffix(e.Name(), "_broken.pgsql") {
|
|
continue
|
|
}
|
|
seen++
|
|
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src := string(data)
|
|
once := format(src)
|
|
// VerifySafe bundles every runtime gate: semantic equivalence, comment
|
|
// preservation, structural balance, and idempotence.
|
|
if err := VerifySafe(src, once, config.Default()); err != nil {
|
|
t.Errorf("%s: %v", e.Name(), err)
|
|
}
|
|
}
|
|
if seen == 0 {
|
|
t.Skip("no corpus files")
|
|
}
|
|
t.Logf("verified %d corpus files (semantic + comments + structure + idempotence)", seen)
|
|
}
|
|
|
|
// semanticallyEqual is a test-local alias for the exported safety check.
|
|
func semanticallyEqual(a, b string) bool {
|
|
return SemanticallyEqual(a, b)
|
|
}
|