Skip to content

Milestones 1–2: PEG parser engine port and the accept/reject conformance gate - #2

Merged
kyleconroy merged 3 commits into
mainfrom
claude/step-1-9uyvjw
Aug 21, 2026
Merged

Milestones 1–2: PEG parser engine port and the accept/reject conformance gate#2
kyleconroy merged 3 commits into
mainfrom
claude/step-1-9uyvjw

Conversation

@kyleconroy

Copy link
Copy Markdown
Contributor

Implements the first two milestones of PLAN.md: the engine (tokenizer, grammar loader, matcher) and the corpus conformance gate — darkwing accepts a statement iff the pinned DuckDB binary parses it.

Milestone 1 — the engine

  • internal/grammar/ — the .gram/.list files vendored verbatim from the pinned DuckDB commit (provenance + SHA-256 checksums in internal/grammar/README.md), embedded via go:embed, plus the grammar loader: a port of peg_parser.cpp/parsed_grammar.cpp and the assembly step from inline_grammar.py (keyword lists become ordered-choice rules).
  • token/ — token kinds, Token{Kind, Text, Span}, and DuckDB's keyword categories. The vendored lists overlap outside reserved/unreserved (32 keywords sit in two lists at this pin, e.g. GENERATED is both column-name and func-name), so category membership is a bitmask matching the pinned build's effective behavior, not the exactly-one-list claim in upstream docs.
  • lexer/ — port of base_tokenizer.cpp + parser_tokenizer.cpp: dollar-quoted strings (with the $[0-9] parameter disambiguation and upstream's rewrite to a plain quoted literal), E'' escape strings, nested block comments, the PostgreSQL operator +-trimming rule, and the parser behavior's tokenize errors.
  • internal/matcher/ — matcher tree compiled from the grammar (matcher_factory.cpp), packrat memoization over upstream's designated rule list (parser_packrat.cpp), the transcribed rule-override table (identifier variants, literals, EndOfInput), ParseResult tree with upstream-style dumping, and furthest-failure syntax error at or near "X" reporting. Upstream quirks are ported and pinned by tests: negative lookahead drops only the ! operator (its operand still consumes), and / binds only the immediately preceding element.
  • cmd/debug-parse — dump tokens or the raw ParseResult tree for SQL on the command line.

The 19-unmatched-parens pathological case from upstream's parser blog post runs in ~1 ms as a benchmark (packrat working as advertised).

Milestone 2 — corpus + accept/reject green

The pin advanced to 8cbdaba6acb50db7780d22a65dd131584d472262, the commit of the DuckDB nightly CLI v2.0.0-alpha38195 used as the oracle — grammar, corpus expectations, and oracle all come from the same commit.

  • internal/sqltest/ — sqllogictest .test reader (extraction only; records end at empty-or-comment lines exactly as upstream's SQLLogicParser does; ${...} template substitutions are skipped, never expanded).
  • internal/testfile/ — the consolidated corpus format (cases separated by ==, SQL/expectation by --) plus *.metadata.json todo/skip sidecars keyed by case content hash so entries survive regeneration.
  • internal/duckdbsrc/ — locate/verify the pinned CLI and run statements against :memory: with extension autoloading disabled; Parser Error ⇒ must-reject, success or any post-parse error ⇒ must-accept.
  • cmd/regenerate — rebuild parser/testdata/ from a DuckDB source tree + the pinned binary (the only way expectations change); cmd/next-test — print the next todo case.
  • parser/parser_test.go — the corpus harness and gate, with -check-parse case dumps and -record-todos triage (auto-records only transformer-raised rejects; engine disagreements always fail).

Corpus results

Upstream .test files scanned 4,178
Corpus files / unique statements 4,071 / 45,226
Must-accept / must-reject 43,819 / 1,407
Grammar-level disagreements 0
Todo (transformer-raised rejects, land with milestones 3–5) 290
Skipped with notes (parser-extension records, bind-time nested parses) 11

Every accept/reject decision that depends on the grammar + matcher agrees with the pinned binary; the 290 todos are cases upstream rejects inside its transformer (positional-reference bounds, chained-comparison bans, VALUES arity, recursive-CTE modifier checks, …), which the matcher alone cannot see.

go build, go vet, gofmt, and go test -race ./... are clean; no binaries are committed (the oracle CLI is located via DARKWING_DUCKDB).

🤖 Generated with Claude Code

https://claude.ai/code/session_01CTQBC9AzfdFPFsZtwn1mHY


Generated by Claude Code

claude added 3 commits August 20, 2026 21:29
Scaffold the module (go.mod, MIT LICENSE + LICENSE.DUCKDB, CI, CLAUDE.md)
and implement the engine layer described in PLAN.md:

- internal/grammar: vendor the .gram/.list files verbatim from DuckDB
  commit 044a04a7cd39e6e8235f756597ae42dde084e5e5 (provenance + SHA-256 in
  internal/grammar/README.md) and port the grammar loader
  (peg_parser.cpp/parsed_grammar.cpp) plus the assembly step from
  inline_grammar.py (keyword lists become ordered-choice rules).
- token: token kinds, Token{Kind, Text, Span}, and DuckDB's keyword
  categories. The vendored lists overlap outside reserved/unreserved
  (32 keywords sit in two lists at this pin), so category membership is a
  bitmask, matching the pinned build's effective behavior rather than the
  exactly-one-list claim in upstream docs.
- lexer: port of base_tokenizer.cpp + parser_tokenizer.cpp, including
  dollar-quoted strings with the $[0-9] parameter disambiguation and
  quote-rewrite, E'' escape strings, nested block comments, the
  PostgreSQL operator '+' trimming rule, and the parser behavior's
  tokenize errors.
- internal/matcher: matcher tree compiled from the grammar
  (matcher_factory.cpp), packrat memoization over upstream's designated
  rule list (parser_packrat.cpp), the transcribed rule-override table
  (identifier variants, literals, EndOfInput), ParseResult tree with
  upstream-style dumping, and furthest-failure syntax errors. Upstream
  quirks are ported and pinned by tests: negative lookahead drops only
  the '!' operator (its operand still consumes), and '/' binds only the
  immediately preceding element.
- cmd/debug-parse: dump tokens or the raw ParseResult tree, peeling one
  TopLevelStatement at a time like upstream's parse loop.

The 19-unmatched-parens pathological case from upstream's blog post runs
in ~1ms as a benchmark; go test -race is clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTQBC9AzfdFPFsZtwn1mHY
Advance the pin to 8cbdaba6acb50db7780d22a65dd131584d472262, the commit of
the DuckDB nightly CLI v2.0.0-alpha38195 (the oracle): grammar, corpus
expectations, and oracle must come from the same commit, and the nightly
is the pin we can verify against. Re-vendor the grammar (describe.gram,
select.gram, create_view.gram, one unreserved keyword removed) and update
the provenance README and keyword-count tests.

New corpus machinery, per PLAN.md § Conformance oracle and corpus:

- internal/sqltest: sqllogictest .test reader, extraction only. Records
  end at empty-or-comment lines exactly as upstream's SQLLogicParser does;
  template substitutions (${...}) are skipped, never expanded.
- internal/testfile: the consolidated corpus format (cases separated by
  ==, SQL/expectation by --) plus *.metadata.json todo/skip sidecars
  keyed by case content hash so entries survive regeneration.
- internal/duckdbsrc: locate and verify the pinned CLI, run statements
  against :memory: with extension autoloading disabled, classify Parser
  Error (must-reject) vs success/post-parse errors (must-accept).
- cmd/regenerate: extract test/sql/**/*.test + test/fuzzer/**/*.test,
  dedup, run the oracle concurrently, write parser/testdata. Expectations
  are never edited by hand.
- cmd/next-test: print the next todo case from corpus metadata.
- parser/parser_test.go: the corpus harness and milestone-2 gate, with
  -check-parse case dumps and -record-todos triage (auto-records only
  transformer-raised rejects; engine disagreements always fail).

Corpus: 4,071 files, 45,226 unique statements (43,819 must-accept, 1,407
must-reject) from 4,178 upstream .test files. The gate is green: every
grammar-level accept/reject decision agrees with the pinned binary. 290
cases are todo (upstream rejects them in its transformer - positional
reference bounds, chained comparisons, recursive-CTE modifier checks,
VALUES arity, and similar - which land with milestones 3-5), and 11 are
skipped with notes (parser-extension records and nested bind-time parses
the grammar never sees).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTQBC9AzfdFPFsZtwn1mHY
Regeneration pipeline correctness:

- cmd/regenerate now removes corpus files (and their sidecars) that a
  full run did not produce, so tests deleted or renamed upstream stop
  gating darkwing against an older pin's verdicts; orphaned sidecars are
  cleaned up the same way. Partial runs (-only/-limit) skip both the
  cleanup and the corpus-wide README rewrite instead of clobbering state
  for files they did not regenerate.
- Oracle timeouts and crashes are no longer recorded as must-accept: the
  CLI produced no verdict (it may have died inside its own parser), so
  those statements are dropped as unverified and counted in the README.
  The 12 timeout statements are gone from the corpus; one file whose
  only case was unverified is deleted by the new stale cleanup.
- The bind-time nested-parse imprecision (query('...') raising Parser
  Error for its inner string) is now documented on duckdbsrc.classify
  and in the corpus README: triage stays manual because such a
  disagreement is indistinguishable from a genuine engine bug.

Format and code hygiene:

- testfile.CanStore also rejects empty statements, statements whose
  first line is empty (Read drops blank lines between cases), and
  statements whose first line starts with '#' (header absorption), with
  tests - future corpus sources hit the skippedUnstorable path instead
  of silent round-trip corruption.
- The TopLevelStatement peel loop now lives in one place,
  matcher.Engine.MatchAll (returning partial results alongside errors);
  the harness classify/dumpCase, cmd/debug-parse, and the matcher tests
  all use it.
- duckdbsrc uses errors.As instead of the hand-rolled isExit helper;
  the dead token import and its keep-alive are gone from parser_test.

The gate stays green over the regenerated corpus (43,807 must-accept,
1,407 must-reject); go test -race ./... is clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTQBC9AzfdFPFsZtwn1mHY
@kyleconroy
kyleconroy merged commit ada98a5 into main Aug 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants