Skip to content

Commit 3c33d7b

Browse files
committed
Check span invariants over the corpus, and fix named constraints
Nothing verified byte spans: a wrong one survives the corpus and the round trip alike, and sqlc slices the source with them. TestSpans walks every parsed corpus case and asserts that a span is a valid range, that a child lies within its parent, and that statements are disjoint and in source order — the last is what makes the text between two statements, where "-- name:" comments live, unambiguous. It found that a named constraint's span started after its own CONSTRAINT clause, so the name was outside the node holding it. That is 20,815 cases checked, and one class of bug. difftest's split filter also needed two rounds: sqlite3_complete's scanner is much cruder than the tokenizer, so a ";" ends a statement for it inside brackets, inside a bind parameter's TCL-style suffix, and inside a string it pairs differently. Those are all incomparable, not wrong. 22M mutations at depth 2 now run clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JzBeCg7rjweVW3uGPg5G7T
1 parent 202f1dc commit 3c33d7b

4 files changed

Lines changed: 136 additions & 14 deletions

File tree

cmd/difftest/main.go

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,7 @@ func main() {
141141

142142
// check asks both parsers about one input and records any disagreement.
143143
func check(r *sqlitesrc.Runner, sql string, rep *report) {
144-
// The oracle splits a script into statements the way the sqlite3 shell
145-
// does, with sqlite3_complete, which counts no parentheses: a ";" inside
146-
// brackets ends a statement for it. meyer parses the script as a whole.
147-
// Where the two disagree about the boundaries there is nothing to
148-
// compare, and the disagreement is the harness's, not the parser's.
149-
if semicolonInsideParens(sql) {
144+
if splitDiffers(sql) {
150145
rep.skipped.Add(1)
151146
return
152147
}
@@ -223,9 +218,16 @@ func checkRoundTrip(sql string, rep *report) {
223218
}
224219
}
225220

226-
// semicolonInsideParens reports whether any ";" appears within brackets,
227-
// skipping the quoting and comment forms the tokenizer knows about.
228-
func semicolonInsideParens(sql string) bool {
221+
// splitDiffers reports whether the oracle would cut the script into
222+
// statements somewhere meyer would not, which makes the two incomparable.
223+
//
224+
// The oracle splits the way the sqlite3 shell does, with sqlite3_complete,
225+
// whose scanner is much cruder than the tokenizer: it understands strings,
226+
// the four quoting styles and comments, but nothing else. So a ";" ends a
227+
// statement for it even inside brackets, and even inside a token the real
228+
// tokenizer would have swallowed whole -- ":v(a;b)" is one bind parameter
229+
// to the tokenizer and two statements to sqlite3_complete.
230+
func splitDiffers(sql string) bool {
229231
depth := 0
230232
for _, t := range lexer.Lex(sql) {
231233
switch t.Kind {
@@ -239,6 +241,29 @@ func semicolonInsideParens(sql string) bool {
239241
if depth > 0 {
240242
return true
241243
}
244+
default:
245+
if strings.Contains(t.Text, ";") && !completeUnderstands(t) {
246+
return true
247+
}
248+
}
249+
}
250+
return false
251+
}
252+
253+
// completeUnderstands reports whether sqlite3_complete skips over a token
254+
// the same way the tokenizer does. The closed quoted forms it does, blob
255+
// literals included, since it reads their body as a single-quoted string.
256+
// An unterminated one it does not: in ":v('(%d)',changes());" the tokenizer
257+
// swallows the first quote into the bind parameter and finds a string
258+
// starting at the second, while sqlite3_complete pairs the two.
259+
func completeUnderstands(t token.Token) bool {
260+
switch t.Kind {
261+
case token.STRING, token.BLOB:
262+
return true
263+
case token.ID:
264+
switch t.Text[0] {
265+
case '"', '`', '[':
266+
return true
242267
}
243268
}
244269
return false

parser/parse_ddl.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,16 +105,22 @@ func (p *parser) parseColumnAndConstraintList(n *ast.CreateTableStmt) {
105105
}
106106
}
107107
var pending *ast.Ident
108+
pendingStart := 0
108109
for {
109110
start := p.cur().Pos
110111
if p.at(token.CONSTRAINT) {
111112
// tcons ::= CONSTRAINT nm names whatever constraint follows,
112113
// and is itself a complete tcons: a list may end with one.
113-
p.advance()
114+
pendingStart = p.advance().Pos
114115
pending = p.expectName()
115116
} else {
116117
c := p.parseTableConstraint(start)
117-
c.Name, pending = pending, nil
118+
if pending != nil {
119+
// The name is part of the constraint it introduces, so the
120+
// constraint's span has to start at its CONSTRAINT keyword.
121+
c.Name, c.Span.Start = pending, pendingStart
122+
pending = nil
123+
}
118124
n.Constraints = append(n.Constraints, c)
119125
}
120126
// tconscomma ::= COMMA. / tconscomma ::= . The comma is optional
@@ -145,18 +151,24 @@ func (p *parser) parseColumnDef() *ast.ColumnDef {
145151
n := &ast.ColumnDef{Name: p.expectName()}
146152
n.Type = p.parseTypeToken()
147153
var pending *ast.Ident
154+
pendingStart := 0
148155
for {
149156
cStart := p.cur().Pos
150157
if p.at(token.CONSTRAINT) { // ccons ::= CONSTRAINT nm.
151-
p.advance()
158+
pendingStart = p.advance().Pos
152159
pending = p.expectName()
153160
continue
154161
}
155162
c := p.parseColumnConstraint(cStart)
156163
if c == nil {
157164
break
158165
}
159-
c.Name, pending = pending, nil
166+
if pending != nil {
167+
// As for table constraints, the CONSTRAINT clause belongs to
168+
// the constraint that follows it.
169+
c.Name, c.Span.Start = pending, pendingStart
170+
pending = nil
171+
}
160172
n.Constraints = append(n.Constraints, c)
161173
}
162174
n.Span = p.span(start)

parser/span_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package parser_test
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
"sync/atomic"
7+
"testing"
8+
9+
"github.com/sqlc-dev/meyer/ast"
10+
"github.com/sqlc-dev/meyer/internal/testfile"
11+
"github.com/sqlc-dev/meyer/parser"
12+
)
13+
14+
// TestSpans checks the invariants sqlc relies on when it slices the source
15+
// with a node's byte offsets. Nothing else verifies them: a span can be
16+
// wrong in a tree that is otherwise perfectly shaped, and both the corpus
17+
// and the round-trip property would still pass.
18+
//
19+
// The invariants are:
20+
//
21+
// - a span is a valid range within the input;
22+
// - a child lies within its parent;
23+
// - statements are disjoint and in source order, so the text between two
24+
// of them (where the "-- name:" comments live) is unambiguous.
25+
func TestSpans(t *testing.T) {
26+
paths, err := filepath.Glob(filepath.Join("testdata", "*.test"))
27+
if err != nil {
28+
t.Fatal(err)
29+
}
30+
if len(paths) == 0 {
31+
t.Fatal("no corpus files; run go run ./cmd/regenerate-parse")
32+
}
33+
var checked atomic.Int64
34+
t.Cleanup(func() { t.Logf("checked spans of %d cases", checked.Load()) })
35+
for _, path := range paths {
36+
t.Run(strings.TrimSuffix(filepath.Base(path), ".test"), func(t *testing.T) {
37+
t.Parallel()
38+
cases, err := testfile.Read(path)
39+
if err != nil {
40+
t.Fatal(err)
41+
}
42+
for _, c := range cases {
43+
stmts, err := parser.ParseString(c.SQL)
44+
if err != nil {
45+
continue
46+
}
47+
checked.Add(1)
48+
checkStmtSpans(t, c, stmts)
49+
}
50+
})
51+
}
52+
}
53+
54+
func checkStmtSpans(t *testing.T, c testfile.Case, stmts []ast.Stmt) {
55+
t.Helper()
56+
prevEnd := 0
57+
for _, s := range stmts {
58+
if s.Pos() < prevEnd {
59+
t.Errorf("%s: statement at %d starts before the previous one ended at %d\n %q",
60+
c.Name, s.Pos(), prevEnd, c.SQL)
61+
return
62+
}
63+
prevEnd = s.End()
64+
if !checkNodeSpans(t, c, s, 0, len(c.SQL)) {
65+
return
66+
}
67+
}
68+
}
69+
70+
// checkNodeSpans walks a subtree, returning false once something is wrong so
71+
// that one bad node does not produce a page of consequences.
72+
func checkNodeSpans(t *testing.T, c testfile.Case, n ast.Node, lo, hi int) bool {
73+
t.Helper()
74+
if n.Pos() > n.End() || n.Pos() < lo || n.End() > hi {
75+
t.Errorf("%s: %T span %d:%d is not within %d:%d\n %q",
76+
c.Name, n, n.Pos(), n.End(), lo, hi, c.SQL)
77+
return false
78+
}
79+
for _, child := range n.Children() {
80+
if !checkNodeSpans(t, c, child, n.Pos(), n.End()) {
81+
return false
82+
}
83+
}
84+
return true
85+
}

parser/testdata/ast/ddl.tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ CreateTableStmt{
289289
]
290290
Constraints: [
291291
TableConstraint{
292-
Span: 434:475
292+
Span: 420:475
293293
Name: Ident{
294294
Span: 431:433
295295
Name: "pk"

0 commit comments

Comments
 (0)