Spec §8 — Contexts
A context is a dynamically-scoped value: established for the dynamic extent of a body, readable from anything that runs within that extent, invisible outside it. Contexts are Vilan’s answer to ambient parameters (the current reactive owner, the current turn, the enclosing nursery) without global mutable state and without threading a parameter by hand through every signature.
Contexts are compiled away. The implementation threads each context’s value as a hidden parameter through exactly the functions and closures that transitively read it; there is no runtime storage, no async-local machinery, and a program that never creates a context pays nothing. Everything in this chapter is checked at compile time.
8.1 The model
std::context::Context<T> is the handle (appendix §A.4, a lang item:
the compiler keys on its operations). A context is created once, at
module level, and referred to by that name:
let flavor: Context<i32> = Context::new();
flavor.run(value, body)(establish):valueis the context’s value for the dynamic extent ofbody(the closure’s execution and everything it calls, transitively).runyieldsbody’s value.flavor.get()(the strict read): yields the establishedT.flavor.get_safe()(the safe read): yieldsOption<T>,Some(value)under an enclosingrun,Noneotherwise.flavor.clear(body)(clear):run’s inverse — for the dynamic extent ofbodythe context is not established. A strictget()inside is the coverage error aget()outside everyrunis (the refusal names theclear);get_safe()yieldsNone; a closure created inside captures the cleared state; aruninside re-establishes it (the nearest wins).clearyieldsbody’s value.
Context::new, run, get, get_safe and clear are intrinsics: the
threading pass rewrites their call sites away. They must be applied
directly to the context’s name (a receiver that is not a named
context is rejected). A context is not otherwise useful as a value:
moving one through a parameter or a field severs the link between its
runs and its reads; the reads can then never be covered. run’s
body argument must be a closure literal (or an injected closure value,
§8.5); clear’s must be a closure literal.
Context<T>’s value type is inferred from its first run, exactly as
a List<T>’s element type is inferred from push — wherever that run
is written: a module’s get() takes its type from a run in the entry
as from one in any module (B584).
import std::context::Context;
let flavor: Context<i32> = Context::new();
fun describe(): str {
i"flavor {flavor.get()}"
}
fun main() {
let inner = flavor.run(7, || {
print(describe()); // flavor 7 — dynamic extent reaches callees
flavor.run(9, || describe())
});
print(inner); // flavor 9 — the nearest run wins
}
Establishment nests: a read observes the nearest enclosing run.
Two runs of different contexts are independent.
8.2 Reads: strict and safe
The two read forms differ in what they demand of the program, not in what they return at a covered site:
- A strict
get()demands coverage (§8.3): the compiler proves every path that reaches it passes through an enclosingrun. Code holding only strict reads carries the bareT. - A safe
get_safe()never demands coverage: uncovered paths supplyNone. Code reachable without aruncarries the value asOption<T>; at a covered→safe boundary the bare value wraps inSomeautomatically.
Strictness is a property of code regions, propagated caller-ward: a function that (transitively) reaches a strict read is strict; a function whose only demands are safe reads is safe. One function may be strict for one context and safe for another. Each context threads independently.
8.3 Coverage
For every strict read, the compiler checks, over the whole call graph,
that the reading code cannot be entered without the value. It is a
compile error (“context … is read here, but this code can be reached
without an enclosing run”) when a strict read is reachable from:
- the program’s top level or the entrypoint
main(the uncovered roots:mainis semantically the outermost, run-less extent); - a module-level initializer;
- any caller chain that does not pass through a
runof that context.
A call dispatched on a generic bound is covered by instantiation:
the compiler resolves each call chain’s recorded type bindings — through
generic forwarding functions, recursively — and a needy candidate
implementation demands coverage only of the callers whose instantiation
actually selects it. A chain the compiler cannot resolve (an
unrecorded binding, a function taken as a value or itself reachable
through dispatch) is covered conservatively: the site is treated as
reaching every candidate, so a needy candidate demands coverage of the
caller even if another candidate would have been selected. A call
dispatched on a concrete receiver’s type (an inherited trait
default) is covered by that receiver: only the members the receiver’s
type selects demand coverage — except a self call inside a shared
default body, whose receiver is unknowable at the site and which stays
conservative. Dead code, a function with no callers at all, is
exempt (it cannot run uncovered); a function called from top level, or
taken as a value, is entered without the value and is never covered,
whatever its other callers.
The error anchors at your code. A strict read you wrote reports at
that read — anywhere in your workspace: an imported sibling module, or
a member package your workspace root’s [project] packages declares
(membership is that declaration, never where a directory happens to
live). A strict read that sits inside library code — a
standard-library helper (effect, map, and or all reach the
ambient owner through one read in std::reactive), or an external
dependency package’s function (a git dependency, or a path dependency
that is not a declared member) — reports at each of your calls that
enters the library on an uncovered path, one error per call (a call
inside a covering run is never blamed), with a secondary note
pointing at the library-internal read it reaches, in the library’s own
file. Library-internal calls never carry labels of their own.
The error also shows the whole uncovered path, not just its endpoint: every call of yours between the program’s entry and the anchor carries its own label (“the context requirement flows through this call”; at a call dispatched on a generic bound, “may flow through” — the site might select an implementation that never reads). The labels run from the outermost frame down toward the read, and a deep chain elides its tail behind an explicit “… N more uncovered calls on this path”. A covered call is clean: providing the context stops the trace.
A function that reads a context cannot be used as a value
(“… reads context …, so it can’t be used as a value”): an indirect
call would bypass the hidden parameter. Wrap it in a closure literal at
the use site: the closure captures the channel correctly (§8.4).
Safe reads never fence. In uncovered positions they read None; this
is what lets library code ask “is there an ambient X?” from anywhere
(the standard library’s spawn registration does exactly this, §7.7).
8.4 Closures capture at creation
A closure created inside a covered region captures the context value at its creation site, and the capture is what its body reads, regardless of where the closure is later called:
import std::context::Context;
let flavor: Context<i32> = Context::new();
fun main() {
let snap = flavor.run(3, || {
|| flavor.get() // captures 3 at creation
});
print(flavor.run(8, || snap())); // 3 — not 8
}
This is the stability rule §7.5 relies on: ambient values do not shift
across suspensions or across deferred invocation, because the closure’s
channel was fixed when it was made. (Contrast dynamic-binding systems
where the call site’s environment decides; in Vilan only run’s
extent and creation sites decide.)
A callback that must NOT carry the value — one minted inside a tracking
scope and invoked after it closed — is made inside clear, which fixes
its channel as absent:
import std::context::Context;
import std::option::Option::{ Some, None };
let flavor: Context<i32> = Context::new();
fun describe(): str {
match flavor.get_safe() {
Some(let value) => i"flavor {value}",
None => "no flavor",
}
}
fun main() {
let callback = flavor.run(3, || flavor.clear(|| || describe()));
print(callback()); // no flavor — minted with the value cleared
}
8.5 Injected closures: the context clause
Capture-at-creation has one structural consequence: a closure literal
passed to an establishing function is created before the extent
exists, so it would capture the caller’s (often absent) value: exactly
wrong for helpers like nursery(body) or run_with_owner(owner, body)
whose entire purpose is to run the body under a fresh value.
A context clause on a closure type solves this by deferring the
binding to the call site:
fun with_flavor<T>(body: (|| T) context flavor): T {
flavor.run(7, body)
}
A parameter, a let binding, a struct field, a generic argument
or a return type whose closure type carries context <name>
declares an injected closure:
- A closure literal written where one is expected does not capture at creation; it takes its own hidden parameter, bound anew at each call.
- Each call through the injected value demands the context from the caller like a strict read, and supplies the caller’s value.
- An unannotated local binding holding a closure literal, passed into a clause position, adopts the clause (as if the literal were written inline).
The clause is part of the closure type, not a property of the parameter that happened to write it, so it travels wherever the type does. A struct can hold an injected body:
struct Conditional {
body: (|| i32) context flavor,
}
fun hold(body: (|| i32) context flavor): Conditional {
Conditional { body }
}
fun value(held: Conditional): i32 {
flavor.run(2, held.body)
}
Reading such a field yields an injected value carrying the same
restrictions the parameter had, and a closure literal written where the
field is initialised is born under the clause’s extent — which is what
makes the value’s body run under the ambient value at the call, not
at the value’s construction. A generic argument carries the clause the
same way (Conditional<(|| i32) context flavor>, where the field is
declared as the parameter), and so does a return type:
fun make(): (|| i32) context flavor {
|| flavor.get()
}
A clause written after a non-closure return type is the function’s
own declared requirement instead (§8.6): fun f(): i32 context flavor
says what the body may read, while fun f(): (|| i32) context flavor
says what the returned closure demands of its caller. The return type
decides which reading applies, because only a closure type can carry a
clause at all. The parentheses are required for the closure reading:
fun f(): || i32 context flavor is refused, because the type grammar’s
greed takes the clause onto the closure’s own return type — i32, which
cannot carry one — and the declaration then means neither of the two
things it could have meant.
Because the binding is deferred, an injected value may only flow where
the threading can follow it: it can be called, forwarded to a
position with the same clause, or passed as run’s body. Any other
use (putting it in a collection, handing it to a position with no
clause) is a compile error (“an injected (context-typed) closure can
only be called, forwarded …, or passed to run”). Forwarding is
clause-for-clause: a value carrying one clause landing in a position
demanding another is refused naming both.
A clause may name several contexts (context (a, b)); the clause must
name context bindings, and it composes with the closure-type markers of
§7.4 ((sync || T) context turn_scope is the reactive layer’s shape).
The sync marker itself stays a parameter contract: it means “an
async argument is refused here”, and a parameter is the only position
that could otherwise accept one — a plain closure type already refuses
an async store everywhere else.
8.6 Declared requirements: the context clause on a fun
A function’s requirement is inferred from its body by default (§8.3): it is whatever its body transitively reads, and it flows to every caller. A function may instead declare it, with the same clause spelling, after the return type:
fun render(row: Row): str context (app_ctx, turn_scope) {
app_ctx.get().theme + row.label()
}
The clause closes the signature and names context bindings, in any order.
Written after a return type it precedes a borrows clause; written
without one it follows it (grammar §3.3). A function with no clause keeps
inference; the two forms coexist in one program.
A declaration changes three things, and all three follow from one idea: the requirement becomes a fact about the signature rather than about the body.
- The body’s reads must be a subset of the clause. A strict read the
clause does not declare is a compile error at the declaration
(
…'s body reads context `b`, which this `context` clause does not declare), naming the clause the body needs. Safe reads (§8.2) impose no requirement and are never part of the subset. - A clause may be wider than the body reads. Declaring a context the body does not (yet) read is a warning, not an error: the signature is the promise, and adding the read later must not break callers. The function takes the value and ignores it.
- Callers are checked against the clause alone. A call from code that
is neither inside a
runof that context nor itself declaring it is a compile error at the call, naming the clause and the callee — one hop (context `X` is required by `f`'s `context` clause, but this code can be reached without an enclosing `run` …). The declaring function’s own body is never the site of a coverage refusal: its signature says the value arrives, and the check moves to where that promise is made or broken.
This is what makes the clause a boundary. Nothing a caller is told depends on the callee’s body any more, so a diagnostic about a deep read cannot surface at an unrelated call, and a call the type solver could not resolve cannot cascade past the declaration.
A clause on a trait or impl method is not supported: a dispatched
call selects its callee at the call site, so there is no single
declaration to check the requirement against. Declare a free fun and
call it from the method.
The editor shows the clause in a hovered signature, and offers Declare the inferred contexts on the subset refusal.
8.7 Interactions
- Async (§7.5): captures are fixed at creation, so a context value
is stable across every suspension of the extent by construction. An
awaiting
runbody holds its value across its whole chain. - Spawns (§7.7): every spawn site is an implicit safe read of the standard library’s ambient nursery: inside a nursery’s extent the value is present and the task registers; outside, the read is absent and the spawn stays free-floating.
- Platforms: the threading pass runs before emission for every target; contexts work identically on process and browser platforms (no host storage is involved).
8.8 The standard library’s ambient values (informative)
std builds its ambient machinery on this one mechanism: owner_scope
(the reactive disposal owner: run_with_owner, comp),
turn_scope (the current write-batching turn: turn, batch), and
ambient_nursery (std::task: nursery registration and the
cancellation signal). Their semantics are library contracts, not
language rules; they are specified by their reference pages.