Skip to content

Commit 23b3957

Browse files
committed
Address PR review: printer package, no line width, docs
- 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
1 parent 3dff35f commit 23b3957

16 files changed

Lines changed: 472 additions & 454 deletions

File tree

docs/howto/fmt.md

Lines changed: 18 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,18 @@ canonical format. Each query is parsed with the engine's parser and printed
55
back from the syntax tree, so formatting never depends on how the query was
66
written — only on what it means.
77

8-
A statement that fits within 80 columns is printed on a single line. A longer
9-
statement breaks at clause boundaries (`FROM`, `WHERE`, `ORDER BY`, ...), and
10-
any part that still does not fit — a column list, an `AND`/`OR` chain, a
11-
parenthesized subquery — breaks again, indented one level deeper. This is the
12-
same layout model used by Prettier and by ruff's Python formatter: the printer
13-
lays each group of the statement out flat when it fits and breaks it when it
14-
does not.
15-
16-
Comments are never deleted. The comments above each query are kept, including
17-
the [`-- name:`](../reference/query-annotations.md) annotation and multi-line
18-
`/* */` blocks, and a comment on the same line as a statement's closing
19-
semicolon stays attached to it.
8+
Like `gofmt`, the formatter does not impose a maximum line width. A statement
9+
written on a single line stays on a single line, and a statement the author
10+
broke across lines keeps its breaks: the printer notices which clause
11+
boundaries (`FROM`, `WHERE`, `ORDER BY`, ...) and list boundaries the author
12+
broke at and preserves them, normalizing indentation and spacing around them.
2013

2114
Comments inside a statement are formatted along with it: each comment is
2215
anchored to the code around it by source position and printed back there —
2316
a comment trailing a select-list item stays with that item, a comment above
24-
a clause stays above its keyword — and a statement carrying comments keeps
25-
its multi-line shape. Any statement that cannot be proven to survive
26-
formatting unchanged is left exactly as written.
27-
28-
Formatting currently supports the `sqlite` engine, whose parser surfaces the
29-
comments its lexer sees; other engines' query files are left untouched and
30-
gain support as their parsers are updated. Files that cannot be parsed
31-
without the compiler's preprocessing (for example, files using
32-
`sqlc.slice()` on engines whose parser rejects it) are also skipped; a
33-
skipped file is reported on standard error and left unchanged.
17+
a clause stays above its keyword — and a comment that runs to the end of its
18+
line breaks the statement open around it. Any statement that cannot be
19+
proven to survive formatting unchanged is left exactly as written.
3420

3521
## Usage
3622

@@ -60,7 +46,10 @@ running `sqlc fmt` rewrites it to:
6046

6147
```sql
6248
-- name: GetAuthor :one
63-
SELECT id, name, bio FROM authors WHERE id = ? LIMIT 1;
49+
SELECT id, name, bio
50+
FROM authors
51+
WHERE id = ?
52+
LIMIT 1;
6453

6554
-- name: SearchAuthors :many
6655
SELECT
@@ -69,10 +58,11 @@ SELECT
6958
bio,
7059
created_at
7160
FROM authors
72-
WHERE name LIKE ?
73-
AND bio IS NOT NULL
74-
AND id > ?
75-
AND created_at > ?
76-
AND name <> ?
61+
WHERE name LIKE ? AND bio IS NOT NULL AND id > ? AND created_at > ? AND name <> ?
7762
ORDER BY name;
7863
```
64+
65+
`GetAuthor` keeps the line breaks its author wrote; had it been written on
66+
one line, it would stay on one line. In `SearchAuthors`, the line comment
67+
cannot share a line with the code after it, so the statement breaks open
68+
around it, while the `WHERE` chain — written on one line — stays on one.

docs/reference/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Available Commands:
1010
completion Generate the autocompletion script for the specified shell
1111
createdb Create an ephemeral database
1212
diff Compare the generated files to the existing files
13-
fmt Format query files
13+
fmt Format SQL queries
1414
generate Generate source code from SQL
1515
help Help about any command
1616
init Create an empty sqlc.yaml settings file

internal/cmd/fmt.go

Lines changed: 13 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ import (
2020
"github.com/sqlc-dev/sqlc/internal/sql/sqlpath"
2121
)
2222

