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.
8.4 KiB
Project Overview
PgTidy is a PostgreSQL-focused linter and formatter written in Go. It enforces a consistent SQL style, detects PostgreSQL-specific issues, and formats migrations, schemas, functions, procedures, triggers, and related database code. The primary day-one use case is formatting PL/pgSQL stored procedures to a defined house style.
It ships as a CLI, an LSP server, a VSCode extension, and (later) a DataGrip integration.
Agent Rules
Keep your answers short. Question everything, never assume or guess. Ask the user if you are unsure about anything.
Architecture
PgTidy uses a hybrid strategy:
- Formatting is driven by a custom lossless lexer + CST (concrete syntax tree) that preserves every comment and whitespace span, so formatting can round-trip safely.
- Linting (later milestones) is driven by the real PostgreSQL grammar via
go-pgquery(libpg_query compiled to WASM with wazero — no cgo), which yields an accurate AST for deep semantic rules.
A single Go core backs every frontend (CLI, LSP, editors) and shares one diagnostics type.
cmd/pgtidy/ — CLI entry; subcommands: fmt, lint (v2), lsp (v3), version
pkg/lexer/ — lossless lexer: tokens + trivia (comments/whitespace) attached
pkg/cst/ — concrete syntax tree (round-trippable node model)
pkg/parser/ — recursive-descent parser → CST (DML + DDL + PL/pgSQL)
pkg/format/ — Doc-IR printer (Wadler/Prettier-style) + style application
pkg/config/ — .pgtidy.yaml discovery/merge: style + rule config
pkg/diagnostics/ — shared diagnostic type (CLI + LSP)
pkg/pgast/ — go-pgquery wrapper: SQL → real PG AST (lint, v2)
pkg/lint/ — rule engine + rule packs (v2)
pkg/lsp/ — LSP server (v3)
editors/vscode/ — VSCode extension (v3)
editors/datagrip/ — native JetBrains plugin, shells out to the CLI (v4)
testdata/corpus/ — real-world .pgsql procedures used as the safety/idempotence harness
Why this architecture
- No cgo (WASM-embedded libpg_query) keeps cross-compilation trivial and lets us bundle a single static binary per platform inside the VSCode extension.
- Formatting needs a lossless CST — libpg_query drops comments and whitespace, so it cannot be the sole basis for a formatter. We build our own lexer/CST so the formatter never loses a comment.
- Deep lint needs an accurate AST — go-pgquery gives the real PostgreSQL parse tree.
- One core, many frontends — CLI, LSP, VSCode, and DataGrip all reuse the same engine and
the same
diagnosticstype.
Core invariants (the formatter must never violate these)
- Lossless lex:
emit(lex(src)) == srcbyte-for-byte. The lexer keeps all trivia. - Semantic equivalence: formatting only changes trivia/layout. Verify by re-lexing the output and comparing the non-trivia token stream against the input.
- Idempotence:
fmt(fmt(x)) == fmt(x). - Graceful degradation: any span the parser cannot handle is passed through verbatim rather than corrupted.
- Runtime safety gate: every frontend (CLI
fmt, LSPtextDocument/formattingandrangeFormatting) callsformat.VerifySafe(src, out, style)before writing or returning formatted output. If it returns a non-nil error the formatter has a bug — the caller must refuse to write/emit the result and keep the original source, never guess or best-effort it. This is enforced at runtime, not just inpkg/format/*_test.go, so a formatter bug can never silently drop or alter code.VerifySaferuns four checks:- Semantic equivalence (
SemanticallyEqual): re-lex both sides, compare the non-trivia token streams — case-insensitive for identifiers/keywords, exact otherwise, recursing into dollar-quoted bodies. Comments and whitespace are trivia and are ignored here. - Comment preservation (
CommentsPreserved): every--and/* */comment insrcreappears inout, in order, with the same content (line endings and indentation are normalised away; dropping, merging, splitting, reordering, or rewording a comment is not). Descends into dollar-quoted bodies. - Structural balance (
StructurallyBalanced): the( ) [ ]and BEGIN/CASE/IF/LOOP…END nesting profile ofoutmatchessrc, counting only real code tokens (comment and string/dollar-quote contents are skipped). - Idempotence: re-formatting
outyieldsoutunchanged.
- Semantic equivalence (
Line endings: the formatter re-emits all layout with st.Newline (default \n), so a
CRLF input file is normalised to LF on write. This includes \r\n that sits inside a
multi-line string literal — a line-ending change there is layout normalisation, not a change
of code content, so SemanticallyEqual compares string tokens modulo \r\n ↔ \n
(normNL in pkg/format/safety.go). Set newline: "\r\n" in .pgtidy.yaml to keep CRLF.
Commands
go build ./cmd/pgtidy # Build the binary
go test ./... # Run all tests (incl. corpus safety harness)
go vet ./... # Static analysis
go fmt ./... # Format Go code
House style (formatter defaults; all configurable via .pgtidy.yaml)
- Keywords UPPERCASE; data types lowercase; identifiers lowercase snake_case.
- Indent: 2 spaces per level.
- Leading-comma style, one item per line, for SELECT column lists and function params.
- Function headers: params one-per-line in parens; LANGUAGE / SECURITY / volatility each on
their own line;
ASthen$$on its own line; body; closing$$;on its own line. - PL/pgSQL:
DECLAREalone, vars 2-space indented;--Block--comment markers preserved;BEGIN/ENDat body level; IF/ELSIF/ELSE/END IF, loops, CASE indent their bodies. - Spacing: spaces around binary operators (
=,<>,||, …) and:=; no space around::,->,->>, array[...], or before a call's(. - Dollar-quote tags preserved verbatim (
$$,$S$,$Z$, …).
Milestones
V1 — Formatter + CLI (current priority)
- Lexer (
pkg/lexer): full PG token coverage incl. dollar-quoted strings,--and/* */comments, operators. Comments + whitespace as leading/trailing trivia on tokens. Acceptance:emit(lex(src)) == srcbyte-for-byte across the corpus. - CST + parser (
pkg/cst,pkg/parser): recursive descent for DML (SELECT/INSERT/ UPDATE/DELETE/CTE), DDL (CREATE FUNCTION/PROCEDURE/TABLE/INDEX/TRIGGER, ALTER, DO). - PL/pgSQL body parser: DECLARE/BEGIN/END, IF/CASE/LOOP, assignments, nested SQL.
- Printer (
pkg/format): Doc-IR (group/indent/line/softline) driven bystyleconfig. Graceful degradation — unparsed spans pass through verbatim. - CLI (
cmd/pgtidy fmt):--check,--write/-w, stdin→stdout,--diff; config discovery walking up to.pgtidy.yaml; CI-friendly exit codes. - Config (
pkg/config): load/merge style config; defaults = house style above.
V2 — Linter
pkg/pgastgo-pgquery (WASM) wrapper;pkg/lintrule engine (rule ID, severity, config).- Rule packs: style/consistency, migration safety (ACCESS EXCLUSIVE locks, unsafe ALTER/ADD
COLUMN, non-CONCURRENTLY index builds, blocking constraints), naming conventions,
correctness/anti-patterns (SELECT *, missing WHERE on UPDATE/DELETE). Emit
diagnostics. pgtidy lintsubcommand;--fixfor autofixable rules.
V3 — LSP + VSCode
pkg/lsp:textDocument/formatting+ range formatting,publishDiagnostics,codeActionquick-fixes — all reusing the core.editors/vscode: TS extension usingvscode-languageclient, launches bundledpgtidy lsp. Per-platform VSIX (win32/linux/darwin × x64/arm64) built in CI matrix.
V4 — DataGrip
editors/datagrip: native JetBrains plugin (no LSP4IJ dependency) that shells out to thepgtidybinary directly. Formatting and version info runpgtidy fmt/version; lint diagnostics and quick-fixes run via a nativeExternalAnnotatorcallingpgtidy lint --json.
Verification
- Formatter:
go test ./...runs golden-file tests + corpus harness asserting idempotence and token-stream equality before/after (no semantic change). - CLI:
pgtidy fmt --checkreturns non-zero on unformatted input, zero when clean. - Lint (v2): fixture SQL with known violations → assert expected diagnostics;
--fixround-trips. - LSP/VSCode (v3): load a
.sqlfile in a dev-host VSCode, confirm format-on-save and live diagnostics via the bundled binary.