Skip to content

Rewrite the parser as hand-written recursive descent, removing goyacc - #35

Merged
kyleconroy merged 20 commits into
mainfrom
claude/parser-rewrite-architecture-07i1hh
Aug 7, 2026
Merged

Rewrite the parser as hand-written recursive descent, removing goyacc#35
kyleconroy merged 20 commits into
mainfrom
claude/parser-rewrite-architecture-07i1hh

Conversation

@kyleconroy

Copy link
Copy Markdown
Contributor

Replaces the goyacc-generated LALR parser (parser.y, 17.7k lines / 713 productions, plus hintparser.y) with a hand-written recursive-descent parser, following the architecture of sqlc's other parsers (zetajones, doubleclick, meyer, teesql). goyacc, the grammar files, the generated parsers, and the generator tooling are all removed.

Contracts preserved

  • AST is unchanged. The ast package is untouched; every statement produces the same node graph as before.
  • Public API is unchanged. Parser, Scanner, New/Reset/ParseSQL/Parse/ParseOneStmt, the digester, and ParseHint keep their signatures and behavior.
  • Error messages are byte-for-byte identical. The goyacc line N column M near "..." format is reproduced exactly, including action-error positions (reduce-time lookahead) and farthest-failure reporting. A recorded corpus of 782 invalid inputs with the goyacc parser's exact error and warning strings (parser/testdata/errors.json, captured before the removal) is replayed by TestRDErrorFidelity: 782/782 match.
  • One documented deviation: OriginTextPosition is now the deterministic production-start offset. goyacc restamped it from stale parser-stack slots (empty reductions read one past the stack top), producing context-dependent values; no test relied on those. Recorded in PLAN.md.

How the rewrite was done

The migration ran statement family by statement family behind an in-process differential harness: while goyacc was still in-tree, every input handled by the RD parser was also parsed by goyacc and the two ASTs compared field-for-field with a reflective renderer (internal/dump). LALR conflict resolutions (shift-greedy cross joins, paren disambiguation between expressions and subqueries, NOT shifting, partition-option FOLLOW sets) were replicated deliberately and are commented at the sites that implement them. The harness and fallback were removed in the final milestone once error fidelity was established.

Architecture

  • parser/rd_parser.go — streaming token window over the existing lexer (O(1) memory, mark/rewind speculation), statement dispatch with one owner per leading token, and the historical statement-text bookkeeping.
  • parser/parse_*.go — one file per statement family (~25 files); every nontrivial function names the grammar production it implements.
  • parser/parse_expr.go / parse_func.go — the expression precedence ladder and atoms.
  • parser/parse_hint.go — the optimizer-hint sub-parser, replacing hintparser.y.
  • parser/rd_errors.go — error construction reproducing the goyacc format.
  • parser/token_kinds.go / hint_token_kinds.go — token constants and lexer value types, snapshotted from the generated code and hand-maintained now; TestKeywordConsistent keeps the keyword tables in exact agreement.

Results

  • Full test suite green (go test ./... -race), including the restore/roundtrip suites and the 512KB single-INSERT memory-budget test.
  • Benchmarks improve across the board vs goyacc (ns/op): SysbenchSelect 5972 → 4864, ParseSimple 17741 → 14060, ParseComplex 237400 → 209091.
  • Net diff vs the pre-rewrite tree: +21,312 / −49,325 lines.

PLAN.md records the milestone plan and the decisions that survive it; CLAUDE.md describes the new layout for future contributors.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK


Generated by Claude Code

claude added 20 commits August 6, 2026 20:14
Lay out the architecture for replacing the goyacc-generated parser
(parser.y, hintparser.y, and the goyacc toolchain) with hand-written
recursive descent, following the same architecture as the sibling
parsers zetajones, doubleclick, meyer, and teesql: oracle-generated
golden corpus with todo metadata, next-test/check-parse dev loop,
rule-attribution comments, differential testing, and fuzzing.

The AST and public parser API are frozen; the goyacc parser at a
pinned commit serves as the oracle so the new parser can be verified
field-for-field against the trees produced today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
The RD parser lexes on demand through a sliding window over the existing
Scanner, parses statement lists with exact stmtText() semantics, and
falls back to the goyacc parser for anything unimplemented. Under go
test, every input the RD parser handles is re-parsed by goyacc and the
two results compared field-for-field via the new internal/dump renderer,
turning the whole existing suite into a differential corpus.

Also: keyword-class tables extracted from parser.y (the future single
source of truth), MARINO_RD_LOG fallback logging with cmd/next-test to
rank unimplemented constructs, and internal/panics to cope with the
generated token constants shadowing the any/recover builtins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
Ports the Expression/BoolPri/PredicateExpr/BitExpr/SimpleExpr grammar
layers with the %left ladder as precedence climbing, all literal forms
(string adjacency, charset introducers, hex/bit), identifiers and
qualified generic calls, system/user variables, CASE/CAST/CONVERT,
INTERVAL date arithmetic (with the LALR-style speculation), the keyword
and non-keyword builtin function forms, aggregates with windowing
clauses, window function calls, and frame specs.

