Add sqlc fmt: a comment-preserving SQL formatter, SQLite first - #4580
Conversation
Format each configured query file by parsing it with the engine's parser and printing it back with ast.Format, keeping the comments above every statement (including the -- name: annotation). Files are rewritten in place; --diff prints the changes to stdout instead. Formatting is conservative: a PostgreSQL statement must keep the same pg_query fingerprint, and statements for other engines must round-trip through parse and format unchanged, otherwise the original text is kept. Files that need the compiler's preprocessing to parse (e.g. sqlc.slice() on SQLite) are skipped with a warning. Supporting changes: - dolphin: also record ORDER BY as SortBy nodes in SortClause (sharing the converted expressions with the existing WindowClause hack) so formatting a MySQL query no longer drops ORDER BY or its direction - ast: recognize the pre-rewrite @name shape (the @ operator applied to a bare column reference) as a named parameter so formatting emits @id instead of @ id Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
Replace TrackedBuffer's flat string builder with a small Wadler-style
document IR — the layout model behind Prettier and ruff (via Biome's
printer). Format methods now emit text plus layout tokens: line and
softline break opportunities, groups the renderer tries to lay out flat,
and indented regions. The renderer keeps a group on one line when its
flat width fits in the space remaining and otherwise breaks it, so a
statement that fits within 80 columns stays on a single line, a longer
one breaks at clause boundaries, and any list or parenthesized region
that still does not fit breaks again one level deeper:
SELECT id, name, bio, created_at
FROM authors
WHERE name LIKE $1
AND bio IS NOT NULL
AND id > $2
ORDER BY name, id
LIMIT $3;
ast.Format still renders on a single line for existing callers; the new
ast.Pretty(n, dialect, width) powers sqlc fmt at width 80.
Statement formatters gained break points: SELECT/INSERT/UPDATE/DELETE
clauses, JOINs, set operations, CTE bodies, subqueries, column lists and
VALUES rows. Clause-level AND/OR chains print without the redundant
outer parentheses and flatten same-operator nesting.
Fixes surfaced along the way:
- dolphin: MySQL LIKE mapped to the ILIKE kind, so formatting rewrote
LIKE as ILIKE (invalid MySQL); NOT LIKE dropped its NOT entirely.
The converter now honors IsLike and Not, using the ~~/!~~ operator
names the other engines already use.
- ast: LIKE/ILIKE formatting respects the negated operator spelling
(!~~, !~~*), so NOT LIKE survives formatting on every engine.
- ast: SELECT DISTINCT ON (col) was missing the space before the
target list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
Comments cannot survive the trip through the AST — every engine's parser discards them — so make the formatter guarantee they are preserved: - A statement containing interior comments (or optimizer hints, which share comment syntax) is left exactly as written. A lexical scanner detects comments outside string literals, quoted identifiers, and dollar-quoted strings; it scans under both string-escaping conventions and treats anything unterminated as a comment, so ambiguity can only leave a statement unformatted, never delete a comment. - Multi-line /* */ comment blocks above a statement are now kept in the header, with the statement below them still formatted. - A comment on the same line as a statement's closing semicolon stays attached to that statement instead of drifting into the next header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
First engine on the gofmt model: comments are carried beside the tree, anchored by source positions, and the printer weaves them back in. No more skipping commented statements for SQLite. - internal/sql/lex: dialect-aware comment scanner producing positioned ast.Comment values (text, byte span, own-line flag). The sqlc-side stand-in for the parsers' own token streams; engines switch to their parser's stream as each learns to expose it, and nothing downstream changes. - ast: Comment and CommentSet (comment list + line index — the FileSet equivalent — plus the printer's cursor). TrackedBuffer flushes every comment positioned before the node it is about to print: own-line comments print on their own line, a comment on the same source line as the code before it trails that code after the comma or clause, and inline block comments stay in the flow. - ast: hardline and breaker tokens in the doc IR. A line comment swallows the rest of its line, so it measures as infinitely wide — every group containing one breaks (breakParent by arithmetic) — and the renderer dedupes consecutive breaks so comments collapse cleanly against clause breaks. - statement formatters flush before each clause keyword (beforeClause) and after list commas, so comments land where they were written. - fmt: the sqlite path threads interior comments through the printer and proves the result three ways before accepting it: comment multiset equality after re-lexing the output, a clean reparse, and idempotence (reprinting the reparsed output reproduces it). A file-level check additionally refuses any result that changes the file's comments. Other engines keep the verbatim fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
meyer's parser now returns the trivia its single lexer pass produces (sqlc-dev/meyer branch claude/parsefile-trivia-pw47ve), so the sqlite engine gains ParseFile, returning the new ast.File: statements plus the file's comments, from one pass over the source. fmt discovers the capability by interface — engines with ParseFile get statements and comments together; the rest keep Parse and the verbatim fallback for commented statements. The reprinter's verification also rides ParseFile now: reparsing the output yields both the statement and its comments for the multiset and fixed-point checks in a single call. internal/sql/lex, the sqlc-side comment scanner that stood in for the parser's token stream, is deleted: its only consumer was sqlite, and the parser is now the single source of lexical truth there. Engines that come online later (oliphant's Scan, marino's collector) will feed the same ast.File shape from their own lexers. meyer is pinned to a pseudo-version of the ParseFile branch; bump to the tagged release once that merges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
Pull in the tagged meyer release (v0.1.2, byte-identical to the branch pin) and make the support policy explicit: sqlc fmt formats only the engines whose parser surfaces its comments — sqlite today. Other engines' query files are left unchanged, with a one-line stderr notice per engine instead of a silent no-op. Engines join by teaching their parser ParseFile and adding a case to newQueryFormatter. With that policy in place, an adversarial review of the accumulated changeset flagged everything the gate made unreachable, now removed: - queryFormatter requires ParseFile itself; the separate fileParser capability type, the Parse-based branch in formatQueries, and the capability checks collapse away - the hasComment/scanComment lexical detector (the old skip machinery for engines without reprinter support) - verifyFormatted's PostgreSQL fingerprint branch and its unused parameters; verification now rides ParseFile like everything else - the MySQL #-comment and /*+ hint branches threaded through the header/trailing text helpers, along with their engine parameters: future engines arrive via ParseFile, whose comments flow through the reprinter by position, so these lexical special cases will not return The pg/mysql fmt testdata now pin the skip behavior: empty diffs plus the unsupported-engine notice on stderr. Docs updated to match, with the example rewritten in sqlite syntax and verified against real output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
Replace the print-time position cursor with the attachment model, so formatting and future AST rewriting share one comment mechanism. AttachComments classifies a statement's comments once, against a dry run of the printer itself: source positions and lines decide trailing vs leading, and each comment is attached to the emission point — node, clause boundary, or list boundary — where the printer will reach it. From then on positions are never consulted: PrettyWithComments emits by node identity, which is what lets an edited or synthetic tree print its comments correctly (the dave/dst model; each record also keeps the node the comment followed, for rewriting tools to move comments with their nodes). The emission points participate in the dry run as boundary markers, so attach-time classification and print-time emission are the same decision by construction — there is no second placement logic to drift. CommentSet and the position-flush functions are deleted; the doc IR, emission styles, and verification net are unchanged, and the formatted output is byte-identical across the goldens and corpus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
kyleconroy
left a comment
There was a problem hiding this comment.
The ast/print.go needs to be moved to a separate ast/printer package. Can we easily move tracked buffer to that package?
- Extract the doc renderer into internal/sql/ast/printer; TrackedBuffer embeds printer.Buffer and keeps the AST-facing layer (node dispatch, comment emission) in the ast package, since every node's Format method names TrackedBuffer and moving it would create an import cycle. - Drop the 80-column line width: like gofmt, fmt no longer rewraps lines on its own. AttachComments records which printer-modeled boundaries the author broke at, and boundary() keeps those breaks; one-line statements stay on one line. - Fold the comment-free path into the attachment path: formatStmt always goes through formatWithComments, retiring formatRaw/verifyFormatted. - Cobra: Short is now "Format SQL queries"; Long drops the comments-never-deleted sentence and the engine-support paragraph. - Docs: rewrite howto/fmt.md for the no-width model with a regenerated example; update cli.md; regenerate the sqlite endtoend golden. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
|
On the review-body question — moving Done in half: the document renderer (the Wadler engine — tokens, groups, indentation, width fitting) now lives in
So the split is by knowledge instead: Also in the latest push: the line width is gone entirely. Generated by Claude Code |
fmt dropped double quotes from identifiers: the sqlite dialect's QuoteIdent returned every name bare, and the round-trip verification could not see the loss because it compares case-insensitively while the parser folds unquoted identifiers to lower case — so "EmailAddress" reprinted as EmailAddress, which sqlc's catalog then failed to resolve. Quote any identifier that is not a plain lower-case name, or that collides with a keyword (meyer's table, case-insensitive), escaping embedded quotes. The fmt endtoend case now pins quoted mixed-case and keyword identifiers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
fmt rewrote @name and $name to :name: the converter collapsed SQLite's three named-bind spellings into one pseudo-operator and the printer re-spelled them with the dialect's NamedParam. The spellings are not interchangeable to sqlc — @name is sqlc's named-parameter syntax, while :name and $name pass through — so the rewrite silently dropped parameters from generated code (caught by the managed-db context, which reads the query with SQLite's own analyzer and kept the parameter). Carry the author's sigil through as the pseudo-operator and print it back verbatim. NamedParam had no other callers, so it leaves the Dialect interface. The fmt endtoend case now pins all three spellings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
Three layout regressions from the corpus sweep:
- A multi-line CREATE TABLE collapsed onto one line: its Format method
had no layout tokens at all. It now models the column list the way
DML lists are modeled — boundaries between (and before) the columns,
so the author's breaks are kept and a one-liner stays a one-liner —
and prints a space before the open parenthesis, matching how these
statements are written.
- A multi-line CTE collapsed: the author's break after "AS (" sat
inside the parenthesized body, where no boundary was modeled. The CTE
body now has one, and each statement marks the boundary between its
WITH clause and its own first keyword.
- A break before VALUES exploded the INSERT column list: the columns
from convertColumnNames carried no source positions, so the break
classifier had nothing to anchor the list's boundaries to and blamed
them for the line change. The converter now stamps positions there
and on CREATE TABLE column definitions.
Statements with a WITH clause also wrap their body in its own group, so
a break inside the WITH clause no longer forces the body apart clause
by clause. CREATE VIRTUAL TABLE now renders as nothing rather than as a
plain CREATE TABLE — its module arguments are parsed away, so no
faithful rendering exists and the verification net keeps the statement
as written. The fmt endtoend case pins all of these.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
Two-word type names printed squashed (VARYING CHARACTER(32) came back as VARYINGCHARACTER(32)): the converter folds the spaces out of type names for catalog matching, and the printer had nothing else to show. ast.TypeName now carries the authored spelling alongside the folded name — the sqlite converter fills it for column types and casts from meyer's token-joined name — and the printer prefers it. Fixing that surfaced how much of a CREATE TABLE the reprint silently destroyed: PRIMARY KEY came back as NOT NULL, DEFAULT, UNIQUE, CHECK and every other constraint vanished, a typeless column grew an 'any', and IF NOT EXISTS was dropped. Column PRIMARY KEY, typeless columns and IF NOT EXISTS are now modeled and print faithfully. Everything the node still cannot carry — other column constraints, table constraints, table options, TEMP, AS SELECT, and a NOT NULL next to a PRIMARY KEY (which does not imply it in SQLite) — marks the statement Incomplete: it renders as nothing, no verification accepts that, and sqlc fmt keeps the statement exactly as written. ALTER TABLE ADD COLUMN gets the same treatment. TestFormat learns that an empty rendering is that signal, and the fmt endtoend case pins the new behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
Resolve the docs conflict for the pure-Markdown docs directory: drop this branch's docs/index.rst toctree edit in favor of main's deletion and register howto/fmt.md in docs/toc.yaml's Commands section instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
CREATE VIRTUAL TABLE no longer falls back through the Incomplete guard: meyer keeps a module's argument list as raw source text (the grammar belongs to the module), so the statement can print faithfully. ast.CreateTableStmt carries the module name and its arguments verbatim alongside the catalog-facing columns, and prints as the declaration. The arguments have no spans of their own, so the author's exact breaks cannot be observed; a declaration written across lines keeps the canonical broken form — one argument per line — and a one-liner stays a one-liner, decided from the statement's source span. Incomplete remains for what the node still cannot carry: column and table constraints beyond plain NOT NULL and PRIMARY KEY, table options, TEMP, and AS SELECT bodies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
sqlc fmtformats the query files referenced by the configuration: each statement is parsed with the engine's parser and printed back from the AST in a canonical form, with its comments kept where they were written. Files are rewritten in place;--diffprints the changes to stdout instead.Engine support
SQLite only for now. Comments cannot be reprinted without parser support, so an engine joins
fmtwhen its parser surfaces the comments its lexer already scans. meyer does this as of v0.1.2 (sqlc-dev/meyer#6):ParseFilereturns statements plus the lexer's trivia from a single pass. Other engines' query files are left unchanged, with a one-line stderr notice per engine; each joins by implementingParseFile(meyer is the template — oliphant'sScanalready emits comment tokens, marino needs a small scanner change) and adding a case tonewQueryFormatter.Architecture
Two layers, borrowed from the formatters that got this right:
internal/sql/ast/print.go: Format methods emit text plus layout tokens (line,softline,group,indent), and the renderer lays each group out flat when it fits within 80 columns, breaking at clause boundaries — then lists,AND/ORchains, and parenthesized regions one level deeper — when it doesn't.ast.Formatstill renders on a single line for existing callers (expander, tests).ast.AttachCommentsclassifies each one once (trailing vs leading, by source position and line) against a dry run of the printer itself, and from then on positions are never consulted — the printer emits by node identity, which also means a future AST-rewriting tool prints comments correctly on edited or synthetic trees. A line comment measures as infinitely wide in the doc IR, so it forces its enclosing groups to break: commented statements format instead of collapsing.Safety
Comments are never deleted and SQL is never changed, enforced rather than assumed:
Along the way this surfaced and fixed two real MySQL converter bugs (dolphin mapped
LIKEto theILIKEkind and droppedNOTfromNOT LIKE), addedSortBynodes for MySQLORDER BY(previously invisible to formatting), and taughtA_Exprto recognize pre-rewrite@nameparameters so they print as@name, not@ name.Testing
fmtcases (testdata/fmt/{sqlite,postgresql,mysql}) pin the formatted output, the comment placements, and the unsupported-engine skip behavior, run through the real CLI viaexec.json.--tags=examplessuite passes with live PostgreSQL and MySQL.fmtafterfmtproduces no diff) and formatted files still compile.Docs:
docs/howto/fmt.md(example verified against real output), CLI reference entry, andinternal/sql/ast/CLAUDE.mdnotes on the doc IR and comment machinery.🤖 Generated with Claude Code
https://claude.ai/code/session_018MTvpHqNMadH12pTtsgUq2
Generated by Claude Code