A Grammar Is Two Glyphs
Strip any PEG library down to its skeleton and two combinators remain. Ordered choice: try alternatives in order, first success wins, no reconsideration. Sequence: parse this, then that, deliver both. Everything else — repetition, optionality, lookahead sugar — is decoration on those two bones.
Koru already ships both. Ordered choice is what | branch dispatch has meant
since std/regex:match: first matching branch wins, the rest are never
consulted. And sequence is what nesting has always meant in a flow: this, then that. Which leads somewhere pleasant: a parser library for Koru does
not need a grammar formalism. It needs a vocabulary — a handful of ordinary
module events — and the grammar writes itself in glyphs you already read
every day.
A recursive grammar, on the page
std/parser declares a grammar as a region whose rules are effect arms —
each arm binds a cursor (the rule’s view of the input at its position) and
hands it to an explicit picker. The common picker is std/parser:match:
ordered choice over patterned branches, first match wins. A bare name in a
branch head references another rule, which is all recursion needs; a sequence
is a chain — sub(<rule>) demands a sub-rule, lit("...") consumes a
literal token:
[with]std/parser:grammar(nums)
! value v |> match(v)
| `-?[0-9]+` n -> n
| array a -> a
! array a |> match(a)
| `\[` _ |> sub(value): v |> lit("]") -> v
std/parser:parse("[42]", grammar: nums)
| value v |> std/io:print.ln("v={{ v:s }}")
| parse-error { line, col, expected, found } |> std/io:print.ln("err {{ line:d }}:{{ col:d }} expected {{ expected:s }} found {{ found:s }}") This prints v=42. Read the array rule aloud and it is the grammar,
verbatim: a [, then a value, then a ], produce the value. value refers to array and array refers back to value, so [[7]] parses to v=7 with no further ceremony — recursion is rule references, not machinery.
Notice what the -> arms are doing. A patterned branch in Koru has always dispatched — the input selects which body runs. Here the selected arm produces: the input selects which value the rule returns to whoever
demanded it. We started calling these patterned returns, and they’re the
semantic heart of the library — the patterned-branch twin of a multi-arm
effect’s resume, where the thing that “brings the flow to an arm” is the
match testing the input rather than a callee choosing.
And because the picker is an explicit invocation — not magic the grammar
event performs behind your back — a rule’s body is, in principle, ordinary
code. match is the common case, and the only one the compiler currently
specializes; but the seam between |> and -> is real flow, which is what
makes table-driven dispatch, grammars partially loaded from data, or a
hand-written scanner for one hairy corner expressible under the same
protocol rather than bolted on beside it.
The terminals are the same raw patterns std/regex branches use, and they
compile the same way: at compile time, each distinct pattern becomes a
specialized DFA matcher — here an anchored longest-prefix matcher, because a
grammar terminal asks “does this token start here, and how far does it
reach?“. Linear time, no backtracking inside a terminal, ReDoS-immune by
construction. The rules themselves become mutually recursive functions in the
generated code. Nothing is interpreted at runtime; the grammar dissolves the
way everything comptime in Koru dissolves.
Errors are the other half of the surface
A parser that answers “no” without saying where is half a parser. The parse-error branch carries the furthest failure position as line and
column, what was expected there, and what was found instead. Feed the grammar "[42" and the error is not “no alternative matched at byte 0” — it is:
err 1:4 expected \] found end of input The parse committed into the array alternative, consumed [ and the number,
and died wanting the close bracket — and it says so, at the exact offset.
Line/col is computed once, at the failure, by counting newlines; the happy
path never pays for it.
The same errors-first posture holds at compile time. A left-recursive rule — one that can reach itself before consuming any input — would recurse forever at runtime, so the transform rejects it during compilation, names the rule, and states the rewrite: put a consuming element first.
The library is an instrument — it found compiler bugs on day one
A new stdlib library exercises the toolchain from an angle nothing else
does. std/parser’s very first terminal was `\[` — and the regex
engine turned out to have no escape support at all. No pattern in the
language could match a literal bracket, dot, or star. One library-day old,
and it forced atom escapes, class escapes, and the \d \s \w shorthands
into the engine, where every regex user now has them.
The best find, though, was a shape. The library’s first sequencing
spelling nested bodiless | branches under | branches — and the parser
took it, because the parser nests continuations purely by indentation. It
parsed; it even ran. It was also nonsense: a | branch is picked by
something — an invocation dispatching, an effect being resumed — and a
bodiless branch picks nothing. The design walk ruled it an invalid shape,
which yielded both the ratified surface above (the picker made explicit) and
a new compile-time wall: nested branches under a bodiless branch are now
rejected, by name, with the fix in the hint. The doctrine that came out of
it is worth stating baldly, because it guards every comptime library from
here on: a transform may only consume shapes the language has ruled —
never shapes the parser merely tolerates.
Alongside those: the unused-binding check learned that a transform-root flow’s arm bindings are the transform’s data (a rule name is consumed by the rewrite, not by a body), and the emitter learned not to synthesize effect-handler machinery from transform-consumed arms. Every fix landed at the root, in the same change that surfaced it.
Where this sits
Cut one is deliberately spartan: pure PEG choice with backtracking between
alternatives, whole-input consumption, and every value a text slice. The
next moves are already visible from here. A ruled-but-unbuilt [with] annotation gives regions scoped vocabulary resolution — match(v), sub(value), lit("]") bare, with full qualification remaining the default
everywhere else — so a grammar will read as tersely as it thinks. Typed
terminals arrive the way regex named groups did — a type on a binding is a
conversion at the splice. Tree-building arrives with the constructor’s
recipe arc: a rule body that pushes into named cells makes the AST a recipe
of recipes, one grammar with many materializations. And the north star, held
loosely: Koru’s own parser is a Zig program today, and std/koru:parse exposes it — meaning the day a std/parser grammar can express Koru’s
syntax, conformance is a diff between two ASTs, and the rewrite proposes
itself with a referee already standing.
A grammar is two glyphs. The rest is vocabulary — and the vocabulary is just events.