Skip to content

Commit 3dff35f

Browse files
committed
ast: attach comments to nodes; the printer stops consulting positions
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
1 parent 84658eb commit 3dff35f

9 files changed

Lines changed: 259 additions & 165 deletions

File tree

‎internal/cmd/fmt.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -426,17 +426,17 @@ func formatWithComments(f queryFormatter, raw *ast.RawStmt, interior []ast.Comme
426426
return out, true
427427
}
428428

429-
// prettyCommented renders a statement with comments, returning "" when the
430-
// printer panics or fails to place every comment.
429+
// prettyCommented attaches a statement's comments to its nodes and renders
430+
// it, returning "" when the printer panics or fails to place every comment.
431431
func prettyCommented(raw *ast.RawStmt, f queryFormatter, comments []ast.Comment, src string) (out string) {
432432
defer func() {
433433
if r := recover(); r != nil {
434434
out = ""
435435
}
436436
}()
437-
cs := ast.NewCommentSet(comments, src)
438-
out = ast.PrettyWithComments(raw, f, fmtLineWidth, cs)
439-
if !cs.Exhausted() {
437+
ct := ast.AttachComments(raw, f, comments, src)
438+
out = ast.PrettyWithComments(raw, f, fmtLineWidth, ct)
439+
if !ct.Exhausted() {
440440
return ""
441441
}
442442
return out

‎internal/sql/ast/CLAUDE.md‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,21 @@ tokens so the flat rendering is correct SQL; layout tokens are optional.
3939

4040
### Comments
4141
`ast.File{Stmts, Comments}` is what a comment-surfacing parser returns
42-
(SQLite via meyer's ParseFile). `PrettyWithComments(n, d, width, cs)`
43-
weaves a `CommentSet` back in gofmt-style: the printer flushes each
44-
comment before the first node positioned after it, using source line
45-
numbers to decide trailing (same line, after the code) vs leading (own
46-
line, `hardline()`-separated). A line comment forces every enclosing
47-
group to break — `hardline` and `breaker` tokens measure as infinitely
48-
wide — so commented statements format instead of collapsing.
42+
(SQLite via meyer's ParseFile). `AttachComments(raw, d, comments, src)`
43+
classifies each comment once, against a dry-run of the printer: source
44+
positions and line numbers decide trailing (same line as the code
45+
before) vs leading, and each comment is attached to the emission point
46+
— node or clause/list boundary — where the printer will reach it. From
47+
then on positions are never consulted: `PrettyWithComments(n, d, width,
48+
table)` emits by node identity, which is what lets edited or synthetic
49+
trees print their comments correctly (the dave/dst model; each record
50+
also keeps the node the comment followed, for future rewriting tools).
51+
A line comment forces every enclosing group to break — `hardline` and
52+
`breaker` tokens measure as infinitely wide — so commented statements
53+
format instead of collapsing. Emission points (`beforeClause`,
54+
`boundary` in joinComma/condition) double as classification markers on
55+
the dry run, guaranteeing attach-time decisions and print-time emission
56+
agree.
4957

5058
### Dialect Interface
5159
Dialect-specific formatting is handled via the `Dialect` interface:

‎internal/sql/ast/comment.go‎

Lines changed: 150 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ package ast
22

33
import (
44
"sort"
5-
"strings"
5+
6+
"github.com/sqlc-dev/sqlc/internal/sql/format"
67
)
78

89
// File is a parsed query file: its statements together with the comments
@@ -32,59 +33,168 @@ type Comment struct {
3233
// Line reports whether the comment runs to the end of its line (-- or #),
3334
// so nothing may be printed after it on the same line.
3435
func (c Comment) Line() bool {
35-
return !strings.HasPrefix(c.Text, "/*")
36+
return len(c.Text) < 2 || c.Text[0] != '/' || c.Text[1] != '*'
3637
}
3738

38-
// CommentSet carries a statement's interior comments through the printer,
39-
// gofmt-style: the printer holds a cursor over the comment list and flushes
40-
// every comment positioned before the node it is about to print.
41-
type CommentSet struct {
42-
comments []Comment
43-
// lineStarts holds the byte offset of the first byte of every line of
44-
// the source, sqlc's stand-in for go/token.FileSet: it turns a byte
45-
// offset into a line number, which is what placement decisions compare.
46-
lineStarts []int
39+
// commentRec is one attached comment: the anchor nodes on either side of it
40+
// in print order, and whether it trails the code before it (same source
41+
// line) or leads the code after it (its own line).
42+
//
43+
// The next anchor is where the printer emits the comment; the prev anchor
44+
// is the node the comment belongs to when the tree is edited — a trailing
45+
// comment travels with the node it annotates, the way dave/dst attaches
46+
// decorations in the Go ecosystem.
47+
type commentRec struct {
48+
c Comment
49+
prev, next Node
50+
trailing bool
51+
}
4752

48-
next int // index of the next unprinted comment
49-
cursor int // greatest node position printed so far
53+
// CommentTable holds a statement's comments attached to its nodes. It is
54+
// built once by AttachComments, before any printing (or, later, editing):
55+
// each comment is classified against the statement's anchor nodes by source
56+
// position and line, and from then on positions are never consulted again —
57+
// the printer emits comments by node identity, which is what lets an edited
58+
// or synthetic tree print its comments correctly.
59+
type CommentTable struct {
60+
recs []commentRec
61+
// byNext indexes recs by their next anchor, in source order; end holds
62+
// the comments with no anchor after them, which trail the statement.
63+
byNext map[Node][]int
64+
end []int
65+
taken []bool
66+
nTaken int
5067
}
5168

52-
// NewCommentSet prepares comments for printing against source text. Only
53-
// comments inside the printed statement should be included; comments above
54-
// the statement and after its terminator are the caller's to keep.
55-
func NewCommentSet(comments []Comment, src string) *CommentSet {
56-
cs := &CommentSet{comments: comments, lineStarts: []int{0}}
57-
for i := 0; i < len(src); i++ {
58-
if src[i] == '\n' {
59-
cs.lineStarts = append(cs.lineStarts, i+1)
69+
// Exhausted reports whether every attached comment was printed.
70+
func (t *CommentTable) Exhausted() bool {
71+
return t == nil || t.nTaken == len(t.recs)
72+
}
73+
74+
// take returns and consumes the comments anchored to n, in order.
75+
func (t *CommentTable) take(n Node) []commentRec {
76+
if t == nil || n == nil {
77+
return nil
78+
}
79+
idxs := t.byNext[n]
80+
if len(idxs) == 0 {
81+
return nil
82+
}
83+
out := make([]commentRec, 0, len(idxs))
84+
for _, i := range idxs {
85+
if t.taken[i] {
86+
continue
6087
}
88+
t.taken[i] = true
89+
t.nTaken++
90+
out = append(out, t.recs[i])
6191
}
62-
sort.SliceStable(cs.comments, func(i, j int) bool {
63-
return cs.comments[i].Start < cs.comments[j].Start
64-
})
65-
return cs
92+
return out
6693
}
6794

68-
func (cs *CommentSet) lineOf(pos int) int {
69-
return sort.SearchInts(cs.lineStarts, pos+1) - 1
95+
// takeRemaining returns and consumes every comment not yet printed; the
96+
// statement is over, so everything left trails it.
97+
func (t *CommentTable) takeRemaining() []commentRec {
98+
if t == nil {
99+
return nil
100+
}
101+
var out []commentRec
102+
for i := range t.recs {
103+
if t.taken[i] {
104+
continue
105+
}
106+
t.taken[i] = true
107+
t.nTaken++
108+
out = append(out, t.recs[i])
109+
}
110+
return out
70111
}
71112

72-
// advance moves the cursor forward to pos; the cursor never moves back.
73-
func (cs *CommentSet) advance(pos int) {
74-
if pos > cs.cursor {
75-
cs.cursor = pos
113+
// AttachComments classifies a statement's interior comments against its
114+
// nodes, producing the table the printer (and any future rewriting tool)
115+
// works from. Placement follows gofmt's rules, decided here once from
116+
// source positions and lines: a comment on the same line as the code before
117+
// it trails that code; any other comment leads the first node printed after
118+
// it; a comment after the last node trails the statement.
119+
func AttachComments(raw *RawStmt, d format.Dialect, comments []Comment, src string) *CommentTable {
120+
anchors := collectAnchors(raw, d)
121+
lines := []int{0}
122+
for i := 0; i < len(src); i++ {
123+
if src[i] == '\n' {
124+
lines = append(lines, i+1)
125+
}
126+
}
127+
lineOf := func(pos int) int {
128+
return sort.SearchInts(lines, pos+1) - 1
129+
}
130+
131+
table := &CommentTable{byNext: make(map[Node][]int, len(anchors))}
132+
for _, c := range comments {
133+
// prev: the printed node with the greatest position before the
134+
// comment (for the trailing/leading call). next: the emission point
135+
// for the first printed node after the comment — the node itself,
136+
// or, when boundary markers immediately precede it in print order,
137+
// the earliest of those markers, which is where the printer will
138+
// look for this comment first.
139+
var prev, next Node
140+
prevPos := -1
141+
for _, a := range anchors {
142+
if !a.marker && a.pos < c.Start && a.pos > prevPos {
143+
prev, prevPos = a.node, a.pos
144+
}
145+
}
146+
for i, a := range anchors {
147+
if a.marker || a.pos <= c.Start {
148+
continue
149+
}
150+
j := i
151+
for j > 0 && anchors[j-1].marker {
152+
j--
153+
}
154+
next = anchors[j].node
155+
break
156+
}
157+
rec := commentRec{
158+
c: c,
159+
prev: prev,
160+
next: next,
161+
trailing: !c.OwnLine && prev != nil && lineOf(prevPos) == lineOf(c.Start),
162+
}
163+
table.recs = append(table.recs, rec)
164+
i := len(table.recs) - 1
165+
if next == nil {
166+
table.end = append(table.end, i)
167+
} else {
168+
table.byNext[next] = append(table.byNext[next], i)
169+
}
76170
}
171+
table.taken = make([]bool, len(table.recs))
172+
return table
77173
}
78174

79-
// pending returns the next unprinted comment, if any.
80-
func (cs *CommentSet) pending() (Comment, bool) {
81-
if cs.next >= len(cs.comments) {
82-
return Comment{}, false
83-
}
84-
return cs.comments[cs.next], true
175+
type anchor struct {
176+
node Node
177+
pos int
178+
// marker anchors are emission points (beforeClause, list boundaries)
179+
// rather than printed nodes; they carry no position of their own.
180+
marker bool
85181
}
86182

87-
// Exhausted reports whether every comment was printed.
88-
func (cs *CommentSet) Exhausted() bool {
89-
return cs.next >= len(cs.comments)
183+
// collectAnchors renders the statement once, flat, recording every
184+
// positioned node in the order the printer visits them. Print order is what
185+
// comment emission is defined against, so classifying against it keeps the
186+
// attach-time decision and the print-time emission point identical.
187+
func collectAnchors(n Node, d format.Dialect) (out []anchor) {
188+
defer func() {
189+
// A formatter panic here surfaces later, on the real print; anchors
190+
// collected so far still place most comments.
191+
recover()
192+
}()
193+
tb := NewTrackedBuffer()
194+
tb.anchors = &out
195+
if ft, ok := n.(nodeFormatter); ok {
196+
ft.Format(tb, d)
197+
}
198+
return out
90199
}
200+

‎internal/sql/ast/delete_stmt.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,28 +53,28 @@ func (n *DeleteStmt) Format(buf *TrackedBuffer, d format.Dialect) {
5353
}
5454

5555
if items(n.UsingClause) {
56-
buf.beforeClause(n.UsingClause)
56+
buf.beforeClause(n.UsingClause, d)
5757
buf.line()
5858
buf.WriteString("USING ")
5959
buf.join(n.UsingClause, d, ", ")
6060
}
6161

6262
if set(n.WhereClause) {
63-
buf.beforeClause(n.WhereClause)
63+
buf.beforeClause(n.WhereClause, d)
6464
buf.line()
6565
buf.WriteString("WHERE ")
6666
buf.condition(n.WhereClause, d)
6767
}
6868

6969
if set(n.LimitCount) {
70-
buf.beforeClause(n.LimitCount)
70+
buf.beforeClause(n.LimitCount, d)
7171
buf.line()
7272
buf.WriteString("LIMIT ")
7373
buf.astFormat(n.LimitCount, d)
7474
}
7575

7676
if items(n.ReturningList) {
77-
buf.beforeClause(n.ReturningList)
77+
buf.beforeClause(n.ReturningList, d)
7878
buf.line()
7979
buf.WriteString("RETURNING ")
8080
formatReturningOptions(buf, d, n.ReturningOldAlias, n.ReturningNewAlias)

‎internal/sql/ast/insert_stmt.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func (n *InsertStmt) Format(buf *TrackedBuffer, d format.Dialect) {
5353
if n.DefaultValues {
5454
buf.WriteString(" DEFAULT VALUES")
5555
} else if set(n.SelectStmt) {
56-
buf.beforeClause(n.SelectStmt)
56+
buf.beforeClause(n.SelectStmt, d)
5757
buf.line()
5858
buf.astFormat(n.SelectStmt, d)
5959
}
@@ -69,7 +69,7 @@ func (n *InsertStmt) Format(buf *TrackedBuffer, d format.Dialect) {
6969
}
7070

7171
if items(n.ReturningList) {
72-
buf.beforeClause(n.ReturningList)
72+
buf.beforeClause(n.ReturningList, d)
7373
buf.line()
7474
buf.WriteString("RETURNING ")
7575
formatReturningOptions(buf, d, n.ReturningOldAlias, n.ReturningNewAlias)

‎internal/sql/ast/join_expr.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func (n *JoinExpr) Format(buf *TrackedBuffer, d format.Dialect) {
2222
return
2323
}
2424
buf.astFormat(n.Larg, d)
25-
buf.beforeClause(n.Rarg)
25+
buf.beforeClause(n.Rarg, d)
2626
buf.line()
2727
if n.IsNatural {
2828
buf.WriteString("NATURAL ")

0 commit comments

Comments
 (0)