Lezer grammar for Julia, used by Pluto.jl. Lenient by design: it's an editor grammar, so over-accepting invalid Julia is fine; mis-parsing or erroring on valid Julia is not.
npm run prepare— full build via rollup, ~2 minutes. Required beforenpm testpicks up grammar changes (tests run againstdist/, which is gitignored).- Fast iteration:
node_modules/.bin/lezer-generator src/julia.grammar -o /tmp/out.js(~30–60s) reports grammar conflicts without bundling. Always do this first after grammar edits; only run the full build once it's conflict-free. npm testitself is instant (~50ms). Test files aretest/*.txtin@lezer/generator/testfileTests format; anonymous tokens like"["are omitted from expectations, named nodes likebegin,AssignmentOp(in)appear.
experiments/ contains a differential-testing harness (see its README):
parse thousands of real .jl files from ~/.julia/packages with the
published parser and the new build, compare error-node counts per file.
Setup: npm pack @plutojl/lezer-julia@<old-version>, unpack into
node_modules/old-lezer-julia. Demand 0 regressed files before
considering a change done. Files "changed with same error count" need
spot-checking — GLR tie flips hide there. experiments/find-regression.js
shows source context of new error nodes in a file.
Don't use julia -e to cross-check semantics: startup takes minutes here.
Issue threads + JuliaSyntax behavior knowledge are faster oracles.
- The expression layer is parameterized on index context:
expression<e>/primary-expr<e>/CallExpression<e>/tuple<e>etc. are instantiated withexpr(normal) orexpr-idx(inside[...], wherebegin/endare index values at any depth — issue #15). Index context propagates through calls/parens/tuples/braces/arrow bodies and resets inside block bodies (compound statements, do-blocks), matching Julia. - If you add a rule reachable from
expression<e>that mentionsprimary-expr<expr>(or anything expr-instantiated), you will get mass reduce/reduce conflicts in idx states. Parameterize the rule instead (this is whyOpenMacrocallExpression<e>andmacro-head<e>are parametric). compound-statement-idxduplicatescompound-statementfor bracket context. The~bgnmarker afterkw<'begin'>(in bothexpr-idxand the idxBeginStatement) is what lets GLR fork begin-as-index vs begin-block.MatrixRowelements are separated by the zero-length external tokenspaceSep(whitespace containing no newline). This is what makes[f(x)]a call instead of the juxtapositionf,(x).MatrixRowcarries@dynamicPrecedence=-1purely to break GLR ties toward non-matrix readings.- The generator
foris its own external token (genFor,extend: true+canShift), distinct from theforkeyword. This is what letssum(@show x for x in y)fork: one branch shiftsgenFor(macro args end, generator starts, matching Julia'sfor_generatorrule), the other shifts keywordfor(a for-loop as the next macro argument,f(@m x for i in y end)); the loser dies on error. A grammar-level fix is impossible — see the conflict-marker gotcha below. ..lexes via the externaltrailingInttoken: built-inFloatLiteralaccepts a trailing dot, so1..2would otherwise lex as1..2. External tokenizers run before@tokens, sotrailingInt(decimal digits immediately followed by two dots) wins there and only there. Leading dots of relative imports (import ..A,...A) lex as../...by maximal munch;ImportPathaccepts them, with~impdotsforking againstimport Base: ..(operator importable).
- Soft keywords use
kwid<>(@extend), hard keywords usekw<>(@specialize).kw<'public'>madepublicunusable as an identifier (x = public,f(public),@public,macro public);kwid<'public'>(likeas/outer/in) keeps thepublic x, ystatement working while lettingpublicbe an ordinary identifier elsewhere. - External tokenizers that call
stack.canShiftMUST becontextual: true. Otherwise their token is cached per-position and shared across GLR branches; a branch where the token is invalid silently dies. This bug hid for years in thenewlinetokenizer and only surfaced once brackets could contain begin-blocks. - Even with contextual tokenizers, a GLR fork can only survive a newline if
both branches can shift it. That's why
begin/quote/try/else/finallyhave explicitnewline?after the keyword. Usenewline?, not_t?:_tincludes thesemicolontoken (';'+) which overlaps the';'literal in ParenExpression/tuple separators → generator error "Overlapping tokens semicolon and ';'". (Hencebegin; 1; endstill doesn't parse — known pre-existing limitation.) @dynamicPrecedenceis a global footgun. Putting it onCallExpressionto win the matrix-juxtaposition ambiguity broke:(x)and$(x)(they fork against call-with-Operator-head). Prefer making the losing fork impossible (e.g. spaceSep) over dp; if you must use dp, run the full corpus diff.- GLR ties (equal dp, both branches errorless) are resolved arbitrarily
and context-dependently — the same snippet can parse differently
depending on following lines. If a corpus file changes tree without
changing error count, suspect a tie and break it deterministically.
The corpus also can't clear you of tie bugs it never exercises:
x[begin:end]misparsed as a begin-block with a bare-Operatorbody (:is a valid expression!) and no corpus file contained it. Pluto cells are short snippets, so snippet-level parses are what matter. Fixed with@dynamicPrecedence=-1on the idxBeginStatement; the whole class isx[begin <op> end]for any standalone-operator<op>. - Precedence beats
~markersin lezer-generator (addActionInner): a conflict already resolved by!precignores ambiguity markers silently. Equalizing precedence on both sides activates the marker, but for every lookahead token at that boundary, not just the one you care about — for macro-args-vs-generator this exploded the table ("Goto table too large"). When you need a fork on one specific token, give that token its own contextual external tokenizer (extend: true) instead — that's whygenForexists; same family of trick asspaceSep. - JuliaSyntax's
ParseStateflags are the oracle for context rules:for_generator=true(set inparse_call_arglist) is whyforterminates open macro arguments inside any call/bracket/paren, while@simd for ... endat statement level keeps the loop as an argument. - Token selection is parse-state-dependent:
'''as CharLiteral andx'''as triple adjoint coexist without conflict because the CharLiteral token isn't valid in post-expression states. [a -1]vs[a - 1]whitespace-sensitivity (Julia hvcat) is NOT modeled; both parse as BinaryExpression. Same class:[a ~b]parses as binding. Accepted leniency.
memory/MEMORY.md(untracked) has session notes overlapping this file.- Maintainer (fonsp) handles version bumps and
make release; don't bumppackage.jsonin PRs unless asked. - PRs: branch off
main,gh pr create --draft. dist/ is never committed.