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

The Whim Programming Language

Whim is an experimental programming language inspired by PHP and Hack. As a research project, it explores language ideas by putting them into practice, with the hope that useful ones can contribute to PHP’s future.

Do not use Whim in production.

function greet(string $name): string {
  return 'Hello, ' . $name . '!';
}

write_line!(greet('Ada'));

Whim has reified generics, value-based arrays, classes, interfaces, enums, pattern matching, async tasks, and a large standard library. The whim command runs and formats source files, prints bytecode, and manages Git dependencies.

This book explains the language as it works now. Whim has no promise of backward compatibility. A later release may change or remove any rule in this book.

Try Whim in the Whim playground without installing it.

Start with Installation, then write Your First Program.

Installation

Run Whim through the whim command.

Platform support

Release builds (✅ available, ❌ unavailable):

ArchitecturemacOSLinux (glibc)Linux (musl)FreeBSDWindows (MSVC)
x86_64✅ *✅✅ *✅ *✅
x86❌❌❌❌❌
AArch64✅✅ *✅ *✅ *❌
ARM❌❌❌❌❌
RISC-V❌✅ *❌❌❌
LoongArch64❌❌❌❌❌
PowerPC64❌❌❌❌❌

* marks builds that are not tested in CI. RISC-V builds target 64-bit systems.

CI runs the Rust and Whim test suites on pushes to main and pull requests. FreeBSD and Linux musl builds run only in the release workflow, without running tests.

Shell installer

Install the latest release on macOS, Linux, or FreeBSD:

curl --proto '=https' --tlsv1.2 -sSf https://whim.sh/install.sh | bash

On Alpine Linux, install the tools needed by the installer first:

apk add bash curl ca-certificates

On FreeBSD 14.4 or later, install the tools and PostgreSQL client library first:

pkg install bash curl postgresql18-client

FreeBSD builds use the system PostgreSQL client library, including when the script does not use PostgreSQL.

Pass a version to install a specific release:

curl --proto '=https' --tlsv1.2 -sSf https://whim.sh/install.sh | bash -s -- --version=0.2.0

The installer verifies build attestations when a compatible GitHub CLI is available.

On Linux, it detects glibc or musl and downloads the matching build.

Note: Release 0.1.0 has no attestation.

Windows

On 64-bit Windows 10 version 1803 or later, download whim-<version>-x86_64-pc-windows-msvc.zip from GitHub Releases. Extract whim.exe and add its directory to your user PATH. Open a new terminal and run whim --version.

Windows uses the same standard-library API. POSIX-only operations throw Whim\Unwind\UnsupportedPlatformException; see Windows platform limits.

Manual installation

Download the archive for your system from GitHub Releases. Put the whim file (whim.exe on Windows) in a directory on your PATH. Then check it:

whim --version

Docker

The image at ghcr.io/carthage-software/whim supports amd64, arm64, and RISC-V 64. Mount a project and pass its entry file:

docker run --rm -v "$PWD:/app" ghcr.io/carthage-software/whim:latest main.whim

Each release publishes latest, the full version, and the major-minor version.

Build from source

Install Rust 1.99 or later. From the repository root, run:

cargo build --locked --release

The build produces the whim executable at target/release/whim.

On Windows, use the x86_64-pc-windows-msvc Rust toolchain. Install Visual Studio Build Tools with the C++ tools and Windows SDK, plus CMake, Perl, and NASM. The build produces target\release\whim.exe. Release builds set RUSTFLAGS=-C target-feature=+crt-static to include the C runtime. The package manager needs Git for Windows on PATH.

On FreeBSD, use the latest package repository for Rust 1.99 or later, then install the build tools and PostgreSQL client library:

pkg install rust cmake git pkgconf postgresql18-client

Source files

Whim source files use .whim. Compiled artifacts use .whia.

Continue with Your First Program.

Editor Setup

Whim includes a language server in the whim command. Keep whim on the editor’s PATH so the editor can run:

whim language-server

The language server provides basic completion, snippets, formatting, folding, selection ranges, and lint and syntax diagnostics. It loads whim.toml from each workspace folder or its nearest parent, using the same format and lint settings as the CLI. Format and lint filters also apply to files you open in the editor.

If Whim cannot find a manifest, it shows an error and checks only open files. Formatting and diagnostics still work for those files. Run whim init to create a manifest, then restart the language server to enable workspace checks. Restart it after changing the config too.

Zed

Install Whim Language Support (whim-lang) from Zed’s Extensions page:

  1. Open Zed’s Extensions page.
  2. Search for whim-lang or Whim Language Support.
  3. Select Install.

The extension’s source is available in the Whim Zed repository.

Helix

Add this to ~/.config/helix/languages.toml:

use-grammars = { only = ["whim"] }

[[language]]
name = "whim"
language-id = "whim"
scope = "source.whim"
file-types = ["whim"]
shebangs = ["whim"]
roots = ["whim.toml"]
comment-tokens = ["//"]
block-comment-tokens = { start = "/*", end = "*/" }
indent = { tab-width = 2, unit = "  " }
language-servers = ["whim"]
auto-format = true
grammar = "whim"

[language-server.whim]
command = "whim"
args = ["language-server"]

[[grammar]]
name = "whim"
source = { git = "https://github.com/carthage-software/tree-sitter-whim", rev = "80d6e34e1f9b0c8cb1c23377cc276cf82e62f3d3" }

use-grammars keeps the grammar commands limited to Whim. If the file already sets it, add "whim" to its existing list instead.

Install the full highlighting query from Tree-sitter Whim:

mkdir -p ~/.config/helix/runtime/queries/whim
curl --fail --location \
  https://raw.githubusercontent.com/carthage-software/tree-sitter-whim/80d6e34e1f9b0c8cb1c23377cc276cf82e62f3d3/queries/highlights.scm \
  --output ~/.config/helix/runtime/queries/whim/highlights.scm

Fetch and build the grammar, then check the setup:

hx --grammar fetch
hx --grammar build
hx --health whim

Remove auto-format = true if you do not want Helix to format files when you save them.

Your First Program

Create hello.whim:

write_line!('Hello, Whim!');

Run it:

whim hello.whim

Whim runs statements at file scope. You do not need a main function.

Start a project

A single file is enough for a script. For a project, create an empty directory and initialize it:

mkdir hello
cd hello
whim init

whim init creates whim.toml, src/main.whim, and tests/main.whim. It also starts a Git repository when needed and writes the Git rules used by Whim projects. Pass --no-git to skip Git setup.

Run the generated program:

whim src/main.whim

Use whim init when you start an application or library. The manifest lets Whim format the whole project and manage its Git dependencies.

Variables

A variable starts with $. Assignment creates it:

$language = 'Whim';
$year = 2026;

write_line!($language . ' ' . $year);

Whim infers the variable’s type from its value. A later assignment may change that type:

$value = 10;
$value = 'ten';

assert!($value is string);

Use == and != for equality. Conditions must produce bool:

$name = 'Ada';
if ($name != '') {
  write_line!('Hello, ' . $name);
}

if ($name) is an error. Whim does not turn strings, numbers, arrays, or null into booleans.

Functions

A function declares each parameter and its return type:

function square(int $number): int {
  return $number * $number;
}

assert!(square(9) == 81);

Whim checks the argument before the call and checks the result before it returns to the caller.

Imports

Use a fully qualified name or import a symbol:

use Whim\Str;

$parts = Str\split('one,two,three', ',');
assert!(length!($parts) == 3u);

One use form imports classes, interfaces, enums, functions, constants, aliases, and newtypes.

Format the file

From a project with a whim.toml, format every project source:

whim fmt

You may instead name one file or directory:

whim fmt hello.whim
whim fmt src

Use --check when you want a nonzero exit status instead of an edit:

whim fmt --check

The next chapter builds a program with files, loops, and a dict.

A Small Program

This program counts the words in a file. It uses command-line arguments, file I/O, functions, loops, and a dict.

Save it as words.whim:

use Whim\Env;
use Whim\File;
use Whim\Str;

function count_words(string $text): dict<string, int> {
  $counts = dict[];
  $words = Str\split(Str\lowercase($text), ' ');

  foreach ($words as $word) {
    $word = Str\trim($word);
    if ($word == '') {
      continue;
    }

    if (!contains_key!($counts, $word)) {
      $counts[$word] = 0;
    }

    $counts[$word]++;
  }

  return $counts;
}

$arguments = Env\get_arguments();
if (length!($arguments) < 1) {
  write_error_line!('usage: whim words.whim <file>');
  exit!(2);
}

$text = File\read($arguments[0]);
$counts = count_words($text);

foreach ($counts as $word => $count) {
  write_line!($word . ': ' . $count);
}

Run it:

whim words.whim README.md

Env\get_arguments() contains the arguments after the source file. The input path is at index 0.

dict[] creates an empty dict. Reading a missing key throws, so the program uses contains_key! before it reads the count. The first word starts at zero, then ++ adds one.

Dicts keep insertion order. The last loop prints words in the order in which the program first found them.

This parser splits only on spaces. A full word parser would also handle tabs, newlines, and punctuation. This example keeps the parser short so it can focus on the language.

The whim Command

The whim program runs source, formats and lints it, prints bytecode, and manages Git dependencies.

Use whim --help or whim COMMAND --help for the installed version’s exact options.

Run

The short and explicit forms are equal:

whim app.whim one two
whim run app.whim one two

The first argument after the file has index zero in Whim\Env\get_arguments().

Use - as the file to read source from standard input:

printf "write_line!('hello');\n" | whim -

Source read this way cannot use embed! because it has no directory.

Global options must come before the source file:

OptionEffect
`–colors autoalways
--config PATHload settings from another whim.toml

Use -- before a source path that starts with -.

whim run reads the nearest whim.toml, loads the bundled standard-library artifact, then compiles the entry file and files that the program loads. It does not inspect whim.lock or vendor/.

Runtime settings belong in [runtime]:

[runtime]
optimizations = "on"
call-depth = 10000
cycle-threshold = 10001
full-trace = false

Each field is optional. For one run, use WHIM_OPTIMIZATIONS, WHIM_CALL_DEPTH, WHIM_CYCLE_THRESHOLD, or WHIM_FULL_TRACE. Environment values override the file:

WHIM_OPTIMIZATIONS=off whim app.whim
WHIM_FULL_TRACE=true whim app.whim

Format

whim fmt
whim fmt src/ tests/
whim fmt --check

With no paths, the command finds the nearest whim.toml and formats the project from that file’s directory. It skips vendor/ and .git/. A command with no paths fails when it cannot find a manifest.

The command also accepts files and directories. An explicit directory ignores format.include but honors exclusions. An explicit file bypasses both. --check reports files that would change and writes nothing.

Project selection belongs in [format]:

[format]
include = ["**/*.whim"]
exclude = ["src/generated/**"]

Patterns use / and are relative to the manifest directory. Exclusion wins. The default inclusion is **/*.whim; vendor/ and .git/ are always excluded from directory walks.

Layout settings can come from the nearest whim.toml, a file passed through the global --config option, or command options:

  • --print-width N
  • --tab-width N or --tab-size N
  • --use-tabs true|false
  • --end-of-line lf|crlf

Command options override file settings.

Disassemble

whim disassemble app.whim
WHIM_OPTIMIZATIONS=off whim disassemble app.whim
printf "write_line!('hello');\n" | whim disassemble -

This command compiles the program and prints its register bytecode. It does not run the entry file. WHIM_OPTIMIZATIONS=off prints the form before optimization.

Lint

whim lint
whim lint src/ tests/
whim lint --json src/ > lint.json

With no paths, whim lint finds the nearest whim.toml and checks that project. It uses the same file discovery as fmt, with separate lint.include and lint.exclude settings. Explicit directories ignore the inclusion list; explicit files bypass file filters. Per-rule exclusions still apply.

All 15 rules start enabled. The command prints source diagnostics and leaves files unchanged. Error-level findings, syntax errors, and file errors return a nonzero status. Lower levels still print; set lint.minimum_fail_level to make them fail the command too. --json writes diagnostics as a JSON array to standard output; logs stay on standard error. See Linting for the JSON fields, rules, and settings.

Language server

whim language-server

The language server speaks LSP over standard input and output. It provides keyword completion, snippets, formatting, folding, selection ranges, and lint and syntax diagnostics. It publishes diagnostics as open documents change and supports document and workspace diagnostic requests. Workspace checks use open buffers for unsaved files and read other files from disk. The server reads lint settings at startup; restart it after changing whim.toml. It does not provide symbol navigation.

Project commands

init, add, remove, install, and update change dependency state. show, why, suggestions, and fund inspect it. why-not tests a new requirement against the graph. It creates and locks the project cache and may fetch Git tags, so it needs a writable project and network access.

These commands search the current directory and its parents for whim.toml, except init, which creates a project in the current directory. whim init --no-git skips Git setup.

Package command failures leave the old manifest, lock, vendor tree, and loader together. See Git Dependencies.

Log output

WHIM_LOG sets the CLI log filter. WHIM_LOG=off disables all log output, including error-level records. A CLI command can therefore fail with no error text while still returning a nonzero status. This is intended.

Log errors are not Whim exceptions. Disabling log output does not catch or change a thrown value, an uncaught throwable, or a panic! trace.

lint and fmt log the selected file count and a final summary at info level. The summary includes clean files, changed files, findings by level, file errors, elapsed time, and whether the command failed. For fmt --check, changed files are files that need formatting. A file error does not stop checks on other files.

Use debug to inspect the loaded configuration, file filters, discovery totals, worker count, and pipeline duration. Use trace to see why paths were skipped, which lint rules are enabled, and how long each file spends in reading, parsing, linting or formatting, and reporting:

WHIM_LOG=whim=debug whim lint src/
WHIM_LOG=whim=trace whim fmt --check src/
WHIM_LOG=error whim lint src/

The whim= filter limits these logs to the CLI. Logs go to standard error; lint diagnostics and format diffs go to standard output in file discovery order. Directory entries are sorted by name. Trace records carry the command, batch, and file context across workers. Detailed phase timers run only at trace level.

The commands check output writes and the final buffer flush. If the receiving process closes a pipe, they stop reporting and exit successfully, as with whim lint src/ | head. Other output failures return a nonzero status.

Exit status

The CLI returns zero on success. exit!($status) selects another status. panic! and an uncaught throwable use 255. CLI errors return a nonzero status. CLI error text goes to standard error. Lint findings go to standard output. Program output goes to the handle used by its write calls.

Linting

whim lint checks source for common mistakes, unclear code, and security risks. It does not run the code. All rules start enabled, and linting never changes a file.

whim lint
whim lint src/ tests/

The first form requires a whim.toml and checks its project directory. Both forms skip vendor/ and .git/ during directory walks.

The command logs a run summary to standard error. WHIM_LOG=whim=debug shows configuration, discovery, and full counts; WHIM_LOG=whim=trace adds file and phase timings. WHIM_LOG=error hides progress logs while keeping lint diagnostics. See Log output for details.

JSON output

Use --json to write one JSON array to standard output:

whim lint --json
whim lint --json src/ > lint.json

Each entry contains:

FieldValue
pathSource path, using the same spelling as text output
codeRule code, lint-attribute for an invalid lint attribute, or syntax or read for a file error
levelerror, warning, info, note, or help
messageDiagnostic message
spanstart and end objects, each with a byte offset; null for read errors
renderedFull plain-text diagnostic, including annotations and help; null for read errors

Offsets start at zero, and the end offset is exclusive. They count UTF-8 bytes, not characters. The array follows file discovery order and is empty ([]) when there are no findings. Whim writes it in batches as files finish, so it does not hold the whole report in memory.

JSON output has no ANSI color codes, even with --colors always. Logs and setup errors, such as an invalid config or a missing target path, stay on standard error. --json does not change file selection, rule settings, or exit status.

Rules

RuleDefault levelChecks
tagged-todowarningTODO comments without an owner or issue tag
tagged-fixmewarningFIXME comments without an owner or issue tag
loop-does-not-iteratewarningLoops with an unconditional break or return
yoda-conditionshelpComparisons with a variable before a literal or constant
use-compound-assignmenthelpAssignments such as $x = $x + 1
no-parameter-shadowingwarningCatch and foreach targets that reuse a parameter name
prefer-while-loopnoteFor loops with no initializer or increment
prefer-early-returnhelpOne if statement wrapping a function body
prefer-early-continuehelpOne if statement wrapping a loop body
no-multi-assignmentswarningChained assignments in one expression
readable-literalwarningLong numeric literals without underscore separators
no-assign-in-argumentwarningAssignments used directly as call arguments
no-assign-in-conditionwarningAssignments used directly as if or while conditions
no-dead-storewarningLocal assignments overwritten before a read
no-redundant-variablewarningFinal local values that are never read
no-empty-catch-clausewarningCatch clauses that do not handle their error
excessive-nestingwarningBlock depth above the set limit
cyclomatic-complexityerrorCallable or class-like branch counts above the set limit
no-redundant-statichelpstatic references where a final class makes self sufficient
no-redundant-finalhelpFinal methods in final classes or enums
no-redundant-elsehelpElse branches after a branch that always exits
no-redundant-continuehelpA continue at the end of a loop body
no-self-assignmentwarningVariables or properties assigned to themselves
no-redundant-usewarningUnused or same-namespace imports
no-redundant-readonlyhelpReadonly properties repeated in a readonly class
no-redundant-nullsafehelpNullsafe property links made redundant by ??
constant-conditionhelpIf conditions with a result fixed by the source
inline-variable-returnhelpA local assigned only for the next return
no-redundant-string-concathelpAdjacent string literals joined with .
no-empty-commentnoteComments that contain no text
no-literal-passworderrorLiteral passwords, tokens, secrets, and API keys
no-insecure-comparisonerrorDirect comparisons of password or token values
sensitive-parametererrorSensitive parameters missing the marker attribute
no-debug-symbolswarningdebug! calls in application code
disallowed-symbolswarningExact symbols named in the rule settings

Use a tag such as TODO(#123), TODO(@owner), or FIXME(owner).

no-dead-store checks each function, method, or closure on its own. It tracks branches separately and counts closure captures as reads. It skips parameters, scoped bindings, variables used in catch or finally blocks, and names that start with an underscore. It reports an overwritten assignment, not every unused variable.

no-redundant-variable reports the last value written to a local when no later code reads it. It keeps loop-carried reads, branch joins, closure captures, and finally blocks in scope. Its help keeps expressions that have side effects.

The security rules infer sensitive values from names ending in password, token, secret, apikey, or api_key, without regard to case. They provide local checks, not a proof that a program is secure. Use Whim\Hash\equals() for secret string comparisons and Whim\Password\verify() for password hashes. sensitive-parameter accepts the exact Whim\Marker\SensitiveParameter attribute, including an imported alias, and skips Boolean-only parameters.

Scoped lint attributes

Use Whim\Lint\Allow, Warn, Deny, and Forbid to set a rule’s level on a declaration:

use Whim\Lint\{Allow, Deny};

#[Deny('no-debug-symbols')]
class HeaderParser {
  #[Allow(
    rule: 'no-insecure-comparison',
    reason: 'The token is not security-sensitive',
  )]
  public function hasToken(
    string $value,
    #[Allow(
      rule: 'sensitive-parameter',
      reason: 'This is an HTTP grammar token',
    )]
    string $token,
  ): bool {
    return $value == $token;
  }
}

Allow suppresses the rule. Warn reports a warning. Deny reports an error. Forbid reports an error and prevents any nested scope from lowering its level. Trying to lower a forbidden rule produces an error on the conflicting attribute, even when the code has no finding for that rule. A nested Deny keeps an enclosing Forbid in force.

The setting covers the declaration, its signature, and its contents. It ends at the declaration’s boundary. Nested declarations can override an outer Allow, Warn, or Deny. Attributes follow source order; the last setting for a rule wins unless an earlier Forbid prevents it. The setting follows source scope, not calls or inheritance.

All four attributes are repeatable. They support classes, interfaces, enums, functions, methods, closures, parameters, properties, constants, enum cases, type aliases, newtypes, and files. Each takes a required string $rule and an optional null|string $reason = null, both exposed as public properties. Use one attribute per rule.

Use #![...] to set a rule’s level for the whole file:

namespace App;

use Whim\Lint\Allow;

#![Allow(
  rule: 'no-debug-symbols',
  reason: 'This script inspects runtime values',
)]

debug!(vec[1, 2, 3]);

File settings cover all code and comments in the file, including code before the attribute and code in other namespaces. File attributes follow source order, and each name uses the namespace and imports at its location. Declaration attributes override file Allow, Warn, or Deny settings, even when the file attribute appears later. A file Forbid prevents any declaration or later file attribute from lowering the level.

Arguments may use positions or names. Named arguments can appear in either order, and a positional rule can be followed by reason: .... The reason explains the setting; the linter only evaluates the rule argument.

Rule names must use exact lint codes. The linter reads string literals, parentheses, and string concatenation, so these are equivalent:

use Whim\Lint\Allow;

#[Allow('sensitive-parameter')]
#[Allow(rule: 'sensitive' . '-' . 'parameter')]
function token_value(string $token): string {
  return $token;
}

The linter does not resolve constants or run code to read a rule name. An unknown rule, missing rule argument, duplicate or extra argument, non-string rule name, or rule expression that it cannot read produces a lint-attribute error. These errors cannot be suppressed or lowered by another attribute or by rule settings.

Project settings supply the starting policy. Warn, Deny, or Forbid can enable a disabled rule within a declaration or file. File and per-rule exclusions still apply. The CLI and language server use the same attribute rules, and minimum_fail_level still determines whether a warning fails the command.

Settings

File patterns use / and are relative to the manifest directory. Exclusions win. Explicit directories ignore include; explicit files bypass both file filters. Per-rule exclusions apply to every file.

[lint]
include = ["**/*.whim"]
exclude = ["src/generated/**"]
minimum_fail_level = "error"

[lint.rules.tagged-todo]
level = "warning"
exclude = ["tests/**"]

[lint.rules.yoda-conditions]
mode = "deny"

[lint.rules.no-assign-in-condition]
ignore-while-statements = true

[lint.rules.excessive-nesting]
threshold = 7
function-like-threshold = 4

[lint.rules.readable-literal]
min-digits = 5

[lint.rules.prefer-early-return]
max-allowed-statements = 0

[lint.rules.no-empty-comment]
preserve-single-line-comments = true

[lint.rules.cyclomatic-complexity]
threshold = 15
method-threshold = 8

[lint.rules.disallowed-symbols]
symbols = [
    'Legacy\run',
    { name = 'Legacy\Client', help = 'Use Modern\Client.', level = 'error' },
]

[lint.rules.no-literal-password]
enabled = false

Each rule accepts enabled, level, and exclude. Rule levels and minimum_fail_level accept error, warning, info, note, or help. minimum_fail_level controls the exit status without hiding lower-level findings.

yoda-conditions.mode defaults to require; choose deny to keep variables on the left. no-assign-in-condition.ignore-while-statements defaults to false and covers both while and do/while. excessive-nesting.threshold defaults to 7; its optional function-like-threshold counts each function body from 1. The two early-exit rules allow no wrapped statements by default. readable-literal.min-digits defaults to 5. no-empty-comment checks line and block comments unless preserve-single-line-comments is true. cyclomatic-complexity.threshold defaults to 15. Its optional method-threshold also checks each method.

disallowed-symbols uses exact, case-sensitive symbol names. It checks declarations, imports and aliases, attributes, types, patterns, direct calls, construction, constants, and static members. A leading \ in a setting is optional. Each entry may set its own help text and level. An empty list reports nothing. Dynamic callable or class values and late-bound static references do not have one source-resolved name, so the rule skips them.

The language server uses each workspace’s whim.toml, including file and rule exclusions. These filters also apply to open files. Editors can request a full workspace check when a manifest exists; without one, Whim reports the missing config and checks only open files. Open buffers take precedence over disk contents. Restart the server after creating or changing the config.

Source Text and Comments

A Whim source file contains UTF-8 text. The usual file suffix is .whim. ASCII spaces, tabs, line feeds, carriage returns, vertical tabs, and form feeds separate tokens but have no other meaning. The formatter uses two spaces for each level by default.

Identifiers

An identifier starts with an ASCII letter, _, or a non-ASCII UTF-8 byte. Later bytes may also be decimal digits. A backslash joins namespace segments.

$answer2 = 42;

function café(): string {
  return 'coffee';
}

assert!(café() == 'coffee');

Whim compares names by their UTF-8 bytes. It does not fold case or normalize Unicode. Two spellings that look alike may still name different symbols.

Statements and blocks

Most simple statements end with ;:

$answer = 42;
write_line!($answer);

A block uses braces and does not take a trailing semicolon:

if (true) {
  write_line!('inside the block');
}

Control-flow headers use parentheses. This applies to if, while, for, foreach, and catch.

Comments

Whim has line comments, block comments, and doc comments:

// This comment ends with the line.

/* This comment may
   span several lines. */

/** Returns the answer. */
function answer(): int {
  return 42;
}

A doc comment belongs to the declaration that follows it. # does not start a comment. The tokens #[, #![, and #{ start a declaration attribute list, a file attribute list, and an object shape, respectively. Each is a single token: whitespace or comments cannot separate its characters.

Shebang line

A file may start with a Unix shebang:

#!/usr/bin/env whim

The shebang must start at byte zero. Whim treats # anywhere else as an error unless it begins #[, #![, or #{.

Number literals

Unsuffixed integers use signed 64-bit values. The source forms are:

assert!(42 == 4_2);
assert!(0xff == 255);
assert!(0b1010 == 10);
assert!(0o755 == 493);

Underscores may split digits. A decimal integer cannot start with 0 unless it is zero. Write 0o for octal.

Append u or U for an unsigned 64-bit integer. Both spellings have the same meaning in every base. The optional i or I suffix marks a signed integer; it has the same meaning as no suffix.

assert!(42u == 42U);
assert!(42 == 42i);
assert!(42i == 42I);
assert!(0xffu == 255u);
assert!(0b1010U == 10u);
assert!(0o755u == 493u);
assert!(18_446_744_073_709_551_615u == Whim\Math\UINT_MAX);

A suffix follows the digits without a gap. Float literals cannot take an integer suffix. An out-of-range literal is a compile error; an unsuffixed literal does not become unsigned when it exceeds the signed maximum. The compiler rejects a negated nonzero unsigned literal such as -24u. -0u is valid and equals 0u.

Floats use decimal digits and may use an exponent. A decimal point may have digits on only one side:

assert!(1.5 == 15e-1);
assert!(.5 == 0.5);
assert!(5. == 5.0);
assert!(1_000.0 == 1000.0);

The runtime stores floats as IEEE 754 double-precision values.

String tokens

Strings explains single-quoted and double-quoted strings. Backticks do not quote strings.

Top-level declarations

Functions, classes, interfaces, enums, constants, aliases, and newtypes may appear only at file or namespace scope. They cannot appear inside a function or control-flow block.

File-scope statements form that file’s executable body. A loaded file may both declare symbols and run code.

Names and Keywords

Whim names are case-sensitive. User, user, and USER are three names.

Variables and symbols

A variable starts with $:

$value = 42;

Declared symbols do not:

const LIMIT = 10;

function limit(int $value): int {
  if ($value > LIMIT) {
    return LIMIT;
  }

  return $value;
}

Classes, interfaces, enums, functions, constants, aliases, and newtypes share one symbol table. Two such declarations cannot use the same qualified name.

Properties have their own member names. Methods, class constants, and enum cases share the class-like member table. They cannot reuse one name in the same family.

Qualified names

A backslash separates namespace parts:

App\Model\User

A leading backslash starts at the global namespace:

\Whim\Json\encode

Without that leading slash, Whim resolves a qualified name through imports and the current namespace.

The reserved _ name

The bare name _ cannot name any symbol or member. Whim uses it as a wildcard, a match default, and an unnamed generic slot.

Variables include $, so $_ is valid. It is an ordinary variable, not a discard:

$_ = 'kept';
assert!($_ == 'kept');

Keyword levels

Whim has three keyword levels.

Full keywords, such as if, match, and return, cannot name a function or a constant. fresh is a full keyword and a primitive type. It cannot name a top-level declaration; $fresh remains a valid variable name.

Soft keywords, as and is, may name functions but not constants. Context keywords, such as class, int, and readonly, may name functions or constants where the parser can tell what they mean.

Every keyword may name a class-like member because ->, ?->, ::, or a member declaration makes that use clear:

final class Tokens {
  public const MATCH = 'match';

  public function match(): string {
    return self::MATCH;
  }
}

assert!(new Tokens()->match() == 'match');

The keyword appendix lists every word and its level.

Namespaces and Imports

A namespace prefixes each declaration in its body.

File namespace

The common form applies from its declaration to the end of the file:

namespace App\Model;

final class User {}

The class name is App\Model\User.

Braced namespace

A braced namespace limits the prefix to one block:

namespace App\Math {
  function double(int $value): int {
    return $value * 2;
  }
}

assert!(App\Math\double(21) == 42);

Declarations remain top-level even when a namespace block holds them.

Imports

use imports one symbol under its last name:

namespace App;

use Whim\DateTime\Date;
use Whim\Json;

$date = Date::from('2026-08-21');
$json = Json\encode(dict['date' => $date->toString()]);
assert!($json != '');

An alias chooses another local name:

use App\Model\User as ModelUser;

One declaration may import several names:

use Whim\HTTP\Message\Request, Whim\HTTP\Message\Response;
use Whim\HTTP\Message\{FieldMap, ProtocolVersion};

The braced form shares the prefix before {. Either form permits an alias on each item and an optional trailing comma in a braced list.

Whim uses one import form for every symbol kind. It has no use function or use const form.

Two imports cannot claim the same local name. A declaration also cannot reuse an imported local name.

Imports do not load code

use changes name lookup only. It does not read a file or run an autoloader. Whim loads code through require!, require_once!, or a registered autoloader. The next chapter covers each path.

Constants and Initializers

A namespace constant binds one name to one value:

const ANSWER = 42;
const LABEL = 'answer';

assert!(ANSWER == 42);

Namespace constants have no written type. Their value gives them a type. A class-like constant may state a type, as the class chapter shows.

Constant expressions

Constants, attribute arguments, parameter defaults, and property defaults use constant expressions. Such an expression may use:

  • scalar literals and existing constants;
  • file!() and directory!();
  • the platform information constructs;
  • embed! with a literal relative path;
  • unary and binary operators;
  • conditional expressions whose condition and both branches are constant expressions;
  • tuple, vec, and dict literals, including vec and dict spreads;
  • a fn closure with no captures;
  • a named class construction;
  • function, static method, and method calls whose inputs are constant expressions.

Calls may run code, inspect state, and throw. “Constant expression” names the source forms allowed at the use site. It does not mean pure or compile-time work. The expression itself cannot read a local variable or $this directly.

fresh!() cannot appear in a constant expression. An indirect call that reaches it during an initializer throws TypeError. Create and store a fresh value in ordinary runtime code instead.

final class Box {
  public function __construct(public int $value) {}

  public static function from(int $value): Box {
    return new Box($value);
  }
}

function add(int $left, int $right): int {
  return $left + $right;
}

const TOTAL = add(20, 22);
const BOX = Box::from(TOTAL);

assert!(BOX->value == 42);

A fn closure may use an expression or block body. Its parameters and locals are not captures. The compiler rejects closures that need outer variables or $this:

const DOUBLE = fn(int $value): int => $value * 2;
const MAKE_READER = fn(int $value): fn(): int {
  return fn(): int => $value;
};

assert!((DOUBLE)(21) == 42);
assert!((MAKE_READER)(42)() == 42);

MAKE_READER captures nothing. When called, it creates a closure that captures the supplied $value.

Forms that do not qualify

A constant expression cannot use:

  • a variable or $this;
  • assignment, indexing, or a property read;
  • interpolation;
  • a closure with captures;
  • match, throw, or a partial call;
  • language constructs other than those listed above;
  • vec[$value; $size];
  • a class name held in an expression.

The compiler reports which form broke the rule.

When Whim evaluates values

Whim evaluates a namespace or class constant when it declares that symbol. It evaluates a static property default when it declares the class.

Whim evaluates an instance property default for each new object. Two objects do not share an object made by that default. It evaluates an omitted parameter default for each call.

An attribute argument runs when Whim creates the attribute value for its target.

Self-reference and load order

A constant may refer to a constant declared later in the same compiled unit. Whim resolves the name before it starts the unit. A constant cannot depend on itself, whether the path is direct or passes through other constants.

Code may use a loaded constant from another unit. An unknown constant may run the autoloader. A failed load leaves the constant undefined.

Loading Files

Whim can compile and run another source file while a program runs.

require!

require!($path) resolves the path, compiles the file, links its symbols, and runs its file-scope statements:

require!(directory!() . '/support.whim');

directory!() returns the directory of the current source file. Use it for a path that should not depend on the process working directory.

A source file returns null. A parse, compile, link, or file error throws. A symbol declared by a successful load becomes available to later code.

require! runs the file each time. A file that declares symbols cannot usually run twice because the second load would redeclare them.

require_once!

require_once!($path) loads a resolved path at most once:

require_once!(directory!() . '/vendor/autoload.whim');

A later call for the same resolved path returns null without running the file again. Circular require_once! calls stop when they reach a file whose load has started but not ended.

If require! loaded a path first, require_once! also treats it as loaded.

Whim compiles a loaded file as one unit. Declarations in that unit can refer to each other even when the source declares them later.

The linker rejects duplicate names before it publishes the new unit. A failed load does not replace an existing symbol.

Autoloading

When Whim needs an unknown symbol, it may ask registered autoloaders to define it. Type checks, generic arguments, property types, constants, and ordinary calls may all trigger autoloading.

An autoloader must return true only after it has defined the requested symbol. See Autoloading for the API and generated Git dependency loader.

Compilation and Execution

Whim compiles source to register bytecode, links its declarations, then runs its file body. The whim command performs these steps in one process.

Program start

For whim app.whim, the command:

  1. creates the language core;
  2. loads the bundled standard-library artifact;
  3. reads and compiles app.whim;
  4. links its declarations;
  5. runs its file-scope statements; and
  6. runs all remaining destructors.

The core contains the symbols that the language itself needs. The standard library is a precompiled .whia artifact made from the source under lib/src/. The entry file sees both sets of symbols.

The command reads runtime settings from the nearest whim.toml. It does not read whim.lock or vendor/. A project that uses Git packages must require its generated loader.

One source unit

The compiler treats one source file as one unit. It parses the whole unit before it runs any of it. Declarations in that unit may refer to declarations written later in the same file.

Whim reports failures by stage:

  • ParserError for invalid source tokens or syntax;
  • CompilerError for a rule that one unit breaks;
  • LinkerError for a conflict between declared symbols or class contracts.

The linker publishes a unit only after its declarations pass. A failed unit does not replace a symbol that already exists.

Initializers and the file body

After linking, Whim evaluates namespace constants, class constants, and static property defaults. It also checks the values of static properties. An initializer may call code or throw, so a unit can fail before its first file-scope statement.

Instance property defaults run when code creates an object. Parameter defaults run when a call omits that argument. Attribute arguments run when code asks Whim to create the attribute object.

File-scope statements then run from top to bottom. Their locals belong only to that file body. The body returns null when it ends.

Optimization

The compiler optimizes each unit before it runs. It may remove a type check that it proves will pass, choose a bytecode instruction for a known value kind, fold a fixed expression, or inline an eligible call.

Optimization must not change program results. Use WHIM_OPTIMIZATIONS=off to compare a problem with plain bytecode. Use whim disassemble to inspect either form.

Loaded files

require!, require_once!, and autoloaders compile more units in the same process. Each successful unit adds its symbols to the current program. Its file-scope statements run under the same event loop and use the same heap, standard handles, environment, and process state.

A loaded unit cannot redeclare an existing symbol. require_once! identifies a file by its resolved path and stops a second run of that path.

Calls, tasks, and shutdown

Normal calls use one stack of call frames. Async tasks use separate stacks and cooperate on one event loop. A task runs until it returns, throws, or reaches a wait that suspends it.

The entry body does not drain the event loop on its own. Call Async\drain() or await a future to run scheduled work. Referenced tasks keep Async\drain() running; unreferenced tasks do not.

After the entry body ends, Whim runs remaining destructors. An uncaught throwable ends the program with status 255. exit! ends it with the requested status and does not run pending finally blocks. Shutdown destructors still run after exit!. panic! prints a message and trace, uses status 255, skips pending finally blocks, and runs shutdown destructors.

Built-in Values

Every Whim value has a runtime type. A declaration can state which values it accepts. Whim checks that rule when the program reaches the boundary.

Scalar values

Whim has seven scalar value kinds.

TypeValues
nullnull
booltrue and false
intsigned 64-bit integers
uintunsigned 64-bit integers, from 0 through 18,446,744,073,709,551,615
floatdouble-precision floating-point numbers
stringbyte strings
freshopaque identities created by fresh!()

Integers and floats stay distinct:

assert!(1 is int);
assert!(1u is uint);
assert!(1.0 is float);
assert!(1u != 1);
assert!(1 != 1.0);

Strings hold bytes, not a promise of valid UTF-8. The source file is UTF-8, but a string may contain any byte through escapes, file reads, sockets, or binary decoding.

fresh!() creates an identity distinct from every identity this VM has already created. Copying it preserves that identity:

$a = fresh!();
$b = fresh!();
$copy = $a;
assert!($a is fresh);
assert!($a != $b);
assert!($a == $copy);
$names = dict[$a => 'first', $b => 'second'];
assert!($names[$copy] == 'first');

Fresh values support == and !=, type checks, and dictionary keys. They have no order, arithmetic, bit operations, or implicit string or bool conversion. Even copies reject order comparisons such as $a <= $copy and $a <=> $copy. They cannot index a vec, tuple, or string.

$a as uint gives an unsigned integer for explicit use with APIs that need one. Equal identities give equal integers, and distinct identities give distinct integers during that VM lifetime. The integer has no promised value across runs and gives no ordering contract. Casting an integer back to fresh fails, including an integer obtained from a fresh value. Casting a fresh value to fresh preserves it.

If the VM exhausts its identity space, fresh!() throws OverflowError; further calls also fail. Separate VMs may reuse representations. Fresh values are not persistent IDs, distributed IDs, or secrets.

Whim\Refine\Scalar and Whim\Refine\ArrayKey include fresh. Whim\Refine\Numeric does not. JSON and BSON reject fresh values and keys; cast explicitly to a supported type when encoding a diagnostic representation. Such an encoding cannot restore the identity.

Arrays

Whim has three array forms:

TypeMeaning
(A, B)an immutable, fixed-size tuple
vec<T>a mutable list with uint keys from zero
dict<K, V>a mutable, ordered key-value map

array<K, V> accepts a tuple, vec, or dict whose keys and values fit K and V.

$pair = ('Ada', 36);
$names = vec['Ada', 'Grace'];
$scores = dict['Ada' => 10, 'Grace' => 12];

assert!($pair is (string, int));
assert!($names is vec<string>);
assert!($scores is dict<string, int>);

The collections chapter covers their syntax and update rules.

Objects and callables

object accepts any class instance. A class or interface name accepts objects of that class or its subtypes.

An object shape, such as #{ name: string, ... }, checks public instance properties and their current values. Omit ... to require exactly the listed public properties.

fn(A, B): R accepts a callable with two parameters and result R:

function apply(fn(int): int $operation, int $value): int {
  return $operation($value);
}

$double = fn(int $value): int => $value * 2;
assert!(apply($double, 21) == 42);

classname<T> accepts a class name whose instances satisfy T.

Wide and empty types

mixed accepts every value. !never also means every value.

never accepts no value. A function with return type never cannot return. It must throw, exit, or keep running.

void is valid only as a function or method return. Such a callable returns no value. You cannot use void for a parameter or property.

Literal types

A scalar value may also act as a type:

function choose('yes'|'no' $answer): bool {
  return $answer == 'yes';
}

assert!(choose('yes'));

true, 42, 42u, and 'yes' each describe one value. Literal types make unions, ranges, enum cases, and constants precise.

String length types

Put an integer or integer range after string to limit its byte length:

TypeAccepted strings
string[0]empty strings
string[5]strings with five bytes
string[1..]strings with at least one byte
string[1..=64]strings with one through 64 bytes

The range uses the same bounds as an integer range. Its lower bound is inclusive. .. excludes the upper bound, while ..= includes it.

Whim treats string[0] as '', string[1..] as string&!'', and string[0..] as string.

String lengths count bytes. For example, the UTF-8 form of é has two bytes:

assert!('é' is string[2]);

String length types work in aliases, parameters, properties, generic bounds, casts, and patterns.

Conditions

Conditions must be bool. Whim has no truthy or falsy conversion:

$name = 'Ada';
if ($name) {
  write_line!($name);
}

Write the test you mean:

$name = 'Ada';
if ($name != '') {
  write_line!($name);
}

This rule also applies to while, do ... while, &&, ||, &&=, and ||=.

Strings

A Whim string is a sequence of bytes. It may contain text, encoded data, or arbitrary binary data.

Single quotes

Single quotes do not interpolate variables. They recognize \\ and \'. Other backslash pairs stay as written:

$name = 'Ada';
assert!('Hello, $name' == 'Hello, $name');
assert!('it\'s' == "it's");
assert!('\n' == "\\n");

Use single quotes when the value needs no interpolation or control-byte escape.

Double quotes

Double quotes support escapes and interpolation:

$name = 'Ada';
$next = 41;

assert!("Hello, $name" == 'Hello, Ada');
assert!("answer: {$next + 1}" == 'answer: 42');

The short form accepts one variable. Braces accept any expression. An interpolated value must be a string, int, or float.

Escape $, {, or } when you need the byte itself:

assert!("\$name \{value\}" == '$name {value}');

Escape sequences

Double-quoted strings support these escapes:

EscapeByte or text
\\backslash
\"double quote
\$, \{, \}interpolation marker as text
\n, \r, \tline feed, carriage return, tab
\v, \f, \evertical tab, form feed, escape
\xH, \xHHone byte from one or two hex digits
\O, \OO, \OOOone byte from up to three octal digits
\u{H...}one Unicode scalar encoded as UTF-8

An octal escape must fit in one byte. A Unicode escape cannot name a surrogate or a value above 10FFFF.

An unknown escape keeps its backslash:

assert!("\x" == '\x');

Length and indexing

length! counts bytes. Indexing returns a one-byte string:

$text = 'abc';
assert!(length!($text) == 3u);
assert!($text[1] == 'b');

A string length type limits the same byte count:

function read_name(string[1..=64] $name): string {
  return $name;
}

assert!('Ada' is string[3]);

string[0] is the same type as ''. string[1..] is the same type as string&!''.

An index accepts int or uint. A negative or out-of-range index throws OutOfBoundsError. Strings are immutable, so indexed assignment fails.

Concatenation

. joins strings, ints, and floats:

assert!('count=' . 3 == 'count=3');
assert!('value=' . 1.5 == 'value=1.5');

It does not convert bool, null, arrays, objects, or callables. Use an explicit conversion for those values.

Text and bytes

Whim\Str works on bytes. Its case functions handle ASCII. Use Whim\Encoding\UTF8 to check or repair UTF-8, and use Whim\Binary for fixed binary formats.

Tuples, Vecs, and Dicts

Whim arrays are values. Tuples are fixed and immutable. Vecs and dicts are mutable.

Tuples

A tuple may hold a different type at each position:

$user = (7, 'Ada', true);
assert!($user[0] == 7);
assert!($user is (int, string, bool));

A one-item tuple needs a trailing comma:

$one = ('only',);
assert!($one[0] == 'only');

() is not a value. Tuples may hold 1 through 12 items. Their indexes do not change, and tuple elements reject writes.

A tuple type may end with a rest type:

function read_row((int, ...string) $row): int {
  return $row[0];
}

assert!(read_row((7, 'a', 'b')) == 7);

Vecs

A vec has dense uint keys from zero:

$names = vec['Ada', 'Grace'];
$names[1] = 'Hopper';
$names[] = 'Linus';

assert!($names == vec['Ada', 'Hopper', 'Linus']);

Indexed assignment must replace an existing position. Use $vec[] = $value to append.

The fill form evaluates its value once, then repeats it:

$zeros = vec[0; 4];
assert!($zeros == vec[0, 0, 0, 0]);

Spread a vec or tuple into a vec literal:

$middle = vec[2, 3];
assert!(vec[1, ...$middle, 4] == vec[1, 2, 3, 4]);

A dict cannot spread into a vec.

Dicts

A dict accepts bool, int, uint, string, and fresh keys. It keeps insertion order:

$scores = dict['Ada' => 10, 'Grace' => 12];
$scores['Ada'] = 11;
$scores['Linus'] = 9;

assert!($scores['Ada'] == 11);
assert!(length!($scores) == 3u);

Keys do not convert. 1, 1u, '1', and true are four different keys. Copies of a fresh value name the same entry. Its uint cast names a separate entry. The unsigned maximum is a valid key. Vec, tuple, and string positions accept either int or uint. Both 1 and 1u name position one. Negative positions and positions outside the value’s length are out of bounds. Collection lengths and iteration keys for vecs and tuples use uint.

Replacing a value keeps the key’s place. Removing a key and adding it again moves it to the end.

A duplicate literal or spread key keeps its first place and its last value:

$key = 'a';
$values = dict[$key => 1, 'b' => 2, $key => 3];
assert!($values == dict['a' => 3, 'b' => 2]);

A dict spread accepts a tuple, vec, or dict. A tuple or vec contributes uint keys. A dict keeps its keys.

Reading entries

An ordinary indexed read requires an existing index. Reading a missing vec position, tuple position, or dict key throws OutOfBoundsError. Use ?? to provide a fallback for a missing or null entry:

$values = dict['ready' => true];
assert!(!contains_key!($values, 'missing'));
assert!(($values['missing'] ?? false) == false);

contains_key! checks existence, including entries whose value is null. contains! checks values:

$values = vec[10, 20];
assert!(contains_key!($values, 1));
assert!(contains_key!($values, 1u));
assert!(contains!($values, 20));

length! works on every array and on strings and returns uint.

Removing entries

remove!($array, $key) removes and returns one entry. On a vec, it preserves order by shifting every later item left. remove_first!($vec) is the same ordered operation at index zero. Use these forms when remaining indexes and iteration order must not change.

Both operations take time in proportion to the items after the removed one. Repeated remove_first! calls therefore take quadratic time. Use Whim\DataStructure\Deque for repeated FIFO removal.

swap_remove!($vec, $index) instead moves the last item into $index. It takes constant time on an unshared vec, but changes order. Use it for unordered work sets and pools. remove_last!($vec) also takes constant time and does not reorder the remaining items.

$values = vec['a', 'b', 'c', 'd'];
$removed = swap_remove!($values, 1);

assert!($removed == 'b');
assert!($values == vec['a', 'd', 'c']);

All vec mutations may first copy storage when another vec shares it.

Iteration

foreach walks keys and values in array order:

$seen = vec[];
foreach (dict['a' => 1, 'b' => 2] as $key => $value) {
  $seen[] = $key . $value;
}

assert!($seen == vec['a1', 'b2']);

The loop binds copies. Assigning $key or $value does not change the array. The loop also keeps the set of entries with which it began, so changing the source during the loop does not change the current walk.

Value Semantics

Whim treats scalars, strings, and arrays as values. Objects and callables have identity.

Array assignment

Assigning an array creates an independent value:

$first = vec[1, 2];
$second = $first;
$second[] = 3;

assert!($first == vec[1, 2]);
assert!($second == vec[1, 2, 3]);

The runtime may share array storage until one value changes. Code cannot observe that sharing.

Array parameters follow the same rule:

function append_zero(vec<int> $values): vec<int> {
  $values[] = 0;
  return $values;
}

$source = vec[1];
$result = append_zero($source);

assert!($source == vec[1]);
assert!($result == vec[1, 0]);

Nested arrays

A write through mutable containers updates the outer value:

$grid = vec[vec[1, 2]];
$grid[0][1] = 20;
assert!($grid == vec[vec[1, 20]]);

A tuple blocks every write through its positions, even when one position holds a vec.

Object identity

Object assignment keeps the same object:

final class Cell {
  public int $value = 0;
}

$first = new Cell();
$second = $first;
$second->value = 42;

assert!($first->value == 42);

Copying an array does not clone the objects inside it:

final class Cell {
  public int $value = 0;
}

$cell = new Cell();
$left = vec[$cell];
$right = $left;
$right[0]->value = 7;

assert!($left[0]->value == 7);

The arrays are distinct values. Both hold the same object.

Closure captures

A closure captures each outer variable it uses. It captures the current value. An object value still points to the same object:

final class Cell {
  public int $value = 0;
}

$number = 1;
$cell = new Cell();
$read = fn(): (int, int) {
  return ($number, $cell->value);
};

$number = 2;
$cell->value = 3;

assert!($read() == (1, 3));

Lifetime

A value stays alive while a strong reference can reach it. The runtime also finds unreachable cycles.

using, drop!, destructors, weak references, and cycle collection add rules for resources. Resources and Cleanup and References and Cycles cover those rules.

Equality and Order

Whim does not convert either side of an equality check.

Equality

== tests equality. != tests its opposite:

assert!(1 == 1);
assert!(1 != 1.0);
assert!(1u != 1);
assert!(1u != 1.0);
assert!('1' != 1);
assert!(0 != false);
assert!('' != null);

Two vecs or tuples are equal when their kinds, sizes, and ordered values match. Two dicts are equal when they hold the same strict keys and equal values. Dict insertion order does not affect equality:

assert!(vec[1, 2] != vec[2, 1]);
assert!((1, 2) != (2, 1));
assert!(dict['a' => 1, 'b' => 2] == dict['b' => 2, 'a' => 1]);

Objects and callables compare by identity:

final class Token {}

$token = new Token();
$same = $token;

assert!($token == $same);
assert!($token != new Token());

$callable = fn(): int => 1;
assert!($callable == $callable);
assert!($callable != fn(): int => 1);

Enum cases are single values. A case equals itself and not another case.

Float details

Positive and negative floating zero are equal. NaN is not equal to any value, including itself.

Order operators

<, <=, >, and >= compare:

  • any pair of int, uint, and float;
  • string with string, in byte order.

Other pairs throw IncompatibleOperandsError. Arrays, objects, callables, bool, and null have no built-in order.

assert!(1 < 2);
assert!(1 < 2.0);
assert!('apple' < 'banana');
assert!('10' < '9');

A comparison with NaN is false.

Numeric ordering compares mathematical values without first rounding integers to float. It can find equal magnitudes where strict equality is false:

assert!(1u <= 1);
assert!((1u <=> 1) == 0);
assert!(0u > -1);
assert!(9_007_199_254_740_993u > 9_007_199_254_740_992.0);
assert!(Whim\Math\UINT_MAX < 18_446_744_073_709_551_616.0);

Three-way comparison

<=> returns -1, 0, or 1 under the same order rules:

assert!((1 <=> 2) == -1);
assert!((2 <=> 2) == 0);
assert!(('b' <=> 'a') == 1);

It throws when either number is NaN.

Comparison operators do not chain. Write 1 < $value && $value < 10. The parser rejects 1 < $value < 10.

Expressions

Most expressions produce a value. Literals, variables, arrays, operators, calls, member access, object construction, closures, casts, assignments, and match are expressions. return, throw, break, and continue are expressions that do not complete normally.

Variables and constants

Reading an unassigned variable throws UndefinedVariableError. An assignment creates the variable:

$value = 42;
assert!($value == 42);

A constant uses its bare name. A class constant uses :::

const LIMIT = 10;

final class Defaults {
  public const int RETRIES = 3;
}

assert!(LIMIT == 10);
assert!(Defaults::RETRIES == 3);

Object creation

new calls a class constructor:

final class Point {
  public function __construct(public int $x, public int $y) {}
}

$point = new Point(3, 4);
assert!($point->x == 3);

You may omit parentheses when the constructor takes no argument: new Point and new Point() are equal calls.

Use ::<...> between a generic class name and its arguments:

final class Box<T> {
  public function __construct(public T $value) {}
}

$box = new Box::<string>('value');
assert!($box->value == 'value');

A class-name string may drive construction:

final class Token {}

$class = 'Token';
$token = new $class();
assert!($token is Token);

The value must name a declared concrete class. Use classname<T> at a typed boundary when the named class must fit a base type.

Member access

-> reads an instance property or calls an instance method. :: reads a static property, constant, enum case, or static method.

?-> stops when the receiver is null. It returns null and does not evaluate the call arguments:

final class User {
  public function __construct(public string $name) {}
}

$user = null;
assert!($user?->name == null);

The receiver must otherwise be an object of the right type. Whim has no dynamic property creation.

Type tests and casts

is returns a bool:

$value = 42;
assert!($value is int);
assert!(!($value is string));

as returns the same value or throws TypeError:

$value = 42 as int;
assert!($value == 42);

?as returns null instead of throwing:

$value = 'forty-two' ?as int;
assert!($value == null);

Whim keeps types at runtime, so these operators work with unions, ranges, generic types, aliases, newtypes, collection shapes, and symbol types.

Coalescing

$left ?? $right returns the left value when it is present, initialized, and non-null. Otherwise it evaluates and returns the right side. False, zero, and empty strings are present values:

assert!((0 ?? 10) == 0);
assert!((false ?? true) == false);
assert!((null ?? 10) == 10);

On the left of ??, property and index accesses probe the whole access path. A missing property, uninitialized property, missing index, or null at any step selects the fallback. This includes static properties and indexes into vecs, dicts, tuples, and strings. Parentheses around an access preserve this behavior.

$settings = dict[];
assert!(($settings['display']['theme'] ?? 'light') == 'light');
$settings['display'] = dict['theme' => 'dark'];
assert!(($settings['display']['theme'] ?? 'light') == 'dark');

Receivers and keys are evaluated at most once, from left to right. Once a step selects the fallback, subsequent keys are skipped. The right side is evaluated only when needed. Invalid receiver or key types and visibility errors still throw. An undefined local variable still throws UndefinedVariableError.

Calls and other computations execute normally, including their arguments. For example, transform($values['key']) ?? $default performs an ordinary read of $values['key'] before calling transform. Exceptions from a receiver, key expression, or call propagate.

??= probes the final assignment target and writes the right side only when that target is missing, uninitialized, or null. It returns the retained or assigned value. A declared property can be initialized, and an existing dict can receive a new key:

$settings = dict[];
assert!(($settings['theme'] ??= 'light') == 'light');
assert!(($settings['theme'] ??= 'dark') == 'light');

Every intermediate value must already exist, be initialized, and have the appropriate type. ??= never creates intermediate containers. For example, $settings['display']['theme'] ??= 'light' requires an existing, non-null $settings['display']. A missing parent throws before the right side runs. All normal assignment checks apply, including property types, visibility, readonly restrictions, and collection bounds. Missing vec positions are not created by ??=.

Pipeline

|> calls the callable on its right with the left value:

function double(int $value): int {
  return $value * 2;
}

$answer = 21 |> double(...);
assert!($answer == 42);

Both sides run once. A partial callable may choose the input position:

function add(int $left, int $right): int {
  return $left + $right;
}

assert!((3 |> add(?, 4)) == 7);

Conditional expressions

$condition ? $then : $otherwise chooses one expression:

$ready = true;
$label = $ready ? 'ready' : 'waiting';
assert!($label == 'ready');

Whim evaluates the condition once and requires bool, just like if. A non-boolean condition throws TypeError before either branch runs. Only the chosen branch runs, and its value becomes the result. The branches may produce different types; ordinary boundary checks still apply to the chosen value.

The chosen branch may instead transfer control with return, break, continue, or throw. It then produces no value for the enclosing expression:

function first_true(vec<bool> $values): int {
  foreach ($values as $value) {
    return $value ? 42 : continue;
  }

  return 0;
}

assert!(first_true(vec[false, true]) == 42);

In return $value ? break : continue, either branch leaves the expression, so the outer return does not run. break and continue keep their enclosing loop targets. Existing finally and using cleanup rules apply. A condition that transfers control also skips the branches. Both branches must still be legal in their surrounding scope.

The operator binds more tightly than assignment and more loosely than ??, ||, and &&. It associates to the right: $a ? $b : $c ? $d : $e means $a ? $b : ($c ? $d : $e). Both branches allow assignments. All three operands are required; use ?? when choosing a fallback for null.

Conditional expressions are also valid in constant-expression positions when the condition and both branches meet those positions’ existing rules.

return

return ends the current function, method, or closure. It may carry the value given back to the caller:

function classify(bool $ready): string {
  return match ($ready) {
    true => 'ready',
    false => return 'waiting',
  };
}

assert!(classify(false) == 'waiting');

A bare return gives back null. A void callable cannot return a value, and a never callable cannot return. return is not valid at file scope or inside a finally block.

Returning runs each active finally block and releases each active using value before the caller resumes.

break and continue

break leaves the nearest loop. continue starts its next pass. Neither produces a value because control leaves the expression.

$values = vec[];
for ($index = 0; $index < 5; $index++) {
  $value = match ($index) {
    1 | 3 => continue,
    4 => break,
    $_ => $index,
  };

  $values[] = $value;
}

assert!($values == vec[0, 2]);

Both run any active finally blocks and release any active using values on the way out.

An integer literal chooses an outer loop:

$count = 0;
for ($x = 0; $x < 3; $x++) {
  for ($y = 0; $y < 3; $y++) {
    $count++;
    break 2;
  }
}

assert!($count == 1);

The level must be greater than zero and cannot exceed the number of enclosing loops. Omitting it means 1.

throw

throw evaluates an object that implements Whim\Unwind\Throwable, then starts unwinding. It does not produce a value because the current path ends. This makes it useful in expressions such as a coalescing fallback:

use Whim\Unwind\RuntimeException;

function cached_or_fail(null|string $cached): string {
  return $cached ?? throw new RuntimeException('value is not cached');
}

assert!(cached_or_fail('ready') == 'ready');

See Throwing and Catching for exception types, catching, and cleanup.

Operators and Arithmetic

Whim checks operator types at runtime. It does not turn strings or booleans into numbers.

Integer arithmetic

+, -, and * return int when both operands are int. Whim checks overflow and underflow:

assert!(2 + 3 == 5);
assert!(2 - 3 == -1);
assert!(2 * 3 == 6);

An out-of-range result throws OverflowError or UnderflowError. Unary -, ++, and -- use the same checks.

Two uint operands produce a checked uint. Mixed int and uint operands throw IncompatibleOperandsError for +, -, *, %, and **, even when both values are small. Cast explicitly to choose the integer kind:

assert!(2u + 3u == 5u);
assert!((2 as uint) + 3u == 5u);
assert!(3u - 2u == 1u);

Unsigned subtraction below zero throws UnderflowError; addition and multiplication above Whim\Math\UINT_MAX throw OverflowError. Negating an unsigned variable returns 0u for zero and throws UnderflowError otherwise. The compiler rejects a negated nonzero unsigned literal.

If either operand of +, -, or * is float, the result is float:

assert!(1 + 0.5 == 1.5);
assert!(2.0 * 3 == 6.0);

Division and remainder

/ accepts any pair of int, uint, and float, and always returns float:

assert!(10 / 2 == 5.0);
assert!(7 / 2 == 3.5);

% accepts two ints or two uints and keeps that kind. A signed result has the sign of the left operand:

assert!(7 % 2 == 1);
assert!(-7 % 2 == -1);

Division or remainder by zero throws DivisionByZeroError, including float division by zero. Operand kinds are checked first: 1u % 0 is an incompatible pair, while 1u % 0u divides by zero. For an exact unsigned quotient, use Whim\UInt\div(7u, 2u), which returns 3u.

Powers

** is right-associative. An int base and a nonnegative int exponent produce an int when the result fits. A negative exponent or any float operand produces a float:

assert!(2 ** 10 == 1024);
assert!(2 ** -1 == 0.5);
assert!(2.0 ** 2 == 4.0);

Integer overflow throws. 0 ** -1 throws DivisionByZeroError.

uint ** uint returns a checked unsigned result. 0u ** 0u is 1u; 1u raised to any unsigned power is 1u. Any float operand selects floating arithmetic, so 2u ** -1.0 is 0.5.

Bit operators

&, |, and ^ accept two ints or two uints. ~ accepts either integer kind. They preserve that kind; ~0u is Whim\Math\UINT_MAX.

The left operand of << and >> may be int or uint. The count may independently be int or uint, from 0 through 63; other counts throw ArithmeticError. The result keeps the left operand’s kind. Left shifts discard high bits. Signed right shifts extend the sign; unsigned right shifts insert zeros.

Increment and decrement

Prefix ++$value changes the target and returns the new value. Postfix $value++ returns the old value. -- follows the same rule. These operators accept int, uint, and float targets. Unsigned steps preserve uint: $value++ adds an unsigned one, while $value += 1 rejects an unsigned target. If arithmetic or a declared type check fails, the target keeps its old value.

Boolean operators

!, &&, and || accept bool. && skips its right side when the left side is false. || skips it when the left side is true.

Concatenation

. joins strings, ints, uints, and floats as text. It rejects other values. Unsigned values print all their decimal digits without a suffix. Interpolation and output use the same rule.

Comparison and type operators

Equality and Order covers ==, !=, <, <=, >, >=, and <=>.

is, as, and ?as check a runtime type. These operators and comparisons do not chain.

Binding order

The full precedence table appears in the operator appendix. When the order is not plain, use parentheses. They cost nothing and state the intended order.

Assignment and Indexing

Assignment is an expression. It stores a value and returns that value:

$target = 0;
$result = ($target = 5);

assert!($target == 5);
assert!($result == 5);

Targets

An assignment target may be:

  • a variable;
  • an object property;
  • a static property;
  • a vec or dict index;
  • a vec append target, $values[];
  • a tuple or dict destructuring pattern.

Literals, arithmetic results, strings, and tuple entries are not writable targets. A property on an object returned by a call is writable because the object keeps its identity.

Compound assignment

Whim supports these compound forms:

+=  -=  *=  /=  %=  **=  .=
&=  |=  ^=  <<=  >>=  &&=  ||=  ??=

The target expression runs once:

final class Counter {
  public int $calls = 0;

  public function index(): int {
    $this->calls++;
    return 0;
  }
}

$counter = new Counter();
$values = vec[10];
$values[$counter->index()] += 1;

assert!($counter->calls == 1);
assert!($values[0] == 11);

&&=, ||=, and ??= short-circuit like their non-assignment forms.

??= probes the final property or index and assigns only if it is missing, uninitialized, or null. Every intermediate parent must already exist and have the appropriate type; missing parents throw before the right side runs. No intermediate containers are created. The write obeys the same type, visibility, readonly, and bounds checks as ordinary assignment.

Tuple and vec destructuring

A tuple target accepts a tuple or vec:

($name, $age) = ('Ada', 36);
assert!($name == 'Ada');
assert!($age == 36);

Without a rest target, the source size must match. A trailing rest target collects the remaining values in a vec:

($head, ...$tail) = vec[1, 2, 3];
assert!($head == 1);
assert!($tail == vec[2, 3]);

Use bare ... to allow and ignore the rest.

Defaulted targets

A default runs only when its position is missing. A present null does not use the default:

($name, $role = 'member') = vec['Ada'];
assert!($role == 'member');

($id, $label = 'fallback') = vec[1, null];
assert!($label == null);

A default may use an earlier binding. Once one target has a default, every later fixed target must also have one. A rest target may follow them.

Dict destructuring

A dict target selects named keys:

$source = dict['id' => 7, 'profile' => dict['name' => 'Ada']];
dict['id' => $id, 'profile' => dict['name' => $name]] = $source;

assert!(($id, $name) == (7, 'Ada'));

Each key expression runs before the source expression. A missing key throws. The source must be a dict.

Write order

Whim reads the full source before it writes any target. A target may reuse the source variable without changing later reads:

$values = (1, 2, 3);
($values, $second, $third) = $values;
assert!(($values, $second, $third) == (1, 2, 3));

Whim writes targets from left to right. If one variable appears twice, the last write wins. $_ follows the same rule because it is an ordinary variable.

Calls and Evaluation Order

Whim evaluates expressions from left to right unless an operator states that it short-circuits.

Calls

Whim evaluates the callable first, then each argument from left to right. For a method call, it evaluates the receiver before the arguments. A constructor uses the same argument order.

final class Log {
  public static vec<string> $events = vec[];
}

function mark(string $name, int $value): int {
  Log::$events[] = $name;
  return $value;
}

function sum(int $a, int $b, int $c): int {
  return $a + $b + $c;
}

assert!(sum(mark('a', 1), mark('b', 2), mark('c', 3)) == 6);
assert!(Log::$events == vec['a', 'b', 'c']);

Operators

Whim evaluates the left operand before the right. An indexed write evaluates the index before the stored value. A dict entry evaluates its key before its value.

Array literal elements, tuple entries, and object constructor arguments also run from left to right.

Short-circuit forms

These forms may skip work:

  • false && $right skips $right;
  • true || $right skips $right;
  • a present, initialized, non-null left side of ?? skips the right side;
  • a null receiver for ?-> skips the member access and call arguments;
  • match evaluates only the chosen arm result;
  • condition ? then : otherwise checks a boolean condition once and evaluates only the chosen branch;
  • a destructuring default runs only for a missing position.

0, false, and '' are not null, so ?? keeps them.

Property and index paths on the left of ?? stop at the first missing, uninitialized, or null step and select the fallback. Later keys are skipped. Receivers and evaluated keys run once. Invalid types, visibility violations, undefined locals, and exceptions from calls still throw. See coalescing for examples and the boundary between probing an access path and evaluating a computation.

Pipeline

$value |> $callable evaluates the value, then the callable, once each. It then calls the callable with the value.

Type checks

Whim checks call arguments after it has evaluated them. It checks a return value when the callable returns. A failed check throws TypeError at that boundary.

Default parameter values run when the call omits that argument. Named and partial calls still preserve source evaluation order for the expressions that the caller supplies.

Statements and Loops

Statements run actions and choose control flow.

Blocks group statements as the bodies of declarations and control-flow constructs, such as functions, if, loops, try, and using. Standalone { ... } and {} blocks are syntax errors, including inside another body.

Named functions, classes, interfaces, enums, global constants, type aliases, newtypes, imports, and file attributes belong at the top level of a file or namespace. They cannot appear inside function bodies or control-flow blocks. Use a closure when you need a function value inside a body.

Expression statements

Any expression may form a statement when followed by ;:

$value = 1;
$value++;
write_line!($value);

Discarding a value marked #[MustUse] raises an error. Use its result or pass it to discard! to state that you meant to ignore it.

Every expression statement needs an expression; ; alone is a syntax error.

Final locals

final binds a local once:

final $rate = 0.1;
final $price = 250.0;

assert!($price * $rate == 25.0);

The declaration must be the local’s first assignment. Any later write in the same function or file body is a compile error. A final local still follows normal value rules: an object may change through the local, but the local cannot point to another object.

if

An if condition must be bool:

$value = -3;
if ($value < 0) {
  $sign = 'negative';
} else if ($value == 0) {
  $sign = 'zero';
} else {
  $sign = 'positive';
}

assert!($sign == 'negative');

while and do ... while

while tests before each pass. do ... while runs its body once before the first test:

$count = 0;
while ($count < 3) {
  $count++;
}

do {
  $count--;
} while ($count > 0);

assert!($count == 0);

for

A for loop keeps its setup, condition, and step in one header:

$sum = 0;
for ($number = 1; $number <= 5; $number++) {
  $sum += $number;
}

assert!($sum == 15);

Each header section may hold a comma-separated expression list. An empty condition acts as true:

$left = 0;
$right = 3;
for (; $left < $right; $left++, $right--) {
  write_line!($left . ':' . $right);
}

foreach

foreach accepts an array, an Iterator<K, V>, or a ToIterator<K, V>:

$seen = vec[];
foreach (dict['a' => 1, 'b' => 2] as $key => $value) {
  $seen[] = $key . $value;
}

assert!($seen == vec['a1', 'b2']);

Omit the key when it is not needed. The value target may be a destructuring pattern.

Array iteration uses the entries present when the loop begins. Changing the source does not change that walk.

Variable scope

Each file body, function, method, and closure has its own variable scope. Control-flow blocks do not create another variable scope. A variable assigned on only some paths remains undefined when execution took another path.

Functions cannot read file-scope variables. A closure captures the outer variables it uses.

Match and Destructuring

match evaluates its subject once. It tests each arm in order, then evaluates the first arm that matches.

Literal patterns

Literal patterns use strict equality. | joins alternatives:

function label(mixed $value): string {
  return match ($value) {
    0 => 'zero',
    1 | 2 | 3 => 'small',
    _ => 'other',
  };
}

assert!(label(2) == 'small');
assert!(label('2') == 'other');

If no arm matches, Whim throws UnhandledMatchError.

Wildcard patterns

_ accepts any value without binding a variable. As a fallback arm, it must come last. Inside another pattern, it ignores that part of the value:

$outer = 'outer';
assert!(match (42) { _ => $outer } == 'outer');

$second = match ((1, 'two')) {
  (_, $value) => $value,
  _ => 'other',
};

assert!($second == 'two');

Variable patterns

A variable pattern accepts any value and binds that value inside the selected arm:

$description = match (42) {
  $value => 'value:' . $value,
};

The binding exists only in its arm. It may shadow an outer variable without changing it.

Type patterns

A type pattern checks the subject with is:

function kind(mixed $value): string {
  return match ($value) {
    int => 'integer',
    string => 'text',
    _ => 'other',
  };
}

assert!(kind(42) == 'integer');

_ also marks an ignored slot in a larger type, such as vec<_>.

Combining patterns with @

left @ right requires both patterns to match the same value. This lets one side bind a value while the other checks it:

function describe(mixed $value): string {
  return match ($value) {
    $number @ int => 'int:' . $number,
    $text @ string => 'string:' . $text,
    _ => 'other',
  };
}

Both sides may contain nested patterns. Bindings become available to the arm expression after every check succeeds. A failed arm cannot leave a partial binding or throw due to a missing collection element.

@ takes the union on its right, so this checks either literal and binds the result once:

$small @ 1 | 2

Use parentheses when each union branch has its own pattern tree:

($value @ 1) | ($value @ 2)

Every union branch must bind the same names in the same layout. One pattern cannot bind the same name twice.

Positional patterns

A parenthesized positional pattern matches a tuple or vec. Without ..., its length must match exactly:

function point_name(mixed $value): string {
  return match ($value) {
    ($x @ int, $y @ int) => $x . ',' . $y,
    _ => 'not a point',
  };
}

assert!(point_name((3, 4)) == '3,4');
assert!(point_name(vec[3, 4]) == '3,4');

A trailing ... permits more values. A pattern after it checks every value in the remainder. A variable after it binds the remainder as a vec:

$total = match (vec[2, 3, 4]) {
  ($first, ...$rest) @ vec<int> => $first + (length!($rest) as int),
  _ => 0,
};

assert!($total == 4);

Intersect the positional pattern with a tuple or vec type when the collection kind matters. Since parenthesized patterns are positional, give a tuple type a name when you need to distinguish it from a vec:

type Point = (int, int);

$point = match ((3, 4)) {
  ($x, $y) @ Point => ($x, $y),
  _ => null,
};

Vec patterns

vec[...] matches only a vec. Its length is exact unless it ends with ...:

$first = match (vec[1, 2, 3]) {
  vec[$head @ int, ...int] => $head,
  _ => 0,
};

assert!($first == 1);

Dict patterns

A dict pattern uses string, integer, or boolean literal keys. true, 1, and '1' are distinct keys, as are false, 0, and '0'. Without ..., the pattern requires the exact key set. With ..., it permits unlisted keys:

$name = match (dict['id' => 7, 'name' => 'Ada']) {
  dict['name' => $value @ string, ...] => $value,
  _ => 'unknown',
};

assert!($name == 'Ada');

A missing key rejects the arm. It does not raise OutOfBoundsError.

Patterns may nest on either side of @:

function extract(mixed $value): null|(int, string) {
  return match ($value) {
    dict['foo' => $foo @ 1 | 2, 'bar' => $bar @ !'', ...] @ dict['foo' => int, 'bar' => string, 'baz' => float, ...] => (
      $foo,
      $bar,
    ),
    _ => null,
  };
}

$value = dict['foo' => 2, 'bar' => 'yes', 'baz' => 1.5];
assert!(extract($value) == (2, 'yes'));

Rest patterns and captures

In tuple, vec, and dict patterns, ... permits a remainder. A pattern after ... checks every remaining value and must not introduce bindings inside its elements, entries, properties, alternatives, or nested rests.

Two forms can bind the whole remainder: ...$rest and ...$rest @ pattern. In the second form, pattern checks each remaining value and must contain no bindings. The capture goes on the left of @. Parentheses do not change these rules.

$result = match (vec[1, 'two', 'three']) {
  vec[$first @ int, ...$strings @ string] => ($first, $strings),
  _ => null,
};
assert!($result == (1, vec['two', 'three']));

Tuple and vec rests bind a vec. Dict rests bind a dict that preserves the remaining keys and their order. An empty remainder passes its element checks and binds an empty collection. If an element fails, the entire arm fails.

...#{ value: int } and ...$rest @ #{ value: int } are valid. ...#{ $value }, ...($x, $y), ...vec[...$inner], and ...$rest @ #{ $value } are compile errors. Rest captures do not collect individual fields into new collections.

Pattern intersections

left & right requires both patterns to match the same value. It binds more tightly than @ and |, and can combine type checks with destructuring:

$pair = match ((3, 4)) {
  ($x, $y) & (int, int) => ($x, $y),
  _ => null,
};
assert!($pair == (3, 4));

Object patterns

#{ name: pattern } matches public instance properties. It uses the same exact and open property-set rules as object shape types. A missing, inaccessible, or uninitialized required property rejects the arm. Private, protected, and static properties are ignored for exactness.

An entry may contain any pattern, including a nested shape. #{ $value } is shorthand for #{ value: $value }; this shorthand is available only in patterns. Write #{ value: $other } to bind a different name. Add ... to allow additional public properties.

Inside a collection rest, object patterns follow the rest binding rules: ...$rest @ #{ value: int } captures the whole remainder, while ...#{ $value } is a compile error.

A named type can precede an object pattern directly. Foo #{ $value } has the same matching and binding behavior as Foo & #{ $value }. This also works with generic names and nested patterns:

use Whim\Result\Ok;
use Whim\Result\Err;

function describe_result(mixed $result): string {
  return match ($result) {
    Ok<string> #{ value: '' } => 'empty',
    Ok<string> #{ $value } => $value,
    Err<int> #{ error: $code @ 400..=499 } => 'client error: ' . $code,
    Err<int> #{ $error } => 'error: ' . $error,
    _ => 'other',
  };
}

assert!(describe_result(new Ok::<string>('hello')) == 'hello');
assert!(describe_result(new Err::<int>(404)) == 'client error: 404');

Intersections check the left pattern before the right. Once an object pattern’s layout matches, its listed property values are captured in source order before its nested patterns are checked. The selected arm receives those captured values, even if resolving a nested type invokes an autoloader that changes the original object. Bindings remain local to the selected arm; unsuccessful arms and union alternatives do not expose partial bindings. Property names cannot repeat within a shape, and the usual duplicate-binding and union-layout rules apply.

Assignment destructuring

Destructuring assignment uses tuple and dict targets, but it does not test match arms. A mismatch throws. See Assignment and Indexing.

Language Constructs

A language construct looks like name!(...), but it is not a function. The compiler knows its rules and may emit direct bytecode for it.

Fresh identities

fresh!() takes no arguments and creates a fresh scalar. Every executed call creates a distinct identity within the VM lifetime. Copies keep their identity, and skipped branches create none:

$a = fresh!();
$b = fresh!();
assert!($a != $b);

The construct runs at runtime. It cannot run in a constant expression, even through a function call. Compiled artifacts store the generation instruction and create identities when executed.

Array and string constructs

  • length!($value) returns the byte length of a string or the item count of an array, as a uint.
  • contains!($array, $value) checks array values with strict equality.
  • contains_key!($array, $key) checks an array key or index.
  • remove!($array, $key) removes and returns one entry.
  • swap_remove!($vec, $index) removes and returns one item without preserving vec order.
  • remove_first!($vec) removes and returns the first item.
  • remove_last!($vec) removes and returns the last item.

The remove forms change their target. remove! accepts vecs and dicts. The other forms accept vecs. They throw when no matching item exists. See Removing entries for their order and cost.

Assertions

assert! requires a bool. A false result throws AssertionError:

$value = 42;
assert!($value > 0, 'value must be positive');

The message is optional. The error includes the failed expression and its source location.

Output

  • write!(...) writes values to standard output.
  • write_line!(...) also writes a line ending.
  • write_error!(...) writes to standard error.
  • write_error_line!(...) also writes a line ending.

Each argument must be a string, int, or float.

Debug output

debug!(...) writes a source location and a structural view to standard error. It shows types, string byte lengths, object properties, and collection values. It hides private property values and sensitive callables. It stops after 64 items or 32 nested levels.

debug!(dict['ready' => true, 'count' => 2]);

Use debug! while working, then remove it from normal output paths.

Explicit discard

discard!($value) evaluates and ignores a value. It is the explicit way to ignore a result marked #[MustUse].

Sequencing expressions

sequence!($first, ..., $last) evaluates each expression once, from left to right, and produces the last expression’s value and type. It requires at least one expression and allows a trailing comma:

$result = sequence!(
  $value = 10,
  $value *= 2,
  $value + 1,
);

assert!($result == 21);
assert!($value == 20);

Each earlier expression behaves like an expression statement, including #[MustUse] checks. Use discard! on an individual expression to deliberately ignore a required result. If the whole sequence is an expression statement, the last expression also follows the usual discard rules.

A sequence shares the surrounding variable scope and control flow. return exits the enclosing callable, break and continue target the enclosing loops, and an exception skips the remaining expressions. Normal cleanup rules still apply. The compiler emits the expressions directly without a function call or closure.

Use it in a match arm that needs several expressions:

$input = '  hello  ';
$value = match ($input) {
  '' => 'empty',
  $_ => sequence!(
    $trimmed = Whim\Str\trim($input),
    assert!(length!($trimmed) > 0),
    $trimmed,
  ),
};

assert!($value == 'hello');

Only the selected arm evaluates its sequence. The arguments are expressions; statements such as if, foreach, and try still belong in statement bodies.

Object cloning

clone! copies an object and may replace named properties during the copy:

final readonly class Point {
  public function __construct(public int $x, public int $y) {}

  public function withY(int $y): Point {
    return clone!($this, y: $y);
  }
}

$first = new Point(1, 2);
$second = $first->withY(9);
assert!(($first->y, $second->y) == (2, 9));

Whim checks property visibility, readonly rules, and types during the clone.

Lifetime and process control

drop!($variable) releases one local now. It throws LeakedResourceError if another strong reference keeps a resource alive.

exit!() ends the process with status zero. exit!($status) requires an int and uses its low eight bits. Exit is not an exception: catch cannot catch it, and finally does not run after it.

panic!($message) reports a broken invariant. It evaluates one expression, which must produce a string, writes panic: message and the current stack trace to standard error, and ends the process with status 255. It is not an exception. catch cannot catch it, and finally does not run after it. Shutdown destructors still run.

A non-string message throws TypeError before the panic occurs. Errors thrown while evaluating the message also propagate normally. These errors can be caught and run pending finally blocks.

if (!contains_key!($states, $name)) {
  panic!("the state table has no entry for {$name}");
}

Panic traces hide TraceBoundary frames unless full traces are on. They also hide parameters marked SensitiveParameter. Use throw for errors that a caller may handle. Use panic! only when continuing would be wrong.

Platform information

These constructs take no arguments and return strings describing the compilation target. This defaults to the platform running Whim. The compiler stores these values as string constants.

ConstructValueExamples
cpu_architecture!()CPU architecture'x86_64', 'aarch64', 'riscv64'
operating_system!()Operating system'linux', 'macos', 'freebsd', 'windows'
operating_system_family!()Operating system family'unix', 'windows'
shared_library_prefix!()Shared library filename prefix'lib', ''
shared_library_suffix!()Shared library filename suffix, including the dot'.so', '.dylib', '.dll'
shared_library_extension!()Shared library extension without the dot'so', 'dylib', 'dll'
executable_suffix!()Executable filename suffix, including a dot if present'', '.exe'
executable_extension!()Executable extension without the dot'', 'exe'

You can use them in constant expressions, including constant declarations, parameter defaults, property defaults, and attributes:

const PLATFORM = operating_system!() . '/' . cpu_architecture!();

write_line!(PLATFORM);

Source paths and loading

  • file!() returns the current source file path.
  • directory!() returns its directory.
  • embed!('./file') reads a file while compiling.
  • require!($path) loads and runs a source file.
  • require_once!($path) does that at most once per resolved path.

file!() and directory!() also work in constant expressions:

const SOURCE_FILE = file!();
const SOURCE_DIRECTORY = directory!();

assert!(SOURCE_FILE == file!());
assert!(SOURCE_DIRECTORY == directory!());

See Loading Files for load and error rules.

Compile-time file embedding

embed! takes one literal relative path. Whim resolves it from the directory of the source file that contains the construct, reads the file while compiling, and stores its exact bytes as a string:

const TEMPLATE = embed!('./template.html');

Whim does not decode text or change line endings. The running program does not read the file. A source read from standard input cannot use embed! because it has no directory. Absolute paths, missing files, unreadable files, and directories cause compile errors.

The compiler reads an embedded file even when the construct appears in code that will not run. It reads each resolved path once per compilation. Do not use embed! for secrets: the bytes remain plain in bytecode and compiled artifacts.

Functions

A function declaration has a name, optional type parameters, parameters, an optional return type, an optional where clause, and a body.

function area(float $width, float $height): float {
  return $width * $height;
}

assert!(area(4.0, 2.5) == 10.0);

Parameter and return types

Whim checks each typed argument before the function starts. It checks a typed result before the caller receives it.

function identity<T>(T $value): T {
  return $value;
}

assert!(identity::<string>('value') == 'value');

An omitted parameter or return type means mixed. Write the type when the function has a useful contract.

A void function returns no value:

function announce(string $message): void {
  write_line!($message);
}

announce('ready');

A never function cannot return:

function fail(string $message): never {
  throw new Whim\Unwind\RuntimeException($message);
}

Optional parameters

A default makes a parameter optional:

function greet(string $name, string $greeting = 'Hello'): string {
  return $greeting . ', ' . $name;
}

assert!(greet('Ada') == 'Hello, Ada');
assert!(greet('Ada', 'Welcome') == 'Welcome, Ada');

Required parameters must come before optional parameters. Whim evaluates an omitted default when the call enters the function. It then checks the default against the parameter type.

Defaults use constant expressions. They may use literals, arrays, constants, named object construction, and calls whose receiver and arguments are also constant expressions.

Named arguments

An argument may name its parameter:

function box(string $label, int $width = 3, int $height = 1): string {
  return $label . ':' . $width . ':' . $height;
}

assert!(box('panel', height: 9) == 'panel:3:9');
assert!(
  box(
    height: 2,
    label: 'card',
    width: 4,
  )
  == 'card:4:2',
);

Named arguments may skip optional parameters and may appear out of declaration order. An unknown or repeated name throws ArgumentCountError.

Argument count

Whim has no implicit variadic parameter. A function receives exactly its declared parameters. Use a vec when a call should pass a list:

function sum(vec<int> $values): int {
  $total = 0;
  foreach ($values as $value) {
    $total += $value;
  }

  return $total;
}

assert!(sum(vec[1, 2, 3]) == 6);

Too few or too many arguments throw ArgumentCountError.

Local scope

Parameters and assignments belong to the function call. A function cannot read variables from the file that declared it. Use parameters, constants, static properties, or a closure capture to pass data in.

Calls may recurse. [runtime].call-depth sets the frame limit. The WHIM_CALL_DEPTH environment variable overrides it for one run. Exceeding the limit throws StackOverflowError.

Generic functions

A function may declare reified type parameters:

function singleton<T>(T $value): vec<T> {
  return vec[$value];
}

assert!(singleton::<int>(7) is vec<int>);

Whim does not infer T from an argument. Supply ::<...> or give T a default. The Generics chapter covers bounds, defaults, variance, and forwarding.

Closures

A closure is an anonymous function you can save in a variable or pass as arguments to other functions. You can create the closure in one place and then call the closure elsewhere to evaluate it in a different context. Unlike functions, closures can capture values from the scope in which they’re defined.

Bodies

A closure uses fn:

$double = fn(int $value): int {
  return $value * 2;
};

assert!($double(21) == 42);

Like a named function, a closure may have type parameters, typed parameters, defaults, a return type, a where clause, attributes, and a block body.

A closure may also have an expression body:

$factor = 3;
$multiply = fn(int $value): int => $value * $factor;

assert!($multiply(4) == 12);

A block body may contain any statements. It does not return its last expression. Use return to return a value. A block body may declare void; an expression body may not.

Captures

A closure captures each outer variable that its body uses. Capture is by value at creation time. Parameters and locals assigned before use are not captures.

$offset = 10;
$add = fn(int $value): int {
  return $value + $offset;
};

assert!($add(5) == 15);

Capture copies the current value. A later assignment to the outer variable does not change that copy. Mutating a captured local also does not change the outer local.

Objects keep identity when copied, so a captured object still sees later property changes.

$this

A closure made in an instance method may use $this:

final class Counter {
  public function __construct(private int $value) {}

  public function reader(): fn(): int {
    return fn(): int => $this->value;
  }
}

$counter = new Counter(7);
assert!($counter->reader()() == 7);

The callable keeps the receiver alive.

Callable types

fn(int, string): bool describes a callable by its input and output types. Whim checks callable compatibility when a value crosses a typed boundary, then checks each call as it runs.

Parameter types are contravariant and the return type is covariant. A callable that accepts mixed may replace one that accepts int. A callable that returns int may replace one that returns int|string.

Callable values compare by identity. Two closures with the same source are still different values.

First-Class Callables and Partial Function Application

Whim creates a first-class callable with foo(...). Partial function application uses ? to leave arguments open. Both produce fn values, as closure expressions do.

First-class callables

Write (...) in place of the argument list:

function square(int $value): int {
  return $value * $value;
}

$square = square(...);
assert!($square(9) == 81);

For a generic function, put the type arguments first:

function identity<T>(T $value): T {
  return $value;
}

$read = identity::<string>(...);
assert!($read('value') == 'value');

Bound methods

A first-class instance method keeps its receiver:

final class Greeter {
  public function __construct(private string $name) {}

  public function greet(string $prefix): string {
    return $prefix . $this->name;
  }
}

$greeter = new Greeter('Ada');
$greet = $greeter->greet(...);
assert!($greet('Hello, ') == 'Hello, Ada');

Static methods use ClassName::method(...). Whim checks visibility when it creates the callable.

Partial function application

? leaves one argument open:

function join(string $left, string $middle, string $right): string {
  return $left . $middle . $right;
}

$wrap = join('(', ?, ')');
assert!($wrap('value') == '(value)');

Whim evaluates bound argument expressions when it creates the partial. Later calls reuse those values.

Several holes become parameters in the order in which the partial expression lists them:

function format(int $id, string $label, bool $loud): string {
  return $label . ':' . $id;
}

$render = format(label: ?, loud: true, id: ?);
assert!($render('item', 7) == 'item:7');

The first new parameter fills label; the second fills id.

Leaving later parameters open

A trailing ... leaves other unbound parameters open:

function shape(string $kind, int $size = 1, string $mode = 'flat'): string {
  return $kind . ':' . $size . ':' . $mode;
}

$deep = shape(?, mode: 'deep', ...);
assert!($deep('cube', 4) == 'cube:4:deep');

Without the trailing ..., parameters without holes keep their defaults or no longer belong to the partial callable.

You can apply partial function application to an existing callable. Whim preserves the bound values and the order of the remaining holes.

Classes and Properties

A class defines objects with identity. Two variables can point to the same object, and a change through either variable changes that object.

class Counter {
  public int $value = 0;

  public function increment(): void {
    $this->value++;
  }
}

$first = new Counter();
$second = $first;
$second->increment();
write_line!($first->value); // 1

Declaring a class

A class body may contain properties, constants, and methods. Every member must state its visibility: public, protected, or private.

class User {
  public const string KIND = 'user';
  private static int $created = 0;

  public function __construct(public readonly int $id, private string $name) {
    self::$created++;
  }

  public function rename(string $name): void {
    $this->name = $name;
  }

  public function label(): string {
    return $this->id . ': ' . $this->name;
  }

  public static function created(): int {
    return self::$created;
  }
}

Properties use $ in their names. Methods and constants do not. A property may share a name with a method or constant. Methods and constants may not share a name with each other. An enum case also uses that member name set.

Construction

new creates an object, then calls __construct when the class has one. The call site must have access to that constructor. Code outside the class can call only a public constructor.

final class Point {
  public function __construct(public int $x, public int $y) {}
}

$point = new Point(3, 4);

A visibility modifier on a constructor parameter promotes that parameter to a property. The property keeps the parameter’s type and readonly modifier.

Without promotion, the constructor assigns properties through $this:

final class Name {
  private string $value;

  public function __construct(string $value) {
    $this->value = $value;
  }

  public function value(): string {
    return $this->value;
  }
}

Named arguments work with constructors:

final class Entry {
  public function __construct(public int $number, public string $text) {}
}

$entry = new Entry(
  text: 'answer',
  number: 42,
);

A child constructor does not call its parent on its own. Call parent::__construct(...) when the parent needs setup.

A private constructor can force callers through a factory:

final class Token {
  private function __construct(public string $value) {}

  public static function from(string $value): Token {
    return new Token($value);
  }
}

$token = Token::from('ready');

Property state

A property may have a default value:

class Job {
  public string $state = 'waiting';
}

A property without a default starts uninitialized. Reading it throws Whim\Unwind\UninitializedPropertyError.

The coalescing operators can read a fallback for an uninitialized property or initialize it with ??=.

class Job {
  public string $state;
}

$job = new Job();
$state = $job->state; // throws

An initializer may call functions or methods and may create objects. Whim evaluates a non-static property initializer for each new object. Objects do not share that value.

final class Box {
  public function __construct(public int $value) {}
}

final class Holder {
  public Box $box = new Box(1);
}

$first = new Holder();
$second = new Holder();
$first->box->value = 9;
write_line!($second->box->value); // 1

Every write checks the property’s type. A compound assignment also leaves the old value in place when its operation or type check fails.

Readonly properties

A readonly property accepts one write. The write must occur in code that can access the property. Later writes throw Whim\Unwind\ReadonlyError.

class Account {
  public readonly int $id;

  public function __construct(int $id) {
    $this->id = $id;
  }
}

A child constructor may initialize an inherited public or protected readonly property. It may not initialize a private parent property.

readonly class makes each instance property readonly, including promoted properties that omit the word readonly.

readonly class Pair {
  public function __construct(public int $left, public int $right) {}
}

A readonly class cannot have static properties. A readonly class may extend only another readonly class, and a child of a readonly class must also be readonly.

Static properties and class constants

Static properties belong to the class family, not to one object. An inherited static property uses the same stored value.

class Registry {
  public static int $count = 0;
}

class ChildRegistry extends Registry {}

ChildRegistry::$count = 3;
write_line!(Registry::$count); // 3

A class constant has a type and a value:

final class Limits {
  public const int MAXIMUM = 100;
}

write_line!(Limits::MAXIMUM);

final prevents a child from replacing a class constant.

Constant values may call functions and methods or create objects. Whim checks them when it declares the class.

$this, self, parent, and static

$this is the current object in an instance method.

self names the class that declares the current method. parent names its direct parent. static names the class on which the call began, so it supports late static dispatch.

class Model {
  public static function create(): static {
    return new static();
  }

  public function kind(): string {
    return 'model';
  }
}

final class Post extends Model {
  public function kind(): string {
    return 'post';
  }
}

write_line!(Post::create()->kind()); // post

Use self, not static, as a parameter type. A return type may use either.

Abstract and final classes

An abstract class may hold abstract methods. An abstract method has no body. A concrete child must implement every abstract method before code can create it.

abstract class Shape {
  abstract public function area(): float;
}

final class Square extends Shape {
  public function __construct(public float $side) {}

  public function area(): float {
    return $this->side * $this->side;
  }
}

Code cannot create an abstract class. Code cannot extend a final class or replace a final method.

final abstract class has a narrow use: it groups static code. It may contain only constants, static properties, and concrete static methods.

final abstract class Numbers {
  public const int ONE = 1;

  public static function one(): int {
    return self::ONE;
  }
}

Cloning

clone!($object) creates a new object with copies of the source properties. The two objects then have separate property slots.

final class Point {
  public function __construct(public int $x, public int $y) {}
}

$source = new Point(1, 2);
$copy = clone!($source, y: 9);
$source->x = 7;

write_line!($copy->x); // 1
write_line!($copy->y); // 9

Named fields after the object replace properties on the clone. Normal visibility and readonly rules apply at the call site. A wrong field name or a non-object source throws TypeError. Some built-in classes reject cloning. Whim has no clone hook.

Destruction

A class may declare __destruct(): void. Whim calls it when no strong reference can reach the object, including when cycle collection finds an unreachable cycle.

final class Lease {
  public function __destruct(): void {
    write_line!('released');
  }
}

$lease = new Lease();
$lease = null;

A child inherits its parent’s destructor unless it declares one. A child destructor replaces the parent destructor; call parent::__destruct() to run both.

A destructor may throw. During normal execution, the throw starts at the statement that removed the last reference. During shutdown, a destructor failure becomes the program’s failure. Keep destructors short and make cleanup safe to call more than once.

Inheritance and Visibility

A class may extend one class and implement any number of interfaces.

interface Named {
  public function name(): string;
}

class Entity {
  public function __construct(public readonly int $id) {}
}

final class User extends Entity implements Named {
  public function __construct(int $id, private string $label) {
    parent::__construct($id);
  }

  public function name(): string {
    return $this->label;
  }
}

Whim rejects class and interface inheritance cycles.

Dynamic method dispatch

An instance call uses the method on the object’s runtime class. This remains true when a parent method makes the call.

class Animal {
  public function describe(): string {
    return 'a ' . $this->kind();
  }

  protected function kind(): string {
    return 'animal';
  }
}

final class Dog extends Animal {
  protected function kind(): string {
    return 'dog';
  }
}

write_line!(new Dog()->describe()); // a dog

parent::method() calls the parent implementation directly.

Visibility

Any scope may use a public member.

The declaring class, its parents, and its children may use a protected member. This access works both ways within one inheritance family. It does not grant access to an unrelated class.

private code belongs only to the class that declares it. A child cannot use its parent’s private member.

Private properties and methods do not take part in normal overriding. A child may declare its own private member with the same name. Parent code still uses the parent’s member, and child code uses the child’s member.

class ParentValue {
  private string $value = 'parent';

  public function parentValue(): string {
    return $this->value;
  }
}

class ChildValue extends ParentValue {
  private string $value = 'child';

  public function childValue(): string {
    return $this->value;
  }
}

Method overrides

An overriding method must keep a compatible contract:

  • It cannot add required parameters.
  • It may add optional parameters.
  • It may widen visibility, such as protected to public.
  • It may not change an instance method to static or a static method to an instance method.
  • It may not replace a final method.
  • Its parameter and return types must meet the inherited type contract.

Whim checks these rules when it links the classes. An abstract child may leave an inherited abstract method open. A concrete child may not.

Property inheritance

Property types are invariant. A child that redeclares an inherited public or protected property must use the same type. It must also keep the property readonly or writable as declared. It may not change a static property to an instance property or an instance property to static. These rules also apply to properties declared through constructor promotion.

A child may redeclare a compatible inherited property. The object still has one slot for that inherited property.

A private parent property is a different slot from a child property with the same name.

Constant inheritance

A child inherits visible class and interface constants. A class constant may replace an inherited class constant with a narrower type:

class Broad {
  public const int|string VALUE = 1;
}

class Narrow extends Broad {
  public const int VALUE = 2;
}

A child cannot replace a final constant.

An inherited method and constant cannot share a name. A class also cannot replace an interface constant it implements.

Generic base types

Type arguments form part of the inherited contract:

interface Source<out T> {
  public function value(): T;
}

final class IntegerSource implements Source<int> {
  public function value(): int {
    return 1;
  }
}

The number of base type arguments must match. Each argument must meet its bound. A class cannot implement incompatible forms of the same generic interface.

Sealed families

The for clause limits direct children or implementors.

interface Vehicle for Motorized, Towed {}
interface Motorized extends Vehicle for Car {}
interface Towed extends Vehicle for Trailer {}

final class Car implements Motorized {}
final class Trailer implements Towed {}

Only Motorized and Towed may directly extend or implement Vehicle. Only Car may directly extend or implement Motorized. Only Trailer may directly extend or implement Towed.

Permission continues through a listed child. Car is a Vehicle because it implements Motorized. The child controls which symbols may sit directly below it.

A sealed class uses the same form:

abstract class Event for Login {}
class Login extends Event {}
final class DetailedLogin extends Login {}

The linker enforces the family even when its members load from separate files.

Interfaces and Sealed Families

An interface states which members an object provides. It may require methods, constructors, properties, and constants.

interface Record {
  public const string KIND = 'record';
  public readonly int $id;

  public function label(): string;
}

final class User implements Record {
  public function __construct(public readonly int $id) {}

  public function label(): string {
    return self::KIND . ' ' . $this->id;
  }
}

Every interface member states its visibility. Interface properties cannot have storage or default values. A constructor parameter in an interface cannot use property promotion because the interface cannot declare storage.

Method requirements

A method without a body is a requirement:

interface Writable {
  public function write(string $bytes): void;
}

A concrete class must supply a compatible method. Staticness, visibility, parameters, return type, and generic parameters form part of that contract.

An interface may give a method a body. A class then inherits that default unless it supplies its own compatible method.

interface Named {
  public readonly string $name;

  public function displayName(): string {
    return $this->name;
  }
}

final class Person implements Named {
  public function __construct(public readonly string $name) {}
}

write_line!(new Person('Ada')->displayName());

Constructor requirements

An interface can require a constructor:

interface Buildable {
  public function __construct(int $id, int $revision);
}

final class Widget implements Buildable {
  public function __construct(public int $id, public int $revision) {}
}

The class constructor must accept every call allowed by the interface constructor.

Property requirements

An interface may require a public property:

interface MutableName {
  public string $name;
}

interface FixedName {
  public readonly string $name;
}

Property types are invariant. A class that implements MutableName must expose a writable string property. A class that implements FixedName must expose a readonly string property.

Constants

An interface constant has a value. Implementing classes inherit it.

interface Format {
  public const string NAME = 'json';
}

final class JsonFormat implements Format {}

write_line!(JsonFormat::NAME);

An implementing class cannot redeclare that constant. Whim also rejects a class that inherits conflicting constants from two interfaces.

Extending interfaces

An interface may extend more than one interface:

interface HasId {
  public readonly int $id;
}

interface HasLabel {
  public function label(): string;
}

interface Entity extends HasId, HasLabel {}

The child interface contains all inherited contracts. A class must satisfy them as one set. Whim rejects inherited method and constant name conflicts.

self in a contract

self in an interface method resolves to the implementing class at runtime.

interface Copyable {
  public function copy(): self;
}

final class Item implements Copyable {
  public function copy(): self {
    return $this;
  }
}

Generic interfaces

Interfaces may have reified type parameters and variance:

interface Source<out T> {
  public function read(): T;
}

interface Sink<in T> {
  public function write(T $value): void;
}

out T may appear only in output positions. in T may appear only in input positions. An unmarked type parameter is invariant. The generics chapter gives the full rules.

Sealed interfaces

An interface can list the symbols allowed to sit directly below it:

interface Outcome for Success, Failure {}
final class Success implements Outcome {}
final class Failure implements Outcome {}

Any other direct implementor or child interface fails to link. Listed child interfaces may define their own lists. See Inheritance and Visibility for the full rule.

Enums

An enum defines a fixed set of values. Each case is one shared value.

enum Direction {
  case North;
  case South;
}

$direction = Direction::North;
write_line!($direction->name); // North

Code cannot create an enum with new and cannot clone an enum case.

Unit enums

An enum without a backing type is a unit enum. Its cases have names but no backing values.

enum State {
  case Waiting;
  case Running;
  case Done;
}

Every enum case has a public readonly name property. Every enum has a static cases() method that returns its cases in source order.

enum State {
  case Waiting;
  case Running;
  case Done;
}

$states = State::cases();
write_line!($states[0]->name); // Waiting

All enums implement Whim\Enum\UnitEnum.

Backed enums

A backed enum uses int, uint, or string. A type alias that resolves to one of those types also works.

enum Status: string {
  case Ready = 'ready';
  case Waiting = 'waiting';
}

enum Code: int {
  case Ok = 200;
  case Missing = 404;
}

Every case must have a value, and no two cases may have the same value. A backed case has a public readonly value property.

Backed enums also implement Whim\Enum\BackedEnum<int>, Whim\Enum\BackedEnum<uint>, or Whim\Enum\BackedEnum<string>.

Looking up backed cases

from returns the case with a given value. It throws Whim\Unwind\ValueError when no case has that value.

tryFrom returns the case or null.

Backing values retain their kind. A uint-backed enum requires unsigned case values, and a signed lookup does not find an unsigned case of the same magnitude.

enum Status: string {
  case Ready = 'ready';
  case Waiting = 'waiting';
}

$ready = Status::from('ready');
$missing = Status::tryFrom('missing');

write_line!($ready->name);
assert!($missing == null);

Methods and interfaces

An enum may implement interfaces and define concrete methods.

interface Labelled {
  public function label(): string;
}

enum Level: int implements Labelled {
  case Low = 1;
  case High = 10;

  public function label(): string {
    return match ($this) {
      self::Low => 'low',
      self::High => 'high',
    };
  }
}

An enum cannot:

  • extend a class or enum;
  • declare type parameters;
  • declare properties or a constructor;
  • declare abstract methods;
  • replace its built-in enum methods.

Its methods, constants, and cases share one member name set.

Matching enum cases

An enum case is also a type that matches only that case. It can appear directly in a match arm:

enum Status: string {
  case Ready = 'ready';
  case Waiting = 'waiting';
}

function label(Status $status): string {
  return match ($status) {
    Status::Ready => 'ready now',
    Status::Waiting => 'not yet',
  };
}

Whim does not require a match over an enum to list every case at compile time. Code can load types after the first file compiles. If no arm matches at runtime, Whim throws Whim\Unwind\UnhandledMatchError.

Runtime Type Checks

Whim keeps type data while a program runs. It checks types at each boundary that has a declared type.

function double(int $value): int {
  return $value * 2;
}

assert!(double(4) == 8);

Calling double('4') throws Whim\Unwind\TypeError. Whim does not turn the string into an integer.

Where Whim checks types

Whim checks:

  • function and method arguments;
  • return values;
  • property defaults and writes;
  • static properties and constants;
  • enum backing values;
  • generic type arguments and their bounds;
  • collection writes when a typed place holds the collection;
  • is, as, ?as, match patterns, and typed catch clauses;
  • class, interface, and override contracts.

The compiler may prove that a check will pass and remove it. This changes no result. A check remains when the compiler cannot prove the type.

Type errors do not change the old value

Whim prepares a write, runs the operation, checks the result, then stores it. If any step throws, the old place stays unchanged.

class Counter {
  public int $value = 1;
}

$counter = new Counter();
$counter->value = 'wrong'; // throws; value remains 1

This rule also covers nested array writes and compound assignments.

is

$value is T returns true when the value fits T.

function describe(int|string $value): string {
  if ($value is int) {
    return 'integer ' . $value;
  }

  return 'string ' . $value;
}

The value expression runs once. The type may contain aliases, integer ranges, string length limits, collections, type parameters, and symbol types.

An unknown name may run the autoloader. If the name remains unknown, the check returns false instead of throwing. A later declaration can make a later check match.

Whim permits some bare generic names in a class test:

final class Box<T> {}

$box = new Box::<string>();
assert!($box is Box);
assert!($box is Box<_>);

Built-in collection names need their full number of type arguments in a runtime check. Write vec<_>, dict<_, _>, or array<_, _>, not bare vec, dict, or array.

as

$value as T first checks the whole destination type. If the value already fits, it keeps its value and numeric kind. Otherwise it tries numeric conversion and throws TypeError if no valid conversion fits.

$value = 42 as int;

Numeric conversions follow these rules:

FromToRequirement
intuintzero or greater
uintintat most Whim\Math\INT_MAX
int or uintfloatrounds to the nearest binary64 value
floatintfinite, whole, and in [-2^63, 2^63)
floatuintfinite, whole, and in [0, 2^64)

Casts never wrap or truncate a fractional float. They do not parse strings, convert booleans, or convert the elements of a collection.

assert!((42 as uint) == 42u);
assert!((42u as int) == 42);
assert!((42u as float) == 42.0);
assert!((42.0 as uint) == 42u);
assert!((42u as (int|uint)) is uint);
assert!((42.0 as (uint|int)) == 42u);
assert!((42.0 as (int|uint)) == 42);
assert!((1.5 ?as uint) == null);

When conversion is needed, a union tries its numeric branches in source order. Aliases and type parameters follow the types they name. Each result must fit its branch, including range and literal limits. Intersections retain all their constraints; a negated type alone does not choose a numeric conversion. For example, 42.0 as (0u..=10u|int) produces signed 42.

Float conversion can lose integer precision. Whim\Math\UINT_MAX as float rounds to 2^64, which cannot convert back to uint.

A cast to a newtype adds that newtype’s tag after checking the backing type. It does not convert an incompatible backing value: convert to uint before tagging a signed value with a uint-backed newtype.

?as

$value ?as T follows the same checks and numeric conversions, returning null when they fail.

$raw = 'not an integer';
$timeout = ($raw ?as int) ?? 30;

This form does not throw for a type mismatch. It may still throw while evaluating $value.

Deep checks

Collection and generic checks inspect their parts when the type calls for it.

$values = vec[1, 2];
assert!($values is vec<int>);

$values[] = 'changed';
assert!(!($values is vec<int>));
assert!($values is vec<int|string>);

The same rule applies to dict keys and values, tuple positions, shape types, object type arguments, and newtype backing values.

An empty vec has type vec<never>. An empty dict has type dict<never, never>. Since never fits every type, empty collections fit any compatible element type.

Reified generic types

An object keeps its type arguments:

final class Box<T> {
  public function __construct(public T $value) {}
}

$box = new Box::<int>(1);
assert!($box is Box<int>);
assert!(!($box is Box<string>));

Function and method calls also keep their bound type arguments for the length of the call. Code may test a value against a type parameter with is T.

Relative class types

Inside a class:

  • self is the declaring class with its bound type arguments;
  • parent is its direct parent;
  • static is the runtime class on which the call began.

static may appear as a return type but not a parameter type. Use self for a parameter that accepts the declaring class and its children.

Type identifiers

Whim\Type gives code an opaque integer key for a type.

use Whim\Type;

function same_type<T>(T $value): bool {
  return Type\of($value) == Type\id::<T>();
}

assert!(same_type::<int>(42));

Type\of($value) describes the value’s runtime type. Type\id::<T>() describes T. Equal types have equal identifiers in one engine.

Use a type identifier only for equality or as an array key. Its integer value has no public meaning. Do not store it for another process or run.

Loading and checks

Whim can compile a type name before that symbol loads. Once an autoloader or a file declares the symbol, later checks use its real type. This is why Whim does not require all type names to exist when it first parses a file.

Links between declared classes and interfaces are stricter. Whim checks their inheritance and member contracts when it links them.

Unions, Intersections, and Ranges

Whim builds larger types from smaller ones. These operators describe sets of values. They do not convert values.

Union types

A|B accepts a value that fits A or B.

type Number = int|float;

function square(Number $value): Number {
  return $value * $value;
}

A union may contain any valid member except void or never. never adds no value, so Whim rejects it as a redundant union member. mixed already contains every value, so Whim rejects other members beside it.

Direct duplicate or covered members are also errors:

int|int
bool|true

Aliases can hide that two written members are equal. Whim still gives the union the right runtime meaning.

Intersection types

A&B accepts a value that fits both A and B.

interface Named {}
interface Stored {}

function save(Named&Stored $value): void {}

An intersection can combine class and interface contracts or refine any type:

type NonEmptyString = string&!'';
type SmallPositiveInt = int&1..=100;

mixed adds no rule to an intersection, so Whim rejects it there. A direct duplicate member is also an error.

An impossible intersection is a valid empty type. For example, int&SomeInterface has no value unless the two parts can overlap.

Negated types

!T accepts every value outside T.

function require_value(!null $value): !null {
  return $value;
}

Negation works inside collections, callable types, bounds, catch clauses, and other composed types.

$values = vec[1, 'text'];
assert!($values is vec<!bool>);

Useful identities include:

  • !never accepts every value;
  • !mixed accepts no value;
  • !!T has the same values as T.

Whim does not allow negation of void or the wildcard _.

Precedence

Prefix ! and = bind first. & binds before |.

A&B|C       means (A&B)|C
A|B&C       means A|(B&C)
!(A|B)      excludes the whole union

Use parentheses when they make the type easier to read.

Literal types

An integer, float, string, boolean, null, enum case, or constant can describe one value.

type Answer = 'yes'|'no';
type Ordering = -1|0|1;

function enabled(true $value): void {}

Signed integer, unsigned integer, and float literal types remain distinct. 1, 1u, and 1.0 each accept only their own kind.

Integer range types

Range types accept integer intervals.

TypeAccepted values
1..101 through 9
1..=101 through 10
0..zero and all larger integers
..0all integers below zero
..=0zero and all smaller integers

The lower bound is inclusive. .. excludes the upper bound, while ..= includes it.

type Port = 1..=65535;
type Offset = 0..;

function connect(Port $port): void {}

Both bounds may be negative. A reversed or equal exclusive range is empty. 1..=1 contains only 1.

Unsigned bounds select an unsigned range: 1u..=20u, 1u.., and ..=20u accept only uints. Missing bounds extend to zero or the unsigned maximum. Both written bounds must have the same kind; 1..=20u is a compile error. 0u.. is the whole unsigned domain, while ..0u is empty. Signed ranges such as 0.. continue to reject uints.

Ranges work as generic bounds and type arguments:

function clamp_input<T: 0..=100>(T $value): T {
  return $value;
}

assert!(clamp_input::<25>(25) == 25);

String length types

Square brackets after string limit its byte length:

TypeAccepted lengths
string[5]exactly five bytes
string[1..]one byte or more
string[..64]fewer than 64 bytes
string[1..=64]one through 64 bytes

These types use signed integer bounds. Unsigned bounds such as string[4u] are compile errors. Empty ranges accept no value.

type Name = string[1..=64];

function greet(Name $name): string {
  return 'Hello, ' . $name;
}

Whim treats string[0] as '', string[1..] as string&!'', and string[0..] as string.

never

never contains no values. A function that returns never must throw, exit, or keep running.

function fail(string $message): never {
  throw new Whim\Unwind\RuntimeException($message);
}

never may appear in parameters and generic types. Since no caller can supply a value of that type, it helps express branches that cannot run. A method that returns never can satisfy a contract with any return type.

void

void is only a return type. A void function returns no value.

void cannot appear in a parameter, property, union, negation, type argument, or type alias.

Structural Types

Whim can describe the parts of arrays, objects, and callables at runtime.

Homogeneous vecs

vec<T> accepts a vec whose every value fits T.

function total(vec<int> $values): int {
  $sum = 0;
  foreach ($values as $value) {
    $sum += $value;
  }

  return $sum;
}

The vec’s runtime element type changes as code adds or removes values. An empty vec has element type never, so it fits vec<T> for any T.

Bare vec accepts a vec without checking its items at a declared boundary. The matching forms is vec, as vec, and a bare vec match pattern are not valid; write vec<_> when an explicit runtime check may accept any items.

Vec shapes

vec[T0, T1] describes fixed positions and an exact length.

function pair(vec[int, string] $value): void {}

pair(vec[42, 'answer']);

A final ...T allows zero or more extra values of T:

type Row = vec[string, ...int];

assert!(vec['row'] is Row);
assert!(vec['row', 1, 2] is Row);

Homogeneous dicts

dict<K, V> checks every key against K and every value against V.

function scores(dict<string, int> $scores): void {}

scores(dict['Ada' => 10, 'Grace' => 12]);

Dict keys can be int, uint, string, bool, or fresh. The type may use one of them, a union, a range, or another type that fits those key kinds.

An empty dict has type dict<never, never> and fits every valid dict key and value type.

Bare dict and array follow the same boundary rule. In an explicit runtime check, use dict<_, _> or array<_, _>.

Dict shapes

A dict shape lists required keys and their value types.

Keys may be string, integer, or boolean literals. As in dict values, true, 1, and '1' are distinct keys, as are false, 0, and '0'.

type UserRow = dict['id' => int, 'name' => string];

$user = dict['id' => 1, 'name' => 'Ada'];
assert!($user is UserRow);
type Partition<T> = dict[true => vec<T>, false => vec<T>];

$partition = dict[true => vec[1, 3], false => vec[2, 4]];
assert!($partition is Partition<int>);
assert!(!(dict[1 => vec[1, 3], 0 => vec[2, 4]] is Partition<int>));

Without a rest entry, the dict must have only the listed keys. A rest entry allows other keys and gives them a key and value type:

type Named = dict['name' => string, ...];
type ScoredUser = dict['id' => int, 'name' => string, ...<string, int|float>];

$user = dict['id' => 1, 'name' => 'Ada', 'score' => 9.5];
assert!($user is ScoredUser);

Object shapes

#{ name: string } accepts an object with exactly one public instance property, name, whose current value is a string. Add a final ... to allow other public instance properties:

type NamedObject = #{ name: string, ... };
type WithValue<T> = #{ value: T, ... };

class Person {
  public string $name = 'Ada';
  public int $age = 36;
}

$person = new Person();
assert!($person is NamedObject);
assert!($person is #{ age: int, name: string });
assert!(!($person is #{ name: string }));

Property names are case-sensitive identifiers, without $. Required properties must exist, be public instance properties, be initialized, and have values that match their corresponding types. Inherited public properties count. Private, protected, and static properties do not participate, even when the check runs inside their declaring class. An uninitialized required property rejects a check; it does not throw an uninitialized-property error. An extra public property still counts toward exactness when it is uninitialized.

#{} accepts objects with no public instance properties. #{ ... } accepts any object, just like object. Neither form accepts an array or another non-object value. Object shapes have a bare ... rest marker; unlike dict shapes, they do not have a typed rest entry. Property names cannot repeat. Trailing commas are allowed.

Shapes check current values, independently of a property’s declared type. A mixed property holding an integer can satisfy an int entry. A successful check does not change the property’s declared type, visibility, or readonly rules. Mutating the object can make a later check fail, including when the object is held inside a vec, dict, or tuple.

Place a shape after a named type to combine the nominal and structural checks. Name #{ ... } is shorthand for Name & #{ ... }, including generic names:

use Whim\Result\Ok;

$value = new Ok::<string>('hello');
assert!($value is Ok<string> & #{ value: string & !'' });
assert!($value is Ok<string> #{ value: string & !'' });

The name and shape form one type: !Name #{ value: int } negates the entire combination, like !(Name & #{ value: int }). Closed and open property-set rules apply equally to both spellings.

Shapes work in aliases, generic arguments and bounds, callable signatures, parameter and return types, property types, is, as, and ?as. Their subtyping rules compare required names independently of source order and allow narrower property value types. An open expected shape allows extra properties; a closed expected shape requires the same public property set and a closed actual shape. Every object shape is a subtype of object. A class name alone does not promise that its properties are initialized with the required current values; use a shape or an intersection to express that requirement.

The opening #{ is one token. Whitespace or a comment cannot separate # from {. Object shapes are types and patterns, not object construction expressions. See Match and Destructuring for extracting their properties in a match arm.

Tuple types

(A, B) describes an exact tuple length and each position.

type Coordinate = (float, float);

function move(Coordinate $point): Coordinate {
  return ($point[0] + 1.0, $point[1] + 1.0);
}

A one-item tuple type has a trailing comma: (T,).

A final ...T accepts zero or more trailing items of T:

type Delivery = (int, int, ...string);

assert!((41, 99) is Delivery);
assert!((41, 99, 'fragile', 'signed') is Delivery);

The rest item must be last. Omitting its type, as in (int, ...), uses mixed. A tuple has at least one and at most twelve fixed items.

The common array type

array<K, V> accepts a tuple, vec, or dict whose keys fit K and values fit V.

function count_values(array<_, _> $values): int {
  return length!($values) as int;
}

assert!(count_values(vec[1, 2]) == 2);
assert!(count_values(dict['one' => 1]) == 1);
assert!(count_values((1, 'two')) == 2);

Vec and tuple keys are uint positions starting at 0u. Nonempty vecs and tuples can satisfy array<uint, V> but cannot satisfy array<int, V>. Dict keys keep their declared kinds.

array<K, V> is a read-only type view. It does not change the value into a new array form.

Callable types

fn(A, B): R describes a callable that accepts A and B and returns R.

function apply(fn(int): string $format, int $value): string {
  return $format($value);
}

$result = apply(fn(int $value): string => 'n=' . $value, 42);

A leading = marks an optional parameter in a callable type:

fn(int, =string): bool

This type accepts a callable that lets callers omit its second argument.

Bare fn accepts any callable.

Callable parameters are contravariant. A callable that accepts mixed can stand in for one that needs only int. Return types are covariant. A callable that returns int can stand in for one that may return int|string.

Callable values also carry their generic binding and origin. The symbols as types chapter covers specific function and method families.

Class-name types

classname<T> accepts a string that names a class whose instances fit T.

interface Shape {}
class Circle implements Shape {}

function make(classname<Shape> $class): Shape {
  return new $class();
}

$shape = make('Circle');

The string may include reified type arguments, such as Box<int>. A child class name also fits a parent or interface bound. A missing class or wrong type argument fails the check.

The inner type must be able to contain a class-like type. classname<int> is invalid.

Wildcards

_ means that a nested type exists but its fixed value does not matter.

assert!(vec[1, 'two'] is vec<_>);
assert!(dict['one' => 1] is dict<_, _>);

It can select some generic arguments while keeping others fixed:

final class Pair<A, B> {}

$pair = new Pair::<string, int>();
assert!($pair is Pair<_, int>);

It also works in tuple positions and callable parameters.

_ cannot stand alone as a type. Whim does not allow !_. It also does not mean mixed: Box<_> accepts a Box<string>, while Box<mixed> follows the class’s variance and may reject it.

Generics

Whim generics keep their type arguments at runtime. Classes, interfaces, functions, methods, closures, aliases, and newtypes may declare type parameters.

final class Box<T> {
  public function __construct(public T $value) {}
}

function identity<T>(T $value): T {
  return $value;
}

$box = new Box::<int>(42);
$value = identity::<string>('Whim');

Declaring type parameters

Type parameters appear between < and > after a symbol name.

final class Pair<A, B> {
  public function __construct(public A $first, public B $second) {}
}

Each name must be unique in that list. A nested generic declaration may add its own parameters:

final class Box<T> {
  public function __construct(public T $value) {}

  public function map<U>(fn(T): U $transform): Box<U> {
    return new Box::<U>($transform($this->value));
  }
}

Supplying type arguments

A type annotation uses ordinary angle brackets:

Box<int>
dict<string, Box<int>>

A constructor or call uses ::<...>:

final class Box<T> {
  public function __construct(public T $value) {}
}

function identity<T>(T $value): T {
  return $value;
}

$box = new Box::<int>(1);
$value = identity::<int>(2);

The number of arguments must match the number of parameters that lack defaults. Whim does not infer a missing type argument from a value. Supply it or declare a default.

This applies to functions, methods, static methods, closures, and arrows.

$identity = fn<T>(T $value): T {
  return $value;
};

$value = $identity::<string>('text');

Runtime reification

The type argument remains available during the call and in the object.

function matches<T>(mixed $value): bool {
  return $value is T;
}

assert!(matches::<vec<int>>(vec[1, 2]));
assert!(!matches::<vec<int>>(vec[1, 'two']));

An object test includes its type arguments:

final class Box<T> {}

$box = new Box::<int>();
assert!($box is Box<int>);
assert!(!($box is Box<string>));

A generic class may use its parameter in properties, methods, parent types, and implemented interfaces. Every write and call keeps that binding.

Defaults

= Type gives a type parameter a default.

final class Box<T = int> {}

function identity<T = int>(T $value): T {
  return $value;
}

$box = new Box();
$value = identity(42);

Whim applies the default when the caller omits the argument. The default must meet the parameter’s bound. A default cannot depend on its own parameter before that parameter has a binding.

Defaults also let a generic callable fit a non-generic callable type. A generic callable with no defaults still needs type arguments when invoked.

Bounds

T: Bound limits the type arguments accepted for T.

interface Named {
  public function name(): string;
}

function label<T: Named>(T $value): string {
  return $value->name();
}

Whim checks each supplied or defaulted type argument against the bound. Bounds can use any type expression, including unions, ranges, negation, constants, and other type parameters.

Use + when one parameter must meet several bounds:

interface Named {}
interface Stored {}

function save<T: Named + Stored>(T $value): void {}

A bound may depend on another parameter:

type Weaken<T: W, W> = W;

Here T must fit W, and the alias exposes only W at runtime.

Where clauses

A function, method, or closure can declare upper bounds with where. The clause follows the return type, or the parameter list when the return type is omitted:

function identity<T>(T $value): T where T: !null {
  return $value;
}

$identity = fn<T>(T $value): T where T: !null => $value;

$block = fn<T>(T $value): T where T: !null {
  return $value;
};

For a callable’s own type parameters, <T: Bound> and where T: Bound impose the same requirement. Whim checks supplied or defaulted type arguments when it binds them, including when creating a callable with explicit type arguments. Inline bounds and where constraints both apply when a declaration uses both.

A where clause can also constrain type parameters from an enclosing scope:

class Collection<T> {
  public function inspect<U>(): void where T: int|float, U: vec<T> {}
}

Each entry names an available type parameter, followed by : and its upper bound. Instance methods can use class and method type parameters; static methods can use only their own type parameters. Closures can use their own type parameters and those from their enclosing scope. An inner type parameter shadows an outer parameter with the same name.

Constraints on enclosing type parameters are checked before the body or default parameter expressions run. Each type argument must be a subtype of its bound. If any constraint fails, Whim throws Whim\Unwind\TypeError. Repeated constraints must all hold.

These bounds apply only to the callable. They do not change the bounds on its enclosing scope. For example, Collection<string> is a valid type, but calling inspect::<vec<string>>() on it throws TypeError because string does not fit int|float. On Collection<int>, inspect::<vec<int>>() satisfies both bounds.

An override must accept every set of type arguments that the inherited method accepts. It may keep or weaken inherited bounds, but must not strengthen them. This applies whether a bound appears in the type parameter list or a where clause.

FunctionLikeReflection::getWhereConstraints() exposes the function’s where constraints in source order. Reflection keeps inline bounds in TypeParameterReflection::getBounds() and does not combine the two forms.

Constructing a type parameter

Code may create a reified type parameter when its bound supplies a constructor.

interface Constructable {
  public function __construct();
}

function create<T: Constructable>(): T {
  return new T();
}

A bound may also supply static methods:

interface Buildable {
  public static function build(int $seed): static;
}

function build<T: Buildable>(int $seed): T {
  return T::build($seed);
}

Calling a method through a type parameter with no matching bound fails. Whim does not allow class-constant access through a type parameter.

Variance

Variance controls how one generic type relates to another.

Covariance

out T marks an output type parameter.

interface Source<out T> {
  public function read(): T;
}

If int fits int|string, then Source<int> fits Source<int|string>.

A covariant parameter may appear in return types and readonly properties. It may not appear where the caller can send a value in, such as a writable property or method parameter.

Contravariance

in T marks an input type parameter.

interface Sink<in T> {
  public function write(T $value): void;
}

If int fits mixed, then Sink<mixed> fits Sink<int>.

A contravariant parameter may appear in method parameters. It may not appear as an output type.

Invariance

An unmarked parameter is invariant. Cell<int> and Cell<int|string> are then different types, and neither fits the other only because their arguments do.

final class Cell<T> {
  public function __construct(public T $value) {}
}

Whim checks variance in aliases, classes, interfaces, functions, methods, closures, and arrows. A negation reverses the position while Whim checks it.

Generic inheritance

A child binds its parent’s parameters in its extends or implements clause.

interface Source<out T> {
  public function read(): T;
}

abstract class Base<T> {
  public function __construct(protected T $value) {}
}

final class IntegerSource extends Base<int> implements Source<int> {
  public function read(): int {
    return $this->value;
  }
}

The object is an IntegerSource, a Base<int>, and a Source<int>.

Whim rejects a class that reaches the same generic interface with incompatible type arguments.

Generic first-class callables

A first-class callable may remain unbound:

function identity<T>(T $value): T {
  return $value;
}

$generic = identity(...);
$value = $generic::<int>(42);

Calling $generic(42) fails because T has no binding or default.

Bind the type argument while creating the callable when all calls should use one type:

function identity<T>(T $value): T {
  return $value;
}

$integers = identity::<int>(...);
assert!($integers(42) == 42);

A bound callable keeps that type and rejects another binding.

Empty types and covariance

never is useful for variant types. A value such as Result<int, never> can fit Result<int, string> when the error parameter is covariant: it cannot hold an error, so the wider error type is safe.

The same rule explains why an empty vec<never> fits vec<int>.

Symbols as Types

Every Whim symbol has a type meaning. A name in a type position does not always mean a class.

This rule lets types name specific constants, enum cases, functions, and methods as well as objects.

Classes and interfaces

A class name accepts instances of that class and its children.

class Message {}
final class Notice extends Message {}

function send(Message $message): void {}

send(new Message());
send(new Notice());

An interface name accepts objects that implement it. Generic arguments remain part of either type.

Enums and cases

An enum name accepts every case of that enum. An enum case name accepts only that case.

enum Colour {
  case Red;
  case Blue;
}

function paint(Colour $colour): void {}
function paint_red(Colour::Red $colour): void {}

Case types work in unions, bounds, casts, and match arms.

Constants

A constant name accepts values equal to that constant.

const OK = 200;
const NOT_FOUND = 404;

function success(OK $status): void {}

success(200);

The same rule applies to class-like constants:

final class Codes {
  public const int SUCCESS = 200;
}

function success(Codes::SUCCESS $status): void {}

Two constants with the same value denote the same set of values, even when they have different names.

This also gives a bare constant its expected meaning in a match:

const OK = 200;

function label(int $status): string {
  return match ($status) {
    OK => 'ok',
    $_ => 'other',
  };
}

The arm compares the subject with the constant’s value. It does not treat OK as an undeclared class.

Functions

A function name as a type accepts first-class callables made from that function.

function add(int $left, int $right): int {
  return $left + $right;
}

function run_add(add $callable): int {
  return $callable(20, 22);
}

$callable = add(...);
assert!(run_add($callable) == 42);

Another callable with the same signature does not fit add. A partial call of add does not fit it either. Use fn(...) when origin does not matter.

For a generic function, the bare name describes the whole function family. A type argument selects one binding:

function identity<T>(T $value): T {
  return $value;
}

$integer = identity::<int>(...);
assert!($integer is identity);
assert!($integer is identity<int>);
assert!(!($integer is identity<string>));

A non-generic function takes no type arguments in a type.

Methods

Type::method accepts first-class callables made from that method family.

interface Mapper<T> {
  public function map<U>(T $value, U $fallback): U;
}

final class IntegerMapper implements Mapper<int> {
  public function map<U>(int $value, U $fallback): U {
    return $fallback;
  }
}

function call_map(Mapper<int>::map<string> $callable): string {
  return $callable(42, 'value');
}

$map = new IntegerMapper()->map::<string>(...);
assert!(call_map($map) == 'value');

The receiver must belong to the named class or interface family with compatible type arguments. A method on an unrelated class does not fit merely because its signature matches. A partial method call does not fit.

Inherited static method callables may fit both the parent and child method families.

Aliases and newtypes

A type alias expands to its target type. A newtype keeps a distinct tag while also fitting its backing type.

type Identifier = int;
newtype UserId = int;

assert!(1 is Identifier);
assert!(UserId(1) is UserId);
assert!(UserId(1) is int);
assert!(!(1 is UserId));

See Aliases and Newtypes for the full rules.

One symbol name set

At namespace scope, classes, interfaces, enums, constants, functions, aliases, and newtypes share one name set. Code cannot declare two kinds under one name.

Within a class-like body, methods, constants, and enum cases share one name set. Properties have $ names and use a separate set.

This rule removes any guess from a type name. Once Whim resolves a symbol, its kind defines the type meaning above.

Type identifiers

When code needs one key for any type, use Whim\Type\id::<T>(). For a value’s runtime type, use Whim\Type\of($value). These functions avoid trying to turn a type into source text, which cannot preserve aliases, bindings, and loaded symbol identity without ambiguity.

Aliases and Newtypes

Type aliases and newtypes both name a backing type. An alias is transparent. A newtype adds a runtime tag.

Type aliases

Declare an alias with type:

type UserName = string&!'';
type Coordinate = (float, float);

function greet(UserName $name): string {
  return 'Hello, ' . $name;
}

UserName and string&!'' are the same type. A value gains no tag when it passes through the alias.

Aliases may refer to aliases declared later. They may also be recursive when the recursion passes through a collection or other value structure.

type Json = null|bool|int|float|string|vec<Json>|dict<string, Json>;
type Tree<T> = T|(Tree<T>, Tree<T>);

Whim checks recursive values without running forever on cycles.

Generic aliases

An alias may have parameters, bounds, defaults, and variance.

type Pair<A, B> = (A, B);
type Source<out T> = fn(): T;
type NonNull<T: !null = int> = T;

The alias expands with its supplied arguments. Runtime diagnostics may keep the alias name when that name makes the failed contract clearer.

An alias cannot name bare void.

Newtypes

Declare a newtype with newtype:

newtype UserId = int;
newtype PostId = int;

Construct it by calling its name:

newtype UserId = int;
newtype PostId = int;

$user = UserId(7);
$post = PostId(7);

The constructor checks the backing type and adds the tag. UserId(7) fits UserId and int. Plain 7 fits int but not UserId. PostId(7) does not fit UserId.

newtype UserId = int;

function load(UserId $id): void {}

load(UserId(7));

Newtypes use their backing value directly for normal operations:

newtype Count = int;

$total = Count(2) + Count(3);
assert!($total == 5);

Member access, indexing, calls, iteration, arithmetic, comparison, and string joining act on the backing value when that operation supports it.

Casting newtypes

$value as Newtype checks the backing type and applies the newtype tag. ?as returns the tagged value or null.

newtype UserId = int;

$id = 7 as UserId;
$missing = 'seven' ?as UserId;

assert!($id is UserId);
assert!($missing == null);

Casting a newtype to its backing type succeeds. Casting between two newtypes checks the new target’s backing type and applies the target tag.

Generic newtypes

Newtypes may have parameters, bounds, defaults, and variance.

newtype Identifier<T: int|string = int> = T;
newtype Producer<out T> = fn(): T;

$number = Identifier(42);
$name = Identifier::<string>('user');

The constructor uses ::<...> when you supply type arguments.

Layered newtypes

A newtype may back another newtype:

newtype Inner = int;
newtype Outer = Inner;

$value = Outer(Inner(5));
assert!($value is Outer);
assert!($value is Inner);
assert!($value is int);

Each tag remains part of the value’s type.

Mutable backing values

A newtype check always includes the current backing value. Mutation can make a tagged collection stop fitting its declared newtype.

newtype Numbers = vec<int>;

$numbers = Numbers(vec[1]);
$numbers[] = 'changed';

assert!(!($numbers is Numbers));

Whim does not freeze the backing vec. It checks the type again when code crosses a typed boundary.

Dict keys retain their newtype tags. The tag forms part of the key’s identity: UserId(42), OrderId(42), and plain 42 can name three distinct entries. Lookup requires the same tag and backing value. Iteration returns the tagged key, and a dict with tagged keys can satisfy dict<UserId, string>.

JSON and BSON object names must be plain strings. Cast tagged keys to string before encoding them; these formats cannot preserve distinct newtype keys with the same text.

Attributes

Type aliases and newtypes may carry attributes that target their symbol kinds. Whim\Attribute\Attribute defines separate target bits for aliases and newtypes.

Choosing one

Use an alias when two spellings should accept the same values. Use a newtype when code must not mix two values only because their backing types match.

type Port = 1..=65535;
newtype UserId = int;

Port refines integers without adding identity. UserId marks which domain an integer belongs to.

Throwing and Catching

throw stops the current path with an object that implements Whim\Unwind\Throwable.

use Whim\Unwind\InvalidArgumentException;

function divide(int $left, int $right): float {
  if ($right == 0) {
    throw new InvalidArgumentException('division by zero');
  }

  return $left / $right;
}

Throwing a scalar or an object that does not implement Throwable raises TypeError instead.

Throwable data

Whim\Unwind\Error and Whim\Unwind\Exception are the two built-in roots. Both implement Throwable and expose:

  • getMessage(): string
  • getCode(): int
  • getFile(): string
  • getLine(): int
  • getTrace(): vec<TraceFrame>
  • getPrevious(): null|Throwable
  • toString(): string

Their constructor accepts a message, an integer code, and an optional previous throwable.

use Whim\Unwind\RuntimeException;

$cause = new RuntimeException('connection failed');
$error = new RuntimeException('request failed', 0, $cause);

Whim records the file, line, and trace when it creates the throwable. Throwing the same object later does not replace that trace.

A trace frame contains the function name, file, line, and argument values. A marker attribute may hide a sensitive parameter.

Errors and exceptions

Error reports a language, compiler, or runtime contract failure. Examples include TypeError, OutOfBoundsError, ReadonlyError, and UndefinedSymbolError.

Exception reports a failure that application code may handle. LogicException and RuntimeException are its two main groups.

Both use the same throw and catch rules. The split tells readers whether a failure points to a broken program rule or an expected outside condition.

Whim\Unwind\UnsupportedPlatformException extends RuntimeException and reports an operation the running platform cannot provide. Such operations fail before starting work. Before sending or watching a process signal, call isSupported() on its Whim\Process\Signal value. It returns false for all signals on Windows; unsupported signals also return false from isCatchable(). Whim\Command\UnsupportedSignalException extends this exception too.

try and catch

try must have at least one catch, else, or finally clause.

use Whim\Unwind\InvalidArgumentException;

try {
  throw new InvalidArgumentException('bad value');
} catch (InvalidArgumentException $error) {
  write_line!($error->getMessage());
}

Whim tests catch clauses in source order. A clause may omit its variable:

try {
  throw new Whim\Unwind\RuntimeException('failed');
} catch (Whim\Unwind\RuntimeException) {
  write_line!('failed');
}

A union catches more than one type:

use Whim\Unwind\RuntimeException;

final class TimeoutException extends RuntimeException {}
final class NetworkException extends RuntimeException {}

function work(): void {}

function recover(TimeoutException|NetworkException $error): void {}

try {
  work();
} catch (TimeoutException|NetworkException $error) {
  recover($error);
}

If no clause matches, Whim unwinds to an outer try. A throw from a catch body also moves outward; sibling catches do not see it.

Catch guards

Add if after a catch to test the matched value:

use Whim\Unwind\RuntimeException;

try {
  throw new RuntimeException('missing', 404);
} catch (RuntimeException $error) if ($error->getCode() == 404) {
  write_line!('not found');
} catch (RuntimeException $error) {
  write_line!('other failure');
}

Whim runs a guard only after its type matches. A false guard moves to the next clause. If the guard throws, that new throwable replaces the current one and moves outward.

else

A try else block runs only when the try body reaches its end without a throw, return, break, or continue.

$completed = false;

try {
  write_line!('work');
} else {
  $completed = true;
}

assert!($completed);

It does not run after a catch. A throw from else moves outward; sibling catches do not see it.

Variables assigned in the try body are available in else under the normal scope rules.

finally

finally runs after the try body and any matching catch or else. It also runs before return, break, or continue transfers control out of the protected code.

try {
  write_line!('work');
} finally {
  write_line!('cleanup');
}

If finally throws, its throwable replaces a pending return or throwable. Nested finally blocks each run once while control moves outward.

Whim checks a pending return value after it runs finally. Cleanup still runs when the return later fails its declared type.

exit! ends normal execution. It does not run enclosing catch, else, or finally clauses. Whim still runs shutdown destructors.

panic!($message) evaluates its message expression, then writes the string and current stack trace to standard error and exits with status 255. A non-string result throws a catchable TypeError before the panic occurs. Errors during message evaluation also propagate normally. Panic is for a broken invariant, not an error a caller can handle.

Uncaught throwables

An uncaught throwable ends the program with exit code 255. Whim writes its class, message, code, source location, notes, and stack trace to standard error.

Set WHIM_FULL_TRACE=true when the normal trace hides frames marked as boundaries.

Catch narrowly

Catch the type that the current code can handle. Catching Throwable and then continuing can hide type errors, broken invariants, and resource leaks. Let an unknown failure keep its original trace.

Option and Result

Use null|T for simple absence when null cannot be a valid T.

function find_name(int $id): null|string {
  return match ($id) {
    1 => 'Ada',
    $_ => null,
  };
}

Use Option<T> when a present value may itself be null. Use Result<T, E> when failure is data that the caller should inspect.

Keep nullable parameters nullable. Requiring callers to allocate an option to pass no argument adds work and makes calls harder to read.

Option values

Whim\Option\Some<T> contains a value. Whim\Option\None contains no value.

use Whim\Option\None;
use Whim\Option\Some;

$present = new Some::<null>(null);
$absent = new None();

assert!($present->isSome());
assert!($absent->isNone());

Some<null> differs from None, which is the reason to use an option here.

Whim\Option\some($value) and Whim\Option\none() are short constructors.

Reading an option

isSome() and isNone() test the branch.

unwrap() returns the value from Some. It throws LogicException on None. Use it only after isSome() or another rule proves the branch.

unwrapOr($default) returns the value or the given default. unwrapOrElse($fallback) calls the fallback only for None.

use Whim\Option;

$value = Option\none()->unwrapOr::<int>(42);
assert!($value == 42);

Changing an option

MethodSome<T>None
map($f)Some($f($value))unchanged None
mapOr($default, $f)$f($value)$default
andThen($f)$f($value)unchanged None
orElse($f)unchanged Some$f()
filter($test)same Some or Noneunchanged None
inspect($f)calls $f, then returns itselfreturns itself
okOr($error)Ok($value)Err($error)

Callbacks for a branch that does not run are not called.

Result values

Whim\Result\Ok<T> contains a success value. Whim\Result\Err<E> contains an error value.

use Whim\Result\Err;
use Whim\Result\Ok;
use Whim\Result\Result;

function parse_switch(string $value): Result<bool, string> {
  return match ($value) {
    'on' => new Ok::<bool>(true),
    'off' => new Ok::<bool>(false),
    $_ => new Err::<string>('expected on or off'),
  };
}

The error type need not implement Throwable. It may be a string, enum, object, or any other type.

Reading a result

isOk() and isErr() test the branch.

unwrap() returns the success value and throws LogicException on Err. unwrapErr() returns the error and throws on Ok.

unwrapOr($default) returns the success value or the default. unwrapOrElse($fallback) calls the fallback with the error only for Err.

ok() returns Some($value) or None. err() returns Some($error) or None.

Changing a result

MethodOk<T>Err<E>
map($f)Ok($f($value))keeps the error
mapErr($f)keeps the valueErr($f($error))
mapOr($default, $f)$f($value)$default
andThen($f)$f($value)keeps the error
orElse($f)keeps the value$f($error)
inspect($f)calls $f, then returns itselfreturns itself
inspectErr($f)returns itselfcalls $f, then returns itself

The generic return types keep all branches. For example, andThen returns Result<U, E|F> when the old error is E and the callback may return F.

Capturing a throwable

Whim\Result\attempt::<T, E>($callback) runs a callback and returns Ok<T> on success. It catches only E and returns Err<E>. Any other throwable continues outward.

use Whim\Result;
use Whim\Unwind\RuntimeException;

$result = Result\attempt::<int, RuntimeException>(
  fn(): int => throw new RuntimeException('failed'),
);

assert!($result->isErr());

E must implement Throwable.

Must-use results

Option and result methods carry #[MustUse]. If code discards their result by mistake, Whim raises DiscardedResultError. Use the returned value, pass it on, or write discard!(...) when ignoring it is deliberate.

Resources and Cleanup

Whim frees an object when code removes its last strong reference. A class can use __destruct(): void for final cleanup. Code that needs a strict lifetime can use using or drop!.

Ordinary lifetime

An object stays alive while a local, property, collection, closure, call frame, or trace holds it. Assigning over the last local reference may run its destructor at that statement.

final class Lease {
  public function __destruct(): void {
    write_line!('closed');
  }
}

$lease = new Lease();
$lease = null; // may print closed here

Arguments are strong references for the length of the call. Returning an object passes a strong reference to the caller.

using

using binds one or more values to a block and requires the block to hold the last strong reference to each value when it ends.

use Whim\IO\MemoryHandle;

using ($handle = new MemoryHandle()) {
  $handle->writeAll('temporary data');
}

The check runs on normal fallthrough, return, break, continue, and throw. If another strong reference still holds the value, Whim throws LeakedResourceError.

$escaped = null;

using ($handle = new Whim\IO\MemoryHandle()) {
  $escaped = $handle;
} // throws: $escaped still holds the handle

When a throw was already leaving the block, the leak error keeps that throwable as its previous error.

The binding is local to the using block. If an outer local has the same name, Whim restores it after the block. Reassigning the bound local does not change which original value the block owns.

Destructuring in using

A binding may use tuple, vec, or dict destructuring.

using (
  ($reader, $writer) = open_pair(),
) {
  copy($reader, $writer);
}

Whim checks each bound value as the block ends. Every value must have no other strong owner.

drop!

drop!($local) requires the local to hold the last strong reference, releases it, and makes the local undefined.

$resource = open_resource();
drop!($resource);

If another strong reference exists, Whim throws LeakedResourceError and keeps the local unchanged.

drop! accepts several locals. It checks all of them before dropping any, so a failure leaves every local intact.

drop!($first, $second, $third);

A self-cycle or another object cycle counts as another strong reference.

finally

Use finally when cleanup is an action rather than ownership of one value.

function guarded(bool $fail): void {
  try {
    if ($fail) {
      throw new Whim\Unwind\RuntimeException('failed');
    }
  } finally {
    write_line!('finished');
  }
}

guarded(false);

using checks ownership. finally always runs an action. Choose the rule the operation needs.

Explicit close methods

I/O handles and other closeable objects provide close(). Call it when code must handle a close error or release an outside resource before the object’s last reference dies.

The destructor remains a fallback. A safe close method should allow a second call, and its destructor should not mask an earlier failure.

Cycles

Strong references can form an unreachable cycle. Whim’s cycle collector finds such cycles and runs their destructors.

Whim\GC\collect_cycles() requests a collection and returns the number of boxes it freed as a uint. The runtime also starts collection after its cycle threshold. Set that threshold under [runtime] in whim.toml, or set WHIM_CYCLE_THRESHOLD for one run.

Do not depend on the exact order in which unrelated cycle destructors run.

References and Cycles

Object variables are strong references. They keep objects alive and preserve identity across assignments, calls, collections, properties, and closure captures.

The Whim\Reference namespace also provides weak references and weak maps.

Weak references

Weak<T> points to an object without keeping it alive.

use Whim\Reference\Weak;

final class User {
  public function __construct(public string $name) {}
}

function watch_user(): Weak<User> {
  $user = new User('Ada');
  $weak = new Weak::<User>($user);
  assert!($weak->get() is User);
  return $weak;
}

$weak = watch_user();
assert!($weak->get() == null);

get() returns null|T. A non-null result is a new strong reference for as long as the caller keeps it.

A weak reference does not stop using or drop! from releasing a value.

Weak maps

WeakMap<K, V> stores values under object keys without keeping those keys alive.

use Whim\Reference\WeakMap;

final class Request {}

$map = new WeakMap::<Request, string>();
$request = new Request();
$map->set($request, 'state');

assert!($map->has($request));
assert!($map->get($request) == 'state');

When code removes the last strong reference to a key, its entry leaves the map.

The main methods are:

  • set($key, $value): void
  • has($key): bool
  • get($key): V
  • remove($key): V
  • length(): uint

get and remove throw OutOfBoundsError when the key has no entry.

Use a weak map for caches or per-object data that must not own the key. Use a dict when keys are scalar or when the map should own all of its data.

Strong cycles

Two or more objects may keep each other alive after code removes all outside references. Whim records possible cycles and collects them later.

final class Node {
  public null|Node $next = null;
}

$left = new Node();
$right = new Node();
$left->next = $right;
$right->next = $left;
$left = null;
$right = null;

Whim\GC\collect_cycles();

Weak references can break ownership cycles when one direction only needs to observe the other object.

Iterators

foreach can read an array, an Iterator<K, V>, or a ToIterator<K, V>. Vecs and tuples yield uint keys. Dicts and custom iterators keep their key types.

$letters = dict['a' => 1, 'b' => 2];
foreach ($letters as $letter => $position) {
  write_line!($letter . ': ' . $position);
}

The key is optional:

foreach (vec['Ada', 'Grace'] as $name) {
  write_line!($name);
}

Iterator

An iterator owns one position. Its next() method returns the next key and value, or null after the last item.

interface Iterator<out K, out V> {
  public function next(): null|(K, V);
}

Passing an iterator to a second foreach does not rewind it. The second loop starts at the iterator’s current position.

ToIterator

A value that can start a new pass implements ToIterator<K, V>:

interface ToIterator<out K, out V> {
  public function toIterator(): Iterator<K, V>;
}

foreach calls toIterator() at the start of each pass. The method should return a new iterator unless the type has a clear reason to share a position.

Writing an iterable value

ArrayIterator turns any Whim array into an iterator.

use Whim\Iterate\ArrayIterator;
use Whim\Iterate\Iterator;
use Whim\Iterate\ToIterator;

final class Names implements ToIterator<uint, string> {
  public function __construct(private vec<string> $names) {}

  public function toIterator(): Iterator<uint, string> {
    return new ArrayIterator::<uint, string>($this->names);
  }
}

$names = new Names(vec['Ada', 'Grace']);
foreach ($names as $index => $name) {
  write_line!($index . ': ' . $name);
}

Iterable types

Whim\Refine\Iterable<K, V> is the union of:

  • Iterator<K, V>
  • ToIterator<K, V>
  • array<K, V>

Library functions use this type when they only need to read a sequence. Such a function accepts a tuple, vec, dict, iterator, or iterable object without first copying it.

An iterator may yield data once. Do not assume that an Iterable can start a second pass unless it is an array or implements ToIterator.

Iterator helpers

map, filter, take, drop, and join return lazy iterators. They read an entry only when their next() method needs one. map and filter keep the source keys. take does not read past its limit.

Their concrete types are public: MapIterator, FilterIterator, TakeIterator, DropIterator, and JoinIterator. Use the helper functions for short pipelines, or construct these types when an API needs a concrete adapter.

count, reduce, to_vec, and to_dict consume the rest of an iterable. to_dict keeps its keys; to_vec keeps only its values. take and drop accept uint counts; count returns a uint.

use Whim\Iterate;

$values = Iterate\take::<uint, int>(
  Iterate\filter::<uint, int>(
    Iterate\map::<uint, int, int>(
      vec[1, 2, 3, 4],
      fn(int $value): int => $value * 2,
    ),
    fn(int $value): bool => $value > 4,
  ),
  1u,
);

assert!(Iterate\to_vec::<uint, int>($values) == vec[6]);

Functions in Whim\Vec and Whim\Dict return complete arrays instead.

Use a lazy iterator for a long stream or when the caller may stop early. Use a vec or dict when the caller needs random access, a count, or more than one pass.

Tasks and Futures

Whim runs tasks on one event loop. A task may pause while it waits for a timer, a socket, a channel, or another task. Other ready tasks run during that pause. This is concurrency, not parallel work on several CPU cores.

Starting a task

Async\spawn() schedules a callable and returns a Future<T>.

use Whim\Async;

$future = Async\spawn::<int>(fn(): int {
  return 21 * 2;
});

$answer = $future->await();
assert!($answer == 42);

The task starts on a later event-loop turn. await() pauses the current task until the future succeeds or throws. If the task throws, await() throws the same error.

Code must observe every future. Await it, attach a result handler, return it, or call ignore() when the result does not matter. Dropping an unobserved failed future may raise UnhandledAwaitableException.

Future operations

Future<T> extends Promise<T> and provides:

  • await($cancellation): T waits for its result.
  • map($success): Future<U> changes a success value.
  • then($success, $failure): Future<U> handles either result.
  • catch($failure): Future<T|U> recovers from a failure.
  • always($callback): Future<T> runs after either result.
  • ignore(): static marks the result as intentionally unobserved.

These methods return a new future, except await() and ignore(). They do not change the source future.

use Whim\Async;

$length = Async\spawn::<string>(fn(): string => 'whim')
  ->map::<uint>(fn(string $value): uint => length!($value));

assert!($length->await() == 4u);

Deferred results

Deferred<T> owns the write side of a future. Its consumer receives only the Future<T> interface.

use Whim\Async\Deferred;

$deferred = new Deferred::<int>();
$future = $deferred->getFuture();
$deferred->complete(42);
assert!($future->await() == 42);

Call complete($value) for success or error($error) for failure. A deferred may complete only once.

Waiting for many tasks

Async\all() awaits every keyed future and returns a dict with the same keys. It waits for all futures even when one fails. It throws the sole error or a CompositeException that contains all errors.

Async\concurrently() starts each supplied callable, then applies all(). Async\series() calls each callable in order without spawning tasks.

Async\first() returns or throws the first completed result. Async\any() returns the first successful result and throws CompositeException if every future fails. Both require at least one future. Futures that lose remain observable; neither function consumes or ignores their values.

Yielding and timers

  • Async\later() pauses until the next event-loop turn.
  • Async\sleep($duration, $cancellation) pauses for a duration.
  • Async\drain() runs scheduled work until no work remains.

Use later() to let ready work run without adding a time delay. Use sleep() for a real delay. Both may suspend the current task.

Task-local values

TaskLocal<T> stores one value per task. A new task starts with no value, even when the task that starts it has set one. Each TaskLocal object has its own storage.

use Whim\Async;
use Whim\Async\TaskLocal;

$requestId = new TaskLocal::<string>();
$requestId->set('main');

$future = Async\spawn::<null|string>(fn(): null|string => $requestId->get());
assert!($future->await() == null);
assert!($requestId->get() == 'main');

$requestId->clear();
assert!($requestId->get() == null);

Top-level code has its own task-local storage. get() returns null until the current task calls set(), and again after clear().

TaskGroup

TaskGroup tracks tasks that return void.

use Whim\Async\TaskGroup;

$group = new TaskGroup();
$group->defer(fn(): void {
  write_line!('first');
});
$group->defer(fn(): void {
  write_line!('second');
});
$group->awaitAll();

awaitAll() waits for every tracked task. It then throws the sole error or a CompositeException with all failures. A cancellation token cancels the wait, not the tasks already in the group.

WaitGroup

WaitGroup tracks work completed elsewhere. Call add() before starting one unit of work, call done() once it finishes, and call wait() to pause until the count reaches zero. done() throws if the count is already zero.

getCount() returns the current count as a uint. A cancellation token cancels only the wait.

Semaphore and Sequence

Semaphore<Tin, Tout> limits how many calls to one operation may run at once. Its constructor takes a nonzero uint limit and the operation. waitFor($input) waits for a slot, runs the operation, and releases the slot in a finally block.

Sequence<Tin, Tout> is a semaphore with a limit of one. It runs calls in order. Both types can report pending work, wait for a free slot, and cancel pending calls with a supplied error.

Channels and Cancellation

A channel sends typed values between tasks. Channel\bounded() creates a fixed-size buffer. Channel\unbounded() creates a buffer with no fixed limit. Both return a sender and a receiver.

use Whim\Async;
use Whim\Channel;

($sender, $receiver) = Channel\bounded::<string>(4u);

$producer = Async\spawn::<null>(fn(): null {
  $sender->send('one');
  $sender->send('two');
  $sender->close();
  return null;
});

assert!($receiver->receive() == 'one');
assert!($receiver->receive() == 'two');
$producer->await();

Sending

Sender<T> provides:

  • send($value, $cancellation) waits for buffer space.
  • trySend($value) returns at once and throws FullException when full.
  • waitUntilSendable($cancellation) waits for space without sending a value.

Sending through a closed channel throws ClosedException.

Receiving

Receiver<T> provides:

  • receive($cancellation): T waits for a value.
  • tryReceive(): T returns at once and throws EmptyException when empty.
  • waitUntilReceivable($cancellation) waits for a value without taking it.

A receiver can drain values buffered before the channel closed. Once the closed channel is empty, receive operations throw ClosedException.

The API throws instead of using null to mean “no value.” A channel may carry null when T permits it.

Channel state

The sender and receiver share these operations:

  • getCapacity(): null|uint returns null for an unbounded channel.
  • count(): uint returns the buffered item count.
  • isFull() and isEmpty() inspect the buffer.
  • close() ends further writes.
  • isClosed() reports whether the channel has closed.

Closing an already closed channel has no further effect.

Cancellation tokens

Long waits accept null|CancellationToken. A wait with null does not observe a token.

A cancellation token provides four operations:

interface CancellationToken {
  public function isCancellationRequested(): bool;
  public function throwIfCancellationRequested(): void;
  public function register(fn(): void $callback): int;
  public function unregister(int $id): void;
}

Code that starts a cancellable wait should first call throwIfCancellationRequested(), then register a callback, and unregister it in finally. Most users should pass a token to library operations instead of managing registrations themselves.

Signal cancellation

SignalCancellationToken starts uncancelled. Calling cancel() marks it cancelled, runs each registered callback, and makes later checks throw CancelledException. A second cancel() has no effect. The optional cause is stored under the cancellation error.

If one callback throws, cancel() rethrows it. If several callbacks throw, it throws CompositeException.

use Whim\Async\SignalCancellationToken;

$token = new SignalCancellationToken();
$token->cancel();
assert!($token->isCancellationRequested());

Linked cancellation

LinkedCancellationToken accepts one or more source tokens. It cancels when any source cancels. Destroying it removes its source registrations.

Timeouts

TimeoutCancellationToken cancels after a Time\Duration. It may also take a parent token. It cancels when either the timer ends or the parent cancels.

The timer arms only while code has registered a callback. Direct calls to isCancellationRequested() and throwIfCancellationRequested() still check the elapsed time.

Timeout cancellation throws CancelledException whose cause is a TimeoutException. Parent cancellation has no timeout cause.

Cancellation is cooperative. It wakes operations that accept the token. It does not stop arbitrary code or undo work that already finished.

Attributes

An attribute adds typed data to a file, declaration, or parameter. Whim checks the attribute class, its target, its arguments, and whether it may repeat.

Defining an attribute

Mark a class with Whim\Attribute\Attribute:

use Whim\Attribute\Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class Table {
  public function __construct(public string $name) {}
}

#[Table('users')]
final class User {}

Applying #[Table('users')] stores the class and arguments. Reflection’s newInstance() creates a Table value. The arguments follow the same count and type rules as a normal constructor call and may not read local variables.

An attribute class may have no constructor. Apply it without parentheses:

use Whim\Attribute\Attribute;

#[Attribute(Attribute::TARGET_FUNCTION)]
final class Endpoint {}

#[Endpoint]
function home(): string {
  return '/';
}

Targets

Pass one or more target flags to #[Attribute]:

FlagTarget
TARGET_CLASSclass, interface, or enum
TARGET_FUNCTIONnamed function or closure
TARGET_METHODmethod
TARGET_PROPERTYproperty
TARGET_CLASS_CONSTANTclass-like constant or enum case
TARGET_PARAMETERfunction, method, or closure parameter
TARGET_TYPE_ALIAStype alias
TARGET_NEWTYPEnewtype
TARGET_CONSTANTnamespace constant
TARGET_FILEsource file
TARGET_SYMBOLany named symbol
TARGET_ALLevery supported target

Join flags with |:

use Whim\Attribute\Attribute;

#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)]
final class Timed {}

With no flag, an attribute accepts every target.

Repeated attributes

An attribute may appear once on one target unless its flags include IS_REPEATABLE.

use Whim\Attribute\Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final readonly class Tag {
  public function __construct(public string $name) {}
}

#[Tag('http')]
#[Tag('public')]
final class Handler {}

Each use stores its own arguments. Source order stays intact.

File attributes

Use #![...] to attach attributes to the whole file. The opening #![ is one token. A file attribute list is a top-level statement and needs no semicolon.

namespace App;

use Whim\Attribute\Attribute;
use Whim\Reflection;

#[Attribute(Attribute::TARGET_FILE | Attribute::IS_REPEATABLE)]
final readonly class Tag {
  public function __construct(public string $name) {}
}

#![Tag(name: 'http')]

function home(): string {
  return '/';
}

$file = Reflection\reflect_function('App\\home')->getFile();
foreach ($file->getAttributes::<Tag>() as $attribute) {
  write_line!($attribute->newInstance()->name);
}

Put file attributes at the top level of a file or namespace, including after imports or executable statements. They apply to the whole file. The compiler collects them before execution and optimization. Function bodies and control-flow blocks contain only executable statements, so they cannot contain file attributes.

Names follow the namespace and imports at the attribute’s location. Arguments follow the same constant-expression rules as other attributes and cannot read local variables. All file attribute lists share one target, including lists in different namespaces. Repeating an attribute anywhere in the file requires IS_REPEATABLE. Reflection returns them in source order.

File attributes belong to the file, so a symbol’s getAttributes() does not include them. Use the symbol’s getFile() to read them. A file that contains only declarations, imports, and file attributes has no top-level code.

Reading attributes

Use Whim\Reflection to inspect attributes:

use Whim\Attribute\Attribute;
use Whim\Reflection;

#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final readonly class Tag {
  public function __construct(public string $name) {}
}

#[Tag('http')]
#[Tag('public')]
final class Handler {}

$handler = Reflection\reflect_class('Handler');
assert!($handler != null);

foreach ($handler->getAttributes::<Tag>() as $attribute) {
  $tag = $attribute->newInstance();
  write_line!($tag->name);
}

Every declaration reflection provides getAttributes::<T>() and getAttributesByName(). Both return attribute reflections without running an attribute constructor. AttributeReflection::newInstance() constructs a fresh attribute value.

Use reflect_function() for a function. A class-like reflection provides its methods, properties, and constants. A method reflection provides its parameters. reflect_object($case)->getEnumCase() finds an enum case. Missing declarations and members return null.

See Reflection for the complete API.

Whim rejects non-constant arguments, missing attribute classes, invalid targets, and illegal repeats before top-level code runs. newInstance() checks argument values against the constructor’s parameter types.

Built-in Attributes

Whim supplies attributes for call contracts, error reports, and lint settings.

Lint settings

Whim\Lint\Allow, Warn, Deny, and Forbid set a lint rule’s level within a declaration or file. All four are repeatable and accept a string $rule and an optional null|string $reason = null. They support every attribute target and remain ordinary attribute objects at runtime. See Scoped lint attributes for their scope and argument rules.

MustUse

#[MustUse] requires the caller to consume a return value. It accepts an optional note.

use Whim\Marker\MustUse;

#[MustUse('store or send the token')]
function make_token(): string {
  return 'token';
}

$token = make_token();

Calling make_token(); as a statement raises DiscardedResultError. Call discard!(make_token()) when discarding the value is intentional.

Whim rejects #[MustUse] on a callable that returns void or never, since such a call has no result to consume.

SensitiveParameter

#[SensitiveParameter] hides one argument in stack traces.

use Whim\Marker\SensitiveParameter;

function sign(string $message, #[SensitiveParameter] string $secret): string {
  return $message . ':' . $secret;
}

The call still receives the real value. Only diagnostic output gets a SensitiveParameterValue in its place. SensitiveParameterValue::getValue() returns the hidden value to code that already holds that wrapper.

Deprecated

#[Deprecated] marks a symbol or member as old. Its first argument is the version that marked it old. Its optional second argument explains what to use.

use Whim\Marker\Deprecated;

#[Deprecated('0.2.0', 'use encode()')]
function old_encode(string $value): string {
  return $value;
}

Using the symbol reports the version and note.

TrackCaller

#[TrackCaller] moves an explicit throw site through marked wrappers to the first outer call without the marker. This keeps a small public wrapper from hiding the user’s call site.

Use it on an API that checks arguments or forwards an error. Do not add it to a callable that cannot throw and does not call user code.

TraceBoundary

#[TraceBoundary] hides the marked frame and deeper implementation frames from normal stack traces. WHIM_FULL_TRACE=true whim app.whim shows them.

Use it at a library boundary where lower frames add no useful action for the caller. It does not catch, change, or suppress an error.

Compiler Markers

These marker attributes tell the compiler how it may treat a declaration. Most application code does not need them.

Inline control

  • #[AlwaysInline] asks the optimizer to inline a resolved function or method.
  • #[NeverInline] forbids inlining.
  • #[Cold] marks a rare function or method and keeps it out of line.

These markers do not change a program’s result. They may change its bytecode, speed, code size, and stack trace. Measure before adding them.

Frameless

#[Frameless] marks an eligible function or method that can run without a call frame. The callable must take no parameters and return a literal value. The compiler rejects other shapes.

Frameless calls cannot hold normal call state. Use the marker only for tiny constant accessors supplied by the standard library.

Inheritance checks

#[ConsistentConstructor] makes child constructors keep a signature that is compatible with the parent constructor.

#[ConsistentGenerics] makes inherited generic bounds stay equal through the class family.

Both markers belong on classes. They let code use a class family without finding a different construction or generic contract on one child.

Stub

#[Stub] gives source tools the signature of a symbol whose implementation already exists in the language core. The compiler checks that the real symbol exists and matches the stub, then skips the source body.

The optional reason says why the symbol is a stub. The optional issue records the tracked speed work for a temporary built-in implementation.

namespace Whim\Process;

use Whim\Marker\Stub;

#[Stub]
function get_id(): (1..) {}

Only the standard library should declare stubs. A normal package must provide the code it declares.

Autoloading

Whim asks one registered callback to load a missing symbol. The standard library turns that callback into an ordered list of Autoloader values.

An autoloader receives the requested SymbolKind and full name. It returns true when it handled the request and false when the next loader should try.

Direct symbol maps

Autoloader::withSymbolFile() maps one full symbol name to a file. withSymbolFiles() adds a dict of mappings. Both return a cloned loader and leave the old value unchanged.

use Whim\Autoload;
use Whim\Autoload\Autoloader;

$loader = new Autoloader()
  ->withSymbolFile('App\\User', directory!() . '/src/User.whim')
  ->withSymbolFile('App\\load_user', directory!() . '/src/functions.whim');

Autoload\register($loader);

When Whim requests a mapped name, the loader calls require_once! for its file. The file must declare the requested symbol.

A fallback loader

withFallback() sets a callable for names outside the direct map.

use Whim\Autoload;
use Whim\Autoload\Autoloader;
use Whim\Symbol\SymbolKind;

$loader = new Autoloader()->withFallback(
  fn(SymbolKind $kind, string $name): bool {
    if ($name != 'App\\User') {
      return false;
    }

    require_once!(directory!() . '/src/User.whim');
    return true;
  },
);

Autoload\register($loader);

The kind tells the loader whether Whim needs a class, interface, enum, function, constant, type alias, or newtype. One name cannot belong to two symbol kinds.

The loader list

Autoload\register() appends a loader. unregister() removes every equal entry and does nothing when no entry matches. get_autoloaders() returns the current list in call order.

Autoload\load_symbol($kind, $name) asks the same chain used by the engine and returns whether the symbol now exists.

A loader that returns true must define the requested symbol. If it does not, Whim throws UndefinedSymbolError. Syntax, type, link, and top-level errors from a loaded file pass to the caller.

Generated dependency loader

whim install writes vendor/autoload.whim. Requiring it registers one loader for the root project and installed packages:

require_once!(directory!() . '/vendor/autoload.whim');

The generated loader contains fixed namespace maps. It does no network access, manifest parsing, package resolution, or directory scan. whim run does not look for it; the application must require it.

Git Dependencies

Whim uses Git repositories as package identities and SemVer tags as releases. It has no registry, package names, global cache, install scripts, features, or path-only package form.

Every package command stores data under the current project. whim run knows nothing about this system.

Whim also maintains official packages in separate Git repositories. They use the same commands and file layout as any other package.

Manifest

The root whim.toml may contain:

manifest-version = 1

[package]
repository = "https://github.com/acme/application"
homepage = "https://acme.example"
author = "Acme"
description = "An example application."
license = "MIT"
sponsor = "https://github.com/sponsors/acme"

[requirements]
whim = "^0.1"

[autoload.namespaces]
"App\\" = "src/"

[dependencies]
"git+https://github.com/acme/router.git" = "^1.2"

[dev-dependencies]
"git+ssh://[email protected]/acme/testing.git" = { version = "~2.0" }

[conflicts]
"git+https://github.com/acme/old-router.git" = "*"

[suggests]
"git+https://github.com/acme/profiler.git" = "^1"

[overrides]
"git+https://github.com/acme/router.git" = "git+https://github.com/acme/router-fork.git"

[format]
include = ["**/*.whim"]
exclude = ["src/generated/**"]
print_width = 80
tab_width = 2
use_tabs = false
end_of_line = "lf"

[runtime]
optimizations = "on"
call-depth = 10000
cycle-threshold = 10001
full-trace = false

Every manifest starts with manifest-version = 1. Unknown fields are errors.

Runtime settings

[runtime] controls program execution. optimizations accepts "on" or "off". call-depth sets the frame limit. cycle-threshold sets the cycle collector’s root limit. full-trace keeps TraceBoundary frames in traces.

All four fields are optional. WHIM_OPTIMIZATIONS, WHIM_CALL_DEPTH, WHIM_CYCLE_THRESHOLD, and WHIM_FULL_TRACE override them for one command. The boolean environment value accepts true or false.

Package details

[package] is optional. repository, homepage, author, description, license, and sponsor describe the project. They do not identify it or alter resolution.

license must be a valid SPDX expression. With no license, Whim treats the package as proprietary when it warns about incompatible licenses. sponsor must be an HTTP or HTTPS URL without credentials.

Whim warns when a dependency has no license. It also warns when a dependency requires a copyleft license but the root license has no copyleft choice. This check is a warning, not legal advice or a full license proof.

Engine requirement

[requirements].whim limits the Whim versions that may use the package. A missing requirement accepts any version. The root and every selected package must accept the running Whim version.

Source groups

[dependencies] is part of the running application. [dev-dependencies] is for development. One source cannot appear in both. Whim ignores development dependencies declared by installed packages.

Each value is a Cargo-style SemVer requirement. The short and table forms are equal:

[dependencies]
"git+https://github.com/acme/a.git" = "^1.2"
"git+https://github.com/acme/b.git" = { version = ">=2, <3" }

Whim accepts exact, caret, tilde, wildcard, and comma-joined comparisons. It does not accept ||. The requirement must permit a pre-release before the resolver can select it.

[conflicts] names versions that cannot share a graph with this package. [suggests] lists useful packages that Whim does not install on its own.

[overrides] belongs only in the root manifest. It tells Whim to read releases and code from a replacement repository while keeping the original repository as the graph identity. The replacement must have tags that satisfy the original requirements.

Namespace maps

Each [autoload.namespaces] key ends in \. Its value is a relative directory inside the repository. The empty prefix and Whim\ are forbidden. When prefixes overlap, the longest prefix wins. Two selected packages may not export the same exact prefix.

Git sources

Whim accepts:

  • git+https://
  • git+ssh://
  • git+file://
  • normal HTTPS URLs and SCP-style SSH input accepted by package commands

The manager normalizes command input before storing identity. It rejects plain HTTP, git://, relative paths, URL queries, fragments, passwords, tokens, and Git transport helpers. HTTPS, SSH, and local file URLs remain different identities even when they point to the same repository.

Each release tag must be 1.2.3 or v1.2.3. If both names point to one commit, they are one release. If they point to different commits, that version is ambiguous and cannot resolve. Whim ignores non-SemVer tags.

Resolution

Whim selects one version for each normalized source. All paths to that source must accept the same version. An override changes the Git repository that supplies versions and code; it does not change the source key in the graph.

The resolver prefers a version already present in whim.lock when that version still meets all rules. For an unlocked source, it chooses the highest matching release. It selects a pre-release only when a requirement permits that exact pre-release line.

Each selected manifest must accept the running Whim version. Whim rejects a self-dependency, a dependency cycle, a selected conflict, or two constraints with no common release. The error shows the sources and requirements that led to the conflict.

The resolver reads only runtime dependencies from installed packages. It also reads their Whim requirement, namespace maps, conflicts, suggestions, license, and sponsor link. It rejects overrides in an installed package.

Commands

init

whim init creates a project in the current directory. It writes whim.toml, src/main.whim, and tests/main.whim, then starts a Git repository unless the directory already belongs to one. It also writes .gitignore and .gitattributes rules for dependency and archive files.

whim init
whim init --no-git

--no-git skips Git and both Git files. Initialization preserves existing source files and existing Git rules. It never overwrites an existing manifest.

add and remove

whim add https://github.com/acme/router.git --version '^1.2'
whim add [email protected]:acme/testing.git --dev
whim remove https://github.com/acme/router.git

Without --version, add chooses the latest stable release and writes a caret requirement. Adding an existing source changes its requirement. Moving it between runtime and development requires removing it first.

Both commands resolve and stage the whole new state before replacing tracked files.

install

whim install
whim install --no-dev

With no lock, install resolves the graph and creates one. With a current lock, it installs those exact commits and does not choose newer tags. It fails when resolution fields in the manifest no longer match the lock.

--no-dev leaves the development closure and its namespaces out of the vendor tree and loader. A warm install reuses checked local data and need not contact the remote.

update

whim update
whim update https://github.com/acme/router.git

With no source, update may move the whole graph within its requirements. With sources, it unlocks those identities and moves other packages only when their constraints require it. A targeted update needs an existing lock.

If a locked version tag now points to another commit, update stops and reports the old and new commit IDs.

explain and inspect

whim show SOURCE prints details about one installed package. The report includes its locked version, tag, commit, install path, package metadata, namespace maps, Whim requirement, dependencies, development dependencies, conflicts, and suggestions. The source may use any spelling that normalizes to the locked Git identity.

whim why SOURCE prints the chain that requires a locked source and says whether it is installed.

whim why-not SOURCE --version RANGE explains why that source and range cannot join the current graph. The range defaults to *.

whim suggestions lists suggestions from the root project and installed packages. Each entry names who suggested it. whim fund prints Whim’s sponsor link and the sponsor links in the installed graph.

Lock and vendor tree

Commit whim.lock. It pins each normalized source, version, tag, commit, tree, manifest hash, exported-file hash, dependencies, optional replacement source, license, sponsor link, and suggestions. It stores no time, credential, or incidental working-directory path. A configured git+file:// source is an absolute identity, so its path appears in the lock.

The lock also stores a hash of the root fields that affect resolution:

  • the Whim requirement;
  • namespace maps;
  • runtime and development dependencies;
  • conflicts;
  • overrides.

Package details, format settings, suggestions, comments, and TOML order do not make a root lock stale. For an installed package, the hash covers its Whim requirement, namespace maps, runtime dependencies, and conflicts.

The lock parser rejects unknown fields, unsorted or repeated package entries, malformed hashes, and references to missing packages.

Ignore vendor/ unless the project wants to commit installed source for an offline release. Its layout is:

vendor/
  autoload.whim
  packages/<source-hash>/
  .whim/git/<source-hash>.git/
  .whim/stages/
  .whim/state.toml
  .whim/install.lock

Package directories use the full BLAKE3 hash of the normalized source. The local bare Git repositories live only in this project. install.lock stops two package commands from changing the project at once.

Loading packages

Applications opt in:

require_once!(directory!() . '/vendor/autoload.whim');

For App\Model\User mapped to src/, the loader tries:

  1. src/Model/User.whim
  2. a group file in src/Model/

The group file is classes.whim, interfaces.whim, enums.whim, functions.whim, types.whim, or constants.whim, based on the requested symbol kind.

The loader performs at most two file checks for one matched prefix. A file that loads but does not define the requested symbol causes UndefinedSymbolError.

Safety and transactions

Whim uses the system Git program without a shell. It works only with bare repositories that it created. It does not run package hooks, scripts, filters, submodules, or executables.

Remote Git commands stop after five minutes. Set WHIM_PACKAGE_NETWORK_TIMEOUT to a positive number of seconds to change this limit for one command.

The archive reader rejects absolute paths, parent traversal, links, devices, FIFOs, excessive path lengths, and size limits. Whim hashes sorted file paths, modes, lengths, and contents, then checks an existing vendor tree before reuse.

The fixed input limits are:

  • 1 MiB for a manifest, lock, or installed-state file;
  • 8,192 sources in one graph;
  • 100,000 tags from one source;
  • 100,000 archive entries, including directories, in one package;
  • 4,096 bytes in one package path;
  • 256 MiB in one file;
  • 1 GiB of unpacked data in one package.

Whim rejects archive links, devices, and FIFOs rather than trying to copy them. It keeps regular files, directories, and executable mode bits.

Package commands take a project lock. They prepare packages and the loader under vendor/.whim/stages/, and keep backups and recovery data under vendor/.whim/. They then swap the manifest, lock, vendor tree, and loader together. The project root changes only during that final swap. The next package command stops if it finds vendor/.whim/transaction.pending.

Inspect the manifest, lockfile, vendor tree, loader, state file, staging directories, and backups before removing that marker. Whim does not recover an interrupted swap on its own.

Official Packages

The Whim project maintains Trifle packages on Codeberg. Each package has its own repository.

These packages use names under Trifle\. Their Git tags define their versions. Install them through Whim’s Git package manager.

PackageDescriptionInstall
ansiANSI colours, styles, cursor movement, and screen controls.whim add git+ssh://[email protected]/trifle/ansi
argsTyped command-line parsing and generated usage.whim add git+ssh://[email protected]/trifle/args
cacheStampede-safe local LRU and database caches.whim add git+ssh://[email protected]/trifle/cache
configTyped configuration from values, files, and the environment.whim add git+ssh://[email protected]/trifle/config
consoleTerminal output, tables, progress, and prompts.whim add git+ssh://[email protected]/trifle/console
cqrsCommand and query buses with events, logs, and traces.whim add git+ssh://[email protected]/trifle/cqrs
diffMyers diffs for values and text, with unified output.whim add git+ssh://[email protected]/trifle/diff
dotenv.env parsing and environment layering.whim add git+ssh://[email protected]/trifle/dotenv
eventTyped events with hierarchy-aware listeners.whim add git+ssh://[email protected]/trifle/event
expectFluent expectations for tests.whim add git+ssh://[email protected]/trifle/expect
fakeDeterministic fake data generated from a seed.whim add git+ssh://[email protected]/trifle/fake
feedAtom and RSS feed generation.whim add git+ssh://[email protected]/trifle/feed
humanizeHuman-readable durations, sizes, counts, and numbers.whim add git+ssh://[email protected]/trifle/humanize
lockLeased mutual exclusion over pluggable stores.whim add git+ssh://[email protected]/trifle/lock
logStructured logging with composable loggers.whim add git+ssh://[email protected]/trifle/log
markdownCommonMark and GitHub-flavoured Markdown parsing.whim add git+ssh://[email protected]/trifle/markdown
markdown-ansiMarkdown rendering for styled terminal text.whim add git+ssh://[email protected]/trifle/markdown-ansi
markdown-htmlMarkdown rendering for HTML.whim add git+ssh://[email protected]/trifle/markdown-html
moneyInteger money, ISO 4217 currencies, and lossless allocation.whim add git+ssh://[email protected]/trifle/money
otpHOTP, TOTP, and authenticator provisioning.whim add git+ssh://[email protected]/trifle/otp
qrQR code generation with matrix and SVG output.whim add git+ssh://[email protected]/trifle/qr
queueMessage queues with retries, workers, and pluggable stores.whim add git+ssh://[email protected]/trifle/queue
sealSealed payloads and signed JWTs with key rotation.whim add git+ssh://[email protected]/trifle/seal
semverSemantic versions and Cargo-style requirements.whim add git+ssh://[email protected]/trifle/semver
templateLogic-less Mustache templates rendered from JSON values.whim add git+ssh://[email protected]/trifle/template
themePaints and themes for terminal text.whim add git+ssh://[email protected]/trifle/theme
traceTimed spans recorded through structured logs.whim add git+ssh://[email protected]/trifle/trace

Codeberg hosts all Trifle package repositories.

After you add Trifle\Diff and load vendor/autoload.whim, you can compare two sequences:

use Trifle\Diff;
use Trifle\Diff\Operation;

$edits = Diff\diff::<string>(vec['a', 'b', 'c'], vec['a', 'c', 'd']);

foreach ($edits as $edit) {
  $marker = match ($edit->operation) {
    Operation::Keep => ' ',
    Operation::Delete => '-',
    Operation::Insert => '+',
  };

  write_line!($marker . ' ' . $edit->value);
}

See Git Dependencies for manifests, locks, updates, and autoloading.

Debugging and Tests

Whim has language constructs for checks and debug output.

assert!

assert!($condition) requires a boolean. It throws AssertionError when the value is false.

$answer = 6 * 7;
assert!($answer == 42);

The error shows the source expression. A comparison also shows its left and right values.

Assertions always run. Whim has no release mode that removes them.

debug!

debug!($value) prints the call site and a detailed form of the value.

$numbers = vec[1, 2, 3];
debug!($numbers);

Strings show their byte length and escaped bytes. Objects show their class, generic arguments, visible property values, and hidden private values. Arrays show their size and items.

Debug output stops after 64 collection items and 32 nested levels. This keeps a large or cyclic value from filling the terminal. The path is relative to the working directory when possible.

Do not parse debug output. Its form may change between Whim releases.

Writing tests

Use assert! for each condition:

assert!(2 + 2 == 4);
assert!(length!('whim') == 4u);
assert!(Whim\Str\contains('whim', 'him'));
assert!(contains_key!(vec[1, 2], 1));

You may add a message after the condition:

$status = 200;
assert!($status == 200, 'the request must succeed');

Whim does not impose a test file layout or test runner. A test is a normal Whim program that throws on failure. A shell, build tool, or Whim script can run a set of such files.

Stack traces

An uncaught throwable prints its type, message, source, and call stack. Normal traces stop at TraceBoundary frames. Set WHIM_FULL_TRACE=true when debugging the standard library itself:

WHIM_FULL_TRACE=true whim tests/example.whim

Parameters marked SensitiveParameter remain hidden in either trace mode.

panic!($message) evaluates an expression that must produce a string, prints the same kind of stack trace, then exits with status 255. The panic cannot be caught; a non-string message throws a catchable TypeError instead. Use panic to mark a state that must never occur.

Bytecode

Use disassembly when a result differs between optimized and unoptimized code, or when checking whether the optimizer found a known type:

whim disassemble example.whim > optimized.txt
WHIM_OPTIMIZATIONS=off whim disassemble example.whim > plain.txt

Bytecode is an implementation detail. It may change in any release.

Standard Library Rules

The standard library uses the Whim\ namespace. The CLI loads its compiled artifact before it compiles the entry file. Programs do not need to require it.

Whim also maintains official packages under the Trifle\ namespace. They do not ship in the standard-library artifact.

Public and private code

Public declarations live under domain namespaces such as Whim\Str, Whim\HTTP, and Whim\Database.

The language core supplies names under Whim\_Private. A domain _Private namespace holds Whim code used by that domain. Both forms are implementation details. Application code must not call them.

Most library code uses Whim. The core supplies operations that need the operating system, event loop, heap, or a tested low-level library.

Input and output types

Library functions take the broadest safe input and return the narrowest useful output. A read-only collection function normally accepts Refine\Iterable<K, V>, not only a vec or dict. A function that needs random access or a known size accepts an array.

Refined types state checked bounds in the signature:

use Whim\Refine\NonEmptyString;
use Whim\Refine\NonZero;

function repeat_name(NonEmptyString $name, NonZero<uint> $count): vec<string> {
  return Whim\Vec\fill($count, $name);
}

The engine checks those bounds at the call.

Missing values

The library uses null|T when T cannot itself contain null. It uses Option<T> when T is generic and may contain null, since Some(null) and None must stay different.

This rule keeps concrete APIs small without losing a value in generic code. Function parameters follow the same rule.

Errors

Library operations throw on failure. Result is available for APIs where an error is ordinary data, but it is not the default error path.

The Whim\Unwind hierarchy covers general argument, state, range, and runtime errors. A domain adds its own exception when callers need to distinguish that failure. Cancellation uses CancelledException.

Value style

Configuration and message objects are often readonly. Methods named with... return a changed clone:

$configuration = Whim\TCP\ListenConfiguration::default()
  ->withReuseAddress(true)
  ->withBacklog(256u);

Mutable resources, pools, buffers, handles, and task controls use stateful methods. Any object that owns a closeable operating-system resource also closes it from its destructor. Use using for prompt cleanup.

Naming

Functions use snake_case; methods use camelCase. Types use PascalCase and constants use SCREAMING_SNAKE_CASE.

An operation driven by a callback often ends in _by. Its key-aware form often ends in _with_key or _by_key. A try... method returns at once and reports that it cannot proceed; a blocking form may suspend the current task.

Stability

Whim has no compatibility promise. The standard library may add, remove, rename, or redesign an API in any release. Read the docs that ship with the binary you run.

Core Types and Functions

This page covers small contracts used throughout the standard library.

Refine

Whim\Refine names common types:

  • ArrayKey is string|int|uint|bool|fresh.
  • Numeric is int|uint|float.
  • Scalar is int|uint|float|string|bool|fresh.
  • Nullable<T> is T|null.
  • NonNull, NonEmptyString, NonEmptyVec<T>, and NonEmptyDict<K, V> exclude empty values.
  • NonZero<T> excludes zero from a numeric type. NonZero<uint> accepts positive uints; NonZero<int> accepts positive and negative nonzero ints.
  • NegativeInt and NonPositiveInt name signed integer ranges.
  • Uint8, Uint16, and Uint32 name unsigned integer ranges with fixed bounds. Int8, Int16, and Int32 name their signed counterparts.
  • Percent is 0u..=100u; Digit is 0u..=9u.
  • Iterable<K, V> accepts an iterator, a value that creates an iterator, or an array.
  • AnyTuple and AnyTupleOf<T> accept tuples of any supported length.
  • Exclude<T, N> removes N from T; Extract<T, U> keeps their overlap.

Standard-library counts and lengths use uint, with literals such as 0u and 16u. The length!() built-in also returns uint.

It also names common callables:

  • Predicate<T>: fn(T): bool
  • Transform<T, U>: fn(T): U
  • Reducer<T, U>: fn(U, T): U
  • Consumer<T>: fn(T): void
  • Supplier<T>: fn(): T
  • Comparator<T>: fn(T, T): Comparison\Ordering

Falsy and Truthy describe PHP’s old truth rules for data conversion. Whim conditions still require bool; Whim does not apply those aliases to if or loops.

Option and Result

Option<T> is the sealed family Some<T>|None. Some stores its public readonly value. None stores nothing.

The main operations are isSome, isNone, unwrap, unwrapOr, unwrapOrElse, map, mapOr, andThen, orElse, filter, okOr, and inspect. unwrap() on None throws LogicException.

Result<T, E> is the sealed family Ok<T>|Err<E>. Their public readonly fields are value and error. Result adds unwrapErr, mapErr, ok, err, and inspectErr to the same map and chain style. Unwrapping the wrong side throws LogicException.

Use Option\some($value) and Option\none() as short constructors. Result\attempt::<T, E>($callback) returns Ok or catches E into Err.

See Option and Result for examples and the null rule.

Comparison

Equal<T> defines equals(T): bool. Order<T> extends it with compare(T): Ordering and default methods for less, greater, min, max, and clamp.

Ordering has Less, Equal, and Greater. It can test each relation, reverse an order, or chain a second order through then and thenWith.

Collection sort functions take Comparator<T>, which returns Ordering, not an integer.

Conversion and defaults

Convert\ToString defines the explicit toString(): string contract. Whim has no magic __toString method.

Default\Default defines public static function default(): static. Types use it when one value is the clear default configuration or empty state.

Runtime type IDs

Whim\Type is part of the runtime core. Programs can use it without a standard-library artifact.

Type\of($value) returns the engine-local TypeId of a value. Type\id::<T>() returns the ID of a reified type. Equal types have equal IDs in one engine run.

Type\to_debug_string($id) returns a type spelling for logs and diagnostics. The spelling is not an identity or a source format. Two different IDs may have the same debug string, such as when one name denotes a type parameter in one place and a declared type in another. Never use the string as a map key or for type comparison; use TypeId itself. The spelling may change between Whim releases.

Type IDs are for maps and dispatch inside one process. They may change between runs. Do not store them in files or send them over a network.

Symbols and enums

Symbol\exists($name, $autoload) checks any named symbol. get_kind($name) returns its SymbolKind or throws when absent.

Every enum implements Enum\UnitEnum; a backed enum also implements BackedEnum<int|uint|string>. Unit enums expose name; backed cases also expose value.

Garbage collection

Reference counts release ordinary values. GC\collect_cycles() asks the cycle collector to find unreachable strong cycles and returns the number it collected as a uint. Normal programs seldom need to call it.

Function Helpers

Whim\Fn provides five helpers that return callables. They use the Transform<I, O> and Consumer<T> aliases from Whim\Refine and need no package install.

HelperReturned callable
identity<T>()Returns its input unchanged.
tap<T>(callback)Calls the callback, then returns its input.
after<I, O, R>(first, next)Passes the result of first to next.
when<I, O>(condition, then, otherwise)Tests the input and calls the chosen branch.
rethrow()Throws the throwable passed to it.

The helpers do not call their callbacks until you call the returned function. Callbacks may suspend or throw. after waits for the first callback to return before it calls the next; when calls only the chosen branch. Errors pass through unchanged.

Identity and observation

identity returns the same value. tap adds a callback without changing the value passed to the next step:

use Whim\Fn;
use Whim\Reference\Strong;

$identity = Fn\identity::<int>();
assert!($identity(7) == 7);

$seen = new Strong::<int>(0);
$observe = Fn\tap::<int>(fn(int $value): void {
  $seen->value = $value;
});

assert!($observe(3) == 3);
assert!($seen->value == 3);

tap returns the input after the callback runs. A callback can still change an object it receives, as with any other function call.

Composition and branches

after runs two transforms in order. The first output type must match the second input type:

use Whim\Fn;

$doubleThenText = Fn\after::<int, int, string>(
  fn(int $value): int => $value * 2,
  fn(int $value): string => 'n=' . $value,
);

assert!($doubleThenText(4) == 'n=8');

when passes the same input to the predicate and the chosen transform. Both branches must return the declared output type:

use Whim\Fn;

$choose = Fn\when::<int, string>(
  fn(int $value): bool => $value > 0,
  fn(int $_): string => 'positive',
  fn(int $_): string => 'other',
);

assert!($choose(1) == 'positive');
assert!($choose(0) == 'other');

Rethrowing errors

rethrow returns a fn(Throwable): never. Use it when an error callback should throw the error it receives:

use Whim\Fn;
use Whim\Unwind\RuntimeException;

$rethrow = Fn\rethrow();
$expected = new RuntimeException('failed');
$caught = null;
try {
  $rethrow($expected);
} catch (RuntimeException $error) {
  $caught = $error;
}

assert!($caught == $expected);

Each helper has #[MustUse]: store, pass, call, or explicitly discard the returned callable.

Reflection

Whim\Reflection inspects loaded files, declarations, types, and values.

Files

get_loaded_files() returns loaded FileReflection values, sorted by path with anonymous inputs first. reflect_file($path) finds a loaded file or returns null. Both functions read loaded metadata without loading or executing a file.

A file reflection provides:

  • getPath(): the recorded source path, or null for an anonymous input such as standard input.
  • getOrigin(): the file’s DeclarationOrigin, matching its symbols’ origin.
  • getLocation(): the whole source span for a user file with a path, or null otherwise.
  • getAttributes::<T>() and getAttributesByName($class): file attributes in source order. Each attribute’s getTarget() returns its FileReflection.
  • getDocumentation(): null; files have no declaration documentation.
  • getSymbols($kind = null): the file’s named symbols, sorted by fully qualified name. Pass a Whim\Symbol\SymbolKind to select one kind.
  • hasTopLevelCode(): whether the source contains executable statements outside function and method bodies, including statements inside namespaces.
use Whim\Reflection;
use Whim\Symbol\SymbolKind;

foreach (Reflection\get_loaded_files() as $file) {
  write_line!($file->getPath() ?? '<anonymous>');
  foreach ($file->getSymbols(SymbolKind::Function) as $function) {
    write_line!('  ' . $function->getName());
  }
}

Lookup matches the recorded path exactly, including its bytes. Use a path from getPath() or SourceLocation::getFile(). Artifacts keep their individual source files; the artifact container itself is not an extra source file. If a path has been loaded more than once, lookup and listing use its latest load. Anonymous inputs remain separate entries even when they share a diagnostic label.

Every symbol reflection has getFile(), which returns its defining FileReflection or null for a symbol defined in Rust. This works for classes, interfaces, enums, functions, constants, type aliases, and newtypes, including symbols from anonymous inputs. A symbol keeps its defining file even when the same path is loaded again later.

Symbols include declarations across all namespaces in the file. Methods, closures, imports, and stub declarations backed by another definition are not separate symbols in this list. Files without named symbols still appear in get_loaded_files().

The compiler records hasTopLevelCode() before optimization. Unreachable statements still count, even if optimization removes them. Imports, declarations, and file attributes do not count. Initializers within declarations do not count either, so this flag does not guarantee that loading the file has no side effects.

FileReflection implements DeclarationReflection. The attribute syntax for files is #![...]; the target flag is Attribute::TARGET_FILE.

Find declarations

Each symbol kind has its own lookup function:

  • reflect_class, reflect_interface, and reflect_enum find class-like symbols.
  • reflect_type_alias and reflect_newtype find named types.
  • reflect_function and reflect_constant find functions and constants.
  • reflect_symbol finds any named symbol.
  • reflect_class_like accepts a class name or an object.

Each function returns null for a missing name or the wrong symbol kind. The second argument to reflect_symbol controls autoloading. Name-based lookup functions may invoke the registered autoloader.

namespace Example;

use Whim\Reflection;

final class User {
  public function __construct(public int $id) {}
}

$class = Reflection\reflect_class('Example\\User');
assert!($class != null);
assert!($class->getName() == 'Example\\User');
assert!($class->getProperty('id')?->isPromoted());

get_loaded_symbols() returns symbols loaded in the current engine. Its optional Whim\Symbol\SymbolKind argument filters the result. This function does not invoke an autoloader.

Origin and source

Each declaration has one DeclarationOrigin:

  • Core: Rust code in the engine.
  • Extension: code from a Whim artifact, including the standard library.
  • User: source that the program loads.

Only user declarations have a SourceLocation or a docblock. Core and extension declarations return null for both. A source location gives the file, byte offsets, lines, and columns. getDocumentation() returns the whole docblock, including /** and */.

Declarations list their attributes. getAttributes::<T>() returns attributes whose class fits T. getAttributesByName() matches an exact class name. AttributeReflection::newInstance() creates an instance of the attribute.

Symbols and members

The Whim\Reflection namespace has one reflection class for each named symbol kind.

On a class-like reflection, getMethods(), getProperties(), and getConstants() include inherited members. getDeclaredMethods(), getDeclaredProperties(), and getDeclaredConstants() return direct declarations only. A class reflection gives its parent, interfaces, constructor, destructor, flags, and attribute definition. An enum reflection gives its cases and backing type.

The Whim\Reflection\Member namespace covers methods, properties, class constants, and enum cases. Member lookup functions return null for a missing name. A method lists the parent or interface methods it implements. A property gives its type, default value, and promoted, readonly, and static flags.

Invoke callables and create instances

FunctionReflection::invoke($typeArguments = vec[], $arguments = vec[]) calls the reflected function. CallableReflection::invoke() takes the same arguments and calls the fn value retained by reflect_callable(). Both implement Whim\Reflection\InvokableReflection.

Type arguments are a vec<TypeReflection> in type parameter order. Value arguments are a vec<mixed> in parameter order. Omitted arguments use the callable’s defaults. Whim checks argument counts, types, bounds, where constraints, and return types as it does for a direct call. It does not infer type arguments from values.

use Whim\Reflection;

function identity<T>(T $value): T {
  return $value;
}

$function = Reflection\reflect_function('identity');
$int = Reflection\reflect_type::<int>();
assert!($function->invoke(vec[$int], vec[42]) == 42);

$prefix = 'Hello, ';
$greet = Reflection\reflect_callable(fn(string $name): string => $prefix . $name);
assert!($greet->invoke(vec[], vec['world']) == 'Hello, world');

A callable keeps its captures, bound receiver, class scope, type bindings, and partial arguments. Pass only the remaining arguments to a partial application. A specialized fn uses its stored type arguments; passing more type arguments throws TypeError. ClosureReflection describes a closure declaration. To invoke a closure with its captures, reflect the fn value with reflect_callable().

MethodReflection::invoke($target, $typeArguments = vec[], $arguments = vec[]) requires an object for an instance method and a classname<object> for a static method. The target’s class must be the declaring class or one of its subtypes. An unrelated class fails even if it has a method with the same name. The call uses the target’s override when one exists. For a static call, the target class binds static, including new static() and a static return type. The method’s declaring class still determines self and parent.

Visibility uses the scope that calls invoke(). Holding a reflection of a private or protected method grants no extra access. Instance methods get class type arguments from the object; the supplied type arguments bind only the method’s own type parameters.

ClassReflection::instantiate($typeArguments = vec[], $arguments = vec[]) creates an object and runs its constructor. Type arguments bind the class; value arguments go to the constructor. Normal constructor visibility and instantiation rules apply. Constructors and destructors cannot be called through MethodReflection::invoke().

Every supplied TypeReflection must be resolved. Use resolve() with a type environment to replace unbound parameters before passing them. Invocation returns the callable’s result, or null for a void callable. Exceptions propagate to the caller, and calls may suspend as usual. Discarding an invoked callable’s result still enforces its #[MustUse] attribute.

Generics

A generic declaration lists its type parameters in source order. Each TypeParameterReflection gives its owner, position, variance, bounds, and default.

FunctionLikeReflection::getWhereConstraints() returns Whim\Reflection\Generic\WhereConstraintReflection entries in source order, including repeated constraints. Functions, methods, and closures without a clause return an empty vector. Each constraint provides:

  • getParameter(): the exact TypeParameterReflection it constrains.
  • getBound(): its upper bound as a TypeReflection, with generic parameters left unresolved.
  • getDeclaringFunctionLike(): the function, method, or closure that declares the constraint.
  • getPosition(): its zero-based position in the clause.
  • getLocation(): its source span for user code, or null for an artifact.

Inherited methods retain their original declaring method and type parameters. Resolve a bound with an object or callable’s TypeEnvironmentReflection to substitute its type arguments. Where constraints remain separate from TypeParameterReflection::getBounds(), which describes the parameter declaration. Artifacts preserve these constraints.

A TypeEnvironmentReflection maps each type parameter to its type argument. The declaration forms part of a type parameter’s identity, so two parameters named T use separate keys. Object and callable reflections include bindings from parent classes and interfaces.

ParameterReflection::getDefaultValue($environment = null) evaluates a parameter’s default with the supplied type bindings. Pass the callable’s type environment for a generic function or method, or the object’s or specialized class type’s environment for a constructor parameter. Each call evaluates the default again, including any object or closure it creates.

getSpecialization() returns the type arguments that a class or object passes to a parent class or interface. TypeReflection::resolve() replaces type parameters with arguments from a type environment. Its second argument supplies the called class type for static.

Types

Three functions return type reflections:

  • reflect_type::<T>() reflects the reified type T.
  • reflect_type_of($value) reflects a value’s runtime type.
  • reflect_type_id($id) finds the type for an engine-local Whim\Type\TypeId.

TypeReflection reports the type kind, text, resolved state, and type ID. For a resolved type, accepts() tests a value, equals() compares types, and isSubtypeOf() tests the subtype relation.

Whim\Reflection\Type has a reflection class for each type form: primitive values, literals, integer ranges, string lengths, named types, unions, intersections, negation, functions, collections, shapes, class names, tuples, wildcards, type parameters, and static.

The primitive fresh uses PrimitiveTypeReflection, like int and uint. Its kind is TypeKind::Fresh:

use Whim\Reflection;
use Whim\Reflection\Type\PrimitiveTypeReflection;
use Whim\Reflection\Type\TypeKind;
use Whim\Type;

$identity = fresh!();
$type = Reflection\reflect_type::<fresh>();
assert!($type is PrimitiveTypeReflection);
assert!($type->getKind() == TypeKind::Fresh);
assert!($type->toString() == 'fresh');
assert!($type->accepts($identity));
assert!(!$type->accepts($identity as uint));
assert!($type->equals(Reflection\reflect_type_of($identity)));
assert!($type->getId() == Type\of($identity));
assert!(Reflection\reflect_type_id($type->getId())?->equals($type));

Every fresh identity has the same type ID. That ID identifies the fresh type; $identity as uint gives the value’s representation. Reflection keeps fresh in collection types, generic arguments, properties, and callable signatures. A fresh-backed newtype keeps its tag; reflecting its getBackingValue() returns the fresh type.

The primitive uint has kind TypeKind::Uint. Literal, integer-range, enum backing-value, and dict-shape-key reflection preserve unsigned values and their full 64-bit magnitude. Their value and bound results can therefore be uint.

StringLengthTypeReflection reports the least byte length and the optional greatest byte length.

toString() returns text for logs and error messages. The text is neither a type ID nor valid source code. getId() and equals() compare types.

Live values

ObjectReflection keeps a strong reference to its object and reports:

  • its class and reified class type;
  • its full type environment;
  • every instance property, including private properties from parent classes;
  • each property’s declared type, current value type, and whether it is initialized.

PropertyValueReflection::getValue() throws UninitializedPropertyError when the property is uninitialized.

reflect_callable() returns a CallableReflection for a fn value. It reports the declaration, function type, type bindings, bound object, called class, captured values, and bound arguments. Its getDeclaration() returns a FunctionReflection, MethodReflection, or ClosureReflection. These share the FunctionLikeReflection interface for declaration metadata.

Three predicates distinguish callable values:

ValueisClosure()isFirstClassCallable()isPartialFunctionApplication()
fn(int $x): int => $xtruefalsefalse
foo(...) or $object->method(...)falsetruefalse
foo::<int>(...)falsetruefalse
foo(?, 'fixed') or foo(?, ?)falsefalsetrue

Exactly one predicate returns true. Partial function application reports isPartialFunctionApplication() even when it binds no values or starts from a closure. getDeclaration() still reports the original declaration. Binding type arguments or using (...) on an existing callable preserves its kind.

reflect_newtype_value() returns the outer newtype reflection, or null for a value that has no newtype. getBackingValue() returns the value inside that newtype. Pass the result to reflect_newtype_value() to inspect a nested newtype.

Object shape types

reflect_type::<#{ value: int, ... }>() returns an ObjectShapeTypeReflection with kind TypeKind::ObjectShape. isOpen() reports whether extra public instance properties are allowed. getProperties() returns ObjectShapePropertyReflection values in source order; each has getName() and getType() methods. The usual accepts(), isSubtypeOf(), resolve(), and type identity operations apply to object shapes. accepts() checks current property values on every call.

Strings, Numbers, and Binary Data

Byte strings

Whim strings hold bytes, so Whim\Str uses byte offsets and byte lengths.

length returns uint; searches return null|uint. Search and slice input offsets stay signed, so negative offsets can count from the end. Slice, splice, and comparison lengths use null|uint. A length larger than the remaining bytes clamps to the end. Repeat counts and padding lengths use uint too. Chunk sizes, split limits, and wrap widths use NonZero<uint>.

Inspection functions include length, ord, chr, byte_at, compare, compare_ci, search, search_last, contains, starts_with, and ends_with. Search and containment functions accept a byte offset. The byte checks are is_whitespace, is_digit, is_letter, is_alphanumeric, is_hex_digit, and is_ascii_punctuation. A _ci suffix means ASCII case-insensitive matching.

Slice functions include slice, splice, chunk, split, range, before, after, and their last and case-insensitive forms.

Transform functions include ASCII lowercase, uppercase, capitalize, reverse, repeat, rot13, replacements, prefix and suffix stripping, padding, trimming, shuffling, word splitting, and wrapping.

use Whim\Str;

$words = Str\split('one,two,three', ',');
assert!(Str\join(' + ', $words) == 'one + two + three');
assert!(Str\starts_with('whimsical', 'whim'));
assert!(Str\slice('abcdef', 1, 3u) == 'bcd');
assert!(Str\length('abcdef') == 6u);

Use Encoding\UTF8 before treating unknown bytes as Unicode text.

Unicode text and code points

Unicode\case_fold applies full, locale-independent case folding to valid UTF-8. It can expand one code point into several, such as \u{df} into ss. It throws EncodingException when the string is not valid UTF-8.

Unicode\code_point_at reads a scalar value at a byte offset. Unicode\code_point_before reads the value ending before an offset. They return null at the matching string end and U+FFFD for malformed UTF-8. Str\from_code_point encodes a Unicode\ScalarValue as UTF-8.

Unicode\CodePoint covers all code points from zero through U+10FFFF. Unicode\ScalarValue excludes the surrogate range, which UTF-8 cannot encode.

The other Whim\Unicode functions test integer code points without decoding a string. They check valid scalar values, whitespace, letters, marks, numbers, decimal digits, punctuation, symbols, separators, controls, and case. Every check returns false for an invalid code point.

use Whim\Unicode;

assert!(Unicode\case_fold("Stra\u{df}e") == 'strasse');
assert!(Unicode\code_point_at("\u{1f600}", 0) == 0x1f600);
assert!(Unicode\is_letter(0x4e2d));
assert!(Unicode\is_whitespace(0x3000));
assert!(Unicode\is_punctuation(0x3001));

Integers and floats

Int\try_parse($text), UInt\try_parse($text), and Float\try_parse($text) return null for invalid input. They do not accept a partial number.

UInt\try_parse accepts decimal digits, an optional leading +, and leading zeros. It rejects a minus sign, whitespace, underscores, base prefixes, source suffixes, fractions, exponents, and values above the unsigned maximum. UInt\div(uint $numerator, uint $denominator): uint returns the exact integer quotient and throws DivisionByZeroError for a zero denominator.

use Whim\Math;
use Whim\UInt;

assert!(UInt\try_parse('18446744073709551615') == Math\UINT_MAX);
assert!(UInt\try_parse('42u') == null);
assert!(UInt\div(Math\UINT_MAX, 2u) == 9_223_372_036_854_775_807u);

Float also tests NaN, finite, and infinite values. to_bits and from_bits convert a 64-bit float to its integer bit pattern. to_bytes and from_bytes use an explicit Binary\Endianness.

Math

Whim\Math provides checked integer division, absolute value, clamp, square root, exponent, logarithms, floor, ceiling, round, and trigonometry.

expm1(x) computes exp(x) - 1, and log1p(x) computes ln(1 + x). Both keep accuracy when x is close to zero. log1p(-1.0) returns negative infinity, and inputs below -1.0 return NAN. tanh computes the hyperbolic tangent. All three use native floating-point operations and preserve signed zero.

dot multiplies pairs of floats and adds the products in iteration order. It accepts vectors, tuples, dictionaries, and objects that implement Iterator or ToIterator. Dictionary keys do not affect the pairs. Both inputs must have the same length, or it throws Whim\Unwind\LengthException. Empty inputs return 0.0.

sum and sum_floats accept iterables. min, max, min_by, and max_by return null for no input. mean and median accept arrays because they need their size or more than one pass.

to_base, from_base, and base_convert support bases 2 through 36. to_base accepts non-negative ints and the full uint range. Numeric comparison and aggregate helpers such as min, max, mean, and median accept uints. sum keeps its signed inputs; float-only functions still require float.

The namespace defines integer and float limits plus NAN, INF, E, and PI. Read each limit by its full name: positive minima and lowest signed values use different constants. UINT_MIN and UINT_MAX are 0u and 18_446_744_073_709_551_615u.

Whim\Refine\Uint8, Uint16, and Uint32 accept uints within their bit widths: 0u..=255u, 0u..=65_535u, and 0u..=4_294_967_295u. NonZero<uint> excludes 0u. These types do not accept signed ints. Use uint for the full unsigned 64-bit range.

Ranges

Whim\Range represents full, lower-bound, upper-bound, and two-bound integer ranges. full, from, to, and between build them. Range objects expose their bounds and can create an iterator.

These objects are useful when a runtime value must carry a range. Type ranges such as 1..=10 remain part of the type system.

Binary encoding

Whim\Binary reads and writes signed and unsigned integers of 8, 16, 32, and 64 bits, plus 32-bit and 64-bit floats. Multi-byte functions require Endianness::Big or Endianness::Little.

One-shot encode_* functions return bytes; decode_* functions read bytes and check their exact width. MemoryReader, MemoryWriter, HandleReader, and HandleWriter provide moving cursors. Buffered readers report remaining data; buffered writers return their bytes through toString().

Unsigned decoders and reader methods always return uint, including for zero and small values. Cast the result to int when a signed API requires it; values above INT_MAX fail that cast. Unsigned encoders and writer methods require uints within the chosen width, including the 64-bit methods. All reject values that do not fit. Reader byte counts, skips, cursors, lengths, and remaining-byte counts also use uint.

Use binary APIs for protocol fields and file formats. Do not reverse byte strings by hand.

Collections and Data Structures

Vec functions

Whim\Vec reads any Iterable<_, T> when keys do not matter and returns a packed vec.

Creation and access include values, keys, fill, reproduce, and integer range. Transform tools include map, map_with_key, map_nonnull, flat_map, enumerate, reductions, and reduce. fill and reproduce take a uint size. The reproduce callback receives a uint index. keys and key-aware callbacks use uint keys for vecs and tuples. enumerate returns (uint, T) pairs.

Filtering includes filter, key-aware filters, null removal, partitioning, and callback forms that keep non-null results.

Order and slice tools include reverse, unique, unique_by, sort, sort_by, shuffle, take, drop, slice, and chunk. equals compares vecs whose values implement Comparison\Equal<T>. concat, flatten, and zip combine inputs. take, drop, and slice take uint counts and offsets. chunk takes a nonzero uint size.

use Whim\Vec;

$values = Vec\map::<int, int>(vec[1, 2, 3], fn(int $n): int => $n * 2);
$even = Vec\filter::<int>($values, fn(int $n): bool => $n % 4 == 0);
assert!($even == vec[4]);

These functions return new vecs. Indexed assignment, append assignment, remove!, and swap_remove! change an existing vec variable. remove! preserves order; swap_remove! moves the last item into the removed index.

Dict functions

Whim\Dict keeps keys. It creates dicts from iterables, entries, keys, value selectors, groups, and counts. count_values returns uint counts. Copying a vec or tuple into a dict keeps its uint keys; 0 and 0u remain distinct dict keys.

Transform tools map values or keys, flatten nested keyed values, reindex, and flip. Filters preserve keys. Slice tools take, drop, pull, select keys, and apply while predicates. As with vecs, take, drop, and slice take uint counts and offsets.

merge, diff, and intersect have value and key forms. equal accepts a custom equality callback. Dict sorting can sort by value, selected value, or key while preserving keys.

Lazy iteration

Whim\Iterate holds Iterator, ToIterator, and ArrayIterator. Use it when the caller may stop early or the source is a stream. See Iterators.

Queue, stack, deque, and heap

Whim\DataStructure provides mutable focused containers:

  • Queue<T> adds at the back and removes from the front.
  • Stack<T> adds and removes at the top.
  • Deque<T> adds, removes, and peeks at both ends.
  • BinaryHeap<T> orders values with a comparator.
  • PriorityQueue<T, P> stores a value with a separate priority and comparator.

Empty removals and peeks return Option<T> so a stored null remains distinct from no item. Each type reports count, isEmpty, supports clear, converts to a vec, and implements ToIterator. count() returns a uint. These containers yield uint keys during iteration.

The heap and priority queue do not share an implementation. Equal values or priorities have no set relative order.

Graphs

Whim\Graph provides directed and undirected graphs, traversal, cycle checks, topological sorting, and shortest paths. Graphs store nodes of type TNode and edge weights of type TWeight. They compare nodes with Whim equality; nodes do not need an ordering or a dictionary key type.

Building a graph

Use directed() or undirected() to create an empty graph. add_node() and add_edge() return a graph with the added node or edge. The original graph keeps its nodes and edges. add_edge() adds either endpoint if it is missing:

use Whim\Graph;

$empty = Graph\directed::<string, int>();
$graph = Graph\add_edge::<string, int>($empty, 'A', 'B', 1);
$graph = Graph\add_edge::<string, int>($graph, 'A', 'C', 5);
$graph = Graph\add_edge::<string, int>($graph, 'B', 'C', 1);

assert!(Graph\nodes::<string, int>($empty) == vec[]);
assert!(Graph\nodes::<string, int>($graph) == vec['A', 'B', 'C']);
assert!(Graph\neighbors::<string, int>($graph, 'A') == vec['B', 'C']);
assert!(Graph\has_path::<string, int>($graph, 'A', 'C'));
assert!(!Graph\has_path::<string, int>($graph, 'C', 'A'));
assert!(Graph\bfs::<string, int>($graph, 'A') == vec['A', 'B', 'C']);
assert!(Graph\topological_sort::<string, int>($graph) == vec['A', 'B', 'C']);
assert!(Graph\shortest_path::<string>($graph, 'A', 'C') == vec['A', 'B', 'C']);

DirectedGraph<TNode, TWeight> and UndirectedGraph<TNode, TWeight> implement the sealed Graph<TNode, TWeight> interface. Both classes are readonly. The graph protects its structure; node values and weights may still hold mutable objects.

Both classes provide these methods:

MethodResult
getNodes()All nodes in insertion order.
getEdgesFrom(node)Outgoing Edge<TNode, TWeight> objects in insertion order.
hasNode(node)Whether the node exists.
hasEdge(from, to)Whether an edge connects the nodes.
withNode(node)A graph containing the node.
withEdge(from, edge)A graph containing the edge; both endpoints must already exist.
hasCycle()Whether the graph contains a cycle.

Edge<TNode, TWeight> holds a public readonly to node and a null|TWeight weight. Its weight defaults to null.

You can also pass a node vector and a vec<(TNode, Edge<TNode, TWeight>)> to either graph constructor. A constructor throws Whim\Unwind\InvalidArgumentException if an edge refers to a missing node. An undirected graph adds reverse edges with the same weights.

Traversal and ordering

FunctionResult
nodes(graph)The node vector.
neighbors(graph, node)Direct neighbors in edge order.
has_path(graph, from, to)Whether a path connects two existing nodes.
has_cycle(graph)Whether a cycle exists.
bfs(graph, start)Nodes reached in breadth-first order.
dfs(graph, start)Nodes reached in depth-first order.
topological_sort(directedGraph)A node order in which each edge’s source precedes its destination, or null for a cycle.

Traversals visit each reached node once and follow edges in insertion order. They return an empty vector when the start node is missing. has_path() returns false when either endpoint is missing; an existing node has a path to itself. Topological sorting returns an empty vector for an empty graph.

Shortest paths

shortest_path() accepts a graph with integer weights. shortest_path_by() accepts any weight type and a callback that converts each weight to an integer cost. Both return a nonempty vector from source to destination, or null if an endpoint is missing or the destination is unreachable. An edge with a null weight costs 1; the conversion callback does not run for that edge.

use Whim\Graph;

$graph = Graph\directed::<string, string>();
$graph = Graph\add_edge::<string, string>($graph, 'A', 'B', 'long');
$graph = Graph\add_edge::<string, string>($graph, 'A', 'C', 'x');
$graph = Graph\add_edge::<string, string>($graph, 'C', 'B', 'x');

$path = Graph\shortest_path_by::<string, string>(
  $graph,
  'A',
  'B',
  fn(string $weight): int => length!($weight) as int,
);
assert!($path == vec['A', 'C', 'B']);

The search uses Dijkstra’s algorithm for nonnegative costs and Bellman-Ford when any cost is negative. It throws NegativeCycleException when a negative cycle is reachable from the source and can reach the destination. A cycle elsewhere does not make that path fail. A path cost outside the integer range throws Whim\Unwind\OverflowError.

Trees

Whim\Tree provides typed trees, traversal, search, mapping, filtering, and folds. Each Node<T> holds a value and an ordered vector of children. The sealed interface has two readonly classes: TreeNode<T> and LeafNode<T>.

Building a tree

Use tree() for a value with children and leaf() for a value with no children. Both classes expose getValue() and getChildren():

use Whim\Tree;

$tree = Tree\tree::<string>('root', vec[
  Tree\leaf::<string>('left'),
  Tree\tree::<string>('right', vec[Tree\leaf::<string>('child')]),
]);

assert!(Tree\count::<string>($tree) == 4u);
assert!(Tree\depth::<string>($tree) == 2u);
assert!(Tree\pre_order::<string>($tree) == vec['root', 'left', 'right', 'child']);
assert!(Tree\post_order::<string>($tree) == vec['left', 'child', 'right', 'root']);
assert!(Tree\leaves::<string>($tree) == vec['left', 'child']);
assert!(Tree\path_to::<string>($tree,
  fn(string $value): bool => $value == 'child',
) == vec['root', 'right', 'child']);
assert!(Tree\at_index::<string>($tree, vec[1u, 0u])->unwrap() == 'child');

You can also construct TreeNode<T>(value, children) or LeafNode<T>(value) directly. A TreeNode with no children counts as a leaf. Tree shape stays fixed; a stored value may still hold a mutable object.

FunctionResult
is_leaf(node)Whether the node has no children.
count(node)The number of nodes, including the root.
depth(node)The greatest depth; a root with no children has depth 0u.
leaves(node)Leaf values, in child order.
pre_order(node)Each value before its children’s values.
post_order(node)Each value after its children’s values.
level_order(node)Values in breadth-first order.
all(node, predicate)Whether every value matches.
any(node, predicate)Whether any value matches.
contains(node, value)Whether a value compares equal under Whim equality.
find(node, predicate)The first breadth-first match as Some<T>, or None.
path_to(node, predicate)The value path to the first breadth-first match, or null.
at_index(node, indexPath)The value at a path of unsigned child indices as Some<T>, or None.
to_index(node, predicate)The child-index path to the first breadth-first match, or null.

all() and any() call predicates in pre-order and stop when the answer is known. Value paths include the root. An empty index path refers to the root; to_index() returns an empty vector when the root matches. at_index() accepts an iterable of uint indices, including a vector, tuple, or iterator.

Transforming and folding

FunctionResult
reduce(node, callback, initial)An accumulator built in pre-order; the callback receives (accumulator, value).
map(node, callback)A tree with the same shape and transformed values.
filter(node, predicate)A tree of matching nodes whose ancestors also match, or null if the root fails.
fold(node, callback)A result built from child results; the callback receives (value, vec<childResult>).
traverse(node, callback)A result with lazy child results; the callback receives (value, fn(): vec<childResult>).
to_dict(node)A nested dictionary with value and children entries.

map() and fold() process children before their parent. filter() tests a node before its children; rejecting a node drops its whole subtree. It returns TreeNode<T> objects for all retained nodes, including leaves.

traverse() lets a callback skip children. Calling its child supplier computes and caches that node’s child results. Later calls return the cached results:

use Whim\Math;
use Whim\Tree;

$tree = Tree\tree::<int>(1, vec[Tree\leaf::<int>(2), Tree\leaf::<int>(3)]);
$total = Tree\traverse::<int, int>(
  $tree,
  fn(int $value, fn(): vec<int> $children): int => $value + Math\sum($children()),
);
assert!($total == 6);

$root = Tree\traverse::<int, int>(
  $tree,
  fn(int $value, fn(): vec<int> $_children): int => $value,
);
assert!($root == 1);

Building from flat data

from_iterable<TItem, TId, TValue>() accepts an iterable of items and three callbacks: an item ID, its parent ID as Option<TId>, and its stored value. TId must satisfy Whim\Refine\ArrayKey. None marks the root:

use Whim\Option\None;
use Whim\Option\Option;
use Whim\Option\Some;
use Whim\Tree;

type Row = (string, null|string);

$tree = Tree\from_iterable::<Row, string, string>(
  vec[('child', 'root'), ('root', null)],
  fn(Row $row): string => $row[0],
  fn(Row $row): Option<string> {
    if ($row[1] == null) {
      return new None();
    }
    return new Some::<string>($row[1]);
  },
  fn(Row $row): string => $row[0],
);
assert!(Tree\pre_order::<string>($tree) == vec['root', 'child']);

Items may appear before their parents. Children follow input order. The builder reads the iterable once, then checks the IDs and parent paths before it builds the tree. Use stable callbacks: the builder may call them more than once.

All input errors extend Whim\Unwind\InvalidArgumentException:

ErrorCause
NoRootNodeExceptionNo item has None as its parent, including empty input.
MultipleRootNodesExceptionMore than one item has None as its parent.
OrphanedNodeExceptionAn ID repeats, a parent is missing, or a parent chain forms a cycle.

Variation

Whim\Variation holds values that depend on named choices. A fresh identity names a dimension. Every choice with that identity follows the same selection: false takes its first branch; true takes its second.

use Whim\Variation;

$dimension = fresh!();
$x = Variation\choose::<int>($dimension, 10, 20);
$y = Variation\choose::<int>($dimension, 1, 2);
$sum = Variation\combine::<int, int, int>(
  $x,
  $y,
  fn(int $a, int $b): int => $a + $b,
);

assert!($sum->select(dict[$dimension => false]) == 11);
assert!($sum->select(dict[$dimension => true]) == 22);

The sum has two outcomes, 11 and 22. It cannot mix the first branch of $x with the second branch of $y. To make two choices independent, give them different fresh identities.

Building choices

The sealed interface Variation<T> has two forms:

  • Value<T> holds a plain value in its readonly value property.
  • Choice<T> holds a readonly dimension, whenFalse, and whenTrue. Both branches are Variation<T> values, so choices can nest and share branches.

value::<T>($value) creates a Value<T>. choose::<T>($dimension, $whenFalse, $whenTrue) creates a Choice<T> from two plain values. Use the Choice<T> constructor to join existing trees:

use Whim\Variation;
use Whim\Variation\Choice;

$size = fresh!();
$colour = fresh!();
$small = Variation\choose::<string>($colour, 'small red', 'small blue');
$large = Variation\choose::<string>($colour, 'large red', 'large blue');
$item = new Choice::<string>($size, $small, $large);

assert!($item->select(dict[$size => true, $colour => false]) == 'large red');

A repeated dimension stays linked even if it appears at different depths or in a different order in another tree. Choices do not impose an order on fresh values.

Selecting and specializing

select(dict<fresh, bool> $selection): T follows one path and returns its value. It throws Unwind\InvalidArgumentException if a dimension on that path has no selection. Extra selections do no harm. A dimension that occurs only on a branch the method does not visit needs no selection.

specialize($selection): Variation<T> resolves the supplied dimensions and keeps the remaining choices. It returns a new tree when needed and shares unchanged branches with the source. Supplying every dimension produces a Value<T>:

use Whim\Variation;

$dimension = fresh!();
$number = Variation\choose::<int>($dimension, 10, 20);
$chosen = $number->specialize(dict[$dimension => true]);

assert!($chosen is Variation\Value<int>);
assert!($chosen->select(dict[]) == 20);
assert!($number->select(dict[$dimension => false]) == 10);

Mapping and combining

map::<U>(fn(T): U $function): Variation<U> transforms each reachable leaf. combine::<A, B, C>($left, $right, fn(A, B): C $function): Variation<C> transforms each reachable pair. Both call the function during the operation. Later calls to select() do not run it again. A thrown error stops the operation and reaches the caller.

Callbacks should be free of side effects: the operation may call them for several alternatives, including repeated values. Independent dimensions can make the number of pairs grow quickly. These operations keep a tree of choices; they do not build a list of every selection.

The tree is readonly. A value stored inside it can still be a mutable object; the library does not clone or freeze that object. Arithmetic, branching, and other operations use explicit callbacks rather than new language operators.

See Logic Variables for shared values that become bound later, and Built-in Values for fresh identities.

Logic Variables

Whim\Logic\Variable<T> is a shared variable that starts unbound. Binding it gives every reader a value. Linking two variables means that binding either also binds the other:

use Whim\Logic;

$x = new Logic\Variable::<int>();
$y = new Logic\Variable::<int>();
Logic\unify::<int>($x, $y);
$y->bind(42);

assert!($x->get() == 42);
assert!($y->get() == 42);

Assigning a variable object to another local keeps the same shared state. Variable<T> is mutable; it is not a copy value.

Binding and linking

bind(T $value): void sets the binding. Binding it again succeeds only if the new value compares equal with ==; otherwise it throws ConflictException. That exception extends Unwind\LogicException.

Equality follows Whim’s usual rules. Objects compare by identity. NaN does not equal itself, so binding an already bound NaN again throws. A binding does not freeze a mutable object stored as its value.

unify::<T>($left, $right): void links two variables. The instance method $left->unify($right) does the same work. Both variables must have the same value type T.

  • Two unbound variables share their next binding.
  • Linking an unbound variable to a bound one gives it that binding at once.
  • Two bound variables can link when their values compare equal. They then share one of those values; the library does not promise which copy it keeps.
  • Unequal bindings throw ConflictException and keep their separate bindings.
  • Linking a variable to itself, or linking already linked variables, has no further effect.

bind() and unify() do not suspend. A later binding or link wakes readers already waiting on either variable. Chains and repeated links cannot form a cycle.

isBound(): bool reports whether a variable has a binding. A bound null is distinct from an unbound variable:

use Whim\Logic\Variable;

$value = new Variable::<null>();
assert!(!$value->isBound());
$value->bind(null);
assert!($value->isBound());
assert!($value->get() == null);

Waiting and cancellation

get(null|Async\CancellationToken $cancellation = null): T returns a bound value at once, or suspends the current task until a binding arrives:

use Whim\Async;
use Whim\Logic\Variable;

$value = new Variable::<string>();
$reader = Async\spawn::<string>(fn(): string => $value->get());
Async\later();
$value->bind('ready');

assert!($reader->await() == 'ready');

Cancellation stops only that read. It leaves the variable, its binding, and other readers intact. A token that has already cancelled makes get() throw even if the variable is bound. An unbound read with no future binding waits until cancellation or task shutdown.

This library links whole variables and compares bound values. It does not unify fields inside data structures, undo bindings, or search through possible answers. Reads remain explicit calls to get().

See Channels and Cancellation for cancellation tokens and Variation for values with explicit alternatives.

Time and Calendars

Whim\Time measures exact spans and clock readings. Whim\DateTime handles calendar dates, civil times, timezones, parsing, and formatting.

Duration

Time\Duration stores normalized seconds and nanoseconds. It can represent a positive, zero, or negative span.

Factory methods create spans from weeks, days, hours, minutes, seconds, milliseconds, microseconds, or nanoseconds. zero, second, millisecond, microsecond, nanosecond, and max return common values.

plus, minus, and invert return new durations. Total methods convert to minutes through nanoseconds. Float totals may lose precision; total nanoseconds returns an integer.

Monotonic and wall clocks

Time\Instant::now() reads a monotonic clock. Use it for elapsed work because wall-clock corrections do not move it backward. elapsed, durationSince, plus, and minus work with durations.

Time\SystemTime is a wall-clock reading. It supports now, unixEpoch, Unix timestamps, comparison, and duration arithmetic. Use it for dates, file times, protocol times, and stored event times.

Do not turn an Instant into a calendar date. Do not measure a timeout with SystemTime.

Use Clocks and Sleepers when code needs a clock or sleeper that tests can control. Whim\Clock provides system, frozen, and offset clocks, plus system and virtual sleepers.

Use Cron Expressions to find scheduled times in a chosen timezone.

Date, Time, and DateTime

Date stores a year, month, and day. Time stores hour, minute, second, and nanosecond. DateTime combines them without a timezone.

Each type has a checked fromParts, a nullable parse, a throwing from, formatting, comparison, and a standard toString form. fromPartsUnsafe exists for trusted parsed data and does not replace input checks in application code.

use Whim\DateTime\Date;

$date = Date::from('2026-08-21');
assert!($date->toString() == '2026-08-21');
assert!($date->isLeapYear() == false);

Month, Weekday, Era, and Meridiem name calendar values. Refined aliases bound years, days, hours, minutes, seconds, nanoseconds, and UTC offsets.

Periods and durations

DateTime\Period stores calendar years, months, weeks, and days. Adding one month follows month length and an Overflow rule. Adding a Time\Duration adds exact elapsed time.

These differ around short months and daylight-saving changes. Use a period for “next month” or “tomorrow.” Use a duration for “after 3,600 seconds.”

TimeZone

TimeZone::from($id) loads an IANA name, fixed offset, or other accepted zone. utc() and system() return common zones. A zone can report its offset and abbreviation at a SystemTime, plus its prior and next transition.

Resolving a local DateTime may find no instant or two instants during a clock change. Disambiguation selects compatible, earlier, later, or reject behavior.

ZonedDateTime

ZonedDateTime joins one exact instant with a timezone and its local calendar fields. It can start from SystemTime, DateTime, current time, RFC 2822 text, or the standard parser.

withTimeZone keeps the instant and changes its displayed local fields. Duration arithmetic follows elapsed time. Period arithmetic follows the local calendar and resolves the result in the zone.

Formatting

DateTime\Formatter::fromPattern() builds a checked pattern. Ready formatters cover ISO date, ISO time, ISO date-time, RFC 3339, RFC 2822, Temporal text, and HTTP dates.

The formatter has separate methods for Date, Time, DateTime, and ZonedDateTime. Parsing methods return null on invalid text; from methods throw a domain error.

Clocks and Sleepers

Whim\Clock lets code read time and sleep through interfaces. Use the system implementations for real time, or a controlled clock and sleeper in tests. The types are part of the standard library; they need no package or autoloader.

Reading time

Clock::now() returns a Whim\Time\SystemTime. Clock::nowIn($timezone) reads the clock once and returns a Whim\DateTime\ZonedDateTime in that timezone.

  • SystemClock reads SystemTime::now() on each call.
  • FrozenClock holds a time until advance($duration) or travelTo($time) changes it. A negative duration moves it backward.
  • OffsetClock adds a fixed duration to another clock’s current reading. It follows changes to the inner clock.

Pass a Clock to code that needs the current time:

use Whim\Clock\Clock;
use Whim\Clock\FrozenClock;
use Whim\Time\Duration;
use Whim\Time\SystemTime;

final readonly class Expiry {
  public function __construct(private Clock $clock) {}

  public function after(Duration $duration): SystemTime {
    return $this->clock->now()->plus($duration);
  }
}

$clock = FrozenClock::atUnixTimestamp(1_000);
$expiry = new Expiry($clock);
assert!($expiry->after(Duration::fromMinutes(30))->getSeconds() == 2_800);
$clock->advance(Duration::fromMinutes(1));
assert!($expiry->after(Duration::fromMinutes(30))->getSeconds() == 2_860);

new FrozenClock($time) accepts an existing SystemTime. FrozenClock::atUnixTimestamp($seconds, $nanoseconds = 0) accepts a Unix timestamp; nanoseconds must be in 0..999_999_999. FrozenClock::startingNow() freezes the time when the call runs. Copies of the clock share its state. A saved reading stays unchanged when the clock moves.

use Whim\Clock\FrozenClock;
use Whim\Clock\OffsetClock;
use Whim\DateTime\TimeZone;
use Whim\Time\Duration;

$base = FrozenClock::atUnixTimestamp(0);
$ahead = new OffsetClock($base, Duration::fromHours(1));
assert!($ahead->now()->getSeconds() == 3_600);
assert!(1 == $ahead->nowIn(TimeZone::utc())->dateTime->time->hour);
$base->advance(Duration::second());
assert!($ahead->now()->getSeconds() == 3_601);

SystemClock also implements Whim\Default\Default. SystemClock::default() returns a new system clock.

Sleeping

Sleeper::sleep($duration, $cancellation = null) pauses the current task. Both implementations accept a Whim\Async\CancellationToken and throw Whim\Async\CancelledException when cancellation is requested.

  • SystemSleeper calls Whim\Async\sleep(). Other tasks can run while it waits. It also implements Whim\Default\Default; SystemSleeper::default() returns a new system sleeper.
  • VirtualSleeper advances a linked FrozenClock for a positive duration, then yields once with Whim\Async\later(). It adds no real delay.

Zero and negative durations add no delay and do not move the frozen clock. Every virtual sleep still yields once, including those durations.

use Whim\Clock\FrozenClock;
use Whim\Clock\VirtualSleeper;
use Whim\Time\Duration;

$clock = FrozenClock::atUnixTimestamp(0);
$sleeper = new VirtualSleeper($clock);
$sleeper->sleep(Duration::fromDays(1));
assert!($clock->now()->getSeconds() == 86_400);
$sleeper->sleep(Duration::fromSeconds(-1));
assert!($clock->now()->getSeconds() == 86_400);

VirtualSleeper checks cancellation before advancing and after yielding. Cancellation before the call changes no time. Cancellation during the yield leaves any time already added in place.

Concurrent virtual sleeps each add their duration to the same clock. Two sleeps of two and three seconds advance it by five seconds. This helper does not simulate timer deadlines or advance the scheduler’s clock.

See Time and Calendars for time values and timezone operations, and Tasks and Futures for scheduling and cancellation.

Retry Policies, Circuit Breakers, and Rate Limits use these clocks and sleepers to control time in tests.

Cron Expressions

Whim\Cron\Expression parses five-field cron expressions, checks whether a time matches, and finds the next or previous occurrence in a chosen timezone. It computes times; your application decides how to wait for them and run work.

Parsing and matching

Expression::parse() returns an expression or null for invalid syntax. Expression::from() throws Whim\Unwind\InvalidArgumentException on invalid syntax. Expressions are readonly and implement Whim\Convert\ToString. toString() returns the input with outer whitespace removed; it keeps macros, letter case, and spacing between fields.

use Whim\Cron\Expression;
use Whim\DateTime\TimeZone;
use Whim\DateTime\ZonedDateTime;

$schedule = Expression::from('*/15 9-17 * * MON-FRI');
$zone = TimeZone::utc();
$time = ZonedDateTime::from('2026-08-21T10:15:30+00:00[UTC]')->systemTime;

assert!($schedule->matches($time, $zone));
assert!($schedule->toString() == '*/15 9-17 * * MON-FRI');
assert!(Expression::parse('60 * * * *') == null);

$next = $schedule->next($time, $zone);
assert!($zone->at($next)->dateTime->toString() == '2026-08-21T10:30:00');
$previous = $schedule->previous($time, $zone);
assert!($zone->at($previous)->dateTime->toString() == '2026-08-21T10:15:00');
MethodResult
parse(expression)An Expression, or null for invalid syntax.
from(expression)An Expression, or an InvalidArgumentException.
matches(time, zone)Whether the minute containing the SystemTime matches.
next(after, zone)The first occurrence strictly after the supplied time.
previous(before, zone)The last occurrence strictly before the supplied time.
toString()The expression as written, with outer whitespace removed.

Occurrences fall on minute boundaries. next() skips an occurrence exactly at the supplied time. previous() also skips an exact occurrence, but can return the start of the same minute when the supplied time includes seconds or nanoseconds.

Fields and syntax

The fields are minute, hour, day of month, month, and day of week:

FieldValues
Minute0–59
Hour0–23
Day of month1–31
Month1–12, or JAN–DEC
Day of week0–7, or SUN–SAT; both 0 and 7 mean Sunday.

Names ignore case. Separate fields with spaces or tabs. Each field accepts:

  • * for every value.
  • Lists such as 1,15,30.
  • Inclusive ranges such as 9-17 or MON-FRI.
  • Positive steps such as */15, 1-30/7, or 5/17. A single value with a step runs from that value to the field’s maximum; 5/17 selects minutes 5, 22, 39, and 56.

A step applies within its field. It does not measure elapsed time across hours, days, or timezone changes. The parser rejects descending ranges, zero or negative steps, unknown names, and values outside the field’s range.

Day matching

When both day fields are restricted, a date matches either field. For example, 0 0 13 * FRI runs at midnight on every Friday and every thirteenth day of the month.

A day field is unrestricted only when it is exactly *. A stepped field such as */2 counts as restricted. When one day field is unrestricted, the other field alone selects the days. When both are unrestricted, every day matches. The minute, hour, and month fields must always match.

Macros

Macros ignore case and expand to these five-field forms:

MacroExpression
@yearly, @annually0 0 1 1 *
@monthly0 0 1 * *
@weekly0 0 * * 0
@daily, @midnight0 0 * * *
@hourly0 * * * *

The parser accepts five fields and these macros. It does not accept a seconds field, @reboot, ?, L, W, or #.

Timezones and clock changes

Every query takes a Whim\DateTime\TimeZone. The same expression can serve more than one zone; it does not store a zone or use the system zone by default.

When clocks move backward, a local minute can occur twice. Both instants participate in matching and in next or previous searches:

use Whim\Cron\Expression;
use Whim\DateTime\TimeZone;
use Whim\DateTime\ZonedDateTime;

$schedule = Expression::from('30 2 * * *');
$paris = TimeZone::from('Europe/Paris');
$first = ZonedDateTime::from('2026-10-25T00:30:00+00:00[UTC]')->systemTime;
$second = ZonedDateTime::from('2026-10-25T01:30:00+00:00[UTC]')->systemTime;

assert!($schedule->matches($first, $paris));
assert!($schedule->matches($second, $paris));
assert!($schedule->next($first, $paris)->equals($second));
assert!($schedule->previous($second, $paris)->equals($first));

When clocks move forward, a missing local time shifts forward by the size of the gap. In Paris, a scheduled 02:30 becomes 03:30 on the spring change. That shifted occurrence also matches matches(). This rule handles clock changes of other sizes, including half an hour.

Expressions that cannot occur

Valid syntax does not guarantee a calendar occurrence. For example, 0 0 30 2 * asks for February 30. next() searches through the calendar year five years after the supplied time. previous() searches back through the year five years before it. Both throw Whim\Cron\UnmatchableExpressionException when they find no occurrence within that limit.

The search also follows the range supported by Time and Calendars.

Retry Policies

Whim\Retry runs a callable again after a failure. A Policy sets the maximum attempt count, delays, jitter, error condition, and optional deadline.

use Whim\Reference\Strong;
use Whim\Retry;
use Whim\Retry\Policy;
use Whim\Unwind\RuntimeException;

$calls = new Strong::<int>(0);
$result = Retry\retry::<string>(Policy::attempts(3u), fn(): string {
  $calls->value++;
  if ($calls->value < 3) {
    throw new RuntimeException('try again');
  }

  return 'done';
});

assert!($result == 'done');
assert!($calls->value == 3);

The count includes the first attempt. Policy::attempts(1u) runs once. By default, a policy has no delay and retries only Whim\Unwind\Exception and its subclasses. It does not retry an Error. When retries stop, the retrier throws the last throwable unchanged.

Delays and conditions

Policy methods return copies:

MethodEffect
withDelay(Delay)Uses NoDelay, FixedDelay, or ExponentialDelay.
withFixedDelay(Duration)Uses the same delay after each failed attempt.
withExponentialDelay(initial, multiplier = 2u, maximum = null)Multiplies the delay after each failed attempt, with an optional cap.
withJitter(Jitter)Uses the exact delay (None) or a random delay from zero to that delay (Full).
withCondition(fn(Throwable): bool)Replaces the rule that decides which errors to retry.
withDeadline(Duration)Stops after a failure if elapsed time exceeds the deadline or the next delay would pass it.

Attempt counts must be positive. Delays and deadlines may be zero but cannot be negative. An exponential multiplier must be at least two. An uncapped delay that grows beyond Duration’s range throws OverflowError.

The deadline does not interrupt an operation already running. Use an operation’s own timeout or cancellation support to bound that call. A cancellation token passed to retry or Retrier::run cancels retry pauses; the operation must handle its own cancellation.

Controlled time

Retrier accepts a Whim\Clock\Sleeper, a Whim\Clock\Clock, and a Whim\RandomSequence\Sequence. It uses SystemSleeper, SystemClock, and a securely seeded sequence by default.

A frozen clock and virtual sleeper let tests check delays without waiting:

use Whim\Clock\FrozenClock;
use Whim\Clock\VirtualSleeper;
use Whim\RandomSequence\MersenneTwisterSequence;
use Whim\Reference\Strong;
use Whim\Retry\Jitter;
use Whim\Retry\Policy;
use Whim\Retry\Retrier;
use Whim\Time\Duration;
use Whim\Unwind\RuntimeException;

$clock = FrozenClock::atUnixTimestamp(0);
$retrier = new Retrier(
  new VirtualSleeper($clock),
  $clock,
  new MersenneTwisterSequence(7u),
);
$policy = Policy::attempts(4u)
  ->withExponentialDelay(Duration::second())
  ->withJitter(Jitter::None)
  ->withDeadline(Duration::fromSeconds(10));

$calls = new Strong::<int>(0);
$result = $retrier->run::<int>($policy, fn(): int {
  $calls->value++;
  if ($calls->value < 4) {
    throw new RuntimeException('try again');
  }

  return 42;
});

assert!($result == 42);
assert!($clock->now()->getSeconds() == 7);

See Clocks and Sleepers, Circuit Breakers, and Rate Limits.

Circuit Breakers

Whim\Fuse\Fuse counts failures and blocks requests for a recovery period once they reach a threshold. The caller checks allow(), runs the operation, and reports its outcome with recordSuccess() or recordFailure().

StateBehaviour
State::ClosedAllows requests. A success clears the consecutive failure count; the failure threshold opens the fuse.
State::OpenBlocks requests until the recovery period ends.
State::HalfOpenAllows requests again. The success threshold closes the fuse; any failure reopens it.

Defaults are five failures, thirty seconds of recovery, and one success to close. Failure and success thresholds must be positive; recovery may be zero but cannot be negative.

use Whim\Clock\FrozenClock;
use Whim\Fuse\Fuse;
use Whim\Fuse\State;
use Whim\Time\Duration;

$clock = FrozenClock::atUnixTimestamp(0);
$fuse = new Fuse(
  failureThreshold: 2u,
  recovery: Duration::fromSeconds(30),
  successThreshold: 2u,
  clock: $clock,
);

assert!($fuse->allow());
$fuse->recordFailure();
$fuse->recordFailure();
assert!(!$fuse->allow());
assert!(($fuse->retryAfter() as Duration)->equals(Duration::fromSeconds(30)));

$clock->advance(Duration::fromSeconds(30));
assert!($fuse->getState() == State::HalfOpen);
$fuse->recordSuccess();
$fuse->recordSuccess();
assert!($fuse->getState() == State::Closed);

getState() and allow() check whether recovery has ended. retryAfter() returns the remaining delay while open, or null when requests may proceed. reset() closes the fuse and clears all counts.

A fuse uses Whim\Clock\SystemClock by default. Pass any Whim\Clock\Clock to control time. Moving the clock backward extends the remaining recovery period.

Share one fuse among callers that should share a failure count. Its state lives in memory. It does not run operations, impose timeouts, or limit the number of requests in HalfOpen. The caller controls those choices and must report each outcome.

See Clocks and Sleepers, Retry Policies, and Rate Limits.

Rate Limits

Whim\Throttle provides two limiters. Each key has its own state and starts with a full allowance. Keys must be nonempty strings.

LimiterBehaviour
TokenBucketAdds one permit per rate interval, up to a burst limit.
FixedWindowGrants a fixed permit count per window, starting at a key’s first hit or peek.

Both implement Limiter:

MethodEffect
hit(key)Takes one permit if available and returns a Decision.
peek(key)Returns the current decision without taking a permit.
allow(key)Takes a permit if available and returns a boolean.
wait(key, cancellation = null)Sleeps until a permit is available, then takes it.
forget(key)Removes the stored state for one key.

Decision has two cases: Allowed holds the remaining permit count; Denied holds a retryAfter duration. isAllowed() reports which case it is.

Token buckets

Rate::of(amount, per) sets a permit count per duration. perSecond, perMinute, and perHour are shorter forms. The count and duration must be positive. interval() divides the duration by the count, rounding down with a minimum of one nanosecond.

The default burst limit equals the rate’s permit count. Pass a separate burst limit to change how many permits a key can store:

use Whim\Clock\FrozenClock;
use Whim\Throttle\Allowed;
use Whim\Throttle\Denied;
use Whim\Throttle\Rate;
use Whim\Throttle\TokenBucket;
use Whim\Time\Duration;

$clock = FrozenClock::atUnixTimestamp(0);
$limiter = new TokenBucket(Rate::perSecond(1u), burst: 2u, clock: $clock);
assert!(($limiter->peek('peer') as Allowed)->remaining == 2u);
assert!($limiter->allow('peer'));
assert!($limiter->allow('peer'));

$decision = $limiter->hit('peer');
$wait = match ($decision) {
  Allowed => Duration::zero(),
  $denied @ Denied => $denied->retryAfter,
};
assert!($wait->equals(Duration::second()));
assert!($limiter->allow('other'));

$clock->advance(Duration::second());
assert!($limiter->allow('peer'));
assert!(!$limiter->allow('peer'));

Fixed windows and waiting

FixedWindow accepts a positive limit and window duration. A new window restores the full allowance. Requests just before and after a window boundary can use up to twice the limit.

Both limiters use Whim\Clock\SystemClock and SystemSleeper by default. Pass a frozen clock and virtual sleeper to test waits without real delays:

use Whim\Clock\FrozenClock;
use Whim\Clock\VirtualSleeper;
use Whim\Throttle\FixedWindow;
use Whim\Time\Duration;

$clock = FrozenClock::atUnixTimestamp(0);
$limiter = new FixedWindow(
  1u,
  Duration::fromSeconds(5),
  $clock,
  new VirtualSleeper($clock),
);

assert!($limiter->allow('worker'));
$limiter->wait('worker');
assert!($clock->now()->getSeconds() == 5);
assert!(!$limiter->allow('worker'));

The cancellation token cancels a wait’s sleep. When a permit is already available, wait takes it without sleeping.

Limiter state lives in memory. Share an instance among callers that should share limits, and call forget when a key no longer needs tracking. Moving the clock backward resets a fixed window; a token bucket keeps its permit count and starts counting elapsed time from the new time.

See Clocks and Sleepers, Retry Policies, and Circuit Breakers.

State Machines

Whim\State provides typed finite state machines. Machine<S, E> holds one current state of type S and accepts events of type E. These may be enums, strings, or other Whim types; the machine compares states and events with Whim equality.

Declaring transitions

Use MachineBuilder<S, E> to set the initial state and declare allowed moves:

use Whim\State\MachineBuilder;

enum OrderState {
  case Draft;
  case Placed;
  case Shipped;
}

enum OrderEvent {
  case Place;
  case Ship;
}

$builder = new MachineBuilder::<OrderState, OrderEvent>(OrderState::Draft);
$builder->allow(OrderState::Draft, OrderEvent::Place, OrderState::Placed);
$builder->allow(OrderState::Placed, OrderEvent::Ship, OrderState::Shipped);

$order = $builder->build();
assert!($order->getState() == OrderState::Draft);
assert!($order->can(OrderEvent::Place));
assert!($order->peek(OrderEvent::Place) == OrderState::Placed);
assert!($order->getState() == OrderState::Draft);
assert!($order->apply(OrderEvent::Place) == OrderState::Placed);
assert!($order->apply(OrderEvent::Ship) == OrderState::Shipped);
assert!(!$order->can(OrderEvent::Place));

$another = $builder->build();
assert!($another->getState() == OrderState::Draft);
MethodEffect
getState()Returns the current state.
peek(event)Returns the first allowed target, or null when none matches.
can(event)Reports whether a transition accepts the event.
apply(event)Changes state, calls observers, and returns the target. Throws TransitionException when no transition accepts the event.

Each build() creates a new machine with the builder’s current transition and observer lists. Later additions to the builder do not change machines already built. Guard and observer objects can still be shared between machines.

States may include null. In that case, peek() returning null can mean either a valid null target or no transition. Use can() to tell them apart.

Guards and observers

Guard<S, E>::allows(from, event, to) decides whether a matching transition may run. FunctionGuard wraps a fn(S, E, S): bool.

The machine checks transitions in declaration order. When a guard returns false, it tries the next transition for the same state and event. The first accepted transition wins. A transition without a guard always accepts the matching state and event.

Observer<S, E>::onTransition(from, event, to) receives a completed move. FunctionObserver wraps a fn(S, E, S): void. Observers run in registration order after the machine sets its new state:

use Whim\Reference\Strong;
use Whim\State\FunctionGuard;
use Whim\State\FunctionObserver;
use Whim\State\MachineBuilder;

$ready = new Strong::<bool>(false);
$moves = new Strong::<vec<(string, string, string)>>(vec[]);
$builder = new MachineBuilder::<string, string>('queued');
$builder->allow('queued', 'start', 'running',
  new FunctionGuard::<string, string>(
    fn(string $_from, string $_event, string $_to): bool => $ready->value,
  ),
);
$builder->observe(new FunctionObserver::<string, string>(
  fn(string $from, string $event, string $to): void {
    $moves->value[] = ($from, $event, $to);
  },
));

$machine = $builder->build();
assert!(!$machine->can('start'));
$ready->value = true;
assert!($machine->apply('start') == 'running');
assert!($moves->value == vec[('queued', 'start', 'running')]);

peek() and can() run guards but do not change state or call observers. A guard error passes to the caller before any state change. An observer error passes to the caller after the state change and stops the remaining observers; it does not undo the move.

Callbacks may suspend. Coordinate callers that share a machine when callbacks can yield; the machine does not lock transitions.

You can also construct Machine<S, E> directly with an initial state, a vec<Transition<S, E>>, and a vec<Observer<S, E>>. Each Transition holds public from, event, to, and guard fields.

Encoding and Data Formats

Text and byte encodings

Whim\Encoding groups encodings by format:

  • Base32 supports its standard alphabets and optional padding.
  • Base64 supports standard and URL-safe alphabets and optional padding.
  • Hex encodes and decodes hexadecimal text.
  • URI percent-encodes full URI references without changing their syntax.
  • Url handles component and form encoding.
  • Punycode converts international labels.
  • QuotedPrintable handles text and binary modes plus strict or forgiving input.
  • EncodedWord handles encoded words used in mail headers.
  • UTF8\lossy replaces invalid UTF-8 with the replacement character.

Decode functions throw DecodingException on malformed input. Encode functions throw EncodingException when the format cannot hold a value.

These functions are not interchangeable. URI\encode preserves URI delimiters and valid percent escapes. URI\decode leaves escaped delimiters encoded, so decoding cannot turn data into URI syntax. URL form encoding maps spaces and plus signs by form rules; component encoding does not. Base64 URL-safe text uses a different alphabet from normal Base64.

JSON

Json\Value is:

null|bool|int|uint|float|string|vec<Value>|dict<string, Value>

Json\encode($value, $pretty) accepts that union or an object implementing ToJson. decode($text) returns Json\Value. decode_as::<T>() calls T::fromJson() for a type that implements FromJson.

use Whim\Json;

$encoded = Json\encode(dict['ready' => true, 'count' => 3]);
$decoded = Json\decode($encoded);
assert!($decoded is dict<string, Json\Value>);

JSON objects have string keys. The encoder rejects non-finite floats. Bad text throws DecodingException; an unsupported value throws EncodingException.

Unsigned encoding writes exact decimal digits. Integer tokens decode as int when they fit, then uint when they fit the unsigned range; integers outside both ranges throw DecodingException. Decimal points and exponents select float. -0 decodes as signed zero. Oversized integer tokens do not fall back to a rounded float.

JSON has no unsigned marker. Encoding 42u and decoding it returns signed 42. The unsigned maximum can round-trip with its kind intact because it exceeds the signed range. Unsigned dict keys remain invalid JSON object keys.

CSV

CSV\Reader is an iterator of vec<string> records over an IO\ReadHandle. Its constructor sets the delimiter, enclosure, and escape byte. It reads as the caller asks for rows.

CSV\Writer writes records to an IO\WriteHandle. writeAll accepts an iterable of records. The CSV\read and CSV\write functions are short constructors.

Malformed quoting throws MalformedCSVException. CSV has no built-in schema; all fields are strings.

BSON

BSON\encode writes a BSON\Document to one byte string. BSON\decode reads one complete document. ToBson and FromBson map application values to and from documents.

The value union includes arrays and documents, 32-bit integers, binary data, object IDs, wall-clock times, regular expressions, timestamps, Decimal128, JavaScript, symbols, database pointers, and BSON marker values. ObjectId can parse, generate, compare, and render identifiers. Binary::fromUUID and toUUID convert UUID binary values.

BSON\Reader reads consecutive documents from an IO\ReadHandle. BSON\Writer writes them to an IO\WriteHandle. The reader rejects documents over 16 MiB by default; its constructor can set a lower or higher limit.

Bad bytes throw DecodingException. Values that BSON cannot represent throw EncodingException.

Compression

Compression\Codec creates a Compressor and Decompressor. Whim supplies:

  • Gzip
  • Deflate
  • Brotli
  • Zstandard

A transformer accepts chunks through push($bytes) and returns output now ready. finish() returns the last bytes. A compressor also has flush(). Finish a transformer once and do not push more input after it.

TransformReadHandle applies a transformer while reading another handle. This keeps the codec independent from files, sockets, and HTTP.

Registry maps lowercase content-coding names to codecs. It rejects duplicate names and provides contains, get, and codings. HTTP compression middleware uses the same registry.

HTML

HTML\escape_text escapes text-node content. escape_attribute applies the stricter attribute rules. decode and decode_attribute apply WHATWG character-reference rules for their matching contexts. entity expands one case-sensitive named reference written without & or ;, or returns null.

Escaping text does not make it safe as an unquoted attribute, URL, script, style, or HTTP header. Escape for the output context.

Regular expressions

Regex\Pattern::compile($source) compiles a byte regular expression or throws InvalidPatternException.

The pattern can test matches, find one MatchResult, replace all literal matches, or split a string. Regex\escape($literal) quotes bytes for a pattern.

MatchResult exposes its byte start, end, full value, byte length, and numbered or named captures. An absent or unmatched capture returns null.

Match offsets and lengths use uint. matches and find accept a uint offset, defaulting to 0u; an offset beyond the subject returns no match. replaceAll takes a null|uint limit. capture accepts a uint index or a name, while the public captures dictionary keeps its signed numeric keys. An out-of-range capture index returns null.

Patterns work on bytes. Validate or repair UTF-8 first when an application needs Unicode text rules.

MIME values

MIME\MediaType parses a type, subtype, and parameters. essence() omits the parameters. Parameters and Headers are readonly ordered collections with case-insensitive names and original values.

ContentDisposition parses inline, attachment, names, and filenames. filename() returns a safe last path component; unsafeFilename() returns the raw declared value. ContentId parses and creates content IDs.

MIME\Sniff\from_string() detects a media type from a byte prefix. from_handle() samples a read handle without changing the caller’s logical content stream.

MIME parts and multipart data

A Part exposes mediaType(), headers(), and a streaming body() handle. Text, Data, and RawPart create common parts. Data transfer encoding runs as chunks rather than copying a whole attachment.

MultiPart owns a boundary and an ordered list of parts. Its body streams each boundary, header block, and part body. MultiPart\Parser reads a multipart body from a handle and spools large parts from memory to a temporary file.

Set the parser’s size, header, part, and spool limits for untrusted HTTP input. Bad boundaries or fields throw MultiPartException.

Files and I/O

Whim\IO uses small capability interfaces. A function asks only for the work it needs.

Handle contracts

  • Handle marks an I/O value.
  • ReadHandle reads bytes and waits for readable data.
  • WriteHandle writes bytes and waits for writable space.
  • BufferedReadHandle adds byte, line, and delimiter reads.
  • BufferedWriteHandle adds flush.
  • SeekHandle moves a cursor.
  • CloseHandle reports and closes owned state.
  • FileDescriptorHandle exposes an owned OS\FileDescriptor.

Write operations return a uint byte count. Seeking accepts a signed offset and returns a uint position. IO\copy() and IO\copy_chunked() also return the number of bytes copied as uint.

CloseHandle::__destruct() attempts a final close and discards its error. Use using when close time or errors matter.

Reading

tryRead($maxBytes) never waits. It returns bytes now ready and may return an empty string. waitUntilReadable($cancellation) suspends until a read may make progress. reachedEndOfDataSource() tells an empty read from the end.

read combines readiness and non-blocking reads. readAll reads through the end with an optional byte limit. readFixedSize requires an exact count.

Reader adds buffering around any ReadHandle. It can read one byte, one line, through a suffix, or through a suffix with a bound. Its private buffer preserves bytes read past a delimiter.

Writing

tryWrite($bytes) writes without waiting and returns the count. write waits as needed and may write part of the input. writeAll continues until it sends all bytes.

IO\copy moves all bytes from a read handle to a write handle. copy_chunked lets the caller choose a chunk size and limit. pipe runs a read-to-write copy as a task.

In-memory and adapted handles

MemoryHandle is readable, writable, seekable, closeable, and convertible to a string. Reads and writes share one cursor.

Other adapters include:

  • BoundedReadHandle fails after a read limit.
  • FixedLengthReadHandle exposes an exact length.
  • TruncatedReadHandle stops after a maximum length.
  • ConcatReadHandle reads several sources in order.
  • JoinedReadWriteHandle joins separate read and write sides.
  • TeeWriteHandle copies writes to several targets.
  • sink handles discard writes or expose end-of-input reads.
  • SpoolHandle keeps small data in memory and moves larger data to a temporary file while preserving one seekable handle. Its memory threshold takes uint.

Adapters do not claim an operating-system descriptor unless their own contract implements FileDescriptorHandle.

Standard handles

IO\input_handle(), write_handle(), and error_handle() return the process standard input, output, and error handles. They are descriptor-backed. The language write constructs use the same output channels.

Files

File\open_read_only, open_write_only, and open_read_write return typed file handles. WriteMode selects open-or-create, truncate, append, or must-create behavior.

use Whim\File;
use Whim\IO;

using ($source = File\open_read_only('input.txt')) {
  using ($target = File\open_write_only('output.txt')) {
    IO\copy($source, $target);
  }
}

File\read and File\write cover one-shot work. File handles expose their path and size, support seeking, and can take shared or exclusive locks. Writable file handles can change the file length with truncate and flush pending data and metadata with synchronize. Both operations run on the blocking worker pool and accept a cancellation token.

File\read takes a uint byte offset, and truncate takes a uint length.

File system

Whim\Filesystem creates files, directories, hard links, symbolic links, named pipes, and temporary entries. It deletes, renames, copies, changes modes and owners, reads directories, reads links, and resolves canonical paths.

Inspection covers existence, node kind, access bits, metadata, and available or total disk space. metadata follows a symbolic link; symbolic_link_metadata inspects the link itself.

Metadata byte sizes, block counts, and link counts use uint, as do the available and total disk-space results.

Callers must pass true to delete a directory tree. Permission values use unsigned octal literals such as 0o755u.

Filesystem\exchange_creation_mask() replaces the process-wide file creation mask and returns its previous value. Set it during startup. A temporary change can race with file creation in another task or blocking worker.

Whim\Path\SEPARATOR is / on Unix and \ on Windows. Windows also accepts / in ordinary paths. Windows paths use UTF-8, with WTF-8 for unpaired UTF-16 surrogates, so names read from the filesystem can be passed back unchanged.

File descriptors

OS\FileDescriptor owns a Unix descriptor or a Windows handle or socket. duplicate($number) creates a new owned descriptor from an open number. toUint() returns its number as uint. isClosed() and close() manage its lifetime.

Duplicating is not the same as borrowing an integer. The new object owns its descriptor and closes it.

Windows platform limits

Files, pipes, TCP, UDP, TLS, Unix stream sockets, child processes, timers, and coroutines use Windows APIs. Unsupported POSIX operations throw Whim\Unwind\UnsupportedPlatformException before starting the operation:

  • Unix user and group IDs, account lookups, and supplementary groups.
  • Process sessions and groups, POSIX priorities, and resource limits.
  • Replacing the current process with Process\replace.
  • File ownership, POSIX permission changes, creation masks, and filesystem FIFOs.
  • Unix datagram sockets and SO_REUSEPORT.
  • Sending UDP packets marked ExplicitCongestion::CongestionExperienced.
  • POSIX load averages and signals. Signal::isSupported() returns false.

On Windows, Child::kill() and Child::terminate() end the child immediately. They return Command\Exited with the Windows exit code. Windows exit codes use the full unsigned 32-bit range. terminate() cannot first send SIGTERM.

File metadata reports -1 for Unix user and group IDs. Its permission bits describe the read-only attribute rather than a POSIX access mode. New files inherit Windows access-control rules from their parent directory. Creating symbolic links requires Developer Mode or the Windows symlink privilege.

The package manager stores Git executable bits in named file streams so package checksums agree across platforms. Use NTFS or another filesystem that supports these streams for the project and package cache.

Environment, Processes, and Terminals

Environment

Whim\Env reads the current process state:

  • get_arguments() returns arguments after the entry file.
  • get_variable() returns null for a missing environment value.
  • get_variables() returns all current environment values.
  • set_variable() and remove_variable() update the process environment.
  • current_directory() and set_current_directory() handle the working path.
  • home_directory() and temporary_directory() return common paths.
  • current_binary() returns the Whim executable path.
  • current_script() returns the entry source path when one exists.
use Whim\Env;

$arguments = Env\get_arguments();
$home = Env\home_directory();
if ($home != null) {
  write_line!($home);
}

Environment state belongs to the whole process. A library should prefer an explicit parameter when a value can vary by call.

Child commands

Command\Command is a readonly child-process definition. Start with Command::create($program), then add arguments, environment values, a working directory, stream choices, or a separate process group through with... methods.

A stream may use a pipe, inherit the parent’s stream, discard data, use a file descriptor, or use a terminal. The command API passes arguments directly; it does not join them into shell text.

start() returns a Child. run() waits and returns Output. output() returns standard output and throws FailedException on a bad exit. succeeds() returns a boolean.

A child exposes its process ID, running state, available pipe handles, signals, and waits. join() captures output. terminate() sends a graceful signal and then kills after its optional grace period. kill() stops at once.

ExitStatus is Exited|Signalled. Output stores status, standard output, and standard error.

Current process

Process\get_id() and get_parent_id() return process IDs. The identity functions read or change real, effective, and supplementary user and group IDs. Session and process-group functions inspect or change POSIX process relationships. Priority functions read or change a process’s scheduling priority. Resource-limit functions read or change the current process’s soft and hard limits; null means unlimited. A missing process argument means the current process.

Process, user, group, and session IDs use uint, as do resource limits. Command\Child::getId() returns the same unsigned process ID accepted by the process functions.

Changing identities, groups, sessions, priorities, or resource limits may need operating-system permission. A failed operation throws RuntimeException with the system error as its cause.

Process\replace() replaces Whim with another program and returns never. Its argument list follows the program name. A null environment inherits the current one; a dict replaces it. Failure throws RuntimeException.

exists($pid) checks a process. signal and signal_group send a supported POSIX Signal.

cpu_times() returns the user and system CPU time consumed by the current process and its waited-for children. Each value is a Duration.

watch_signal() calls a function each time a catchable signal arrives. Keep the returned SignalWatcher alive while it is needed, then call close(). Its destructor also stops the watch.

find_executable($name) searches the current executable path and returns an absolute non-empty path or null.

Not every listed signal exists on every POSIX system. isSupported() checks the host; isCatchable() rejects signals such as forced kill and stop.

Host information

OS\information() identifies the running system and machine. uptime() returns a Duration. load_averages() reports the one-, five-, and fifteen-minute load averages. memory() reports total and available physical memory in bytes as uint values.

Users and groups

OS\find_user() and OS\find_group() query the operating-system account directory by name or a uint identifier. They return immutable User and Group records, or null when no record exists.

OS\groups_for_user() returns the available primary and supplementary group records for a user. A group’s memberNames contains only names explicitly listed in that group record; users whose primary group matches may be absent.

Account-directory lookups run on the blocking worker pool. They accept an optional cancellation token and do not block other Whim tasks.

Shell text

Use Shell\escape_argument() to quote one value for the POSIX shell and Shell\join() to quote and join a list. Use them only when the task needs shell syntax. Prefer Command for a normal program call.

Terminal

Terminal\attached($descriptor) checks whether a descriptor is a terminal. size($descriptor) returns positive uint columns and rows or null. path($descriptor) returns the terminal path or null.

Network, TLS, and Proxies

Network APIs use the event loop and accept cancellation tokens on waits. They return typed endpoints and capability interfaces rather than raw descriptor numbers.

IP addresses and CIDR

IP\Address stores either four IPv4 bytes or sixteen IPv6 bytes. parse returns null; from throws on bad text. v4 and v6 require one family. fromBytes accepts an exact 4-byte or 16-byte string.

An address can return canonical, expanded, byte, and reverse-DNS forms. It can test loopback, private, link-local, multicast, unspecified, documentation, and global unicast ranges. It can also test and create IPv4-mapped IPv6 addresses.

CIDR\Block joins an address and prefix. It reports membership, overlap, and the first and last address.

International domain names

IDNA\to_ascii converts a Unicode domain name to its ASCII form. IDNA\to_unicode converts an IDNA domain name to Unicode. Both apply UTS #46, strict host-name rules, and DNS length limits. Invalid input raises an encoding or decoding exception.

URI, IRI, and URL

URI\URI follows generic URI syntax. It may be relative. It stores optional scheme and authority plus path, query, and fragment. URI\resolve resolves a reference against a base.

IRI\IRI permits international text and converts to and from a URI. Its host handling applies IDNA through the standard conversion path.

URL\URL is the stricter form used by network clients. It requires a scheme and authority, uses a numeric port, exposes an Origin, and reads or builds query parameters. It converts to URI or IRI.

All three types have nullable parse, throwing from, readonly public parts, with... copies, normalization, equality, and toString.

Use URI for a relative reference, IRI for an international identifier, and URL for an address a client can connect to.

Endpoints and streams

Network\InternetEndpoint stores an IP\Address and a Uint16 port. Ports use uint literals such as 443u; 0u asks the system to choose a local port when binding. UnixEndpoint stores a local socket path or null for an unnamed endpoint.

Network\Stream<TEndpoint> is a read, write, close, and file-descriptor handle. It reports local and peer endpoints and can shut down reads, writes, or both. Listener<TEndpoint> accepts streams.

An empty read is not enough to prove closure. Use the read-handle end check.

TCP

TCP\connect($host, $port, $configuration, $cancellation) resolves a name or uses an IP address and returns a TCP\Stream.

TCP\listen() binds an address and returns a listener. Listen configuration controls no-delay on accepted streams, address and port reuse, IPv6-only mode, and backlog. Connect configuration controls no-delay and an optional local bind.

DefaultConnector implements the connector interface for reusable clients. SecureConnector wraps another connector and performs TLS. Secure streams implement both the TCP and TLS stream contracts.

UDP

UDP\bind() returns a datagram socket. sendTo sends bytes and metadata to an endpoint. receive and tryReceive return a Datagram with bytes, sender, destination details, and congestion data when the host supplies it. Received and outgoing interface indices use uint.

Connecting a UDP socket fixes its peer and returns ConnectedSocket; it does not create a byte stream. Connected sends still preserve datagram boundaries.

Bind configuration controls reuse, broadcast, IPv6-only mode, and socket buffer sizes.

On FreeBSD, IPv4 send metadata supports source addresses and congestion bits, but not an explicit outgoing interface. Leave interfaceIndex in SendMetadata at 0u for IPv4; a nonzero value throws NetworkException. Received datagrams still report their interface index, and IPv6 supports selecting an interface. A socket bound to a specific IPv4 address must use that source address. On FreeBSD 14.4, sockets bound or connected to an IPv4-mapped IPv6 address do not receive packet metadata: datagrams report an interface index of 0u and ExplicitCongestion::NotCapable. Bind to an IPv4 address, or use an unconnected dual-stack socket bound to ::, when you need that metadata.

Unix sockets

Unix\connect and Unix\listen use local socket paths. Unix\pair() returns two connected non-blocking streams. All expose file descriptors and work with the same I/O and cancellation APIs as TCP.

SOCKS

SOCKS\Connector implements TCP\ProxyConnector. Its configuration sets the proxy host and port plus optional username and password. It performs SOCKS5 negotiation, reports authentication and protocol errors, and then returns the same TCP stream interface.

TLS identities and settings

TLS\Certificate reads DER or PEM certificate data. Identity joins a certificate chain and private key.

Client configuration controls system roots, added roots, an optional client identity, ALPN values, TLS version bounds, peer verification, name checking, SNI, and session reuse.

Verification defaults to full checks. AllowSelfSigned supports local work; Disabled removes peer checks and should not protect a real remote connection.

Server configuration starts with an identity and can add SNI identities, client roots, optional or required client authentication, ALPN values, version bounds, and session reuse.

TLS over a stream

TLS\Connector<TEndpoint> wraps any matching network connector, not only TCP. Acceptor<TEndpoint> wraps an accepted stream. TLS\listen() wraps a listener.

A secure stream still exposes normal network reads and writes. Its ConnectionState reports the negotiated version, cipher suite, ALPN value, handshake kind, peer certificates, and server name where available.

TLS closes and errors remain separate from the transport’s own close and error types. Always close the secure stream, not only the stream under it.

HTTP Messages and Cookies

Whim\HTTP\Message holds transport-free HTTP values. Bodies use I/O handles, so a message need not hold all bytes in memory.

Fields

FieldMap is a readonly ordered list of (name, value) fields plus an index for case-insensitive lookup. from() validates field names and values.

  • get($name) returns the first value or null.
  • getAll($name) returns every value in order.
  • has($name) checks a name.
  • with replaces all values under a name.
  • withAdded appends one value.
  • without removes a name.
  • count() returns the number of entries as uint.
  • toVec, isEmpty, and toIterator inspect the map.

fromPartsUnsafe is for already parsed and indexed internal data. Application code should use from.

Request and response

Request stores method, request target, optional absolute URL, protocol version, fields, optional body, and optional future trailers. Build one with fromParts or fromURL. fromPartsUnsafe skips method and target checks.

Response stores status, protocol version, fields, optional body, and optional future trailers. Its constructor checks the refined status type at the call.

Both are readonly. withMethod, withStatus, withHeader, withBody, and other with... methods return changed copies.

ProtocolVersion names HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3 message values. The included client and server support HTTP/1.1 and HTTP/2; the HTTP/3 enum case does not add an HTTP/3 transport.

Transaction joins informational responses with the final response. Exchange joins one request and one response. reason_phrase($status) returns a standard phrase when one exists.

Response helpers

Whim\HTTP\Message\Response provides:

  • json($value) with application/json
  • text($value) with UTF-8 plain text
  • html($value) with UTF-8 HTML
  • empty($status) with no body
  • redirect, see_other, temporary_redirect, and permanent_redirect

Text and HTML accept a string or Convert\ToString. JSON accepts Json\Value or Json\ToJson. Redirects accept a string, URI, or URL.

Request cookies

Cookie\Collection parses Cookie request fields into an ordered list of name-value pairs. Duplicate names stay available through getAll; get returns the first.

The collection implements iteration and toString. fromHeaders reads all cookie fields from a FieldMap.

Cookie\SetCookie stores name, value, expiry, max age, domain, path, secure, HTTP-only, and same-site settings. fromParts validates each part; parse returns null for a bad field. with... methods return changed copies.

Cookie\add($response, $cookie) appends one Set-Cookie field to a response. It does not replace other cookies.

SameSite has Strict, Lax, and None. A SameSite::None cookie should also be secure for current browsers.

Sessions

HTTP\Session\Session stores Json\Value entries under non-empty names. It supports contains, throwing get, set, remove, iteration, toDict, and clear. It can request a new identifier or destruction.

Session\Configuration sets the cookie template, idle timeout, optional cookie lifetime, and rolling expiry. The default cookie is __session, secure, HTTP-only, path /, and same-site lax. Pass a cookie with secure: false for plain HTTP local work.

MemoryStore keeps session records in one process. DatabaseStore uses the database contracts. Store writes use a revision check; a conflict throws ConflictException rather than dropping one concurrent update. Record revisions and the revision passed to Store::replace use uint, starting at 0u.

Store::rotate takes the old identifier, expected revision, new identifier, data, and TTL. It replaces the old record with the new one atomically and starts the new record at revision 0u. A missing, expired, or changed source, or a live record at the new identifier, causes ConflictException. A conflict leaves live records unchanged. The session middleware uses this operation when a loaded session requests a new identifier.

The server session middleware loads a record, attaches the session to the request context, and writes or deletes it after the handler. The context gives the handler getSession().

HTTP Client

The HTTP client sends HTTP\Message\Request values and returns a Transaction. It supports HTTP/1.1 and HTTP/2, connection reuse, TLS, proxies, middleware, redirects, retries, cookies, and cancellation.

Basic use

use Whim\HTTP\Client\DefaultClient;
use Whim\HTTP\Message\Request;
use Whim\URL\URL;

$client = new DefaultClient();
$request = Request::fromURL('GET', URL::from('https://example.com/'));
$transaction = $client->send($request);
$body = $transaction->response->body?->readAll();

Client::send() takes a request, per-send settings, and optional cancellation. The request needs an absolute URL or a base URL in the client settings.

The returned body is a read handle. Read or close it before expecting a pooled HTTP/1.1 connection to return to the pool.

DefaultClient

DefaultClient accepts a connector, a Configuration, and an iterable of middleware. The default connector pools direct TCP and TLS connections.

Configuration sets header and body size limits, informational response limits, a base URL, TLS settings, HTTP/2 settings, enabled protocol versions, and an optional proxy.

The informational-response limit uses uint, such as 16u. Pool limits and the redirect limit also use uint; pass 0u when disabling an idle pool. Response header and body size limits use NonZero<uint>, such as 8192u.

SendConfiguration overrides one call. It can also set callbacks for informational responses and connection metadata, plus a connection timeout.

The client rejects trailers without a body, CONNECT without a tunnel API, and a TRACE request with a body.

Connections and connectors

A Connector receives an origin, request, settings, and cancellation token. It returns a Connection. A connection reports protocol and endpoint metadata, whether the pool may reuse it, and an exchange operation.

DirectConnector opens one network connection. PooledConnector reuses safe connections by origin. UnixConnector sends HTTP over a Unix socket. Origin and endpoint ports use uint in the range 0u..=65535u.

ProxyConfiguration supports an HTTP proxy URL, optional authorization, TLS to the proxy, a server-name override, and host bypass rules.

Middleware

Client middleware runs after the connector acquires a connection and before the protocol exchange. It receives the connection, request, effective settings, next handler, and cancellation token.

CookieJar stores accepted response cookies and adds matching cookies to later requests. It follows domain, path, secure, expiry, and same-site data available to the client. clear() removes stored cookies and count() returns their number as uint.

DeniedDestinationsMiddleware rejects configured IP blocks after resolution. publicOnly() blocks private, loopback, link-local, and other non-public destinations. Use it when a caller controls the target URL.

Redirects and retries

RedirectClient wraps any client. It follows 301, 302, 303, 307, and 308 with a fixed limit. It strips credentials on cross-origin moves, follows safe referrer rules, and rewinds a seekable request body when a redirect must replay it. A body that cannot rewind stops the redirect.

RetryClient retries idempotent methods after connection or transport errors. It uses bounded attempts and increasing delays. A request body must be seekable to replay. The backoff multiplier uses NonZero<uint> and defaults to 2u.

Redirect and retry are client decorators, not connection middleware, because they may need another connection.

WebSocket client

HTTP\WebSocket\Client\connect() performs a WebSocket handshake for a URL and returns a client connection. Configuration sets frame and message limits, headers, origin, subprotocols, response header size, and TLS settings. Frame, message, header, and read sizes use NonZero<uint>.

The connection adds URL, response fields, endpoints, and TLS state to the common WebSocket connection API described in the server chapter.

HTTP Server

The server accepts HTTP/1.1 and HTTP/2 streams, parses requests, calls one handler, and writes responses. Its public layer stays in Whim and works with the network and I/O interfaces.

Handler

For event streams, use Whim\HTTP\SSE. It encodes events and provides a response body that the server can stream as events arrive.

Every request reaches:

interface Handler {
  public function handle(
    Context $context,
    Request $request,
    CancellationToken $cancellation,
  ): Response;
}

FunctionHandler accepts a callable with any useful subset of context, request, and cancellation parameters, or no parameters. It adapts that callable once in its constructor.

use Whim\HTTP\Server\Handler\FunctionHandler;

$handler = new FunctionHandler(
  fn(): Whim\HTTP\Message\Response =>
    Whim\HTTP\Message\Response\text('hello'),
);

Context

Every context has protocol, local endpoint, peer endpoint, and a mutable dict of string parameters. It can hold one session and register callbacks that run when the response completes.

Some contexts add a capability:

  • Informational can send a 1xx response.
  • Push can start an HTTP/2 server push.
  • Secure exposes TLS security state.
  • Upgrade can hand the stream to another protocol.

Check the interface with is before using an optional capability.

Bindings and server life

Binding\Stream wraps a network listener and an enableH2c choice. A TLS listener negotiates HTTP/2 through ALPN; a plain listener can opt into cleartext HTTP/2.

Server takes one or more bindings and a configuration. serve($handler, $cancellation) runs until cancellation, closed bindings, or a fatal accept or connection error. One server object can serve only once.

Shutdown stops accepts and closes idle HTTP/1 connections without sending a timeout response. Active requests can finish until shutdownTimeout expires; the server then cancels their work and closes the remaining connections.

Configuration bounds idle time, header and body time, response writes, connections, connections per peer, concurrent requests, server pushes, header and body sizes, requests per connection, HTTP/2 settings, middleware, and the error responder.

The request-body size and server-push limits use uint, such as maximumRequestBodySize: 10_485_760u and maximumServerPushes: 100u. Connection, concurrent-request, header-size, and requests-per-connection limits use NonZero<uint>, such as maximumConnections: 1_000u. CORS::maxAge and RequestDecompression::maximumDecodedBytes also use uint values; maxAge may be null.

Middleware

Server middleware receives context, request, next handler, and cancellation. It may change the request, call the next handler, change the response, or return without calling next.

Middleware\wrap($handler, $layers) creates a handler chain. The server’s configuration can hold the same middleware list.

Built-in middleware includes:

  • CORS for origin, method, field, credential, and cache rules.
  • HandlerTimeout for an optional per-handler deadline.
  • RequestDecompression for registered content codings and size bounds.
  • ResponseCompression for Accept-Encoding negotiation.
  • Session for cookie-backed stored sessions.
  • FunctionMiddleware for a callable adapter.

Handler timeouts are not part of the base server. Add the middleware when the application wants that policy.

Errors and responders

HTTPException carries a 4xx or 5xx status for expected request failure. Other throwables become server errors. The configured Responder turns a status and optional cause into a response.

Bare returns an empty error response. Debug includes the throwable and full trace text and is suitable only for local work. A responder failure or an exception from an upgrade callback stops the server and remains visible.

Router

HTTP\Router\Router implements both Handler and RouteCollection. add registers one method, path pattern, and handler. Helpers cover GET, HEAD, POST, PUT, PATCH, DELETE, and OPTIONS.

Patterns support:

  • literal text;
  • {name} for one non-empty path segment;
  • {name:expression} for a segment checked by a byte regular expression;
  • [text] for an optional sequence;
  • a final * for the rest of the path, stored under parameter name *;
  • \* for a literal asterisk.

A match percent-decodes each captured value and appends it to Context::$parameters before it calls the route handler. Router\parameter($context, $name) returns one value or null.

prefix($path, $group) adds a prefix to every route registered in the group. through($middleware, $group) wraps only that group. The router implements ToIterator over (method, pattern, handler) registrations.

No path match throws NotFoundException. A path with no matching method throws MethodNotAllowedException and includes the allowed methods.

Static files

Handler\StaticFiles serves a root directory under a URL prefix. It supports an index file, fixed response fields, cache control, media sniffing, ranges, conditional requests, and safe path resolution. It rejects traversal outside the root.

The file body stays an I/O handle. The handler does not read the full file into memory.

WebSocket server

HTTP\WebSocket\Server\Handler upgrades an HTTP/1.1 route and gives a Connection to a ConnectionHandler. FunctionConnectionHandler adapts callables with useful subsets of its arguments.

A connection can send text, binary, ping, and close frames. receive() accepts a per-call cancellation token. tryReceive() returns null when no complete message is ready; waitUntilReceivable() waits without taking one.

Received messages are TextMessage, BinaryMessage, or CloseMessage. The connection reports its subprotocol and close code and reason. Configuration bounds frames and complete messages and lists accepted subprotocols.

Destroying a live connection attempts a safe close without letting a transport error escape the destructor.

Server-Sent Events

Whim\HTTP\SSE encodes server-sent events and supplies streaming HTTP response bodies. It ships with stdlib and needs no package or autoloader.

Events

An Event carries UTF-8 text, an optional name, an optional ID, and an optional reconnect delay:

use Whim\HTTP\SSE\Event;
use Whim\Time\Duration;

$event = new Event(
  '{"temperature": 21}',
  name: 'reading',
  id: '42',
  retry: Duration::fromSeconds(3),
);
assert!(
  $event->encode()
  == "id: 42\nevent: reading\nretry: 3000\ndata: "
  . '{"temperature": 21}'
  . "\n\n",
);

encode() writes a data: field for each payload line and ends the event with a blank line. It converts CR and CRLF line endings to LF. Empty data still writes a field, so the browser delivers the event.

Null omits a name or ID. An empty name selects the default message event; an empty ID clears the browser’s resume position. Names and IDs cannot contain line breaks, and IDs cannot contain NUL. All text must be valid UTF-8. Invalid input throws Whim\Unwind\InvalidArgumentException.

Retry durations must be non-negative. Encoding drops fractions of a millisecond and handles Duration::max() without integer overflow.

Comment::of($text) encodes a comment and replaces line breaks with spaces. Browsers ignore comments; a server can use them as heartbeats.

Sources and response bodies

Implement Whim\HTTP\SSE\Source\Source to supply events:

use Whim\Async\CancellationToken;
use Whim\HTTP\SSE;
use Whim\HTTP\SSE\Event;
use Whim\HTTP\SSE\Source\Source;

final class Greetings implements Source {
  private bool $finished = false;

  public function poll(null|CancellationToken $cancellation = null): vec<Event> {
    $cancellation?->throwIfCancellationRequested();
    $this->finished = true;
    return vec[new Event('hello', name: 'greeting', id: '1')];
  }

  public function isFinished(): bool {
    return $this->finished;
  }
}

$response = SSE\respond(new Greetings());
assert!($response->status == 200);
assert!($response->body?->readAll() == "id: 1\nevent: greeting\ndata: hello\n\n");

respond($source, $headers = FieldMap::default()) returns status 200 with Content-Type: text/event-stream; charset=utf-8, Cache-Control: no-store, and X-Accel-Buffering: no. It keeps other caller headers and removes Content-Length and Transfer-Encoding, letting the server choose framing. The HTTP/1.1 server streams chunks; the HTTP/2 server streams DATA frames.

Body implements Whim\IO\ReadHandle and Whim\IO\CloseHandle. A read polls only after it drains buffered events. An empty batch produces Comment::WAITING instead of ending the stream. The body ends after the source finishes and its buffer drains. An already finished source receives no polls.

For a long-lived source, poll() must wait for events or a heartbeat interval and honor its cancellation token. Returning empty batches at once in a loop would consume CPU. Source\FunctionSource wraps a polling callable with the same signature; it never reports itself finished.

The body checks cancellation before and after each poll. Source errors reach the reader unchanged. Closing the body discards buffered bytes and closes a source that implements CloseHandle. A suspended poll cannot refill a body after another task closes it. Use using when reading a body directly. If a handler keeps a body or source, register Body::close() through Context::onResponseComplete() to release it when the response ends.

Reconnection and completion

SSE\last_event_id($request) reads Last-Event-ID, returning null when the field is absent or empty. The application decides how to resume from that ID; the library stores no event history.

SSE\refuse($event, $headers = FieldMap::default()) sends one event with status 200 and ends the response. This alone does not stop reconnection. The client must call EventSource.close() after an application-defined terminal event:

const events = new EventSource('/events');
events.addEventListener('done', () => events.close());
events.addEventListener('failed', () => events.close());

A successful stream ending normally makes EventSource reconnect. To tell a browser to stop without sending an event, return an HTTP 204 response. These rules follow the SSE specification.

See HTTP Server for handlers and response completion, and HTTP Messages and Cookies for response values.

Databases

Whim\Database defines one async contract for SQLite, PostgreSQL, custom drivers, and connection pools. It accepts raw parameterized SQL and does not include a query builder.

Values and rows

Database\Value is null|bool|int|float|string|Blob. Blob is a newtype over string, so text and binary parameters stay distinct. A Row is a vec of values.

Column stores result-column name and database type data.

Connectors and connections

A Connector opens a Connection with an optional cancellation token. A connection can:

  • execute parameterized SQL
  • prepare a statement
  • begin a transaction
  • check the server with ping
  • report whether it is safe to reuse
  • close

SQL parameters use $1, $2, and so on. The number controls binding; first appearance does not. Pass values in numeric placeholder order.

use Whim\Database;
use Whim\Database\PostgreSQL\Connector;

using ($connection = new Connector('dbname=app')->connect()) {
  using ($result = $connection->execute(
    'SELECT id, name FROM users WHERE id = $1',
    vec[42],
  )) {
    $row = $result->fetch();
  }
}

Prepared Statement::execute() accepts only its values and cancellation. Statements and results are closeable.

Results

A result exposes column metadata, an affected-row count when the operation has one, and streaming fetch(). Fetch returns one row or null at the end. getAffectedRows() returns null|uint.

Helpers cover common shapes:

  • fetch_one returns one row or null and rejects extra rows.
  • fetch_all returns every row.
  • fetch_value returns one selected value.
  • transactional begins, calls a function, commits on success, and rolls back on failure.

Close a result after an early stop so its connection can run another operation.

Transactions

begin($isolation, $readOnly, $cancellation) starts a transaction. A null isolation uses the database default. TransactionIsolation has read uncommitted, read committed, repeatable read, and serializable levels, though a driver may reject a level its database cannot provide.

A transaction implements Executor and adds isActive, commit, rollback, and close. Closing an active transaction rolls it back.

Pool

ConnectionPool wraps a connector. connect() checks out a lease that still implements Connection; closing it returns a reusable connection to the pool.

PoolConfiguration controls:

  • maximum open connections
  • maximum idle connections
  • idle timeout
  • total connection lifetime
  • validation interval
  • acquisition timeout

The default acquisition timeout is 30 seconds. A null timeout waits without a pool deadline, though the caller’s cancellation token can still end the wait.

The pool reports open, idle, and waiting counts as uints. Closing it closes idle connections and fails pending acquisitions. Checked-out leases close or return when their owners finish.

SQLite

Database\SQLite\Connector opens a path or URI. inMemory() creates a private in-memory database.

Configuration sets read-only and create behavior, URI parsing, busy timeout, statement-cache size, and foreign-key enforcement. SQLite work runs through the shared bounded blocking pool, so file work does not stop the event loop. The timeout in milliseconds and statement-cache capacity take uints.

Each connection permits one active operation. The driver waits for its prior worker operation to retire before reuse; application code does not retry a private busy state.

PostgreSQL

Database\PostgreSQL\Connector accepts a libpq connection string, which is marked sensitive in traces. It uses non-blocking libpq socket progress with the Whim event loop.

PostgreSQL errors preserve SQLSTATE, detail, and hint on Database\Exception. Call getSQLState() instead of matching error text.

Errors

The hierarchy separates connection, query, and transaction failures. AcquisitionTimeoutException means a pool wait expired. ConcurrentOperationException means code attempted overlapping work on one connection. Database server “busy” or lock errors remain query errors with their database code.

Mail and MIME Messages

Whim\Message models Internet mail. Whim\MIME supplies its content parts and headers. Whim\SMTP sends a message and envelope.

Addresses

Message\Address\Mailbox stores local part, domain, and optional display name. Group stores a named address group. AddressList holds mailboxes and groups, supports iteration, and can flatten its mailboxes.

Each type has nullable parse, throwing from, and toString. Mailbox also has checked fromParts. Output constructors enforce valid dot-atom and domain shape instead of accepting header injection.

Message IDs

MessageId parses, creates, and formats a message identifier. generate() uses secure random data plus an optional domain. MIME\ContentId supplies the same shape for body parts and can form a cid: URI.

Message

Message\Message::create() starts an immutable message. withFrom, withSender, withTo, withCc, withBcc, withReplyTo, withDate, withMessageId, withSubject, withReferences, withInReplyTo, and withContent return changed messages and keep matching headers in sync.

withHeader and withoutHeader handle custom fields. fromHeaders parses the known structured fields from an existing MIME header map.

Message\parse($handle) reads a message and MIME body. serialize($message) returns a streaming read handle. Large attachments stay streaming through transfer encoding and multipart output.

MIME\MultiPart\Parser takes uint values for its spool threshold, maximum part count, maximum part size, and maximum header size. 0u means no limit for each maximum.

Envelope

An SMTP envelope is separate from visible message headers. Envelope::fromParts takes an optional sender and at least one recipient. fromMessage derives the sender from Sender or the first From mailbox and recipients from To, Cc, and Bcc.

Bcc addresses belong in the envelope but should not appear in serialized visible headers.

SMTP values

SMTP\Command and Reply parse and format protocol lines. EnhancedStatusCode stores its class, subject, and detail. Enums name capabilities, security modes, priority, delivery status requests, return modes, and delivery deadlines. SMTP\Client\Connection::maximumMessageSize() returns the advertised size limit as null|uint.

Transport

SMTP\Client\DefaultTransport connects, negotiates EHLO features, applies optional authentication, sends a message, and pools idle connections.

Transport configuration sets host, optional port, plaintext, STARTTLS, or implicit TLS, local hostname, pipelining, chunking, chunk size, partial-success policy, idle pool limits, connector, and TLS settings. maximumIdleConnections uses uint and defaults to 1u; 0u disables idle connection reuse. The chunk size uses NonZero<uint> and defaults to 65_536u.

Ports use uint in the range 0u..=65535u. When the caller omits a port, STARTTLS uses 587u, implicit TLS uses 465u, and plaintext uses 25u.

Idle connections have a timeout. A checked-out idle connection must answer NOOP; otherwise the transport closes it and opens a new one.

Authentication

Authenticators implement one contract over an SMTP connection. The library includes PLAIN, LOGIN, CRAM-MD5, SCRAM-SHA-256, and XOAUTH2. Credentials carry SensitiveParameter markers.

The server must advertise the chosen method. A missing or rejected method throws AuthenticationException, not a missing dict-key error.

Delivery options and report

Per-send settings cover delivery status notifications, envelope ID, required TLS, priority, deliver-by, and future release when the server advertises those extensions.

With partial success disabled, a rejected recipient fails the send. With it enabled, DeliveryReport lists every rejected mailbox and reply, including the case where the server rejects all recipients.

Connection, authentication, protocol, extension, and transmission failures use separate exception types.

Hashes, Passwords, and Random Data

Hashes

Hash\Algorithm names SHA-2, SHA-3, Keccak-256, SHA-1, MD5, RIPEMD-160, BLAKE3, BLAKE2b-256, CRC, Adler, and xxHash forms. Each case reports its digest length and whether it is cryptographic.

Hash\digest($algorithm, $bytes, $hex) computes one digest. Hasher accepts chunks through update and returns bytes from one final finish.

Hash\hmac computes a keyed MAC for a supported digest. HmacHasher is its streaming form. Hash\equals compares secret byte strings in fixed time for their length.

Hash\pbkdf2_sha256($password, $salt, $iterations) derives 32 bytes with PBKDF2-HMAC-SHA-256. The iteration count must be between 1 and 1,000,000.

Do not use CRC, Adler, xxHash, MD5, or SHA-1 for a new security check. Their presence supports checksums and old formats.

Password hashes

Password\hash($password, $algorithm) creates a fresh salt and returns a self-describing hash. Algorithms are Bcrypt, Argon2i, and Argon2id, with checked cost settings.

verify checks a password and returns false for an unknown hash. needs_rehash reports whether a stored hash uses a different algorithm or cost.

Bcrypt accepts at most 72 password bytes; the function rejects longer input instead of truncating it. Traces hide passwords and stored hashes.

Secure random data

SecureRandom\bytes($length) reads operating-system random bytes. string($length, $alphabet) selects unbiased characters from an alphabet. Both take a uint length, such as 32u. int($min, $max) selects an inclusive unbiased integer. float() returns a value from zero inclusive to one exclusive.

The default alphabet for a random string is safe for common text uses. Supply an explicit alphabet when a protocol has a fixed one.

Failure to obtain enough operating-system randomness throws InsufficientEntropyException.

Pseudo-random sequences

PseudoRandom\int and float use one process sequence seeded from secure random bytes. They are suitable for tests, sampling, games, and shuffling, not for keys, tokens, salts, or passwords.

RandomSequence\MersenneTwisterSequence accepts an unsigned 32-bit seed and is repeatable. SecureSequence reads fresh secure data. Both implement Sequence with next, nextFloat, and inclusive nextIn.

Use an explicit sequence object when a test needs the same output on every run.

UUID

UUID\UUID::v4() creates a random UUID. v7() creates a time-ordered UUID. parse accepts canonical lowercase text or returns null; from throws.

fromBytes requires exactly 16 bytes. toBytes, toString, version, and equals expose the value without changing it. Parsed UUIDs may have no known version.

Namespace Index

This index lists the public standard-library namespaces. Names that end in \_Private are not public and do not appear here.

Core values and contracts

NamespacePurpose
Whimthe VERSION constant
Whim\Attributedefine attribute classes and targets
Whim\Autoloadregister and run symbol autoloaders
Whim\Comparisonequality, order, and Ordering
Whim\Convertexplicit value conversion contracts
Whim\Defaultthe default-value contract
Whim\Enuminterfaces implemented by all enums
Whim\Fnidentity, observation, composition, branches, and rethrowing
Whim\GCexplicit cycle collection
Whim\Lintscoped lint settings
Whim\Logicsingle-assignment variables, linking, and waiting
Whim\Markerbuilt-in attributes and compiler markers
Whim\OptionSome, None, and option helpers
Whim\Promisethe read-only async result contract
Whim\Referenceweak references and weak maps
Whim\Refinecommon aliases, ranges, and callable types
Whim\Reflectionfiles, declarations, types, values, and invocation
Whim\Reflection\Attributeattribute rules and target kinds
Whim\Reflection\Generictype parameters, bindings, and type environments
Whim\Reflection\Membermethods, properties, constants, and enum cases
Whim\Reflection\Typetype forms and their parts
Whim\ResultOk, Err, and throwable capture
Whim\Statetyped state machines, guards, and observers
Whim\Symbolsymbol lookup and symbol kinds
Whim\Typeengine-local type identifiers
Whim\Unwinderrors, exceptions, throwables, and trace frames
Whim\Variationvalues with correlated choices

See Core Types and Functions, Function Helpers, Reflection, Option and Result, and Built-in Attributes.

See State Machines for typed transitions, guards, and observers.

See Variation and Logic Variables for values with explicit alternatives or later bindings.

Strings, numbers, and collections

NamespacePurpose
Whim\Binaryfixed-width integer and float encoding
Whim\Dicteager keyed collection functions
Whim\Floatfloat parsing, bit forms, and checks
Whim\Intinteger parsing
Whim\UIntunsigned integer parsing and exact division
Whim\Iterateiterators and lazy collection functions
Whim\Matharithmetic, statistics, bases, and math constants
Whim\Rangeruntime integer range objects
Whim\Strbyte-string search and changes
Whim\UnicodeUnicode case folding and code-point checks
Whim\Veceager list functions

See Strings, Numbers, and Binary Data, Collections and Data Structures, and Iterators.

Data structures and formats

NamespacePurpose
Whim\BSONBSON values, encoding, decoding, readers, and writers
Whim\CSVstreaming CSV readers and writers
Whim\Compressiongzip, deflate, Brotli, and Zstandard streams
Whim\DataStructurequeue, stack, deque, heap, and priority queue
Whim\Encodingshared encoding errors and contracts
Whim\Encoding\Base32Base32 text
Whim\Encoding\Base64standard and URL-safe Base64
Whim\Encoding\EncodedWordmail header encoded words
Whim\Encoding\Hexhexadecimal text
Whim\Encoding\PunycodePunycode labels
Whim\Encoding\QuotedPrintablequoted-printable text and bytes
Whim\Encoding\URIwhole-URI percent encoding
Whim\Encoding\UTF8UTF-8 checks and lossy repair
Whim\Encoding\Urlpercent and form encoding
Whim\Graphdirected and undirected graphs, traversal, and paths
Whim\HTMLWHATWG character references and escaping
Whim\JsonJSON values, encoding, and decoding
Whim\MIMEmedia types, fields, content IDs, and parts
Whim\MIME\MultiPartmultipart writing and parsing
Whim\MIME\Parttext, data, and raw MIME parts
Whim\MIME\Sniffmedia-type checks from byte prefixes
Whim\Regexbyte regular expressions
Whim\Treetyped trees, traversal, search, mapping, and folds
Whim\UUIDUUID parsing plus versions 4 and 7

See Graphs, Trees, and Encoding and Data Formats.

Time, environment, and the operating system

NamespacePurpose
Whim\Clocksystem and controlled clocks and sleepers
Whim\Commandchild-process setup and control
Whim\Croncron parsing, matching, and next or previous occurrences
Whim\DateTimedates, civil times, zones, and formatting
Whim\Envarguments, paths, and environment variables
Whim\OSowned file descriptors, accounts, and host metrics
Whim\PathPOSIX path constants
Whim\Processprocess identity, CPU time, signals, and executable lookup
Whim\ShellPOSIX shell quoting
Whim\Terminalterminal checks, paths, and size
Whim\Timedurations, monotonic instants, and wall time

See Time and Calendars, Clocks and Sleepers, Cron Expressions, and Environment, Processes, and Terminals.

Files and I/O

NamespacePurpose
Whim\Filetyped file handles and one-shot file work
Whim\Filesystempaths, directories, links, metadata, and disk space
Whim\IOhandle contracts, buffering, adapters, and copying

See Files and I/O.

Async work

NamespacePurpose
Whim\Asynctasks, futures, cancellation, groups, and limits
Whim\Channelbounded and unbounded task channels

See Tasks and Futures and Channels and Cancellation.

Retries and request limits

NamespacePurpose
Whim\Fusecircuit breakers with failure and recovery thresholds
Whim\Retryretry policies, delays, jitter, and deadlines
Whim\Throttlekeyed token buckets and fixed windows

See Retry Policies, Circuit Breakers, and Rate Limits. All three use Clocks and Sleepers.

Network and addresses

NamespacePurpose
Whim\CIDRIP network blocks
Whim\IDNAinternational domain-name conversion
Whim\IPIPv4 and IPv6 values
Whim\IRIinternational resource identifiers
Whim\Networkendpoint, stream, listener, and connector contracts
Whim\SOCKSSOCKS5 proxy connections
Whim\TCPTCP streams, listeners, and connectors
Whim\TLSTLS settings, identities, streams, and listeners
Whim\UDPdatagram sockets
Whim\URIgeneric URI values and reference resolution
Whim\URLabsolute network URLs and origins
Whim\UnixUnix-domain streams, listeners, and pairs

See Network, TLS, and Proxies.

HTTP

NamespacePurpose
Whim\HTTP\ClientHTTP/1.1 and HTTP/2 clients and connectors
Whim\HTTP\Client\Middlewareclient cookie state and request policy
Whim\HTTP\Cookierequest cookies and Set-Cookie values
Whim\HTTP\Messagemethods, fields, requests, responses, and exchanges
Whim\HTTP\Message\Responsecommon response factories
Whim\HTTP\Routerroute registration and dispatch
Whim\HTTP\Serverserver setup, settings, and errors
Whim\HTTP\Server\Bindingstream-listener server bindings
Whim\HTTP\Server\Contextrequest context and optional capabilities
Whim\HTTP\Server\Handlerfunction, static-file, and handler contracts
Whim\HTTP\Server\MiddlewareCORS, sessions, compression, and timeouts
Whim\HTTP\Server\Responderbare and debug error responses
Whim\HTTP\Sessionsession values and settings
Whim\HTTP\Session\Storagememory and database session stores
Whim\HTTP\SSEserver-sent events and streaming response bodies
Whim\HTTP\SSE\Sourceevent sources and polling function adapters
Whim\HTTP\WebSocketWebSocket messages and connections
Whim\HTTP\WebSocket\Clientclient handshakes and connections
Whim\HTTP\WebSocket\Serverserver upgrades and connection handlers

See HTTP Messages and Cookies, HTTP Client, HTTP Server, and Server-Sent Events.

Databases

NamespacePurpose
Whim\Databaseshared database, result, transaction, and pool contracts
Whim\Database\PostgreSQLevent-loop PostgreSQL driver
Whim\Database\SQLiteblocking-pool SQLite driver

See Databases.

Mail

NamespacePurpose
Whim\MessageInternet mail messages and IDs
Whim\Message\Addressmailboxes, groups, and address lists
Whim\SMTPSMTP values, envelopes, replies, and settings
Whim\SMTP\ClientSMTP transport and delivery reports
Whim\SMTP\Client\AuthenticationPLAIN, LOGIN, CRAM-MD5, SCRAM, and XOAUTH2

See Mail and MIME Messages.

Security and random data

NamespacePurpose
Whim\Hashdigests, HMAC, checksums, and streaming hashers
Whim\Passwordbcrypt and Argon2 password hashes
Whim\PseudoRandomthe process pseudo-random sequence
Whim\RandomSequencerepeatable and secure random sequence objects
Whim\SecureRandomoperating-system random bytes, strings, ints, and floats

See Hashes, Passwords, and Random Data.

Appendix A: Keywords

Whim gives keywords three reservation levels. The level controls where the word may still act as a name.

LevelFunction nameConstant nameMember name
fullnonoyes
softyesnoyes
contextualyesyesyes

A member name follows ->, ?->, or ::, or appears in a class-like member declaration. Every keyword may appear there.

Full keywords

These words may appear only as keywords or member names:

break       catch       continue    do          else
false       finally     fn          for         foreach
fresh       function    if          match       new
null        parent      return      self        static
throw       true        try         using       while

Soft keywords

These words may also name functions:

as          is

They cannot name constants because a bare use could conflict with the operator.

Contextual keywords

These words may name functions, constants, and members when their position makes the meaning clear:

abstract    array       bool        case        class
classname   const       default     dict        enum
extends     final       float       implements  in
int         uint        interface   mixed       namespace   never
newtype     object      out         private     protected
public      readonly    string      type        use
vec         void        where

The _ identifier

Whim reserves _ even though it is not a keyword token. It cannot name a namespace symbol, class-like member, parameter, type parameter, or import.

$_ is valid because variable names include the $ prefix. Whim treats it as an ordinary variable.

Literal words

true, false, and null are full keywords and literal values. They may also appear in type positions as literal types.

Case

Keywords use the lowercase spellings above. Names are case-sensitive, so a different case is a different identifier:

function Match(): int {
  return 42;
}

assert!(Match() == 42);

Use such names with care. The formatter does not change identifier case.

Appendix B: Operators

The table runs from loose binding to tight binding. Parentheses override this order.

LevelOperatorsAssociation
Assignment=, +=, -=, *=, /=, %=, **=, .= and compound bit, shift, coalesce, and Boolean assignmentsright
Conditional? :right
Coalesce??right
Boolean or`
Boolean and&&left
Comparison==, !=, <, <=, >, >=, <=>none
Typeis, as, ?asnone
Pipeline`>`
Concatenation.left
Bitwise or``
Bitwise xor^left
Bitwise and&left
Shift<<, >>left
Addition+, -left
Multiplication*, /, %left
Prefix!, ~, unary + and -, prefix ++ and --right
Exponentiation**right
Postfixcalls, indexing, ->, ?->, ::, postfix ++ and --left

Comparisons and type operators do not chain. Write the intended grouping or combine complete comparisons with &&.

Collection and language operations

Several operations use construct syntax and do not belong in the precedence table:

  • length!($value) returns the length of a string or array.
  • contains!($array, $value) and contains_key!($array, $key) search arrays.
  • remove!($array, $key) removes and returns an entry.
  • swap_remove!($vec, $index) removes and returns an item without preserving vec order.
  • clone!($object, property: $value) clones an object with changed properties.
  • drop!($resource) ends ownership immediately and reports a leak.
  • debug!($value) prints a bounded structural view with its source location.
  • require! and require_once! load source files.

The compiler checks these forms by their own rules.

Appendix C: Language Constructs

A construct uses function-like syntax, but the compiler applies its own rules. This table lists every construct.

ConstructResultMain rule
assert!($test)voidthrows when a bool test is false
assert!($test, $message)voidadds a string message
clone!($object, ...)objectclones an object and may replace fields
contains!($array, $value)boolchecks array values with strict equality
contains_key!($array, $key)boolchecks an array key or index
cpu_architecture!()stringreturns the platform’s CPU architecture
debug!(...)nullprints source sites and bounded value details
directory!()stringreturns the current source directory
discard!($value)voidmarks a discarded result as deliberate
drop!($local, ...)voidreleases locals that hold the last strong references
embed!('./file')stringembeds a file’s exact bytes while compiling
executable_extension!()stringreturns the executable extension without a dot
executable_suffix!()stringreturns the executable suffix, including a dot if present
exit!()neverexits with status zero
exit!($status)neverexits with the low eight bits of an int
file!()stringreturns the current source file
fresh!()freshcreates a distinct identity within the VM lifetime
length!($value)uintcounts string bytes or array items
operating_system!()stringreturns the platform’s operating system
operating_system_family!()stringreturns the platform’s operating system family
panic!($message)neverrequires a string, prints a trace, and exits with status 255
remove!($array, $key)valueremoves and returns one entry
swap_remove!($vec, $index)valueremoves a vec item without keeping order
remove_first!($vec)valueremoves and returns the first item
remove_last!($vec)valueremoves and returns the last item
require!($path)nullloads and runs a source file
require_once!($path)nullloads a resolved path at most once
sequence!($first, ..., $last)last expression’s typeevaluates one or more expressions in order and produces the last value
shared_library_extension!()stringreturns the shared library extension without a dot
shared_library_prefix!()stringreturns the shared library filename prefix
shared_library_suffix!()stringreturns the shared library suffix, including the dot
write!(...)voidwrites to standard output
write_line!(...)voidwrites to standard output, then ends the line
write_error!(...)voidwrites to standard error
write_error_line!(...)voidwrites to standard error, then ends the line

Mutable targets

The remove constructs require a writable array place, not an arbitrary expression. drop! accepts locals and makes them undefined.

$values = vec[10, 20, 30];
$removed = remove!($values, 1);

assert!($removed == 20);
assert!($values == vec[10, 30]);

remove! preserves order when its target is a vec, so removing a non-final item shifts each later item left. remove_first! does the same at index zero. Use either form when later indexes or iteration order must stay predictable. Removing near the front of a vec takes time in proportion to the items that follow it, so repeatedly draining a vec with remove_first! takes quadratic time. Use Whim\DataStructure\Deque for a FIFO queue.

swap_remove! does not shift. It moves the last item into the removed item’s index:

$values = vec[10, 20, 30, 40];
$removed = swap_remove!($values, 1);

assert!($removed == 20);
assert!($values == vec[10, 40, 30]);

Use it for an unordered vec when removal speed matters. Its removal step takes constant time. As with any vec mutation, changing a shared vec may first copy its storage. remove_last! also takes constant time and keeps the order of all remaining items.

remove! also accepts a dict. It removes the requested key without changing the other entries. swap_remove!, remove_first!, and remove_last! accept only vecs. A missing key, invalid index, or empty vec throws OutOfBoundsError.

Output arguments

Write constructs accept any number of string, int, or float expressions. They evaluate arguments from left to right. The line forms add the host line ending after the last argument.

write_line!('count: ', 3);

They reject bool, null, arrays, objects, and callables. Convert such values first.

Debug arguments

debug! accepts any number of values. It writes one source location and value view for each argument to standard error, then returns null.

$value = debug!(42);
assert!($value == null);

The view stops after 64 collection items and 32 nested levels.

Process exit

exit! stops the process. It does not throw. A catch cannot intercept it, and pending finally blocks do not run.

panic! evaluates one expression that must produce a string, then prints the message and current stack trace to standard error and exits with status 255. A non-string result throws a catchable TypeError instead. Errors during message evaluation propagate normally. Trace boundaries and sensitive parameter markers apply. Both exit forms run shutdown destructors.

Source paths

file!() and directory!() take no arguments. Their values belong to the source file that contains the construct, not the process entry file. Both are valid in constant expressions.

embed! takes one literal relative path. It resolves from that same source directory and reads the file while compiling. It accepts any bytes and performs no file access when the program runs. It rejects absolute paths and source read from standard input.

See Language Constructs for examples and Resources and Cleanup for drop!.

Appendix D: Grammar Guide

This guide shows the main source forms. It does not replace the parser. The chapters linked from each section define the checks and runtime rules.

The notation uses:

  • | for a choice;
  • ? for an optional part;
  • * for zero or more parts;
  • + for one or more parts;
  • quoted text for source tokens.
attributes      := attribute-list+
attribute-list  := "#[" attribute ("," attribute)* ","? "]"
file-attribute-list
                := "#![" attribute ("," attribute)* ","? "]"
attribute       := qualified-name call-arguments?

qualified-name  := "\\"? identifier ("\\" identifier)*
variable        := "$" identifier
literal         := "null" | "true" | "false"
                 | integer-literal | float-literal | string-literal
signed-integer-literal
                := "-"? integer-literal

The lexical chapter defines identifier and literal bytes. The bare identifier The grammar reserves _ even where this guide says identifier.

Source file

source-file     := shebang? source-item*

source-item     := file-attribute-list
                 | namespace-declaration
                 | use-declaration
                 | function-declaration
                 | class-declaration
                 | interface-declaration
                 | enum-declaration
                 | type-alias
                 | newtype-declaration
                 | constant-declaration
                 | statement

A file may mix declarations and statements. A namespace declaration can apply to the rest of the file or hold a braced source body. Declarations, imports, and file attributes belong at the top level of a file or namespace.

namespace-declaration
                := "namespace" qualified-name ";"
                 | "namespace" qualified-name "{" source-item* "}"

use-declaration := "use" use-items ";"
use-items       := use-item ("," use-item)*
                 | qualified-name "\\" "{" use-item
                   ("," use-item)* ","? "}"
use-item        := qualified-name ("as" identifier)?

See Namespaces and Imports and Loading Files.

Functions and parameters

function-declaration
                := attributes? "function" function-name type-parameters?
                   parameter-list return-type? where-clause? block

parameter-list  := "(" (parameter ("," parameter)* ","?)? ")"

parameter       := attributes? parameter-modifier* type? variable
                   ("=" expression)?
parameter-modifier
                := visibility | "readonly"

return-type     := ":" type

type-parameters := "<" type-parameter ("," type-parameter)* ","? ">"

type-parameter  := variance? identifier bounds? default-type?
variance        := "in" | "out"
bounds          := ":" type ("+" type)*
default-type    := "=" type

A closure uses fn and captures outer variables by value. Its body is one expression or a block.

closure         := attributes? "fn" type-parameters? parameter-list
                   return-type? where-clause? closure-body

closure-body    := "=>" expression | block

Visibility and readonly on a parameter promote it to a property and are valid only in a class constructor.

See Functions and Closures.

Classes

class-declaration
                := attributes? class-modifier* "class" identifier
                   type-parameters? class-parent? class-interfaces?
                   sealed-family? class-body

class-modifier  := "abstract" | "final" | "readonly"
class-parent    := "extends" named-type
class-interfaces
                := "implements" named-type ("," named-type)*
sealed-family   := "for" named-type ("," named-type)*

class-body      := "{" class-member* "}"
class-member    := property | class-constant | method

Properties include $ in their names. Methods and class constants do not.

property        := attributes? property-modifier+ type?
                   variable ("=" expression)? ";"

property-modifier
                := visibility | "static" | "readonly"

class-constant  := attributes? constant-modifier+ "const" type? identifier
                   "=" expression ";"
constant-modifier
                := visibility | "final"

method          := attributes? method-modifier+ "function"
                   member-name type-parameters? parameter-list
                   return-type? where-clause? (block | ";")

where-clause    := "where" where-constraint ("," where-constraint)* ","?
where-constraint
                := identifier ":" type

method-modifier := visibility | "abstract" | "final" | "static"
visibility      := "public" | "protected" | "private"

A constructor parameter with a visibility word declares a promoted property. readonly may appear before or after the visibility word. A property, constant, or method has exactly one visibility word. The other modifiers may appear in any order, but each may appear only once.

A callable’s where clause requires each named type parameter to fit its bound.

See Classes and Properties and Inheritance and Visibility.

Interfaces and enums

interface-declaration
                := attributes? "interface" identifier type-parameters?
                   interface-parents? sealed-family? interface-body

interface-parents
                := "extends" named-type ("," named-type)*

interface-body  := "{" interface-member* "}"
interface-member
                := property | class-constant | method

enum-declaration
                := attributes? "enum" identifier enum-backing?
                   enum-interfaces? "{" enum-member* "}"

enum-backing    := ":" type
enum-interfaces := "implements" named-type ("," named-type)*
enum-member     := enum-case | class-constant | method
enum-case       := attributes? "case" member-name
                   ("=" expression)? ";"

See Interfaces and Sealed Families and Enums.

Aliases, newtypes, and constants

type-alias      := attributes? "type" identifier type-parameters?
                   "=" type ";"

newtype-declaration
                := attributes? "newtype" identifier type-parameters?
                   "=" type ";"

constant-declaration
                := attributes? "const" identifier "=" expression ";"

See Aliases and Newtypes.

Statements

statement       := expression ";"
                 | if-statement
                 | while-statement
                 | do-while-statement
                 | for-statement
                 | foreach-statement
                 | try-statement
                 | using-statement
                 | final-local-statement

block           := "{" statement* "}"

if-statement    := "if" "(" expression ")" block
                   ("else" (if-statement | block))?

while-statement := "while" "(" expression ")" block

do-while-statement
                := "do" block "while" "(" expression ")" ";"

for-statement   := "for" "(" expression-list? ";"
                   expression-list? ";" expression-list? ")" block

foreach-statement
                := "foreach" "(" expression "as" foreach-target
                   ("=>" foreach-target)? ")" block

foreach-target  := assignment-target
assignment-target
                := variable | property-access | static-property-access
                 | array-index | array-append
                 | tuple-destructure | dict-destructure

tuple-destructure
                := "(" destructure-item ","
                   (destructure-item ("," destructure-item)*)? ","? ")"
destructure-item
                := assignment-target
                 | assignment-target "=" expression
                 | "..." assignment-target?
dict-destructure
                := "dict" "[" (expression "=>" assignment-target
                   ("," expression "=>" assignment-target)* ","?)? "]"

expression-list := expression ("," expression)*

final-local-statement
                := "final" variable "=" expression ";"

See Statements and Loops.

Error handling and cleanup

try-statement   := "try" block catch-clause* else-clause? finally-clause?

catch-clause    := "catch" "(" type variable? ")" guard? block
guard           := "if" "(" expression ")"
else-clause     := "else" block
finally-clause  := "finally" block

using-statement := "using" "(" using-binding
                   ("," using-binding)* ","? ")" block
using-binding   := bind-target "=" expression

A try statement needs at least one catch, else, or finally clause.

See Throwing and Catching and Resources and Cleanup.

Expressions

expression      := literal
                 | interpolated-string
                 | variable
                 | constant-name
                 | "(" expression ")"
                 | tuple-literal
                 | vec-literal
                 | vec-fill
                 | dict-literal
                 | closure
                 | match-expression
                 | "new" class-expression call-arguments?
                 | "break" integer-literal?
                 | "continue" integer-literal?
                 | "return" expression?
                 | "throw" expression
                 | unary-expression
                 | binary-expression
                 | conditional-expression
                 | assignment-expression
                 | call-expression
                 | partial-call
                 | member-expression
                 | index-expression
                 | construct-expression

conditional-expression
                := expression "?" expression ":" expression

class-expression is a named class, self, parent, static, a type parameter, or an expression that yields a class-name string. The operator appendix gives the precedence that turns the broad forms above into one tree.

Calls may use positional or named arguments. ? in an argument place creates a partial call. ... in a call place creates a first-class callable or leaves later partial-call parameters open.

call-arguments  := "(" (call-argument ("," call-argument)* ","?)? ")"
call-argument   := (parameter-name ":")? (expression | "?" | "...")

... must be the sole or final placeholder. Ordinary calls do not accept placeholders.

The sequence! construct accepts one or more expressions, with an optional trailing comma:

sequence-expression
                := "sequence" "!" "(" expression ("," expression)* ","? ")"

fresh-expression := "fresh" "!" "(" ")"

Both are construct expressions. See Language Constructs for evaluation and discard rules.

Collection literals use these forms:

tuple-literal   := "(" expression "," ")"
                 | "(" expression "," expression
                   ("," expression)* ","? ")"

vec-literal     := "vec" "[" (vec-item ("," vec-item)* ","?)? "]"
vec-item        := expression | "..." expression
vec-fill        := "vec" "[" expression ";" expression "]"

dict-literal    := "dict" "[" (dict-item ("," dict-item)* ","?)? "]"
dict-item       := expression "=>" expression | "..." expression

See Expressions, Operators and Arithmetic, and First-Class Callables and Partial Function Application.

Match patterns

match-expression
                := "match" "(" expression ")" "{"
                   match-arm ("," match-arm)* ","? "}"

match-arm       := pattern "=>" expression

pattern         := union-pattern
union-pattern   := as-pattern ("|" as-pattern)*
as-pattern      := intersection-pattern ("@" union-pattern)?
intersection-pattern
                := primary-pattern ("&" primary-pattern)*
primary-pattern := variable
                 | prefix-type
                 | "(" pattern ")"
                 | tuple-pattern
                 | vec-pattern
                 | dict-pattern
                 | object-pattern
                 | named-object-pattern

named-object-pattern
                := qualified-name ("<" type-list ">")? object-pattern

tuple-pattern   := "(" pattern ("," pattern)*
                   ("," trailing-pattern)? ","? ")"
vec-pattern     := "vec" "[" (pattern ("," pattern)*)?
                   ("," trailing-pattern)? ","? "]"
dict-pattern    := "dict" "[" (dict-pattern-entry
                   ("," dict-pattern-entry)*)?
                   ("," trailing-pattern)? ","? "]"
dict-pattern-entry
                := (string-literal | signed-integer-literal | "true" | "false")
                   "=>" pattern
object-pattern  := "#{" object-pattern-items? "}"
object-pattern-items
                := object-pattern-entry ("," object-pattern-entry)*
                   ("," "...")? ","? | "..." ","?
object-pattern-entry
                := identifier ":" pattern | variable
trailing-pattern
                := "..." pattern?

Variables bind. Types and literals check. @ requires both patterns to match the same value, as does &. Tuple, vec, dict, and object patterns may nest. Tuple, vec, and dict patterns may end with ... to accept, check, or bind the rest. Object patterns use a bare ... to permit additional public properties.

A pattern after a collection’s ... cannot contain bindings, except for a whole-remainder capture written as $rest or $rest @ pattern, where the pattern on the right contains no bindings. The compiler enforces this rule recursively; parentheses preserve the same meaning.

See Match and Destructuring.

Types

type            := union-type
union-type      := intersection-type ("|" intersection-type)*
intersection-type
                := prefix-type ("&" prefix-type)*
prefix-type     := "!" prefix-type
                 | "=" prefix-type
                 | primary-type

primary-type    := object-shape-type
                 | named-shape-type
                 | built-in-type
                 | named-type
                 | literal-type
                 | range-type
                 | string-length-type
                 | tuple-type
                 | vec-type
                 | dict-type
                 | array-type
                 | callable-type
                 | classname-type
                 | "(" type ")"

named-type      := qualified-name ("<" type-list ">")?
                   ("::" identifier ("<" type-list ">")?)?
                 | "self" ("::" identifier ("<" type-list ">")?)?
                 | "parent"
                 | "static"
named-shape-type := qualified-name ("<" type-list ">")? object-shape-type
vec-type        := "vec" ("<" type ">")? | "vec" "[" shape-items? "]"
dict-type       := "dict" ("<" type "," type ">")?
                 | "dict" "[" dict-shape-items? "]"
array-type      := "array" ("<" type "," type ">")?
callable-type   := "fn" | "fn" "(" callable-parameters? ")" ":" type
classname-type  := "classname" "<" type ">"

type-list       := type ("," type)* ","?
built-in-type   := "null" | "bool" | "int" | "uint" | "float" | "string"
                 | "fresh" | "object" | "mixed" | "never" | "void"
literal-type    := literal | "-" (integer-literal | float-literal)
range-type      := signed-integer-literal (".." | "..=")
                   signed-integer-literal?
                 | (".." | "..=") signed-integer-literal
string-length-type
                := "string" "[" (integer-literal | string-length-range) "]"
string-length-range
                := integer-literal (".." | "..=") integer-literal?
                 | (".." | "..=") integer-literal

tuple-type      := "(" type "," ")"
                 | "(" type "," tuple-type-tail ","? ")"
                 | "(" trailing-type ")"
tuple-type-tail := type ("," type)* ("," trailing-type)?
                 | trailing-type
trailing-type   := "..." type?

shape-items     := type ("," type)* ("," trailing-type)? ","?
dict-shape-items
                := dict-shape-entry ("," dict-shape-entry)*
                   ("," dict-shape-rest)? ","?
dict-shape-entry
                := (string-literal | integer-literal | "true" | "false") "=>" type
dict-shape-rest := "..." ("<" type "," type ">")?
object-shape-type
                := "#{" object-shape-items? "}"
object-shape-items
                := object-shape-entry ("," object-shape-entry)*
                   ("," "...")? ","? | "..." ","?
object-shape-entry
                := identifier ":" type

callable-parameters
                := callable-parameter ("," callable-parameter)* ","?
callable-parameter
                := "="? type

See Runtime Type Checks, Unions, Intersections, and Ranges, and Collection and Callable Types.

Appendix E: Glossary

Artifact

A compiled Whim unit stored in a .whia file.

Array

The common type of tuples, vecs, and dicts.

Attribute

A typed value attached to a declaration, member, or parameter.

Autoloader

Code that tries to define a symbol when Whim first needs it.

Backed enum

An enum whose cases each have an int or string value.

Bound

A type rule that limits a generic type argument.

Callable

A fn value created by a closure expression, first-class callable syntax, or partial function application.

Cancellation token

A value that tells a waiting operation to stop waiting.

Class family

A class and its parent and child classes.

Closure

An unnamed function written with fn. Its body is an expression or block, and it captures the outer variables it uses by value.

Constant expression

A source form allowed in constants and defaults. It cannot read a local variable or $this directly, but a call inside it may run code, inspect state, or throw.

Coroutine

A call stack that may pause and later continue on the event loop.

Dict

A mutable ordered array with bool, int, uint, string, or fresh keys.

First-class callable

A function or method saved as a fn value with foo(...) or $object->method(...).

Future

A read-only view of a value or throwable that will arrive later.

Identity

The rule by which two values refer to the same object or callable.

Interface

A set of methods, properties, constants, and constructor rules that an object must meet.

Literal type

A type that contains one scalar, constant, or enum-case value.

Newtype

A runtime tag placed on a value that must fit a backing type.

Nullable type

A union of null and another type, written null|T.

Partial function application

A call expression that creates a callable with selected arguments left open, such as foo(?, 'fixed').

Reified generic

A generic whose type arguments stay available while the program runs.

Resource

An object whose lifetime code checks with using or drop!.

Sealed family

A class or interface that lists the symbols allowed directly below it.

Strong reference

A reference that keeps an object alive.

Symbol

A named class, interface, enum, function, constant, alias, or newtype.

Task

A cooperatively scheduled async call.

Tuple

An immutable fixed-size array whose positions may have different types.

Type alias

A transparent name for another type.

Type identifier

An engine-local integer used to compare or index runtime types.

Unit enum

An enum whose cases have names but no backing values.

Value semantics

Assignment and argument passing give arrays independent values. The runtime may share their storage until one value changes.

Vec

A mutable dense array with uint keys from zero.

Weak reference

A reference that observes an object without keeping it alive.

Wildcard type

_ inside another type, meaning that a nested type exists but need not match one fixed type.

Appendix F: Frequently Asked Questions

Why does Whim exist?

Whim is an experimental programming language inspired by PHP and Hack. As a research project, it explores language ideas by putting them into practice, with the hope that useful ones can contribute to PHP’s future.

Whim uses syntax from the PHP family. It is not a PHP implementation. It has its own types, arrays, generics, async work, errors, and standard library.

Can I run PHP code as Whim code?

No. Some source may look alike, but the languages have different syntax and runtime semantics.

Why does Whim check types at runtime?

Whim can load code while it runs. Runtime checks keep each declared contract in force after that load. This includes generic arguments, ranges, callable signatures, and collection members.

Why must conditions return bool?

Whim does not guess whether null, a number, a string, or an array means true. Write the test you mean.

Why do arrays use value semantics?

A local array change should not change another variable by accident. The runtime shares storage until one copy changes, so assignment need not copy all items at once.

When should I use null, Option, or Result?

Use null|T when null cannot also be a valid T. Use Option<T> when Some(null) must differ from no value. Use Result<T, E> when failure is data that the caller should inspect. Most library failures throw.

Does Whim use threads?

Normal Whim code runs on one event loop. Tasks can overlap waits, but they do not run Whim code on several CPU cores at once. Separate bounded worker pools run blocking SQLite, file, and operating-system work.

Where is the package registry?

There is none. A Git repository is a package identity. SemVer Git tags are its releases. Whim installs each graph under the current project’s vendor/.

Does whim run load packages on its own?

No. Run reads settings from the manifest, but it does not inspect the lock or vendor directory. Require vendor/autoload.whim from the application.

Will Whim keep old code working?

No. Any release may add, change, or remove language rules and library APIs.

Should I use Whim in production?

No. Whim is an experimental research project. It makes no production support or security guarantees.