Context values are the program's connections to the outside world - output, input, time, randomness, files, memory - passed down the call stack implicitly instead of as arguments. Each is read with a Context Reference, &name, and can be replaced for a file, a folder, or the whole project with @inject, @injectLocally and @injectGlobally - see Context Reference and Inject for the syntax.
Most values are typed by an interface from interface - each card below lists the members it requires. Implement that interface on your own type and inject an instance, and everything that reads the value below that point uses yours instead of the builtin one. The common replacements are ready-made - see Components - so a fixed clock for tests is one line:
Anything else is a type of your own that implements the interface:
The interfaces report failures with these types (see Error):
The exact rules for declaring, requiring and injecting context values. The Context page introduces them.
A function needs a context value when it reads one, or calls a function that needs one, where nothing injected it. The compiler follows the calls and warns about each function that doesn't @require what it needs - so a function's attributes list everything it reaches outside itself, however deep the call that reaches it. An inject before a call covers it, and so does a default in the called function's own @require, which then provides the value itself. One @require lists as many as the function needs - @require(&stdout, &clock) - each written like a parameter, with its type when nothing declares it and its default after :=. The policies - &hashSeed and the others above - are read by the builtin operations everywhere, so they're never declared, and @main, @test and @bench functions, which the runtime calls with the builtins provided, declare nothing. An effect attribute that names the value says it as well: @reads(&clock), @writes(&clock) or @effects(&clock) requires &clock with the type it's declared with, so it needs no @require(&clock) beside it. See @require.
A function given as a value is followed too, through what holds it: @require on a parameter, a field or an interface's method says what calling the function there may read. Calling it needs those values, like calling a function that reads them - so timed needs &clock - and a lambda given to it may read them without the function it's written in needing them for the lambda. report still needs &clock above, because it calls timed; the lambda's &stdout isn't on step, so it's report's to declare.
Any name can be injected, not only the builtin ones - a database, a request's user, a feature flag. Each has a type, the way every variable does: context name: Type declares it, and every &name it reaches is of that type, and every inject name has to give one. It reaches as far as a definition is seen - with local in front, its own file; with nothing, its folder; with global, the whole project. Reading or injecting a name nothing declares is an error, the same as using a variable nothing defines. Two folders can each declare the same name, but only with the same type: an injection goes by the name alone, so one folder's inject reaches the other folder's reads. See Context Definition.
A value after := is the default: what &name reads where nothing up the call stack injected one, made the first time it's read and shared by every read after that - unlike a struct field's default or a @require default, which are worked out again each time. Without a default, reading a value nobody injected is an error, which !? can replace like any other.
@require declares one for a single function instead: its name, its type, and optionally a default for when nothing up the call stack injected it, made again on each call. A value declared with context - or a builtin, like &stdout - is required by its name alone, @require(&accounts), which takes the declared type; one nothing declares has its type written. See @require.
A const marked @inject is injected into every call in its file, @injectLocally into every call in its folder, and @injectGlobally into every call in the project - one of each per file, folder and project. Each declares its name too, with the const's type, over the same reach - it needs no context line of its own. This is where a program's shared dependencies live. An inject statement further down the call stack is nearer, so it still wins: a test injects its own database over the folder's. See HTTP Server.
A function can be injected the same way: its value is the function itself, so &name(...) calls whichever one is in effect - the file's, or one a caller injected over it. A definition named in backticks provides a context value by that name, and it's read back in backticks too: &`format price`, inject `format price`, reject `format price`. A context declaration and @require take a quoted name the same way. A context name is never a path, so it can't hold a /.
Every block keeps its injections to itself: an inject inside an if ends with the if's block. To inject only sometimes, inject in the block that needs it and choose the value - reading &name keeps what was already there. A loop body is a block of its own, so the injection above lasts one iteration.
#stdoutWhere the program's normal output goes - by default the process's standard output. Printing is meant to go through it, so injecting an in-memory OutputStream captures a function's output, e.g. to check it in a test.
interface.OutputStream(3)write(bytes: Bytes): IOError ! Undefinedwrites the bytes as they arewriteText(text: String): IOError ! Undefinedwrites the text, UTF-8 encodedflush(): IOError ! Undefinedpushes out anything buffered Ready-made: MemoryOutputStream, NullOutputStream, TeeOutputStream
#stderrWhere diagnostics and error messages go - by default the process's standard error, kept apart from &stdout so they don't mix into the program's actual output.
interface.OutputStream(3)write(bytes: Bytes): IOError ! Undefinedwrites the bytes as they arewriteText(text: String): IOError ! Undefinedwrites the text, UTF-8 encodedflush(): IOError ! Undefinedpushes out anything buffered Ready-made: MemoryOutputStream, NullOutputStream, TeeOutputStream
#stdinWhere the program reads its input - by default the process's standard input. Inject one over a fixed string to feed a function scripted input.
interface.InputStream(3)read(count: U64): IOError ! Bytesup to count bytes, empty at end of inputreadLine(): IOError ! String?undefined at end of inputreadAll(): IOError ! Byteseverything left until end of input Ready-made: MemoryInputStream
#clockThe source of the current time: read with &clock.now(), and what sleep waits on. Inject a fixed or manually advanced clock to make time-dependent code deterministic.
interface.Clock(2)now(): Instantthe current timesleep(duration: Duration): Undefinedwaits for duration - with nothing else to run, the program idles Ready-made: FixedClock, OffsetClock
#randomThe source of random numbers, used by every builtin that picks at random (e.g. shuffle). Inject one with a fixed seed to make runs - and tests - repeatable.
interface.Random(3)nextU64(): U64uniform over all of U64nextF64(): F64uniform in [0, 1)between(range: Range<Integer>): Integeruniform within the range Ready-made: SeededRandom
#envThe process's environment variables. Reads and writes go through this value rather than the real environment, so a test can inject its own set without touching the process's.
interface.Environment(3)get(name: String): String?undefined when not setset(name: String, value: String): Undefinedsets name to valueall(): Map<String, String>every variable, by name Provided for free, built on the members above: settings
Ready-made: MemoryEnvironment
#argsThe arguments the program was started with. Plain data rather than an interface - inject any Array<String> to run a @main function with different arguments.
#fsAccess to the file system: reading, writing and listing files and directories. Code that works with files goes through it, so an in-memory FileSystem lets that code run without touching the disk. It's there while compiling too - the compiler's own - so #comptime(...) code can read files into the program (see Compile-time Code).
interface.FileSystem(9)read(path: String | Path): IOError ! Bytesthe whole file as bytesreadText(path: String | Path): IOError ! Stringthe whole file as UTF-8 textwrite(path: String | Path, bytes: Bytes): IOError ! Undefinedcreates or replaces the filewriteText(path: String | Path, text: String): IOError ! Undefinedcreates or replaces the file, UTF-8 encodedlist(path: String | Path): IOError ! Array<String>entry names directly inside a directoryexists(path: String | Path): Booleana file or directory is thereisDirectory(path: String | Path): Booleanit's there and a directorycreateDirectory(path: String | Path): IOError ! Undefinedcreates it, and any missing parentsremove(path: String | Path): IOError ! Undefineddeletes a file or a directory, with everything in it Provided for free, built on the members above: glob
Ready-made: MemoryFileSystem
#databaseThe connection #SQL {{{ ... }}} queries run through - a query for statements that select rows, an execute for ones that change them. There's none by default, so running a query is an IOError until one's injected - usually SqliteDatabase, as a const marked @injectLocally, or SqliteDatabase.memory() in a test.
interface.Database(2)query(sql: String, parameters: Array<Unknown>): IOError ! Array<Map<String, Unknown>>the rows selectedexecute(sql: String, parameters: Array<Unknown>): IOError ! U64how many rows changed Ready-made: SqliteDatabase
#requestThe HTTP request the running route answers - its method, path, params (the {name} segments of the route's path), query string, form fields, headers and body. The server injects it for each request before calling the route; there's none outside one, and a test of a route injects its own, inject request := Request{params := Map.of(("id", "7"))}. See @serve.
#httpWhere HttpRequests are sent - &http.send(request), or request.send() - each giving a Promise<IOError ! Response> that settles once the server has answered. By default the JavaScript host's own client: on Bun, a request goes out on a thread of its own while the program waits; in a web page the browser sends it, and the program goes on from its await once the answer comes. Without a JavaScript host (plain WASI, a native executable) every request is an IOError saying so. A test injects MemoryHttpClient, which answers from a Map and keeps what it was sent. See Sending HTTP Requests.
interface.HttpClient(1)send(request: HttpRequest): Promise<IOError ! Response>the server's answer, once it's come - whatever its status, a 404 too; an IOError when none came (no network, a refused connection, a name that doesn't resolve) Ready-made: MemoryHttpClient
#timezoneThe zone local dates and times are in - what Date.today(), DateTime.now() and Instant.toDateTime() use when they're given none. Plain data: by default the host's own zone (UTC on a WASI runtime, which has none); inject TimeZone.fixed(...) or TimeZone.named("Europe/Warsaw") to pin it, in a test as anywhere.
#cancellationWhether the running work has been asked to stop. Plain data: by default one nothing cancels. Whoever starts some work injects a Cancellation of their own and cancels it - or gives it a deadline - when the work isn't wanted anymore; the work asks between its steps with &cancellation.check()!!.
#snapshotsWhere snapshot tests keep the texts they compare against - &snapshots.check("name", text)!! passes when text is the one kept, keeps it the first time, and fails showing the first line that differs otherwise; --update-snapshots keeps the new text instead. By default files in the program's folder's snapshots/; inject MemorySnapshots to test code that checks snapshots. See Testing.
interface.SnapshotStore(3)read(name: String): IOError ! String?the text kept under name - undefined when there's none yetwrite(name: String, text: String): IOError ! Undefinedkeeps text under name, replacing what was thereupdating(): Booleanwhether a text that differs replaces the kept one instead of failing - what --update-snapshots turns on Provided for free, built on the members above: check
Ready-made: FileSnapshots, MemorySnapshots
#loggerWhere structured log entries go: a LogLevel, a message, and a map of named fields. Write one with &logger.info("...") - or debug, warn, error - and add fields as a second argument when there are any. Replace it to send logs somewhere else, filter them by level, or collect them in a test; a replacement only writes log, and gets the other four for free.
interface.Logger(1)log(level: LogLevel, message: String, fields: Map<String, Unknown>): Undefinedone entry - the one method an implementation writes Provided for free, built on the members above: debug, info, warn, error
Ready-made: MemoryLogger, StreamLogger
#panichandlerWhat a panic does - panic, todo, unreachable, Optional.expect, a call past &maxCallDepth - all hand their error to it. The builtin one is AbortingPanicHandler: print the error and its trace to &stderr and end the program once pending defer blocks have run. Its handle never returns, so a replacement ends the program too - or, in a test runner, the test.
interface.PanicHandler(1)handle(error: Error): Neverpanic, todo, unreachable, Optional.expect and a too-deep call end here Ready-made: AbortingPanicHandler
#heapallocatorWhere heap memory comes from - what Pointer.allocate and RcPointer.new allocate with. Inject an arena or a tracking allocator to control or measure allocations; it returns OutOfMemoryError when a request can't be met.
interface.Allocator(3)allocate(size: U64): OutOfMemoryError ! Bytesa fresh block of size bytesresize(block: Bytes, size: U64): OutOfMemoryError ! Bytesthe block grown or shrunk to size - its contents kept up to the smaller size, possibly movedfree(block: Bytes): Undefinedgives a block back Ready-made: TrackingAllocator
#stringnormalizationThe Unicode normal form text ==, hashing and compare look at. Undefined by default - text is compared as it is, codepoint by codepoint. Inject NFC and "é" written as one codepoint equals "e\u{301}", an e and a combining accent; NFKC also equates compatibility forms, like "fi" and "fi". A plain value.
#hashseedWhat every builtin hash starts from - 0xcbf29ce484222325 by default, so hashes, and anything ordered by them, are the same on every run. A server taking keys from untrusted input can inject a random one at startup, so an attacker can't pick keys that all collide. Inject it for a whole program (@injectGlobally), not around part of one: a Map or Set built under one seed can't find its keys under another. A plain value.
#maxcalldepthHow deeply calls may nest - 100_000 by default. A call one deeper panics (see &panicHandler) instead of overflowing the platform's stack, so runaway recursion fails the same way on every target. Raise it for deeply recursive code, lower it in a test to check a function's depth. A plain value.
Ready-made implementations of the interfaces above, reached through the #builtin.implementation namespace - never as a bare name, so they don't crowd your own code. Inject one to pin time or randomness in a test, capture or silence output, fake the file system, or measure memory. Capturing what a function prints, for example:
#fixedclockonly moves when told to - sleep returns at once, moving the time forward instead of waiting
Function new(at: Instant := Instant.epoch()): FixedClocktime stands at atMethod set(at: Instant): Undefinedtime jumps to atMethod advance(by: Duration): Undefinedtime moves forward by#offsetclockanother clock shifted by a fixed amount - "run as if it were tomorrow"; sleeps really wait
Function new(offset: Duration, inner: Clock := &clock): OffsetClockinner's time plus offset#seededrandomthe same sequence for the same seed, on every platform (SplitMix64) - not for anything secret
Function new(seed: U64): SeededRandomthe sequence seed starts#memoryoutputstreamkeeps everything written, in memory - to check what a function printed
Function new(): MemoryOutputStreamemptyMethod bytes(): Byteseverything written so farMethod text(): ParseError ! Stringthe same, as UTF-8Method clear(): Undefinedforgets everything written#nulloutputstreamthrows everything away - to silence a function
Function new(): NullOutputStreama stream that keeps nothing#teeoutputstreamwrites everything to two streams, first then second - to watch output while also keeping it
Function new(first: OutputStream, second: OutputStream): TeeOutputStreamwrites go to first, then second#memoryinputstreamreads from fixed bytes - to feed a function scripted input
Function fromBytes(bytes: Bytes): MemoryInputStreamreads the bytes, then end of inputFunction fromText(text: String): MemoryInputStreamencoded as UTF-8Method remaining(): U64bytes not read yet#memoryenvironmentvariables held in a Map, apart from the process's own
Function new(variables: Map<String, String> := Map.of()): MemoryEnvironmentcopies variables#memoryfilesystemfiles and directories held in memory - "/"-separated absolute paths, "/" the root. A directory exists once created or once a file is written under it; writing creates the directories on the way
Function new(): MemoryFileSystemempty, only the rootFunction of(files: Map<String, String>): MemoryFileSystempath to text, encoded as UTF-8#sqlitedatabasean SQLite database - a file, created when it doesn't exist, or one held in memory. Needs a JavaScript host (--js, which --run and --test use): the host's own SQLite runs the statements
Function open(path: String): IOError ! SqliteDatabasethe database file at path, created when it doesn't existFunction memory(): IOError ! SqliteDatabaseempty, and gone when the program ends#memoryhttpclientanswers from a Map instead of the network, at once - and keeps every request it's sent, to check what a function asked for. A request it has no answer for gets a 404
Function new(answers: Map<String, Response> := Map.of()): MemoryHttpClientanswers by "METHOD url" - "GET https://api.example.com/todos"Method requests(): Array<HttpRequest>every request sent, in order#filesnapshotsa file for each snapshot, "
Function new(folder: String := "snapshots"): FileSnapshotsfiles in folder, relative to where the program runs - updating when the environment's REFINGERE_UPDATE_SNAPSHOTS is 1, which --update-snapshots sets Replaces &snapshots - source
#memorysnapshotssnapshots held in a Map - to test code that checks snapshots
Function new(texts: Map<String, String> := Map.of(), update: Boolean := false): MemorySnapshotsstarting with texts, by name - replacing differing texts when update is trueMethod texts(): Map<String, String>every text it keeps, by name Replaces &snapshots - source
#memoryloggerkeeps every entry, in order - to check what a function logged
Function new(): MemoryLoggerno entries yetMethod entries(): Array<(level: LogLevel, message: String, fields: Map<String, Unknown>)>every entry, in orderMethod messages(level: LogLevel?): Array<String>every message, or only those at levelMethod clear(): Undefinedforgets every entry#streamloggerone line per entry to a stream - "WARN disk almost full free=12 path=/tmp" - dropping entries below minimum (Debug < Info < Warn < Error)
Function new(stream: OutputStream := &stderr, minimum: LogLevel := LogLevel.Info): StreamLoggerwrites entries at minimum or above to stream#abortingpanichandlerthe builtin &panicHandler - prints the error and its trace() to &stderr and ends the program, once pending defer blocks have run
Function new(): AbortingPanicHandlerprint and end the program - the builtin &panicHandler Replaces &panicHandler - source
#trackingallocatorpasses every request on to another allocator, counting as it goes - and, given a limit, refuses with OutOfMemoryError any request that would take more than limit bytes live at once, to test that path
Function new(limit: U64? := undefined, inner: Allocator := &heapAllocator): TrackingAllocatorlimit undefined means no limit - every request goes on to innerMethod live(): U64bytes handed out and not yet freedMethod peak(): U64the most live() has ever beenMethod allocations(): U64successful allocate calls Replaces &heapAllocator - source