Open a Vocabulary, Shadow Nothing

· 5 min read

Koru makes you say the whole name. std/io:print.ln, std/parser:match, std/regex:match — module and event, every time, everywhere. That is not verbosity nobody got around to trimming; it is what the resolver does. A bare name is stamped onto the main module with no import lookup at all — imports bring modules into the build, not names into scope. Full qualification is the only thing that reaches across a module boundary, and so it is the default, on purpose. The friction is the encapsulation.

Which is fine until you write a grammar. A std/parser rule is nothing but match, sub, and lit calls, and prefixing every one of them with std/parser: buries the grammar under its own plumbing. The rule is the vocabulary of one library; it should be allowed to speak it plainly. So there is one opt-in that opens a module’s vocabulary — inside one region, and nowhere else.

[with] opens a region

[with]M on an invocation lets the resolver consult module M for the otherwise-unresolved bare names in that invocation’s immediate lexical subtree. Here is the recursive grammar from A Grammar Is Two Glyphs again — same rules, but the verbs are now bare, because the grammar region opened std/parser:

[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("[7]", 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=7. match, sub, and lit carry no qualifier and resolve to std/parser because the grammar invocation opened it; std/io:print.ln outside the region stays fully named, because the region is exactly the subtree under the annotated call and nothing leaks past it. The target is not spelled twice — it is the module of the invocation [with] sits on, so [with]std/parser:grammar opens std/parser with no redundancy.

It fills blanks — it never overwrites one

The whole safety of the feature is in one word: unresolved-only. [with] is consulted for a bare name only if that name does not already resolve. It adds answers where there were none; it never replaces an answer that already exists. So opening a region can never change what already-working code means — the worst it can do is resolve a name that was going to be an error anyway.

The pin makes the guarantee concrete. A local warn event and std/io both own a name called warn. The region opens std/io — and bare warn still resolves to the local event, because the local one already resolved and the opened one is only consulted for blanks:

pub event warn { msg: []const u8 } -> []const u8
warn -> msg

[with]std/io:print.ln("open") |> warn(msg: "kept"): w |> std/io:print.ln("local-warn: {{ w:s }}")

Prints local-warn: kept. Had [with] shadowed on open, warn would have routed into std/io and the local event would have gone silent. It does not, because the resolver restamps a bare name only when the main module does not already own it.

Many opens, one subtree

A subtree may carry more than one [with], and every one of them is consulted. Two regions, two modules — app/alpha owns ping-a, app/beta owns ping-b, and both bare names resolve in the same chain because both regions are open over it:

std/io:print.ln("start") |> [with]app/alpha:ping-a(msg: "seed"): s |> [with]app/beta:ping-b(msg: s): t |> ping-a(msg: t): u |> ping-b(msg: u): v |> std/io:print.ln("resolved={{ v:s }}")

Prints resolved=seed. The value threads the length of the chain and both vocabularies stay open across it — the resolver collects [with] targets from every annotated step in the subtree, not just the head.

When two opens both claim a name, it refuses

Additive resolution has one honest failure mode: a bare name that two open regions both own. There is no principled winner — flow order is not a preference — so the resolver does not invent one. It stops, names both vocabularies, and hands the fix back:

// app/alpha and app/beta both export `ping`
[with]app/alpha:ping(msg: "seed"): s |> [with]app/beta:ping(msg: s): t |> ping(msg: t): u |> ...
error[KORU140]: bare name 'ping' resolves against more than one opened
vocabulary ('app.alpha' and 'app.beta') — qualify the call explicitly to pick one

The wall matters more than the convenience. A resolver that silently picks the first region is a resolver that will one day pick the wrong one and print a plausible answer — the exact failure [with] exists on the far side of. So the ambiguous case is a compile-stage rejection with the remedy in the sentence: name the module, resolve the name yourself.

Where it sits

[with] is not a parser feature. Scoped resolution is a pass in Koru’s own metacircular pipeline — koru_std/compiler.kz — that rewrites bare nodes in a [with] subtree, so it serves the constructor’s recipes and any future region-shaped library exactly as it serves grammars. Living there is also why the collision reports where it does: [with] resolution runs as metacircular analysis, so its rejection arrives at the backend-execution stage alongside every other analysis wall, not at the front-end parse.

Full names remain the default, and that is the point. [with] does not soften Koru’s stance that reaching across a module boundary should be visible — it carves one region where a library’s own vocabulary is the local dialect, guarantees that carving it changes the meaning of nothing that already worked, and refuses to guess when a name has two homes. Open a vocabulary; shadow nothing.