Expression origin offsets replicate the generated parser's per-reduction
yySetOffset stamping. DO statements now route through the RD parser,
putting expressions under the differential harness. The dump renderer
reads OriginalText instead of Text() so rendering never memoizes
converted text onto the tree the suite deep-compares.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
SELECT with all clause forms (options with merge semantics, field lists
with offset/text bookkeeping, FROM/joins with the LALR shift-greedy
cross-join rebalancing via ast.NewCrossJoin, index hints, partitions,
AS OF, TABLESAMPLE, LATERAL), set operations with the SetOprClauseList
folding and last-field text fixups, CTEs, TABLE/VALUES statements,
locking clauses, INTO OUTFILE, window definitions, and real SubSelect
parsing with statement-text spans.

The '(' ambiguities mirror the LALR resolutions: expression atoms and IN
predicates prefer the parenthesized-expression derivation and fall back
to the subquery one; table factors and statements resolve by scanning to
the first non-'(' token.

The differential dump no longer renders OriginTextPosition: the goyacc
runtime restamps it from stale expression values in reused parser-stack
slots (empty reductions point yyVAL one past the top), so its value
depends on stack layout rather than the statement. The RD parser stamps
deterministic production-start offsets — the value the explicit test
assertions expect — and those assertions remain the gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
INSERT/REPLACE (column lists, VALUES with row aliases, SET form, query
forms with the braced-subquery action), UPDATE (single-table with
ORDER BY/LIMIT vs multi-table), DELETE (single-table, USING, and
tables-before-FROM multi-table forms with the TableNameOptWild
disambiguation), LOAD DATA with all clauses, IMPORT INTO (file and
SELECT sources), and the BATCH non-transactional wrapper. WITH-prefixed
statements now dispatch to SELECT/UPDATE/DELETE after the clause.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
CLAUDE.md carries the family-convention dev guide, PLAN.md's testing
section now reflects the in-process differential harness as built, and
internal/rdconventions.md records the porting conventions for the
remaining grammar families.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
GrantStmt/GrantProxyStmt/GrantRoleStmt and RevokeStmt/RevokeRoleStmt
with the shared RoleOrPrivElemList parse and the ON/TO/FROM
disambiguation matching the LALR resolution, all PrivType alternatives
(including the MariaDB-gated BINLOG MONITOR error), privilege levels,
REQUIRE clauses, user specs with auth options, and the
REVOKE ALL PRIVILEGES, GRANT OPTION special case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
Ports every production of hintparser.y: all TableOptimizerHintOpt
alternatives, LEADING with nested lists, READ_FROM_STORAGE, QB_NAME
(both forms), SET_VAR, MEMORY_QUOTA with the overflow-warning path,
TIME_RANGE, and the hint identifier/value helpers. Error behavior is
byte-identical: hintparser.y has no error productions, so the first
syntax error appends one Errorf and aborts, while unrecognized-hint
warnings come from ordinary nil-hint productions.

The RD statement parser now parses hints with rdParseHint while the
goyacc oracle keeps the old hint parser, so the in-process differential
cross-checks hint ASTs inside every hinted statement. A dedicated
corpus + fixed-seed random differential test covers the hint parser
directly. ParseHint stays on goyacc until removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
Work-in-progress snapshot of the column-type system and the
SET/SHOW/transaction/misc statement families; the tree builds and the
full parser suite (including the rd/yacc differential) passes at this
point. Completion commits follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
SET in all its forms (variable assignments with scope handling and the
ON/BINARY special-value guards, PASSWORD, TRANSACTION characteristics,
CONFIG, SESSION_STATES, RESOURCE GROUP, ROLE and DEFAULT ROLE), the
complete SHOW statement family, transactions (BEGIN/START TRANSACTION
variants, COMMIT/ROLLBACK with completion types, savepoints), and
USE/KILL/TRUNCATE/FLUSH/HELP/SHUTDOWN/RESTART/LOCK-UNLOCK TABLES/
BINLOG/PREPARE/EXECUTE/DEALLOCATE/EXPLAIN-DESC/TRACE with their exact
grammar actions. SET BINDING, LOCK/UNLOCK STATS, and EXPLAIN/TRACE of
still-unported statements fall back to goyacc deliberately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
Work-in-progress snapshot from the CREATE TABLE port; the tree builds
and the full parser suite (including the rd/yacc differential) passes
at this point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
The full column type system (numeric with mode-dependent REAL/DOUBLE
handling, string types in all spellings, ENUM/SET with binary-literal
element flen math, blob/text, date-time, VECTOR), column definitions
with every ColumnOption (defaults with the parenthesized-value algebra,
generated columns, AUTO_RANDOM, references), constraints (primary/
unique/fulltext/foreign key/check, vector and columnar indexes, index
options with exact merge semantics), and CREATE TABLE end to end: LIKE
form, AS SELECT forms, every TableOption, and the complete partition
grammar with its validation aborts.

