Repository navigation
Conversation
- Reimplements the Rust generator's grammar front end, NFA/pattern build, token-conflict analysis, keyword identification, and lex-table merge/minimize/sort - Pins behaviour with parity tests against reference parser.c output (behaviour, record, golden, differential - Exports a small API for internal/tablebuild; MIT license file covers ported upstream code. - Registers the package in the CI support lane so it runs in the standard test sweep. Buckley-Change-Hash: sha256:f21ccfbee318280f5ece41f629f831b8ac123efe12cd2e5baacb1d229ff8e35e Buckley-Change-Stats: files=44 insertions=19510 deletions=1 binaries=0
odvcencio
marked this pull request as draft
November 12, 2026 00:04
- Cap one construction at 131,072 lex states, 33.5M summed automaton states, and 4.2M character transitions - Check the budget before inserting each state so an exact-fit budget still builds - Track peak sums per construction and print them from the census, showing the bundled grammars stay far below the caps. - Make the differential test assert by default: disagreements fail unless they are deliberate port refusals (not-ported or size limit) - Move the package doc to doc.go and document the differences from the generator. Buckley-Change-Hash: sha256:e32f5a7991ca1bb593c6b7b47681792999ada7434b80f845c4a553a87bb7f766 Buckley-Change-Stats: files=10 insertions=308 deletions=35 binaries=0
- Replace the single automaton-state cap and three lex-table limits with per-construction budgets - Count growth in pattern parsing, rule flattening, inline checks, NFA building, conflict search, table construction, minimization, and single-token collection - Speed duplicate-production detection in flattening with a key set instead of scanning earlier productions. - Report budget peaks in the census and classify refusals by returned category rather than error text - Add blowup and per-budget tests; refused constructions must stay under 60s and the allocation ceiling. Buckley-Change-Hash: sha256:95e0b9a375ca71868bd7be5fe17813a6fac4d2c25f42fc8aaba5eda92360dcdc Buckley-Change-Stats: files=15 insertions=570 deletions=206 binaries=0
- Check input-shape caps before any construction, refusing oversized grammars early. - Count steps of each pass behind the caps so work stops at a budget. - Replace quadratic split-state closure with a marked set; differential test pins output. - Intern production-key strings so long names no longer multiply flatten memory. - Raise limits to powers of two and assert tenfold headroom over measured grammars. Buckley-Change-Hash: sha256:0a652c5ade840430d9345316ad5c4588fed48b575723348c77043d97be20b9f2 Buckley-Change-Stats: files=22 insertions=928 deletions=105 binaries=0
- Replace per-range scans with merge-based union, binary-search intersection, and single-pass remove_intersection - Join literal runs and class ranges once instead of copying the prefix per element; keep flag filtering linear - Index map pairs by sorted key and resolve range conditions by binary search rather than a pass over every range - Group following-token unions per left symbol so neighbouring-step pairs no longer multiply set additions - Skip already-passed ranges in class difference when both classes are canonical Buckley-Change-Hash: sha256:e3fef2fd7faff811888acf53cfd3275146d95648066043cdfa60b6fbccaa6c97 Buckley-Change-Stats: files=17 insertions=1361 deletions=165 binaries=0
- Form range-work, minimization, following-char, render and symbol-cell products in int64 so caps cannot wrap on 32-bit builds. - Add always-compiled operation counters per hot function so quadratic regressions are caught deterministically. - Rewrite quadratic and size-blowup tests to assert counter bounds instead of elapsed time; drop the per-grammar census timeout. - Lazy-build unclosed-repetition messages and inspect only three runes per group head to avoid quadratic quoting/scanning. - Clarify doc.go: memory bounded by budgets, work enforced per pass, no total step bound claimed. Buckley-Change-Hash: sha256:282c471ddc9333b40218227722b8fcd6488601a7a709efa6791df7380ccdbdef Buckley-Change-Stats: files=16 insertions=646 deletions=89 binaries=0
- Ruled-out set now joins leading advances once via unionOf, avoiding quadratic clone-and-add copies - planState charges map pairs, sort, and join to bRenderWork before building the advance map - Accept-state placeholders in expand_tokens now call checkSize so automaton budget covers them - collectLargeCharacterSets charges comparison and union cost upfront for accurate work accounting - New negative-control tests verify each work counter rejects earlier quadratic implementations Buckley-Change-Hash: sha256:0938313be6b8b6b08d7bae83a42a69de69b45fd8d60be50f8812cb6e0f6e75bd Buckley-Change-Stats: files=7 insertions=484 deletions=21 binaries=0
- Integrate exact-lexer generation and scanner comparisons to check lexer records and scanner ports against pinned C. - Include offset, escaped-comma, and arena accounting corrections, retry interruption reporting, test relocation, and clearer engine requirements. - Resolve the CI workflow conflict by hand, combining both versions to retain branch checks and incoming test coverage. - Merge verification results were not supplied; the combined workflow remains unverified.
odvcencio
marked this pull request as ready for review
November 12, 2026 13:59
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Adds the package
internal/lexbuild: a Go port of the lexer path of the tree-sitter generator, release v0.27.1 (grammar front end for tokens, NFA, lex table construction, token conflicts, state merging and minimization, lex modes, reusable flags). Nothing calls the package yet, so no parse changes, andgo list -deps .does not list it. It also addslexbuildto the race lane of.github/workflows/ci.ymlthat already listslexexact.Why this approach?
What the package does
It reads a
grammar.jsonand the valid tokens of each parse state, and builds the lex states the v0.27.1 generator builds, in the record form thatinternal/lexexactreads from a generatedparser.c. Each Go function names the generator function it ports (file.rs: fn name) and keeps its order of operations and its iteration orders where they reach the output. The licence of the ported code (MIT) is ininternal/lexbuild/LICENSE, copied byte for byte from the upstream release. The package uses the standard library andinternal/lexexactonly, reads no environment variable, and builds forwasip1.Contract
grammar.jsonand one token set per parse state (the tokens that are valid lookaheads in it). The package does not compute the parse table; the tests take token sets from a referenceparser.c.NotPortedErrorthat names them: the regex flagsxand-u, script, Script_Extensions, Age and segmentation property classes.[ab]*a[ab]{24}has 33,554,432 reachable lex states; aSEQof 24 two-wayCHOICEs flattens to 16,777,216 productions;a?{1000000}has 2,000,001 automaton states). What holds in this package, and no more:ParseGrammar,Prepare, and the repetition counts of patterns). A grammar over a cap is refused with a*SizeErrorthat names the cap.*SizeErrorbyPrepare,Analyze,BuildUnmerged,BuildLexTablesorNewFirstLastSets.*SizeError. A pass without a budget is polynomial in the capped sizes. No total of work is claimed: earlier versions of this text gave one (3 x 10^10, then 6.05 x 10^10) and three reviews found work that it left out.The package is written for the repository's own grammars and was not otherwise hardened against hostile input. Each limit is more than ten times the largest value over the 204 bundled grammars that could be measured;
moveandwatwere not measured (the generator writes no reference parser for them).doc.golists the same values (TestDocListsTheLimitschecks that it does), and the census test fails if a limit is not ten times above the measured maximum.BuildUnmergedreturns anerroras a third result;NewFirstLastSetsreturns(*FirstLastSets, error); the fieldSizeError.Limitis anint64. Other exported signatures are unchanged, and the fourth review round (below) changes none.grammargenlexer builder. Its design (one automaton per lex mode, widened valid sets, fixes after construction, no end-of-input edges) differs from the generator's, so a port gives exact equality to check against.Correctness
Tests
Run on Linux (amd64 for the local checks; a Linux arm64 container, Go 1.26, network off, for the checks that need the reference files) on main
24984a9c, with this change applied:go test ./internal/lexbuild ./internal/lexexact: pass (103 top-level tests inlexbuild, none fail). Also with-race, andgo vetwithGOARCH=386andGOOS=wasip1 GOARCH=wasm. The wholelexbuildtest suite also passes withGOARCH=386(the test binary runs on this machine): 97 top-level tests pass and the rest skip because they need the reference files.Record equality with the generator's
parser.cfor three small grammars: lex states 44/44, 89/89 and 22/22 equal; lex starts 32/32, 137/137 and 28/28; reusable flags 224/224, 2,077/2,077 and 117/117. State behaviour equal in 53/53, 100/100 and 26/26 state pairs.Census over the bundled grammars against the generator, release v0.27.1 (needs the reference files; skipped without them): 202 equal; 2 differ (ocaml, verilog: the generator cannot write their unmerged table, so the coincident-token input comes from the merged table); 2 not measured (move, wat: the generator writes no reference parser for them). Ten more grammars are equal only given that merged-table input (ada, c_sharp, cpp, crystal, julia, kotlin, nim, odin, ruby, scala). Reserved-word rows are checked as a subset of the port's sets, because the generator removes tokens when it merges parse states. No measured grammar reaches a size limit.
Differential test against the generator binary (
TestLexDifferential, needs the binary, skipped without it). It asserts: any grammar that ends as differ, port refuses only, generator refuses only or reference unreadable fails the test after it prints them, and a run that compares nothing fails.-diffreportis an explicit report-only mode whose output says "report only, nothing asserted". The census returns a structured category so that a size refusal reaches its own bucket, "port refuses: size limit". Result of the asserting form: 24,000 generated grammars (seeds 6000 x 5000, 20000 x 10000, 40000 x 6000, 60000 x 3000), four runs, all pass. Both accept and equal: 14,695. Both accept and differ: 0. Port refuses only: 0. Generator refuses only: 0. Reference unreadable: 0. Port refuses as not ported yet: 418. Port refuses: size limit: 567. Both refuse: 8,319. Generator timeout, not compared: 1. The 567 size refusals are generated grammars that the generator accepts and a cap or budget refuses (for example a repetition count of 4294967295 on an empty expression, which the generator reduces to nothing). The earlier run without the caps had 15,258 equal, 429 not ported and 8,312 both refuse.Size limit tests (
size_test.go): one test for each cap and each budget lowers the limit and checks the*SizeErrorfrom each entry point (TestShapeCaps,TestLexSizeBudget,TestLexMinimizeBudget,TestTransitionIDsBudget,TestConflictSearchBudget,TestFlattenBudget,TestFlattenKeysDoNotCopyNames,TestPrecedencePairsBudget,TestFirstLastBudget,TestClosureWorkBudget,TestNFABudget,TestPatternRangesBudget,TestInlineBudget,TestSymbolCellsBudget,TestWorkBudgets).TestAddStatesMatchesGeneratorcompares the linear closure with the generator's scan-and-insert version on 300 random automata. The blowup inputs run with the default limits and are measured (peak heap, sampled every 2 ms): lex table 0.15 s and 48 MiB, conflict search 0.16 s and 28 MiB, flatten 0.04 s and 40 MiB, 10,000 precedence names 0.04 s and 5 MiB,a?{120000}0.69 s and 88 MiB. The 5,631-byte input with a 4,096-byte field name is refused by the name cap, and by the key budget (0.04 s, 39 MiB) when the name cap is raised.TestDifferentialClassifiesSizeRefusalchecks the bucket.Fourth review round (work that was quadratic inside the caps). Each fix has a test that compares the new function with a copy of the old one on random inputs (
quadratic_test.go), and a test with the input that made the old one slow. Times are from a Linux arm64 container, one run each (directional); the "before" column is the code of the pull request head, the "after" column is this tree:PATTERNof one letter with 1,000,000iflags (finding 1)filterPatternFlags(wasflags += "i")Prepare43.6 sPrepareabout 20 ms{64}(262,006 bytes; finding 2)charSetFromClass(wasadd_rangefrom index 0 for each range)Prepare52.8 s; the scan makes 64 x 65,500 x 65,499 / 2 = 137,285,904,000 iterations (computed; the test counts 1,999,000 for a class of 2,000)Prepare36 msBuildUnmergedwith token set {0} (finding 3)removeIntersectionSteps(wasremoveIntersectionwith front removals)hirConcatOf(joined a literal run by copying it for each letter)Prepare7.9 sPrepareabout 60 msThe equality tests:
TestFilterPatternFlagsMatchesAppendLoop,TestCharSetFromClassMatchesRestartLoop(canonical, ordered and unordered range lists),TestRemoveIntersectionMatchesInPlaceVersion(20,000 random pairs, including identical sets; the two remainders and the intersection, in order),TestAddMatchesRangeByRangeAdd,TestUnionOfMatchesAddOneByOne,TestHirConcatOfMatchesPairwiseJoin,TestClassDifferenceMatchesRestartScan,TestRangeConditionMatchesRangeByRange,TestDoesIntersectMatchesMerge,TestTerminalIndicesMatchesBitLoop,TestGetFollowingTokensMatchesPairwiseAdd. The search for the same shape in the whole package found these further places, each with its bound at the caps ingrowth-audit.md(rows 12, 13, 18, 20, 22, 33 to 38 and 40): the join of literal runs, the difference of classes, the unions of starting and following characters, the listing of the ranges of a state (Table.Ranges) and the joins of follow tokens were above 10^10 steps at the caps and are fixed; three budgets were charged too little (transition grouping, rendering, minimization rounds) and now count what they run; the others are stated with their numbers. Two budgets are new (bRangesWork,bFollowChars), and three limits are higher because the new count measures more: transition grouping 536,870,912 to 2,147,483,648 (measured 31,040,624 before, 157,325,188 now), rendering 33,554,432 to 134,217,728 (2,203,471 and 8,909,711).Fifth review round (three findings and a search for the same shapes; the details and the numbers are in
growth-audit.md).parseGroupconverted the whole remaining pattern to a string for each group. Four patterns ofstrings.Repeat("()", 131000)plus one letter (1,048,185 bytes of grammar) converted 68,644,524,000 runes. It now converts the next three runes, the longest prefix it tests.TestManyGroupsInspectThreeRunesEachcounts the runes it inspects: 1,571,996 (at most 3 for each of the 524,000 groups), against 68,644,524,000.TestGroupHeadMatchesWholeRemainderputs the whole-remainder version back through a test hook and compares the trees and error texts on 20,000 drawn patterns with groups, non-capturing groups, named groups, flag groups and look-around forms (4,916 patterns that parse and 35,084 that are refused; both versions agree on every one).int:(2*80500+4)*(15001+1)is 2,415,382,008, which a 32-bit build wraps to -1,879,585,288 and passes.rangesCostand its sum are nowint64, the operands are converted before the product, and the check goes throughcheckWork. The audit of every budget check and counter found four more products that could pass 2^31 - 1 at the caps and changed them:splitStateIDGroups,collectLargeCharacterSets,planState(the three throughmulWork),getFollowingChars, and the reserved-set cells inflattenGrammar(now throughcells). The other counters are checked after every addition and their limit plus one addition stays far below 2^31 (listed row by row in the audit). Regression tests:TestRangesCostDoesNotWrap,TestRangeWorkGuardRefusesTheReviewsGrammar(the review's grammar throughBuildUnmerged: the guard counts 2,415,382,008 and refuses),TestMinimizationProductDoesNotWrap,TestFollowingCharsWorkDoesNotWrap,TestReservedCellsDoNotWrap,TestRenderingProductDoesNotWrap,TestMulWorkAtTheCaps. Each computes the old arithmetic inint32and requires that it wraps, so none depends on the host. With the old arithmetic put back in a scratch copy, the 386 run fails the seven overflow tests: six report the failure andTestMinimizationProductDoesNotWrapruns into the test timeout, because the unchecked loop does not stop. On a 64-bit host the old arithmetic does not wrap, so only the 386 run and theint32checks inside the tests show the defect.opcount.go: package-level, compiled in always, added to once after the loops of the function that does the work) and compare them with a bound that a linear version meets and the earlier version misses by at least a factor of 20 (inRangechecks the factor):TestPatternFlagsAreLinear,TestClassOfManyRangesIsLinear,TestRenderingManyRangesIsLinear,TestLongLiteralIsLinear,TestClassDifferenceIsLinear,TestStartingCharsAreLinear,TestRangesOfStateAreLinearInPoints. The blowup tests (TestLexSizeBlowupRefused,TestConflictSearchBlowupRefused,TestFlattenBlowupRefused,TestFirstLastBudget,TestClosureWorkBudget) now require that no budget counted more than twice its limit, in place of a time limit. The 8-minute watchdog ofTestLexCensusis removed. Elapsed times are printed witht.Logf. The 60-second limit on the generator process in the differential test stays: it bounds the reference program, and a timeout puts the grammar in its own bucket instead of failing the test.parseCountedbuilt its "unclosed" error, which quotes the whole pattern, for every counted repetition before it knew it needed one. A pattern of 60,000a{1}quoted 240,000 bytes 60,000 times (1.4 x 10^10 bytes). The message is now made where it is returned (TestCountedRepetitionsBuildNoMessage: 0 bytes quoted for a pattern that parses, once for one that fails). The other conversions of a remainder (filterPatternFlags,hirConcatOf, the property-class name scan) were fixed earlier or read each rune once.doc.goand this text state only what is enforced (see Contract).TestDocListsTheLimitsnow also fails ifdoc.golists a limit the code does not have, if it claims a total of work, or if it lacks the sentence that the package was not hardened against hostile input.Sixth review round (three findings and a search for the same two shapes; the numbers are in
growth-audit.md).bRenderWorkdid not count the clones: five maps of 8,000 separate ranges copied 5 x 8,000 x 7,999 / 2 = 159,980,000 ranges. The leading sets are now joined once (leadingRuledOut: the same canonical union) and the pairs, their sort and the join are counted before they are made.TestAdvanceMapJoinsLeadingSetsOncecounts the ranges collected (40,000 for the review's input, run at 8,000 alternatives in the container, 20,000 in the default test at 4,000) and requires thatbRenderWorkcounted at least as many.TestRuledOutJoinMatchesCloneLoopcompares the plans with the clone-and-add loop on 300 random states, and the census and differential code compares the plan of every state of both tables with the plan made by the clone-and-add loop (no difference in 202 + 24,000 grammars).inRange,workRange).TestWorkChecksRejectEarlierImplementationsruns the earlier implementation of nine functions through the existing hook or itsOldcopy (which counts its work in the same counter), on a smaller input, and requires that the same check fails: groups (groupHeadOf, 16,008,000 against a bound of 24,000), flag filter (200,010,000 against 80,000), literal join (200,009,999 against 80,000), class of single code points (7,998,000 against 32,000), class difference (4,501,500 against 24,000), range intersection (4,507,498 against 24,000), union of starting characters (4,504,499 against 48,000), range conditions (10,000,000 against 28,000) and the advance-map sets (ruledOutOf, 10,005,000 against 40,000). The current implementation passes each check at the same size. Two counters are allowed to read zero and say so in the test: the range-insertion count ofTestStartingCharsAreLinear(it measures the absence of insertions in the middle of a vector, and the positive count of the same run is checked beside it) and the quoted-pattern bytes ofTestCountedRepetitionsBuildNoMessage(absence;TestMessageCounterCountsEagerQuotesshows the counter reads 100 x the pattern when the message is made eagerly).REPEATand+), were appended to the automaton without a check againstbNFAStates. A grammar of a pattern of 262,143 letters and the pattern()ended with 262,145 states. Each now callscheckSize(0)first.TestAcceptStateIsCheckedruns that grammar, and the boundary on seven small patterns (the limit at the number of states builds, one less refuses) and the limit zero on().growth-audit.md). Fixed besides the review's: the join of the main-token sets and the copy of the first large set incollectLargeCharacterSetswere not counted; the pairs of the advance map were not counted. Three places are bounded by the caps and not counted (the extra terminals, the lifting of common prefixes, the set operations of a class); they are listed with their bounds.doc.gois unchanged: it states only what is enforced.Independent review rounds found defects in earlier versions; each fix has a test that fails without it (for example assertions kept until expansion, JSON reading and number range as the generator's reader does them, nesting limit enforced before class-set lowering).
Repository checks on the final tree (the CI
staticcheckcommand with v0.6.1 and the package list of the workflow prints nothing):go run ./cmd/citestplanand-stale-run-names,go run ./cmd/v1guard,scripts/check_generated_ownership.shandscripts/check_evidence_paths.shpass;go run ./cmd/v1layoutprints 680/680 root Go files (the count of this tree's base; this change adds no root file);go list -deps .lists nothing fromlexbuild. Data tables copied from the Rust Unicode sources start with a plain comment, not aCode generatedmarker, because no generator in this repository owns them.Logs of the runs in this section and the growth audit: pr1618-lexbuild-daba3.tar.gz (sha256
1b2c96a6d4b1de9dd05d9e2f8de41554fa0438e60a31fe5a9e76007096a4051a).Limits and not verified
growth-audit.md). The walk for FIRST and LAST sets is the generator's (2 x rules x productions, 9.2 x 10^9 at the caps, not reduced) and the pairing of tokens depends on the parse states of the caller. The memory figure in the contract is computed from the limits and estimated unit sizes (rule node 112 B, production step 112 B, automaton state 80 B, range 8 B); it was not measured at the limits, and the large inputs of the table above were run only as far as that table says. Listing the ranges of a state costs up to 60 steps for each counted transition evaluation (three binary searches); that factor is estimated. The sampled heap peaks in the blowup tests are memory checks, not time checks; a sample can only under-measure. Go'sencoding/jsonand the Unicode tables were not audited. A full run of the package at the caps of a 32-bit build was not made.AnalyzeandBuildLexTablestakes the parse states that the caller passes as an input: the pairing of tokens that share a parse state and the grouping of parse states are budgeted, but the number of states is the caller's.unicodepackage is Unicode 15.0.0; the test reports 9,656 differing code points against the Rust tables of Unicode 17.0.0 and does not use Go's tables).grammar.jsonforms its generator draws; other forms are covered by the census and the unit tests only. The generator's own limits (time, memory) are not modelled: a grammar the generator cannot build is not compared.moveandwat). A grammar that needs more than a limit is refused, not built wrongly. A pattern such as(){4294967295}that the generator accepts is refused by the cap on repetition counts.LEXBUILD_REF, and from the binary inLEXBUILD_GEN; without them these tests skip. The census and the differential of the sixth round ran in a Linux arm64 container on the tree as pushed to the branch plus this change; the budget maxima of the census differ from the previous round in one budget only (rendering: 8,909,711 to 9,113,452, because the join of the main-token sets is now counted).Performance
scripts/run_randomized_benchmarks.sh(GOMAXPROCS=1,-count=1per process, 20 shuffle seeds,-benchtime=750ms,-benchmem); a fixed-order run is not evidencePerformance is not the goal of this PR; no benchmark comparison is claimed.
Maintainability
The diff adds one package and one name in a CI package filter. The Unicode tables and the small golden files are inputs of the port and its tests.
Self-review
Review your own diff before requesting review. Walk through it as if you're seeing it for the first time, and keep any self-review comments concise and focused on the non-obvious parts.
The limits above are the open points. The diff is about 19,500 lines, most of it Unicode tables and tests.
Due diligence