23-
// fmtLineWidth is the line width formatted queries are wrapped to. A
24-
// statement whose flat form fits stays on one line; a longer one breaks at
25-
// clause boundaries, and any list or parenthesized region that still does
26-
// not fit breaks again one indentation level deeper.
27-
const fmtLineWidth = 80
23+
// noLineLimit renders without a maximum line width: like gofmt, fmt never
24+
// rewraps a line on its own. Breaks come from the author's own line breaks
25+
// at the boundaries the printer models, and from comments, which cannot
26+
// share a line with the code after them.
27+
const noLineLimit = 1 << 30
2828

2929
// queryFormatter is what the fmt command needs from an engine: a parser
3030
// that surfaces the comments its lexer already scans (so statements and
@@ -51,18 +51,13 @@ func newQueryFormatter(engine config.Engine) queryFormatter {
5151
func newFmtCmd() *cobra.Command {
5252
cmd := &cobra.Command{
5353
Use: "fmt",
54-
Short: "Format query files",
54+
Short: "Format SQL queries",
5555
Long: `Format the SQL query files referenced by the configuration file.
5656
5757
Each query is parsed with the engine's parser and printed back in a canonical
58-
form, with its comments kept where they were written. Comments are never
59-
deleted, and a statement that cannot be proven to survive formatting
60-
unchanged is left exactly as written. Files are rewritten in place; pass
61-
--diff to print the changes to stdout instead.
62-
63-
Formatting currently supports the sqlite engine; other engines' query files
64-
are left untouched and gain support as their parsers are updated to surface
65-
comments.`,
58+
form, with its comments kept where they were written. A statement that cannot
59+
be proven to survive formatting unchanged is left exactly as written. Files
60+
are rewritten in place; pass --diff to print the changes to stdout instead.`,
6661
RunE: func(cmd *cobra.Command, args []string) error {
6762
defer trace.StartRegion(cmd.Context(), "fmt").End()
6863
stderr := cmd.ErrOrStderr()
@@ -383,23 +378,10 @@ func isCommentLine(line string) bool {
383378
// original text when formatting cannot be proven to preserve the query.
384379
func formatStmt(f queryFormatter, raw *ast.RawStmt, orig string, interior []ast.Comment, src string) string {
385380
fallback := strings.TrimSuffix(strings.TrimSpace(orig), ";") + ";"
386-
if len(interior) > 0 {
387-
// The reprinter path: interior comments are woven back in by
388-
// position. Any failure to prove the result faithful keeps the
389-
// statement as written.
390-
if out, ok := formatWithComments(f, raw, interior, src); ok {
391-
return out
392-
}
393-
return fallback
394-
}
395-
out := formatRaw(raw, f)
396-
if strings.TrimSpace(strings.TrimSuffix(out, ";")) == "" {
397-
return fallback
398-
}
399-
if !verifyFormatted(f, out) {
400-
return fallback
381+
if out, ok := formatWithComments(f, raw, interior, src); ok {
382+
return out
401383
}
402-
return out
384+
return fallback
403385
}
404386

405387
// formatWithComments pretty-prints a statement with its interior comments
@@ -435,7 +417,7 @@ func prettyCommented(raw *ast.RawStmt, f queryFormatter, comments []ast.Comment,
435417
}
436418
}()
437419
ct := ast.AttachComments(raw, f, comments, src)
438-
out = ast.PrettyWithComments(raw, f, fmtLineWidth, ct)
420+
out = ast.PrettyWithComments(raw, f, noLineLimit, ct)
439421
if !ct.Exhausted() {
440422
return ""
441423
}
@@ -462,26 +444,3 @@ func sameComments(a, b []ast.Comment) bool {
462444
}
463445
return true
464446
}
465-
466-
// formatRaw pretty-prints a statement's AST, turning a formatter panic into
467-
// an empty string so the caller falls back to the original text.
468-
func formatRaw(raw *ast.RawStmt, f queryFormatter) (out string) {
469-
defer func() {
470-
if r := recover(); r != nil {
471-
out = ""
472-
}
473-
}()
474-
return ast.Pretty(raw, f, fmtLineWidth)
475-
}
476-
477-
// verifyFormatted reports whether the formatted SQL provably still means
478-
// the same thing: it must parse back as a single statement that formats to
479-
// itself. The comparison ignores case because some parsers normalize
480-
// keyword and identifier case.
481-
func verifyFormatted(f queryFormatter, formatted string) bool {
482-
file, err := f.ParseFile(strings.NewReader(formatted))
483-
if err != nil || len(file.Stmts) != 1 || file.Stmts[0].Raw == nil {
484-
return false
485-
}
486-
return strings.EqualFold(formatRaw(file.Stmts[0].Raw, f), formatted)
487-
}
Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,32 @@
11
--- a/query.sql
22
+++ b/query.sql
3-
@@ -1,16 +1,15 @@
3+
@@ -1,12 +1,15 @@
44
-- name: GetAuthor :one
5+
+SELECT id, name, bio
6+
+FROM authors
7+
+WHERE id = ?
8+
+LIMIT 1;
59
-select id,name , bio from authors
610
-where id = ? limit 1;
7-
+SELECT id, name, bio FROM authors WHERE id = ? LIMIT 1;
811

912
-- name: PickyQuery :many
10-
-SELECT id, -- the primary key
11-
- name,
12-
- -- computed downstream
13-
- bio
1413
+SELECT
1514
+ id, -- the primary key
1615
+ name,
1716
+ -- computed downstream
1817
+ bio
18+
-SELECT id, -- the primary key
19+
- name,
20+
- -- computed downstream
21+
- bio
1922
FROM authors
2023
-- soft-deleted rows are filtered
21-
-WHERE bio IS NOT NULL
22-
- AND id > ?;
23-
+WHERE bio IS NOT NULL AND id > ?;
24-
25-
-- name: InlineBlock :many
24+
WHERE bio IS NOT NULL
25+
@@ -16,5 +19,6 @@
2626
SELECT /* inline note */ id, name FROM authors ORDER BY name;
27-
@@ -17,4 +16,3 @@
2827

2928
-- name: ListAuthors :many
29+
+SELECT id, name, bio
30+
+FROM authors
3031
-SELECT id, name, bio FROM authors
31-
-ORDER BY name;
32-
+SELECT id, name, bio FROM authors ORDER BY name;
32+
ORDER BY name;

internal/sql/ast/CLAUDE.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ This package defines the Abstract Syntax Tree (AST) nodes used by sqlc to repres
77
### Node Interface
88
All AST nodes implement the `Node` interface with:
99
- `Pos() int` - returns the source position
10-
- `Format(buf *TrackedBuffer)` - formats the node back to SQL
10+
- `Format(buf *TrackedBuffer, d format.Dialect)` - formats the node back to SQL
1111

1212
### TrackedBuffer
1313
The `TrackedBuffer` type (`print.go`) handles SQL formatting with dialect-specific behavior:
@@ -18,25 +18,38 @@ The `TrackedBuffer` type (`print.go`) handles SQL formatting with dialect-specif
1818
- `TypeName(ns, name string)` - formats type names (dialect-specific)
1919

2020
### Pretty printing
21+
The doc renderer lives in `ast/printer` (`printer.Buffer`), which knows
22+
nothing about AST nodes; `TrackedBuffer` embeds it and adds the
23+
AST-facing layer (`astFormat`, comment emission, anchors).
2124
`TrackedBuffer` records a Wadler-style document (the model behind Prettier
2225
and ruff): Format methods emit text plus layout tokens, and the renderer
2326
decides which break opportunities become newlines.
2427

25-
- `line()` - a space when the group fits on one line, a newline otherwise
26-
- `softline()` - nothing when the group fits, a newline otherwise
27-
- `group()` / `endGroup()` - a region laid out flat when its width fits
28-
- `indent()` / `endIndent()` - one level deeper (2 spaces) after breaks
29-
- `joinComma(list)` - joins items with `,` + `line()`
28+
- `Line()` - a space when the group fits on one line, a newline otherwise
29+
- `Softline()` - nothing when the group fits, a newline otherwise
30+
- `Group()` / `EndGroup()` - a region laid out flat when its width fits
31+
- `Indent()` / `EndIndent()` - one level deeper (2 spaces) after breaks
32+
- `Breaker()` - forces every enclosing group to break (measures as
33+
infinitely wide)
34+
- `joinComma(list)` - joins items with `,` + `Line()`
3035
- `condition(node)` - clause-level AND/OR chain without outer parentheses,
3136
one branch per line when broken
3237

3338
`ast.Format(n, d)` renders on a single line (all breaks collapse);
3439
`ast.Pretty(n, d, width)` breaks lines to fit `width` columns. Statement
35-
Format methods open a group and put `line()` before each clause keyword
40+
Format methods open a group and put `Line()` before each clause keyword
3641
(`FROM`, `WHERE`, ...), so a statement that fits stays on one line and a
3742
long one breaks at clause boundaries. When adding a Format method, write
3843
tokens so the flat rendering is correct SQL; layout tokens are optional.
3944

45+
The fmt command does not pass a real width: like gofmt it never rewraps
46+
on its own, so it prints with an effectively infinite width and defers
47+
to the author's line breaks instead. `AttachComments` records, for each
48+
emission-point marker, whether its neighbouring printed nodes sat on
49+
different source lines; when they did, `boundary()` emits a `Breaker()`
50+
there on the real print, so the group the author broke stays broken and
51+
everything else stays flat.
52+
4053
### Comments
4154
`ast.File{Stmts, Comments}` is what a comment-surfacing parser returns
4255
(SQLite via meyer's ParseFile). `AttachComments(raw, d, comments, src)`

internal/sql/ast/bool_expr.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,26 +43,26 @@ func (n *BoolExpr) Format(buf *TrackedBuffer, d format.Dialect) {
4343
op = "OR "
4444
}
4545
buf.WriteString("(")
46-
buf.group()
47-
buf.indent()
48-
buf.softline()
46+
buf.Group()
47+
buf.Indent()
48+
buf.Softline()
4949
if items(n.Args) && op != "" {
5050
first := true
5151
for _, item := range n.Args.Items {
5252
if _, ok := item.(*TODO); ok {
5353
continue
5454
}
5555
if !first {
56-
buf.line()
56+
buf.Line()
5757
buf.WriteString(op)
5858
}
5959
first = false
6060
buf.astFormat(item, d)
6161
}
6262
}
63-
buf.endIndent()
64-
buf.softline()
65-
buf.endGroup()
63+
buf.EndIndent()
64+
buf.Softline()
65+
buf.EndGroup()
6666
buf.WriteString(")")
6767
}
6868
}

internal/sql/ast/comment.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@ type CommentTable struct {
6464
end []int
6565
taken []bool
6666
nTaken int
67+
// breaks marks the emission points where the author broke the line:
68+
// the printer keeps those breaks, gofmt-style, instead of imposing a
69+
// line width.
70+
breaks map[Node]bool
71+
}
72+
73+
// breakAt reports whether the author broke the line at this emission point.
74+
func (t *CommentTable) breakAt(n Node) bool {
75+
return t != nil && t.breaks[n]
6776
}
6877

6978
// Exhausted reports whether every attached comment was printed.
@@ -169,6 +178,33 @@ func AttachComments(raw *RawStmt, d format.Dialect, comments []Comment, src stri
169178
}
170179
}
171180
table.taken = make([]bool, len(table.recs))
181+
182+
// Record where the author broke the line: an emission point whose
183+
// neighbouring printed nodes sit on different source lines. The printer
184+
// keeps these breaks (and only imposes new ones for comments), so the
185+
// statement's shape stays the author's.
186+
table.breaks = make(map[Node]bool)
187+
for i, a := range anchors {
188+
if !a.marker {
189+
continue
190+
}
191+
prevPos, nextPos := -1, -1
192+
for j := i - 1; j >= 0; j-- {
193+
if !anchors[j].marker {
194+
prevPos = anchors[j].pos
195+
break
196+
}
197+
}
198+
for j := i + 1; j < len(anchors); j++ {
199+
if !anchors[j].marker {
200+
nextPos = anchors[j].pos
201+
break
202+
}
203+
}
204+
if prevPos >= 0 && nextPos >= 0 && lineOf(prevPos) != lineOf(nextPos) {
205+
table.breaks[a.node] = true
206+
}
207+
}
172208
return table
173209
}
174210

@@ -197,4 +233,3 @@ func collectAnchors(n Node, d format.Dialect) (out []anchor) {
197233
}
198234
return out
199235
}
200-

internal/sql/ast/common_table_expr.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ func (n *CommonTableExpr) Format(buf *TrackedBuffer, d format.Dialect) {
3232
buf.WriteString(")")
3333
}
3434
buf.WriteString(" AS (")
35-
buf.group()
36-
buf.indent()
37-
buf.softline()
35+
buf.Group()
36+
buf.Indent()
37+
buf.Softline()
3838
buf.astFormat(n.Ctequery, d)
39-
buf.endIndent()
40-
buf.softline()
41-
buf.endGroup()
39+
buf.EndIndent()
40+
buf.Softline()
41+
buf.EndGroup()
4242
buf.WriteString(")")
4343
}

0 commit comments

Comments
 (0)