Warning-emitting actions force-lex the lookahead first (goyacc lexes
its one-token lookahead before any reduce), keeping scanner positions
inside warning strings byte-identical for the differential.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
CREATE DATABASE/INDEX/VIEW/USER/ROLE/SEQUENCE/STATISTICS/PLACEMENT
POLICY/MASKING POLICY/RESOURCE GROUP (with the CREATE OR REPLACE
disambiguation), every DROP form including DROP PREPARE and the
HYPO index variant with the lock/algorithm validation quirks, RENAME
TABLE/USER, FLASHBACK to timestamp/TSO in all six forms, and RECOVER
TABLE. CREATE/DROP BINDING and procedures deliberately still fall back
to goyacc for a later pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
AlterTableStmt in all seven alternatives and every AlterTableSpec:
add/drop/modify/change columns, constraints (including vector and
columnar indexes), all partition operations (add/drop/truncate/
coalesce/reorganize/exchange/rebuild/import/discard and the LESS THAN
forms with their node-text spans), rename variants, charset conversion,
lock/algorithm clauses, TiFlash replicas, attributes and stats options,
plus ALTER DATABASE/SEQUENCE/INSTANCE/RANGE/POLICY/RESOURCE GROUP/USER.

LALR subtleties reproduced and documented in place: the spec-level
table-option list ends at commas (%prec higherThanComma) unlike CREATE,
greedy comma consumption in partition-name and alter-order lists, the
ALTER DATABASE no-name alternative excluding CHARSET/ENCRYPTION, and
the MASKING/STATS_EXTENDED shift-over-identifier decisions. EXPLAIN of
ALTER TABLE is wired through ExplainableStmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
The full procedure grammar: parameters with modes, BEGIN/END blocks,
DECLARE for variables/conditions/cursors/handlers with the complete
handler-condition set, IF/CASE/WHILE/REPEAT, labeled statements with
the label-mismatch action, OPEN/FETCH/CLOSE, CALL with expr-origin
stamping, and DROP PROCEDURE — with the body and parameter source-text
captures transcribed exactly. Bindings: CREATE/DROP/SET BINDING in all
forms (FOR/USING, plan digests, FROM HISTORY) with their statement
source-text slicing, wired into the CREATE/DROP/SET dispatchers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
ADMIN in every alternative (DDL job control with alter-job options,
checks/checksums, index recovery/cleanup, binding admin, REPAIR TABLE,
BDR roles, workload snapshots), ANALYZE TABLE in all ten forms with
option validation, BACKUP/RESTORE with the full BRIE option set and
stream/point-in-time forms, SPLIT/DISTRIBUTE region statements, CANCEL
jobs, CALIBRATE RESOURCE, RECOMMEND INDEX, QUERY WATCH, TRAFFIC
capture/replay, PLAN REPLAYER (with its statement source-text capture),
REFRESH/LOAD/LOCK/UNLOCK STATS, and the SHOW-led BRIE/traffic forms.
ANALYZE is now wired through procedure bodies and TRACE as well.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
The RD parser can now produce final errors with goyacc's exact
semantics: position-only syntax errors formatted like Scanner.Errorf
from the offending lookahead token's recorded position, action errors
at reduce-time lookahead positions (actionErrorf), and
farthest-failure tracking so backtracking reports the deepest token a
viable parse reached, the way the single-pass automaton does.

A 782-entry error corpus (testdata/errors.json, byte-exact fields)
records every invalid input the suite exercises with goyacc's error and
warning strings; TestRDErrorFidelity replays them through the RD error
mode and now passes with zero mismatches. Detection-point fixes along
the way: greedy '.'-qualified table names, NotSym shifting before
non-predicate tokens, DEFAULT builtin-function shifting, LOAD DATA
IGNORE...LINES, SHOW TABLE PARTITION, the CREATE dispatcher error
position, and partition Validate deferring to FOLLOW-set syntax errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
Deletes parser.y (17.7k lines), hintparser.y, the generated parser.go
and hintparser.go (55k lines), the vendored goyacc tool, and the
genkeyword generator. Token constants, the lexer value types, and the
yyLexer interface are snapshotted verbatim into token_kinds.go and
hint_token_kinds.go, hand-maintained from here on. keywords.go stops
being generated; TestKeywordConsistent now keeps it, the lexer's
tokenMap, and the identifier keyword classes in exact three-way
agreement, replacing the parser.y-scraping test.

ParseSQL and ParseHint route only through the hand-written parser, with
error semantics validated byte-for-byte against the 782-entry corpus
recorded from goyacc before removal. Coverage gaps the silent fallback
had been masking are fixed: OPTIMIZE TABLE, SQL_TSI_* time units,
TIMESTAMPADD/DIFF as identifiers, TABLE/VALUES statement kinds in
subquery position, and derived tables inside parenthesized join lists.

The Parser reuses its scan buffer and token window across calls (as the
goyacc parser reused its symbol stack); benchmarks against the
pre-rewrite parser: SysbenchSelect 5972→4864 ns/op, ParseSimple
17741→14060 ns/op, ParseComplex 237400→209091 ns/op, with fewer
allocations per parse across the board.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXutbhBiY6MZWbdXRrM7CK
@kyleconroy
kyleconroy merged commit 53ee434 into main Aug 7, 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