Open a Vocabulary, Shadow Nothing
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.