Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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): value is the context’s value for the dynamic extent of body (the closure’s execution and everything it calls, transitively). run yields body’s value.
  • flavor.get() (the strict read): yields the established T.
  • flavor.get_safe() (the safe read): yields Option<T>, Some(value) under an enclosing run, None otherwise.
  • flavor.clear(body) (clear): run’s inverse — for the dynamic extent of body the context is not established. A strict get() inside is the coverage error a get() outside every run is (the refusal names the clear); get_safe() yields None; a closure created inside captures the cleared state; a run inside re-establishes it (the nearest wins). clear yields body’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 enclosing run. Code holding only strict reads carries the bare T.
  • A safe get_safe() never demands coverage: uncovered paths supply None. Code reachable without a run carries the value as Option<T>; at a covered→safe boundary the bare value wraps in Some automatically.

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: main is semantically the outermost, run-less extent);
  • a module-level initializer;
  • any caller chain that does not pass through a run of 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 run of 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 run body 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.