Declarations
Namespaces & Injection
How names are organized into namespaces and pulled into scope, and how
Leviathan's compile-time dependency-injection system — bind
and inject — wires an implementation to a call site without
a container, a registry, or any runtime lookup.
Namespaces
A namespace is a pure declaration — disk layout is irrelevant, and the same namespace may be reopened in more than one place; the declarations merge.
namespace Geometry {
class Point { int x; int y; }
}
// elsewhere, same namespace, reopened — merges with the block above
namespace Geometry {
float distance(Point a, Point b) => 0.0; // placeholder body
}
A name declared inside is reached by qualifying it with
::: Geometry::Point. Namespaces can nest, so
a path like A::B is itself a valid qualifier.
Imports: use & uses
Two forms pull namespace members into the enclosing scope so they no
longer need the NS:: prefix.
uses NS; // import ALL of NS's names into the enclosing scope
uses A::B; // nested path
use NS::name; // import ONE name (value, function, class, or
use NS::name as alias; // nested namespace) — any decl kind, uniformly
use A::B; // a nested namespace itself: B::f() then works
use binds one name, selectively, with an optional
as rename that is collision-proof; uses dumps
a whole namespace's names at once. An import is a declaration in
whatever lexical scope it appears in — the top level of a file is one
such scope, so a top-of-file import covers exactly that file, not the
whole program. Like declarations generally, imports are
hoisted: visible throughout their scope regardless of
where they appear in it.
Both forms are pure compile-time resolution: an alias names the
same slot, not a runtime copy, so writing through it is a
write to the original — and is rejected at compile time exactly like a
qualified NS::name = ... assignment.
Shadowing follows one rule — specific beats bulk.
Nearer declarations shadow imports, and a single-name use
shadows a same-named uses-dumped name in the same scope.
Dependency injection
bind declares what to construct for a type; inject
selects one when a call site needs to disambiguate. There is no
container object and no runtime registry — resolution is a
compile-time, lexical lookup.
bind ILogger => ConsoleLogger(); // factory binding (body rule = method body rule)
bind ILogger { if (cfg) return A(); return B(); }
greet(); // injection is implicit when unambiguous
greet(inject ILogger); // explicit selector on collision
The factory form (=> expr) is a shorthand for the
block form ({ ... }), whose body follows the same rules
as an ordinary method body — anything that can compute and
return a value is allowed, including conditionals.
A bind is block-scoped, lexically
resolved, and nearest-wins: the closest
enclosing bind for a type is the one a call in that block
uses. Injection itself is implicit whenever exactly one bind for the
needed type is in scope; inject Type is an explicit
selector, needed only when more than one candidate could apply.
Declaring two binds for the same type in one scope is a compile
error — duplicate binding.
Bind placement
Injection resolves lexically at the injection site —
where the unfilled parameter is bound — not dynamically along the
call chain. A bind written inside a function's own body
only reaches calls made from inside that body; it has no
effect on parameters the function itself receives from its caller.
App-wide wiring therefore has to live at the outermost scope that encloses every call whose argument it should fill — typically one level above the entry-point call, not inside it:
bind IEnv => FakeEnv(...); // must enclose the CALL to main(), not main's own body
main();
Putting the same bind as the first line inside
main would not work: by the time main's body
runs, its own IEnv parameter has already been resolved
against whatever bind was in scope at the call site above it.
use-activated binds
Channel 1. A selective use of a type name
does double duty: it imports the name, and if the type's namespace
exports a bind for it, the use also installs that bind.
A factory bind (bind T => ...; or bind T { ... })
written at the top level of a namespace body is that
namespace's exported bind for T. Writing
use NS::T; (or use NS::T as A;), when
T resolves to a class or interface, installs NS's
bind for T into the scope the use is written
in — exactly as if bind T => <that factory>; (or
bind A => ...; under the alias) were textually present
there.
use std::IEnv; // brings the name; activates the system bind file-wide
class FakeEnv : IEnv {
Array<string> canned;
new FakeEnv(Array<string> a) { canned = a; }
Array<string> args() => canned;
string? variable(string n) => None;
}
{
bind IEnv => FakeEnv(["prog", "-v"]); // block-level: shadows the activated root bind
main(); // main's IEnv parameter fills with the fake
}
main(); // outside the block: the system bind againThe rules governing activation:
- No other path activates
-
uses NS;(bulk import, including the implicituses std;) never activates a bind — only a selectiveuseof the type does.use NS::fn;of a non-type imports the name and activates nothing. Auseof a type whose namespace has no top-level bind for it imports the name and activates nothing, silently. - Textual beats activated, silently, same scope
-
A hand-written
bind T => ...;in the same scope as ause-activation ofTwins — it does not trigger the duplicate-bind hard error, which is reserved for two textual claims. This is what makes the fake-in-a-block idiom above frictionless. - Activated-vs-activated can't collide
-
Bind keys are type-keyed, so two
usestatements can only install two binds for the same key by importing the same type twice — which dedupes to the one namespace bind. - Nearest-wins is unchanged
- An activated bind participates in shadowing exactly like a textual bind at the same position.
- An alias changes the name only
- Activation is identical under
as.
comptime
Binds are compile-time data, and ordinary (non-comptime)
code that injects a capability and later folds is unaffected. An
injection written directly inside a
comptime-folded root — comptime T x = (inject ICap)...;,
comptime if (...) — is denied at the injection site
itself: comptime folding runs before the checker's bind-scope pass
exists, so no bind, textual or use-activated, is ever in
scope there.
Bindings values and bind someBindings; are
not implemented — deliberately deferred. The many-scattered-binds
aggregation use case is owned by the metaprogramming splice
mechanism (rule-generated ordinary bind statements at a
splice site) instead of a second aggregation mechanism, and the
remaining manual-multi-swap use case is already one atomic,
collision-checked block of ordinary bind statements.
Use lexical factory binds — plain, or use-activated —
as shown above.
Capability interfaces
Five thin, type-keyed alternatives to the ambient globals, gated
behind ordinary bind/inject rather than a new
mechanism. All five live in namespace std, so the implicit
uses std; makes the names visible everywhere —
visibility never means provision; only an explicit
use std::I...; (Channel 1, above) activates a capability's
root bind.
| Interface | Members | Shim over |
|---|---|---|
IEnv | args(), variable(name) -> string? | env::* |
IConsole | write(s), writeln(s), writeln() | console |
IClock | now() — epoch ms | std::sysNow |
IFileSystem | open(path, mode), exists(path) | File |
INet | connect(host, port) -> TcpStream?, listen(port) | TcpStream/TcpListener |
Each interface has exactly one system implementation
(SystemEnv, SystemConsole, ...) — a stateless
shim delegating to the matching ambient surface, root-bound fresh per
injection (bind IEnv => SystemEnv();) rather than a
shared global instance. The shim is the only
implementation underneath either path, so system code and injected
code observe identical behavior. IFileSystem/INet
gate acquisition — handing back the real File or
TcpStream — not the streams' own I/O surface afterward.
The ambient globals (env::*, console, File, ...)
remain reachable from anywhere regardless of what a signature says.
The guarantee a capability interface buys is that the disciplined
path — one injected parameter — is now cheaper than the
ambient one, not that the compiler forbids the latter.