Files
PgTidy/AGENTS.md
T
Hein 5cdec88299
CI / Test (push) Successful in 37s
CI / Build (push) Successful in 52s
feat(format): expand the runtime safety gate
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.
2026-09-10 15:17:07 +02:00

8.4 KiB
Raw Permalink Blame History

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 diagnostics type.

Core invariants (the formatter must never violate these)

  1. Lossless lex: emit(lex(src)) == src byte-for-byte. The lexer keeps all trivia.
  2. Semantic equivalence: formatting only changes trivia/layout. Verify by re-lexing the output and comparing the non-trivia token stream against the input.
  3. Idempotence: fmt(fmt(x)) == fmt(x).
  4. Graceful degradation: any span the parser cannot handle is passed through verbatim rather than corrupted.
  5. Runtime safety gate: every frontend (CLI fmt, LSP textDocument/formatting and rangeFormatting) calls format.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 in pkg/format/*_test.go, so a formatter bug can never silently drop or alter code. VerifySafe runs 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 in src reappears in out, 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 of out matches src, counting only real code tokens (comment and string/dollar-quote contents are skipped).
    • Idempotence: re-formatting out yields out unchanged.

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; AS then $$ on its own line; body; closing $$; on its own line.
  • PL/pgSQL: DECLARE alone, vars 2-space indented; --Block-- comment markers preserved; BEGIN/END at 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)

  1. 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)) == src byte-for-byte across the corpus.
  2. CST + parser (pkg/cst, pkg/parser): recursive descent for DML (SELECT/INSERT/ UPDATE/DELETE/CTE), DDL (CREATE FUNCTION/PROCEDURE/TABLE/INDEX/TRIGGER, ALTER, DO).
  3. PL/pgSQL body parser: DECLARE/BEGIN/END, IF/CASE/LOOP, assignments, nested SQL.
  4. Printer (pkg/format): Doc-IR (group/indent/line/softline) driven by style config. Graceful degradation — unparsed spans pass through verbatim.
  5. CLI (cmd/pgtidy fmt): --check, --write/-w, stdin→stdout, --diff; config discovery walking up to .pgtidy.yaml; CI-friendly exit codes.
  6. Config (pkg/config): load/merge style config; defaults = house style above.

V2 — Linter

  • pkg/pgast go-pgquery (WASM) wrapper; pkg/lint rule 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 lint subcommand; --fix for autofixable rules.

V3 — LSP + VSCode

  • pkg/lsp: textDocument/formatting + range formatting, publishDiagnostics, codeAction quick-fixes — all reusing the core.
  • editors/vscode: TS extension using vscode-languageclient, launches bundled pgtidy 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 the pgtidy binary directly. Formatting and version info run pgtidy fmt/version; lint diagnostics and quick-fixes run via a native ExternalAnnotator calling pgtidy 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 --check returns non-zero on unformatted input, zero when clean.
  • Lint (v2): fixture SQL with known violations → assert expected diagnostics; --fix round-trips.
  • LSP/VSCode (v3): load a .sql file in a dev-host VSCode, confirm format-on-save and live diagnostics via the bundled binary.