LEVIATHAN v0.1 · in development

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

A bind must enclose the call site, not the callee

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 again

The rules governing activation:

No other path activates
uses NS; (bulk import, including the implicit uses std;) never activates a bind — only a selective use of the type does. use NS::fn; of a non-type imports the name and activates nothing. A use of 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 a use-activation of T wins — 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 use statements 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.
Binds and 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.

Binder objects (Channel 2) planned

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.

InterfaceMembersShim over
IEnvargs(), variable(name) -> string?env::*
IConsolewrite(s), writeln(s), writeln()console
IClocknow() — epoch msstd::sysNow
IFileSystemopen(path, mode), exists(path)File
INetconnect(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.

A claim, not an enforced sandbox

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.