Capabilities
Most languages let any function anywhere read the clock, touch the disk or open a socket. Lumina doesn't. Effects are passed in as parameters. A function that takes no capabilities is pure: it computes a result from its arguments and does nothing else. A function that needs the outside world says which part of it in its signature.
The entry point receives the world
function main(console: Console, fs: FileSystem): Integer {
fs.write("/tmp/lumina_doc_probe.txt", "a\nb\nc")
match countLines("/tmp/lumina_doc_probe.txt", fs) {
Ok(n) => console.println(`{n} lines`),
Err(e) => console.println(`failed: {e}`)
}
return 0
}
function countLines(path: String, fs: FileSystem): Integer!Error {
text: String = fs.read(path)?
return Ok(text.split("\n").length())
}
3 lines
main asks for Console and FileSystem, and the runtime supplies the real implementations. From there they flow down explicitly. countLines can read files, and its signature says so. It cannot print or open a socket, because it was not given those capabilities.
This is dependency injection built into the language, with no container, no decorators and no reflection.
Defining your own capability
A capability is an interface marked @Capability:
@Capability interface Clock {
function now(self): Integer
}
struct FixedClock implements Clock {
timestamp: Integer
public function now(self): Integer = self.timestamp
}
Now separate the decision from the effect:
// Pure: no capability, so no I/O. Easy to test.
function isExpired(createdAt: Integer, ttl: Integer, now: Integer): Boolean = now - createdAt > ttl
// Effectful: the Clock it reads is visible in the signature.
function sessionExpired(createdAt: Integer, clock: Clock): Boolean = isExpired(createdAt, 3600, clock.now())
Testing with fakes, not mocks
Because the clock is a parameter, the test simply passes a different one. You need no monkey-patching, no global time freezing and no mocking framework:
@Test
function expiresAfterOneHour(): Void {
assert(sessionExpired(0, FixedClock { timestamp: 3601 }))
assert(!sessionExpired(0, FixedClock { timestamp: 60 }))
}
$ lumina test session.lumina
Results: 1 passed, 0 failed, 0 skipped (1 total)
All tests passed.
Pure @Test functions like this one are also run at compile time when the file is compiled. A failing pure test fails the build before the program starts:
[compile-time test] FAIL doublesCorrectly
SUMMARY: 0 passed, 1 failed
error[E4016]: ... compile-time test failed: assertion failed [1 of 1 failed]
The same pattern works for FileSystem (an in-memory fake), Network, Process, Environment and random numbers. The spec suite in tests/spec/47_capability_interfaces/ and tests/spec/27_provide/ has a working example for each.
@Provide: fill capabilities from scope
Threading a capability through many layers can get noisy. @Provide binds an implementation for a block, and calls inside that block that omit the capability argument receive it automatically:
@Capability interface Greeter {
function greet(self, name: String): String
}
struct FormalGreeter implements Greeter {
public function greet(self, name: String): String = `Dear {name}`
}
function welcome(name: String, g: Greeter): String = g.greet(name)
function main(console: Console): Integer {
@Provide(Greeter: FormalGreeter())
{
console.println(welcome("Alice")) // g is supplied by @Provide
}
return 0
}
Dear Alice
The parameter is still in welcome's signature, so the dependency stays visible. Only the call site gets shorter. Bindings are resolved by the dynamic scope, are inherited by tasks spawned inside the block, and can be shadowed by a nested @Provide. Passing the argument explicitly always wins.
Effect handlers
For effects that are better modeled as "ask the context", Lumina also has algebraic effects. A function declares the effects it performs with / {Effect}, and the caller installs a handler:
import from "lumina.meta"
effect Config {
lookup(): Integer
}
function retries(): Integer / {Config} {
return perform Config.lookup() + 1
}
function main(console: Console): Integer {
result = handle {
retries()
} with Config {
lookup() => resume(2)
}
console.println(`retries = {result}`) // retries = 3
return 0
}
An effect that escapes main without a handler is a compile error (E9003). The stdlib's LLM integration is built on this mechanism; see Interop & Stdlib.
How strict is "pure"?
- Capabilities you define and the stdlib ones (
Console,FileSystem,Clock,Network, ...) are enforced through ordinary typing. Without the parameter you cannot call the method. - Constructing a built-in ambient capability out of thin air is rejected (E4009).
- Purity checking covers every function, built-in free functions included in progress.
- Ambient built-ins such as
read_file(path)draw a warning that points to the matching capability call, herefs.read(path)in progress. The warning is W106; the built-ins keep working.@Allow("W106", "reason")silences it for one function.
Capabilities also bound code an agent writes at run time; see Agents inside your program.
Language_Capabilities.md and Language_Effects.md have the full design.
Next: Async