A Grammar Is Two Glyphs

· 6 min read

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.