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 Praxis Book

Praxis is a small, statically typed, garbage-collected programming language for Advent of Code-style puzzle solving. It is procedural and expression-oriented, it infers essentially every type, and it is built around three things a puzzle solver spends the evening doing: reading a strange input format, manipulating data, and finding out why the program just fell over.

A whole program can be three lines:

var numbers = read lines(int)

out(numbers.sum())
$ praxis run sum.px --input sum.in
60

There is no main, no imports, no annotations. read lines(int) is not a library call — read is an expression in the language and lines(int) is a parser written in a small DSL the compiler checks and compiles alongside the rest of your program. numbers is a Vec[Int] because that is what the parser produces, and the compiler worked that out rather than being told.

The three things this book is mostly about

Reading input. Puzzle input is never a data format anybody would choose. Praxis makes the shape of the file the shape of the code. Structure is written outside backticks, where whitespace does not matter; the literal text of the input is written inside them, where it does:

var moves = read lines(`{dir:word} {amount:int}`)

var horizontal = 0
var depth = 0

for move in moves {
    match move.dir {
        "forward" => { horizontal = horizontal + move.amount }
        "down" => { depth = depth + move.amount }
        "up" => { depth = depth - move.amount }
        _ => {}
    }
}

out(horizontal * depth)
$ praxis run dive.px --input dive.in
150

moves is a Vec of records with a dir field and an amount field. Nothing declared that record; the template’s captures are where it came from. Reading input is the part of the book that covers this, and the cookbook has a recipe for every input shape Advent of Code has thrown so far.

Type inference. Praxis is statically typed and almost none of the types are written down. Inference runs over your whole program including the parsers, so a mistake about the shape of the input is a compile error rather than a surprise at line 400. The editor shows you what it concluded: fn foo(a, b) reads as fn foo(a: Int, b: Int) in VS Code, and you can accept the hint to write the annotation into the file. Type inference covers the model, what generalizes, and how to read the errors when the compiler disagrees with you.

The crash debugger. Praxis has no exceptions and no error handling. An index out of bounds, a missing key, an integer overflow, a parse mismatch or a failed assertion stops the program and hands you the wreckage:

error: program faulted: index out of bounds

Backtrace:
#0   window_sum
#1   <entry>

  locals:
    values: Vec[Int] = [12, 7, 41]
    start: Int = 1
  temps:
    <tmp#3: Int> @ "values[start]" = 7
    <tmp#5: Int> @ "start + 1" = 2
    <tmp#6: Int> @ "values[start + 1]" = 41
    <tmp#9: Int> @ "start + 2" = 3

Those are not only the locals — they are the intermediate values of the expression that faulted, each labelled with the source text it came from. In a terminal you get a prompt instead of an exit code, and can walk the frames, print expressions against the captured state, look at the input near the parser’s cursor, then fix the file and reload without losing your input. The crash debugger is the part of the book that covers it.

What Praxis is not

It does not build standalone binaries; the compiler and the runtime are one executable and your program is JIT-compiled every time you run it. There is no ownership, no lifetimes, no manual memory management. There are no user-visible traits, no operator overloading, no macros, and no exceptions. There is no concurrency. There is no package registry.

These are deliberate, and when two of the goals conflict there is an order that settles it: correctness and diagnostics first, then input ergonomics, then edit-run-debug speed, then language simplicity, and only then runtime performance.

How this book is arranged

  • Getting started — build the compiler, run a program, learn the command surface.
  • The language — the whole surface, from bindings to pattern matching to the collection set, with the prelude and the method catalog as reference tables at the end.
  • Reading input — the read expression and its DSL.
  • Type inference — what the compiler works out, and why it sometimes will not.
  • The crash debugger — the fault model, and the prompt you get instead of an exit code.
  • Tooling — the language server, the VS Code extension, and an index of every diagnostic code.
  • Under the hood — for the reader who wants to change the compiler rather than use it.
  • Appendix A has complete programs; Appendix B has the grammar.

Every example here was run

No code block in this book that shows a program and its output was written by hand. Each one is a real file under docs/book/examples/, and

docs/book/examples/verify.sh

re-runs every one of them against the compiler in this repository and diffs the result against the output printed in the chapter. That includes the programs that are supposed to fail: the diagnostics and the debugger transcripts are captured the same way. If the language changes, this book breaks loudly.

Installing Praxis

Praxis is built from source. There are no binary releases and no installer: you need a Rust toolchain, and at the end of it you have a praxis binary that JIT-compiles .px files. cargo will fetch and build it for you, or you can build it from a checkout.

What you need

A stable Rust toolchain. The repository pins one in rust-toolchain.toml:

[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]

rustup reads that file when you run any cargo command inside the checkout, so it selects — and if necessary installs — the right toolchain for you. The workspace declares rust-version = "1.88" as its minimum — the floor its own dependencies impose — and anything newer on the stable channel is fine.

Nothing else is required to build the compiler. Praxis links Cranelift for code generation, so there is no LLVM to install.

Installing from crates.io

The compiler is published as fifteen crates and the binary lives in praxis-cli, which depends on the other fourteen:

$ cargo install praxis-cli

That builds in release mode and puts praxis in ~/.cargo/bin — worth having on your PATH, because the VS Code extension looks for it there by default. The crate is praxis-cli and the binary is praxis; the bare name praxis on crates.io belongs to an unrelated project.

Everything below is for building from a checkout instead, which is what you want if you are working on the compiler itself.

Building the compiler

The praxis binary is the praxis-cli crate. From the repository root:

$ cargo build --release -p praxis-cli

That produces target/release/praxis. Confirm it works:

$ ./target/release/praxis --version
praxis 0.1.0

cargo build -p praxis-cli without --release writes target/debug/praxis instead. The debug binary is a complete compiler and builds a good deal faster, which matters while you are working on the compiler itself. Use the release binary for anything you care about the running time of: the code generator and the GC heap are ordinary Rust, and an unoptimized build of them is unoptimized.

To put the binary you just built on your PATH, install from the checkout rather than from the registry:

$ cargo install --path crates/praxis-cli

That builds in release mode and copies the binary into ~/.cargo/bin, overwriting whatever cargo install praxis-cli put there.

The just runner

The repository’s quality gate is a just file. You do not need just to build or use the compiler, only to run the checks the way CI runs them.

$ cargo install just

The recipes are small, and just with no arguments lists them:

recipewhat it does
just buildcargo build --workspace
just testcargo test --workspace
just clippycargo clippy --workspace --all-targets -- -D warnings
just fmtcargo fmtmodifies files
just fmt-checkcargo fmt --check
just bookrenders the book to docs/book/book
just book-verifyre-runs every example in the book against this compiler
just cifmt-check, clippy, test, then the book’s examples
just asanthe whole suite under AddressSanitizer, on a nightly toolchain
just publish-planthe crates.io publish order, and what is already up
just publish-drypackages every crate and builds it from its own tarball

just ci is the gate, and the point of it is that it is the only gate: the hosted CI job checks out the tree, installs the toolchain, installs just, and runs just ci. It has no logic of its own, so what CI does and what you do before pushing cannot drift apart. fmt is deliberately not a dependency of ci — CI verifies formatting, it never rewrites your files. Neither is book-bless, which rewrites the book’s expectations from whatever the compiler prints today and would paper over the regression book-verify exists to catch.

Two things about just ci are worth knowing before you run it the first time.

It takes about seventeen minutes on a development laptop, and most of that is not compilation. On macOS the bulk is XProtect scanning each freshly linked test binary the first time it is executed.

Doctests are not part of it. Every library crate sets doctest = false. An example in a /// comment is still compiled by cargo doc, but it is never executed, so an assertion written there checks nothing — put it in a unit test. The reason is cost, not principle: rustdoc --test has to analyze a whole crate before it can discover that the crate has no doctests, that work is never cached, and it was costing 95 seconds a run to execute the single doctest the workspace had.

just asan needs a nightly toolchain (rustup toolchain install nightly) and is not part of ci, because an instrumented build is a second full compile of the workspace; hosted CI runs it on a nightly schedule instead. It does not cover JIT-generated code — Cranelift emits that raw and no -Z flag reaches it — so a green ASan run is necessary and not sufficient for a change that puts new unsafe behaviour in generated code.

The VS Code extension

The extension lives in editors/vscode. It is thin on purpose: it registers the .px extension, launches praxis lsp, contributes three commands and a TextMate grammar for highlighting before the server attaches. There is no parsing and no type logic in it. Everything the editor knows about your program arrives from the compiler over the protocol, so the two cannot disagree about what a file means.

Build it, then package it:

$ cd editors/vscode
$ npm install
$ npm run compile
$ npx @vscode/vsce package

That writes praxis-0.1.0.vsix in the same directory. Install it from the Extensions view’s menu → Install from VSIX…, or from a shell:

$ code --install-extension editors/vscode/praxis-0.1.0.vsix

The .vsix is gitignored, and reinstalling the same version replaces the previous one.

Then point the extension at your binary. The setting is praxis.binaryPath; it defaults to the bare name praxis, resolved on PATH. If you did not run cargo install, set it to an absolute path to target/release/praxis. That one path is used for the language server and for the run and check commands, so there is a single thing to get right; changing it restarts the server.

If the server cannot start, you get an error message naming the command it tried and the setting to change, rather than a stack trace. It is nearly always a praxis.binaryPath that points at nothing.

The three commands are Praxis: Run File, Praxis: Check File and Praxis: Restart Language Server. The first two save the buffer and then run the binary in an integrated terminal — a terminal rather than an output channel, because the crash debugger is interactive and a write-only pane cannot answer a prompt. Run File appends --input input.txt when a file by that name sits beside the source.

Everything else arrives without the extension contributing anything, because it is a server capability: diagnostics, hover, completion, signature help, go-to-definition, document symbols, semantic tokens, find references, rename, workspace symbols, inlay hints and quick fixes. See Editor support for what each of them does.

One default is worth knowing about. Inlay hints are on, so an unannotated binding or parameter shows the type the compiler inferred, and ?T where inference has not pinned one; accepting a hint writes the annotation into the file wherever that is legal.

Other editors

Any LSP client can drive praxis lsp — it speaks JSON-RPC over stdio and takes no arguments. Clients that append --stdio to the server’s argv are fine: the flag is accepted and ignored, because stdio is the only transport there is.

Running the book’s examples

Every program in this book that is shown together with its output is a real file under docs/book/examples/, and docs/book/examples/verify.sh re-runs all of them against the expectations printed here:

$ ./docs/book/examples/verify.sh getting-started

6 ok, 0 failed

If a chapter and the compiler ever disagree, that script is what says so.

Your first program

A Praxis program is one file with a .px extension. It has no imports, no main and no module declaration — the top-level statements in the file are the program.

out("Hello, Praxis!")

Save that as hello.px and run it:

$ praxis run hello.px
Hello, Praxis!

out is the print function; it writes its argument and a newline to standard output. run parses, type-checks, lowers, JIT-compiles and executes in one step — there is no separate build product and nothing lands on disk.

A program that reads its input

Praxis exists to solve puzzles that arrive as a text file, so the interesting first program reads one. Here is the sonar-sweep problem from Advent of Code 2021 day 1: given a list of depth measurements, count how many are larger than the one before.

The input is a file of integers, one per line — call it sonar.in:

199
200
208
210
200
207
240
269
260
263

And the first draft of the program:

var depths = read lines(int)

var increases = 0
for i in 1..depths.length() {
    if depths.get(i) > depths.get(i - 1) {
        increases = increases + 1
    }
}

out(increase)

Three things are worth naming before we run it. var is the only binding form, and every binding is assignable; there is no let, and no mut either — a parameter, a for variable and a name bound by a pattern are all writable too. read lines(int) is an input parser, not a library call — lines(int) is a shape the compiler understands, and the type of depths is derived from it as Vec[Int] rather than declared. And 1..depths.length() is a half-open range, so it stops one short of the end, which is what a loop that looks backwards wants.

Getting it wrong

$ praxis run sonar-draft.px --input sonar.in
error[Y110]: no method `length` on type `Vec[Int]` taking 0 argument(s)

  sonar-draft.px:4:20
  4 | for i in 1..depths.length() {
    |                    ^^^^^^ no method `length` on type `Vec[Int]` taking 0 argument(s)

error[N001]: `increase` is not defined

  sonar-draft.px:10:5
  10 | out(increase)
     |     ^^^^^^^^ `increase` is not defined

help: did you mean `increases`?
      increases

praxis: 2 error(s)

Two mistakes, both reported. That is the normal case: analysis does not stop at the first error, so one run tells you everything the front end knows.

Read one diagnostic and you can read all of them. It opens with a severity and a code — Y110 is a type error, N001 a name-resolution error, and the letter says which phase found it. Then the file, line and column, then the source line with the exact span underlined. help: is a suggestion, and where the compiler is confident enough to write the replacement — as it is for increase here — the same suggestion is the quick fix your editor offers. Every code is listed in Diagnostic codes.

The compiler declined to run the program at all. Nothing was JIT-compiled and nothing was executed: a file with an error in it never reaches the back end, so you cannot get partial output from a program that does not type-check. praxis check sonar-draft.px prints exactly the same report and skips even trying.

Note also what the first error did not say. There is no “did you mean len?” under length. A near miss is offered when it is within an edit distance of max(1, n / 3) for a name of n characters — so length, at six characters, gets a budget of two, and len is three edits away. A suggestion that fires too eagerly is worse than none, because an editor that offers to rewrite x as y teaches you to stop reading the quick-fix list.

Getting it right

length is spelled len, and the variable is increases:

var depths = read lines(int)

var increases = 0
for i in 1..depths.len() {
    if depths.get(i) > depths.get(i - 1) {
        increases = increases + 1
    }
}

out(increases)
$ praxis run sonar.px --input sonar.in
7

--input FILE is one of two ways to feed a program. The other is standard input, which is what you get when you leave the flag off:

$ praxis run sonar.px < sonar.in
7

They differ in one respect that matters when something goes wrong: --input is read up front, so an unreadable file is reported before your program starts, while standard input is not read until the program’s first read actually evaluates. A program with no read in it never touches stdin at all.

The shorter way

The loop above is the one you would write in any language. Praxis would rather you wrote a pipeline:

var depths = read lines(int)

out(depths.zip(depths.skip(1)).count(|pair| pair.1 > pair.0))
$ praxis run sonar-pipeline.px --input sonar-pipeline.in
7

skip(1) drops the first measurement and zip pairs the two sequences positionally, stopping at the shorter — so each pair is a measurement and the one after it. |pair| pair.1 > pair.0 is a closure over the resulting tuple, and count answers how many satisfy it. There is no .collect() at the end because there is nothing to collect: every stage materializes, so the chain already is a value. See Pipelines.

When a program compiles and still goes wrong

A clean praxis check means the types work out, not that the program does. Division by zero, an index past the end of a Vec, integer overflow and a read that does not match its input are all runtime faults:

var total = 10
var n = 0

out(total / n)

Run that on a terminal and Praxis drops you into an interactive debugger at the faulting instruction, with the locals still alive. Run it anywhere else — a pipe, a CI job, a --debug never — and it prints the same state noninteractively and exits 1:

$ praxis run divide-by-zero.px --debug never
error: program faulted: division by zero

Backtrace:
#0   <entry>

  locals:
    total: Int = 10
    n: Int = 0
  temps:
    <tmp#1: Int> @ "10" = 10
    <tmp#3: Int> @ "0" = 0
    <tmp#5: Int> @ "total / n" = <uninit>
    <tmp#6: Unit> @ "out(total / n)" = <uninit>

The temps are the compiler’s own intermediate values, each labelled with the expression that produced it, and <uninit> marks the ones the fault stopped from ever being assigned — which is how you find the instruction that failed. The fault model explains the fault kinds, and Entering the debugger explains when you get a prompt.

Where to go next

The three commands you will use are run, check and — through your editor — lsp; The command line is the complete surface, including the exit codes. A file is a program explains what the top level really is, and why a fn main is just another function. And The read expression is the part of Praxis that most repays reading early: lines(int) is the simplest shape it has, and the puzzle input you are about to paste in is probably not that shape.

The command line

praxis has three commands — run, check and lsp — and one global flag, --color. The only other flags are run’s --input and --debug, and lsp’s --stdio.

$ praxis --help
Praxis is a small, statically typed, garbage-collected language for Advent of Code-style puzzle solving.

Homepage: https://github.com/tljubej/praxis

Usage: praxis [OPTIONS] <COMMAND>

Commands:
  run    Parse, type-check, JIT-compile, and run the program
  check  Run the front end (lex + parse + type-check) without executing
  lsp    Start the language server over stdio. Speaks JSON-RPC LSP on stdin/stdout; not meant to be run by hand
  help   Print this message or the help of the given subcommand(s)

Options:
      --color <COLOR>
          When to color diagnostic output: `auto` (default) colors iff stderr is a terminal; `always` forces color; `never` emits plain text
          
          [default: auto]

  -h, --help
          Print help (see a summary with '-h')

  -V, --version
          Print version

praxis run

praxis run <FILE> [--input <FILE>] [--debug auto|always|never] [--color auto|always|never]

run is the whole pipeline: lex, parse, resolve, infer, lower to typed HIR, monomorphize, lower to MIR, verify, JIT-compile with Cranelift, execute. Nothing is written to disk and there is no build artifact to clean up.

$ praxis run hello.px
Hello, Praxis!

If the front end finds an error, nothing is compiled and nothing runs:

$ praxis run sonar-draft.px --input sonar.in
error[Y110]: no method `length` on type `Vec[Int]` taking 0 argument(s)

  sonar-draft.px:4:20
  4 | for i in 1..depths.length() {
    |                    ^^^^^^ no method `length` on type `Vec[Int]` taking 0 argument(s)

error[N001]: `increase` is not defined

  sonar-draft.px:10:5
  10 | out(increase)
     |     ^^^^^^^^ `increase` is not defined

help: did you mean `increases`?
      increases

praxis: 2 error(s)

What gets printed, and where

A program’s own output goes to stdout. Everything the compiler says goes to stderr. That split holds for diagnostics, for the runtime-fault report and for the crash debugger’s prompt. So praxis run day05.px > answer.txt captures the answer and still shows you the errors, and 2>/dev/null gets you the answer and nothing else.

Everything a program reports, it prints itself. run adds nothing of its own to stdout — there is no result line, because a file’s top level has no answer value:

var values = read lines(int)
out(values.len())
$ praxis run count-lines.px --input count-lines.in
6

That 6 is the out call and nothing else — which is why out(...) at the top level never echoes its argument twice. A file is a program has the rule in full.

--input FILE

Read the process input from a file rather than from standard input.

$ praxis run sonar.px --input sonar.in
7
$ praxis run sonar.px < sonar.in
7

The two are equivalent to the program and differ in when the bytes are read. --input is read eagerly, before the program starts, so an unreadable path is reported before any output is produced:

$ praxis run sonar.px --input nope.txt
error: failed to read input file `nope.txt`: No such file or directory (os error 2)
$ echo $?
2

Standard input is read lazily, by the program’s first read and never before. A program with no read in it never touches stdin, which is what stops praxis run hello.px from blocking forever on a terminal or on a CI harness that is holding the pipe open. A terminal stdin reads as empty rather than waiting for a human who was not asked for anything.

An I/O error on either path is reported and exits 2. It is never laundered into empty input: a truncated read would otherwise produce a confidently wrong answer. An empty file, on the other hand, is input — a zero-byte --input is passed through as the empty text, and what a parser makes of that is the parser’s business. read lines(int) over nothing is []; a parser that needs content faults at offset 0..0 and says what it expected to find there.

--debug auto|always|never

What to do when the program faults at run time — division by zero, an index out of bounds, integer overflow, a failed read, an explicit panic.

valuebehaviour
auto (default)enter the interactive crash debugger iff stdin and stdout are both terminals
alwaysenter the debugger regardless, reading commands from stdin
neverprint the noninteractive fault report and exit

The default is the useful one at a keyboard and the safe one everywhere else: piped, redirected or run from a script, auto behaves as never, so nothing ever hangs waiting on a prompt nobody can see.

$ praxis run divide-by-zero.px --debug never
error: program faulted: division by zero

Backtrace:
#0   <entry>

  locals:
    total: Int = 10
    n: Int = 0
  temps:
    <tmp#1: Int> @ "10" = 10
    <tmp#3: Int> @ "0" = 0
    <tmp#5: Int> @ "total / n" = <uninit>
    <tmp#6: Unit> @ "out(total / n)" = <uninit>

That report is the same state the interactive debugger would show you, printed once instead of offered as a prompt. --debug always is how you drive the debugger from a script: it reads its commands from stdin, which is what this book’s own debugger examples do. See Noninteractive mode and Entering the debugger.

praxis check

praxis check <FILE> [--color auto|always|never]

The front end only: lex, parse, resolve names, infer types, check match coverage. No code is generated and the program is not run, so it is the fast answer to “does this file make sense”, and it is the command an editor’s save-hook wants.

On success it prints nothing:

$ praxis check sonar.px
$ echo $?
0

check and the language server are not two implementations of the front end. praxis check routes through the same query layer the LSP server answers from, so a divergence between what the command line prints and what your editor underlines is unrepresentable rather than merely unlikely. The sort order, the decision to analyze a tree that already has parse errors, and the set of diagnostics that reaches you are settled once, inside the query, and both consumers read them from there.

run performs the same analysis before it compiles anything, so a file check rejects is a file run refuses, with the same text. The reverse does not hold, and in two ways. check cannot tell you about a fault that only happens when the program runs. It also stops one pass earlier than run does: lowering to typed HIR reports a handful of errors of its own — Y013 for an integer literal outside the range of Int, Y125 for a for or closure binding that can fail to match — and those reach you from praxis run only. Not from check, and not from the editor either, which publishes the same query layer’s diagnostics. A clean check means the front end is satisfied, not that the file compiles.

praxis lsp

praxis lsp [--stdio]

The language server. It speaks JSON-RPC over stdin and stdout and is not meant to be run by hand — the VS Code extension launches it, and so will any other LSP client you point at the binary. Run it in a terminal and it will sit waiting for a protocol header, then exit 1 when its stdin closes.

--stdio is accepted and ignored. Several clients append it to the server’s argv to select a transport, and stdio is the only transport this server has, so the flag names something already true. It exists because the alternative was exiting 2 on an argument the convention says is harmless — before a byte of protocol was spoken — which every client reports as “the server crashed” rather than as a bad flag.

The server is one synchronous loop with no async runtime. Its working set is a single file and the front end answers in single-digit milliseconds, so there is nothing for a second thread to do: the loop owns the document store and the query cache outright, and holds no lock, because there is no other thread that could want one. A $/cancelRequest drops a request still sitting in the queue; one already being served runs to completion. Editor support lists what it serves.

--color auto|always|never

Global: it may be given before or after the subcommand. It styles diagnostics throughout, and the error: label of the fault report. The rest of that report — the backtrace, the locals, the temps — and every line the crash debugger prints once it has a prompt are plain text whatever you pass.

valuebehaviour
auto (default)style output iff stderr is a terminal
alwaysstyle even when piped
neverplain text

--color never is what you want when capturing output for a test or a transcript. auto already does the right thing when you redirect — the ANSI codes are omitted because stderr is not a terminal — so never is for the case where stderr is a terminal and you want plain text anyway.

Exit codes

codemeaning
0success — the program ran to completion, or check found no errors
1the file has errors and was not run, or it ran and faulted
2usage or I/O — bad flag, unknown subcommand, missing argument, unreadable source or --input file

1 covers both “did not compile” and “compiled and then died”, which are distinguishable from the output but not from the status. If you need to tell them apart in a script, run praxis check first: it returns 1 for exactly the first case.

An internal compiler error — a MIR verifier failure, a JIT failure — also exits 1, with a message that begins internal error: or error: JIT compilation failed. Those are compiler bugs, not program errors.

A file is a program

A .px file is a whole program. Its top-level statements are what runs, in source order, and there is no entry-point ceremony to write around them.

out("first")

fn double(n) { n * 2 }

var answer = double(21)
out(answer)

struct Point { x: Int, y: Int }

out(Point { x: 1, y: 2 })
first
42
{ x: 1, y: 2 }

Statements and declarations may be interleaved. The compiler collects the statements out from between the declarations into one generated function, and that function is what the host calls; the declarations stay where they are, as their own items. That is why a fn may appear after code that calls it, and why a var may not — a declaration is visible everywhere in the file, a binding only after the statement that introduces it. (A struct and an enum are declarations too, and are visible everywhere on the same terms.)

A program has no answer value. Every top-level statement runs for effect, the generated entry function is Unit, and what a program reports is whatever it printed with out — which is also what keeps out(x) at the top level from printing twice.

There is no main

main is not a name the language knows. A fn main is an ordinary function, and nothing calls it for you:

fn main() {
    out("main ran")
}

out("the top level ran")
the top level ran

Write main() yourself if you want it run. A file that declares one and never calls it has no program at all — and because that is the shape other languages ask for, praxis run names both ways out of it:

fn main() {
    out("main ran")
}
error: no statements to run
note: this file declares `fn main`, but a Praxis program is its top-level statements — call it with `main()`, or move its body to the top level

A file of declarations that never mentions main gets the first line alone:

fn helper(n) {
    n + 1
}
error: no statements to run

praxis check accepts both files and exits 0. Having nothing to run is not a type error; it is discovered by the thing that wanted to run it.

A function does not see the bindings around it

Top-level bindings are the natural style for a file that is its own program, and they are the one thing a fn cannot reach. A function is not a closure and captures nothing:

var limit = 10

fn over_limit(n) {
    n > limit
}

out(over_limit(11))
error[N007]: `over_limit` cannot use `limit`: a function does not capture the bindings around it (pass `limit` as a parameter, or use a closure)

  fn-does-not-capture.px:4:9
  4 |     n > limit
    |         ^^^^^ `over_limit` cannot use `limit`: a function does not capture the bindings around it (pass `limit` as a parameter, or use a closure)

praxis: 1 error(s)

The message names both fixes, when both exist — a recursive function is told to pass a parameter and nothing else, because a closure cannot name itself. A closure written at the top level does capture; see functions and closures.

A fn cannot be declared inside another fn either — that is N005, and a struct or enum in a function body gets the same code. Declarations live at the top level; everything else is a statement.

A newline ends a statement

Statements are separated by newlines or by ;, and a semicolon is only required when two of them share a line.

// A line comment runs to the end of the line.
/* A block comment /* nests */ and may span lines. */

var a = 1; var b = 2; out(a + b)

var total = 1 +
    2 +
    3
out(total)

var scaled = [3, 1, 2]
    .sorted()
    .map(|n| n * 10)
out(scaled)

fn one() { 1; }
out(one())
3
6
[10, 20, 30]
1

A newline terminates a statement and never an operator chain. It is not consulted anywhere in the operator loop, so 1 + continues onto the next line and a .method() chain runs down as many lines as you like. A trailing ; is a separator and nothing else: fn one() { 1; } still answers 1.

Two statements adjacent on one line with neither separator is P002:

var a = 1 var b = 2
out(a + b)
error[P002]: expected `;` or a line break between statements

  run-on.px:1:11
  1 | var a = 1 var b = 2
    |           ^^^ expected `;` or a line break between statements

praxis: 1 error(s)

A newline is consulted at break and return too: a line break after the keyword means “no value”, whatever the next token is. return on its own line returns nothing and the line below it is a separate statement. It also stands in for the comma between struct fields, between enum variants and between match arms — but not inside a record literal, where the comma is required.

The two line-leading brackets

A ( and a [ each begin something and continue something, and the newline is what breaks the tie: one asked to continue the expression before it does not do so across a line break. So a call whose callee ends a line and whose argument list begins the next is two expressions rather than a call:

fn double(n) { n * 2 }

var doubled = double
(21)

out(doubled)
<closure:0>

doubled is the function itself; (21) is a parenthesized 21, evaluated and thrown away. Nothing is reported, because nothing is ill-formed — that is the cost of the rule, and it is paid so that a match arm beginning (a, b) => on its own line is an arm rather than an argument list for the arm above it. The same tie-break settles p.x followed by a line-leading (: that is a field read and a parenthesized expression, not a method call.

[ behaves the same way, and has to, because a line-leading [ is a list literal:

var rows = [[1, 2], [3, 4]]

var first = rows
[0]

out(first)
out(rows[0])
[[1, 2], [3, 4]]
[1, 2]

first is the whole of rows, and [0] is a one-element list nobody kept. The fix for both traps is the same: move the bracket up onto the previous line.

Source text

Source is UTF-8. Identifiers may be Unicode — ASCII is the recommendation, not the rule — and text is measured in characters, not bytes.

var π = 3.14159
var größe = "Fjörð"

out(π)
out(größe.len())
3.14159
5

// starts a line comment. /* … */ is a block comment and nests, so commenting out a region that already contains a comment works. An unterminated one is T001 rather than a silently swallowed file.

The keywords are var, fn, if, else, while, for, in, loop, match, return, break, continue, read, struct, enum, true and false. That is the whole list. out, panic, Vec, max and the rest of the prelude are ordinary identifiers that happen to be defined, and so are the builtin type names Int, Text, Bool, Char, Float, Unit and Nevervar max = 5 is legal and shadows the builtin — and so is let, which is an ordinary identifier rather than a keyword. See bindings and shadowing.

Reading input

A program that reads its input does so with a read expression, usually as the first top-level statement:

var numbers = read lines(int)

out(numbers.sum())

Given 1, 2 and 3 on three lines:

6

The input file is named with --input, or arrives on stdin. The read expression is a small language of its own and has its own chapter; lines and int are that language’s vocabulary and not the program’s, so outside a read expression neither name is defined.

The generated function has a name you cannot write

It is <entry>, which is not an identifier, so no program can declare a second one and no program can call it. You meet it when a top-level statement faults:

var n = 0
out(10 / n)
error: program faulted: division by zero

Backtrace:
#0   <entry>

  locals:
    n: Int = 0
  temps:
    <tmp#1: Int> @ "0" = 0
    <tmp#3: Int> @ "10" = 10
    <tmp#4: Int> @ "10 / n" = <uninit>
    <tmp#5: Unit> @ "out(10 / n)" = <uninit>

The angle brackets are the point: frame #0 is the file, not something anybody wrote. Everything else about that frame is ordinary — the entry point goes through inference, monomorphization and the backend as a nullary Unit function like any other, so its locals are inspectable and a top-level call to a generic function specializes exactly as a call from a fn body does. What you see above is the non-interactive rendering, from praxis run --debug never. At a terminal the default --debug auto drops you into the crash debugger, stopped at that frame with those locals live.

Bindings and shadowing

var is the language’s one binding form. It introduces a name, that name may be reassigned, and the type it was inferred at is the type it keeps. There is no second keyword and no immutable binding class.

var score = 0
score += 10
score = 25
out(score)

var name: Text = "praxis"
out(name.len())

var seen: Vec[Int] = Vec()
seen.push(3)
out(seen)
25
6
[3]

The : Text and : Vec[Int] are optional. Inference reads the type off the initializer, and off the later uses when the initializer leaves it open — bare Vec() is fine, and the first push decides the element type. Writing the annotation pins the type at the declaration instead, which moves the error to the line that disagrees with you rather than the line after it.

let does not exist

let is not a keyword. The distinction it would draw is inferred rather than declared, and the word is not reserved either, so it is an ordinary identifier the compiler has never heard of.

let x = 5
out(x)
error[N009]: `let` is not a keyword; a binding is written with `var`

  let-is-gone.px:1:1
  1 | let x = 5
    | ^^^ `let` is not a keyword; a binding is written with `var`

help: replace it with `var`
      var

N009 is its own code, and the reason is the fix. let is not a misspelling of anything, so the near-miss search that answers totl with total has nothing useful to say about it: the budget is one edit for a three-letter name, and the name one edit away is Set. The rule is right in general and wrong for this word, so this word is answered before the search runs.

That is the first of four errors from those two lines: let and x run together with no separator (P002), and x is then never declared, so both mentions of it are N001. var let = 5 compiles, if you want the word — which is why this is reported where a statement starts rather than in the lexer.

Assignment keeps the type

Reassignment writes a new value into an existing binding. It never re-runs inference, so the value has to have the type the binding already has:

var score = 0
score = "high"
error[Y001]: expected Int, found Text

  retype.px:2:1
  2 | score = "high"
    | ^^^^^ expected Int, found Text

praxis: 1 error(s)

The span is on the target, not the value: the binding is the thing with the expectation.

The compound operators

There are five — +=, -=, *=, /= and %= — and each is its binary operator’s rule applied to a place. n += 1 is n = n + 1, so what the compound accepts is what the operator accepts, and the right-hand side types against the binding rather than being inferred on its own.

// Each compound is its binary operator applied to a place.
var n = 10
n += 3
n -= 2
n *= 4
n /= 3
n %= 5
out(n)

var f = 10.0
f += 3.0
f -= 2.0
f *= 4.0
f /= 4.0
out(f)

var s = "a"
s += "b"
out(s)
4
11.0
ab

Two consequences fall straight out of “it is the binary operator”:

  • %= is Int-only, because % is. f %= 2.0 is Y016, the same refusal f % 2.0 gets. The other four are defined for Float.
  • += on a Text is concatenation, because + is. It is the one compound that does not require a number.

Everything else needs a numeric target, and Y010 is the error when it does not get one. The operators are statements and not expressions, so var x = (n += 1) does not parse — see the grammar.

n = n + 1 is the rule and not the lowering: a target that is a field or an element is evaluated once, not once to read and again to write.

Every binding is assignable

A function parameter, a for loop’s variable and a name introduced by a pattern are bindings in exactly the sense a var is, and all of them may be written:

fn clamp_low(n) {
    if n < 0 { n = 0 }
    n
}

out(clamp_low(-3))
out(clamp_low(7))

for i in 0..3 {
    i = i * 10
    out(i)
}

var total = 0
for (a, b) in [(1, 2), (3, 4)] {
    a = a * 100
    total += a + b
}
out(total)
0
7
0
10
20
406

Writing a for variable changes this step and nothing else — the next step rebinds it from the sequence. Writing a parameter changes the callee’s binding and nothing at the call site; see the binding and the object below for what is shared.

Shadowing

A later var may shadow an earlier binding of the same name in the same scope. This is not reassignment: it allocates a new binding, with a new symbol, and the new one may have an unrelated type.

var a = 4
var a = "Foo"
out(a)

var b = 4
var b = b + 1
out(b)

var c = 4
var show_old = || out(c)
var c = "Foo"
show_old()
out(c)
Foo
5
4
Foo

Three rules are in that program. The name resolves to the newest binding declared above the use, so out(a) is the Text. A shadowing initializer is resolved in the environment before the new binding enters scope, so the b on the right of var b = b + 1 is the old Int — this is Rust’s rule and the same trap when you meant to assign. And a closure made before a shadowing declaration keeps the binding it captured, so show_old still prints 4 after c has become a Text.

Shadowing is the only way to rebind a name at a new type. If you want the Text, shadow; if you want the same Int with a new value, assign.

The compiler decides the storage

Removing let removed two decisions the programmer used to make by choosing a keyword. The compiler makes them now, from one fact name resolution can see: whether anything ever writes the binding.

Generalization

A binding nothing writes is generalized, under the usual value restriction. A closure bound to such a name is generic and each use instantiates it fresh:

var id = |x| x

out(id(1))
out(id("text"))
1
text

Add one assignment to id and the same program stops compiling:

var id = |x| x
id = |x| x

out(id(1))
out(id("text"))
error[Y001]: expected (Int) -> Int, found (Text) -> ?T

  reassigned-not-generic.px:5:5
  5 | out(id("text"))
    |     ^^^^^^^^^^ expected (Int) -> Int, found (Text) -> ?T

praxis: 1 error(s)

id is monomorphic, out(id(1)) pinned it to (Int) -> Int, and the Text call is the error. This gate is a soundness requirement rather than a convenience: assignment instantiates a scheme and unifies the copy, so a generalized binding would not be constrained by being written, and id = |n| n + 1 followed by id("s") would type-check and reach the backend as a wrong-type call. Generalization covers the value restriction itself.

Capture

A captured binding that something writes is boxed into a GC-managed cell, so the closure observes the write. One that nothing writes is copied into the closure’s environment, which is cheaper. The choice is the compiler’s, made from the same fact:

var n = 1
var show_n = || out(n)
n = 2
show_n()

var fns = Vec[() -> Int]()
for i in 0..3 {
    i = i * 10
    fns.push(|| i)
}
for f in fns {
    out(f())
}
2
0
10
20

show_n prints 2, not 1: it shares n with the code that wrote it. The loop shows the other half of the rule — boxing is per binding event, not per name. A for variable is a fresh binding each step, so the closure made on step i keeps step i’s value even though the variable is assigned inside the loop.

The binding and the object

Rebinding a name and mutating an object are separate operations, and only the second is visible to anyone else. Passing an argument copies a reference: the callee’s parameter is its own binding pointing at the caller’s object.

fn rebind(xs) {
    xs = [9, 9]
    xs
}

fn mutate(xs) {
    xs.push(9)
}

var values = [1]
out(rebind(values))
out(values)

mutate(values)
out(values)
[9, 9]
[1]
[1, 9]

rebind writes its own binding and the caller’s values is untouched. mutate writes the object both names refer to, and the caller sees it.

Places: fields and elements

An assignment target may be a name, a field, or an index. A field or element store writes into an object; it is the second kind of write above, not a rebinding.

struct Point { x: Int, y: Int }

var p = Point { x: 1, y: 1 }
p.x = 5
p.y += 2
out(p)

var xs = [1, 2, 3]
xs[0] = 100
xs[2] += 1
out(xs)

var counts = Counter[Text]()
counts["a"] += 1
counts["a"] += 1
out(counts["a"])
{ x: 5, y: 3 }
[100, 2, 4]
2

A compound operator evaluates its place once. p.x += 1 loads and stores through the same receiver, so pick(log).x += 1 calls pick a single time.

A sequence store replaces and never appends. xs[xs.len()] = v is a fault, not a push:

var xs = [1, 2, 3]
xs[3] = 4
error: program faulted: index out of bounds

(followed by the backtrace described in a file is a program). Use push when you meant to grow the vector. Vec, Deque, Map, Counter and Grid accept an indexed store; Text is the one subscript you can read and not write, because a Text is an immutable payload with nothing to write through. A tuple element is not a place either:

var t = "abc"
t[0] = "z"

var pair = (1, 2)
pair.0 = 5
error[Y020]: values of type `Text` cannot be assigned through 1 index(es)

  not-a-place.px:2:1
  2 | t[0] = "z"
    | ^^^^ values of type `Text` cannot be assigned through 1 index(es)

error[Y021]: the left side of an assignment must be a name, a field, or an index

  not-a-place.px:5:1
  5 | pair.0 = 5
    | ^^^^^^ the left side of an assignment must be a name, a field, or an index

praxis: 2 error(s)

Y021 is also what you get for f() = 1 — a target that names no storage at all. Rebuild the tuple instead, or use a record, which is the thing in this language with named, writable fields.

Scalars

Praxis has six scalar types: Int, Float, Bool, Char, Text and Unit. They are the leaves of every value the language builds — the elements of a Vec[Int], the keys of a Map[Text, Int], the fields of a record. All six have a literal you can write.

TypePayloadWritten as
Intsigned 64-bit42, 1_000_000
FloatIEEE-754 binary643.5, 1e10, 2e-3
Booltrue or falsetrue, false
Charone Unicode scalar value'p'
Textimmutable UTF-8"praxis"
Unitnothing()
// One value of every scalar type the language has.
var n: Int = 42
var f: Float = 3.5
var b: Bool = true
var t: Text = "praxis"
var c: Char = 'p'
var u: Unit = ()

out(n)
out(f)
out(b)
out(t)
out(c)
out(u)
42
3.5
true
praxis
p
Unit

The annotations are optional — each of those types is inferred from the initializer. See Bindings and shadowing.

One further name is legal in type position. Never is the type of an expression that produces no value (panic(...), return, break); see Control flow.

Literals

// Int literals, with `_` allowed between digits.
out(42)
out(1_000_000)

// Float literals: a fraction, an exponent, or both. `.5` is not one.
out(0.5)
out(3.141_592)
out(1.5e3)
out(2e-3)

// Bool, Unit, Text.
out(true)
out(())
out("praxis")
42
1000000
0.5
3.141592
1500.0
0.002
true
Unit
praxis

An underscore may appear between digits of any run — the integer part, the fraction and the exponent each accept them. A trailing _ is not part of the literal. A float needs a digit on both sides of its point: 0.5 is a float and neither .5 nor 2. is one. That is also what keeps 1..5 a range instead of a malformed number — a . joins a numeric literal only when a digit follows it.

A literal is typed by its syntax and by nothing else: 42 is an Int, 42.0 is a Float, and the two do not mix. That rule and everything that follows from it is Numbers.

An integer literal outside the signed 64-bit range is error[Y013]: `9223372036854775808` is outside the range of `Int` . It is raised while lowering, which praxis check does not run, so this is one of the few diagnostics a clean check will not show you and praxis run will.

-9223372036854775808 is therefore not a way to write the smallest Int: the - is a unary operator applied to a literal that is itself out of range, and the literal is what gets reported. 0 - 9223372036854775807 - 1 computes that value instead.

A text literal is a double-quoted run of UTF-8. Eight escapes are decoded: \n, \t, \r, \", \\, \0, \{ and \}. The last two exist because a { opens an interpolation hole, so a literal brace needs a spelling; a } closes nothing outside a hole and so needs no escape, but \} is accepted anyway to let a pair be written symmetrically. Anything else after a backslash is T005 invalid escape in text literal and stops compilation, with one exception: \` is accepted by the lexer — backticks delimit parser templates — and is not decoded, so it stays in the text as two characters. There is no \u{...} escape.

// The eight escapes a text literal decodes.
out("a\tb")
out("line\nbreak")
out("quote: \" backslash: \\")
out("a\rb".len())
out("a\0b".len())

// `\{` and `\}` are literal braces: a bare `{` opens an interpolation hole.
out("a hole is \{expr\}")

// A `\`` is accepted and left alone: two characters, not one.
out("a\`b".len())
a	b
line
break
quote: " backslash: \
3
3
a hole is {expr}
4

A character literal

A Char is written in single quotes: '#', 'a', ' '. It holds exactly one Unicode scalar value, and the escapes are a text literal’s plus \' for the quote itself — \', \\, \n, \r, \t, \0, \", \{ and \}. There are no \x or \u{…} escapes, in a character literal or in a text one.

// A character literal is one Unicode scalar in single quotes.
var wall = '#'
out(wall)
out(wall == "#"[0])

// The escapes are a text literal's, plus `\'` for the quote itself.
out('\n'.to_int())
out('\t'.to_int())
out('\''.to_int())
out('\\'.to_int())

// One scalar, not one byte: `é` is a single character.
out('é'.to_int())

// And a `Char` can be matched on, which is what the literal is for.
fn cell(c: Char) -> Text {
    match c {
        '#' => "wall"
        '.' => "open"
        _ => "something else"
    }
}

for c in "#.x" {
    out(cell(c))
}
#
true
10
9
39
92
233
wall
open
something else

Exactly one character is the whole rule, and the lexer holds it. '' names no character and 'ab' names two, so both are refused where they are written rather than becoming something the program did not mean:

// A character literal names exactly one character. These are lexical errors,
// which is the whole point: `"##"[0]` was a well-typed program that quietly
// meant `#`, and `""[0]` was a fault at run time.
var two = '##'
var none = ''
out(two)
out(none)
error[T007]: a character literal holds exactly one character

  char-literal-not-one-character.px:4:11
  4 | var two = '##'
    |           ^^^^ a character literal holds exactly one character

help: write it as a text literal
      "##"

error[T007]: empty character literal: `''` names no character

  char-literal-not-one-character.px:5:12
  5 | var none = ''
    |            ^^ empty character literal: `''` names no character

praxis: 2 error(s)

That is the point of the literal rather than a convenience. "##"[0] was a well-typed program that quietly meant #, and ""[0] was a fault at run time; neither is expressible as a literal.

"#"[0] still works and still means what it did. It is the spelling for a character read out of a text the program did not write down — a line it just parsed, a name it was given — and the literal is the spelling for one the program chose. t[i] == '#' is the common shape, with one of each.

The literal is also a load rather than a call. An Int literal is two loads out of an interned table, and an ASCII Char literal is the same; "#"[0] is a runtime call that re-evaluates every time it is reached.

The literal is what makes a Char matchable, which is the part that is not cosmetic — see pattern matching.

Every value is an object

All runtime values are garbage-collected objects reached through a handle, including an Int. No storage location a program can name holds an unboxed scalar: a variable, a field, a tuple element, an enum payload, a captured binding and a collection slot all hold a reference.

You cannot observe this. Scalars and Text are immutable, so aliasing one is indistinguishable from copying it, and the language has no identity comparison — == always asks about values. What the uniform model buys is that the crash debugger can print every live binding with its type, and that no generic function needs a boxing rule of its own.

What it does not cost is an allocation per number. The runtime interns Int values from -256 to 1024 and Char values from 0 to 127 into immortal tables, so the loop counters and ASCII characters a puzzle program actually handles are a table read rather than a heap block.

Equality

== and != are defined for every scalar, and they compare values rather than addresses. Two Texts built in different ways are equal when their characters are:

// `==` works on every scalar. `<` works on Int, Float, Char and Text.
out(42 == 42)
out(3.5 != 3.6)
out(true == true)
out(() == ())
out("abc" == "ab" + "c")
out('x' == 'x')

out(1 < 2)
out(1.5 <= 1.5)
out("Z" < "a")
out('a' < 'b')
true
true
true
true
true
true
true
true
true
true

Float equality is IEEE-754, so NaN == NaN is false and 0.0 == -0.0 is true. Both are in Numbers.

Equality extends structurally to tuples, records, enums and collections built out of equatable types. Function values are the one thing that is never equatable. See Capabilities.

Ordering

<, >, <= and >= are defined for exactly four types: Int, Float, Char and Text.

  • Int compares as a signed 64-bit number.
  • Float compares by IEEE-754, so any comparison involving NaN is false.
  • Char compares by Unicode scalar value.
  • Text compares lexicographically by UTF-8 bytes, which for UTF-8 is exactly code-point order.

Bool, Unit, tuples, records, enums, collections and functions have no order. Using one where an order is required is Y006, at check time:

// Bool and Unit have no order. Only Int, Float, Char and Text do.
var ready = true
var done = false
out(ready < done)
$ praxis check bool-order.px --color never
error[Y006]: values of type `Bool` cannot be ordered

  bool-order.px:4:13
  4 | out(ready < done)
    |             ^^^^ values of type `Bool` cannot be ordered

praxis: 1 error(s)

The same rule governs sorted() and heap elements: a Vec[(Int, Int)] cannot be sorted and a MinHeap[(Int, Int)] cannot be pushed to, because a tuple has no order. A lexicographic order over composites is conventional elsewhere and is not defined here: ordering a composite means choosing a semantics for it, and rejecting the program is the honest answer until one is chosen.

Ordering inside a container is a separate question, with one deliberate difference. A container needs a total order or it corrupts its own invariants, so the ordering a heap or a sort uses places a Float NaN after every number and ties it with itself. The source-level < is untouched and stays IEEE-754.

Where to go next

  • Numbers — checked Int arithmetic, Float semantics, and the full operator and precedence table.
  • Text and Char — concatenation, indexing, iteration, and the two Char conversions.
  • The method catalog — every method on every type.

Numbers

Int is a signed 64-bit integer and every arithmetic operator on it is checked: an overflow is a fault that stops the program in the crash debugger, not a wrap that keeps going with a number nobody wrote. Float is IEEE-754 binary64 and behaves the way IEEE-754 says, including never faulting.

The two do not mix. A literal is typed by its syntax — 42 is an Int, 42.0 is a Float — and there is no implicit widening in either direction:

// There is no implicit widening: an Int and a Float never mix.
var scale = 2
out(scale * 1.5)
$ praxis check mixing-numbers.px --color never
error[Y001]: expected Float, found Int

  mixing-numbers.px:3:5
  3 | out(scale * 1.5)
    |     ^^^^^ expected Float, found Int

praxis: 1 error(s)

The conversions are explicit and are a matched pair: Int.to_float() always succeeds, and Float.to_int() truncates toward zero and faults on anything it cannot represent. Both types also render: Int.to_text() and Float.to_text() each answer exactly the characters out writes, because the method and the printer share one renderer. The rule that decides an operation’s type is the operands’: one Float operand makes the operation Float, otherwise it is Int. (A Text operand makes it Text — see Text and Char.)

Overflow is a fault

fn double(n) {
    n * 2
}

out(double(4611686018427387904))
$ praxis run overflow.px --debug never
error: program faulted: integer overflow

Backtrace:
#0   double
#1   <entry>

  locals:
    n: Int = 4611686018427387904
  temps:
    <tmp#2: Int> @ "2" = 2
    <tmp#3: Int> @ "n * 2" = <uninit>

+, -, * and unary - all check. So do / and %, on their one overflowing case: the smallest Int over -1 has no positive counterpart, and both fault with the same integer overflow rather than the division by zero below. The abs prelude helper faults there too, for the same reason.

An Int literal outside the 64-bit range never gets that far — it is Y013 at compile time, described in Scalars.

Opting out of the check

Three modes over the three operators that can overflow without a divisor. Nine methods, all on Int:

addsubmul
wrapping_wrapping_addwrapping_subwrapping_mul
saturating_saturating_addsaturating_subsaturating_mul
checked_checked_addchecked_subchecked_mul
// The three modes over the three operators that overflow without a divisor.
var big = 9223372036854775807
out(big.wrapping_add(1))
out(big.saturating_add(1))
out(big.checked_add(1))
out(big.checked_mul(2))
out(5.checked_add(1))
out(2.wrapping_mul(big))
-9223372036854775808
9223372036854775807
None
None
Some(6)
-2

checked_* answers a real Option[Int], so a miss is something you match on rather than a sentinel value to remember.

There is deliberately no wrapping_div, checked_rem, wrapping_neg or checked_abs. Division by zero always faults, so a checked_div answering None would contradict that; 0.wrapping_sub(x) and 0.checked_sub(x) already spell the negation. Praxis has no bitwise operators at all, which is why wrapping_mul exists: modular multiplication has no other spelling.

Division and modulo

/ on two Ints is integer division truncating toward zero, and % is the remainder with the sign of the dividend: -7 / 2 is -3 and -7 % 2 is -1.

Both fault when the divisor is zero, and both report it the same way:

fn share(total, parts) {
    total / parts
}

out(share(10, 0))
$ praxis run divide-by-zero.px --debug never
error: program faulted: division by zero

Backtrace:
#0   share
#1   <entry>

  locals:
    total: Int = 10
    parts: Int = 0
  temps:
    <tmp#3: Int> @ "total / parts" = <uninit>
fn wrap(n, m) {
    n % m
}

out(wrap(10, 0))
$ praxis run modulo-by-zero.px --debug never
error: program faulted: division by zero

Backtrace:
#0   wrap
#1   <entry>

  locals:
    n: Int = 10
    m: Int = 0
  temps:
    <tmp#3: Int> @ "n % m" = <uninit>

Float division does not fault. 1.0 / 0.0 is inf, -1.0 / 0.0 is -inf and 0.0 / 0.0 is NaN, exactly as IEEE-754 requires.

Float

Float arithmetic never faults. % is not defined for Float at all — there is no float remainder to lower it to, so it is refused at check time rather than computing something else. %= is that same operation and is refused with it:

// `%` is defined for Int only, and `%=` is that same operation.
out(5.0 % 2.0)

var f = 5.0
f %= 2.0
$ praxis check float-remainder.px --color never
error[Y016]: `%` is not defined for `Float`

  float-remainder.px:2:5
  2 | out(5.0 % 2.0)
    |     ^^^^^^^^^ `%` is not defined for `Float`

error[Y016]: `%=` is not defined for `Float`

  float-remainder.px:5:1
  5 | f %= 2.0
    | ^ `%=` is not defined for `Float`

praxis: 2 error(s)

%= is worth stating separately because the rule that governs the other four compounds would let it through: they ask for a numeric target, and a Float is numeric. What refuses f %= 2.0 is not that rule but %’s own — the operator is Int-only wherever it appears. The four that do apply to a Float+=, -=, *=, /= — are float arithmetic, on a binding and through a place alike.

Unary - on a Float is IEEE-754 negation — the sign bit flipped, nothing else — so -0.0 is a value distinct from 0.0, even though the two compare equal.

Float carries twelve methods: abs, sqrt, floor, ceil, round, sign, is_nan, is_infinite, min(other), max(other), to_int and to_text. round rounds half away from zero. min/max return the other operand when one is NaN. pi() and e() are prelude functions, not methods. to_int is the only one that faults: on NaN, on ±inf, and on a finite value outside the signed 64-bit range, with float-to-int conversion out of range.

How a Float prints

out() and to_text() render a finite Float in the shortest text that reads back as the same Float — one function, called from both, so the pair cannot come apart. Because 1 is an Int literal in this language and the two types never mix, a whole-numbered float keeps a fractional part:

// A Float prints in the shortest form that reads back as the same Float,
// so a whole-numbered one keeps its fractional part.
out(1.0)
out(2.5)
out(1e10)
out(0.1 + 0.2)
out(-0.0)
out(1.0 / 0.0)
out(-1.0 / 0.0)
out(0.0 / 0.0)
out(16.0.sqrt())
out(1.5.to_text())
1.0
2.5
10000000000.0
0.30000000000000004
-0.0
inf
-inf
NaN
4.0
1.5

There is no exponent notation on output: 1e10 prints its ten zeros and then takes a .0 like any other whole number. The three non-finite values print as inf, -inf and NaN and take no suffix, because they are not decimal literals.

The rendered form is an answer and nothing more. Map, Set and Counter order their entries by the number rather than by its printing, so a Set[Float] prints {1.5, 2.0, 10.25} and never puts 10.25 between 1.5 and 2.0.

NaN

NaN is unordered. ==, <, >, <= and >= follow IEEE-754, which means every one of them is false against a NaN — including NaN == NaN, and including NaN <= NaN. != is the mirror of ==, so it is the one that answers true:

// NaN is unordered: `==` and the four order comparisons are false against it.
var nan = 0.0 / 0.0
out(nan == nan)
out(nan != nan)
out(nan < 1.0)
out(nan > 1.0)
out(nan <= nan)
out(nan.is_nan())
// The two zeros compare equal and print differently.
out(0.0 == -0.0)
out(1.0 / 0.0 == 1.0 / -0.0)
false
true
false
false
false
true
true
false

is_nan() is how you actually test for one.

Inside a container the answer differs, deliberately. A heap or a sort needs a total order or it breaks its own invariants, so the ordering a container imposes places NaN after every number and ties it with itself, and treats -0.0 and 0.0 as one key. Source-level < is untouched.

The operators, and what binds tighter

This is the whole set. There are no bitwise operators, no exponent operator, no increment or decrement, and no ternary conditional — if is an expression, so it does that job.

Tightest first:

OperatorsKindAssoc.Notes
f(x)   x[i]   x.name   x.name(...)   x.0postfixlefta ( or [ continues the expression before it only on the same line
readprefixits body is a parser expression
-   !prefix- negates a number, ! negates a Bool
*   /   %infixleft% is Int only
+   -infixleft+ also concatenates Text
==   !=   <   >   <=   >=infixleftresult is Bool
&&infixleftshort-circuits
..   ..=infixleftbuilds a Range
||infixleftshort-circuits
// Tightest first: postfix, then prefix, then the binary levels.
out(-1.5.abs())       // -(1.5.abs())
out((-1.5).abs())     // the other reading, spelled out
out(-2 * 3)           // (-2) * 3
out(2 + 3 * 4)        // 2 + (3 * 4)
out(2 - 3 - 4)        // (2 - 3) - 4
out(1 + 2 == 3)       // (1 + 2) == 3
out(1 == 1 && 2 == 3) // (1 == 1) && (2 == 3)
out(true || false && false)

var span = 0..3 - 1   // 0..(3 - 1)
var n = 0
for i in span { n += 1 }
out(n)
-1.5
1.5
-6
14
-5
true
false
true
2

Two of those rows are worth staring at.

A postfix chain binds tighter than a prefix -. -1.5.abs() is -(1.5.abs()), which is -1.5. Parenthesize when the receiver is meant to be the negative number.

.. binds looser than arithmetic and tighter than ||. 0..n - 1 is 0..(n - 1), which is how every range in the corpus is written.

Comparisons parse left-associatively but do not chain usefully: 1 < 2 < 3 is (1 < 2) < 3, which is a Bool compared with an Int and reports twice — Y001 for the mismatch and Y006 because Bool has no order.

Assignment is not in the table because it is not an expression. =, +=, -=, *=, /= and %= are statements; see Bindings. Each compound is its binary operator’s rule applied to a place, so %= is Int-only exactly as % is, and += concatenates a Text exactly as + does.

The Int helpers in the prelude

abs, sign, min, max, clamp, gcd and lcm are free functions, and every one of them is Int-onlymin(1.0, 2.0) is a type error, not a polymorphic call. pi() and e() are the two Float constants. See The prelude.

Text and Char

A Text is an immutable UTF-8 string. A Char is one Unicode scalar value. They are two types with a small, deliberate surface between them: + concatenates two Texts, t[i] reads a Char out of one, a for walks the same Chars, and Char and Int convert in both directions. That is nearly everything there is.

// `+` builds a Text; `len`, `is_empty` and `[i]` take one apart.
var greeting = "héllo" + ", " + "world"
out(greeting)
out(greeting.len())
out(greeting.is_empty())
out(greeting[1])

var vowels = 0
for c in greeting {
    if c == 'o' || c == 'e' { vowels += 1 }
}
out(vowels)
héllo, world
12
false
é
2

len() is 12 and greeting[1] is é, not half of it: both count and index by Unicode scalar value, never by byte.

+ is the only arithmetic operator

Text + Text is concatenation and produces a new Text — a Text is immutable, so neither operand is touched. s += "x" is the same operator, since a compound assignment types its right-hand side against the binding.

Everything else is refused. -, *, / and % report Y016, and + does not stringify its other operand — there is no implicit conversion to Text in any direction:

// `+` is Text's only arithmetic operator, and it does not stringify its operand.
out("ab" * 3)
out("count: " + 3)
$ praxis check text-operators.px --color never
error[Y016]: `*` is not defined for `Text`

  text-operators.px:2:5
  2 | out("ab" * 3)
    |     ^^^^^^^^ `*` is not defined for `Text`

error[Y001]: expected Text, found Int

  text-operators.px:2:12
  2 | out("ab" * 3)
    |            ^ expected Text, found Int

error[Y001]: expected Text, found Int

  text-operators.px:3:17
  3 | out("count: " + 3)
    |                 ^ expected Text, found Int

praxis: 3 error(s)

"ab" * 3 is repetition in some languages. It is not a spelling here, which keeps it free to mean that later. The refusal to stringify is the load-bearing half: a language whose + renders its other operand has no error left to report, and 1 + 2 in the middle of a longer expression starts depending on what its neighbours are.

The conversion the second error asks for is written, and it is written explicitly:

out("count: " + (3).to_text())

Int, Float and Char each have a to_text(), and each answers exactly the characters out writes — the method and the printer share one renderer, so they cannot disagree. Bool has none, and there is no universal T.to_text(): a conversion defined on every type is the coercion + refuses, arriving under a method name.

For a labelled line you usually want the next section instead.

Interpolation: {…} renders a value

A { inside a text literal opens a hole. The expression in it is evaluated and rendered into the surrounding text:

// A hole renders its value exactly as `out` does — whatever type it holds.
var part2 = 42
out("Part 2: {part2}")

var a = 3
var b = 4
out("{a} + {b} = {a + b}")

// Any type at all, not only the ones with a `to_text()`.
var splits = [10, 20, 30]
var pair = (1, "x")
out("splits: {splits}, pair: {pair}, third: {splits[2]}, how many: {splits.len()}")

// A hole is a full expression, and a `"` inside one opens a literal of its own.
var scores = Map[Text, Int]()
scores["ada"] = 7
out("ada scored {scores["ada"]}, which is {if scores["ada"] > 5 { "a lot" } else { "not much" }}")

// `\{` is a literal brace.
out("a hole looks like \{name\}")
Part 2: 42
3 + 4 = 7
splits: [10, 20, 30], pair: (1, x), third: 30, how many: 3
ada scored 7, which is a lot
a hole looks like {name}

Two things about a hole are worth stating plainly, because both are decisions rather than conveniences.

A hole may hold any type, and it renders exactly what out renders — the same [10, 20, 30], the same (1, x), the same shortest round-tripping float. That is not a coincidence checked by a test: a hole and out call the same renderer through the value’s type descriptor, so a type that prints is a type that interpolates, and the two cannot drift apart. There is no list of interpolable types to fall out of.

A hole holds a full expression, not just a name. {a + b}, {p.0}, {xs.len()}, {m["k"]} and even {if c { "yes" } else { "no" }} are all holes, and a " inside one opens a literal of its own. The expression is parsed exactly as it would be anywhere else, which is why a name in a hole resolves, renames, reports N001 when it does not exist, and is captured when the hole is inside a closure.

A literal brace is \{, joining the escape table in Scalars. A } on its own closes nothing, so it needs no escape — "a } b" is three characters and a pair of spaces.

{{ is not an escape, and it is refused rather than left to mean something else. It is the escape in Rust, C# and Python, so it is the first thing most readers try — and here it would parse: { opens the hole, {} is an empty block, } closes it, so "a{{}}b" would quietly print aUnitb. Rather than let a doubled brace mean a block nobody wanted, the compiler names the spelling that works:

// `{{` is the escape in some other languages. It is not one here, and it is
// refused rather than left to mean something else: a `{` opens a hole, so `{{`
// would open a hole holding a block, and `"a{{}}b"` would quietly print `aUnitb`.
var n = 1
out("a{{}}b")
out("count: {{n}}")
error[P001]: `{{` is not an escape for a literal brace: write `\{` for a `{`, or a value to render between single braces

  text-interpolation-doubled-brace.px:5:8
  5 | out("a{{}}b")
    |        ^ `{{` is not an escape for a literal brace: write `\{` for a `{`, or a value to render between single braces

error[P001]: `{{` is not an escape for a literal brace: write `\{` for a `{`, or a value to render between single braces

  text-interpolation-doubled-brace.px:6:14
  6 | out("count: {{n}}")
    |              ^ `{{` is not an escape for a literal brace: write `\{` for a `{`, or a value to render between single braces

praxis: 2 error(s)

A hole is part of the program

The expression in a hole is an ordinary subtree, not text re-read later, and everything that follows from that is the point of the design. A name in a hole is a real reference: it is captured by an enclosing closure, it renames with every other occurrence, and your editor colours and hovers it as the binding it is rather than as string.

// A name in a hole is an ordinary reference, so a closure captures it like any
// other — the hole is a real part of the program, not text scanned later.
var label = "total"
var n = 7

var describe = |extra| "{label} = {n + extra}"
out(describe(0))
out(describe(3))

// It counts as a capture, which `out` on the closure makes visible.
out(describe)
total = 7
total = 10
<closure:2>

describe prints <closure:2> because it captured two bindings — label and n — and it named both of them only inside a hole.

This does not change what + does. "n = " + n is still Y001, and deliberately so: a hole is a rendering site the program wrote, where + coercing its operand would render values nobody asked to render. The two rules are complements: a hole has no neighbours to depend on, and exists for no purpose but to render what it names. An operator has both.

Indexing answers a Char

t[i] and t.get(i) are one row with two spellings, and both answer a Char. Indexing is by Unicode scalar value, and an index past the end faults:

fn fourth(t) {
    t[3]
}

out(fourth("ab"))
$ praxis run text-index-fault.px --debug never
error: program faulted: index out of bounds

Backtrace:
#0   fourth
#1   <entry>

  locals:
    t: Text = "ab"
  temps:
    <tmp#2: Int> @ "3" = 3
    <tmp#3: Char> @ "t[3]" = <uninit>

There is no store: t[0] = c is Y020 values of type Text cannot be assigned through 1 index(es), because a Text is immutable. Text is the one subscriptable type that reads and does not write.

There is also no slicing. t[1..3] is a type error — a subscript takes an Int, and a Range is not one. To take a piece of a line, either walk it or let the read expression cut it up as it parses.

A character the program chooses is written as a character literal, '#'; "#"[0] is the other spelling, for a character read out of a text the program did not write down. Comparing a Char with a one-character Text is a type error rather than a convenience — c == "a" is error[Y001]: expected Char, found Text, and the fix is c == 'a'.

Char and Int

Three rows, and they are the whole Char surface.

  • Char.to_int() answers the Unicode scalar value. It never faults.
  • Int.to_char() answers the Char with that scalar value. It is the narrowing half, so it faults on a negative value, on anything above 0x10FFFF, and on a surrogate.
  • Char.to_text() answers the one-character Text holding it — the same character out writes. It never faults, because a Char is a validated scalar value by construction.
// Char and Int convert in both directions, and each renders as a Text.
var digits = "2026"
var value = 0
for c in digits {
    value = value * 10 + (c.to_int() - '0'.to_int())
}
out(value)
out('A'.to_int())
out(65.to_char())
out(233.to_char())
out('A'.to_text())
out("count: " + value.to_text())
2026
65
A
é
A
count: 2026
fn as_char(n) {
    n.to_char()
}

out(as_char(55296))
$ praxis run int-to-char-fault.px --debug never
error: program faulted: not a Unicode scalar value

Backtrace:
#0   as_char
#1   <entry>

  locals:
    n: Int = 55296
  temps:
    <tmp#2: Char> @ "n.to_char()" = <uninit>

A Char is not an arithmetic type: c - 48 does not compile. c.to_int() - 48 does, and that round trip is why to_int() exists at all. There is deliberately no is_digit, is_alpha, to_upper or to_lowerto_int() expresses every one of them, and four rows that save a comparison are still four rows.

A Text is iterable

for c in t walks the characters, and the Char it binds is the same one t[i] answers — one runtime function answers both, so the two cannot disagree about what the ith character is, including about counting scalars rather than bytes.

There is no Text.chars(). The for is the spelling, and two spellings for one question is what the catalog refuses. Char itself is not iterable: it is what iterating a Text produces.

A Text is also a full pipeline receiver — the tenth one, and the only one that is not a collection:

// A Text is a pipeline receiver, and its item is the Char `t[i]` answers.
var line = "a1b2c3"
out(line.count())
out(line.count(|c| c >= '0' && c <= '9'))
out(line.filter(|c| c >= 'a').to_vec().len())
out(line.map(|c| c.to_int()).sum())
6
3
3
444

The item type is the decision here, not merely that the loop is accepted. A one-character Text would have served and is not what you get: t[i] answers a Char, so the loop binds a Char, and a program that indexes and a program that iterates compare against the same thing.

The methods

Text’s own catalog is three rows plus the subscript:

MethodResultNotes
t.len()Intnumber of Unicode scalars
t.is_empty()Booltrue iff no scalars
t.get(i)Charfaults if out of range
t[i]Charthe same row, the same answer

Everything a bigger standard library would offer — split, trim, lines, replace, starts_with, repeat, chars, to_upper — is absent. That is not an oversight so much as a division of labour: the work those functions do is what the read expression is for, and it does it while parsing rather than afterwards. The method catalog is the authoritative list.

The two routes back into a Text

Those rows go the other way — a Text taken apart — and there are two that put one back together:

WrittenAnswers
seq.join(sep)the Text items with sep between them
chars.to_text()the Chars of a Vec as one Text, nothing between

join is a pipeline row, so it works on any of the ten receivers; its items must be Text, and it renders nothing — [1, 2].join(",") is expected Text, found Int, and the spelling is [1, 2].map(|n| n.to_text()).join(","). The separator is required, so join("") says at the call site that nothing goes between.

to_text() on a Vec[Char] is the inverse of walking a Text, and it is how a Grid row is drawn back as the line it was read from:

for y in 0..g.height() { out(g.row(y).to_text()) }

Text is equatable, which is what makes it a Map key and a Set element, and separately orderable, which is what makes it a sort key — Bool and tuples are keys without being orderable, so the two properties are worth keeping apart. Comparison is lexicographic over UTF-8 bytes, which for UTF-8 is code-point order, and it is the order a Set[Text] walks in as well as the order sorted() gives.

What a Text costs

A Text has two representations and the language never says which one you have. A literal, a concatenation and the whole of the program’s input are owned payloads. Every capture the input parser hands back is a slice — a view into the buffer it was parsed from, one level deep, with no copy. Parsing a hundred thousand fields therefore allocates a hundred thousand views and copies nothing.

What that buys, and what it does not, is visible only as complexity. An owned text counts its own scalars once, lazily, and caches the count; a slice inherits that answer from its owner. When every scalar in the owner is one byte — which is every ASCII input, and so nearly every input — indexing is a byte offset:

the textt.len()t[i]for c in t
owned, all ASCIIO(1) after the first callO(1)O(n)
owned, has a multi-byte scalarO(1) after the first callO(i)O(n²)
slice of an ASCII ownerO(1)O(1)O(n)
slice of a multi-byte ownerO(its own length)O(i)O(n²)

Rows two and four are the honest residual, and neither the for nor the subscript escapes it: there is no random access into a variable-width encoding without a wider representation, and Praxis does not build one. A text with one non-ASCII character in it costs O(n) per character to walk, whichever spelling you use.

Concatenation always allocates a fresh owned payload, because a new Text has no single owner to point into. Building a long string with += in a loop is therefore quadratic, and there is no builder type that avoids it — but there is join, which walks the sequence once and allocates once, so a line assembled from parts does not have to pay for it.

Where to go next

Control flow

Praxis has if, while, for, loop, break, continue and return, plus match, which has a chapter of its own. Blocks are expressions and so are all of these, so the thing that decides a value and the thing that produces it are usually one piece of syntax.

Two rules carry most of the weight. An if produces a value, which is why there is no ternary operator. A loop is the only loop that produces one, and it produces whatever its breaks carry.

if is an expression

fn grade(score: Int) -> Text {
    if score >= 90 {
        "A"
    } else if score >= 80 {
        "B"
    } else {
        "C"
    }
}

var n = 7
var parity = if n % 2 == 0 { "even" } else { "odd" }

out(parity)
out(grade(95))
out(grade(83))
out(grade(12))
odd
A
B
C

The condition is an ordinary expression, needs no parentheses, and must be Boolif 1 { … } is Y001: expected Bool, found Int, not a truthiness rule. It is parsed with record literals suppressed, so if flag { … } reads the braces as the branch rather than as flag’s field list; a record literal in a condition has to be parenthesized, as in if (P { x: 1 }).x == 1 { … }.

Both branches must agree on a type, and an if with no else has an implicit empty one, so its type is Unit. That is fine as a statement and an error the moment you ask it for something:

var n = 7
var label = if n > 0 { "positive" }
out(label)
error[Y001]: expected Text, found Unit

  if-without-else.px:2:22
  2 | var label = if n > 0 { "positive" }
    |                      ^^^^^^^^^^^^^^ expected Text, found Unit

help: this value is `Unit`; an `if` with no `else` expected `Text` — make the last expression produce a value, or change the declared type to `Unit`

praxis: 1 error(s)

while

var i = 0
var sum = 0
while i < 10 {
    i = i + 1
    if i % 3 != 0 { continue }
    sum = sum + i
}
out(sum)
18

continue jumps to the next test. A while is always Unit: it has an exit path — the condition failing — with no value on it, so there is nothing for it to produce. loop, below, is the one that does.

for over anything iterable

for binding in iterable { … } walks the ten collections and Text. The binding is a pattern, so a Map’s (key, value) pair can be taken apart in place:

for x in [3, 1, 2] { out(x) }
for i in 0..3 { out(i) }
for c in "hi" { out(c) }

var counts = Map()
counts["a"] = 1
counts["b"] = 2
for (word, n) in counts {
    out(word)
    out(n)
}

var heap = MinHeap()
heap.push(3)
heap.push(1)
heap.push(2)
for x in heap { out(x) }
3
1
2
0
1
2
h
i
a
1
b
2
1
2
3

Each iterable’s order is the one its own accessors already promise, and every one is deterministic — a hash-backed collection is walked in ascending order of its members, not in hash order, so two runs of the same program agree. A MinHeap is walked in pop order, which is why 3, 1, 2 came back as 1, 2, 3.

IterableOrder
Vec, Deque, Range, Textin place, by index
Setascending by member
Map, Counterascending by key
BitSetascending bit
MinHeap, MaxHeappop order
Gridrow-major

“Ascending” is the type’s own order, the same one sorted() uses: numeric for Int, Byte and Float, code-point for Char and Text, and element-wise left to right for a tuple, a record or an enum. So a Set[Int] holding 2 and 10 is walked 2, 10, and out(s) prints that same sequence.

A for is Unit. It runs its body; it does not collect anything. To build a value out of a sequence, use a pipeline.

The loop variable is an ordinary binding and may be assigned inside the body. Each step rebinds it, so the assignment does not survive into the next one.

What happens if you mutate what you are iterating

The seven collections that cannot index themselves — Set, Map, Counter, BitSet, MinHeap, MaxHeap, Grid — are walked through a snapshot taken once, before the loop starts. Mutating one inside its own for is well defined and terminates; the walk does not see the change.

A Vec, a Deque, a Range and a Text index themselves, so no snapshot is taken and the loop re-reads the length on every step. A push during the walk is seen:

var seen = Set()
seen.insert(1)
seen.insert(2)
for x in seen {
    out(x)
    seen.insert(x + 10)
}
out(seen)

var xs = [1, 2, 3]
for x in xs {
    out(x)
    if x == 1 { xs.push(99) }
}
out(xs)
1
2
{1, 2, 11, 12}
1
2
3
99
[1, 2, 3, 99]

The Set loop ran twice, over the two members the set had when it began, and both inserts landed anyway. The Vec loop ran four times. If that asymmetry matters to your program, iterate a copy.

A snapshot is the only protocol a collection that cannot index itself can offer. A hash set and a hash map have no nth member, so answering one is a linear scan and every loop over a hashed collection would be quadratic; a heap’s array is ordered at its root and nowhere else, so reading it by index answers in insertion order rather than in heap order. One call that hands back the members costs one Vec per loop and gets every collection right.

loop is the value its breaks carry

loop { … } repeats until something leaves it. It is the only loop that is an expression with a value, and that value is the join of every break in it:

fn collatz_steps(start: Int) -> Int {
    var n = start
    var steps = 0
    loop {
        if n == 1 { break steps }
        if n % 2 == 0 { n = n / 2 } else { n = 3 * n + 1 }
        steps = steps + 1
    }
}

out(collatz_steps(27))
out(collatz_steps(1))
111
0

The loop is the last expression in collatz_steps, so its value is the function’s result. Nothing is written twice, and there is no sentinel to initialize.

The edges all follow from “the join of its breaks”:

  • loop { break 42 } is Int.
  • loop { break } is Unit — a bare break leaves with nothing, so mixing break and break 1 in one loop is a Y001 rather than a coincidence that happens to work.
  • loop { } is Never: it produces no value at all, so it absorbs into whatever sits beside it. if n > 0 { n } else { loop { } } is an Int.

A break carrying a value out of a while or a for is rejected. Those loops have an exit path — the condition failing, the sequence running out — that no break is on, and there is no value to invent for it:

var n = 0
var first = while n < 10 {
    if n * n > 20 { break n }
    n = n + 1
}
out(first)
error[Y017]: a `break` carrying a value needs a `loop`; a `while` produces `Unit`

  break-with-value-in-while.px:3:27
  3 |     if n * n > 20 { break n }
    |                           ^ a `break` carrying a value needs a `loop`; a `while` produces `Unit`

praxis: 1 error(s)

Rewrite it as a loop with the test inside, or read the var the while left behind. Those two loops have an exit the compiler cannot fill: nothing in while c { break 1 } says what the loop produces when c is false, and there is no value to invent.

break and continue apply to the innermost enclosing loop; there are no labels. Where there is no loop, both are Y012`break` outside a loop, `continue` outside a loop. A closure body is outside every loop around it, so loop { var f = || break } is Y012 too: a break inside a closure has no loop of its own to leave.

Ranges

a..b is the integers from a up to but not including b; a..=b includes b. Both bounds are required — there is no a.., ..b or .. — and both are Int.

.. binds looser than arithmetic, so 0..n - 1 means 0..(n - 1), which is what a range with a computed bound almost always wants.

A range is a value, not just a loop header. It binds to a name, goes in a Vec, is a Map key, and is a type a parameter can declare:

var window = 2..6
out(window)
out(1..=3)

var windows = [0..2, 3..5]
out(windows)

var names = Map()
names[0..2] = "low"
names[2..4] = "high"
out(names[0..2])

fn width(r: Range) -> Int {
    var n = 0
    for i in r { n = n + 1 }
    n
}
out(width(window))
out(width(3..=7))
2..6
1..4
[0..2, 3..5]
low
4
5

1..=3 printed as 1..4. A range is normalized to its half-open form when it is built, so 1..=3 and 1..4 are one value — they compare equal and hash to the same key — and the inclusive spelling is the one thing about a range that is not recoverable from it afterwards. Being a key is safe because a range has no mutator at all: its two bounds are as fixed as a tuple’s elements.

Range is a collection type with no methods of its own, so r.len() is Y110. The pipeline methods do work on it: (1..5).count() is 4 and (1..5).sum() is 10.

A descending range is empty

5..0 does not count down. It is empty, and the emptiness is established when the range is built rather than checked by every reader:

var down = 5..0
out(down)

var ran = 0
for i in 5..0 { ran = ran + 1 }
out(ran)

for i in 0..0 { ran = ran + 1 }
out(ran)

// Counting down is a reversed range, which is a Vec and not a Range.
for i in (0..3).reversed() { out(i) }
5..5
0
0
2
1
0

5..0 printed as 5..5, because the constructor clamps an end below start up to start. No range with a negative length exists, so a for reading a range’s length can never get a bound that runs the loop backwards. The case that decides it is 0..n with n == 0: that has to run zero times, not n times in reverse.

The countdown is (0..n).reversed(), a pipeline barrier that answers a Vec[Int]. It does not make a descending Range — there is no such value, and the clamp above is why. Writing 5..0 still earns no diagnostic: it is a legal empty collection, and the language has no warnings to give it.

The bounds are Int and nothing else. A Float range would need a step to yield anything at all — 0.0..1.0 has no elements without one — and a range that cannot say what it yields is not a collection.

return

return leaves the enclosing function, with a value or without one. It is not needed for the common case — the last expression of a function body is its result — but it is the way out of the middle of a loop:

fn first_even(xs) {
    for x in xs {
        if x % 2 == 0 { return x }
    }
    0 - 1
}

A return inside a loop leaves the function, not the loop, which is why a loop exited only by return produces no value and is Never.

The fallback there is written 0 - 1 and not -1 on purpose. A block is an expression and the expression parser does not stop at a line break, so a } followed by a line beginning with - reads as one subtraction spanning both. Parenthesize, or write the negation so it cannot start a line.

Functions and closures

Praxis has two callable forms and one deliberate difference between them. A fn is a top-level declaration and is a function of its parameters and nothing else. A closure, |x| …, is an expression, and it captures the bindings around it.

That is the line to keep in mind, because it is the one the compiler enforces: naming an outer binding inside a fn is an error with its own code, and the error tells you which of the two forms you wanted.

fn

struct Point { x: Int, y: Int }

fn manhattan(a, b) {
    abs(a.x - b.x) + abs(a.y - b.y)
}

fn factorial(n: Int) -> Int {
    if n <= 1 { 1 } else { n * factorial(n - 1) }
}

fn first_even(xs) {
    for x in xs {
        if x % 2 == 0 { return x }
    }
    0 - 1
}

out(manhattan(Point { x: 1, y: 2 }, Point { x: 4, y: 6 }))
out(factorial(5))
out(first_even([1, 3, 6, 7]))
out(first_even([1, 3, 7]))
7
120
6
-1

The last expression of the body is the result; return leaves early. Parameter and return annotations are optional and inference derives both from use, which is how manhattan knows its arguments have x and y fields and answers Int. Write them where they document something, or where the error message you get without them points at the wrong place.

A parameter is a plain name — there is no destructuring in a fn parameter list, unlike a closure’s. It is also an ordinary binding, so a function may assign to its own parameter:

fn clamp_low(n) {
    if n < 0 { n = 0 }
    n
}

Functions may be declared in any order and may call each other, including mutually and recursively. They may not be nested: a fn inside a fn is N005.

A name has exactly one signature. There is no overloading on arity or type, no optional or default parameters, and no named arguments. A call with the wrong count is Y024: this function takes 2 argument(s), but 1 were given. Where another language would overload, Praxis uses a second name — the method catalog does, with min/min_by, max/max_by and find/position. The prelude’s own min and max are the two-argument free functions, and there is no min_by beside them.

A fn does not capture

A fn body may name other declarations — functions, structs, enums, variant constructors, the prelude — because those are reachable from anywhere. It may not name a var outside itself. That is a report, not a silent read:

var offset = 10

fn shift(n: Int) -> Int {
    n + offset
}

out(shift(1))
error[N007]: `shift` cannot use `offset`: a function does not capture the bindings around it (pass `offset` as a parameter, or use a closure)

  fn-does-not-capture.px:4:9
  4 |     n + offset
    |         ^^^^^^ `shift` cannot use `offset`: a function does not capture the bindings around it (pass `offset` as a parameter, or use a closure)

praxis: 1 error(s)

The message names both ways out, and both are ordinary:

var offset = 10

fn shift(n: Int, by: Int) -> Int {
    n + by
}

var shift_by_offset = |n| n + offset

out(shift(1, offset))
out(shift_by_offset(1))
11
11

The boundary is a fn body, not a closure body. A closure opens no boundary of its own, so the question is always about the nearest enclosing fn, and the three cases are the three you would expect:

  • A closure at the top level using a top-level binding is fine — top-level statements are the program’s own body, and the binding is in it.
  • A closure inside a fn, using that fn’s own local, is fine. It captures something the function has.
  • A closure inside a fn, using a binding declared outside that fn, is N007. The closure is inside g; the binding is not.

A recursive fn is offered only the parameter

The message above names two ways out. A recursive function has one, because a closure cannot name itself: var f = |n| … f(n - 1) … resolves f in the environment before the declaration, so the call inside is N001. Rather than suggest something it would then refuse, the compiler drops that half and says which rule took it away:

var step = 2

fn countdown(n: Int) -> Int {
    if n <= 0 { 0 } else { 1 + countdown(n - step) }
}

out(countdown(10))
error[N007]: `countdown` cannot use `step`: a function does not capture the bindings around it (pass `step` as a parameter)

  fn-recursive-cannot-capture.px:4:46
  4 |     if n <= 0 { 0 } else { 1 + countdown(n - step) }
    |                                              ^^^^ `countdown` cannot use `step`: a function does not capture the bindings around it (pass `step` as a parameter)

help: `countdown` calls itself, so a closure is not the way out: a closure cannot name itself (`N001`)

praxis: 1 error(s)

Mutual recursion counts too, and there the help: names the other function in the cycle. A fn that merely calls a recursive one is not itself recursive and keeps both ways out.

A binding declared after the function is N001 rather than N007: only fn, struct and enum are pre-registered for forward reference, so the name is genuinely not in scope and nothing has crossed a boundary.

The alternative is a fn that captures, and it is a worse language: fn and closure would then differ only in syntax, and every function would acquire a hidden environment it did not declare. A fn is a function of its parameters and nothing else, a closure is the thing that captures, and N007 is where the line is drawn.

Closures

A closure is |params| body. The body is one expression, which may be a block. Parameters are patterns, so a pair can be taken apart in the parameter list, and || — one token — is the empty list:

var offset = 10
out([1, 2, 3].map(|x| x + offset))

fn shifted(values, by) {
    values.map(|x| x + by)
}
out(shifted([1, 2, 3], 100))

fn adder(n: Int) -> (Int) -> Int { |x| x + n }
var add5 = adder(5)
out(add5(2))

var seven = || 7
out(seven())

out([(1, 2), (3, 4)].map(|(a, b)| a + b))
[11, 12, 13]
[101, 102, 103]
7
7
[3, 7]

A function type is written (Int) -> Int, and that is what a parameter or a return annotation says when it holds a closure. A closure parameter may be annotated too: |x: Int| x + 1.

A closure’s environment outlives the frame that made it — adder returns one that still has n — because the environment is on the garbage-collected heap. There are no move closures, no borrow captures and no lifetime rules.

Two things a closure cannot do. It cannot name itself — a body that calls f inside var f = |n| … gets N001, because f is not in scope until its own declaration finishes, so recursion needs a fn. And it has no readable form: out on a closure prints <closure:N>, where N is how many bindings it captured — <closure:0> for one that captures nothing.

Captured by value, or through a cell

Whether a capture is a copy or a shared cell is not something you write. The compiler asks one question about the captured binding: does anything, anywhere, assign to it? A binding nothing assigns to is copied into the closure’s environment. A binding something assigns to gets a garbage-collected cell that the declaring frame and every capturing closure share, so a write on either side is seen by both.

var offset = 10
var add_offset = |x| x + offset
out(add_offset(1))
offset = 100
out(add_offset(1))

var base = 10
var add_base = |x| x + base
out(add_base(1))
var base = 100
out(add_base(1))
11
101
11
11

offset is assigned on line 4, so the closure holds a cell and sees 100. base is never assigned — the second var base is a new binding that shadows the first, with its own symbol and its own type — so add_base holds a copy of 10 and keeps answering 11.

That is the whole rule, and it is why shadowing and reassignment, which look alike, behave differently here. See bindings and shadowing for the difference. Nothing in the source declares which of the two you get: the compiler reads whether anything writes the binding, and a keyword for it would be a second statement about the program that has to agree with the first.

A for variable is a fresh binding on every step, so closures made on different steps hold different bindings rather than sharing one — they do not all end up at the last step’s value. Inside a step the rule above still applies: a loop variable the body assigns to is a reassigned binding, so that step’s closure holds that step’s cell and sees the write.

var fs = Vec()
for x in [1, 2, 3] {
    fs.push(|| x)
    x = x + 100
}
for f in fs { out(f()) }
101
102
103

A closure that returns a closure captures for it

A closure whose body is another closure captures whatever the returned one names from outside them both — not just what its own body mentions directly. It has to: the returned closure’s environment is filled from the returning closure’s frame at the moment its literal is evaluated, so the returning closure must be holding the value in order to hand it over.

var base = 10
var mk = |a| |b| a * 100 + b * 10 + base
out(mk)
out(mk(1)(2))

var deep = |a| |b| |c| a + b + c + base
out(deep(1)(2)(3))

var n = 0
var bump = |a| |b| { n = n + a + b; n }
out(bump(1)(2))
out(bump(10)(20))
out(n)
<closure:1>
130
16
3
33
33

mk prints <closure:1>: it captures one binding, base, even though nothing in |a| … names base except the closure it returns. Nesting is not a limit — deep threads the same capture down three levels — and a reassigned binding is still a single shared cell however many environments it passes through, which is why bump accumulates into one n that the outer scope reads back.

What is not captured is anything either closure declares. |a| |b| b + a captures nothing: a is the outer closure’s own parameter, and b is the inner’s. Only a name declared outside both becomes an environment slot.

A fn name in value position is a closure

Writing a function’s name without calling it produces a closure over it, with an empty environment. It can be bound, passed, stored and called like any other:

fn double(n: Int) -> Int { n * 2 }

fn apply(f, x) { f(x) }

var f = double
out(f(3))
out(apply(double, 20))
out([1, 2, 3].map(double))
6
40
[2, 4, 6]

A direct call — double(3) — is still a direct call and allocates nothing. It is only the name in value position that builds a closure, and it builds one per evaluation: var f = double inside a loop allocates on every iteration, the same as |n| double(n) would. Hoist it if that matters.

A generic function has no function value, because there is nothing at a value to specialize it against:

fn identity(x) { x }

var f = identity
out(f(3))
error[Y018]: `identity` is generic, so it has no single function value; write `|x| identity(x)` to fix its type arguments at the call

  generic-fn-as-a-value.px:3:9
  3 | var f = identity
    |         ^^^^^^^^ `identity` is generic, so it has no single function value; write `|x| identity(x)` to fix its type arguments at the call

praxis: 1 error(s)

The remedy in the message works because a closure body is a call site, and a call site is what fixes the type arguments.

How a parameter generalizes

A parameter whose type the body never pins is quantified, and the function may then be called at several types in one program:

fn identity(x) { x }

out(identity(1))
out(identity("s"))
out(identity(true))
1
s
true

Calling a method on a parameter pins it. There is one lowered body per source function, and a method call site carries exactly one catalog entry and one receiver type, so a quantified receiver would be several receiver types at one call site with no way to compile any of them. Two call sites that disagree are therefore a disagreement about the function’s signature, reported as one:

fn head(xs) { xs[0] }

out(head([1, 2, 3]))
out(head(["a", "b"]))
error[Y001]: expected (Vec[Int]) -> ?T, found (Vec[Text]) -> ?T

  parameter-pinned-by-a-method.px:4:5
  4 | out(head(["a", "b"]))
    |     ^^^^^^^^^^^^^^^^ expected (Vec[Int]) -> ?T, found (Vec[Text]) -> ?T

praxis: 1 error(s)

Annotate the parameter, or write a second function.

for over a parameter is the exception

Iterating a parameter splits the two facts. The iterable stays quantified, so one source body serves a Vec, a Range and a Set; the element is pinned, so it is one type for the whole program, even in a body that never touches it:

fn total(items) {
    var t = 0
    for i in items { t = t + i }
    t
}

var seen = Set()
seen.insert(4)
seen.insert(9)

out(total([1, 2, 3]))
out(total(0..5))
out(total(seen))
6
10
13

total is “any iterable, of Int”. The asymmetry is not a preference: a Vec and a Range are walked through different runtime accessors, so a single compiled body could not serve both — one of them would read a length out of the wrong word. One clone per iterable kind is the only way the accessors can be right. The element, in contrast, has to be one type for the loop variable’s slot to have one.

Disagreeing about the element is reported at the call site, with the operation that requires it as a note:

fn total(items) {
    var t = 0
    for i in items { t = t + i }
    t
}

out(total([1, 2, 3]))
out(total(["a", "b"]))
error[Y001]: expected Int, found Text

  iterated-parameter-mismatch.px:8:5
  8 | out(total(["a", "b"]))
    |     ^^^^^^^^^^^^^^^^^ expected Int, found Text

note: this is the operation that requires it

  iterated-parameter-mismatch.px:3:14
  3 |     for i in items { t = t + i }
    |              ^^^^^

praxis: 1 error(s)

Y001 and not “cannot be iterated”: Vec[Text] iterates perfectly well, and the body is correct for every other instantiation of total. What is wrong is t + i, at this one call. Generalization is the wider picture.

Records

A record is a fixed set of named fields. You declare one with struct, build one with a brace literal, and read a field with a dot. There is no impl block and no method syntax: a record is data, and the operations on it are the functions you write.

struct Point {
    x: Int
    y: Int
}

var p = Point { x: 3, y: 4 }
out(p)
out(p.x)

// A field is an assignable place, in every spelling an assignment has.
p.x = 5
p.y += 1
out(p)

// Field punning: `x` and `y` are already the names the fields want.
var x = 10
var y = 20
out(Point { x, y })
{ x: 3, y: 4 }
3
{ x: 5, y: 5 }
{ x: 10, y: 20 }

Fields are separated by a comma or a line break, so struct Point { x: Int, y: Int } on one line and the four-line form above are the same declaration. A trailing comma closes the list.

Note what out prints: { x: 3, y: 4 }, with no Point in front of it. A record formats as its fields.

Fields are places, and a record is an object

p.x = 5 writes the field. The binding is not what is written — the object is — so the receiver need not be a name you reassign, and every other reference to that record sees the write.

struct Point { x: Int, y: Int }

// A record value is an object. A binding names it; it does not own it, so a
// write through one name is visible through every other.
var nodes = [Point { x: 0, y: 0 }]
nodes[0].x += 1
out(nodes)

var alias = nodes[0]
alias.y = 9
out(nodes[0])
[{ x: 1, y: 0 }]
{ x: 1, y: 9 }

nodes[0].x += 1 evaluates nodes[0] once, reads the field and writes it back. min= and max= are not among the spellings a field accepts: those are map updates, and what they mean — “an absent entry accepts the first value” — is about an entry that might not be there. A field always is.

Equality, hashing and nesting come for free

Two records of the same type are equal when their fields are equal, and they hash consistently with that, so a record is a map key or a set element with no declaration on your part. Records nest.

struct Point { x: Int, y: Int }
struct Segment { label: Text, from: Point, to: Point }

// Two records of the same type with equal fields are equal, and hash alike.
var a = Point { x: 1, y: 2 }
var b = Point { x: 1, y: 2 }
out(a == b)

var seen = Set()
seen.insert(a)
seen.insert(b)
out(seen.len())

var owner = Map()
owner[a] = "north"
out(owner[b])

// Records nest, and a nested field is read through the outer one.
var s = Segment { label: "edge", from: a, to: Point { x: 4, y: 6 } }
out(s)
out(s.to.y)
true
1
north
{ label: edge, from: { x: 1, y: 2 }, to: { x: 4, y: 6 } }
6

A struct is nominal: it is the same type as itself and nothing else, so two declarations with identical fields are two types. Point { x: 1, y: 2 } == Vector { x: 1, y: 2 } does not compare unequal — it is Y001, expected Point, found Vector.

You cannot derive or implement any of this, and there is nothing to opt out of: equality, hashing and formatting are decided by the compiler from the field types.

Records are not ordered. Nothing says which field decides, so < and sorted() refuse them:

struct Point { x: Int, y: Int }

// Records are equatable and hashable. They are not ordered: nothing says which
// field decides, so `<` and `sorted()` refuse them. Sort by a key instead.
var ps = [Point { x: 2, y: 0 }, Point { x: 1, y: 9 }]
out(ps.sorted())
$ praxis check docs/book/examples/records-enums/record-cannot-be-ordered.px
error[Y006]: values of type `Point` cannot be ordered

  record-cannot-be-ordered.px:6:8
  6 | out(ps.sorted())
    |        ^^^^^^ values of type `Point` cannot be ordered

praxis: 1 error(s)

Name the field that decides and it sorts:

struct Point { x: Int, y: Int }

var ps = [Point { x: 2, y: 0 }, Point { x: 1, y: 9 }]
out(ps.sorted_by_key(|p: Point| p.x))
[{ x: 1, y: 9 }, { x: 2, y: 0 }]

A record literal must not be mistaken for a block

if p { … } is genuinely ambiguous: p { … } is a well-formed record literal, and p followed by a block is a well-formed if. The rule is that four keyword heads — if and while’s conditions, for’s iterator, match’s scrutinee — claim the brace as their block, and everywhere else a literal is legal.

struct Point { x: Int, y: Int }

var p = Point { x: 1, y: 2 }

// `if`, `while`, `for` and `match` claim the brace that follows their head, so
// a record literal there needs parentheses to say it is not the block.
if (Point { x: 1, y: 2 } == p) {
    out("same")
}

// Inside a bracket the grammar knows what closes it, so a literal is legal at
// any depth: an argument list, a match arm body, a block, a closure.
out(match p {
    Point { x: 1, y } => Point { x: 9, y: y }
    _ => p
})
out([Point { x: 0, y: 0 }].map(|q: Point| Point { x: q.y, y: q.x }))
same
{ x: 9, y: 2 }
[{ x: 0, y: 0 }]

Suppression follows the head’s operands and stops at brackets: a parenthesized expression, an argument list, a block, a record body and a match arm all re-enter with literals allowed. Writing if p == Point { x: 1, y: 2 } { … } without the parentheses is not a tidy error — the { becomes the if’s block, the field list becomes statements, and one line yields a dozen diagnostics. The fix is the parentheses.

The anonymous literal has no such ambiguity to suppress, and none of this applies to it. p { … } is ambiguous because the name could be the whole head; a { where an operand is still required cannot be a keyword’s block, because that block comes after a complete head. So if { hit: true }.hit { … } needs no parentheses. What that form has instead is its own tie with the block — { x: 1 } versus { x } — which Records without names covers.

Anonymous records

A record literal with no name in front of it — { x: 1, y: 2 } — builds a record whose type is its field set, and a named-capture template derives the same kind with no declaration anywhere. Between them this is where most records in a puzzle-shaped program come from.

// A named-capture template derives a record type with no declaration anywhere.
var points = read lines(`{x:int},{y:int}`)
out(points)
out(points[0].x)

// The derived record is an ordinary value: its fields are read by name, and a
// function that takes one need not name the type.
fn area(r) -> Int { r.x * r.y }
for r in points {
    out(area(r))
}

Given this input:

3,4
10,20
[{ x: 3, y: 4 }, { x: 10, y: 20 }]
3
12
200

An anonymous record is a type of its own. It prints as { x: Int, y: Int }, but that is a spelling diagnostics use and not one you can write as an annotation: the type grammar has no record form, so var p: { x: Int, y: Int } does not parse and an anonymous record only ever gets its type from inference — including from a { x: 1, y: 2 } literal, which is the value form and does parse. Two anonymous records are the same type when their field names match and their field types unify. A struct is not one of them, however alike the two look.

struct Point { x: Int, y: Int }

// An anonymous record is a type of its own. It is the same type as another
// anonymous record with the same field names and types, and it is never the
// same type as a `struct` that happens to look like it.
var rows = read lines(`{x:int},{y:int}`)
var p: Point = rows[0]
out(p)
$ praxis check docs/book/examples/records-enums/record-anonymous-is-not-nominal.px
error[Y001]: expected Point, found { x: Int, y: Int }

  record-anonymous-is-not-nominal.px:7:16
  7 | var p: Point = rows[0]
    |                ^^^^^^^ expected Point, found { x: Int, y: Int }

praxis: 1 error(s)

To cross the line, build the struct from the fields: Point { x: rows[0].x, y: rows[0].y }. Most programs never need to — the anonymous record already has the fields, and a function that takes one need not name its type. The type system’s side of it is Records without names.

A bare .name is a field; a zero-argument accessor is a call

p.x reads a field: it lowers to a slot index taken from the record’s definition. v.len() calls a method: it looks the name up in the catalog. The two are different syntax on purpose, and there is no property form of a method.

struct Row { len: Int }

var r = Row { len: 4 }
out(r.len)

var v = [1, 2, 3]
out(v.len())
out(v.len)
$ praxis check docs/book/examples/records-enums/record-field-is-not-a-call.px
error[Y112]: no field `len` on type `Vec[Int]`

  record-field-is-not-a-call.px:8:7
  8 | out(v.len)
    |       ^^^ no field `len` on type `Vec[Int]`

praxis: 1 error(s)

A record field may be called len and is unaffected: r.len reads it, r.len() looks for a method. v.len(), grid.width() and grid.height() all take their parentheses, and a bare one of those is Y112 naming the type it was asked of. The rule exists because a receiver whose type inference has not pinned yet cannot tell a field read from a nullary call, and picking by “whichever the receiver happens to have” would mean adding a field could silently change what an existing expression does.

What the compiler reports

A literal must supply every field, exactly once, and only fields the record has.

struct Point { x: Int, y: Int }

var missing = Point { x: 1 }
var extra = Point { x: 1, y: 2, z: 3 }
var twice = Point { x: 1, x: 2, y: 3 }
out(missing.z)
$ praxis check docs/book/examples/records-enums/record-literal-fields.px
error[Y113]: `Point` literal is missing a field: y

  record-literal-fields.px:3:15
  3 | var missing = Point { x: 1 }
    |               ^^^^^^^^^^^^^^ `Point` literal is missing a field: y

error[Y114]: `Point` has no field `z`

  record-literal-fields.px:4:33
  4 | var extra = Point { x: 1, y: 2, z: 3 }
    |                                 ^ `Point` has no field `z`

error[Y115]: field `x` is initialized more than once

  record-literal-fields.px:5:27
  5 | var twice = Point { x: 1, x: 2, y: 3 }
    |                           ^ field `x` is initialized more than once

error[Y112]: no field `z` on type `Point`

  record-literal-fields.px:6:13
  6 | out(missing.z)
    |             ^ no field `z` on type `Point`

praxis: 4 error(s)

A type cannot refer to itself

There are no recursive types. A declaration that reaches itself through its own annotations is N006, and a mutual pair is named through the member that closes the cycle.

struct Node {
    next: Node
    value: Int
}

struct A { b: B }
struct B { a: A }

out(1)
$ praxis check docs/book/examples/records-enums/record-self-referring.px
error[N006]: `Node` refers to itself, and a self-referring type is not supported

  record-self-referring.px:1:8
  1 | struct Node {
    |        ^^^^ `Node` refers to itself, and a self-referring type is not supported

error[N006]: `A` refers to itself through `B`, and a self-referring type is not supported

  record-self-referring.px:6:8
  6 | struct A { b: B }
    |        ^ `A` refers to itself through `B`, and a self-referring type is not supported

error[N006]: `B` refers to itself through `A`, and a self-referring type is not supported

  record-self-referring.px:7:8
  7 | struct B { a: A }
    |        ^ `B` refers to itself through `A`, and a self-referring type is not supported

praxis: 3 error(s)

Indirection does not help: struct Node { children: Vec[Node] } is the same N006, reported at Node. The message says the feature is missing rather than that the values are impossible, and that wording is deliberate — every field holds a reference, so a tree is a perfectly ordinary runtime shape. What is absent is recursive types in the type system. Model the tree with node ids instead: a Map[Int, Vec[Int]] of children.

One report is emitted per cycle member, and a declaration that merely sits behind a cycle is not the mistake and is not reported — in struct C { a: A } above struct A { b: B } and struct B { a: A }, only A and B are named.

What is not here

There is no generic struct: struct Box[T] { … } does not parse, and Option[T] is the one generic definition in the language — an enum, built in. There are no defaulted fields, no positional construction, no visibility modifiers, and no traits or interfaces to implement.

Taking a record apart in a pattern — including without naming its type — is pattern matching.

Enums and Option

An enum is a closed set of named variants, each optionally carrying a payload. A value is one of them and knows which. You take one apart with match, and the checker knows when you have missed a case.

enum Tile {
    Empty
    Wall
    Number(Int)
    Portal(Text)
}

// A variant is constructed by naming it. One with a payload is called.
out(Empty)
out(Number(7))
out(Portal("ab"))

var tiles = [Empty, Wall, Number(3), Portal("z")]
for t in tiles {
    out(match t {
        Empty => 1
        Wall => 0
        Number(n) => n
        Portal(_) => 100
    })
}
Empty
Number(7)
Portal(ab)
1
0
3
100

Variants are separated by a comma or a line break, so enum Tile { Empty, Wall } and the multi-line form are the same declaration. A constructor is used bare, not qualified: Empty, not Tile::Empty — there is no path syntax.

Payloads

A payload is a parenthesized list of types. There may be more than one, and the pattern names them by position.

enum Move {
    Step(Int, Int)
    Stay
}

// A payload may hold more than one value, and the pattern names them by
// position.
var m = Step(1, 2)
out(m)
out(match m { Step(dx, dy) => dx * 10 + dy, Stay => 0 })

// A wildcard stands in for a payload slot the arm does not need, and `Step(_,
// _)` is how you say "any payload". A bare `Step` is not: a variant that
// carries a payload has to say so in the pattern.
out(match m { Step(_, _) => 1, Stay => 0 })

var at = (0, 0)
for step in [Step(1, 0), Step(0, 2), Stay, Step(3, 4)] {
    at = match step {
        Step(dx, dy) => match at { (x, y) => (x + dx, y + dy) }
        Stay => at
    }
}
out(at)
Step(1, 2)
12
1
(4, 6)

Inside the parentheses you may name fewer sub-patterns than the payload has: the rest are wildcards, so Step(dx) binds the first slot and ignores the second.

Two things are Y124. Naming more than the payload has:

enum Wrapped { Wrap(Int) }

// Naming *more* sub-patterns than the payload has is `Y124`, from analysis —
// so `praxis check`, `praxis run` and the editor all report it.
fn value(w: Wrapped) -> Int {
    match w { Wrap(a, b) => a + b }
}

out(value(Wrap(1)))
$ praxis check docs/book/examples/records-enums/match-too-many-sub-patterns.px
error[Y124]: `Wrap` in `Wrapped` holds 1 value(s), but this pattern names 2

  match-too-many-sub-patterns.px:6:15
  6 |     match w { Wrap(a, b) => a + b }
    |               ^^^^ `Wrap` in `Wrapped` holds 1 value(s), but this pattern names 2

praxis: 1 error(s)

…and naming a payload-carrying variant with no parentheses at all. Stay is a pattern because Stay carries nothing; a bare Step is not:

error[Y124]: `Step` in `Move` holds 2 value(s), but this pattern names 0

help: name the payload, or `_` for each slot you do not need
      Step(_, _)

The parentheses are where you said what you were doing. A bare name says nothing about the value the variant holds, and reads exactly like the payload-less Stay beside it — which is the whole reason it is refused.

An enum declaration is not generic — a variant’s payload types are concrete — and a declaration that reaches itself through a payload type is the same N006 a self-referring record gets.

An enum value records its type

Equality is same type, same variant, equal payloads, and hashing agrees with it, so an enum value is a map key or a set element like any other.

enum Tile { Empty, Wall, Number(Int) }

// Equality is same variant and equal payloads; hashing follows it, so an enum
// value is a map key or a set element like any other.
out(Number(7) == Number(7))
out(Number(7) == Number(8))
out(Empty == Wall)

var seen = Set()
seen.insert(Number(1))
seen.insert(Number(1))
seen.insert(Empty)
out(seen.len())
true
false
false
2

“Same type” is real, not a tag comparison: a value carries a schema naming the enum it belongs to and the shape of each variant, so two enums whose variants line up are still two types. That is what makes Some(3) print as Some(3) rather than as a bare tag, what keeps an Option[Int] the runtime built from an Option[Int] the compiler built, and what lets the debugger say what it is looking at.

Statically the type checker usually gets there first. Two enums may declare the same variant name; in expression position the name resolves like any other name, so a later declaration shadows an earlier one:

enum Colour { Red, Green }
enum Light { Red, Amber }

// An enum value records which enum type it is, so a `Colour` and a `Light` are
// never the same value — and here they are not even the same type. In
// *expression* position `Red` is an ordinary name, and the later declaration
// shadows the earlier one, so this `Red` is `Light`'s.
var c: Colour = Red
out(c)
$ praxis check docs/book/examples/records-enums/enum-value-knows-its-type.px
error[Y001]: expected Colour, found Light

  enum-value-knows-its-type.px:8:17
  8 | var c: Colour = Red
    |                 ^^^ expected Colour, found Light

praxis: 1 error(s)

In pattern position there is no such problem: a variant pattern’s enum is the scrutinee’s, so match c { Red => … } and match l { Red => … } each read their own. That is in the pattern chapter.

Enums are not ordered, for the same reason records are not — nothing says which variant or which payload decides:

enum Tile { Empty, Number(Int) }

// Like records, enums are equatable and hashable but not ordered.
out([Number(1), Empty].sorted())
$ praxis check docs/book/examples/records-enums/enum-cannot-be-ordered.px
error[Y006]: values of type `Tile` cannot be ordered

  enum-cannot-be-ordered.px:4:24
  4 | out([Number(1), Empty].sorted())
    |                        ^^^^^^ values of type `Tile` cannot be ordered

praxis: 1 error(s)

Option

Option[T] is the one generic definition in the language and it is an enum: Some(T) and None. It is what the standard library answers when a value may legitimately be absent. It is not an error channel — a program that runs out of budget or indexes past the end faults; a lookup that finds nothing answers None.

// `Option[T]` is an ordinary enum with two variants, `Some(T)` and `None`. It
// is what a library answers when a value may legitimately be absent.
var counts = Map()
counts["a"] = 1

out(counts.get("a"))
out(counts.get("z"))
out(match counts.get("z") { Some(n) => n, None => 0 })

var words = ["alpha", "beta"]
out(words.find(|w| w == "beta"))
out(words.find(|w| w == "gamma"))
out(words.position(|w| w == "beta"))

out(9000000000000000000.checked_add(9000000000000000000))
out(2.checked_add(3))

// A closure may build one: `filter_map` keeps the `Some`s and drops the `None`s.
out([1, 2, 3].filter_map(|n| if n % 2 == 1 { Some(n * 10) } else { None }))

// And a function may declare one.
fn first_big(v: Vec[Int]) -> Option[Int] {
    v.find(|n| n > 1)
}
out(first_big([1, 2, 3]))
Some(1)
None
0
Some(beta)
None
Some(1)
None
Some(5)
[10, 30]
Some(2)

Some and None are ordinary constructors: you build them, annotate with Option[T], store them in collections, and match them. There are no methods on an Option — no unwrap, no is_some, no ? operator. A match is how you get the value out, and it is two tokens more than an unwrap would be.

What answers an Option

SignatureAbsent means
Map[K, V].get(K) -> Option[V]the key is not in the map
Vec[T].find((T) -> Bool) -> Option[T]nothing matched — the element, not its index
Vec[T].position((T) -> Bool) -> Option[Int]nothing matched — the index
Grid[T].find(T) -> Option[(Int, Int)]the value is nowhere in the grid
Int.checked_add/sub/mul(Int) -> Option[Int]the result overflowed

filter_map’s closure returns Option[U], which is how it drops elements.

Three near neighbours deliberately answer something else. Counter[T].get answers a plain count, because a counter’s absent value is zero rather than absent. v.min() and v.max() on an empty sequence fault: an empty minimum is a mistake in the program, not domain-level absence, and making it an Option would force an unwrap at every call site for a case the caller has already ruled out. Grid.find_all answers a Vec, which already encodes “nothing matched” as emptiness.

An Option is not the value

.get answers an Option[V], so it does not do arithmetic, index or compare as a V. This is the most common first surprise:

var counts = Map()
counts["a"] = 1

// `.get` answers an `Option`, so it is not an `Int` until a `match` takes it
// apart. Where the key is known to be present, index instead: `counts["a"]`.
out(counts.get("a") + 1)
$ praxis check docs/book/examples/records-enums/option-is-not-the-value.px
error[Y001]: expected Int, found Option[Int]

  option-is-not-the-value.px:6:5
  6 | out(counts.get("a") + 1)
    |     ^^^^^^^^^^^^^^^ expected Int, found Option[Int]

praxis: 1 error(s)

There are two spellings and you pick between them: counts.get(k) is explicit absence, and counts[k] is assertion-like access that faults on a miss. Where the key was just inserted three lines up, index. Where it might not be there, match.

Anonymous enums

The input parser’s choice constructor derives an enum with no declaration, one variant per case, each carrying the case’s own payload. It renders as its variants — { Mul({ a: Int, b: Int }) | Do(Unit) | Dont(Unit) } — and it behaves like a declared enum in every way except that it has no name to write in an annotation. Matching one is in the pattern chapter.

Pattern matching

match takes a value apart and picks the first arm whose pattern fits. Arms are separated by a comma or a line break, an arm body is any expression, and the whole match is an expression — so it is either the value of a binding or a statement, depending on where you put it.

The checker requires that the arms cover the type, and that no arm is dead. Both are the same question asked twice, and both are answered by praxis check.

struct Point { x: Int, y: Int }
enum Tile { Empty, Number(Int) }

// A literal pattern tests the value; `_` matches anything and binds nothing.
fn word(n: Int) -> Text {
    match n {
        0 => "zero"
        1 => "one"
        _ => "many"
    }
}
out(word(0))
out(word(9))

// A variant pattern tests the tag and takes the payload apart. A bare name that
// is a variant of the scrutinee's enum is that variant, not a binding.
fn cost(t: Tile) -> Int {
    match t {
        Empty => 1
        Number(n) => n
    }
}
out(cost(Empty))
out(cost(Number(6)))

// A record pattern names fields in any order, and a field it does not name is
// left alone. A punned field binds under its own name.
fn quadrant(p: Point) -> Int {
    match p {
        Point { x: 0, y: 0 } => 0
        Point { x: 0, y } => y
        Point { y: 0, x } => x * 10
        Point { x, y } => x * 100 + y
    }
}
out(quadrant(Point { x: 0, y: 0 }))
out(quadrant(Point { x: 0, y: 7 }))
out(quadrant(Point { x: 7, y: 0 }))
out(quadrant(Point { x: 1, y: 2 }))

// A tuple pattern binds by position, and patterns nest inside one another.
out(match (1, 2, 3) { (a, _, c) => a + c })
out(match Some((4, 5)) { Some((a, b)) => a * b, None => 0 })

// `true` and `false` are literal patterns, and they are the whole of `Bool`.
out(match 3 > 2 { true => "yes", false => "no" })
zero
many
1
6
0
7
70
102
4
20
yes

The pattern forms

pattern := "_"                                            // wildcard
         | literal                                        // Int, Text, Char, true, false
         | Ident                                          // binding, or payload-less variant
         | Ident "(" [pattern ("," pattern)*] ")"         // enum variant
         | Ident "{" [pattern_field ("," pattern_field)*] "}"  // record
         |       "{"  pattern_field ("," pattern_field)*  "}"  // headless record
         | "(" pattern ("," pattern)* ")"                 // tuple
pattern_field := Ident [":" pattern]

That is the whole grammar. Some notes on the edges:

  • Literals are integers, text, characters, true and false. There is no float pattern, and no negative literal — -1 in pattern position is a parse error, because - is an operator and a pattern has no operators.
  • _ binds nothing. It is not an identifier named _: it declares no symbol, so two _ arms are not a duplicate declaration (the second is merely unreachable), and _ has no expression form — reading one is P001: expected an expression.
  • A record pattern may name fewer fields than the record has. The rest are wildcards. Naming a field the record does not have is Y114; naming one twice is Y115, field x is matched more than once, because the second binding would silently replace the first.
  • P {} is a record pattern, not a binding. Bare P binds the whole value under the name P; P {} names the record and matches on it, naming none of its fields — and since a record has one constructor, that covers the type, so an arm after it is Y121.
  • A headless {} is a parse error. It would bind nothing and test nothing, which is what _ is for; a second spelling of “matches everything” is how a half-written pattern becomes an irrefutable arm by accident.
  • Parentheses in pattern position are always a tuple. There is no grouping form, because a pattern has no precedence to override, so (p) is a one-element tuple pattern and Y123 reports it: a tuple pattern names two elements or more. () gets the same Y123, and a parse error after it.

There are no guards. A if n > 3 => … does not parse. Put the condition in the arm body, or match on the condition.

Matching on a character

A Char pattern is a character literal, written '#' (scalars). This is the shape a grid puzzle is made of, and it is the reason the literal exists: before it, a Char had no pattern form at all — "#" in a pattern is a Text, and the scrutinee is a Char, so the arm was Y001 and there was no third thing to write.

fn cell(c: Char) -> Text {
    match c {
        '#' => "wall"
        '.' => "open"
        'S' => "start"
        _ => "unknown"
    }
}

var row = "#.S?"
for c in row {
    out(cell(c))
}

out('#' == row[0])
wall
open
start
unknown
true

Char is an open type, like Int and Text, so the _ is required — there are more characters than a match can enumerate. The last line is the equivalence that makes this a change of spelling rather than a new type: '#' and "#"[0] are the same value, and subscripting a text is still how you get a character out of one you did not write down.

Patterns are not only for match

The pattern grammar is one production, so a for header and a closure parameter take the same shapes an arm does. Neither has a second arm to fall through to, so both must be given a shape that always fits.

struct Point { x: Int, y: Int }

// The pattern grammar is one production, so a `for` header and a closure
// parameter take the same shapes a match arm does.
var points = [Point { x: 1, y: 2 }, Point { x: 3, y: 4 }]
for { x, y } in points {
    out(x * y)
}

var pairs = [(1, 10), (2, 20)]
var sum = 0
for (weight, value) in pairs {
    sum = sum + weight * value
}
out(sum)

// A closure parameter is a pattern too. This one is headless, so it needs the
// annotation to say which record it takes apart.
out(points.map(|{ x, y }: Point| x + y))
2
12
50
[3, 7]

A shape that can fail is Y125, and the message says which of the two positions it is in. Lowering is where it is caught, not analysis, so praxis check passes the file and praxis run is what reports it:

var xs = [Some(1), None]

// A `for` binding has no second arm for an item that does not fit, so a pattern
// that can fail is `Y125`. This one is lowering's too: `praxis check` is silent
// and `praxis run` reports it.
for Some(n) in xs {
    out(n)
}
$ praxis check docs/book/examples/records-enums/match-refutable-binding.px
$ praxis run docs/book/examples/records-enums/match-refutable-binding.px
error[Y125]: a `for` binding must match every item, and a variant pattern does not

  match-refutable-binding.px:6:5
  6 | for Some(n) in xs {
    |     ^^^^^^^ a `for` binding must match every item, and a variant pattern does not

praxis: 1 error(s)

A closure parameter gets the same code and a message that says a closure parameter must match every argument. A literal is refutable too, so for 1 in [1, 2, 3] is Y125 as well.

The scrutinee decides which enum a variant pattern names

A variant pattern’s enum is the scrutinee’s, not whatever the constructor name happens to resolve to elsewhere in the file. Two enums may share a variant name and each match still reads its own.

enum Colour { Red, Green }
enum Light { Red, Amber }

// A variant pattern's enum is the *scrutinee's*. Both enums have a `Red`, and
// each `match` reads its own — no annotation on the pattern, and no ambiguity.
fn colour_code(c: Colour) -> Int {
    match c { Red => 1, Green => 2 }
}

fn light_code(l: Light) -> Int {
    match l { Red => 10, Amber => 20 }
}

out(colour_code(Green))
var stop: Light = Amber
out(light_code(stop))
2
20

That is what makes the next diagnostic possible: once the enum comes from the scrutinee, a name the enum has not is a mistake with nothing else it could mean.

enum Tile { Empty, Wall, Number(Int) }

// A misspelling with a payload has nothing else it could be, so it is reported.
fn cost(t: Tile) -> Int {
    match t { Empty => 1, Wall => 2, Numbr(n) => n }
}

out(cost(Empty))
$ praxis check docs/book/examples/records-enums/match-unknown-variant.px
error[Y122]: `Tile` has no variant `Numbr`

  match-unknown-variant.px:5:38
  5 |     match t { Empty => 1, Wall => 2, Numbr(n) => n }
    |                                      ^^^^^ `Tile` has no variant `Numbr`

praxis: 1 error(s)

The one that bites: a bare name is a binding

A bare Ident is a variant only if the scrutinee’s enum has one by that name. Otherwise it is a binding — and a binding matches everything.

enum Tile { Empty, Wall, Number(Int) }

// `Wal` is not a variant of `Tile`, so it is not a variant pattern — it is a
// binding, and a binding matches everything. The match is exhaustive, nothing
// is reported, and every tile that is not `Empty` takes the second arm.
fn cost(t: Tile) -> Int {
    match t {
        Empty => 1
        Wal => 2
    }
}

out(cost(Empty))
out(cost(Wall))
out(cost(Number(9)))
1
2
2

Nothing is reported, because nothing is wrong: Wal is a legal catch-all binding. If a match you expect to be exhaustive compiles without the arm you thought you needed, look for a misspelt payload-less variant. Writing Wal(_) instead would have been Y122.

A record pattern needs no head

The head of a record pattern is optional. A headless { a, b } pins its record from the scrutinee the way a tuple pattern always has, which is the only spelling available when the record is anonymous — a choice template’s payloads have no name a head could write.

// A `choice` template derives an anonymous enum whose payloads are anonymous
// records. Neither has a name, so a pattern that wants the fields cannot write
// a head — a headless `{ a, b }` pins its record from the scrutinee instead.
var instructions = read scan(choice(
    Mul: `mul({a:int},{b:int})`,
    Do: `do()`,
    Dont: `don't()`,
))

var on = true
var total = 0
for m in instructions {
    match m {
        Mul({a, b}) => { if on { total = total + a * b } }
        Do(_) => { on = true }
        Dont(_) => { on = false }
    }
}
out(total)

// The same walk, binding the whole payload and reading its fields instead.
var sum = 0
for m in instructions {
    match m {
        Mul(p) => { sum = sum + p.a * p.b }
        Do(_) => {}
        Dont(_) => {}
    }
}
out(sum)

Given this input:

xmul(2,3)don't()mul(4,5)do()mul(6,7)
48
68

A headless pattern needs a record it can see. Field names alone do not determine a record type — the language has no row variables — so a scrutinee nothing has pinned is reported rather than silently guessed at:

// A headless record pattern needs a record it can see. Field names alone do not
// determine a record type, so a scrutinee nothing has pinned is reported.
fn total(p) -> Int {
    match p { {x, y} => x + y }
}
out(total(1))
$ praxis check docs/book/examples/records-enums/match-headless-needs-a-record.px
error[Y123]: `{ … }` cannot tell which record it matches here; name the record (`P { … }`) or annotate the value

  match-headless-needs-a-record.px:4:15
  4 |     match p { {x, y} => x + y }
    |               ^^^^^^ `{ … }` cannot tell which record it matches here; name the record (`P { … }`) or annotate the value

praxis: 1 error(s)

The message names the two ways out and both work when the record has a name: write the head (match p { Point { x, y } => … }) or annotate the value (fn total(p: Point)). Neither is available for an anonymous record. It has no name for a head, and the type grammar has no record form to annotate with — { x: Int, y: Int } is a spelling diagnostics print, not one you can write. That is why the payload of a choice is matched at the scrutinee that already knows, as above.

Exhaustiveness and reachability

A match must cover every value its scrutinee can take, and every arm must match something the arms above it do not. These are one question — is this pattern useful against the ones before it? — and it is asked at every position a value has, not only at the top level.

A type has a closed signature when its values can be enumerated: an enum’s variants, Bool’s true/false, and the single constructor a record or a tuple each have. Everything else — Int, Float, Text, Char, Unit, functions, and a type inference could not pin — is open and needs a _.

A missing case is Y120, and the message names the shapes that are missing, up to three of them, with the arms to add:

enum Tile {
    Empty
    Wall
    Number(Int)
    Portal(Text)
}

fn cost(t: Tile) -> Int {
    match t {
        Empty => 1
        Number(n) => n
    }
}

out(cost(Empty))
$ praxis check docs/book/examples/records-enums/match-non-exhaustive.px
error[Y120]: non-exhaustive match: missing `Wall`, `Portal(_)`

  match-non-exhaustive.px:9:5
  9 |     match t {
     |     ^^^^^^^^^ non-exhaustive match: missing `Wall`, `Portal(_)`...
  10 |         Empty => 1
     | ^^^^^^^^^^^^^^^^^^...
  11 |         Number(n) => n
     | ^^^^^^^^^^^^^^^^^^^^^^...
  12 |     }
     | ^^^^^

help: add the missing match arms
              Wall => panic("todo")
              Portal(_) => panic("todo")

praxis: 1 error(s)

The help: text is a machine-applicable suggestion: an editor offers it as a quick fix, and the arms it writes compile, because panic fits whatever type the other arms produced. When the scrutinee has no signature to enumerate the message says missing a _ catch-all arm instead, because there is no shape to name.

Coverage goes through constructors, not just up to them. A one-variant enum does not make every match on it exhaustive:

enum Flag { On, Off }
enum Wrapped { Wrap(Flag) }

// Coverage is asked at every position a value has, not only at the top level:
// `Wrap` is named, and the `Off` inside it is not.
fn on(w: Wrapped) -> Int {
    match w { Wrap(On) => 1 }
}

out(on(Wrap(On)))
$ praxis check docs/book/examples/records-enums/match-non-exhaustive-payload.px
error[Y120]: non-exhaustive match: missing `Wrap(Off)`

  match-non-exhaustive-payload.px:7:5
  7 |     match w { Wrap(On) => 1 }
    |     ^^^^^^^^^^^^^^^^^^^^^^^^^ non-exhaustive match: missing `Wrap(Off)`

help: add the missing match arms
          Wrap(Off) => panic("todo")

praxis: 1 error(s)

An arm that can never run is Y121. The obvious case is an arm after a catch-all:

enum Tile { Empty, Wall, Number(Int) }

fn cost(t: Tile) -> Int {
    match t {
        Empty => 1
        _ => 0
        Number(n) => n
    }
}

out(cost(Empty))
$ praxis check docs/book/examples/records-enums/match-unreachable.px
error[Y121]: unreachable match arm

  match-unreachable.px:7:9
  7 |         Number(n) => n
    |         ^^^^^^^^^^^^^^ unreachable match arm

praxis: 1 error(s)

The less obvious ones are a repeated constructor (A => 1, A => 2) and a nested pattern an earlier arm already subsumed (Some(n) followed by Some(_)) — the same walk finds all three.

The two halves meet at a record: a record has exactly one constructor, so naming it covers the type, and a _ after it is dead.

struct Point { x: Int, y: Int }

// A record has one constructor, so naming it covers the type: the `_` below can
// never run, and an arm that can never run is an error.
fn sum(p: Point) -> Int {
    match p {
        Point { x, y } => x + y
        _ => 0
    }
}

out(sum(Point { x: 1, y: 2 }))
$ praxis check docs/book/examples/records-enums/match-record-needs-no-catch-all.px
error[Y121]: unreachable match arm

  match-record-needs-no-catch-all.px:8:9
  8 |         _ => 0
    |         ^^^^^^ unreachable match arm

praxis: 1 error(s)

match p { Point { x: 0, y } => y } is the other half of the same fact: the constructor is covered, the 0 inside it is not, and the witness names the shape — missing `Point { x: _, y: _ }`.

An unreachable arm still covers what it names, whether or not it can run, so { _ => 1, A => 2 } does not then report a missing B on account of the arm it has just rejected.

Where the check runs

Y120 and Y121 come from analysis, which means praxis check, praxis run and the language server all see them at the same place with the same message. The editor underlines the match and offers the arms.

The check runs after inference rather than inside it, because a scrutinee’s type is not final while inference is still on the stack: a match on an unannotated parameter can be pinned by a call further down the file, and a coverage answer given against a type variable would be a Y120 demanding a _ the program does not need.

So does every other pattern mistake: a payload the pattern does not fit (Y124), and a pattern that can fail in a for header or a closure parameter (Y125). Every diagnostic a well-formed program can earn is analysis’s, so the editor underlines all of them as you type.

Collections

Praxis ships ten collections and no way to define an eleventh. They are built into the compiler: each one is a fixed set of rows in the method catalog, with a runtime representation the collector knows how to trace. There are no traits, no generic containers you write yourself, and no imports — every name below is already in scope.

typewhat it iskey or element requirement
Vec[T]growable ordered sequence; has a literalnone
Deque[T]double-ended queuenone
Map[K, V]hash mapK immutable and hashable
Set[T]hash setT immutable and hashable
Counter[T]map whose absent values read as zeroT immutable and hashable
MinHeap[T]priority queue, smallest firstT orderable
MaxHeap[T]priority queue, largest firstT orderable
BitSetcompact set of non-negative Ints— (members are Int)
Grid[T]rectangular 2-D arraynone
Rangehalf-open interval over Int— (members are Int)

Grid[T] is covered in grids and graphs, because everything interesting about it is two-dimensional. Text is a scalar rather than a collection, but it subscripts and iterates like one; see Text and Char.

Tuples are not in the table — they are a structural type rather than a collection — but they are what makes Map[(Int, Int), T] work, so they are described below.

Constructing a collection

Every collection but Range is built by calling its name:

var v = Vec()
var q = Deque()
var m = Map()
var s = Set()
var counts = Counter()
var lo = MinHeap()
var hi = MaxHeap()
var bits = BitSet()

The element types come from what the program later puts in. When that is not enough — or when you would rather say it than derive it — write the type arguments in brackets before the parentheses:

var counts = Counter[(Int, Int)]()
var ages = Map[Text, Int]()

Those brackets are type arguments, not a subscript, because the name in front is a compiler-owned type constructor. That is the whole rule, and its price is stated rather than hidden: a var binding that shadows one of the ten constructor names cannot be subscripted. The arguments unify with the call’s own variables, so a disagreement is reported at the use that disagrees:

var c = Counter[Text]()
c.inc(1)
$ praxis check type-arg-mismatch.px --color never
error[Y001]: expected Text, found Int

  type-arg-mismatch.px:2:7
  2 | c.inc(1)
    |       ^ expected Text, found Int

praxis: 1 error(s)

Substituting the annotation instead of unifying would have made it win silently; inferring first and then comparing would have reported at the constructor, which is not where the mistake is. The wrong number of arguments is Y007:

error[Y007]: `BitSet` takes 0 type argument(s), but 1 were given

Building one at a size

Vec and Grid also take a size and a fill, which is how you get the working collection an algorithm allocates for itself — an occupancy board, a visited mask, a distance table, a DP row:

// `Vec(n, fill)` and `Grid(w, h, fill)`: the collection an algorithm allocates
// for itself, rather than one it reads or grows a push at a time.
var row = Vec(5, 0)
var board = Grid(3, 2, '.')
board[1, 1] = '#'

out(row)
out(board)
out(board.width())
out(board.height())
[0, 0, 0, 0, 0]
[., ., ., ., #, .]
3
2

The element type comes from the fill, so Vec(3, false) is a Vec[Bool] with nothing written down, and the bracket form composes with it when you would rather say it: Vec[Bool](3, false).

Only those two have a sized form. The other seven take no arguments at all, and Set(3, 0) is an error saying so. Praxis has no arity overloading anywhere else — one name, one signature — and these two are a deliberate, closed exception. Vec and Grid are the collections whose contents are addressed by position, which is what makes “n of them” mean something: a sized Set would be n copies of one element in a set, which is one element. The exception costs the general rule nothing: the shape is chosen by counting the arguments, a syntactic fact known before any argument is typed, and never by looking at what they are.

The fill is one value stored n times, not n copies of it. For a scalar that is unobservable, but a collection fill gives you n names for the same collection:

// The fill is one value stored in every slot, not one copy per slot — the same
// reference semantics `var b = a` has. A push into any cell is visible from all
// four, because they are the same `Vec`.
var cells = Grid(2, 2, Vec())
cells[0, 0].push(1)
out(cells[1, 1])
[1]

That is the same reference semantics a collection already has everywhere else: a binding names an object rather than owning it, so var b = a does not copy and neither does this. If you want n distinct collections, build them: (0..n).map(|_| Vec()).

A negative size — or one so large the runtime cannot allocate it — is not something praxis check can refuse, because the size is an ordinary Int computed at run time. It is a fault instead, and the expression that asked for it is named in the report:

// A size is an ordinary `Int` computed at run time, so a negative one is not
// something `praxis check` can refuse. It is a fault.
var n = 0 - 1
var v = Vec(n, 0)
out(v)
error: program faulted: size or extent out of range

Backtrace:
#0   <entry>

  locals:
    n: Int = -1
    v: Vec[Int] = <uninit>
  temps:
    <tmp#1: Int> @ "0" = 0
    <tmp#2: Int> @ "1" = 1
    <tmp#3: Int> @ "0 - 1" = -1
    <tmp#5: Int> @ "0" = 0
    <tmp#6: Vec[Int]> @ "Vec(n, 0)" = <uninit>

The ceiling is 2²⁸ items, which is two gigabytes of references before a single element object exists — a judgement about what a program plausibly asks for rather than what a usize happens to hold.

The list literal

Vec is the one collection with a literal. [a, b, c] is Vec() followed by one push per element, in source order — same type, same methods, same mutability. There is no separate immutable array.

// A list literal is a Vec: an allocation followed by one push per element.
var v = [3, 1, 2]
v.push(4)
v[0] = 30

out(v)
out(v.len())
out(v[1])
out(v.get(3))
out(v.is_empty())

// An empty literal has no element to read a type from, so the use decides —
// here, an annotation.
var names: Vec[Text] = []
names.push("ada")
out(names)
[30, 1, 2, 4]
4
1
4
false
[ada]

Inference mints one fresh element variable and unifies each element with it in turn, so [] is the ordinary case rather than an exception, and a mixed literal reports at the element that disagrees: [1, "a"] is error[Y001]: expected Int, found Text under the "a".

A [ that begins an expression opens a literal; a [ that continues one subscripts. Position is the whole tie-break, which means a subscript has to be written on one line with its receiver — a v at the end of one line and a [i] at the start of the next is two statements, a value and a list literal that goes nowhere, and nothing reports it.

Subscripting

A subscript is a method-catalog row dispatched on the receiver’s shape and its arity, under names no program can spell. Six types read; five of those six also store.

receiverx[i] readsx[i] = v storesmin= / max=
Vec[T]T, faults out of rangereplaces, faults out of range
Deque[T]T at 0-based front offsetreplaces, faults out of range
TextChar by Unicode scalar— (a Text is immutable)
Map[K, V]V, faults if absentsets, replacing any prior valueyes, when V is Int
Counter[T]Int, zero if absentsets the count outright
Grid[T]T at [x, y]sets the cell at [x, y]

Set, the heaps, BitSet and Range have no subscript at all, and there is no slicing anywhere — an index is a single Int, so v[0..2] is a type error (expected Int, found Range) rather than a slice. s[0] on a Set[Int] is:

error[Y020]: values of type `Set[Int]` cannot be indexed with 1 index(es)

Because it goes through the same dispatch a method call does, a subscript is exactly as generic as a method call, and no more: a function that indexes an unannotated parameter infers, and the first call site decides what the parameter was. Passing a second receiver kind through the same function is a disagreement about that function’s signature rather than a second instantiation — given fn first(c, k) { c[k] }, calling it on both a Map[Text, Int] and a Vec[Int] reports:

error[Y001]: expected (Map[Text, Int], Text) -> ?T, found (Vec[Int], Int) -> ?T

A missing Map key faults

m[k] is the assertion-like read and m.get(k) is the one that answers with absence. They are two different catalog rows pointing at two different runtime wrappers, so the choice is the program’s.

// A subscript is dispatched exactly like a method call, so `m` and `k` need no
// annotation: the call site below is what says they are a `Map[Text, Int]` and
// a `Text`.
fn lookup(m, k) {
    m[k]
}

var ages = Map[Text, Int]()
ages["ada"] = 36
out(lookup(ages, "grace"))
error: program faulted: index out of bounds

Backtrace:
#0   lookup
#1   <entry>

  locals:
    m: Map[Text, Int] = {"ada": 36}
    k: Text = "grace"
  temps:
    <tmp#3: Int> @ "m[k]" = <uninit>

The fault kind is IndexOutOfBounds — an index the collection does not hold. A dedicated MissingKey would read better and does not exist. Run without --debug never and that snapshot becomes an interactive session; see the fault model.

A Counter read never faults, which is the whole point of the type: an absent key reads as zero, and that is what makes counts[k] += 1 work on a key never seen before.

Stores replace, and compound stores evaluate the place once

A Vec or Deque store replaces the element at that index and never appends. v[v.len()] = x is a fault, not a push:

fn store(v, i, x) {
    v[i] = x
}

var v = [1, 2, 3]
store(v, v.len(), 4)
out(v)

That program faults with index out of bounds. push is the spelling that grows a sequence.

m[k] += v is not desugared into m[k] = m[k] + v. The receiver and every index are lowered once into locals that both the read and the write use, so m[f()] += 1 calls f exactly once.

min= and max=

Map has two updating stores, and they exist because a read-modify-write cannot express them: an absent entry accepts the first value, where a plain subscript read of an absent key would fault.

var distance = Map[Text, Int]()

// An absent entry accepts the first value, so no key has to be seeded.
distance["b"] min= 7
distance["b"] min= 4
distance["b"] min= 9
out(distance["b"])

var best = Map[Text, Int]()
best["b"] max= 7
best["b"] max= 4
best["b"] max= 9
out(best["b"])
4
9

Three constraints on the form. The value type must be Int — the wrappers compare through the integer payload, so m["a"] min= "y" is error[Y001]: expected Int, found Text. Map is the only receiver, and a Counter gets its own message rather than the plain-store one:

error[Y020]: values of type `Counter[?T]` cannot be updated with `min=` through 1 index(es)

And the operator is contextual and adjacent: min and max are still ordinary prelude functions, so it is the = touching the identifier that makes the pair an operator. d[k] min = 3, with a space, is two statements run together, and reports as such.

Vec

methodanswer
push(T)Unit — append to the end
len()Int
get(Int)T — faults IndexOutOfBounds if out of range
is_empty()Bool
to_text()Text — the elements as one line; they must be Char
v[i], v[i] = xsee Subscripting

get(i) and v[i] are two spellings of one row and behave identically — neither answers an Option, and both fault out of range. There is no contains, pop, insert, remove, first or last; the pipeline stages (any, find, position, sorted, …) are where those questions get asked. Reversing is one of them: v.reversed() answers a new Vec and leaves the receiver alone, and there is no in-place reverse. The example under the list literal exercises every row above.

to_text() is the odd one out on this table, because it is the only row here that is not about a Vec of anything — a Vec[Char] becomes the line it spells, which is how a Grid row is drawn back.

Deque

methodanswer
push_front(T), push_back(T)Unit
pop_front(), pop_back()T — faults on an empty deque
len()Int
get(Int)T — 0-based from the front
is_empty()Bool
d[i], d[i] = x0-based from the front
var queue = Deque()
queue.push_back("b")
queue.push_back("c")
queue.push_front("a")

out(queue)
out(queue.len())
out(queue[0])

queue[1] = "B"
out(queue.pop_front())
out(queue.pop_back())
out(queue)
[a, b, c]
3
a
a
c
[B]

Map

methodanswer
insert(K, V)Unit — replaces any prior value
get(K)Option[V]
contains(K)Bool
remove(K)Unit
len(), is_empty()Int, Bool
keys()Vec[K]
values()Vec[V]
m[k], m[k] = v, m[k] min= v, m[k] max= vsee Subscripting
var ages = Map[Text, Int]()
ages["ada"] = 36
ages["alan"] = 41
ages["ada"] += 1

out(ages)
out(ages["ada"])
out(ages.len())
out(ages.contains("alan"))
out(ages.keys())
out(ages.values())

match ages.get("grace") {
    Some(n) => out(n)
    None => out("no entry for grace")
}

ages.remove("alan")
out(ages)
{ada: 37, alan: 41}
37
2
true
[ada, alan]
[37, 41]
no entry for grace
{ada: 37}

keys() and values() share one ordering, so they are index-aligned. To get both halves joined, iterate the map or call to_vec(), which answers the (K, V) pairs.

Set

methodanswer
insert(T)Unit
remove(T)Unit — a no-op if absent
contains(T)Bool
len(), is_empty()Int, Bool
var seen = Set()
seen.insert(3)
seen.insert(1)
seen.insert(3)

out(seen)
out(seen.len())
out(seen.contains(1))
out(seen.contains(2))

seen.remove(1)
out(seen)
out(seen.is_empty())
{1, 3}
2
true
false
{3}
false

There are no set operations — no union, intersection or difference. A filter over one and to_set() on the result is the spelling.

Counter

A Counter[T] is a map from T to Int whose absent values read as zero.

methodanswer
get(T)Int — zero if absent, never faults
inc(T)Unit — add one
len(), is_empty()Int, Boollen counts distinct keys
keys()Vec[T]
values()Vec[Int]
c[k], c[k] = nsee Subscripting
var counts = Counter[Text]()
for word in ["the", "cat", "the", "sat"] {
    counts[word] += 1
}
counts.inc("cat")

out(counts)
out(counts["the"])
out(counts.len())
out(counts.keys())
out(counts.values())

// An absent key reads as zero, does not fault, and creates nothing.
out(counts["dog"])
out(counts.len())

// A store is not zero-defaulting: it sets the count outright, and a stored
// zero is an entry like any other.
counts["dog"] = 0
out(counts.len())
out(counts)
{cat: 2, sat: 1, the: 2}
2
3
[cat, sat, the]
[2, 1, 2]
0
3
4
{cat: 2, dog: 0, sat: 1, the: 2}

Reading an absent key does not create it — len() is 3 before and after counts["dog"]. Storing one does, even a zero. If you want a counter built from a sequence in one call, frequencies() is it; see pipelines.

MinHeap and MaxHeap

methodanswer
push(T)Unit
pop()T — smallest (MinHeap) or largest (MaxHeap); faults if empty
peek()T — the same element, not removed; faults if empty
len(), is_empty()Int, Bool
var lo = MinHeap()
for n in [5, 1, 3] {
    lo.push(n)
}
out(lo.peek())
out(lo.pop())
out(lo.pop())
out(lo.len())

var hi = MaxHeap[Int]()
for n in [5, 1, 3] {
    hi.push(n)
}
out(hi.peek())

// Walking a heap does not drain it: the loop reads a snapshot in pop order.
for n in hi {
    out(n)
}
out(hi.len())
out(hi.is_empty())
1
1
3
1
5
5
3
1
3
false

A heap’s element type must be orderable, and no composite is. Only Int, Float, Char and Text are ordered, so the Dijkstra habit of pushing a (cost, node) pair does not compile:

var frontier = MinHeap()
frontier.push((3, "b"))
$ praxis check heap-of-pairs.px --color never
error[Y006]: values of type `(Int, Text)` cannot be ordered

  heap-of-pairs.px:2:10
  2 | frontier.push((3, "b"))
    |          ^^^^ values of type `(Int, Text)` cannot be ordered

praxis: 1 error(s)

The requirement rides on the receiver’s type, not on push, so it is enforced wherever the heap’s element type gets pinned. For weighted shortest paths, reach for the dijkstra helper in grids and graphs instead of building the frontier by hand.

BitSet

A compact set of non-negative Ints. Members are bit positions, not objects, so there is no element type and BitSet[Int]() is an arity error.

methodanswer
insert(Int)Unit — faults on a negative or oversized member
remove(Int)Unit
contains(Int)Bool
len()Int — the popcount
is_empty()Bool
var bits = BitSet()
bits.insert(64)
bits.insert(1)
bits.insert(300)
bits.insert(1)

out(bits)
out(bits.len())
out(bits.contains(64))
out(bits.contains(65))

// Members come out in ascending numeric order.
for n in bits {
    out(n)
}

bits.remove(64)
out(bits)
out(bits.is_empty())
{1, 64, 300}
3
true
false
1
64
300
{1, 300}
false

A member outside 0..=4294967295 faults with size or extent out of range.

Range

a..b is the integers from a up to but not including b; a..=b includes b. Both build the same value — the inclusive form is normalized into its half-open equivalent, which is why 1..=4 prints as 1..5.

A Range has no methods at all, and no subscript:

error[Y110]: no method `len` on type `Range` taking 0 argument(s)

What it has is iteration, and therefore every pipeline stage — which is where count() and sum() below come from.

out(1..4)
out(1..=4)

for i in 1..4 {
    out(i)
}

out((1..4).count())
out((1..4).sum())

// A descending range is empty, not a countdown. The countdown is a barrier.
out((5..0).count())
out((0..5).reversed())

// A Range has no mutator, so it is usable as a Map key.
var spans = Map()
spans[1..4] = "first three"
out(spans[1..4])
1..4
1..5
1
2
3
3
6
0
[4, 3, 2, 1, 0]
first three

A descending range is empty rather than reversed, matching Python and Rust. There is no step or stride form, and 5..0 earns no diagnostic — it is a legal empty collection, and the constructor clamps rather than the literal being refused.

The countdown is written (0..5).reversed(), which answers a Vec[Int] because a pipeline’s currency is Vec — not a descending Range, since no such value exists. See barriers for why reversal needs the whole sequence.

Tuples

A tuple is an anonymous positional product: (a, b), elements read as .0, .1, and so on. Its identity is structural — the element type sequence alone — so two (Int, Int)s built anywhere in the program compare and hash as the same shape.

var point = (3, 4)
out(point)
out(point.0)
out(point.1)

// A tuple's identity is structural: same elements, same value.
out((3, 4) == point)
out((4, 3) == point)

// Mixed element types are fine, and the arity is part of the shape.
var row = ("ada", 36, true)
out(row.1)

// Tuples key a Map, which is what makes (x, y) coordinates work.
var grid = Map()
grid[(0, 0)] = "start"
grid[(3, 4)] = "goal"
out(grid[point])
out(grid.len())
(3, 4)
3
4
true
false
36
goal
2

A tuple element is not an assignable place: t.0 = 1 is error[Y021]: the left side of an assignment must be a name, a field, or an index. And no tuple is orderable, so a Vec of pairs has no sorted()sorted_by_key(|p| p.1) is the spelling, and pattern matching is how a pair gets destructured into names.

Iteration and its order

Every collection is iterable, and so is a Text. The order is fixed and seed-independent — a program’s answer never depends on a hash table’s per-process seed.

receiverfor yieldsorderhow
Vec[T]Tindex orderwalked in place
Deque[T]Tfront to backwalked in place
RangeIntascendingwalked in place
TextCharby Unicode scalarwalked in place
Set[T]Tascending by memberone snapshot
BitSetIntascending numericallyone snapshot
MinHeap[T]Tpop order (ascending)one snapshot
MaxHeap[T]Tpop order (descending)one snapshot
Grid[T]Tcells, row-majorone snapshot
Map[K, V](K, V)ascending by keytwo aligned snapshots
Counter[T](T, Int)ascending by keytwo aligned snapshots

“Ascending” is the value’s order, not the printed text’s: numeric for Int and Float, code-point for Char and Text, false before true, and element-wise left to right for a tuple, a record or an enum. It is the same order sorted() uses, so out(s) and out(s.to_vec().sorted()) print the same sequence, and a Map[(Int, Int), V] over a grid comes out in reading order.

Every key type has such an order, including the ones you cannot write < on: a tuple orders inside a container and (1, 2) < (1, 3) is still refused at check time. Ordering a container is a question about determinism; < is a question about the language.

// A hashed collection walks its members in the *value's* order, not in the
// order they print: 2 before 10, and the same sequence on every run.
var seen = Set()
for n in [1, 2, 10, 20, 3] {
    seen.insert(n)
}
out(seen)
for n in seen {
    out(n)
}

// keys() and values() share that one order, so they are index-aligned.
var m = Map()
m[1] = "one"
m[10] = "ten"
m[2] = "two"
out(m.keys())
out(m.values())

// A `for` over a keyed collection yields the (key, value) pair itself.
for kv in m {
    out(kv.1)
}

// A keyed collection prints in the order it iterates: one order, not two.
var names = Map()
names["a"] = 1
names["a1"] = 2
out(names)
out(names.keys())

// A tuple key orders element-wise, left to right — which is what makes a
// Map[(Int, Int), V] over a grid come out in reading order.
var grid = Map()
grid[(1, 10)] = "b"
grid[(1, 9)] = "a"
grid[(0, 100)] = "z"
out(grid)
{1, 2, 3, 10, 20}
1
2
3
10
20
[1, 2, 10]
[one, two, ten]
one
two
ten
{a: 1, a1: 2}
[a, a1]
{(0, 100): z, (1, 9): a, (1, 10): b}

A keyed collection prints in the order it iterates. One Map has one order: out(m), keys(), values() and a for all walk it by the key’s own value, so a comes before a1 in every one of them. A collection that sorted its printed entries instead would put a1: 2 before a: 1, because 1 sorts before :, and a program that printed a map would disagree with a program that walked it.

The last column of the table is not decoration. A collection walked in place re-reads its length each step, so a push from inside the loop body is visited; a snapshotted one is materialized once before the first step and cannot be affected by the body at all.

// A Vec indexes itself, so a `for` over it re-reads the length each step and
// sees an element pushed by the body.
var v = [1, 2, 3]
for x in v {
    if x == 1 {
        v.push(99)
    }
    out(x)
}

// A Set does not: the loop walks a snapshot taken once, before the first step.
var s = Set()
s.insert(1)
for x in s {
    s.insert(2)
    out(x)
}
out(s)
1
2
3
99
1
{1, 2}

What may be a Map key or a Set element

A key must be hashable and immutable. The rule is mutability, not container-ness:

  • In: every scalar, Text, Range, and — structurally — tuples, records and enums, each a key exactly when all of its components are.
  • Out: Vec, Deque, Map, Set, Counter, MinHeap, MaxHeap, BitSet, Grid, and any function.
struct Point { x: Int, y: Int }

enum Dir { North, South }

// A key type is fixed by the first use, so each of these is its own Map.
var by_int = Map()
by_int[7] = "an Int"

var by_text = Map()
by_text["k"] = "a Text"

var by_tuple = Map()
by_tuple[(1, 2)] = "a tuple of Ints"

var by_record = Map()
by_record[Point { x: 1, y: 2 }] = "a record of Ints"

var by_enum = Map()
by_enum[North] = "an enum with no payload"

var by_range = Map()
by_range[1..4] = "a Range"

out(by_int[7])
out(by_text["k"])
out(by_tuple[(1, 2)])
out(by_record[Point { x: 1, y: 2 }])
out(by_enum[North])
out(by_range[1..4])
an Int
a Text
a tuple of Ints
a record of Ints
an enum with no payload
a Range

A mutable one is refused at check time, at the operation that stores it, in concrete terms:

var seen = Set()
seen.insert([1, 2])
$ praxis check mutable-key.px --color never
error[Y014]: a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

  mutable-key.px:2:6
  2 | seen.insert([1, 2])
    |      ^^^^^^ a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

help: use a value that cannot change — a number, `Text`, or a tuple of those

praxis: 1 error(s)

The requirement is on the collection’s key type rather than on insert, so it reaches through an unannotated parameter too. Given fn store(m, k) { m.insert(k, 1) }, a call store(m, [1, 2]) reports the same Y014 at the insert inside store, naming the Vec[Int] the call site put in k’s place.

The one hole in the rule

A record is accepted as a key, and a record field is assignable. Mutating a field of a key already stored moves the entry’s bucket without moving the entry:

struct Point { x: Int, y: Int }

var p = Point { x: 1, y: 2 }
var seen = Set()
seen.insert(p)
out(seen.contains(p))

// A field store changes the value the set hashed. The entry is still there —
// it is just no longer reachable by its own key.
p.x = 99
out(seen.contains(p))
out(seen.len())
out(seen)
true
false
1
{{ x: 99, y: 2 }}

The entry is still in the set and still prints, and nothing will ever find it again. Build a fresh record instead of storing into one you have used as a key. This is the exact hazard the mutable-container rule exists to prevent; the compiler does not catch it for records.

Conversions, and what is not here

Every collection converts to eight of the ten, by naming what it becomes: to_vec, to_set, to_map, to_counter, to_deque, to_min_heap, to_max_heap, to_bitset. These are pipeline sinks and are documented in pipelines, along with the stages that get you there. Two notes belong here:

  • The two missing ones are Grid and Range. There is no to_grid() — a grid needs a width and an item sequence does not carry one — and no to_range(), because a Range is written a..b rather than built from members.
  • The conversion has to typecheck: to_map needs an item that is a (K, V) pair and to_counter a (T, Int) pair, so [1, 2, 2].to_counter() reports expected (?T, Int), found Int. frequencies() is the call that counts.
  • Two conversions leave the collections entirely and answer a Text: seq.join(sep) on a sequence of Text, and chars.to_text() on a Vec[Char]. They are two rows rather than one because a generic join and a Char-specific one cannot both exist under one name.

Also absent, and a reader coming from Python or Rust will look for them: Vec.contains / pop / insert / remove, in-place reverse and sort (reversed() and sorted() answer new Vecs instead), set algebra, Deque.rotate, and a Range with a step. Each is a Y110 naming the receiver and the arity, so the compiler says which method on which type it could not find rather than guessing:

error[Y110]: no method `contains` on type `Vec[Int]` taking 1 argument(s)

Pipelines

A pipeline is a chain of method calls that walks a sequence of values: a source, some stages that transform the elements one at a time, and a sink that answers with a single value. The compiler knows every one of these methods by name and compiles a chain of them into a single loop over the source.

Two things distinguish it from the iterator chains you may be used to. It is eager — it runs where it is written, not where its result is consumed. And it materializes on its own — a chain that stops without a sink is already a Vec, so there is no collect.

A chain, from source to sink

// The shape of every pipeline: a source, some stages, and a sink.
var readings = [3, -1, 4, 1, -5, 9, 2, 6]

// A sink ends the chain and answers a scalar.
out(readings.filter(|x| x > 0).map(|x| x * x).sum())

// The same chain, one stage per line. A leading `.` continues the
// expression across the newline that would otherwise end the statement.
var answer = readings
    .filter(|x| x > 0)
    .map(|x| x * x)
    .sum()
out(answer)

// No sink: the chain still ends, and what it ends as is a Vec.
out(readings.filter(|x| x > 0).map(|x| x * x))

// `count` has two arities: every element, or the matching ones.
out(readings.count())
out(readings.count(|x| x < 0))
147
147
[9, 16, 1, 81, 4, 36]
8
2

The arguments are ordinary closures. Nothing about a closure changes because it is written inside a chain, and a closure bound to a variable works as well as one written in place.

A pipeline’s source is the for loop’s

The receiver of a pipeline is what a for loop walks, and it yields exactly what the loop’s variable would bind. There are ten of them, one short of the for loop’s own list:

ReceiverItem
Vec[T], Deque[T], Set[T], MinHeap[T], MaxHeap[T]T
Range, BitSetInt
TextChar
Map[K, V](K, V)
Counter[T](T, Int)
// A pipeline's item is the `for` loop's variable. The same source, twice.
var m = Map()
m["a"] = 1
m["b"] = 2

var loop_total = 0
for kv in m {
    loop_total = loop_total + kv.1
}
out(loop_total)
out(m.map(|kv| kv.1).sum())

// Same order, too. Both walks read the same deterministic snapshot.
for kv in m {
    out(kv.0)
}
out(m.map(|kv| kv.0))
3
3
a
b
[a, b]

Iteration order is deterministic and seed-independent for every receiver — a Set walked twice in one run, or in two runs, yields the same order — so a pipeline’s answer is a function of its input alone.

Here is one chain over each of the ten:

// One chain over each of the ten things a pipeline can start from.
// Vec[T] and Deque[T] yield T.
out([3, 1, 2].map(|x| x * 10).sum())

var d = Deque()
d.push_back(10)
d.push_front(20)
out(d.to_vec())

// Set[T], MinHeap[T] and MaxHeap[T] yield T, from a snapshot in a
// deterministic order.
var s = Set()
s.insert(3)
s.insert(1)
s.insert(2)
out(s.filter(|x| x > 1).sorted())

var lo = MinHeap()
lo.push(5)
lo.push(2)
out(lo.sum())

var hi = MaxHeap()
hi.push(5)
hi.push(2)
out(hi.max())

// Range and BitSet yield Int.
out((1..6).map(|n| n * n).sum())

var bits = BitSet()
bits.insert(2)
bits.insert(5)
out(bits.to_vec())

// Text yields Char — the same value `t[i]` answers.
out("mississippi".count(|c| c == 's'))
out("hello".filter(|c| c != 'l').to_vec())

// Map[K, V] yields (K, V), and Counter[T] yields (T, Int).
var m = Map()
m["a"] = 1
m["b"] = 2
out(m.map(|kv| kv.1).sum())
out(m.to_vec())

var tally = ["the", "cat", "the", "dog", "the"].frequencies()
out(tally.filter(|(word, n)| n > 1).map(|(word, n)| word))
60
[20, 10]
[2, 3]
7
5
55
[2, 5]
4
[h, e, o]
3
[(a, 1), (b, 2)]
[the]

Grid[T] is the one iterable that is not a pipeline receiver. A grid’s shape is part of its value, so grid.map(fn) does not resolve at all rather than quietly answering a flat sequence. A grid enters a pipeline through grid.cells() or grid.positions(), which already answer Vecs — see grids and graphs.

The catalog

Everything below is a row in the method catalog, keyed by receiver, name and arity — one row per combinator, shared by all ten receivers. T is the item type; U, K, V and Acc are fresh.

Streaming stages. Each transforms one element at a time and answers a Vec.

StageArgumentResult
map(f)(T) -> UVec[U]
filter(p)(T) -> BoolVec[T]
filter_map(f)(T) -> Option[U]Vec[U]
flat_map(f)(T) -> Vec[U]Vec[U]
take(n)IntVec[T]
skip(n)IntVec[T]
take_while(p)(T) -> BoolVec[T]
enumerate()Vec[(Int, T)]
zip(other)Vec[U]Vec[(T, U)]

Sinks. Each ends the chain with one value.

SinkArgumentResult
sum()Int — the item must be Int
product()Int — the item must be Int
count()Int
count(p)(T) -> BoolInt
min(), max()Int — the item must be Int; faults on an empty sequence
min_by(lt), max_by(lt)(T, T) -> BoolT — faults on an empty sequence
any(p), all(p)(T) -> BoolBool
find(p)(T) -> BoolOption[T]
position(p)(T) -> BoolOption[Int]
fold(init, f)Acc, (Acc, T) -> AccAcc
reduce(f)(T, T) -> TT — faults on an empty sequence

Conversions. Also sinks, so they fuse into the same loop.

ConversionResult
to_vec()Vec[T]
to_set()Set[T]
to_map()Map[K, V] — the item must be a (K, V) pair
to_counter()Counter[T] — the item must be a (T, Int) pair
to_deque()Deque[T]
to_min_heap(), to_max_heap()MinHeap[T], MaxHeap[T]
to_bitset()BitSet — the item must be Int

Barriers. These need the whole sequence before they can answer their first element, so they are not fused: a chain ends at one and begins again from its result.

BarrierArgumentResult
sorted()Vec[T]T must be orderable
sorted_by_key(f)(T) -> KVec[T]K must be orderable
unique()Vec[T], in first-occurrence order
reversed()Vec[T], back to front — no requirement on T
frequencies()Counter[T]
join(sep)TextText — the items must be Text
chunks(n)IntVec[Vec[T]] — consecutive runs of n, last may be short
windows(n)IntVec[Vec[T]] — every run of exactly n, sliding by one

reversed is the barrier with an empty requirement column, and that is its own claim rather than an omission: sorted reads the element’s compare callback and unique reads its hash and equals, while reversal reads nothing at all — so a Vec of closures reverses where it cannot be sorted. It is also what a countdown is written with: for i in (0..n).reversed(), since n..0 is an empty range rather than a descending one.

join is the one barrier that answers a scalar rather than a sequence. Its separator is a required argument, because the catalog has no optional ones — join("") is the no-separator spelling and says so where it is written — and it renders nothing: [1, 2].join(",") is expected Text, found Int, and the spelling is [1, 2].map(|n| n.to_text()).join(","). A sequence of Char uses to_text() instead, which is a Vec[Char] row rather than a pipeline one.

chunks and windows are the two that answer a sequence of sequences, and what separates them is what happens to a group that does not fill. A chunking partitions: every element appears once, in order, and a length the size does not divide leaves a short last chunk. A window slides by one and keeps only the runs that fit, so a sequence shorter than the size answers [].

// The two barriers that answer a sequence of sequences.
var v = [1, 2, 3, 4, 5]

// A chunking partitions: every element once, and a short last chunk.
out(v.chunks(2))
// A window slides by one and keeps only the runs that fit.
out(v.windows(2))
// Larger than the sequence: one short chunk, and no windows at all.
out(v.chunks(9))
out(v.windows(9))

// What they are for: "compare each element with its neighbour".
out(v.windows(2).count(|p| p[1] > p[0]))

// A group is a sequence in its own right, so the chain continues on it.
out(v.windows(2).map(|p| p.sum()))
out(v.chunks(2).map(|c| c.count()))
[[1, 2], [3, 4], [5]]
[[1, 2], [2, 3], [3, 4], [4, 5]]
[[1, 2, 3, 4, 5]]
[]
4
[3, 5, 7, 9]
[2, 2, 1]

Both names are plural because both answer many things, which is what every other such row in the catalog is called: frequencies, positions, cells, keys, items.

Groups share their elements rather than copying them. The 2 in v.windows(2)’s first group and the 2 in its second are one object, which is the same aliasing var b = a already has.

Both fault on a size of zero or less, and that is the only thing either refuses. A run of zero elements is not a short run — chunking a non-empty sequence into them has no finite answer — and a negative one names nothing. A size larger than the sequence is not that fault, as the example above shows: chunks answers one short chunk and windows answers none.

// A run of zero elements is not a short run, so there is no sequence of them.
var v = [1, 2, 3]
out(v.chunks(0))
error: program faulted: size or extent out of range

Backtrace:
#0   <entry>

  locals:
    v: Vec[Int] = [1, 2, 3]
  temps:
    <tmp#1: Vec[Int]> @ "[1, 2, 3]" = [1, 2, 3]
    <tmp#2: Int> @ "1" = 1
    <tmp#3: Unit> = Unit
    <tmp#4: Int> @ "2" = 2
    <tmp#5: Unit> = Unit
    <tmp#6: Int> @ "3" = 3
    <tmp#7: Unit> = Unit
    <tmp#9: Int> @ "0" = 0
    <tmp#10: Vec[Vec[Int]]> @ "v.chunks(0)" = <uninit>
    <tmp#11: Unit> @ "out(v.chunks(0))" = <uninit>

The Vec[Vec[Int]] in that report is the answer’s real type: a group is a sequence, not a flattened run of elements, and <uninit> is the slot the fault left unwritten.

sum, product, min and max are Int operations, not generically numeric. [1.5, 2.5].sum() is error[Y001]: expected Int, found Float. For anything else, min_by and max_by take a “less-than” comparator and work at any element type.

Each stage counts its own input

A stage cannot see the source. It sees what the stage before it handed it, and that is what take, skip, enumerate, zip and position count.

// Every stage that asks "which element is this?" counts its own input, not
// the source's.
var v = [1, 2, 3, 4, 5, 6, 7, 8]
var evens = |x| x % 2 == 0

// The first two survivors, not the survivors among the first two.
out(v.filter(evens).take(2))
out(v.filter(evens).skip(1))

// 0, 1, 2, 3 — the numbering the filter handed on.
out(v.filter(evens).enumerate())

// Paired with the argument's 0th, 1st, 2nd element.
out(v.filter(evens).zip(["a", "b", "c"]))

// The index among the evens: 6 is the third one.
out(v.filter(evens).position(|x| x == 6))

// A splice flattens, and the count keeps running across it.
out(v.take(3).flat_map(|x| [x, x * 10]).enumerate())
[2, 4]
[4, 6, 8]
[(0, 2), (1, 4), (2, 6), (3, 8)]
[(2, a), (4, b), (6, c)]
Some(2)
[(0, 1), (1, 10), (2, 2), (3, 20), (4, 3), (5, 30)]

The bound take and skip take is an ordinary Int expression, evaluated once before the loop. Degenerate bounds mean what they read as: take(0) and take(-1) are empty, skip(-1) drops nothing.

zip’s argument and flat_map’s closure result are Vecs specifically, not the ten receivers. The fused loop indexes both directly, so v.zip(s) on a Set is a type error at the argument and the spelling is v.zip(s.to_vec()).

fold carries an accumulator

fold is the sink for anything the fixed ones do not cover. The accumulator can be any type, including a record or a collection.

// `fold` threads an accumulator of any type through the chain.
struct Run {
    best: Int
    total: Int
    count: Int
}

var xs = [3, 9, 4, 1, 12, 7]

// A record accumulator: three answers from one pass.
var r = xs.fold(Run { best: 0, total: 0, count: 0 }, |acc, x| Run {
    best: max(acc.best, x),
    total: acc.total + x,
    count: acc.count + 1,
})
out(r.best)
out(r.total)
out(r.count)

// A Vec accumulator: a running total, one entry per element. A collection
// is a reference, so the closure hands the same one back each step.
var running = xs.fold([0], |acc, x| {
    acc.push(acc[acc.len() - 1] + x)
    acc
})
out(running)

// `reduce` is `fold` seeded with the first element, so it has no answer
// for an empty sequence and faults instead of inventing one.
out(xs.reduce(|a, b| max(a, b)))
12
36
6
[0, 3, 12, 16, 17, 29, 36]
12

reduce is fold without the seed: its accumulator is the element type, and the first element starts it.

Searching answers an Option

find answers the matching element, position answers its index, and a miss is None in both cases. The result is the ordinary Option enum, so a match is how you read it.

// `find` answers the element, `position` answers the index, and a miss is None.
var words = ["alpha", "beta", "gamma"]

match words.find(|w| w.len() == 4) {
    Some(w) => out("found " + w)
    None => out("nothing that long")
}

match words.position(|w| w.len() == 4) {
    Some(i) => out(i)
    None => out(-1)
}

// The sentinel problem an Option removes: -1 is a perfectly ordinary
// element and a perfectly ordinary index.
var v = [10, -1, 30]
out(v.find(|x| x < 0))
out(v.find(|x| x > 100))

// `any` and `all` stop as soon as the answer is decided.
out(v.any(|x| x < 0))
out(v.all(|x| x < 0))
found beta
1
Some(-1)
None
true
false

The Option is what retires a sentinel that used to be in band. -1 is a legal element of a Vec[Int] and a legal index of nothing, so a find that answered -1 on a miss could not tell [10, -1, 30]’s first negative from no match at all.

An empty sequence

Most sinks have a right answer for an empty source, and give it:

// The sinks that have a right answer for an empty sequence.
var scores: Vec[Int] = []
out(scores.sum())
out(scores.product())
out(scores.count())
out(scores.any(|x| x > 0))
out(scores.all(|x| x > 0))
out(scores.find(|x| x > 0))
out(scores.position(|x| x > 0))
out(scores.fold(100, |acc, x| acc + x))
out(scores.map(|x| x * 2))
0
1
0
false
true
None
None
100
[]

Five do not: min, max, min_by, max_by and reduce all derive their answer from an element, and an empty sequence has none. They fault, and they deliberately do not answer None: an empty min is a mistake in the program, where a find that matches nothing is ordinary domain absence. A 0 would be worse than either — it is below every element of [3, 4] and above every element of [-3, -4], and nothing at the call site could tell it from a real minimum.

// An empty `min` has no answer, and the fault says so.
var scores: Vec[Int] = []
out(scores.min())
error: program faulted: empty collection

Backtrace:
#0   <entry>

  locals:
    scores: Vec[Int] = []
  temps:
    <tmp#1: Vec[Int]> @ "[]" = []
    <tmp#3: Int> = 0
    <tmp#4: Int> = 0
    <tmp#8: Unit> @ "out(scores.min())" = <uninit>

That is what praxis run --debug never prints. With a terminal and the default --debug auto, the same fault opens the crash debugger at the failing frame instead.

A pipeline’s currency is Vec

Every streaming stage answers a Vec, whatever the source was. set.filter(p) is a Vec[T], not a Set[T]. A program that wants a collection back says which one:

// A pipeline's currency is Vec. To get a collection back, name it.
var s = Set()
s.insert(3)
s.insert(1)
s.insert(2)

out(s.filter(|x| x > 1))
out(s.filter(|x| x > 1).to_set().len())

// to_vec is the route out of a keyed collection: keys() and values()
// answer two aligned halves and nothing joins them.
var m = Map()
m["a"] = 1
m["b"] = 2
out(m.to_vec())

// ...and to_map is the route back in.
var scaled = m.map(|kv| (kv.0, kv.1 * 10)).to_map()
out(scaled["b"])

// The rest of the set, one per collection that has a constructor.
out([1, 2, 3].map(|x| x % 2).to_set().len())
out([3, 1, 2].to_deque().pop_front())
out([3, 1, 2].to_min_heap().pop())
out([3, 1, 2].to_max_heap().pop())
out([1, 4].to_bitset().contains(4))
out(["a", "b", "a"].frequencies().to_vec().to_counter()["a"])

// On a Vec receiver, to_vec is the identity — the same reference, not a
// copy.
var v = [1]
var same = v.to_vec()
same.push(2)
out(v.len())
[2, 3]
2
[(a, 1), (b, 2)]
20
2
3
1
3
true
2
2

One rule instead of a rule per collection, and it is answerable without knowing which receiver you are on. The alternative was filter returning the receiver’s own type where a row happened to exist and a Vec otherwise, which nobody can hold in their head.

to_map and to_counter say “a pair” in their receiver, so a mismatch is a type error at the method name rather than a fault later: [1, 2].to_map() is error[Y001]: expected (?T, ?U), found Int. to_bitset says Int the same way, and still faults on a negative or oversized member, which is a value question no type can answer.

There is no to_grid(): a grid needs a width, and a flat item sequence does not carry one.

Barriers

// Six of the eight combinators that need the whole sequence before they
// answer. `chunks` and `windows` are above, with the groups they build.
var v = [3, 1, 4, 1, 5, 9, 2, 6, 5]

out(v.sorted())
out(v.unique())
out(v.reversed())
out(v.frequencies())
out(["a", "b", "c"].join(", "))

// A countdown is a reversed range: `5..0` is empty, not descending.
out((0..5).reversed())

// A chain ends at a barrier and begins again from its result.
out(v.filter(|x| x > 2).sorted().take(3))

// A pair is not orderable, so a Counter orders by an extracted key.
var tally = ["the", "cat", "the", "dog", "the", "cat"].frequencies()
out(tally.to_vec().sorted_by_key(|p| 0 - p.1))

// A barrier takes any source, not only a Vec.
var names = Set()
names.insert("bb")
names.insert("a")
names.insert("ccc")
out(names.sorted())
out(names.sorted_by_key(|t| t.len()))
[1, 1, 2, 3, 4, 5, 5, 6, 9]
[3, 1, 4, 5, 9, 2, 6]
[5, 6, 2, 9, 5, 1, 4, 1, 3]
{1: 2, 2: 1, 3: 1, 4: 1, 5: 2, 6: 1, 9: 1}
a, b, c
[4, 3, 2, 1, 0]
[3, 4, 5]
[(the, 3), (cat, 2), (dog, 1)]
[a, bb, ccc]
[a, bb, ccc]

sorted_by_key exists because a keyed collection cannot order its own items. No composite type is orderable, so the moment a pipeline’s item is a pair — the moment its source is a Map or a Countersorted is unavailable:

// A pipeline whose item is a pair has no `sorted`.
var m = Map()
m["a"] = 1
out(m.to_vec().sorted())
error[Y006]: values of type `(Text, Int)` cannot be ordered

  pair-not-orderable.px:4:16
  4 | out(m.to_vec().sorted())
    |                ^^^^^^ values of type `(Text, Int)` cannot be ordered

praxis: 1 error(s)

The closure extracts an orderable key from an item that is not one, so the ordering requirement moves off the element and onto the key. There is still no reverse flag on sorted, but there are now two ways to write a descending sort: 0 - p.1 as the key, or sorted().reversed(). The first is one pass and the second is two, which is the whole difference.

unique() and to_set() answer different questions: unique keeps first-occurrence order in a Vec, and a Set has no order to preserve. frequencies() and to_counter() are the two directions of the same type change — frequencies counts occurrences of each element, to_counter assigns the count each pair already carries.

What to unlearn, coming from Rust

There is no laziness. A pipeline runs at the point it is written. There is no adaptor object, no impl Iterator, nothing to hold and consume later.

// A pipeline runs where it is written. Nothing waits for a consumer.
fn seen(x) {
    out(x)
    x * 2
}

var v = [1, 2, 3]
var mapped = v.map(|x| seen(x))
out("--- the map is already finished ---")
out(mapped)
1
2
3
--- the map is already finished ---
[2, 4, 6]

There is no collect. A chain that ends on a streaming stage materializes anyway, so the word would name a step the compiler takes whether or not you write it. The method does not exist:

// `collect` is not a method. A chain materializes without being told to.
var v = [1, 2, 3]
out(v.map(|x| x * 2).collect())
error[Y110]: no method `collect` on type `Vec[Int]` taking 0 argument(s)

  no-collect.px:3:22
  3 | out(v.map(|x| x * 2).collect())
    |                      ^^^^^^^ no method `collect` on type `Vec[Int]` taking 0 argument(s)

praxis: 1 error(s)

to_vec() is not collect under another name. On a Vec it is the identity, and it answers the same reference rather than a copy; on the other nine receivers it is a real conversion, because nothing a Set or a Map holds is a Vec until something asks for one.

The chain is still one loop. Eager does not mean a Vec per stage: v.map(f).filter(p).sum() compiles to a single loop with no intermediate allocation. A stage or sink that stops the stream therefore stops the whole loop, which is observable when a stage has a side effect:

// The whole chain is one loop over the source, so a stage or sink that stops
// the stream stops the loop.
fn seen(x) {
    out(x)
    x
}

var v = [1, 2, 3, 4, 5]

// `any` answers as soon as it can, and the map behind it stops with it.
out(v.map(|x| seen(x)).any(|x| x > 1))
out("---")

// `take` stops when it meets the element after the last one it keeps, so
// the stage in front of it runs once more than it keeps.
out(v.map(|x| seen(x)).take(2))
1
2
true
---
1
2
3
[1, 2]

A source that indexes itself — Vec, Deque, Range, Text — is walked in place. The rest are snapshotted once before the loop, which is what a for over them already does.

A pipeline is not an expression type. There is no Seq[T] you can annotate, pass to a function or store in a record: var xs: Seq[Int] = [] is reported as N002, an unknown type. The value between two stages does not exist at run time, and the value at the end of a chain is an ordinary one — a Vec, the collection a conversion named, or a scalar.

A Grid is not one of the ten. g.map(|c| c) is a Y110, and g.cells() or g.positions() is the way into a chain: a grid’s shape is part of its value, and a stage that flattened it would be answering about something else.

Grids and graphs

Two things live in this chapter. Grid[T] is the rectangular two-dimensional collection: fixed width and height, one T per cell, indexed grid[x, y]. And the prelude has twelve graph helpers — the bfs, dfs, dijkstra and A* families, plus flood_fill — which take a start state and a closure that answers “what is next to here?”. They are related because most puzzle graphs are a grid, but neither needs the other: a walk never sees a grid, and a grid knows nothing about search.

Coordinates

(x, y), with x increasing rightward and y increasing downward. x is the column and y is the row, so grid[0, 2] is the leftmost cell of the third line of input. Every position a grid method hands back is an (Int, Int) tuple in that order.

Where a grid comes from

From the input parser, in practice. grid(P) reads one cell per character of each line; matrix(P) reads one cell per whitespace-separated token. Both are covered in structural parsers.

And from the program itself. Grid() is the empty 0×0 grid; Grid(w, h, fill) is the working grid an algorithm allocates for itself — an occupancy board, a visited mask, a distance table — with every cell starting as fill. That is the difference between using Grid[T] and reimplementing it: a hand-rolled Vec[Bool] indexed y * w + x has no contains, no neighbors4, and no bounds behaviour at all. There is still no to_vec-style to_grid on any sequence, so a grid is either read or allocated.

When fill is itself a collection, every cell is the same collection — Grid(2, 2, Vec()) is four names for one Vec — because a binding names an object rather than owning it. And a negative extent, or one whose w × h is past 2^28 cells, is a size or extent out of range fault rather than a check-time refusal: the extents are ordinary Ints computed at run time.

// `read grid(char)` makes every character of every line a cell.
var map = read grid(char)
var rock = '#'

out(map.width())
out(map.height())

// `map[x, y]` is column `x` of row `y`: x rightward, y downward.
// `map.get(x, y)` is the same read spelled as a method.
out(map[0, 0])
out(map.get(3, 1))

// Whether a position is inside is a question, not a fault.
out(map.contains(3, 1))
out(map.contains(9, 0))

// A row, a column, and the whole thing flattened in row-major order.
out(map.row(1))
out(map.column(0))
out(map.cells())

// A `for` walks the cells in that same order.
var rocks = 0
for cell in map {
    if cell == rock { rocks = rocks + 1 }
}
out(rocks)

// A grid is mutable in place; `map.set(x, y, v)` is the same store.
map[0, 0] = rock
map.set(0, 2, rock)
out(map.row(0))
out(map.row(2))

// A row prints as a Vec; `to_text()` draws it back as the line it was read as.
for y in 0..map.height() {
    out(map.row(y).to_text())
}

On the input

..#.
#...
.#.#

that prints

4
3
.
.
true
false
[#, ., ., .]
[., #, .]
[., ., #, ., #, ., ., ., ., #, ., #]
4
[#, ., #, .]
[#, #, ., #]
#.#.
#...
##.#

The wall is written '#', a character literal — the spelling for a character the program chose. "#"[0], a one-character Text subscripted at 0, is the same Char and is what a program reaches for when the character came out of text it did not write down. See Text and Char.

Note what a grid does not have. There is no len(): “how many” would have to choose between cells and rows, and the catalog picks neither, so map.len() is a Y110 — no such method on Grid[Char]. A grid is also not one of the ten pipeline receivers, so map.map(f) and map.filter(p) do not resolve either — map.cells() is the bridge, and map.cells().filter(p).count() is one fused loop over a Vec[T]. A bare for over the grid itself works and yields cells in row-major order, which is the case that mattered.

The working grid

The allocated grid above, in use: a visited mask over a maze, sized from the maze’s own extents and marked as the walk goes.

// `Grid(w, h, fill)` is the working grid — the one an algorithm allocates for
// itself rather than reads. Here it is a visited mask over a maze, filled with
// `false` and marked as the walk goes.
var maze = read grid(char)

var seen = Grid(maze.width(), maze.height(), false)
var frontier = Vec()
frontier.push((0, 0))
seen[0, 0] = true

var reached = 0
var at = 0
while at < frontier.len() {
    var cur = frontier[at]
    at = at + 1
    reached = reached + 1
    for n in maze.neighbors4(cur) {
        if maze[n.0, n.1] != '#' && !seen[n.0, n.1] {
            seen[n.0, n.1] = true
            frontier.push(n)
        }
    }
}

out(seen.width())
out(seen.height())
out(reached)
out(seen[0, 0])
out(seen.contains(0, 0))
out(seen.contains(seen.width(), 0))

On the input

....#
.##.#
....#

that prints

5
3
10
true
true
false

The mask is a Grid[Bool] and the maze a Grid[Char]: the two have the same extents and nothing ties them together, which is the usual arrangement. Written out by hand this is bfs — and the whole of the graph helpers below is the case where you would rather not.

Positions and neighbours

var g = read grid(char)
var blank = '.'

// Every position, row-major, as `(x, y)` tuples.
out(g.positions())

// Where a value is: the first one as `Option[(Int, Int)]`, or all of them.
out(g.find(blank))
out(g.find_all(blank))
out(g.find('z'))

// The in-bounds neighbours of a point. Both take one `(Int, Int)` and
// answer a `Vec[(Int, Int)]`, already clipped to the grid.
out(g.neighbors4((0, 0)))
out(g.neighbors8((0, 0)))
out(g.neighbors4((1, 1)))

// A pattern opens a tuple by binding both elements at once.
for (x, y) in g.neighbors4((1, 1)) {
    out(g[x, y])
}

// `p.0` and `p.1` select one element by position.
var p = g.neighbors4((1, 1))[0]
out(p.0)
out(p.1)

On the input

ab.
cde
.fg

that prints

[(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1), (0, 2), (1, 2), (2, 2)]
Some((2, 0))
[(2, 0), (0, 2)]
None
[(0, 1), (1, 0)]
[(1, 0), (0, 1), (1, 1)]
[(1, 0), (1, 2), (0, 1), (2, 1)]
b
f
c
e
1
0

The neighbour lists are already clipped, so a corner gets two entries and not four — no bounds test of your own. neighbors4 runs up, down, left, right; neighbors8 runs the 3×3 block row-major with the centre skipped. Which entry was which direction is not in the list; that is what around4 and around8, below, are for. find answers Option[(Int, Int)], so a value that is not there is None rather than a position nobody wrote; find_all needs no Option because an empty Vec already says it.

There are two ways to take a (Int, Int) apart. p.0 and p.1 select an element by position, and an index past the end is a Y019 at check time rather than a run-time surprise. A pattern takes the whole tuple at once — in a for binding as above, or in a match. If you want names, use a record as your position instead; both work everywhere a tuple does.

Naming the directions, and counting them

A clipped Vec drops two facts on the floor: which direction each neighbour was, and — at the edge — that there was a direction at all. around4 and around8 keep both. They answer a record, one field per direction, and every field is an Option[(Int, Int)]: None is the direction that leaves the grid.

The field order is the order the record prints in. around4 is the plus read off the page with the centre skipped — up, left, right, down. around8 is the whole 3×3 block in reading order, again with the centre skipped — up_left, up, up_right, left, right, down_left, down, down_right.

The four counts are the other half. count4(p, v) and count8(p, v) count the neighbouring cells that hold v; count4_where(p, f) and count8_where(p, f) count the ones whose cell a closure accepts. A neighbour off the grid has no cell, so it is not counted, and f is never called for it — there is no cell to hand it, and inventing one would mean choosing a value the cell type may not have.

// `#` is a wall. Which way is which, and how boxed in is a square?
var g = read grid(char)
var wall = '#'

// `around4` answers a record with one field per direction, named: `up`,
// `left`, `right`, `down`, in that order.
var corner = g.around4((0, 0))
out(corner)
out(corner.up)
out(corner.right)

// Every field is an `Option`, because a direction can leave the grid — the one
// thing a clipped `Vec` of neighbours cannot say.
match corner.down {
    Some(p) => out(g[p.0, p.1]),
    None => out("off the grid"),
}
match corner.up {
    Some(p) => out(g[p.0, p.1]),
    None => out("off the grid"),
}

// `around8` names all eight, in reading order with the centre skipped.
out(g.around8((1, 1)))

// The counts answer a number without building a collection first.
out(g.count4((1, 1), wall))
out(g.count8((1, 1), wall))
out(g.count4_where((1, 1), |c| c != wall))
out(g.count8_where((1, 1), |c| c != wall))

// A neighbour off the grid has no cell, so it is not counted and the closure
// is never called for it: the corner has two neighbours, not four.
out(g.count4_where((0, 0), |c| true))

On the input

..#
#..
.#.

that prints

{ up: None, left: None, right: Some((1, 0)), down: Some((0, 1)) }
None
Some((1, 0))
#
off the grid
{ up_left: Some((0, 0)), up: Some((1, 0)), up_right: Some((2, 0)), left: Some((0, 1)), right: Some((2, 1)), down_left: Some((0, 2)), down: Some((1, 2)), down_right: Some((2, 2)) }
2
3
2
5
2

The record’s type is called Around4 — a type error prints that name — but it is not a name you can write: var a: Around4 = ... is an N002. Bind it with a plain var and read its fields.

neighbors4 and neighbors8 are still here, and still answer a Vec. That is not a leftover. The graph helpers below type their neighbour argument as (T) -> Vec[T], so bfs(start, |p| g.neighbors4(p)) is the spelling a walk needs, and a record of four Options is not a Vec of anything. Use neighbors4 to walk and around4 to look; use a count when the answer was going to be a number and the loop only existed to add it up.

Turning a grid

var g = read grid(char)

fn show(name: Text, g: Grid[Char]) -> Unit {
    out(name)
    for y in 0..g.height() {
        out(g.row(y))
    }
}

show("original", g)
show("transpose", g.transpose())
show("rotate_right", g.rotate_right())
show("rotate_left", g.rotate_left())

// Each answers a new grid; the receiver is untouched.
show("still original", g)

On abc / def:

original
[a, b, c]
[d, e, f]
transpose
[a, d]
[b, e]
[c, f]
rotate_right
[d, a]
[e, b]
[f, c]
rotate_left
[c, f]
[b, e]
[a, d]
still original
[a, b, c]
[d, e, f]

out(grid) prints the cells flat, in row-major order, with no row structure — printing grid.row(y) in a loop, as show does, is how you look at the shape.

matrix(P) is a Grid[T]

There is no separate Matrix type. grid and matrix differ only in how each cuts a row into cells, and both answer Grid[T].

// The only difference between the two constructors is how a row is cut up.
// `grid` takes one cell per character; `matrix` takes one per whitespace-
// separated token. Both answer a `Grid[T]`.
var heights = parse("123\n456\n", grid(digit))
var readings = parse("12 3\n45 6\n", matrix(int))

out(heights.width())
out(heights.cells())

out(readings.width())
out(readings.cells())

// Same type, so the same methods.
out(heights.row(1))
out(readings.row(1))
3
[1, 2, 3, 4, 5, 6]
2
[12, 3, 45, 6]
[4, 5, 6]
[45, 6]

Both constructors require every row to have the same cell count; a short row is a parse fault unless you ask for grid(P, ragged, fill: "X"). That is the input parser’s business, not the grid’s.

When an index is off the grid

grid[x, y], get, set, row and column fault when the position is not there. contains is the way to ask first.

var g = read grid(char)
out(g[4, 0])
error: program faulted: index out of bounds

Backtrace:
#0   <entry>

  locals:
    g: Grid[Char] = [a, b, c, d]
  temps:
    <tmp#1> = "abcd\n"
    <tmp#2: Int> = 1
    <tmp#3: Grid[Char]> = [a, b, c, d]
    <tmp#5: Int> @ "4" = 4
    <tmp#6: Int> @ "0" = 0
    <tmp#7: Char> @ "g[4, 0]" = <uninit>
    <tmp#8: Unit> @ "out(g[4, 0])" = <uninit>

The Grid[T] method surface

That is all of it — twenty-four rows, including the two subscript forms.

CallAnswersNotes
grid[x, y]Tfaults off the grid
grid[x, y] = vUnitfaults off the grid
grid.get(x, y)Tthe same read as the subscript
grid.set(x, y, v)Unitthe same store as the subscript
grid.width()Intcolumns
grid.height()Introws
grid.contains(x, y)Boolnever faults
grid.row(y)Vec[T]faults off the grid; .to_text() draws it back as a line
grid.column(x)Vec[T]faults off the grid
grid.cells()Vec[T]row-major
grid.positions()Vec[(Int, Int)]row-major
grid.neighbors4(p)Vec[(Int, Int)]p is (Int, Int); clipped
grid.neighbors8(p)Vec[(Int, Int)]p is (Int, Int); clipped
grid.around4(p){ up, left, right, down }each field Option[(Int, Int)]; None off the grid
grid.around8(p){ up_left, up, up_right, left, right, down_left, down, down_right }reading order, centre skipped
grid.count4(p, v)Intorthogonal neighbours holding v; off-grid does not count
grid.count8(p, v)Intall eight; off-grid does not count
grid.count4_where(p, f)Intorthogonal neighbours whose cell f accepts
grid.count8_where(p, f)Intall eight; f never sees an off-grid cell
grid.find(v)Option[(Int, Int)]first match, row-major
grid.find_all(v)Vec[(Int, Int)]every match
grid.transpose()Grid[T]a new grid
grid.rotate_left()Grid[T]90° counter-clockwise, a new grid
grid.rotate_right()Grid[T]90° clockwise, a new grid

There is no grid.map(fn): g.map(f) is a Y110. Map over grid.cells() instead, and index back with grid.positions() if you need to know where each cell was.

Drawing the grid back is how a grid puzzle is debugged, and out(grid.row(y)) is not it — that prints [., ., |]. A Vec[Char] has a to_text(), so the line the grid was read from comes back one call later:

for y in 0..grid.height() { out(grid.row(y).to_text()) }

It is a Vec[Char] row and not a Grid one, so a whole grid still prints as its cells; a picture is a sequence of lines, and the loop is where the reader chooses that.

The graph helpers

There is no graph object, no adjacency type and no node type. Every helper takes a start state and then only functions of it: the graph is the closure. A state is any value you can put in a Set, so an Int node id, a Text, an (Int, Int) grid position and a record all work, and the walk never learns where they came from.

There are twelve of them, and the names are a rule rather than a list. The bare name is the whole walk, _distance is the number, and _path is the route. One search, three questions. Learn the rule on one family and you can write the other three from the name: the arguments are the same in the same order, and the only one the bare form does not take is the goal predicate — which it has no use for, because it is not looking for anything.

HelperSignature
bfsforall T. (T, (T) -> Vec[T]) -> Vec[T]
bfs_distanceforall T. (T, (T) -> Vec[T], (T) -> Bool) -> Option[Int]
bfs_pathforall T. (T, (T) -> Vec[T], (T) -> Bool) -> Option[Vec[T]]
dfsforall T. (T, (T) -> Vec[T]) -> Vec[T]
dfs_distanceforall T. (T, (T) -> Vec[T], (T) -> Bool) -> Option[Int]
dfs_pathforall T. (T, (T) -> Vec[T], (T) -> Bool) -> Option[Vec[T]]
dijkstraforall T. (T, (T) -> Vec[T], (T, T) -> Int) -> Map[T, Int]
dijkstra_distanceforall T. (T, (T) -> Vec[T], (T, T) -> Int, (T) -> Bool) -> Option[Int]
dijkstra_pathforall T. (T, (T) -> Vec[T], (T, T) -> Int, (T) -> Bool) -> Option[Vec[T]]
a_star_distanceforall T. (T, (T) -> Vec[T], (T, T) -> Int, (T) -> Int, (T) -> Bool) -> Option[Int]
a_star_pathforall T. (T, (T) -> Vec[T], (T, T) -> Int, (T) -> Int, (T) -> Bool) -> Option[Vec[T]]
flood_fillforall T. (T, (T) -> Vec[T]) -> Set[T]

The argument order is always start, neighbours, then weight, heuristic and goal where they apply. The goal is a predicate, not a value: |p| p == exit for a specific square, |s| s.depth == 26 for a property. A*’s five arguments are the honest count — a start, a graph, a cost, an estimate and a goal, none of which has a default worth guessing.

Two families are short a row, and both gaps say something. A* has no bare a_star: the heuristic estimates the remaining cost to a goal, so a whole-graph A* would be a search steering at nothing — dijkstra is that walk, and it is why the two share a weight function. flood_fill has no _distance and no _path: it is bfs’s unordered twin, and a Set has thrown away the order a route would be made of. Ask bfs_path for that.

What the three forms answer:

  • The bare name walks everything reachable. bfs and dfs answer a Vec[T] in the order they reached it, flood_fill a Set[T], dijkstra a Map[T, Int] of least costs. None of them needs an Option, because all of them contain the state you started from.
  • _distance answers Option[Int] — the cost of the route the search reaches a goal by, or None when no goal is reachable. For bfs_distance and dfs_distance the cost is the step count, one per edge.
  • _path answers Option[Vec[T]] — that same route, start to goal inclusive, so a route holds exactly one more state than the matching _distance counts steps. The Option is the same Option: a found route always holds at least its own start, so an empty Vec could not stand for “no route”.

One honest caveat, and it is the whole of the difference between the two unweighted families: dfs_path answers a route, not the shortest one, and dfs_distance is that route’s length rather than the fewest steps. Depth-first search commits to the first neighbour a state reports and backtracks only when it must, so it finds a goal by wandering to it. bfs_path is the shortest route; dfs_path is a route that exists. Reach for dfs_* when the graph is a tree, when any route will do, or when you want the one depth-first happens to take — never when you meant “shortest”.

A maze

A grid on stdin, a shortest path, an answer.

// A maze on stdin: `#` is a wall, `S` the start, `E` the exit.
// How few steps is the exit?
var maze = read grid(char)
var wall = '#'

// The graph is this function. `bfs_distance` never sees the grid: it only
// ever asks "what is next to here?" and "is this the exit?".
fn open_neighbours(maze: Grid[Char], wall: Char, p: (Int, Int)) -> Vec[(Int, Int)] {
    var open = Vec()
    for (x, y) in maze.neighbors4(p) {
        if maze[x, y] != wall {
            open.push((x, y))
        }
    }
    open
}

fn square(maze: Grid[Char], mark: Char) -> (Int, Int) {
    match maze.find(mark) {
        Some(p) => p,
        None => panic("the maze is missing a marker"),
    }
}

var start = square(maze, 'S')
var exit = square(maze, 'E')

var steps = bfs_distance(
    start,
    |p| open_neighbours(maze, wall, p),
    |p| p == exit,
)

match steps {
    Some(n) => out(n),
    None => out("walled in"),
}

The input is nine columns by five rows, with a sealed pocket on the right edge:

S..#....#
.#.#.#..#
.#...#.#.
.#.#.#..#
...#...E#
11

bfs_distance counts steps, one per edge, and stops at the first state its predicate accepts — which is a shortest one, because breadth-first reaches every state by its shortest path. is_goal is asked about the start first, so a search that begins at its goal answers Some(0). A goal nothing satisfies is None, not -1 and not a fault; see Option.

The same maze, drawn

Change the suffix and the same search hands back the route instead of its length. Nothing else about the call moves — same start, same neighbours, same goal predicate, same three arguments in the same order — which is the whole point of the naming rule.

// The same maze, and the same graph — but the route rather than the number.
var maze = read grid(char)
var wall = '#'

fn square(maze: Grid[Char], mark: Char) -> (Int, Int) {
    match maze.find(mark) {
        Some(p) => p,
        None => panic("the maze is missing a marker"),
    }
}

var start = square(maze, 'S')
var exit = square(maze, 'E')
var step = |p| maze.neighbors4(p).filter(|q| match q { (x, y) => maze[x, y] != wall })
var arrived = |p| p == exit

// The number, then the route: same start, same graph, same goal.
out(bfs_distance(start, step, arrived))

// `dfs_path` answers *a* route, not a shortest one — depth-first commits to
// the first neighbour a state reports and backtracks only when it must.
match dfs_path(start, step, arrived) {
    Some(route) => out(route.len()),
    None => out("walled in"),
}

// A route runs start to goal inclusive, so it holds one more state than the
// matching `_distance` counts steps.
match bfs_path(start, step, arrived) {
    Some(route) => {
        out(route.len())
        for (x, y) in route {
            if maze[x, y] == '.' { maze[x, y] = 'o' }
        }
        for y in 0..maze.height() {
            out(maze.row(y).to_text())
        }
    },
    None => out("walled in"),
}

On the same input, that prints

Some(11)
20
12
Soo#....#
.#o#.#..#
.#ooo#.#.
.#.#o#..#
...#oooE#

Eleven steps and twelve squares: the route is inclusive of both ends. The depth-first route through the same maze is twenty of them — a route that exists, and not the shortest one. The two calls differ by three letters and answer routes of different lengths, which is the caveat above measured on the maze this chapter is built around.

Marking the route back onto the grid is a good part of why _path exists at all. bfs_distance can tell you a puzzle has an answer; only the route can tell you the answer went the way you thought it did.

Traversals

bfs, dfs and flood_fill do not look for anything. They answer everything reachable — the first two in the order they reached it, the third as a Set. All three contain the state you started from, which is why none of them needs an Option.

var maze = read grid(char)
var wall = '#'

// The same graph as the maze, written as one closure: the in-bounds
// neighbours, minus the walls.
var step = |p| maze.neighbors4(p).filter(|q| match q { (x, y) => maze[x, y] != wall })

// `bfs` and `dfs` answer every reachable state, in the order they reached it.
// Both start with the state you gave them.
var breadth = bfs((0, 0), step)
out(breadth.len())
out(breadth[0])
out(breadth[1])
out(breadth[2])

var depth = dfs((0, 0), step)
out(depth[1])
out(depth[2])

// `flood_fill` asks the same reachability question and drops the order.
var filled = flood_fill((0, 0), step)
out(filled.len())

// The pocket at (8, 2) is open but walled off, so no walk reaches it.
out(maze[8, 2] != wall)
out(filled.contains((8, 2)))

On the same maze:

29
(0, 0)
(0, 1)
(1, 0)
(0, 1)
(0, 2)
29
true
false

Thirty squares are open and twenty-nine are reachable; the pocket at (8, 2) is on the right edge with a wall on each of its three neighbours, so it appears in nothing. dfs descends into the first neighbour a state reports, so its second and third states are (0, 1) and (0, 2) — straight down the left edge — while bfs takes (0, 1) then (1, 0).

A closure stored in a variable, as step is here, is an ordinary value and can be handed to as many helpers as you like. So can a top-level fn by name: bfs(0, steps) and bfs(0, |n| steps(n)) are the same walk. The neighbour result is an ordinary Vec, so a pipeline is a fine way to build it, as step does here.

Costs: dijkstra and A*

The weighted families are the same three questions over a cost instead of a step count. dijkstra answers a whole Map[T, Int] — the least cost from the start to every state it reaches; dijkstra_distance and dijkstra_path answer the number and the route for one goal. A* is the same pair with one extra argument, an estimate of the remaining cost to steer with, and no whole-walk form to be the third.

// A cost field: entering a square costs the number written on it.
var cost = read matrix(int)
var goal = (cost.width() - 1, cost.height() - 1)

var step = |p| cost.neighbors4(p)
var price = |a, b| match b { (x, y) => cost[x, y] }

// `dijkstra` answers a table: the least cost from the start to every state it
// reaches. The start is in it at 0, and a state it cannot reach is absent
// rather than infinite — which is why this one needs no `Option`.
var table = dijkstra((0, 0), step, price)
out(table.len())
out(table[(0, 0)])
out(table[(2, 0)])
out(table[goal])
out(table.contains((99, 99)))

// The goal-directed pair over the same weights: `dijkstra_distance` is the
// number the table already holds for one state, `dijkstra_path` the route to
// it, start to goal inclusive.
out(dijkstra_distance((0, 0), step, price, |p| p == goal))
out(dijkstra_path((0, 0), step, price, |p| p == goal))

fn manhattan(a: (Int, Int), b: (Int, Int)) -> Int {
    match a {
        (ax, ay) => match b {
            (bx, by) => abs(ax - bx) + abs(ay - by),
        },
    }
}

// A* takes one more argument than Dijkstra — an estimate that never
// overshoots. Every step here costs at least 1, so Manhattan distance is
// admissible. There is no bare `a_star`: an estimate is an estimate *of the
// remaining cost to a goal*, so a whole-graph A* would be a walk with nothing
// to steer towards.
out(a_star_distance((0, 0), step, price, |p| manhattan(p, goal), |p| p == goal))
out(a_star_path((0, 0), step, price, |p| manhattan(p, goal), |p| p == goal))

// A zero estimate is admissible too; it turns A* back into Dijkstra.
out(a_star_distance((0, 0), step, price, |p| 0, |p| p == goal))

// A goal nothing satisfies is `None`, not a fault and not a sentinel — and a
// `_path` says it the same way, because an empty route would be a route that
// does not even hold its own start.
out(a_star_distance((0, 0), step, price, |p| 0, |p| p == (99, 99)))
out(a_star_path((0, 0), step, price, |p| 0, |p| p == (99, 99)))

On

1 1 9 1
1 9 1 1
1 1 1 9
9 1 1 1

it prints

16
0
10
6
false
Some(6)
Some([(0, 0), (0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (3, 3)])
Some(6)
Some([(0, 0), (0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (3, 3)])
Some(6)
None
None

The weight function is (T, T) -> Int — the two endpoints of the edge, in that order. Here only the destination matters, because the cost is a property of the square you enter; a graph whose edges carry their own cost would use both.

A*’s contract is that the estimate never exceeds the true remaining cost. |p| 0 always satisfies it, and turns the search into Dijkstra with one goal.

dijkstra_distance and a_star_distance agree here, and so do their _path forms, because both routes cost six: the estimate steers which states get looked at, not which answer comes out. A cost of six over seven squares is the route-is-inclusive rule again — six edges, seven states.

What a state has to be

Every walk keeps the states it has seen in a set, and the weighted ones key a cost table on them, so the state type has to be one that cannot change after it is stored — the same HashStable rule a Map key follows (capabilities).

// Every walk keeps the states it has seen in a set, and the weighted ones key
// a cost table on them. So a state has to be a value that cannot change after
// it is stored — and a `Vec` can.
fn steps(v: Vec[Int]) -> Vec[Vec[Int]] {
    Vec()
}

out(bfs(Vec(), steps).len())
$ praxis check unstable-state.px
error[Y014]: a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

  unstable-state.px:8:5
  8 | out(bfs(Vec(), steps).len())
    |     ^^^^^^^^^^^^^^^^^ a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

help: use a value that cannot change — a number, `Text`, or a tuple of those

praxis: 1 error(s)

The requirement is reported at the call, not inside the helper. If the state type is still a variable — fn walk(start, step) { bfs(start, step) } — it is deferred onto walk’s own signature and answered at each call to walk.

The neighbour function’s result shape is written into the helper’s signature, so handing it the wrong container is a type error and not a run-time surprise:

// The neighbour function's shape is written into the helper's own signature,
// so a `Set` of neighbours is reported at the call rather than at run time.
fn steps(n: Int) -> Set[Int] {
    Set()
}

out(bfs(0, steps).len())
$ praxis check neighbours-must-be-a-vec.px
error[Y001]: expected (Int, (Int) -> Vec[Int]) -> Vec[Int], found (Int, (Int) -> Set[Int]) -> ?T

  neighbours-must-be-a-vec.px:7:5
  7 | out(bfs(0, steps).len())
    |     ^^^^^^^^^^^^^ expected (Int, (Int) -> Vec[Int]) -> Vec[Int], found (Int, (Int) -> Set[Int]) -> ?T

praxis: 1 error(s)

What stops a walk

An answer the walk cannot compute is a fault, not a wrong number. A negative edge weight is the one to know about: Dijkstra and A* settle a state the first time they pop it and never reconsider, so a negative edge makes the answer quietly too large. It stops instead.

fn steps(n: Int) -> Vec[Int] {
    var v = Vec()
    if n < 3 { v.push(n + 1) }
    v
}

out(dijkstra(0, steps, |a, b| -1).len())
error: program faulted: an argument this algorithm has no answer for

Backtrace:
#0   <entry>

  temps:
    <tmp#1: Int> @ "0" = 0
    <tmp#2: (Int) -> Vec[Int]> @ "steps" = <closure:0>
    <tmp#3: (Int, Int) -> Int> @ "|a, b| -1" = <closure:0>
    <tmp#4: Map[Int, Int]> @ "dijkstra(0, steps, |a, b| -1)" = <uninit>
    <tmp#5: Int> @ "dijkstra(0, steps, |a, b| -1).len()" = <uninit>
    <tmp#6: Unit> @ "out(dijkstra(0, steps, |a, b| -1).len())" = <uninit>

The full list of stops:

CauseWhat the program prints
a negative edge weightan argument this algorithm has no answer for
a negative heuristic, which makes g + h fall along a pathan argument this algorithm has no answer for
a path cost or step count that leaves Intinteger overflow
a fault raised inside one of your closuresthat fault, with your function on the backtrace

The list is per family, not per name: the weight rules bind all five weighted helpers, and a _path form stops where its _distance form does. It is one algorithm answering a different question, not a different algorithm.

The last row is worth stating plainly: a walk calls back into your code, and a division by zero inside a neighbour function stops the walk rather than letting it continue over garbage. See the fault model.

An estimate that merely overshoots is not on that list, and that is the one place in this family where you are on your own: A* cannot detect it without computing the answer first, so an inadmissible heuristic is a wrong number rather than a stop.

The prelude

Thirty-seven names are bound in every Praxis file before the first line runs. There is no import, no use, and no way to get more: a program is one file, and the prelude is the whole free-function surface of the language. Everything else is a method, and those are in the method catalog.

The list lives in crates/praxis-stdlib/src/prelude.rs, which is the same table the type checker and the editor read. It falls into five groups.

Output and control

NameSignatureWhat it does
out(T) -> UnitWrite one value to stdout, followed by a newline.
dbg(T) -> TWrite one value to stderr and return it unchanged.
panic(T) -> NeverStop with an explicit message and raise a fault.
assert(Bool) -> UnitStop if the condition is false.

out, dbg and panic take any type and render it through the value’s own formatter, so panic(candidate) says what the candidate was:

out(42)
out("a line")
out([1, 2, 3])
out((1, "x"))
out(Some(3))

var squares = Map()
squares.insert(2, 4)
squares.insert(3, 9)
out(squares)
42
a line
[1, 2, 3]
(1, x)
Some(3)
{2: 4, 3: 9}

dbg is the identity on types, which is what lets it wrap any subexpression without changing what the program computes — including one whose value you then panic on. Both halves of this go to stderr:

var doubled = dbg(21) * 2
panic(doubled)
21
error: program faulted: panic: 42

Backtrace:
#0   <entry>

  locals:
    doubled: Int = 42
  temps:
    <tmp#1: Int> @ "21" = 21
    <tmp#2: Int> @ "dbg(21)" = 21
    <tmp#3: Int> @ "2" = 2
    <tmp#4: Int> @ "dbg(21) * 2" = 42
    <tmp#6> @ "panic(doubled)" = <uninit>

panic’s result type is Never, so a function can end on one and still satisfy a declared result type: fn pick(v: Vec[Int]) -> Int { if v.is_empty() { panic("no candidates") }; v[0] } type-checks, and so does fn boom() -> Int { panic("x") }.

assert is the one name here that is monomorphic. It takes a Bool and nothing else, so assert(1) is a type error rather than a call that silently accepts anything, and it takes exactly one argument — a message parameter has no spelling, because a name in Praxis has exactly one signature.

assert(1 + 1 == 3)
error: program faulted: assertion failed

Backtrace:
#0   <entry>

  temps:
    <tmp#1: Int> @ "1" = 1
    <tmp#2: Int> @ "1" = 1
    <tmp#3: Int> @ "1 + 1" = 2
    <tmp#4: Int> @ "3" = 3
    <tmp#5: Bool> @ "1 + 1 == 3" = false
    <tmp#6: Unit> @ "assert(1 + 1 == 3)" = <uninit>

Both panic and assert raise ordinary faults, which is why the output above carries a backtrace and the locals. Under the default --debug auto — stdin and stdout both a terminal — they drop you into the crash debugger instead of printing. That is the reason they are faults rather than a write to stderr followed by an exit: a panic that bypassed the fault path is a panic you cannot debug.

Numeric helpers

Seven functions on Int, and two nullary Float functions.

NameSignatureWhat it does
abs(Int) -> IntAbsolute value. Faults on Int’s minimum, which has no positive counterpart.
sign(Int) -> Int-1, 0 or 1. Total.
min(Int, Int) -> IntThe smaller of two.
max(Int, Int) -> IntThe larger of two.
clamp(Int, Int, Int) -> Intclamp(value, low, high). Faults if low > high.
gcd(Int, Int) -> IntNon-negative greatest common divisor. gcd(0, 0) is 0.
lcm(Int, Int) -> IntNon-negative least common multiple; 0 if either operand is 0. Faults if the result leaves Int.
pi() -> Floatπ.
e() -> FloatEuler’s number.

pi and e are nullary functions, not bare constants: pi(), not pi.

out(abs(-7))
out(sign(-7))
out(min(3, 9))
out(max(3, 9))
out(clamp(12, 0, 10))
out(gcd(12, 18))
out(lcm(4, 6))
out(pi())
out(e())
7
-1
3
9
10
6
12
3.141592653589793
2.718281828459045

All seven are Int functions and none of them is generic. Float carries its own abs, sign, min and max as methods — x.abs(), x.min(y) — so the free function never has to choose a lowering per instantiation. clamp, gcd and lcm have no Float counterpart at all; (2.5).clamp(0.0, 1.0) is a Y110. Handing a Float to one of the free functions is an ordinary type error:

$ praxis check prelude-min-is-int.px
error[Y001]: expected (Int, Int) -> Int, found (Float, Float) -> ?T

  prelude-min-is-int.px:1:5
  1 | out(min(1.0, 2.0))
    |     ^^^^^^^^^^^^^ expected (Int, Int) -> Int, found (Float, Float) -> ?T

praxis: 1 error(s)

Collection constructors

Nine names. Called with no arguments, each builds an empty collection and the element type comes from what you then put in. Two of them — Vec and Grid — also take a size and a fill, and the argument count is what chooses between the two shapes.

NameSignatureNotes
Vec() -> Vec[T]Ordered, growable, indexed from 0.
Vec(Int, T) -> Vec[T]n slots, every one the fill.
Deque() -> Deque[T]Double-ended queue.
Map() -> Map[K, V]Hash map.
Set() -> Set[T]Hash set.
Counter() -> Counter[T]A map whose absent values read as zero.
MinHeap() -> MinHeap[T]Priority queue, smallest first.
MaxHeap() -> MaxHeap[T]Priority queue, largest first.
Grid() -> Grid[T]2D grid. Grid() is the empty 0 × 0 one.
Grid(Int, Int, T) -> Grid[T]A w × h board, every cell the fill.
BitSet() -> BitSetCompact set of non-negative integers. Takes no type argument.

Only those two are sized, and the rest of the language has no arity overloading at all — Set(3, 0) is an error that says the function takes zero arguments. The two exceptions are the collections whose contents are addressed by position, which is what makes “n of them” mean something: a sized Set is n copies of one element in a set, which is one element, and a sized Map has no answer at all for what its keys would be. This narrows “a name has one signature” without reopening it — the shape is chosen by counting arguments, a syntactic fact available before any argument is typed, and never by looking at their types.

var v = Vec()
v.push(1)

var d = Deque()
d.push_front("front")

var m = Map()
m.insert("k", 1)

var s = Set()
s.insert(3)

var c = Counter()
c.inc("x")

var lo = MinHeap()
lo.push(5)

var hi = MaxHeap()
hi.push(5)

var g = Grid()

var sized = Vec(3, 0)
var board = Grid(3, 2, '.')

var b = BitSet()
b.insert(3)

out(v)
out(d)
out(m)
out(s)
out(c)
out(lo.peek())
out(hi.peek())
out(g.width())
out(b)
out(sized)
out(board)
out(board.width())
[1]
[front]
{k: 1}
{3}
{x: 1}
5
5
0
{3}
[0, 0, 0]
[., ., ., ., ., .]
3

[1, 2, 3] is a Vec literal, so Vec() is only needed when you want an empty one. The tenth collection, Range, has no constructor: a range is written 0..n or 0..=n, and Range is a type name rather than a value — Range() is N001: 'Range' is not defined.

A Map key, a Set element and a Counter key have to be usable as keys, and a heap element has to be orderable — but the constructor is not where that is asked. The requirement is checked at the first method call on the collection, because that is where a program actually puts a value into one, so the construction below is accepted and the len() on the next line is what is refused:

var seen: Set[Vec[Int]] = Set()
out(seen.len())
$ praxis check prelude-key-bound.px
error[Y014]: a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

  prelude-key-bound.px:2:10
  2 | out(seen.len())
    |          ^^^ a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

help: use a value that cannot change — a number, `Text`, or a tuple of those

praxis: 1 error(s)

See capabilities.

Optionality

NameSignatureWhat it is
OptionThe type name. Option[T] is a legal annotation.
Some(T) -> Option[T]Wrap a value.
NoneOption[T]The absent value. Not a call — None, never None().

Option[T] is domain-level absence and not an error channel. It is what Map.get, Grid.find, the sequence find/position, the checked_* arithmetic methods and the goal-directed graph walks answer with.

var seen = Map()
seen.insert("a", 1)

var found: Option[Int] = seen.get("a")
var missing = seen.get("z")

out(found)
out(missing)

match missing {
  Some(n) => out(n)
  None => out("nothing there")
}
Some(1)
None
nothing there

Some and None are the only enum variants the prelude declares; everything else about them is what enums says about any variant.

Graph helpers

Twelve closure-driven walks. None of them takes a graph object — there is no graph type — so a program describes its graph by giving a start state and a function from a state to its neighbours.

The name says which of three questions a helper answers. The bare name is the whole walk, _distance is what the route to a goal came to, and _path is that route. So knowing one family is knowing all of them, and a family with no whole-walk meaning has no bare name — which is why A* is only a_star_distance and a_star_path. Which route depends on the search, and each row says which; only depth-first declines to promise a cheapest one.

NameSignatureAnswers
bfs(T, (T) -> Vec[T]) -> Vec[T]Every state reached, in breadth-first order.
bfs_distance(T, (T) -> Vec[T], (T) -> Bool) -> Option[Int]Steps to the first goal state, or None.
bfs_path(T, (T) -> Vec[T], (T) -> Bool) -> Option[Vec[T]]A shortest route to the first goal state, start to goal inclusive, or None.
dfs(T, (T) -> Vec[T]) -> Vec[T]Every state reached, in depth-first order.
dfs_distance(T, (T) -> Vec[T], (T) -> Bool) -> Option[Int]Steps along the route depth-first search reached a goal by, which need not be the fewest, or None.
dfs_path(T, (T) -> Vec[T], (T) -> Bool) -> Option[Vec[T]]The route depth-first search reached a goal by, which need not be a shortest one, start to goal inclusive, or None.
dijkstra(T, (T) -> Vec[T], (T, T) -> Int) -> Map[T, Int]Least cost to each reachable state. An unreachable state is simply absent.
dijkstra_distance(T, (T) -> Vec[T], (T, T) -> Int, (T) -> Bool) -> Option[Int]Cost of the cheapest route to a goal, or None.
dijkstra_path(T, (T) -> Vec[T], (T, T) -> Int, (T) -> Bool) -> Option[Vec[T]]The cheapest route to a goal, start to goal inclusive, or None.
a_star_distance(T, (T) -> Vec[T], (T, T) -> Int, (T) -> Int, (T) -> Bool) -> Option[Int]Cost of the cheapest route to a goal, or None.
a_star_path(T, (T) -> Vec[T], (T, T) -> Int, (T) -> Int, (T) -> Bool) -> Option[Vec[T]]The cheapest route to a goal, start to goal inclusive, or None.
flood_fill(T, (T) -> Vec[T]) -> Set[T]Every state reached, unordered.

The first parameter is always the start state and every other parameter is a function of it. The weight function takes two adjacent states, the heuristic takes one state and estimates the remaining cost, and the goal is a predicate rather than a value, so a search can stop on a property.

Only the goal-directed helpers answer with an Option, and that pairing is the rule: a walk that always reaches at least its own start cannot fail, and dijkstra needs no Option because “unreachable” is absence from its table. An Option[Vec[T]] is for the same reason an Option[Int] is, and an empty Vec could not stand in for it: a route that was found always holds at least its own start, so “no route” and “a route of nothing” would be the same value.

flood_fill is the unordered twin of bfs and takes no goal at all, so it has neither a _distance nor a _path form — a Set has no route.

fn steps(n: Int) -> Vec[Int] {
  var next = Vec()
  if n * 2 <= 20 { next.push(n * 2) }
  if n + 1 <= 20 { next.push(n + 1) }
  next
}

out(bfs(1, |n| steps(n)).len())
out(dfs(1, |n| steps(n)).len())
out(flood_fill(1, |n| steps(n)).len())
out(dijkstra(1, |n| steps(n), |a, b| b - a).len())

out(bfs_distance(1, |n| steps(n), |n| n == 20))
out(bfs_path(1, |n| steps(n), |n| n == 20))
out(dfs_distance(1, |n| steps(n), |n| n == 20))
out(dfs_path(1, |n| steps(n), |n| n == 20))
out(dijkstra_distance(1, |n| steps(n), |a, b| b - a, |n| n == 20))
out(dijkstra_path(1, |n| steps(n), |a, b| b - a, |n| n == 20))
out(a_star_distance(1, |n| steps(n), |a, b| b - a, |n| 20 - n, |n| n == 20))
out(a_star_path(1, |n| steps(n), |a, b| b - a, |n| 20 - n, |n| n == 20))

out(bfs_distance(1, |n| steps(n), |n| n == 21))
out(bfs_path(1, |n| steps(n), |n| n == 21))
20
20
20
20
Some(5)
Some([1, 2, 4, 5, 10, 20])
Some(8)
Some([1, 2, 4, 8, 16, 17, 18, 19, 20])
Some(19)
Some([1, 2, 4, 5, 10, 20])
Some(19)
Some([1, 2, 4, 5, 10, 20])
None
None

All twenty states are reachable, so the four whole-walk answers all hold twenty entries and differ only in shape — a Vec in visit order twice, a Set, and a Map of costs. The goal-directed pairs are where the names earn themselves: bfs_distance says five steps and bfs_path names the six states they pass through, while dfs_distance says eight, because depth-first reaches 20 by the branch it happened to descend rather than by a shortest route. dijkstra and A* agree with each other on both the cost and the route, since an admissible heuristic changes how much of the graph gets opened and not what is found. The last two calls ask for a goal the graph does not contain, and both forms answer None rather than a sentinel.

Every walk remembers where it has been, so the state type has to be usable as a key: a number, a Text, a Char, a tuple of those, or a record or enum of those. A Vec state is refused at the call site, with the reason rather than the rule:

$ praxis check prelude-graph-state.px
error[Y014]: a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

  prelude-graph-state.px:7:5
  7 | out(bfs([1], |s| step(s)).len())
    |     ^^^^^^^^^^^^^^^^^^^^^ a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

help: use a value that cannot change — a number, `Text`, or a tuple of those

praxis: 1 error(s)

Which of these can fault

Most prelude names cannot fail. The ones that can:

NameFaultWhen
panicpanicAlways. That is the point.
assertassertion failedThe condition was false.
absinteger overflowThe argument is Int’s minimum.
clampempty rangelow > high. There is no value to answer with, and inventing one would be a guess.
gcdinteger overflowOnly for Int’s minimum with itself, whose answer is 2⁶³.
lcminteger overflowThe multiple does not fit an Int, which happens easily.
the twelve graph walkswhatever the closures raiseYour neighbour, weight, heuristic or goal function faulted.
Vec(n, fill)size or extent out of rangen is negative, or larger than the runtime will allocate at a stroke (2²⁸).
Grid(w, h, fill)size or extent out of rangeAn extent is negative, or w × h is past 2²⁸ cells.

out, dbg, sign, min, max, pi, e and Some cannot fault, and neither can any collection constructor called with no arguments — there is nothing you gave it for it to refuse. The two sized forms are the exception, and the size is the reason: it is an ordinary Int computed at run time, so a negative or absurd one cannot be caught at praxis check and is a fault instead. See the fault model for what happens after a fault.

They are ordinary bindings

A prelude name is a normal binding in the file’s root scope, so a var of the same name shadows it for the rest of the file, exactly as any other shadow works:

var max = 10
out(max + 1)
11

Worth knowing mostly so that “max is not a function” stops being mysterious once you have used the name for something else.

What is not here

The type names Int, Text, Bool, Char, Float, Unit and Never are also in scope, as annotations, and they are the whole list: any other name in type position is N002: unknown type.

There is nothing else. No I/O beyond out and dbg, no clock, no randomness, no file access, and no import that would add one — a program’s input arrives through read, and its answer leaves through out.

The method catalog

Every method in Praxis is a row in one table. There are 149 of them, they live in crates/praxis-stdlib/src/builtins.rs, and there is no way to add a 150th from a program: the language has no impl, no traits, no extension methods, and a record carries fields but no methods. This chapter is that table.

The closedness is load-bearing rather than a limitation the compiler tolerates. Because the catalog is the complete method universe, a name it does not carry at that arity can never resolve against any receiver — so fn f(x) { x.nope() } is refused at check time, before anything has said what x is.

How to read these tables

Method is the row’s name and the type pattern of each parameter; Result is its result pattern. Both are rendered the way the compiler prints a type: T, U, K, V and Acc are type variables, two occurrences of one name in a row are the same type, (Int, Int) is a tuple, and (T) -> Bool is a closure parameter. A nullary collection prints bare, so it is BitSet and not BitSet[].

Mutates is the row’s purity flag. The impure rows are exactly the ones that change the receiver, and the flag is visible from a program: the crash debugger’s p expression evaluator refuses a call to an impure method, because a debugger that mutates a faulted state cannot resume it.

var v = [1, 2, 3]
out(v[9])

That faults, and the debugger it drops into will evaluate one of these two calls and not the other:

error: program faulted: index out of bounds

Backtrace:
#0   <entry>

  locals:
    v: Vec[Int] = [1, 2, 3]
  temps:
    <tmp#1: Vec[Int]> @ "[1, 2, 3]" = [1, 2, 3]
    <tmp#2: Int> @ "1" = 1
    <tmp#3: Unit> = Unit
    <tmp#4: Int> @ "2" = 2
    <tmp#5: Unit> = Unit
    <tmp#6: Int> @ "3" = 3
    <tmp#7: Unit> = Unit
    <tmp#9: Int> @ "9" = 9
    <tmp#10: Int> @ "v[9]" = <uninit>
    <tmp#11: Unit> @ "out(v[9])" = <uninit>
Entered crash debugger. 1 frame(s). Type `help` for commands.
Praxis crash> p v.len()
3
Praxis crash> p v.push(4)
error: method `push` is impure (may mutate state) — `p` rejects mutating expressions
Praxis crash> quit

Faults is whether the call can raise a runtime fault. For a row backed by a runtime wrapper this is the wrapper’s own declaration in the ABI manifest, which is what puts a fault check after the call — so “yes” means the check is emitted, not that you are likely to trigger it. Vec.push says yes for a type-mismatch case a well-typed program cannot reach; Vec.get says yes because indexing off the end is the everyday one.

Allocates is whether the call may allocate, and therefore whether its call site is a garbage-collection safepoint. It is derived from the same manifest row, which is why len() says yes on every collection: the count comes back as a freshly boxed Int.

Neither flag is restated per method — both are read off the wrapper the row lowers to, so a row cannot disagree with the code it calls. Thirty-one of the thirty-five pipeline rows have no wrapper to read: the compiler fuses them into the loop, so their tables below carry no Allocates column and their Faults column is what the fused code does rather than a manifest row.

Sequence collections

Vec[T]

MethodResultMutatesFaultsAllocatesWhat it does
get(Int)TnoyesnoThe element at index; faults IndexOutOfBounds if out of range.
is_empty()BoolnononoTrue iff the vector has no elements.
len()IntnonoyesNumber of elements in the vector.
push(T)UnityesyesyesAppend a value to the end; returns Unit.
to_text()TextnoyesyesThese Chars as one Text, with nothing between them; the element type must be Char.
var v = [10, 20, 30]

out(v.len())
out(v.is_empty())
out(v.get(1))

v.push(40)
out(v)
3
false
20
[10, 20, 30, 40]

push is the only way a vector grows. v[v.len()] = x is an IndexOutOfBounds fault and not an append.

Deque[T]

MethodResultMutatesFaultsAllocatesWhat it does
get(Int)TnoyesnoThe element at index (0-based from the front); faults if out of range.
is_empty()BoolnononoTrue iff the deque has no elements.
len()IntnonoyesNumber of elements in the deque.
pop_back()TyesyesnoRemove and return the back element; faults if empty.
pop_front()TyesyesnoRemove and return the front element; faults if empty.
push_back(T)UnityesyesyesAppend a value to the back; returns Unit.
push_front(T)UnityesyesyesPrepend a value to the front; returns Unit.
var d = Deque()
d.push_back(2)
d.push_back(3)
d.push_front(1)

out(d)
out(d.len())
out(d.get(0))
out(d.pop_front())
out(d.pop_back())
out(d.is_empty())
[1, 2, 3]
3
1
1
3
false

Index 0 is the front, whichever end you have been pushing to.

Keyed collections

Map[K, V]

MethodResultMutatesFaultsAllocatesWhat it does
contains(K)BoolnononoTrue iff key is present in the map.
get(K)Option[V]nonoyesThe value for key as Some(value), or None if absent.
insert(K, V)UnityesnoyesSet key to value, replacing any prior value; returns Unit.
is_empty()BoolnononoTrue iff the map has no entries.
keys()Vec[K]nonoyesEvery key, as a Vec[K], ordered with values().
len()IntnonoyesNumber of entries in the map.
remove(K)UnityesnonoRemove key if present; returns Unit.
values()Vec[V]nonoyesEvery value, as a Vec[V], ordered with keys().
var m = Map()
m.insert("a", 1)
m.insert("b", 2)

out(m.len())
out(m.contains("a"))
out(m.get("a"))
out(m.get("z"))
out(m.keys())
out(m.values())

m.remove("a")
out(m)
out(m.is_empty())
2
true
Some(1)
None
[a, b]
[1, 2]
{b: 2}
false

get answers an Option; m[key] faults on a missing key. Those are the two halves of one question and the spelling picks which you meant.

keys() and values() answer Vecs in a fixed, deterministic order — by the key’s own order, the same one sorted() uses, so an Int key comes out numeric — and the two are index-aligned, so keys()[i] and values()[i] belong together. To get both at once, walk the map: for kv in m and the pipeline rows below both yield (K, V) pairs, and m.to_vec() is the Vec[(K, V)].

Set[T]

MethodResultMutatesFaultsAllocatesWhat it does
contains(T)BoolnononoTrue iff value is in the set.
insert(T)UnityesnoyesAdd value to the set; returns Unit.
is_empty()BoolnononoTrue iff the set has no elements.
len()IntnonoyesNumber of elements in the set.
remove(T)UnityesnonoRemove value if present; returns Unit.
var s = Set()
s.insert(1)
s.insert(1)
s.insert(2)

out(s.len())
out(s.contains(2))

s.remove(2)
out(s)
out(s.is_empty())
2
true
{1}
false

Counter[T]

MethodResultMutatesFaultsAllocatesWhat it does
get(T)IntnonoyesThe count for key, or zero if absent (never faults).
inc(T)UnityesyesyesIncrement the count for key by one; returns Unit.
is_empty()BoolnononoTrue iff the counter has no keys.
keys()Vec[T]nonoyesEvery key, as a Vec[T], ordered with values().
len()IntnonoyesNumber of distinct keys in the counter.
values()Vec[Int]nonoyesEvery count, as a Vec[Int], ordered with keys().
var c = Counter()
c.inc("x")
c.inc("x")
c.inc("y")

out(c.get("x"))
out(c.get("never seen"))
out(c.len())
out(c.keys())
out(c.values())
out(c.is_empty())
2
0
2
[x, y]
[2, 1]
false

A Counter is the collection whose absent values read as zero, so get and c[key] never fault and len() counts the keys that were actually touched.

Priority queues and bit sets

MinHeap[T]

MethodResultMutatesFaultsAllocatesWhat it does
is_empty()BoolnononoTrue iff the min-heap has no elements.
len()IntnonoyesNumber of elements in the min-heap.
peek()TnoyesnoThe smallest element without removing it; faults if empty.
pop()TyesyesnoRemove and return the smallest element; faults if empty.
push(T)UnityesnoyesPush a value onto the min-heap; returns Unit.

MaxHeap[T]

MethodResultMutatesFaultsAllocatesWhat it does
is_empty()BoolnononoTrue iff the max-heap has no elements.
len()IntnonoyesNumber of elements in the max-heap.
peek()TnoyesnoThe largest element without removing it; faults if empty.
pop()TyesyesnoRemove and return the largest element; faults if empty.
push(T)UnityesnoyesPush a value onto the max-heap; returns Unit.
var lo = MinHeap()
lo.push(5)
lo.push(1)
lo.push(3)

out(lo.len())
out(lo.peek())
out(lo.pop())
out(lo.is_empty())

var hi = MaxHeap()
hi.push(5)
hi.push(1)

out(hi.peek())
out(hi.pop())
3
1
1
false
5
5

peek is pure and pop is not, which is the only difference between them in this table and the whole difference at the call site.

BitSet

MethodResultMutatesFaultsAllocatesWhat it does
contains(Int)BoolnononoTrue iff the bit for the integer is set.
insert(Int)UnityesyesyesSet the bit for a non-negative integer; returns Unit.
is_empty()BoolnononoTrue iff no bits are set.
len()IntnonoyesNumber of set bits (popcount).
remove(Int)UnityesnonoClear the bit for an integer; returns Unit.
var b = BitSet()
b.insert(3)
b.insert(70)

out(b.contains(3))
out(b.contains(4))
out(b.len())

b.remove(3)
out(b)
out(b.is_empty())
true
false
2
{70}
false

insert faults on a negative or oversized member; remove does not, because clearing a bit that was never in range is not a question the set has to answer. contains is the one row in the catalog that lowers to a dedicated scalar-producing instruction rather than a call, which is why it is not a safepoint.

Grid[T]

MethodResultMutatesFaultsAllocatesWhat it does
around4((Int, Int))Around4nonoyesThe 4 orthogonal neighbors by name: { up, left, right, down }, each Some((x, y)) or None off the grid.
around8((Int, Int))Around8nonoyesThe 8 neighbors by name, in reading order: { up_left, up, up_right, left, right, down_left, down, down_right }, each Some((x, y)) or None.
cells()Vec[T]nonoyesAll cells in row-major order, as a Vec.
column(Int)Vec[T]noyesyesColumn x as a Vec; faults if out of range.
contains(Int, Int)BoolnononoTrue iff (x, y) is within the grid.
count4((Int, Int), T)IntnonoyesHow many of the 4 orthogonal neighbors hold value. A neighbor off the grid has no cell and is not counted.
count4_where((Int, Int), (T) -> Bool)IntnoyesyesHow many of the 4 orthogonal neighbors hold a cell the closure accepts. A neighbor off the grid has no cell, so the closure never sees one.
count8((Int, Int), T)IntnonoyesHow many of the 8 neighbors hold value. A neighbor off the grid has no cell and is not counted.
count8_where((Int, Int), (T) -> Bool)IntnoyesyesHow many of the 8 neighbors hold a cell the closure accepts. A neighbor off the grid has no cell, so the closure never sees one.
find(T)Option[(Int, Int)]nonoyesThe first (x, y) whose cell equals value as Some((x, y)), or None.
find_all(T)Vec[(Int, Int)]nonoyesAll (x, y) positions whose cell equals value, as a Vec.
get(Int, Int)TnoyesnoThe cell at (x, y); faults if out of range.
height()IntnonoyesThe number of rows.
neighbors4((Int, Int))Vec[(Int, Int)]nonoyesThe 4 orthogonal in-bounds neighbors of a point, as a Vec of (x, y).
neighbors8((Int, Int))Vec[(Int, Int)]nonoyesThe 8 in-bounds neighbors of a point, as a Vec of (x, y).
positions()Vec[(Int, Int)]nonoyesAll (x, y) positions in row-major order, as a Vec.
rotate_left()Grid[T]nonoyesA copy rotated 90° counter-clockwise.
rotate_right()Grid[T]nonoyesA copy rotated 90° clockwise.
row(Int)Vec[T]noyesyesRow y as a Vec; faults if out of range.
set(Int, Int, T)UnityesyesnoSet the cell at (x, y); faults if out of range.
transpose()Grid[T]nonoyesA transposed copy (rows ↔ columns).
width()IntnonoyesThe number of columns.

A grid is indexed (x, y) with x the column and y the row, and positions(), cells() and find_all() walk it in row-major order.

Around4 and Around8 are the catalog’s only named result types, and they are records, not collections: read the field with a dot, a.up, and every one of them is an Option[(Int, Int)]. Around4 is the plus with the centre skipped; Around8 is the whole 3×3 block in reading order, again without the centre. Neither has a constructor — a grid is the only thing that makes one — and the order the rows above list the fields in is the order the runtime lays the value out in, which is why it is written that way and not alphabetically.

var g = read grid(one_of(".#"))
var wall = '#'

out(g.width())
out(g.height())
out(g.get(1, 0))
out(g.contains(3, 0))
out(g.row(0))
out(g.column(1))
out(g.cells())
out(g.positions().len())
out(g.neighbors4((1, 1)))
out(g.neighbors8((0, 0)))
out(g.around4((1, 1)))
out(g.around8((0, 0)))
out(g.around4((1, 1)).right)
out(g.count4((1, 1), wall))
out(g.count8((1, 1), wall))
out(g.count4_where((1, 1), |c| c != wall))
out(g.count8_where((1, 1), |c| c != wall))
out(g.find(wall))
out(g.find_all(wall))
out(g.transpose().row(0))
out(g.rotate_left().row(0))
out(g.rotate_right().row(0))

g.set(0, 0, wall)
out(g.row(0))

with input

.#.
..#
3
2
#
false
[., #, .]
[#, .]
[., #, ., ., ., #]
6
[(1, 0), (0, 1), (2, 1)]
[(1, 0), (0, 1), (1, 1)]
{ up: Some((1, 0)), left: Some((0, 1)), right: Some((2, 1)), down: None }
{ up_left: None, up: None, up_right: None, left: None, right: Some((1, 0)), down_left: None, down: Some((0, 1)), down_right: Some((1, 1)) }
Some((2, 1))
2
2
1
3
Some((1, 0))
[(1, 0), (2, 1)]
[., .]
[., #]
[., .]
[#, #, .]

neighbors4 and neighbors8 return only the in-bounds neighbours, so they are already the neighbour function a graph walk wants: bfs(start, |p| g.neighbors4(p)) type-checks because the walk’s neighbours closure is (T) -> Vec[T].

around4 and around8 are not those rows spelled differently. A clipped Vec cannot say which direction each neighbour was, and at the edge of the grid it cannot say there was a direction at all — (1, 1) above has three orthogonal neighbours and the Vec has three entries, with nothing to mark the missing down. Every field of an Around4 is an Option, so both survive, at the cost of no longer being a Vec a walk can consume. Take the Vec for a search and the record when the direction is the answer.

count4 and count8 compare each in-bounds neighbouring cell to a value, where neighbors4 and neighbors8 answer positions; count4_where and count8_where take a predicate on the cell instead. A neighbour off the grid has no cell, so it is never counted and the closure never sees one — which is why the _where pair is the only Grid row besides column, get, row and set that can fault, and it faults for the predicate’s reasons rather than for any of its own.

transpose, rotate_left and rotate_right answer copies and leave the receiver alone.

A Grid is deliberately not a pipeline receiver. for cell in g walks it in row-major order, but g.map(f) is a Y110: a grid enters a pipeline through cells() or positions(), which already answer Vecs. A grid’s shape is part of its value, and a stage that flattened it would be answering about something else.

The pipeline

Thirty-five rows — thirty-four names, because count has two arities — sit on one generic receiver that stands for ten different receivers: Vec, Deque, Set, MinHeap, MaxHeap, Range, BitSet, Map, Counter and Text. That is the for loop’s list minus Grid, and what a receiver yields here is exactly what the for loop’s variable would bind — a Char from a Text, a (K, V) pair from a Map or a Counter, an element from everything else.

var s = Set()
s.insert(2)
s.insert(1)
out(s.sorted())

var m = Map()
m.insert("a", 1)
m.insert("b", 2)
out(m.map(|pair| pair.0))
out(m.to_vec())

var c = Counter()
c.inc("x")
out(c.to_vec())

var d = Deque()
d.push_back(7)
out(d.sum())

var lo = MinHeap()
lo.push(2)
lo.push(1)
out(lo.to_vec())

var b = BitSet()
b.insert(4)
b.insert(9)
out(b.sum())

out((0..5).sum())
out((0..=5).sum())
out("abc".map(|ch| ch.to_int()))
[1, 2]
[a, b]
[(a, 1), (b, 2)]
[(x, 1)]
7
[1, 2]
13
10
15
[97, 98, 99]

A pipeline’s currency is Vec. Every stage answers one whatever the receiver was, which is what makes “what does filter return” answerable without knowing what you started from. A program that wants a different collection back says which one, with a to_* row.

The Requires column below is the row’s own constraint on the item type. Its wording is the compiler’s: an unorderable element gets Y006 values of type ... cannot be ordered, and one that cannot be a key gets Y014 a value of type ... can change after it is stored.

Stages

MethodResultRequiresFaultsWhat it does
enumerate()Vec[(Int, T)]noPair each element with its index.
filter((T) -> Bool)Vec[T]noKeep elements satisfying a predicate, collecting into a Vec.
filter_map((T) -> Option[U])Vec[U]noMap each element to an Option and keep the Some payloads.
flat_map((T) -> Vec[U])Vec[U]noMap each element to a Vec and concatenate the results.
frequencies()Counter[T]items usable as keysnoA Counter holding how many times each element occurs.
map((T) -> U)Vec[U]noApply a function to each element, collecting into a Vec.
reversed()Vec[T]noA new Vec holding these elements in reverse order.
skip(Int)Vec[T]noDrop the first n elements.
sorted()Vec[T]items orderableyesA new Vec holding these elements in ascending order.
sorted_by_key((T) -> K)Vec[T]the extracted key is orderableyesA new Vec ordered by the key the closure extracts.
take(Int)Vec[T]noKeep at most the first n elements.
take_while((T) -> Bool)Vec[T]noKeep elements until the predicate is false.
unique()Vec[T]items usable as keysnoA new Vec with duplicate elements removed, keeping first occurrences.
zip(Vec[U])Vec[(T, U)]noPair elements with another sequence, stopping at the shorter length.

sorted, sorted_by_key, unique, reversed, frequencies and join are barriers: each needs the whole sequence before it can answer anything, so each is a call into the runtime rather than a stage the compiler folds into the loop. reversed is the clearest case of the definition — it cannot answer its first element until it has seen the last. Being a barrier is invisible from a program except in what it costs — the other stages are fused into a single pass over the source, which is also what the “Faults” column is measuring here: a fused stage has no wrapper of its own to fault, while sorted and sorted_by_key do — and sorted_by_key‘s also propagates whatever the key closure raised. reversed is the barrier that does not fault, and the two facts are the same fact: it reads no descriptor callback, which is also why its Requires column is empty where its neighbours’ are not.

join is in the sinks table below rather than here, because it answers a Text rather than a sequence.

Sinks

MethodResultRequiresFaultsWhat it does
all((T) -> Bool)BoolnoTrue if all elements satisfy the predicate (short-circuits).
any((T) -> Bool)BoolnoTrue if any element satisfies the predicate (short-circuits).
count()IntnoNumber of elements.
count((T) -> Bool)IntnoNumber of elements satisfying the predicate.
find((T) -> Bool)Option[T]noThe first matching element, or None.
fold(Acc, (Acc, T) -> Acc)AccnoReduce elements left-to-right with an accumulator and combining closure.
join(Text)Textitems are TextyesThese Text items concatenated with the separator between them.
max()Intitems are IntyesLargest (Int) element. Faults on an empty sequence.
max_by((T, T) -> Bool)TyesLargest element per a (T, T) -> Bool “less-than” comparator.
min()Intitems are IntyesSmallest (Int) element. Faults on an empty sequence.
min_by((T, T) -> Bool)TyesSmallest element per a (T, T) -> Bool “less-than” comparator.
position((T) -> Bool)Option[Int]noThe index of the first matching element, or None.
product()Intitems are IntyesMultiply the (Int) elements.
reduce((T, T) -> T)TyesReduce left-to-right, seeded with the first element.
sum()Intitems are IntyesSum the (Int) elements.

count is the one name in the catalog that carries two arities on a single receiver — count() is the element count, count(pred) the matching-element count — which the table’s (receiver, name, arity) key has always allowed. (get, contains and [] also appear at two arities, but split across receivers: one argument on a Vec, two on a Grid.)

min/max are Int sinks and min_by/max_by take a “less-than” comparator and work on anything. find answers the element, position the index, and both answer an Option.

The seven faulting sinks fault for two reasons and no others. min, max, min_by, max_by and reduce raise empty collection on an empty sequence: each has to answer with an element and there is none. sum and product raise integer overflow, because the running total is checked arithmetic like every other + and *. fold is the sink that does not fault on an empty sequence — it answers its seed — which is the reason to reach for it over reduce.

var v: Vec[Int] = Vec()
out(v.min())
error: program faulted: empty collection

Backtrace:
#0   <entry>

  locals:
    v: Vec[Int] = []
  temps:
    <tmp#1: Vec[Int]> = []
    <tmp#3: Int> = 0
    <tmp#4: Int> = 0
    <tmp#8: Unit> @ "out(v.min())" = <uninit>

Conversions

MethodResultRequiresFaultsWhat it does
to_bitset()BitSetitems are IntyesA BitSet holding these (Int) items. Faults on a negative or oversized member.
to_counter()Counter[T]items are (T, Int) pairs; T usable as a keynoA Counter built from (key, count) pairs. Duplicate keys: last wins.
to_deque()Deque[T]noA Deque holding these items, in order.
to_map()Map[K, V]items are (K, V) pairs; K usable as a keynoA Map built from (key, value) pairs. Duplicate keys: last wins.
to_max_heap()MaxHeap[T]items orderablenoA MaxHeap holding these items.
to_min_heap()MinHeap[T]items orderablenoA MinHeap holding these items.
to_set()Set[T]items usable as keysnoA Set holding these items, duplicates dropped.
to_vec()Vec[T]noThe items as a Vec. On a Vec receiver this is the receiver itself.

There is a conversion for every collection that has a constructor, and exactly one that has none: to_grid does not exist, because a grid needs a width and a flat item sequence does not carry one.

to_map and to_counter say “my item is a pair” in the receiver pattern rather than in prose, so [1, 2].to_map() fails at the method name with expected (?T, ?U), found Int instead of resolving and then faulting.

The whole pipeline, run

var v = [3, 1, 4, 1, 5]

out(v.map(|n| n * 2))
out(v.filter(|n| n > 2))
out(v.filter_map(|n| if n > 3 { Some(n) } else { None }))
out(v.flat_map(|n| [n, n]))
out(v.take(2))
out(v.skip(3))
out(v.take_while(|n| n < 4))
out(v.enumerate())
out(v.zip(["a", "b"]))

out(v.fold(0, |acc, n| acc + n))
out(v.reduce(|acc, n| acc + n))
out(v.sum())
out(v.product())
out(v.count())
out(v.count(|n| n == 1))
out(v.min())
out(v.max())
out(v.min_by(|a, b| a < b))
out(v.max_by(|a, b| a < b))
out(v.any(|n| n == 4))
out(v.all(|n| n > 0))
out(v.find(|n| n > 3))
out(v.position(|n| n > 3))

out(v.sorted())
out(v.sorted_by_key(|n| 0 - n))
out(v.unique())
out(v.reversed())
out(v.frequencies())
out(["a", "b", "c"].join(", "))

out(v.to_vec())
out(v.to_set())
out(v.to_deque())
out(v.to_min_heap().peek())
out(v.to_max_heap().peek())
out(v.to_bitset())
out([(1, "a"), (2, "b")].to_map())
out([("x", 3)].to_counter())
[6, 2, 8, 2, 10]
[3, 4, 5]
[4, 5]
[3, 3, 1, 1, 4, 4, 1, 1, 5, 5]
[3, 1]
[1, 5]
[3, 1]
[(0, 3), (1, 1), (2, 4), (3, 1), (4, 5)]
[(3, a), (1, b)]
14
14
14
60
5
2
1
5
1
5
true
true
Some(4)
Some(2)
[1, 1, 3, 4, 5]
[5, 4, 3, 1, 1]
[3, 1, 4, 5]
[5, 1, 4, 1, 3]
{1: 2, 3: 1, 4: 1, 5: 1}
a, b, c
[3, 1, 4, 1, 5]
{1, 3, 4, 5}
[3, 1, 4, 1, 5]
1
5
{1, 3, 4, 5}
{1: a, 2: b}
{x: 3}

What the Requires column refuses

A tuple can be a key but cannot be ordered — no composite in this language can, because ordering goes through one scalar comparison. That is a statement about < and sorted(), not about a container: a Map[(Int, Int), V] still walks and prints its keys element-wise, because it has to walk them in some reproducible order. A record behaves exactly the same way: a fine key, not orderable. A Vec is neither one nor the other: not orderable, for the same composite reason, and not a key, because it can change after it has been stored. Below, the tuple fails the first column and the Vec fails the second.

var pairs = [(2, "b"), (1, "a")]
out(pairs.sorted())

var groups = [[1], [2]]
out(groups.to_set())
$ praxis check catalog-bounds.px
error[Y006]: values of type `(Int, Text)` cannot be ordered

  catalog-bounds.px:2:11
  2 | out(pairs.sorted())
    |           ^^^^^^ values of type `(Int, Text)` cannot be ordered

error[Y014]: a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

  catalog-bounds.px:5:12
  5 | out(groups.to_set())
    |            ^^^^^^ a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

help: use a value that cannot change — a number, `Text`, or a tuple of those

praxis: 2 error(s)

sorted_by_key is the answer to the first half: the ordering requirement moves to the key the closure extracts, so the elements themselves need not be orderable.

var pairs = [(2, "b"), (1, "a")]
out(pairs.sorted_by_key(|pair| pair.0))
[(1, a), (2, b)]

Scalars

Text is the one scalar with members, and Int, Float and Char have the conversions and the explicit-overflow family.

Text

MethodResultMutatesFaultsAllocatesWhat it does
get(Int)CharnoyesyesThe Char at index; faults if out of range. t[index] is the same row and the same answer.
float()Option[Float]nonoyesThe Float this text spells as Some(x), or None if it spells none.
int()Option[Int]nonoyesThe Int this text spells as Some(n), or None if it spells none.
is_empty()BoolnononoTrue iff the text has no chars.
len()IntnonoyesNumber of Unicode scalar values (chars) in the text.
var line = "héllo"

out(line.len())
out(line.is_empty())
out(line.get(1))
out(line.get(1).to_int())
out((233).to_char())
out(" 42 ".int())
out("héllo".int())
out("1.5".float())
out("inf".float())
5
false
é
233
é
Some(42)
None
Some(1.5)
None

len() counts Unicode scalar values and get/t[i] index by them, not by bytes — which is why "héllo".len() is 5 and line.get(1) is é. Char.to_int and Int.to_char are the round trip out of and back into a character, and they are Int and Char rows rather than Text ones.

int() and float() trim the text and then read exactly what the input parser’s int and float atomics read, over the whole of what is left. They share the parser’s own scanner, so t.int() and parse(t, int) cannot disagree about what a number is. Anything the run does not cover is None"1 2", "12abc", "1." — because a text that is not a number is absence rather than a fault.

Two answers surprise people, and both follow from that rule:

  • "+5".int() is None. The int atomic takes a leading - and not a +. "+5.0".float() is a value, because the float atomic does take one.
  • "inf".float() and "nan".float() are None. Float has those values — 1.0 / 0.0 is one, and to_text() prints them — but no text spells one.

A value past Int’s range is None too. The input parser is the other way to get a number out of text, and the one to reach for when the text came from input in the first place: it reports where the parse broke instead of answering None.

There is no split, no chars and no to_upper: all three are Y110. for ch in text is how a Text is walked, and the pipeline rows above apply to it directly.

Int

MethodResultMutatesFaultsAllocatesWhat it does
checked_add(Int)Option[Int]nonoyesAdd, answering None where the checked + would fault.
checked_mul(Int)Option[Int]nonoyesMultiply, answering None where the checked * would fault.
checked_sub(Int)Option[Int]nonoyesSubtract, answering None where the checked - would fault.
saturating_add(Int)IntnonoyesAdd, clamping to Int’s ends instead of faulting.
saturating_mul(Int)IntnonoyesMultiply, clamping to Int’s ends instead of faulting.
saturating_sub(Int)IntnonoyesSubtract, clamping to Int’s ends instead of faulting.
to_char()CharnoyesyesThe Char with this Unicode scalar value; faults (InvalidChar) if it is negative, above 0x10FFFF, or a surrogate. The narrowing half of the pair, as Float.to_int is.
to_float()FloatnonoyesWiden to Float; the explicit Int→Float conversion.
to_text()TextnonoyesFormat as Text — the same digits out writes.
wrapping_add(Int)IntnonoyesAdd with two’s-complement wraparound instead of a fault.
wrapping_mul(Int)IntnonoyesMultiply with two’s-complement wraparound instead of a fault. The one row here a program could not write for itself: every arithmetic operator is checked and the language has no bitwise operators.
wrapping_sub(Int)IntnonoyesSubtract with two’s-complement wraparound instead of a fault.

Float

MethodResultMutatesFaultsAllocatesWhat it does
abs()FloatnonoyesAbsolute value.
ceil()FloatnonoyesRound toward positive infinity.
floor()FloatnonoyesRound toward negative infinity.
is_infinite()BoolnononoTrue iff ±infinity.
is_nan()BoolnononoTrue iff NaN.
max(Float)FloatnonoyesThe larger of two floats. If either is NaN, returns the other.
min(Float)FloatnonoyesThe smaller of two floats. If either is NaN, returns the other.
round()FloatnonoyesRound half away from zero.
sign()FloatnonoyesSign as -1.0 / 0.0 / 1.0. NaN yields NaN.
sqrt()FloatnonoyesSquare root. Negative inputs yield NaN (IEEE-754).
to_int()IntnoyesyesTruncate toward zero to an Int. Faults on NaN, ±inf, or out of i64 range.
to_text()TextnonoyesFormat as Text (shortest round-trip form; inf/-inf/NaN as literals).

Char

MethodResultMutatesFaultsAllocatesWhat it does
to_int()IntnonoyesThe Unicode scalar value, as an Int. Never faults.
to_text()TextnonoyesThe one-character Text holding this scalar — the same character out writes. Never faults.

All three receivers at once:

var x = -2.5

out(x.abs())
out(x.sign())
out(x.floor())
out(x.ceil())
out(x.round())
out(x.to_int())
out(x.to_text())
out(x.min(1.0))
out(x.max(1.0))
out((2.0).sqrt())
out((0.0 / 0.0).is_nan())
out((1.0 / 0.0).is_infinite())

out((7).to_float())
out((9223372036854775807).wrapping_add(1))
out((9223372036854775807).saturating_add(1))
out((9223372036854775807).checked_add(1))
out((5).checked_sub(1))
out((3).wrapping_mul(4))
out((1660).to_text())
out('A'.to_text())
2.5
-1.0
-3.0
-2.0
-3.0
-2
-2.5
-2.5
1.0
1.4142135623730951
true
true
7.0
-9223372036854775808
9223372036854775807
None
Some(4)
12
1660
A

Integer arithmetic is checked by default, and the nine wrapping_/saturating_ /checked_ rows are how a program opts out of the fault for one operation.

The to_text family is Int, Float and Char, and it is closed at three: Bool has no row and there is no universal T.to_text(). Each of the three answers exactly the characters out writes — the method and the printer share one renderer per scalar, so they cannot drift apart. A labelled line does not need any of them: "n = {n}" renders a value of any type through the same printer.

Subscripts

m[key], v[i] = x and grid[x, y] are catalog rows too, dispatched on the receiver’s shape and the index count exactly as a method call is. Their names — [], []=, []min=, []max= — are not identifiers, so no program can call them by name; the subscript grammar is their only caller.

Six receivers read. Five of the six also store: every one but Text, which is immutable.

ReceiverSpellingResultMutatesFaultsAllocatesWhat it does
Counter[T]c[key]Intnonoyesc[key] — the count for key, or zero if absent; never faults.
Deque[T]d[i]Tnoyesnod[i] — the element at i (0-based from the front); faults if out of range.
Grid[T]g[x, y]Tnoyesnog[x, y] — the cell at (x, y); faults if out of range.
Map[K, V]m[key]Vnoyesnom[key] — the value for key; faults if absent.
Textt[i]Charnoyesyest[i] — the Char at i, indexing by Unicode scalar value and not by byte; faults if out of range.
Vec[T]v[i]Tnoyesnov[i] — the element at i; faults if out of range.
Counter[T]c[key] = nUnityesnoyesc[key] = n — set the count for key.
Deque[T]d[i] = valueUnityesyesnod[i] = value — replace the element at i (0-based from the front); faults if out of range (it never inserts).
Grid[T]g[x, y] = valueUnityesyesnog[x, y] = value — set the cell at (x, y); faults if out of range.
Map[K, V]m[key] = valueUnityesnoyesm[key] = value — set key, replacing any prior value.
Vec[T]v[i] = valueUnityesyesnov[i] = value — replace the element at i; faults if out of range (it never appends — push is the spelling that grows a vector).
Map[K, Int]m[key] max= nUnityesnoyesm[key] max= n — keep the larger value; an absent entry accepts the first value.
Map[K, Int]m[key] min= nUnityesnoyesm[key] min= n — keep the smaller value; an absent entry accepts the first value.
var v = [10, 20, 30]
v[0] = 11
out(v[0])

var d = Deque()
d.push_back("a")
d[0] = "b"
out(d[0])

out("praxis"[2])

var m = Map()
m["k"] = 1
out(m["k"])

var c = Counter()
c["x"] = 4
out(c["x"])
out(c["never seen"])

var best = Map()
best["r"] min= 5
best["r"] min= 3
best["r"] max= 4
out(best)
11
b
a
1
4
0
{r: 4}

min= and max= exist as their own rows rather than as read-modify-write over the other two, because they give an absent entry a meaning no read can express: the first value is accepted as-is. A subscript read of an absent Map key faults, so there would be nothing to compare against.

var m = Map()
m.insert("a", 1)
out(m["b"])
error: program faulted: index out of bounds

Backtrace:
#0   <entry>

  locals:
    m: Map[Text, Int] = {"a": 1}
  temps:
    <tmp#1: Map[Text, Int]> = {"a": 1}
    <tmp#3: Text> @ ""a"" = "a"
    <tmp#4: Int> @ "1" = 1
    <tmp#5: Unit> @ "m.insert("a", 1)" = Unit
    <tmp#6: Text> @ ""b"" = "b"
    <tmp#7: Int> @ "m["b"]" = <uninit>
    <tmp#8: Unit> @ "out(m["b"])" = <uninit>

When a method does not resolve

Two diagnostics cover almost everything. Y110 is “this table has no such row” — including the wrong argument count, since arity is part of the key, so [1, 2].get() is no method 'get' on type 'Vec[Int]' taking 0 argument(s). Y001 is “the row exists and your types do not fit it”, which is what the item shapes produce; the two requirement columns above produce Y006 and Y014 instead.

out("a,b".split(","))
out([1.5, 2.5].sum())
out([1, 2].to_map())
out(['a'].join(""))
out([1, 2].to_text())
$ praxis check catalog-refusals.px
error[Y110]: no method `split` on type `Text` taking 1 argument(s)

  catalog-refusals.px:1:11
  1 | out("a,b".split(","))
    |           ^^^^^ no method `split` on type `Text` taking 1 argument(s)

error[Y001]: expected Int, found Float

  catalog-refusals.px:2:16
  2 | out([1.5, 2.5].sum())
    |                ^^^ expected Int, found Float

error[Y001]: expected (?T, ?U), found Int

  catalog-refusals.px:3:12
  3 | out([1, 2].to_map())
    |            ^^^^^^ expected (?T, ?U), found Int

error[Y001]: expected Text, found Char

  catalog-refusals.px:4:11
  4 | out(['a'].join(""))
    |           ^^^^ expected Text, found Char

error[Y001]: expected Char, found Int

  catalog-refusals.px:5:12
  5 | out([1, 2].to_text())
    |            ^^^^^^^ expected Char, found Int

praxis: 5 error(s)

The second is why sum is spelled as a bound rather than a literal Vec[Int] receiver: the row still matches a Vec[Float], so the report is about the element type you have rather than “no method sum on this type”. The last two are the same shape, and the same reason join and to_text bound their item rather than naming a concrete receiver.

See method resolution for how a call finds its row, and diagnostic codes for the full list.

The read expression

read PARSER applies a parser to the whole process input and gives you back a value whose type the compiler already knows. There is no scanner object, no line iterator and no Result: the shape of the input is written once, in a small sublanguage, and everything after it is ordinary Praxis.

// One `read` at the top, ordinary code underneath. The parser expression is
// broken across lines because whitespace outside the backticks is not input.
var segments = read lines(
    `{x1:int},{y1:int} -> {x2:int},{y2:int}`
)
var total = 0
for s in segments {
    total = total + abs(s.x2 - s.x1) + abs(s.y2 - s.y1)
}
out(segments.len())
out(total)

Given

0,9 -> 5,9
8,0 -> 0,8
9,4 -> 3,4

it prints

3
27

segments is a Vec[{ x1: Int, y1: Int, x2: Int, y2: Int }], and it is that type before the program runs — s.x3 is a compile error, not a runtime surprise. How a parser gets its type is the whole rule.

Where the input comes from

praxis run reads the process input from standard input, or from the file named by --input. By the time a program sees it, the two are the same input.

$ praxis run read-shape.px --input read-shape.in
3
27
$ praxis run read-shape.px < read-shape.in
3
27

Standard input is read lazily. The CLI installs a reader rather than the bytes, and that reader is called by the first read a program evaluates, so a program with no read in it never touches standard input and does not sit waiting on an open pipe. --input FILE is the eager half: the file is read before the program starts, so an unreadable one is reported — with exit code 2, before any output — whether the program reads or not.

read is an expression

It is a prefix expression, so it goes wherever a value goes. Store it in a rebindable variable:

var values = read lines(int)
values = values.filter(|value| value > 1)

or pass it straight into a call:

out(solve(read grid(char)))

What it is not is a stream. Every read parses the same immutable buffer from its first byte, so a second one is not a continuation of the first.

// `read` is not a consuming stream. Both expressions parse the same buffer
// from its first byte, so the second one still sees all six bytes.
var numbers = read lines(int)
var whole = read rest
out(numbers)
out(whole.len())

Over 1\n2\n3\n:

[1, 2, 3]
6

That makes repeated reads deterministic. It is also why most programs have exactly one: a second read is a second description of the same bytes, which is usually a sign the first one wanted to be a sections or a block.

The two parser modes

The operand of read is not an ordinary expression. It is a parser expression, written in a sublanguage with two visual modes, and the backtick is the border between them.

Parser-expression mode is everything outside backticks. It is a grammar of constructor calls and atomic names, and its own whitespace means nothing — newlines, indentation and comments are ignored, because none of it describes input.

read lines(
    // a comment here is a comment, not input
    `{x1:int},{y1:int} -> {x2:int},{y2:int}`,
)

Template mode is everything between backticks. There, every character is about the input: , matches a comma, a space matches a run of horizontal whitespace, and {...} is a capture. Templates and captures covers the syntax, and Whitespace, lines and positions covers what each kind of space matches.

The border is enforced in both directions. A labelled argument such as skip: whitespace or ranges: lines(int) belongs to the parser-expression grammar and is a syntax error in an ordinary call. A backtick template outside read/parse is an error rather than a Text.

A parser is not a value either: there is no var p = int. int inside a parser expression is an atomic parser; int in an ordinary expression is an undefined name.

Empty input is input

A reader that answers zero bytes has given empty input. There is no separate “no input” state a program can be in: --input /dev/null, a closed pipe and a terminal all produce a zero-length buffer, and the parser runs against it.

// Empty input is input. `read-empty.in` is a zero-byte file, and
// `lines` over nothing is an empty Vec — an answer, not a fault.
out(read lines(int))
out((read rest).len())
[]
0

That is the right answer and not a special case: splitting nothing into lines gives no lines. A parser that requires content still fails, and says so at offset zero — which is a sentence you can act on:

// The other half of the rule: a program that requires content still gets a
// fault over empty input, and the fault says where it looked and what for.
out(read int)
error: program faulted: input parse mismatch
       at input offset 0..0: expected int

Backtrace:
#0   <entry>

  temps:
    <tmp#1> = ""
    <tmp#2: Int> = 1
    <tmp#4: Unit> @ "out(read int)" = <uninit>

“You forgot to pipe your input” is a thing that report tells you. When a parse fails reads the rest of it.

Parsing a Text you already have

parse(text, PARSER) runs the same sublanguage against a Text instead of the process input. It is syntax, not a function — its second argument is a parser expression, which is not something an ordinary call could take.

// `parse(text, PARSER)` runs the same sublanguage against a `Text` you already
// have. Nothing is trimmed off the root, so `parse(t, rest)` is the identity.
var sample = "1,2,3"
out(parse(sample, csv(int)))
out(parse("ab\ncd\n", rest) == "ab\ncd\n")
[1, 2, 3]
true

The second line is a property worth relying on: a root parse runs against the whole buffer with nothing trimmed off it, so rest at the root really is everything, terminator included. There is no hidden newline handling anywhere in the parser — the way a file’s own trailing newline stops mattering is the whitespace rule, not a trim.

parse is how you try a parser against a literal without a file, and how you re-parse a field you first captured as text. Every example in these chapters that shows its input inline is using it.

Shaping a program around one read

The shape that works is: one read that produces the whole puzzle, then code that never looks at a byte again.

var data = read sections(
    rules: lines(`{before:int}|{after:int}`),
    updates: lines(csv(int)),
)

data.rules and data.updates are typed collections of records. Nothing downstream splits a string, and nothing downstream can be wrong about what the input looked like, because the description is in one place and the compiler checked it.

Where to go from here:

Atomic parsers

An atomic parser is a leaf: it reads one run of bytes starting at the cursor and produces one value. Everything else in the input sublanguage — templates, lines, grid, choice — exists to decide which bytes an atomic is handed. There are ten of them and the list is closed.

parserwhat it readstype
intan optional -, then decimal digitsInt
uintdecimal digits; a leading - is refusedInt
floatan optional sign, digits, an optional . fraction, an optional exponentFloat
bytea decimal integer in 0..=255Byte
charone Unicode scalar value, whatever it isChar
digitone decimal digitInt
worda non-empty run up to a space, tab, comma, CR or LFText
identifieran identifier, by the language’s own identifier ruleText
textthe region it is givenText
restthe region it is givenText
// All ten atomic parsers, once each, against text chosen so the value is
// visible. `text` and `rest` are in `atom-text-rest.px`.
out(parse("-42", int))
out(parse("007", uint))
out(parse("-2.5e3", float))
out(parse("255", byte))
out(parse("é", char))
out(parse("7", digit))
out(parse("a-b:c d", word))
out(parse("count_2 = 3", identifier))
-42
7
-2500.0
255
é
7
a-b:c
count_2

An atomic name is spelled in lower case, and it is only a name inside a parser expression. int in ordinary code is an undefined identifier; there is no value of “parser” type to bind.

one_of("LR") is a leaf too, but it takes an argument, so it lives with the constructors rather than here. Its result is Char.

Leading spaces and tabs

Seven of the ten skip a leading run of spaces and tabs before they look at anything. Three do not, and it matters: a space is a character, and leading whitespace is part of a text.

  • Skips it: int, uint, float, byte, digit, word, identifier.
  • Reads it: char, text, rest.
// Which atomics skip leading spaces and tabs, and which do not. The numeric
// and word-shaped ones do; `char`, `text` and `rest` read the byte at the
// cursor, because a space is a character and leading space is part of a text.
out(parse("   42", int))
out(parse("   9", digit))
out(parse("   hi", word))
out(parse("   x", char) == parse(" ", char))
out(parse("   ab", rest))
42
9
hi
true
   ab

Only leading horizontal whitespace, and only spaces and tabs — a line ending is never skipped by an atomic. What happens to whitespace an atomic leaves behind is the caller’s business, and there is one rule for that: trailing whitespace belongs to nobody.

The numbers

int takes an optional - and then decimal digits. It does not take a leading +: parse("+1", int) is a mismatch at offset 0. uint is the same run with the sign refused — the type is still Int, and the non-negativity is enforced by the parse rule rather than by a separate integer type.

byte is a decimal integer in 0..=255 producing a Byte. It is a number, not a raw input byte: parse("255", byte) reads three characters. 300 is a mismatch (expected byte), not a wraparound.

digit is exactly one decimal digit, and its type is Int, not Byte or Char. It exists so a dense digit grid has a cell parser: grid(digit) is one digit per cell where grid(int) would be one whole number per cell.

float takes an optional - or +, then digits, then a fraction only if there are digits after the ., then an exponent only if it is complete. So a trailing . or e is not part of the number — it is left for whatever follows.

// `float`'s run takes an optional sign, digits, a fraction only when there are
// digits after the `.`, and an exponent only when it is complete. So `1.` is a
// `1` and a literal dot, and `1e` is a `1` and a literal `e`.
out(parse("+4", float))
out(parse("1.", `{v:float}{tail:rest}`))
out(parse("1e", `{v:float}{tail:rest}`))
4.0
{ v: 1.0, tail: . }
{ v: 1.0, tail: e }

char

One Unicode scalar value, taken at the cursor with nothing skipped. A space is a Char, a tab is a Char, and é is one Char and not two bytes. That is what makes a character grid positional: grid(char) counts cells, so a row with a space in the middle is three columns wide and a row that ends in a space is one column wider than its neighbours.

char fails only when there is nothing left in the region: expected char at the region’s end.

word and identifier

word reads a non-empty run and stops on a space, a tab, a comma, CR or LF. That list is deliberately short — it does not include -, :, |, > or anything else a template might use as punctuation.

// `word` stops on a space, a tab, a comma, CR or LF, and on nothing else. It
// runs straight through `-` and `:`, which is what makes `-to-` templates work
// — the literal that follows the capture is what stops it there.
out(parse("a-b:c d", word))
out(parse("seed-to-soil map:", `{source:word}-to-{destination:word} map:`))
out(parse("hello,world", csv(word)))
a-b:c
{ source: seed, destination: soil }
[hello, world]

The second line is the reason the delimiter set stays small. A bare word swallows seed-to-soil whole; a word capture inside a template stops at the literal that follows it, because every capture is bounded. Growing word’s own delimiter set to cover - would have broken the bare case to fix a case the bound already fixes.

An empty run is a failure: word at a comma reports expected word and reads nothing.

identifier reads a run that starts with an identifier-start character and continues with identifier-continue characters — the language’s identifier class, the same one that decides what a Praxis binding may be called, not a narrower ASCII copy of it. Use it when the input’s names are genuinely identifiers and you want x2 but not x-2.

text and rest

Both take the region they are given, whole, leading whitespace included. In the implementation they are the same parser, and the difference the two names suggest lives one level up: a capture is bounded by whatever follows it in the template, and that bound applies to every capture, not only the text ones.

// `text` and `rest` are one parser: both take the whole region they are given.
// What makes a `text` capture stop early is the bound a template puts on
// *every* capture, so `rest` in the same position stops in the same place.
out(parse("prefoopost", `pre{body:text}post`))
out(parse("prefoopost", `pre{body:rest}post`))
out(parse("a b\nc\n", text) == parse("a b\nc\n", rest))
out(parse("Card 1: 41 48 83", `Card {id:int}: {body:rest}`))
{ body: foo }
{ body: foo }
true
{ id: 1, body: 41 48 83 }

Write text where a capture has something after it and rest where it does not; the two words then say what you meant, even though the compiler cannot tell them apart. Neither ever fails.

The Card line is worth reading twice: the space after : is part of the template’s literal run, so the run consumes it and body starts at 4. A template’s trailing whitespace is a policy the input must satisfy, not text the capture inherits — see whitespace.

How an atomic fails

Every atomic fails the same way: a parse mismatch carrying the byte offset it was looking at and the name of the parser that was looking. There is no Result, no Option and nothing to check.

// `uint` refuses a leading `-`. Every atomic fails the same way: a mismatch
// naming the byte offset it looked at and the parser that looked.
out(read uint)
error: program faulted: input parse mismatch
       at input offset 0..1: expected uint
       actual: -5⏎

Backtrace:
#0   <entry>

  temps:
    <tmp#1> = "-5\n"
    <tmp#2: Int> = 1
    <tmp#4: Unit> @ "out(read uint)" = <uninit>

The expected word is the atomic’s own keyword, so the report names the leaf that disagreed rather than the constructor that called it. The offset is absolute — a byte index into the whole input, not into the line or field the atomic was handed. When a parse fails covers the rest of the report and what the crash debugger does with it.

An atomic that succeeds but does not fill the region it was given is a different question, and its answer belongs to whoever computed the region: lines(int) over 12junk is a mismatch, and lines(int) over 12 is not. Whitespace, lines and positions is that rule.

Templates and captures

A backtick template describes one piece of input by looking like it. The characters between the backticks are the fixed text the input must have, and {...} marks the places where the interesting parts are.

read lines(`{x1:int},{y1:int} -> {x2:int},{y2:int}`)

That reads 0,9 -> 5,9 and produces { x1: 0, y1: 9, x2: 5, y2: 9 }, one per line. The template is the whole specification: no split, no trim, no index arithmetic, and the record’s fields are the names you wrote.

Two things are going on in it.

  • Literal text,, -> — must be matched by the input. Punctuation and words match exactly; a run of spaces has its own rules.
  • A capture{x1:int} — hands a stretch of input to a parser and keeps what it produces.

Named and anonymous captures

A capture is {name:parser} or just {parser}. Which one you write decides the shape of the result, and the rule is read off the template’s own parts.

templateresult
no capturesUnit
one anonymous capturethe captured value itself
two or more anonymous capturesa tuple, in order
named capturesan anonymous record with those field names
// The four shapes, read off the template's own parts: no capture is Unit, one
// anonymous capture is that value, two or more are a tuple, and named captures
// are an anonymous record.
out(parse("hello", `hello`))
out(parse("42", `{int}`))
out(parse("1,2", `{int},{int}`))
out(parse("1,2,x", `{int},{int},{word}`))
out(parse("x=1", `{name:word}={v:int}`))
Unit
42
(1, 2)
(1, 2, x)
{ name: x, v: 1 }

Anonymous captures are for when the position says everything — coordinate pairs, two-column tables. Named captures are for everything else, and they are what makes the rest of the program readable: s.x1 beats s.0 the moment there are more than two of them.

The tuple is an ordinary tuple, all the way down into the collection that holds it:

// A multi-capture template's value is an ordinary tuple, all the way into the
// collection that holds it: it renders and compares like one built by hand.
var pairs = read lines(`{int},{int}`)
var same = Vec()
same.push((1, 2))
same.push((3, 4))
out(pairs)
out(pairs == same)

Over 1,2\n3,4\n:

[(1, 2), (3, 4)]
true

The record is an anonymous record: its type is its field names and their types, and nothing had to be declared.

Named and anonymous captures may not be mixed in one template — the result would have to be a record and a tuple at once.

// Named and anonymous captures may not be mixed in one template: the shape
// would have to be a record and a tuple at once.
out(read lines(`{x:int},{int}`))
error[I020]: named and anonymous captures may not be mixed in one template

  template-mixed-captures.px:3:16
  3 | out(read lines(`{x:int},{int}`))
    |                ^^^^^^^^^^^^^^^ named and anonymous captures may not be mixed in one template

praxis: 1 error(s)

Two captures with the same name is I021, for the same reason a record cannot have two fields called x.

A capture body is a parser expression

The : in {name:parser} is followed by a whole parser expression, not just an atomic name. Constructor calls, string arguments, and templates of their own all go inside the braces.

// A capture body is a whole parser expression, not just an atomic name: a
// constructor call, a call with a string argument, a `}` inside that string,
// and a template of its own all sit inside `{...}`.
out(parse("Monkey 0: 79, 98", `Monkey {id:int}: {items:csv(int)}`))
out(parse("a-b-c", `{parts:sep("-", word)}`))
out(parse("}", `{c:one_of("}")}`))
out(parse("at 3,4", `at {p:`{x:int},{y:int}`}`))
{ id: 0, items: [79, 98] }
{ parts: [a, b, c] }
{ c: } }
{ p: { x: 3, y: 4 } }

The scanner finds the end of a capture by tracking depth rather than by looking for the first }, so a } inside a string, a , inside a call, and a nested backtick run all stay inside the capture where you wrote them. That is why line three works: one_of("}") is a legal body, and the } in its argument does not close anything.

A name is split off only at a : at depth zero, so {g:choice(A: word, B: int)} is a capture named g whose body is a choice — the colons inside the call are not candidates.

Whitespace around the name is trimmed: { n :int} names n. The name itself must be an identifier, by the same rule that decides what a Praxis binding may be called, so {2x:int} is I011. A body naming nothing the compiler recognizes is I012{value:intr} reports unknown parser intr and suggests int — and a body calling a constructor that does not exist is I013. There is no default: a capture whose kind is unrecognized fails the compile rather than quietly becoming an int.

A capture is bounded by what follows it

A capture does not take everything it could. It is handed a region that ends where the run of literal parts after it can first match, and it must fill that region. “Earliest” is what makes text non-greedy, and it applies to every capture, not only the text ones.

The bound is the earliest position at which the whole run of literal parts up to the next capture can match. A run that can match the empty string — nothing at all, or a \s* — constrains nothing, and then the capture takes the rest of its region.

// Every capture is bounded by the run of literal parts that follows it, and
// the bound is the earliest place that whole run can match. A run that can
// match nothing — `\s*`, or nothing at all — is no bound.
out(parse("x y bar", `{a:text} bar`))
out(parse("x y bar", `{a:text}\s+bar`))
out(parse("x  bar", `{a:text}\s*bar`))
out(parse("aaa", `{a:text}a{b:rest}`))
{ a: x y }
{ a: x y }
{ a: x }
{ a: , b: aa }

Read those four in order:

  1. {a:text} bar stops a at the space before bar, not at the first space. The bound is where the run matches, and the run is a space and then bar.
  2. {a:text}\s+bar is the explicit spelling of the same policy and bounds a in the same place. What decides the bound is that the run must match something, not which way it was written.
  3. {a:text}\s*bar bounds a at x, because the earliest place the whole run can start is right after it: \s* eats the two spaces and bar lands. The spaces belong to the policy, not to a.
  4. {a:text}a{b:rest} stops at the first a, which is position zero, so a is empty. Non-greedy means non-greedy.

The last capture in a template has nothing after it, so nothing bounds it: it takes the rest of its region and stops where its own parser stops. That is why a root-level template does not fault on the file’s trailing newline.

A capture is offered the bytes at the cursor including its own leading whitespace — whether to skip that is the child’s decision, not the template’s. What the leading run does not do is bound the capture; see whitespace.

A template ends at the line it opens on

A raw newline may not appear inside a template. \n is how a template matches a line ending, and it is the only way — which is also how a template reaches a second line.

// A template ends at the line it opens on, so `\n` is the only way it matches
// a line ending — and the only way one reaches a second line. The escape
// matches CRLF as well as LF.
out(parse("1\n2\n", `{a:int}\n{b:int}`))
out(parse("1\r\n2\n", `{a:int}\n{b:int}`))
{ a: 1, b: 2 }
{ a: 1, b: 2 }

The escape matches CRLF as well as LF, which is the other half of the reason for the rule. A raw newline is whitespace but not a space, so it would match none of the whitespace policies and fall through to literal text — an LF-only match, silently hostile to a CRLF file. \n costs one character more and is right on both.

It also bounds the report when a template is left open. The run cannot outlive its line, so an unterminated backtick names one line instead of the rest of the file:

// The same rule bounds the report when a template is left open: the run ends
// at the line's end, so `T002` names one line and there is no cascade.
var v = read `{int`
out(v)
error[T002]: unterminated backtick template

  template-unterminated.px:3:14
  3 | var v = read `{int`
    |              ^^^^^^ unterminated backtick template

praxis: 1 error(s)

One error, not a cascade. The } closing the enclosing block is no longer swallowed by the token, so the parser and the type checker never see the damage.

A template is a parser expression everywhere or nowhere

Backticks mean “parser expression” and nothing else. A template outside read and parse has nothing to read from, so it is a diagnostic rather than a Text that happens to contain braces.

// A backtick template is a parser expression everywhere or nowhere.
// Outside `read` and `parse` it has nothing to read from, so it is an error
// rather than a `Text` that happens to contain braces.
var t = `n = {int}`
out(t)
error[Y023]: a backtick template is a parser expression; write `read` before it, or pass it to `parse(text, ...)`

  template-value-position.px:4:9
  4 | var t = `n = {int}`
    |         ^^^^^^^^^^^ a backtick template is a parser expression; write `read` before it, or pass it to `parse(text, ...)`

praxis: 1 error(s)

The alternative is a program that asked to parse an integer printing the word {int}, and type-checking while it did. The message names the fix, because the fix is always the same word.

A backtick is never a way to build text. "..." is the text literal, and the braces in it are interpolation"n = {n}" renders n. The two mechanisms share nothing but the character: a template’s {name:parser} names a capture in the input-parser DSL, while a literal’s {expr} is an ordinary Praxis expression rendered into text.

Escapes

Inside a template, \` is a backtick and \\ is a backslash. \n, \t, \x20, \s* and \s+ are whitespace policies and are covered in Whitespace, lines and positions. Anything else after a backslash is an error naming exactly the sequence you wrote.

A double quote in literal text is a double quote: a string literal is only a thing inside a capture body, so `He said "hi" {x:int}` is an ordinary template.

// Backticks and backslashes take ordinary escapes; a quote inside literal text
// is just a quote, because a string literal is only a thing inside a capture.
out(parse("a`b 3", `a\`b {x:int}`))
out(parse("a\\b 4", `a\\b {x:int}`))
out(parse("He said \"hi\" 5", `He said "hi" {x:int}`))
{ x: 3 }
{ x: 4 }
{ x: 5 }

Where templates go

A template is a parser expression, so it goes anywhere one does: as the whole operand of read, as the child of lines, sections, ws, sep or scan, as a block item, as a choice case, and — as above — inside another template’s capture.

The one thing to know about a template inside a block is that it is offered its own line plus one more for each \n it writes, where every other kind of item is offered the rest of the region. That rule is block’s, and it is why a template with a trailing capture does not swallow the item after it.

For what each of these produces as a type, see How a parser gets its type.

Structural parsers

A structural parser constructor takes a region of the input, splits it, and applies a child parser to each piece. lines splits on line endings, csv on commas, sections on blank lines, grid on nothing at all — it lets the cell parser decide how far a cell reaches. Constructors nest, so sections(lines(csv(int))) is a Vec[Vec[Vec[Int]]] and reads exactly the way it is spelled.

There are fourteen of them and the list is closed; what goes inside one is an atomic parser or a template. Everything below is written as read CONSTRUCTOR(...), because a parser expression only exists after read or inside parse(text, ...); that is what makes a labelled argument such as skip: legal, since it belongs to the parser grammar and has no meaning in an ordinary call.

Each example below is three blocks: the program, the input it was run against, and what it printed.

A call is a shape, checked before anything is built

Each constructor has a fixed argument shape, and the shape is checked before a single parser node is constructed. A wrong argument is a compile error, never an argument that is quietly dropped.

CallShape
lines(P)one parser
sections(P)one parser…
sections(name: P, …)…or named arguments only
csv(P)one parser
ws(P)one parser
sep("SEP", P)a string literal, then a parser
grid(P)one cell parser…
grid(P, ragged, fill: v)…or a cell parser with ragged and fill:
matrix(P)one parser
chars(P), chars(P, skip: policy)one parser and an optional skip:
one_of("LR")one string literal
block(item, …)one or more items, positional or named
choice(Name: P, …)named arguments only, at least one
optional(P)one parser
scan(P)one parser
repeated(P)a named argument of a sections call, and only its last one
repeated(P, N)exactly N sections; may be any named argument of a sections call

repeated is in the table so that misusing it is a specific complaint rather than “unknown constructor”. It is a marker, not a parser in its own right.

lines(P)

Split the region into logical lines and apply P to each. Every line must be consumed by P; what P leaves over is forgiven only when it is whitespace.

// `lines(P)` splits the region into lines and applies P to each one.
var values = read lines(int)

out(values)
out(values.sum())
10
20
30
[10, 20, 30]
60

Result type: Vec[result(P)].

sections(P)

Split on one or more blank lines and apply P to every section. A blank line is sections’ separator the way a comma is csv’s, so an interior run of them is one separator and a trailing run is none.

// `sections(P)` splits on blank lines and applies P to each section.
var groups = read sections(lines(int))

out(groups.len())
for g in groups {
    out(g.sum())
}
1
2

3
4
5

6
3
3
12
6

Result type: Vec[result(P)].

sections(name: P, …) and repeated(P)

Named arguments parse fixed sections in order, and the result is a record with one field per name.

// Named arguments parse fixed sections in order, into a record.
var data = read sections(
    rules: lines(`{before:int}|{after:int}`),
    updates: lines(csv(int)),
)

out(data.rules.len())
out(data.rules.get(0).before)
out(data.updates)
47|53
97|13

75,47,61
97,61,53
2
47
[[75, 47, 61], [97, 61, 53]]

Fewer sections than fields is a parse fault. More sections than fields is not: the extra ones are simply not read — unless the last field is a repeated(P) tail, which takes every section that is left and produces a Vec.

// `repeated(P)` is the final named argument of a `sections` call: it takes
// every section that is left, as a Vec.
var bingo = read sections(
    draws: csv(int),
    boards: repeated(matrix(int)),
)

out(bingo.draws)
out(bingo.boards.len())
out(bingo.boards.get(1))
7,4,9

1 2
3 4

5 6
7 8
[7, 4, 9]
2
[5, 6, 7, 8]

An unbounded tail may only be last, there may only be one, and its name is a field name like any other. All three are checked, because a tail that was silently moved to the end compiled into a different parser than the one written.

“Only last” is an argument about greed, not about the marker: repeated(P) takes every section that is left, so nothing after it could ever match. When you know how many sections the group has, say so — repeated(P, N) takes exactly N and leaves the rest, so a field may follow it.

// `repeated(P, N)` takes exactly N sections, so a field can follow it.
var data = read sections(
    shapes: repeated(lines(int), 2),
    regions: lines(int),
)

out(data.shapes.len())
out(data.shapes.get(0))
out(data.shapes.get(1))
out(data.regions)
1
2

3
4

10
20
30
2
[1, 2]
[3, 4]
[10, 20, 30]

The count is a number written in the program, never a variable: the parser plan is built when the program is compiled, and there is no value in scope then to read one from. It must be at least 1 — a group of no sections parses nothing — and fewer sections than the count is a parse fault, the same as too few sections for a fixed field. A group of six that finds four is input that did not match, not a Vec of four.

csv(P)

Split on commas and apply P to each field. Nothing is trimmed: the field is handed to P whole, P skips the leading horizontal space it does not read, and a leftover run of whitespace is forgiven.

// `csv(P)` splits on commas. The space around a comma is left in the field;
// `int` does not read it, and what a parser declines is not data.
var program = read csv(int)

out(program)
 1, 2 ,3,4
[1, 2, 3, 4]

The same rule with a different child gives the answer you would want there too: csv(char) over a, ,c reads three characters, one of them a space, because char does read a space wherever it is offered one.

csv always makes at least one field, so it has no answer for a blank line. csv(int) over one faults — and lines(csv(int)) never sees a trailing one, because a trailing line its parser makes nothing of is nobody’s.

ws(P)

Split on runs of whitespace and apply P to each token. Every whitespace character separates, a line ending included, so a token never spans a line: two lines of two numbers are four tokens.

// `ws(P)` splits on runs of whitespace, line endings included, so a token
// never spans a line: this is four tokens, not three.
var tokens = read ws(int)

out(tokens)
1 2
3   4
[1, 2, 3, 4]

sep(SEPARATOR, P)

Split on an exact string, with no implicit trimming. The separator’s own spaces are part of the separator.

// `sep(SEPARATOR, P)` splits on an exact string. Nothing is trimmed.
var chain = read sep(" -> ", word)

out(chain)
alpha -> beta -> gamma
[alpha, beta, gamma]

The separator may not be empty. An empty one never advances the cursor, so it is refused at compile time rather than looping at run time.

sep splits the whole region, newlines and all, so the per-line spelling is lines(sep(",", P)) rather than sep(",", P).

chars(P, skip: policy) and one_of("…")

Apply P repeatedly to characters. skip: says what is passed over between matches, and each policy is named by what it skips:

PolicySkips
nonenothing — every byte of the region belongs to P
whitespacespaces and tabs (the default)
newlinesspaces, tabs and line endings

newlines is the broader policy: it skips everything whitespace skips and line endings besides. The names suggest the opposite containment, which is why they are spelled out here.

// `chars(P, skip: policy)` applies P repeatedly. `newlines` is the broader
// policy: it skips spaces, tabs and line endings.
var moves = read chars(one_of("^v<>"), skip: newlines)

out(moves.len())
out(moves)
^v<>
^^
6
[^, v, <, >, ^, ^]

one_of("…") matches one character from a literal set and is the usual child here, but it is an ordinary parser and works anywhere: lines(one_of("LR")) is a Vec[Char] too.

chars reads its whole region or fails — a child failure is a mismatch, not a place to stop. The case that looks like an exception and is not: the file’s own trailing newline is whitespace no child read, so skip: none still works on a newline-terminated file.

// `skip: none` lets nothing through between matches: every byte of the region
// must belong to the character parser. The file's own trailing newline is
// still nobody's — no policy has to absorb it, because `one_of` declined it.
var turns = read chars(one_of("LR"), skip: none)

out(turns)
LRLR
[L, R, L, R]

Result type: Vec[result(P)], derived from the child — so chars(int, skip: whitespace) is a Vec[Int].

grid(P)

Parse rectangular lines into a Grid. A cell is what its cell parser reads: char reads one Unicode scalar, digit reads one digit, int reads a whole integer token. That is a rule and not a granularity — if grid(int) meant one digit per cell, digit would name nothing. A row’s width is the number of cells it produced, and every row must have the same count.

// `grid(P)` parses rectangular lines into a Grid. A cell is whatever the cell
// parser reads: `char` reads one scalar, so this grid is 4 wide.
var map = read grid(char)

out(map.width())
out(map.height())
out(map[2, 0])
out(map)
..#.
#...
4
2
#
[., ., #, ., #, ., ., .]

Change the cell parser and the same file is a different grid:

// The cell parser decides how far a cell reaches. `digit` reads one digit;
// `int` reads a whole integer token, so the same file is a different grid.
var heights = read grid(digit)

out(heights.width())
out(heights)
12
34
2
[1, 2, 3, 4]

grid(int) over those same bytes is one cell per row, because int reads 12 whole. That is the general rule rather than a granularity, and it is why digit exists at all.

Because char reads a space, a row that ends in one is a wider row and grid(char) says so rather than quietly aligning it. See whitespace, lines and positions for the whole of that rule.

grid(P, ragged, fill: value)

Permit uneven rows and pad every short one to the maximum width. ragged and fill: come together or not at all.

// Uneven rows need the ragged form, and `ragged` and `fill:` come together.
var pad = read grid(char, ragged, fill: ".")

out(pad.width())
out(pad.height())
out(pad)
ab
cde
f
3
3
[a, b, ., c, d, e, f, ., .]

The fill value is parsed by the cell parser, so it has to be something that parser can read: "." for grid(char, …), 0 for grid(int, …). An empty fill is refused, for the same reason an empty separator is.

matrix(P)

Parse lines of whitespace-separated elements into a Grid. matrix splits a row into tokens itself, where grid lets the cell parser decide, so column alignment does not matter.

// `matrix(P)` splits each row into whitespace-separated tokens itself.
var board = read matrix(int)

out(board.width())
out(board.height())
out(board[2, 1])
22 13 17
 8  2 23
3
2
23

matrix(P) is not lines(ws(P)). The two differ exactly where a line has no tokens: ws answers a line of spaces with an empty Vec, which is still an element, while matrix makes no row of it at all.

// `matrix(P)` is not `lines(ws(P))`. They differ exactly where a line has no
// tokens: `ws` answers a line of spaces with an empty Vec, which is an
// element; `matrix` makes no row of it at all.
var rows = read lines(ws(int))
var grid = read matrix(int)

out(rows.len())
out(rows)
out(grid.height())
out(grid)
1 2
3 4
  
3
[[1, 2], [3, 4], []]
2
[1, 2, 3, 4]

An interior line with no tokens is a zero-token row, and it fails the width check like any other row of the wrong size.

block(item, …)

Apply parsers in sequence inside one region. A positional template contributes its named captures to the block’s record; a named item contributes one field.

// A `block` applies its items in sequence inside one region. A positional
// template contributes its named captures to the block's record; a named item
// contributes one field. Each item is offered its own lines: the `csv(int)`
// capture is its template's last part and still stops at the end of its line.
var monkeys = read sections(block(
    `Monkey {id:int}:`,
    `  Starting items: {items:csv(int)}`,
    `  Operation: new = old {op:char} {operand:word}`,
))

for m in monkeys {
    out(m.id)
    out(m.items)
    out(m.op)
    out(m.operand)
}
Monkey 0:
  Starting items: 79, 98
  Operation: new = old * 19

Monkey 1:
  Starting items: 54, 65, 75
  Operation: new = old + 6
0
[79, 98]
*
19
1
[54, 65, 75]
+
6

A block item is offered its own lines: a template item gets the line it starts on plus one more for each \n the template itself writes, and every other item gets the rest of the region, because lines, sections, grid and matrix compute their own extent. Without that rule the {items:csv(int)} above — a capture that is its template’s last part — would swallow the rest of the section.

The window is a narrowing and not a requirement: an item may stop short of it, and block carries the cursor on to the next item. That is what lets two items share a line.

// A block item is offered its own lines, but it need not fill them: `block`
// carries its cursor to the next item, so two items can share a line.
var pair = read block(`a: {a:int}`, `b: {b:int}`)

out(pair.a)
out(pair.b)
a: 1 b: 2
1
2

One shape to keep in mind: a non-template greedy item followed by another item still takes the rest of the region.

// A non-template item is offered the rest of the region, so a greedy one that
// is not last leaves nothing for the item after it.
fn parts() -> Int {
    read block(`h:`, a: csv(int), b: word).a.len()
}

out(parts())
h:
1,2
foo
error: program faulted: input parse mismatch
       at input offset 6..11: expected the rest of the field
       actual: h:⏎1,2⏎foo⏎

Backtrace:
#0   parts
#1   <entry>

  temps:
    <tmp#1> = "h:\n1,2\nfoo\n"
    <tmp#2: Int> = 1
    <tmp#5: Int> @ "read block(`h:`, a: csv(int), b: word).a.len()" = <uninit>

csv is offered everything after the header, foo is not an int, and b would have had nothing left in any case. Put the lines(...) or csv(...) item last.

A positional item that produces a scalar has no field name to contribute and is refused at compile time; name it.

choice(Name: P, …)

Parse one of several alternatives into an anonymous enum, one variant per named case, each carrying its parser’s result as its payload. The first case that matches wins, and choice itself does not require the region to be consumed — whoever bounded the region decides that. So inside lines, the longer alternative goes first.

// `choice` parses one of several alternatives into an anonymous enum, one
// variant per named case, each carrying its parser's result as its payload.
// The first case that matches wins, so the longer alternative goes first: a
// leading `Number` would match `bbb: 2` and leave ` 3` for `lines` to reject.
var entries = read lines(choice(
    Pair: `{name:word}: {left:int} {right:int}`,
    Number: `{name:word}: {value:int}`,
))

for e in entries {
    match e {
        Number(p) => out(p.value)
        Pair({ name, left, right }) => out(left + right)
    }
}
aaa: 1
bbb: 2 3
1
5

Cases are matched with an ordinary variant pattern. Number(p) binds the whole payload record and reads it with p.value; Pair({ name, left, right }) takes it apart in the pattern, and that record pattern has no head because the payload record is anonymous. See pattern matching.

optional(P)

Return Option[result(P)]. A failure consumes no input — this is parser-level optionality, not error recovery.

// `optional(P)` returns Option[T]. A failure consumes nothing.
var maybe = read optional(int)

out(maybe)
match maybe {
    Some(n) => out(n)
    None => out("no number at the start of the input")
}
abc
None
no number at the start of the input

Because the failure consumes nothing, the next item in a block sees the same bytes: block(a: optional(int), b: word) over xyz is { a: None, b: xyz }.

scan(P)

Find repeated matches of P inside text that is otherwise irrelevant, in source order, ignoring everything that does not match. Nothing bounds the root, so a scan that matches nothing is an empty Vec rather than a fault.

// `scan(P)` finds repeated matches inside text it otherwise ignores, and
// returns them in source order.
var program = read scan(choice(
    Multiply: `mul({left:int},{right:int})`,
    Enable: `do()`,
    Disable: `don't()`,
))

out(program.len())
var total = 0
var on = true
for step in program {
    match step {
        Multiply(p) => { if on { total = total + p.left * p.right } }
        Enable(_) => { on = true }
        Disable(_) => { on = false }
    }
}
out(total)
xmul(2,3)%&mul(4,5)!don't()_do()?mul(6,7)
5
68

scan steps by Unicode scalar on a miss, so it never attempts a match at a continuation byte.

The one rule they all inherit

Every constructor above answers the same question the same way: a run of whitespace the parser offered it does not read is not data and not a mismatch. int cannot read the space in 1 , so it is padding; char can, so it is a cell. That single rule decides trailing spaces, trailing blank lines and the file’s own terminator for all of them, and it is why no constructor here carries a newline special case. It follows from one more rule: a position is absolute, and a construct that narrows hands its child a narrower window on the same buffer, never a fresh one starting at zero. Both are set out in whitespace, lines and positions.

For the types these constructors produce, see how a parser gets its type; for what happens when the input does not match, when a parse fails; for a working program per input shape, the cookbook.

Whitespace, lines and positions

Puzzle input is full of whitespace that means nothing and whitespace that means everything, often in the same file. Praxis answers the question in one sentence and applies it everywhere:

A run of whitespace the parser offered it does not read is not data and not a mismatch.

There is one question — does the parser offered these bytes read them? — so there is one answer, and the half of the machinery that can ask it is the half that decides. No constructor has a trailing-newline special case, and none may grow one.

Inside a template the question is different, because there whitespace is something you wrote on purpose. That half comes first.

What a space in a template matches

writtenmatches
a run of spacesone or more spaces or tabs
\s*zero or more whitespace characters, line endings included
\s+one or more whitespace characters, line endings included
\x20exactly one space
\texactly one tab
\none line ending, CRLF included
nothingnothing — the literal must start right here

The plain space run is the flexible one, and it is flexible on purpose: puzzle input aligns columns with variable spacing, and a template that had to count spaces would be unusable.

// A run of ordinary spaces in a template matches one or more spaces or tabs.
// That is the flexible rule column-aligned puzzle input needs: one space in
// the template accepts any horizontal run in the input, but not none.
out(parse("1 2", `{a:int} {b:int}`))
out(parse("1     2", `{a:int} {b:int}`))
out(parse("1\t2", `{a:int} {b:int}`))
{ a: 1, b: 2 }
{ a: 1, b: 2 }
{ a: 1, b: 2 }

“One or more” means one or more. A template that writes a space requires a space:

// A space run requires a space. A template written ` -> ` does not match
// `1->2`, and the mismatch names the position where the run was expected.
var pair = read `{a:int} -> {b:int}`
out(pair.a + pair.b)
error: program faulted: input parse mismatch
       at input offset 1..1: expected whitespace
       actual: 1->2⏎

Backtrace:
#0   <entry>

  locals:
    pair: { a: Int, b: Int } = <uninit>
  temps:
    <tmp#1> = "1->2\n"
    <tmp#2: Int> = 1
    <tmp#7: Int> @ "pair.a + pair.b" = <uninit>
    <tmp#8: Unit> @ "out(pair.a + pair.b)" = <uninit>

The other direction is the same rule. A literal with no run written in front of it consumes nothing before matching: the cursor must already be sitting on it.

// A literal with no whitespace run written in front of it consumes none: the
// cursor must already be sitting on it, so `x:` does not skip an indent.
out((read `x:{a:int}`).a)

Over x:1:

error: program faulted: input parse mismatch
       at input offset 0..2: expected literal "x:"
       actual:  x:1⏎

Backtrace:
#0   <entry>

  temps:
    <tmp#1> = " x:1\n"
    <tmp#2: Int> = 1
    <tmp#5: Unit> @ "out((read `x:{a:int}`).a)" = <uninit>

That does not mean a bare , refuses every space near it, and the reason is worth having straight before the second half of this chapter:

// A bare `,` consumes no whitespace of its own, and this still matches: the
// capture's region ends at the comma, `int` reads `1` and leaves the space
// behind, and whitespace a child declined is nobody's.
out(parse("1 ,2", `{a:int},{b:int}`))
out(parse("1, 2", `{a:int},{b:int}`))
{ a: 1, b: 2 }
{ a: 1, b: 2 }

The comma consumed nothing, both times. On the first line the space fell inside a’s region and int declined it; on the second it fell in front of b and int skipped it. Neither is the literal’s doing — which is the whole of the second half.

A run at either end of a literal is a policy

A whitespace run attached to a literal is not text the following capture inherits. It is a requirement on the input, and satisfying it consumes the run.

// A whitespace run at either end of a template literal is a policy the input
// must satisfy, and the policy consumes it. `a` starts after the space,
// however many spaces the input wrote there.
out(parse("x: hello", `x: {a:rest}`))
out(parse("x:   hello", `x: {a:rest}`))
out(parse("1 -> 2", `{a:int} -> {b:int}`))
{ a: hello }
{ a: hello }
{ a: 1, b: 2 }

a is hello and not " hello", and it is the same hello whether the input wrote one space or three. The run at the end of a literal is a policy exactly like the run at its start, so the mirror image of the fault above is a fault too:

// The mirror of `ws-space-required.px`: a run at the *end* of a literal is a
// policy too, so a template written `-> ` does not match `1->2` either.
var pair = read `{a:int}-> {b:int}`
out(pair.a + pair.b)
error: program faulted: input parse mismatch
       at input offset 3..3: expected whitespace
       actual: 1->2⏎

Backtrace:
#0   <entry>

  locals:
    pair: { a: Int, b: Int } = <uninit>
  temps:
    <tmp#1> = "1->2\n"
    <tmp#2: Int> = 1
    <tmp#7: Int> @ "pair.a + pair.b" = <uninit>
    <tmp#8: Unit> @ "out(pair.a + pair.b)" = <uninit>

Note the offsets: 1..1 for the leading spelling and 3..3 for the trailing one. Each names the byte where the run was looked for.

The escapes

\s* and \s+ are the explicit forms, \x20 and \t are the exact ones, and \n matches a line ending.

// A template's whitespace escapes: `\s*` zero or more, `\s+` one or more,
// `\x20` exactly one space, `\t` exactly one tab, `\n` one line ending.
out(parse("1  ,2", `{a:int}\s*,{b:int}`))
out(parse("1,2", `{a:int}\s*,{b:int}`))
out(parse("1  ,2", `{a:int}\s+,{b:int}`))
out(parse("1  2", `{a:int}\x20{b:rest}`))
out(parse("1  2", `{a:int} {b:rest}`))
out(parse("1\t2", `{a:int}\t{b:rest}`))
{ a: 1, b: 2 }
{ a: 1, b: 2 }
{ a: 1, b: 2 }
{ a: 1, b:  2 }
{ a: 1, b: 2 }
{ a: 1, b: 2 }

Line four is the point of \x20: exactly one space is consumed, so b gets " 2" where the flexible run on line five leaves it "2". Reach for \x20 when indentation is data — a puzzle where two leading spaces mean something different from four.

\s* and \s+ are also broader than spaces and tabs: they match line endings.

// `\s*` and `\s+` match line endings too, where a plain space run does not.
// A plain run is horizontal whitespace; the two escapes are all whitespace,
// and these two lines are where that difference shows.
out(parse("1\n2", `{a:int}\s+{b:int}`))
out(parse("1\n2", `{a:int}\s*{b:int}`))
{ a: 1, b: 2 }
{ a: 1, b: 2 }

That is worth knowing in both directions. It makes \s+ a way to join two lines without writing \n; it also means \s+ is not a drop-in for a plain space run when a record must not run past its line. Inside a lines(...) it makes no difference, because the region ends at the line ending anyway.

A capture is not bounded by its own leading whitespace

A capture is offered the bytes at the cursor, its own leading whitespace included — whether to skip them is the child’s decision. What that leading run does not do is decide where the capture ends.

// A capture is offered the bytes at the cursor, its own leading whitespace
// included — the child decides. What the leading run does *not* do is bound
// the capture, or `{a:text}` would stop at byte 0 on an indented line.
out(parse("  foo 3", `{a:text} {v:int}`))
out(parse("  foo 3", `{a:word} {v:int}`))
{ a:   foo, v: 3 }
{ a: foo, v: 3 }

Same template, same bytes, two children, two answers — and both are the child’s own rule from Atomic parsers. If the leading run bounded the capture instead, {a:text} would stop at byte 0 on every indented line, because a space run matches the indent itself.

Trailing whitespace belongs to nobody

Outside a template, whitespace is not something you wrote — it is something the input has. The rule at the top of this chapter is what decides it, and it has two halves.

The bound half asks the child. Wherever a construct requires its child to consume a region exactly — a line, a section, a CSV field, a ws or sep token, a matrix cell, a template capture — what the child leaves over is forgiven if it is whitespace and is a mismatch otherwise.

The extent half asks nobody. A construct that splits a region into lines never hands out a trailing empty one: the split drops the run of lines holding no bytes at all — the file’s own terminator, the "\n\n" an editor leaves behind, any number of them. That happens before any parser runs, which is why it is restricted to lines with nothing in them to decide about. The region itself is not trimmed; only the split is.

// Trailing whitespace belongs to nobody when no parser reads it — at the end
// of a line, of a region, or of the file. `int` makes nothing of a space or of
// a line of spaces, so both are padding rather than data or a mismatch.
out(parse("1 \n2 \n", lines(int)))
out(parse("1 2 3\n\n", ws(int)))
out(parse("1\n2\n  \n", lines(int)))
[1, 2]
[1, 2, 3]
[1, 2]

Every one of those would break under a different rule. Line one is two elements because int cannot read the space after the digit; a construct that required its line to be filled byte for byte would fault. Line two is three tokens because a ws token contains no whitespace at all — a rule that trimmed a fixed number of terminators off the buffer would hand int the token 3\n. Line three is two elements because int makes nothing of a line of spaces, so it is nobody’s.

There is no trim anywhere in this. A root parse runs against the whole buffer with its terminator inside it, which is why parse(t, rest) is the identity on t.

The same rule, a different child

Whitespace a parser can read is data. Change the child and the same bytes come out the other way — which is what says this is one rule and not a file convention.

// The same rule, the other child: `char` reads a space, so the trailing run is
// a cell and the trailing line of spaces is a row. `grid` complains about the
// data, not about a file convention.
out(parse("ab\ncd\n  \n", grid(char)).height())
out(parse("  \n  \n", grid(char)).width())
out(parse("ab\ncd\n  \n", lines(rest)).len())
out(parse("1 2\n3 4\n  \n", lines(ws(int))))
out(parse("1 2\n3 4\n  \n", matrix(int)))
3
2
3
[[1, 2], [3, 4], []]
[1, 2, 3, 4]

char reads a space as a cell, so a trailing line of spaces is a row and grid(char) over " \n \n" is a 2×2 grid of spaces. lines(rest) is lossless for the same reason. And the last two lines are why matrix(P) is not a synonym for lines(ws(P)): a child that succeeds vacuously has made something of the line — ws answers an all-whitespace region with an empty collection — where matrix has no zero-token row to make and drops it.

The one place this bites is grid(char) over a file whose last row alone ends in a space: that is a genuinely ragged grid and it says so. Put the space on every row and the grid is one column wider. Compare grid(int), where the run is padding, because int reads no cell there.

An interior blank line is structure

Only a trailing run is forgiven. An interior blank line is data about the shape of the input, and no constructor skips one.

// Only a *trailing* run is forgiven. An interior blank line is structure: it
// is a zero-element line, and `lines(int)` says so where it stands.
out((read lines(int)).len())

Over 1\n \n2\n:

error: program faulted: input parse mismatch
       at input offset 4..4: expected int
       actual: 1⏎  ⏎2⏎

Backtrace:
#0   <entry>

  temps:
    <tmp#1> = "1\n  \n2\n"
    <tmp#2: Int> = 1
    <tmp#4: Int> @ "(read lines(int)).len()" = <uninit>
    <tmp#5: Unit> @ "out((read lines(int)).len())" = <uninit>

Offset 4 is the end of the blank line, not its start: int skipped the two spaces looking for a digit and ran out of line. grid(digit) and matrix(int) fault on the same shape, by the same rule — a blank line is a zero-cell, zero-token row and the count check rejects it like any other wrong-sized row. The messages differ; the rule does not.

sections is the one construct for which a blank line is its own separator, interior or trailing. That is its definition, not an exception: sections is defined on blank lines the way csv is defined on commas.

An interior run of anything else is data too, and always was. lines(int) over 12junk is a mismatch, chars(digit, skip: none) over 1\n2 is a mismatch, and sep(",", int) over 1,2\n3,4\n is a mismatch because the second field really is 2\n3 — the multi-line spelling is lines(sep(...)). “Trailing” is load-bearing.

Positions are absolute and regions only narrow

A parser position is a byte offset into the whole input. A construct that narrows — lines to a line, sections to a section, a capture to its bound — gives its child a narrower window on the same buffer, never a fresh buffer starting at zero. A window can only get smaller.

That is invisible until something goes wrong, and then it is the whole diagnostic:

// A parser position is absolute. The mismatch is on the second line of the
// second section — what `word` left of it — and the offset it reports counts
// from the first byte of the input, not from the start of that line.
out((read sections(lines(word))).len())

Over

alpha
beta

gamma
has space

it reports

error: program faulted: input parse mismatch
       at input offset 21..27: expected the rest of the line
       actual: alpha⏎beta⏎⏎gamma⏎has space⏎

Backtrace:
#0   <entry>

  temps:
    <tmp#1> = "alpha\nbeta\n\ngamma\nhas space\n"
    <tmp#2: Int> = 1
    <tmp#4: Int> @ "(read sections(lines(word))).len()" = <uninit>
    <tmp#5: Unit> @ "out((read sections(lines(word))).len())" = <uninit>

21..27 is space counted from the first byte of the file, two levels of narrowing down. word read has and stopped there; what faulted is the lines inside the sections requiring its child to fill the line, which is why the report says the rest of the line rather than word. The failing line is the fifth in the file — and the offset is still an offset you can find in the input with any tool you like.

Two consequences fall out of the same design:

  • Every captured Text is a slice of the one input buffer, with the right offset. A word in the second section names its own bytes and not the bytes at the start of the file.
  • A root parse requires nothing. Requiring a region to be filled is a parent’s decision, made by whoever computed the bound. Nobody bounded the root, so scan(...) and a root-level choice(...) may match a fragment and stop — and so may a root-level template, which is why one does not fault on the file’s trailing newline.

The debugger reads the same positions back: in a crash session input shows the failing offset in its input context and parser shows the parser expression that reached it. See Inspecting the input parser and When a parse fails.

If you are adding a constructor

The corollary, stated for anyone extending the parser: do not write a trailing-newline or blank-line special case. A construct that tokenizes to the end of its region and bounds its children exactly has already inherited the rule; a construct that splits lines drops a trailing blank line only when its parser made nothing of it. Anything that forgives whitespace per constructor is fixing this in the wrong place, N times, and will end up disagreeing with itself. One constructor forgiving a run the others do not is the exact shape this rule exists to prevent.

How a parser gets its type

A parser expression has a result type, and the compiler works it out from the expression alone — before the program runs and without looking at any input. read P is an expression of exactly that type. There is no ParseError, no Result to unwrap and no dynamic value to inspect: if the input does not match, the program faults, and if it does match you already have a typed value.

The derivation is a walk over the parser expression. Every rule is local: a collection constructor’s type is its child’s type wrapped, a labelled argument becomes a record field, and an atom’s type is fixed.

Asking the compiler

The quickest way to find out what you have is to annotate the binding with a type it cannot be. The found half of the diagnostic is the derived type.

// Ask the compiler for a parser's type by annotating the binding with a type
// it cannot be. The `found` half of the diagnostic is the derived type.
var a: Bool = read int
var b: Bool = read digit
var c: Bool = read uint
var d: Bool = read float
var e: Bool = read byte
var f: Bool = read char
var g: Bool = read word
var h: Bool = read one_of("LR")
$ praxis check t-atom-types.px --color never
error[Y001]: expected Bool, found Int

  t-atom-types.px:3:15
  3 | var a: Bool = read int
    |               ^^^^^^^^ expected Bool, found Int

error[Y001]: expected Bool, found Int

  t-atom-types.px:4:15
  4 | var b: Bool = read digit
    |               ^^^^^^^^^^ expected Bool, found Int

error[Y001]: expected Bool, found Int

  t-atom-types.px:5:15
  5 | var c: Bool = read uint
    |               ^^^^^^^^^ expected Bool, found Int

error[Y001]: expected Bool, found Float

  t-atom-types.px:6:15
  6 | var d: Bool = read float
    |               ^^^^^^^^^^ expected Bool, found Float

error[Y001]: expected Bool, found Byte

  t-atom-types.px:7:15
  7 | var e: Bool = read byte
    |               ^^^^^^^^^ expected Bool, found Byte

error[Y001]: expected Bool, found Char

  t-atom-types.px:8:15
  8 | var f: Bool = read char
    |               ^^^^^^^^^ expected Bool, found Char

error[Y001]: expected Bool, found Text

  t-atom-types.px:9:15
  9 | var g: Bool = read word
    |               ^^^^^^^^^ expected Bool, found Text

error[Y001]: expected Bool, found Char

  t-atom-types.px:10:15
  10 | var h: Bool = read one_of("LR")
     |               ^^^^^^^^^^^^^^^^^ expected Bool, found Char

praxis: 8 error(s)

An editor shows the same type without the deliberate error — see inference in the editor.

A few things are worth noting from that run. uint and digit are both Int: the non-negativity of uint and the one-digit rule of digit are parse rules, not separate types. byte is Byte — a decimal number in 0..=255, not a raw input byte. word, identifier, text and rest are all Text. The full list is in atomic parsers.

Templates

A template’s type comes from its captures, and there are exactly four cases.

TemplateType
no capturesUnit
one anonymous capturethat capture’s type
several anonymous capturesa tuple, in order
named capturesan anonymous record, one field per name, in order
// A template's type is decided by its captures: none is Unit, one anonymous
// capture is that capture's type, several are a tuple, and named captures are
// an anonymous record.
var a: Bool = read `hello`
var b: Bool = read `{int}`
var c: Bool = read `{int},{int}`
var d: Bool = read `{x:int},{y:int}`
var e: Bool = read `{name:word} {values:csv(int)}`
$ praxis check t-template-types.px --color never
error[Y001]: expected Bool, found Unit

  t-template-types.px:4:15
  4 | var a: Bool = read `hello`
    |               ^^^^^^^^^^^^ expected Bool, found Unit

help: this value is `Unit`; the binding's type annotation expected `Bool` — make the last expression produce a value, or change the declared type to `Unit`

error[Y001]: expected Bool, found Int

  t-template-types.px:5:15
  5 | var b: Bool = read `{int}`
    |               ^^^^^^^^^^^^ expected Bool, found Int

error[Y001]: expected Bool, found (Int, Int)

  t-template-types.px:6:15
  6 | var c: Bool = read `{int},{int}`
    |               ^^^^^^^^^^^^^^^^^^ expected Bool, found (Int, Int)

error[Y001]: expected Bool, found { x: Int, y: Int }

  t-template-types.px:7:15
  7 | var d: Bool = read `{x:int},{y:int}`
    |               ^^^^^^^^^^^^^^^^^^^^^^ expected Bool, found { x: Int, y: Int }

error[Y001]: expected Bool, found { name: Text, values: Vec[Int] }

  t-template-types.px:8:15
  8 | var e: Bool = read `{name:word} {values:csv(int)}`
    |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected Bool, found { name: Text, values: Vec[Int] }

praxis: 5 error(s)

The last one is the general rule at work: a capture’s body is a whole parser expression, so {values:csv(int)} contributes a Vec[Int] field exactly the way a bare csv(int) would be a Vec[Int]. Naming and shape are covered in templates and captures; naming styles may not be mixed in one template, which is what keeps these four cases from overlapping.

A collection’s type is its child’s

Every splitting constructor wraps its child’s type, and nesting the constructors nests the type in the same order.

ParserType
lines(P)Vec[result(P)]
sections(P)Vec[result(P)]
csv(P)Vec[result(P)]
ws(P)Vec[result(P)]
sep("s", P)Vec[result(P)]
chars(P, skip: …)Vec[result(P)]
scan(P)Vec[result(P)]
grid(P)Grid[result(P)]
grid(P, ragged, fill: v)Grid[result(P)]
matrix(P)Grid[result(P)]
optional(P)Option[result(P)]
one_of("…")Char
// A collection constructor's type is its child's, wrapped: nesting the
// constructors nests the type in the same order.
var a: Bool = read lines(int)
var b: Bool = read sections(lines(csv(int)))
var c: Bool = read ws(word)
var d: Bool = read sep(" -> ", word)
var e: Bool = read grid(char)
var f: Bool = read matrix(float)
var g: Bool = read chars(one_of("^v"), skip: newlines)
var h: Bool = read chars(int, skip: whitespace)
var i: Bool = read optional(`{x:int},{y:int}`)
var j: Bool = read scan(`mul({int},{int})`)
$ praxis check t-collection-types.px --color never
error[Y001]: expected Bool, found Vec[Int]

  t-collection-types.px:3:15
  3 | var a: Bool = read lines(int)
    |               ^^^^^^^^^^^^^^^ expected Bool, found Vec[Int]

error[Y001]: expected Bool, found Vec[Vec[Vec[Int]]]

  t-collection-types.px:4:15
  4 | var b: Bool = read sections(lines(csv(int)))
    |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected Bool, found Vec[Vec[Vec[Int]]]

error[Y001]: expected Bool, found Vec[Text]

  t-collection-types.px:5:15
  5 | var c: Bool = read ws(word)
    |               ^^^^^^^^^^^^^ expected Bool, found Vec[Text]

error[Y001]: expected Bool, found Vec[Text]

  t-collection-types.px:6:15
  6 | var d: Bool = read sep(" -> ", word)
    |               ^^^^^^^^^^^^^^^^^^^^^^ expected Bool, found Vec[Text]

error[Y001]: expected Bool, found Grid[Char]

  t-collection-types.px:7:15
  7 | var e: Bool = read grid(char)
    |               ^^^^^^^^^^^^^^^ expected Bool, found Grid[Char]

error[Y001]: expected Bool, found Grid[Float]

  t-collection-types.px:8:15
  8 | var f: Bool = read matrix(float)
    |               ^^^^^^^^^^^^^^^^^^ expected Bool, found Grid[Float]

error[Y001]: expected Bool, found Vec[Char]

  t-collection-types.px:9:15
  9 | var g: Bool = read chars(one_of("^v"), skip: newlines)
    |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected Bool, found Vec[Char]

error[Y001]: expected Bool, found Vec[Int]

  t-collection-types.px:10:15
  10 | var h: Bool = read chars(int, skip: whitespace)
     |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected Bool, found Vec[Int]

error[Y001]: expected Bool, found Option[{ x: Int, y: Int }]

  t-collection-types.px:11:15
  11 | var i: Bool = read optional(`{x:int},{y:int}`)
     |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected Bool, found Option[{ x: Int, y: Int }]

error[Y001]: expected Bool, found Vec[(Int, Int)]

  t-collection-types.px:12:15
  12 | var j: Bool = read scan(`mul({int},{int})`)
     |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected Bool, found Vec[(Int, Int)]

praxis: 10 error(s)

Two of those are worth pausing on. chars(int, skip: whitespace) is a Vec[Int] and not a Vec[Char] — the element type is derived from the child, not assumed from the constructor’s name. And matrix(P) and ragged grid(P) both answer Grid[result(P)]: there is no separate Matrix type.

Labelled arguments become record fields

Wherever the parser grammar takes a name, the name becomes a field or a variant.

  • A named sections(...) is a record with one field per named section, in source order.
  • A repeated(P) tail is one more field, holding a Vec[result(P)]. A counted repeated(P, N) holds the same Vec[result(P)], but in the position it was written rather than at the end — the record’s field order is the source order of the named arguments, so sections(shapes: repeated(lines(int), 2), regions: lines(int)) is { shapes: Vec[Vec[Int]], regions: Vec[Int] }. The count changes how many sections the field reads, not what it holds.
  • A block(...) is one record: a named item contributes its own field, and a positional template contributes each of its named captures directly — flattened into the same record rather than nested inside one.
  • A choice(...) is an anonymous enum with one variant per case, each carrying its case parser’s result as its payload.
// Named arguments become record fields. A `block` flattens a positional
// template's captures into the same record; a `repeated` tail is one field
// holding a Vec; a `choice` is an anonymous enum, one variant per case.
// Each probe is on one line so the diagnostic underlines it on one line.
var a: Bool = read sections(rules: lines(`{before:int}|{after:int}`), updates: lines(csv(int)))
var b: Bool = read sections(draws: csv(int), boards: repeated(matrix(int)))
var c: Bool = read block(`{source:word}-to-{dest:word} map:`, ranges: lines(`{a:int} {b:int}`))
var d: Bool = read choice(Number: `{name:word}: {value:int}`, Op: `{name:word}: {l:word} {r:word}`)
$ praxis check t-record-types.px --color never
error[Y001]: expected Bool, found { rules: Vec[{ before: Int, after: Int }], updates: Vec[Vec[Int]] }

  t-record-types.px:5:15
  5 | var a: Bool = read sections(rules: lines(`{before:int}|{after:int}`), updates: lines(csv(int)))
    |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected Bool, found { rules: Vec[{ before: Int, after: Int }], updates: Vec[Vec[Int]] }

error[Y001]: expected Bool, found { draws: Vec[Int], boards: Vec[Grid[Int]] }

  t-record-types.px:6:15
  6 | var b: Bool = read sections(draws: csv(int), boards: repeated(matrix(int)))
    |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected Bool, found { draws: Vec[Int], boards: Vec[Grid[Int]] }

error[Y001]: expected Bool, found { source: Text, dest: Text, ranges: Vec[{ a: Int, b: Int }] }

  t-record-types.px:7:15
  7 | var c: Bool = read block(`{source:word}-to-{dest:word} map:`, ranges: lines(`{a:int} {b:int}`))
    |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected Bool, found { source: Text, dest: Text, ranges: Vec[{ a: Int, b: Int }] }

error[Y001]: expected Bool, found { Number({ name: Text, value: Int }) | Op({ name: Text, l: Text, r: Text }) }

  t-record-types.px:8:15
  8 | var d: Bool = read choice(Number: `{name:word}: {value:int}`, Op: `{name:word}: {l:word} {r:word}`)
    |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected Bool, found { Number({ name: Text, value: Int }) | Op({ name: Text, l: Text, r: Text }) }

praxis: 4 error(s)

Note what the block line does not say: there is no { header: { source: …, dest: … }, ranges: … }. source and dest sit beside ranges in one flat record, because a positional template’s captures are flattened. A named argument is what nests: in the almanac parser below, the field seeds is a block(...) named argument and its type is the block’s own record, { values: Vec[Int] }.

Duplicate names are refused: two sections fields, two block fields, two captures in one template or two choice cases with the same name are all compile errors, because the record or enum they would build cannot be written.

Reading a nested type

Once the type is derived, it is an ordinary type. Fields are read with ., and a record pattern takes one apart without naming it.

// The derived type is an ordinary type: fields are read with `.`, and a
// record pattern takes one apart without naming it, because it has no name.
var almanac = read sections(
    seeds: block(`seeds: {values:ws(int)}`),
    maps: repeated(block(
        `{source:word}-to-{destination:word} map:`,
        ranges: lines(`{destination:int} {source:int} {length:int}`),
    )),
)

out(almanac.seeds.values)
out(almanac.maps.len())

for m in almanac.maps {
    out(m.source)
    out(m.destination)
    for { destination, source, length } in m.ranges {
        out(destination + source + length)
    }
}
seeds: 79 14 55

seed-to-soil map:
50 98 2
52 50 48

soil-to-fertilizer map:
0 15 37
[79, 14, 55]
2
seed
soil
150
150
soil
fertilizer
52

Read that type outside in and it matches the parser expression term for term: sections(seeds: …, maps: repeated(…)) is a record with seeds and maps; repeated(block(…)) makes maps a Vec of the block’s record; the block’s positional template contributes source and destination, and its named ranges: lines(...) contributes a Vec of the line template’s record.

Anonymous means anonymous

The record and enum types a parser produces have no name, and there is no syntax for writing one down. That has one practical consequence: a helper function cannot declare a parameter of that type, and a struct with the same fields is a different type.

// A parser's record type is anonymous. It is not the declared `struct` that
// has the same fields, and there is no syntax for writing it in an annotation,
// so a helper function cannot take one as a parameter.
struct Point { x: Int, y: Int }

fn total(ps: Vec[Point]) -> Int {
    ps.map(|p| p.x + p.y).sum()
}

out(total(read lines(`{x:int},{y:int}`)))
$ praxis check t-anonymous-vs-struct.px --color never
error[Y001]: expected (Vec[Point]) -> Int, found (Vec[{ x: Int, y: Int }]) -> ?T

  t-anonymous-vs-struct.px:10:5
  10 | out(total(read lines(`{x:int},{y:int}`)))
     |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected (Vec[Point]) -> Int, found (Vec[{ x: Int, y: Int }]) -> ?T

praxis: 1 error(s)

In practice this costs little: closures infer their parameter types, so .map(|p| p.x + p.y) works without an annotation, and a loop over the value needs none either. Where a named type is genuinely wanted, it is one .map away.

// A named type is one `.map` away: the parsed record has the fields, and a
// struct literal takes them.
struct Point { x: Int, y: Int }

fn total(ps: Vec[Point]) -> Int {
    ps.map(|p| p.x + p.y).sum()
}

var points = read lines(`{x:int},{y:int}`).map(|p| Point { x: p.x, y: p.y })

out(points.len())
out(total(points))
1,2
3,4
2
10

More on structural records and where they do and do not unify is in records without names.

When a parse fails

There is no ParseError and no Result. A parser that does not match the input raises a runtime fault, the program stops, and you get the position in the input, what the parser expected there, and a window of the bytes around it. If the run is interactive you land in the crash debugger with the same information under the input and parser commands.

That is the run-time half. The other half never gets that far: a parser expression that is malformed — a constructor that does not exist, an argument of the wrong kind, a template that mixes capture styles — is a compile error with an I0xx code, reported by praxis check before anything runs.

The shape of a parse fault

Each example below is three blocks: the program, the input it was run against, and the standard error of praxis run … --debug never.

// The third line is not an integer.
fn total() -> Int {
    read lines(int).sum()
}

out(total())
1
2
three
4
$ praxis run f-lines-int.px --input f-lines-int.in --debug never
error: program faulted: input parse mismatch
       at input offset 4..4: expected int
       actual: 1⏎2⏎three⏎4⏎

Backtrace:
#0   total
#1   <entry>

  temps:
    <tmp#1> = "1\n2\nthree\n4\n"
    <tmp#2: Int> = 1

Four things are on that first block and each is worth naming.

The fault kind is input parse mismatch. It is one of the runtime fault kinds and it means exactly this: a parser was applied to bytes it could not read. Division by zero, an out-of-range subscript and an integer overflow are different kinds with their own messages — see the fault model.

The input offset is a byte range into the input, absolute and not relative to whatever region the failing parser had been handed. 4..4 is where three starts: bytes 0–3 are 1\n2\n. A zero-width range means the parser expected something at that point and found something else; a non-empty one means the range itself is the complaint.

The expectation is what the failing parser wanted, in its own words: expected int. The failure reported is the deepest one — the one furthest into the input — because that is the most specific point at which parsing broke. Offset decides that and not nesting: at equal offsets the inner parser’s specific complaint is the one kept, and an outer constructor that failed further into the input is reported over the inner one — which is the next example.

The preview is a bounded window of the input around that offset, with line endings rendered as so it stays on one line.

Below that is the ordinary noninteractive crash report: the backtrace, and the locals and temporaries of each frame. The temporary holding the raw input buffer shows up there, which is why the report contains a copy of the input.

Reading the position

The position is where parsing broke, and for a bounded construct that is not always where you would have pointed.

A child that stops short

// `int` reads `12` and stops. `lines` requires the line to be consumed, and
// what is left is not whitespace.
fn values() -> Int {
    read lines(int).len()
}

out(values())
12junk
error: program faulted: input parse mismatch
       at input offset 2..6: expected the rest of the line
       actual: 12junk⏎

Backtrace:
#0   values
#1   <entry>

  temps:
    <tmp#1> = "12junk\n"
    <tmp#2: Int> = 1
    <tmp#4: Int> @ "read lines(int).len()" = <uninit>

int succeeded. lines is what failed, and it says so: expected the rest of the line, spanning junk — the bytes nobody read. Every bounded construct has its own wording, and the wording tells you which one bounded the region:

MessageThe construct that bounded the region
expected the rest of the linelines
expected the rest of the sectionsections
expected the rest of the fieldcsv
expected the rest of the tokenws, sep, matrix
expected the rest of the capturea template capture
expected a grid row of the same cell count as the firstgrid
expected rectangular matrix rowmatrix’s width check
expected section headera named sections with too few sections
expected 6 sections for `shapes` a repeated(P, 6) group with too few sections

Whitespace is the exception, and it is the one rule the whole parser shares: a leftover run the child could not read is forgiven, so lines(int) over "1 \n" is fine. See whitespace, lines and positions.

A row that breaks the shape

// The second row is one cell short. The fault names that row, not the file.
fn heights() -> Int {
    read grid(digit).width()
}

out(heights())
123
45
678
error: program faulted: input parse mismatch
       at input offset 4..6: expected a grid row of the same cell count as the first
       actual: 123⏎45⏎678⏎

Backtrace:
#0   heights
#1   <entry>

  temps:
    <tmp#1> = "123\n45\n678\n"
    <tmp#2: Int> = 1
    <tmp#4: Int> @ "read grid(digit).width()" = <uninit>

4..6 is 45 — the offending row, not the whole grid and not the file. The complaint is about the data (“a row of the same cell count as the first”), not about a file convention, which is what makes it actionable: either the row is short or the grid is ragged and wants grid(P, ragged, fill: v).

A group that came up short

// The count is a promise about the input, so two sections where the program
// said three is a parse fault — not a `Vec` of two. The message names the
// group that came up short, because the number is written in the program and
// "which one" is the only thing left to say.
var data = read sections(
    shapes: repeated(lines(int), 3),
    regions: lines(int),
)

out(data.shapes.len())
1

2
error: program faulted: input parse mismatch
       at input offset 0..5: expected 3 sections for `shapes`
       actual: 1⏎⏎2⏎

Backtrace:
#0   <entry>

  locals:
    data: { shapes: Vec[Vec[Int]], regions: Vec[Int] } = <uninit>
  temps:
    <tmp#1> = "1\n\n2\n"
    <tmp#2: Int> = 1
    <tmp#6: Int> @ "data.shapes.len()" = <uninit>
    <tmp#7: Unit> @ "out(data.shapes.len())" = <uninit>

repeated(P, N) is the one place the program states a number of sections, so the fault states which group the number belonged to rather than the generic expected section header a fixed field gets. Two sections cannot be three, and answering with a Vec of two would be the one outcome the program could not notice.

A literal that was looked for and not found

// The second line writes `=>` where the template writes `->`.
fn pairs() -> Int {
    read lines(`{from:int} -> {to:int}`).len()
}

out(pairs())
1 -> 2
3 => 4
error: program faulted: input parse mismatch
       at input offset 9..11: expected literal "->"
       actual: 1 -> 2⏎3 => 4⏎

Backtrace:
#0   pairs
#1   <entry>

  temps:
    <tmp#1> = "1 -> 2\n3 => 4\n"
    <tmp#2: Int> = 1
    <tmp#4: Int> @ "read lines(`{from:int} -> {to:int}`).len()" = <uninit>

The span is where the literal was looked for, which is after the template’s whitespace policy has run: 3 at offset 7, the space run at 8, and the literal expected at 9.

The case that got furthest

choice tries its cases in order and keeps the failure that reached furthest into the input, so a failed choice names the case the input was trying to be, not the choice itself.

// Neither case matches. The failure reported is the one that got furthest
// into the input — `Multiply` reached the second argument, `Enable` failed at
// the first byte.
fn program() -> Int {
    read lines(choice(
        Multiply: `mul({left:int},{right:int})`,
        Enable: `do()`,
    )).len()
}

out(program())
mul(2,x)
error: program faulted: input parse mismatch
       at input offset 6..6: expected int
       actual: mul(2,x)⏎

Backtrace:
#0   program
#1   <entry>

  temps:
    <tmp#1> = "mul(2,x)\n"
    <tmp#2: Int> = 1
    <tmp#4: Int> @ "read lines(choice( Multiply: `mul({left:int},{right:int})`, Enable: `do()`, )).len()" = <uninit>

expected int at offset 6 is Multiply’s second capture. Enable failed at offset 0 and is not mentioned, which is the point.

In the crash debugger

Run without --debug never on a terminal — or with --debug always anywhere — and the same fault opens the crash debugger. Two commands are about the parse: input prints the offset and the preview, parser prints the expectation.

fn shipping(limit: Int) -> Int {
    // The third line of the input is not an integer.
    var values = read lines(int)
    values.filter(|v| v < limit).sum()
}

out(shipping(25))
1
2
three
4
$ praxis run f-debugger.px --input f-debugger.in --debug always
error: program faulted: input parse mismatch
       at input offset 4..4: expected int
       actual: 1⏎2⏎three⏎4⏎

Backtrace:
#0   shipping
#1   <entry>

  locals:
    limit: Int = 25
    values: Vec[Int] = <uninit>
  temps:
    <tmp#2> = 1
2
three
4

    <tmp#3: Int> = 1
    <tmp#7: (Int) -> Bool> @ "|v| v < limit" = <uninit>
Entered crash debugger. 2 frame(s). Type `help` for commands.
Praxis crash> bt
#0   shipping
#1   <entry>
  (frame 0 selected)
Praxis crash> input
input at offset 4..4:
  1⏎2⏎three⏎4⏎
Praxis crash> parser
expected: int
parser expression: <unknown parser>
Praxis crash> p limit
25
Praxis crash> quit

values is <uninit>: the binding never took a value, because the parse it was waiting on is what failed. Everything bound before the read is live and readable — p limit answers 25 — which is often enough to tell whether the parser is wrong or the input is.

parser expression: <unknown parser> is not a defect in your program. A parse fault carries what the failing parser expected, not the source text of the parser that raised it, so parser answers with the expectation and reports the expression itself as unknown. The full command list is in inspecting the input parser.

Compile-time errors

A malformed parser expression never reaches the interpreter. praxis check reports it with an I0xx code, and it reports every problem in the call rather than the first.

A call that is not the shape the constructor has

// A constructor call is a shape, and the shape is checked before anything is
// built. None of these five reaches the parser interpreter.
var a = read frobnicate(int)
var b = read optional(int, word)
var c = read choice(int)
var d = read sep(int, int)
var e = read chars(digit, skip: wihtespace)
$ praxis check f-err-shape.px --color never
error[I013]: unknown parser constructor `frobnicate`

  f-err-shape.px:3:14
  3 | var a = read frobnicate(int)
    |              ^^^^^^^^^^ unknown parser constructor `frobnicate`

error[I022]: `optional` expects 1 argument, got 2

  f-err-shape.px:4:14
  4 | var b = read optional(int, word)
    |              ^^^^^^^^^^^^^^^^^^^ `optional` expects 1 argument, got 2

error[I022]: `choice` expects at least 1 named argument(s), got 0

  f-err-shape.px:5:14
  5 | var c = read choice(int)
    |              ^^^^^^^^^^^ `choice` expects at least 1 named argument(s), got 0

error[I014]: `choice` argument 1 is a parser, but every argument must be `Name: parser`

  f-err-shape.px:5:14
  5 | var c = read choice(int)
    |              ^^^^^^^^^^^ `choice` argument 1 is a parser, but every argument must be `Name: parser`

error[I014]: `sep` argument 1 is a parser, but the separator must be a string literal

  f-err-shape.px:6:14
  6 | var d = read sep(int, int)
    |              ^^^^^^^^^^^^^ `sep` argument 1 is a parser, but the separator must be a string literal

error[I014]: `skip: wihtespace` is not a skip policy — `none` (skips nothing), `whitespace` (skips spaces and tabs) or `newlines` (skips spaces, tabs and line endings)

  f-err-shape.px:7:14
  7 | var e = read chars(digit, skip: wihtespace)
    |              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `skip: wihtespace` is not a skip policy — `none` (skips nothing), `whitespace` (skips spaces and tabs) or `newlines` (skips spaces, tabs and line endings)

praxis: 6 error(s)

choice(int) gets two errors from one call, which is deliberate: the arity is wrong and the argument kind is wrong, and reporting only one would send you round the loop twice. Note also that the misspelt skip: policy is an error and not a silent fall back to the default.

Values that have no representation, and a marker in the wrong place

// `repeated(...)` is a marker on a named argument of a `sections` call, not a
// parser, and the uncounted form is greedy so it must be last; `sep` needs a
// separator that advances; `grid`'s ragged form is written with both `ragged`
// and `fill:`.
var a = read sections(boards: repeated(matrix(int)), draws: csv(int))
var b = read repeated(int)
var c = read sep("", int)
var d = read grid(char, fill: ".")
$ praxis check f-err-marker.px --color never
error[I028]: an unbounded `repeated(...)` tail may appear only as the final named argument: it consumes every remaining section, so nothing can follow it — write `repeated(P, N)` for a group of N sections, which can

  f-err-marker.px:5:14
  5 | var a = read sections(boards: repeated(matrix(int)), draws: csv(int))
    |              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ an unbounded `repeated(...)` tail may appear only as the final named argument: it consumes every remaining section, so nothing can follow it — write `repeated(P, N)` for a group of N sections, which can

error[I028]: `repeated(...)` is only a named argument of a `sections` call

  f-err-marker.px:6:14
  6 | var b = read repeated(int)
    |              ^^^^^^^^^^^^^ `repeated(...)` is only a named argument of a `sections` call

error[I023]: `sep` needs a non-empty separator: an empty one never advances

  f-err-marker.px:7:14
  7 | var c = read sep("", int)
    |              ^^^^^^^^^^^^ `sep` needs a non-empty separator: an empty one never advances

error[I014]: `grid`'s ragged form is written `grid(P, ragged, fill: value)` — `ragged` and `fill:` come together or not at all

  f-err-marker.px:8:14
  8 | var d = read grid(char, fill: ".")
    |              ^^^^^^^^^^^^^^^^^^^^^ `grid`'s ragged form is written `grid(P, ragged, fill: value)` — `ragged` and `fill:` come together or not at all

praxis: 4 error(s)

Two of those refuse something meaningless rather than something misspelled. An empty separator never advances a cursor, so sep("", P) would loop forever, and it is refused where it is written rather than discovered at run time; an empty fill: is refused by the same rule, because a pad of no characters pads nothing. And grid(char, fill: ".") without ragged is not a shorthand for the ragged form: a fill: on its own would quietly build a different parser from the one written, so the shape requires the two words together.

Templates and block items

// Template and block errors: captures may not mix naming styles, a capture
// name is used once, and a positional `block` item that produces a scalar has
// no field name to contribute.
var a = read lines(`{x:int},{int}`)
var b = read lines(`{x:int},{x:int}`)
var c = read block(int)
$ praxis check f-err-template.px --color never
error[I020]: named and anonymous captures may not be mixed in one template

  f-err-template.px:4:20
  4 | var a = read lines(`{x:int},{int}`)
    |                    ^^^^^^^^^^^^^^^ named and anonymous captures may not be mixed in one template

error[I021]: duplicate capture name `x` in template

  f-err-template.px:5:20
  5 | var b = read lines(`{x:int},{x:int}`)
    |                    ^^^^^^^^^^^^^^^^^ duplicate capture name `x` in template

error[I026]: a positional `block` item returning a scalar must be named

  f-err-template.px:6:14
  6 | var c = read block(int)
    |              ^^^^^^^^^^ a positional `block` item returning a scalar must be named

praxis: 3 error(s)

Every one of these is a type that could not be built: a template with mixed capture styles has no shape, two x captures have no record, and a positional scalar block item has no field name. The rule holds across the whole sublanguage — the errors are about the type the parser would have produced, and they are listed with the rest of the codes in diagnostic codes.

The codes

CodeMeaning
I000a parser expression the lowerer cannot read at all (read 42 is one)
I001a parser AST that could not be converted to a type or a plan
I010an atomic parser name that does not exist
I011an invalid capture name in a template
I012a capture kind that does not exist
I013a parser constructor that does not exist
I014a constructor argument that is invalid or in excess
I020named and anonymous captures mixed in one template
I021one capture name used twice in a template
I022a constructor called with the wrong number of arguments
I023an empty separator, which cannot advance a cursor
I024a section or block field declared twice
I025a sections or choice with no field or case at all
I026a positional block item returning a scalar with no name
I027a choice case declared twice
I028a misplaced or duplicated unbounded repeated(...) tail
I030a backtick template the scanner could not read

Cookbook: input shapes

One recipe per input shape, each a complete program with the input it reads. Every one of them runs: the programs and the outputs below are the files under docs/book/examples/input-b/, and the numbers are what the compiler printed. Each recipe is three blocks — the input, the program, and what it printed.

ShapeParser
one number per linelines(int)
two columnslines(`{left:int} {right:int}`)
one comma-separated linecsv(int)
blank-line groupssections(lines(int))
two named sectionssections(rules: …, updates: …)
a header and N boardssections(draws: …, boards: repeated(matrix(int)))
a map and a command streamsections(map: grid(char), moves: chars(…))
repeated labelled blockssections(…, maps: repeated(block(…)))
instructions in noisescan(choice(…))
a name, an arrow, a listlines(`{from:word} -> {to:ws(word)}`)
mixed instruction lineslines(choice(…))

Run any of them with:

$ praxis run c-one-per-line.px --input c-one-per-line.in

One number per line

The base case. lines(P) splits on line endings and applies P to each line, and every line has to be consumed — a stray non-numeric line is a fault, not a silently skipped element.

199
200
208
210
200
207
240
269
260
263
// Shape: one number per line.
//
// 199
// 200
// 208
var depths = read lines(int)

out(depths.len())
out(depths.sum())
out(depths.max())

// How many readings are larger than the one before.
var increases = depths
    .zip(depths.skip(1))
    .count(|(a, b)| b > a)
out(increases)
10
2256
269
7

The trailing newline needs no special handling. int cannot read it, and a run of whitespace the parser offered it does not read is not data.

Two columns

Two numbers per line, aligned with however many spaces the puzzle felt like. A named-capture template gives a record per line, so the columns have names rather than indices.

3   4
4   3
2   5
1   3
3   9
3   3
// Shape: two columns of numbers, aligned with a variable run of spaces.
//
// 3   4
// 4   3
//
// A run of ordinary spaces in a template matches one or more spaces or tabs,
// so the alignment does not have to be exact.
var pairs = read lines(`{left:int} {right:int}`)

var left = pairs.map(|p| p.left).sorted()
var right = pairs.map(|p| p.right).sorted()

var distance = left
    .zip(right)
    .map(|(a, b)| abs(a - b))
    .sum()

var counts = right.frequencies()
var similarity = left
    .map(|value| value * counts[value])
    .sum()

out(distance)
out(similarity)
11
31

lines(ws(int)) would also read this file, as a Vec[Vec[Int]]. The template is better here because it says there are exactly two columns and what they are called; the mismatch is then a fault at the line that broke the shape rather than a short inner Vec discovered later.

One comma-separated line

A single line of comma-separated integers, read as a Vec[Int] and then mutated in place.

1,9,10,3,2,3,11,0,99,30,40,50
// Shape: one line of comma-separated numbers.
//
// 1,9,10,3,2,3,11,0,99,30,40,50
//
// `csv` splits on commas and nothing else. Space around a comma is left in
// the field, and `int` does not read it, so no trimming is needed.
var program = read csv(int)

out(program.len())
out(program.max())

// Run it as a two-opcode machine: 1 adds, 2 multiplies, 99 halts.
var pc = 0
while pc < program.len() {
    var op = program[pc]
    if op == 99 {
        break
    }
    var a = program[program[pc + 1]]
    var b = program[program[pc + 2]]
    var dst = program[pc + 3]
    if op == 1 {
        program[dst] = a + b
    } else {
        program[dst] = a * b
    }
    pc = pc + 4
}
out(program[0])
12
99
3500

For comma-separated values on many lines, nest: lines(csv(int)) is a Vec[Vec[Int]]. csv(int) on a multi-line file is a mismatch, because csv splits on commas and nothing else, so a field straddles the line ending: over 1,2\n3,4\n the middle field is 2\n3, and what int leaves of it is not whitespace. The file’s own final newline is not what breaks it — the last field above ends in one too, and a run of whitespace nobody read is not data.

Blank-line groups

Groups of numbers separated by blank lines. sections splits on the blank lines, lines splits each section, and the two constructors nest in the order they are written.

1000
2000
3000

4000

5000
6000

7000
8000
9000

10000
// Shape: blank-line separated groups of numbers.
//
// 1000
// 2000
//
// 4000
//
// `sections` splits on blank lines; `lines` splits each section.
var groups = read sections(lines(int))

out(groups.len())

var totals = groups.map(|g| g.sum()).sorted()
out(totals)
out(totals.max())
out(totals.skip(totals.len() - 3).sum())
5
[4000, 6000, 10000, 11000, 24000]
24000
45000

A trailing blank line at the end of the file is not a section: sections never produces an empty one.

Two named sections

Ordering rules, a blank line, then the updates to check against them. The two sections have different shapes, so they get named arguments and the result is a record.

47|53
97|13
97|61
97|47
75|29
61|13
75|53
29|13
97|29
53|29
61|53
97|53
61|29
47|13
75|47
97|75
47|61
75|61
47|29
75|13
53|13

75,47,61,53,29
97,61,53,29,13
75,29,13
75,97,47,61,53
61,13,29
97,13,75,29,47
// Shape: two heterogeneous sections — ordering rules, then updates.
//
// 47|53
// 97|13
//
// 75,47,61,53,29
//
// Named arguments parse the sections in order and give the result one field
// per name.
var data = read sections(
    rules: lines(`{before:int}|{after:int}`),
    updates: lines(csv(int)),
)

out(data.rules.len())
out(data.updates.len())

var forbidden = Set[(Int, Int)]()
for r in data.rules {
    forbidden.insert((r.after, r.before))
}

var ordered = 0
var middles = 0
for update in data.updates {
    var ok = true
    for i in 0..update.len() {
        for j in (i + 1)..update.len() {
            if forbidden.contains((update[i], update[j])) {
                ok = false
            }
        }
    }
    if ok {
        ordered = ordered + 1
        middles = middles + update[update.len() / 2]
    }
}
out(ordered)
out(middles)
21
6
3
143

The sections are matched positionally, in the order they are named. Fewer sections in the file than fields in the parser is a fault (expected section header); more sections are simply not read unless the last field is a repeated(...) tail.

A header and an unknown number of boards

One header section, then as many boards as the file happens to contain. repeated(P) is the final named argument and takes every section that is left.

7,4,9,5,11,17,23,2,0,14,21,24,10,16,13,6,15,25,12,22,18,20,8,19,3,26,1

22 13 17 11  0
 8  2 23  4 24
21  9 14 16  7
 6 10  3 18  5
 1 12 20 15 19

 3 15  0  2 22
 9 18 13 17  5
19  8  7 25 23
20 11 10 24  4
14 21 16 12  6

14 21 17 24  4
10 16 15  9 19
18  8 23 26 20
22 11 13  6  5
 2  0 12  3  7
// Shape: one header section, then an unknown number of boards.
//
// 7,4,9,5,11
//
// 22 13 17 11  0
//  8  2 23  4 24
//
// `repeated(P)` is the final named argument: it takes every section that is
// left. `matrix` splits each row into whitespace-separated tokens itself, so
// the ragged column alignment does not matter.
var bingo = read sections(
    draws: csv(int),
    boards: repeated(matrix(int)),
)

out(bingo.draws.len())
out(bingo.boards.len())
out(bingo.boards.get(0).width())

// When does each board first complete a row or a column?
fn wins_at(board: Grid[Int], order: Map[Int, Int]) -> Int {
    var best = -1
    for y in 0..board.height() {
        var turn = board.row(y).map(|n| order[n]).max()
        if best < 0 || turn < best {
            best = turn
        }
    }
    for x in 0..board.width() {
        var turn = board.column(x).map(|n| order[n]).max()
        if best < 0 || turn < best {
            best = turn
        }
    }
    best
}

var order = Map[Int, Int]()
for (turn, n) in bingo.draws.enumerate() {
    order[n] = turn
}

var turns = bingo.boards.map(|b| wins_at(b, order))
out(turns)

var first = turns.min()
match turns.position(|t| t == first) {
    Some(index) => {
        var winner = bingo.boards.get(index)
        var drawn = bingo.draws.take(first + 1).to_set()
        out(winner.cells().filter(|n| !drawn.contains(n)).sum() * bingo.draws[first])
    }
    None => out("no board wins")
}
27
3
5
[13, 14, 11]
4512

The boards are padded to align their columns, and matrix tokenizes a row on whitespace itself, so 22 13 17 11 0 and 8 2 23 4 24 are both five tokens. grid(int) reads this file identically — int reads a whole token and skips the space in front of it — but matrix is the constructor that says “whitespace-separated” out loud rather than leaning on the cell parser’s whitespace rule to get there.

A map and a command stream

A character map, a blank line, then a stream of movement characters that the puzzle wrapped across lines for no reason of its own.

#######
#.....#
#..#..#
#.....#
#..#..#
#######

>>vv<^^
>>>vv
// Shape: a character map, a blank line, then a stream of movement characters
// wrapped across lines for no reason of its own.
//
// #######
// #.....#
//
// >>vv<^^
//
// `chars(P, skip: newlines)` folds the command stream back into one sequence:
// `newlines` is the broader policy — it passes over spaces, tabs and line
// endings alike. A character the program chooses is a literal, `'#'`.
var data = read sections(
    map: grid(char),
    moves: chars(one_of("^v<>"), skip: newlines),
)

out(data.map.width())
out(data.map.height())
out(data.moves.len())

var wall = '#'
var x = 1
var y = 1
var blocked = 0
for move in data.moves {
    var nx = x
    var ny = y
    // `one_of` already refused every other character, but a `Char` match is
    // never exhaustive without `_`, and an unknown command would stay put.
    match move {
        '>' => { nx = x + 1 }
        '<' => { nx = x - 1 }
        'v' => { ny = y + 1 }
        '^' => { ny = y - 1 }
        _ => {}
    }
    if data.map[nx, ny] == wall {
        blocked = blocked + 1
    } else {
        x = nx
        y = ny
    }
}
out(x)
out(y)
out(blocked)
7
6
12
5
3
4

skip: newlines is what folds the wrapped stream back into one sequence. The default is skip: whitespace, which is horizontal whitespace only and would fault at the first line ending inside the section.

Repeated labelled blocks

A header section, then any number of sections that each begin with a label and continue with lines of numbers. block sequences the label template and the body parser inside one section; repeated(block(...)) applies that to every section that is left.

seeds: 79 14 55 13

seed-to-soil map:
50 98 2
52 50 48

soil-to-fertilizer map:
0 15 37
37 52 2
39 0 15

fertilizer-to-water map:
49 53 8
0 11 42
42 0 7
57 7 4

water-to-light map:
88 18 7
18 25 70

light-to-temperature map:
45 77 23
81 45 19
68 64 13

temperature-to-humidity map:
0 69 1
1 0 69

humidity-to-location map:
60 56 37
56 93 4
// Shape: a header section, then any number of labeled blocks whose bodies are
// lines of numbers.
//
// seeds: 79 14 55 13
//
// seed-to-soil map:
// 50 98 2
// 52 50 48
//
// `block` sequences a header template and a body parser inside one section;
// `repeated(block(...))` applies that to every section that is left. The
// header's captures are flattened into the same record as the named body.
var almanac = read sections(
    seeds: block(`seeds: {values:ws(int)}`),
    maps: repeated(block(
        `{source:word}-to-{destination:word} map:`,
        ranges: lines(`{destination:int} {source:int} {length:int}`),
    )),
)

out(almanac.seeds.values)
out(almanac.maps.len())
out(almanac.maps.get(0).source)
out(almanac.maps.get(0).destination)

// An anonymous record type has no name to write in an annotation, so the
// mapping is done in place rather than in a helper that would have to name it.
var locations = almanac.seeds.values.map(|seed| {
    var v = seed
    for m in almanac.maps {
        for r in m.ranges {
            if v >= r.source && v < r.source + r.length {
                v = r.destination + (v - r.source)
                break
            }
        }
    }
    v
})
out(locations)
out(locations.min())
[79, 14, 55, 13]
7
seed
soil
[82, 43, 86, 35]
35

The header’s captures are flattened into the same record as the named ranges: item, so a map is { source, destination, ranges } and not { header: { … }, ranges: … }. Put the lines(...) item last: it is offered the rest of the region, so anything after it would have nothing left.

Instructions embedded in noise

The data is buried in text that is deliberately corrupt. scan looks for its parser at every position, keeps what matches in source order and ignores the rest.

xmul(2,4)&mul[3,7]!^don't()_mul(5,5)+mul(32,64](mul(11,8)undo()?mul(8,5))
// Shape: instructions embedded in text that is otherwise noise.
//
// xmul(2,4)&mul[3,7]!^don't()_mul(5,5)+mul(32,64](mul(11,8)undo()?mul(8,5))
//
// `scan` looks for its parser at every position, keeps the matches in source
// order and ignores everything else. Nothing bounds the root, so a scan that
// matches nothing is an empty Vec rather than a fault.
var program = read scan(choice(
    Multiply: `mul({left:int},{right:int})`,
    Enable: `do()`,
    Disable: `don't()`,
))

out(program.len())

var all = 0
var enabled = 0
var on = true
for step in program {
    match step {
        Multiply(p) => {
            all = all + p.left * p.right
            if on {
                enabled = enabled + p.left * p.right
            }
        }
        Enable(_) => { on = true }
        Disable(_) => { on = false }
    }
}
out(all)
out(enabled)
6
161
48

Nothing bounds the root, so a scan that matches nothing is an empty Vec rather than a fault. That is the same rule that lets a root-level choice match a prefix: requiring a region to be filled is the decision of whoever computed the bound, and nobody bounded the root.

A name, an arrow, and a list

One line, two shapes. The template names the two halves; sep splits the same line on the exact arrow string.

jqt -> rhn xhk nvd
rsh -> frs pzl lsr
cmg -> qnr nvd lhk bvb
// Shape: a name, an arrow, then a space-separated list.
//
// jqt -> rhn xhk nvd
//
// `sep` splits on the exact string and trims nothing, so the arrow's spaces
// are part of the separator rather than of the words around it. A capture
// whose parser is `ws(word)` takes the whole tail of the line.
var edges = read lines(`{from:word} -> {to:ws(word)}`)

out(edges.len())
for e in edges {
    out(e.from)
    out(e.to)
}

// The same line read the other way: two fields split on the arrow.
var halves = read lines(sep(" -> ", rest))
out(halves.get(0))
3
jqt
[rhn, xhk, nvd]
rsh
[frs, pzl, lsr]
cmg
[qnr, nvd, lhk, bvb]
[jqt, rhn xhk nvd]

Both parsers read the same input: a read expression always parses the whole process input from the beginning, so a second read is not a second half of a stream. That is what makes repeated reads deterministic — and it is also why a normal program only does it once.

Instruction lines of different shapes

One instruction per line, and the instructions do not all look alike. choice inside lines gives one anonymous enum variant per case, and match covers them.

noop
addx 3
addx -5
noop
addx 11
// Shape: one instruction per line, and the instructions have different
// shapes.
//
// noop
// addx 3
// addx -5
//
// `choice` inside `lines` gives one anonymous enum variant per case. The
// first case that matches wins, and `lines` then requires the line to be
// consumed — so a case that matches a prefix of a longer line is caught by
// `lines`, not silently accepted.
var program = read lines(choice(
    Addx: `addx {value:int}`,
    Noop: `noop`,
))

out(program.len())

var x = 1
var cycle = 0
for instruction in program {
    match instruction {
        Noop(_) => { cycle = cycle + 1 }
        Addx(p) => {
            cycle = cycle + 2
            x = x + p.value
        }
    }
}
out(cycle)
out(x)
5
8
10

Case order matters: the first case that matches wins, and choice itself does not require the region to be consumed. Here Noop cannot match an addx 3 line at all, so either order works. Where one case is a prefix of another, put the longer one first — otherwise the shorter one matches, and lines faults on the bytes it left behind.

Choosing between them

Three questions settle most inputs.

What separates the records? A line ending is lines, a blank line is sections, a comma is csv, any run of whitespace is ws, anything else is sep. chars is the one that has no separator: it applies its parser again and again and says what it skips in between.

Does a record have a shape inside it? If so, write a template and name the captures; the fields come out named. If the parts are homogeneous, nest a constructor instead — lines(csv(int)) rather than a template with N captures.

Are the sections different from each other? Then name them, and put a repeated(...) last if there is an unknown number of the final kind. A section whose parts are heterogeneous within the section is a block.

What is left after that is grid and matrix, which are the two-dimensional answers: grid lets its cell parser decide how far a cell reaches, matrix splits a row into whitespace-separated tokens itself.

The constructors are described one by one in structural parsers, the types they produce in how a parser gets its type, and what a mismatch looks like in when a parse fails.

What inference does

No binding, parameter, return type or expression in Praxis needs a type annotation. Every one of them has a static type all the same; the compiler works them out from what the program does with the values. A complete solution can be written without the word Int appearing in it — across the corpus in tests/aoc-corpus/, 28 of the 36 programs carry no annotation at all. Only a struct field and an enum payload must say what they hold, because a declaration is where a type is stated rather than deduced.

The engine is Hindley–Milner inference extended with what the language actually has: mutable bindings, nominal records and enums, the structural records the input parser derives, collection constructors, closures, and a small closed set of internal requirements. Types live in an interned arena — a type is a 32-bit handle into a table, so every expression can carry one for free, and a type variable is a slot in that same table rather than a separate kind of thing.

Here is what all of that comes to, as a program:

fn total(values) {
    values.sum()
}

var values = [1, 2, 3]
out(total(values))
6

Nothing is annotated. [1, 2, 3] makes values a Vec[Int]; passing it to total makes total’s parameter a Vec[Int]; sum on a Vec[Int] makes the result an Int. Information flows in the other direction too — had values been built empty and pushed into afterwards, the push would have decided the element type just the same.

Asking the compiler what it inferred

Inference is only pleasant if you can see its answers. There are three ways to get them, and all three print the same rendering.

The editor is the everyday one. The language server writes an inlay hint at every binding the source does not annotate — parameters, vars, for variables, names a pattern introduces — and hover gives the full scheme. That is its own chapter.

A deliberate mismatch works anywhere. Feed a function an argument it cannot take, and the diagnostic prints the signature inference derived:

fn add(a, b) {
    a + b
}

out(add(1, 2))
out(add("one", 2))
$ praxis check inferred-signature.px --color never
error[Y001]: expected (Int, Int) -> Int, found (Text, Int) -> ?T

  inferred-signature.px:6:5
  6 | out(add("one", 2))
    |     ^^^^^^^^^^^^^ expected (Int, Int) -> Int, found (Text, Int) -> ?T

praxis: 1 error(s)

expected is add’s inferred type — (Int, Int) -> Int, derived from + alone. found is the function type this call site would need. Reading a signature out of a mismatch is a habit worth having; the error chapter covers the rest of what these reports say.

The crash debugger answers directly. type EXPR type-checks an expression against the faulted frame’s locals and prints the result without running it:

fn inspect(values, total, name) {
    panic("stopping here on purpose")
}

var values = Vec[Int]()
values.push(3)
values.push(4)
inspect(values, values.sum(), "run")

Driven with type values, type total, type name, type values.sorted(), quit:

error: program faulted: panic: stopping here on purpose

Backtrace:
#0   inspect__Vec_Int__Int_Text
#1   <entry>

  locals:
    values: Vec[Int] = [3, 4]
    total: Int = 7
    name: Text = "run"
  temps:
    <tmp#4: Text> @ ""stopping here on purpose"" = "stopping here on purpose"
    <tmp#5> @ "panic("stopping here on purpose")" = <uninit>
Entered crash debugger. 2 frame(s). Type `help` for commands.
Praxis crash> type values
Vec[Int]
Praxis crash> type total
Int
Praxis crash> type name
Text
Praxis crash> type values.sorted()
Vec[Int]
Praxis crash> quit

The frame’s locals block already names each binding’s type; type extends the same question to expressions the program never wrote. The frame name in the backtrace — inspect__Vec_Int__Int_Text — is the monomorphized clone the call site selected, which is the inferred signature written a third way.

What a type variable is, and how it prints

An unsolved type is a type variable: a slot in the arena that nothing has said anything about yet. Inference mints one whenever it needs a type it does not know — a fresh parameter, an empty collection’s element, the result of a call it has not resolved — and links it when the program constrains it.

A variable renders with a leading question mark: ?T, ?U, ?V. That is the honest spelling for “still open”, and it is the same one in a diagnostic, in hover, and in an inlay hint. In found (Text, Int) -> ?T above, ?T is the result add would have to produce; the call site never constrained it, because the argument disagreed first.

A variable a scheme quantifies prints without the question mark. fn greet(name) { "hi" } is forall T. (T) -> Text: nothing constrains name, so the type is generic in it. The two spellings are one distinction — a bound variable versus a free one — and which one you get is the subject of the generalization chapter. Only a scheme knows which of its variables it binds, which is why a type printed on its own shows every variable as ?.

Unification

Every rule above is one mechanism: unification. Two types are made equal, or the attempt is a diagnostic. Unifying a variable with a type links the slot; unifying two concrete types recurses into their parts; unifying two things that cannot be the same is Y001, whose expected half is always the requirement and whose found half is always what the program wrote.

Three of unification’s failures carry their own codes, because a generic expected/found would bury the mistake.

A call with the wrong number of arguments is Y024, not a whole-signature mismatch to diff by eye:

fn add(a, b) {
    a + b
}

out(add(1, 2, 3))
$ praxis check wrong-arity.px --color never
error[Y024]: this function takes 2 argument(s), but 3 were given

  wrong-arity.px:5:5
  5 | out(add(1, 2, 3))
    |     ^^^^^^^^^^^^ this function takes 2 argument(s), but 3 were given

praxis: 1 error(s)

A unification that would make a type contain itself is Y002. The occurs check is what stops inference from looping:

fn apply_to_self(f) {
    f(f)
}

out(1)
$ praxis check infinite-type.px --color never
error[Y002]: an infinite type would be required here

  infinite-type.px:2:5
  2 |     f(f)
    |     ^^^^ an infinite type would be required here

praxis: 1 error(s)

A type constructor written with the wrong number of arguments is Y007: Vec[Int, Text] reports at the annotation rather than interning quietly.

Nothing above forbids writing the type down. An annotation is checked by unification like everything else — it is a requirement, not a substitution, so writing one can only reject programs, never change what an accepted one means.

struct Point { x: Int, y: Int }

fn shift(p: Point, dx: Int, dy: Int) -> Point {
    Point { x: p.x + dx, y: p.y + dy }
}

var origin: Point = Point { x: 0, y: 0 }
var counts: Map[Text, Int] = Map()
var double: (Int) -> Int = |n: Int| n * 2
var pair: (Int, Text) = (1, "one")

counts.insert("moves", 2)
out(shift(origin, 3, 4))
out(counts.get("moves"))
out(double(21))
out(pair)
{ x: 3, y: 4 }
Some(2)
42
(1, one)

The positions that take one:

PositionSpellingOptional?
Bindingvar name: T = …yes
Function parameterfn f(a: T, b: U)yes
Function resultfn f(…) -> Tyes
Closure parameter|n: T| …yes
struct fieldstruct S { f: T }required
enum variant payloadenum E { V(T) }required

A type is a scalar name (Int, UInt, Byte, Float, Bool, Char, Text), a declared struct or enum name, a type constructor with its arguments (Vec, Deque, Map, Set, Counter, MinHeap, MaxHeap, Grid, Option, and the nullary Range and BitSet), a tuple (A, B), a function (A) -> B, or Unit. A parenthesized type is the type it groups, and () is Unit, so () -> Int takes no arguments.

There is no annotation syntax on a for variable or on a name a pattern binds — for x: Int in v is a parse error. Those are the two places the editor shows a hint it cannot offer to write into the file.

There is also no annotation form for a read: a parser expression’s shape is its type, which is type derivation.

What inference will not do for you

A name has one signature. Two functions cannot share a name, a call cannot select between arities, and no parameter has a default. Where another language would overload, Praxis spells the second shape as a second name: min and min_by, find and position, sorted and sorted_by_key.

Recursion is checked; mutual recursion generalizes early. A directly recursive function needs no annotation — fn fact(n) { if n <= 1 { 1 } else { n * fact(n - 1) } } comes out (Int) -> Int — because the name is bound to a placeholder before its body is checked and the placeholder is unified with the derived type after. A call to a function declared below unifies against the very placeholder that later declaration will resolve, so a disagreement is reported rather than skipped — reported wherever the conflict surfaces, which for a forward call is usually inside the callee’s body rather than at the call. A mutually recursive pair is checked in both directions, but the first of the two generalizes before the second has finished constraining it; annotate a mutually recursive pair if it misbehaves.

A receiver a method was called on is pinned. fn total(values) { values.sum() } is (Vec[Int]) -> Int once one call site says Vec[Int], not “any sequence of numbers”. That is deliberate, and it is explained in Generalization.

Nothing is inferred across files. A program is one file.


The rest of this part takes the four pieces in turn: Generalization is which variables get quantified and which do not; Method resolution is how .name() finds its row; Capabilities is the closed set of things the compiler decides about a type; and Records without names is what the input parser’s derived record types are and how they relate to a struct.

Generalization

Generalization is the step that turns a type with open variables into a schemeforall T. (T) -> T — so that each use of the name gets its own fresh copy. It is what lets one function serve two element types, and it is the one place where whether you write to a binding changes what its type means. What inference does is the background; this chapter is the rule.

fn swap(a, b) {
    (b, a)
}

fn twice(x) {
    [x, x]
}

var id = |x| x

out(id(1))
out(id("two"))
out(swap(1, "one"))
out(swap(true, 2.5))
out(twice(7))
out(twice("s"))
1
two
(one, 1)
(2.5, true)
[7, 7]
[s, s]

id is forall T. (T) -> T, swap is forall T U. (T, U) -> (U, T), and twice is forall T. (T) -> Vec[T]. Each call instantiates a fresh copy, so the Int use and the Text use never meet.

The rule: a binding something writes is not generalized

Praxis has one binding form, and no keyword marks a binding you may write apart from one you may not. What carries the distinction instead is a fact name resolution already knows: is this binding ever the target of an assignment?

  • A binding nothing writes is generalized, under the value restriction.
  • A binding something writes is not.

Which of the two a binding is, is inferred and never declared. Add one assignment and the same initializer stops being polymorphic:

var id = |x| x
id = |n| n + 1

out(id(1))
out(id("two"))
$ praxis check reassigned-binding.px --color never
error[Y001]: expected (Int) -> Int, found (Text) -> ?T

  reassigned-binding.px:5:5
  5 | out(id("two"))
    |     ^^^^^^^^^ expected (Int) -> Int, found (Text) -> ?T

praxis: 1 error(s)

The gate is a soundness requirement, not tidiness. Assignment instantiates the target’s scheme and unifies the copy, so writing to a generalized binding does not constrain it. Without the gate, var id = |x| x would generalize to forall T. (T) -> T, id = |n| n + 1 would leave it there, and id("two") would then type-check and hand a Text to a closure that adds one to it — a wrong-typed call reaching the backend, not a missing diagnostic.

fn declarations generalize too, after their bodies are checked, and the gate does not reach them: it is a fact read off a var statement, and a fn is not one. Every binding in the language is assignable, so writing to a fn name is accepted. It does nothing:

fn ident(x) {
    x
}

ident = |n| n + 100

out(ident(1))
out(ident("two"))
1
two

The call still runs the declaration, and the scheme is still generic. That the write is discarded rather than refused is a rough edge, not a rule to lean on.

Levels decide which variables are quantified

The textbook rule — “quantify every variable not free in the environment” — is wrong here, because inference is partial: a variable minted inside a function body may still be reachable from an outer binding that has not been inferred yet. Praxis uses Pottier and Rémy’s binding levels.

Every type variable records the level at which it was created. Entering a binding’s body raises a counter; leaving it restores it. Generalizing at a binding site quantifies exactly the unbound variables whose level is strictly deeper than that site. The correctness rule lives in unification: when a younger variable is linked to a type containing older ones, the older ones are lowered to the younger’s level, so an inner generalization cannot quantify something the enclosing scope still reaches.

You do not write levels and they never appear in a diagnostic. The observable consequence is the one above — polymorphism where the environment does not constrain a variable, and a monotype where it does.

A scheme owns its binders

A scheme carries its own binder list. Nothing in the arena records “this variable is quantified”, because that is a fact about a scheme, and only the scheme that quantified it knows.

That is what decides how a variable prints. Inside a scheme that binds it, a variable is T; where no scheme binds it — a bare type, a half-solved call, an element nothing pinned — it is ?T. The question mark means “free here”, not “broken”.

A parameter of a generic fn is on the first side of that line even though its own type is a monotype: c in fn foo(c) { c() } is () -> T, because foo is forall T. (() -> T) -> T and T is what that scheme calls the variable. Every surface that shows a binding’s type — hover, an inlay hint, completion, signature help — asks the same question, so a ? in any of them means the same thing in all of them.

The same fact has a user-visible edge: a generic fn has no single function value, so it cannot be passed as one.

fn ident(x) {
    x
}

var f = ident
out(f(1))
$ praxis check generic-function-value.px --color never
error[Y018]: `ident` is generic, so it has no single function value; write `|x| ident(x)` to fix its type arguments at the call

  generic-function-value.px:5:9
  5 | var f = ident
    |         ^^^^^ `ident` is generic, so it has no single function value; write `|x| ident(x)` to fix its type arguments at the call

praxis: 1 error(s)

A wrapping closure is one instantiation, which is a value. A monomorphic fn name needs no wrapper — it already denotes one function.

Shadowing

Every declaration that reuses a name is a new binding with a new symbol id, inferred independently. Its initializer resolves names in the environment that existed before it, so var x = x + 1 reads the old x and defines a new one, and the two may have completely unrelated types.

var value = read lines(int)
var value = value.sum()
var value = value > 10

out(value)

var label = "hello"
var label = label.len()
out(label)

Given

3
4
5

it prints

true
5

value is a Vec[Int], then an Int, then a Bool. Nothing is reassigned here, so each of the three is inferred and generalized on its own terms, and hovering each occurrence in the editor gives a different symbol and a different type.

Shadowing is also the only way to rebind a name at a new type. value = "text" after var value = 1 is a Y001; var value = "text" is a new binding.

An assignment’s target is decided by scope, not by spelling. In

var a = 1
a = 2
var a = "s"

the assignment writes the first a — which is therefore the one that is not generalized — and the third line introduces a second, unrelated binding.

Two things that deliberately do not generalize

A receiver a method was called on is pinned

fn total(values) {
    values.sum()
}

var counts = [1, 2]
var weights = [1.5, 2.5]

out(total(counts))
out(total(weights))
$ praxis check pinned-receiver.px --color never
error[Y001]: expected (Vec[Int]) -> ?T, found (Vec[Float]) -> ?T

  pinned-receiver.px:9:5
  9 | out(total(weights))
    |     ^^^^^^^^^^^^^^ expected (Vec[Int]) -> ?T, found (Vec[Float]) -> ?T

praxis: 1 error(s)

total’s parameter is Vec[Int] — a monotype — because the first call said so. This is not an oversight, and the reason is lowering rather than inference. There is one lowered body per source function, and monomorphization clones a body whose method calls have already been resolved. One call site therefore carries one catalog row and one receiver type; a quantified receiver would be N receiver types at one call site with nothing to lower.

If you need both, write two functions, or give the second one a closure to do the arithmetic.

An iterated parameter is generic in the iterable and not in its element

A for loop is the exception, and it splits the other way. The collection stays quantified — MIR picks the runtime accessors from the iterator’s constructor, so one clone per iterable kind is the only way the symbols can be right — while the item is pinned, for the same reason a method receiver is.

fn total(items) {
    var t = 0
    for i in items {
        t = t + i
    }
    t
}

var set = Set()
set.insert(4)

out(total([1, 2]))
out(total(0..5))
out(total(set))
3
10
4

One function, three iterable kinds, three clones. Disagree about the element instead and it is a mismatch, reported at the call that broke it with the for as a note:

fn show_all(items) {
    for i in items {
        out(i)
    }
}

show_all([1, 2])
show_all(["a", "b"])
$ praxis check iterated-element-is-pinned.px --color never
error[Y001]: expected Int, found Text

  iterated-element-is-pinned.px:8:1
  8 | show_all(["a", "b"])
    | ^^^^^^^^^^^^^^^^^^^^ expected Int, found Text

note: this is the operation that requires it

  iterated-element-is-pinned.px:2:14
  2 |     for i in items {
    |              ^^^^^

praxis: 1 error(s)

The report is at the call, because for i in items is correct for every other instantiation of show_all; the note says which operation imposed the requirement. That two-span shape is the general form for anything a scheme carried — see Capabilities.

A method on the item resolves exactly the same way, which is worth writing down because it is the combination the example above does not cover — it does arithmetic on the item rather than calling anything:

fn widths(rows) {
    for row in rows {
        out(row.len())
    }
}

widths([[1, 2, 3], [4, 5]])
3
2

…and a value derived from a pinned receiver is pinned too

The pin reaches further than the parameter. A subscript’s result, a method’s result and a for’s item are all pinned by the same rule, so a helper can subscript twice with no annotation anywhere:

fn pick(t, i, j) {
    t[i][j]
}

out(pick([[7, 8], [9, 10]], 0, 0))
7

pick is (Vec[Vec[Int]], Int, Int) -> Int, reconstructed from two subscripts and one call. And because the derived receiver is pinned, pick refuses a second element type for exactly the reason total does above — calling it on a Vec[Vec[Text]] in the same program is a Y001 at the second call, not a second clone.

Resolution runs in rounds to make that work. Resolving a deferred method produces the receiver’s result type, and that result is what the next link waits on, so the constraint channel keeps discharging until nothing is left to answer: one round resolves t[i], and the next resolves t[i][j] against the type the first one produced.

Method resolution

receiver.name(args) is a lookup in one table. The compiler owns a closed method catalog — a list of rows, each with a receiver pattern, a name, a parameter list and a result — and resolving a call means finding the row whose receiver pattern matches the receiver’s inferred type and whose name and arity match the call. There is no impl, no trait, no extension method, and no user-defined method: a record carries fields and nothing else. The rows themselves are the method catalog chapter; this one is how a call finds its row and what happens when it cannot.

The same table answers the language server’s completion and signature help, so what the editor offers on a receiver is exactly what will resolve.

The receiver’s type picks the row

One name can be many rows — the catalog has nine len rows and six get rows. Which one you get is decided by the receiver’s type and the argument count, before any argument’s type is looked at:

var text = "hello"
var v = [10, 20]
var m = Map()
m.insert("a", 1)

out(text.len())
out(v.len())
out(m.len())

out(text.get(1))
out(v.get(1))
out(m.get("a"))
out(m.get("z"))
5
2
1
e
20
Some(1)
None

Three of each are reached here, and the three gets do not even agree on a result type. Ask the compiler:

var text = "hello"

var v = [10, 20]

var m = Map()
m.insert("a", 1)

panic("stopping here on purpose")

Driven with type text.get(1), type v.get(0), type m.get("a"), type v.map(|n| n * 2), type m.keys(), quit:

error: program faulted: panic: stopping here on purpose

Backtrace:
#0   <entry>

  locals:
    text: Text = "hello"
    v: Vec[Int] = [10, 20]
    m: Map[Text, Int] = {"a": 1}
  temps:
    <tmp#1: Text> @ ""hello"" = "hello"
    <tmp#3: Vec[Int]> @ "[10, 20]" = [10, 20]
    <tmp#4: Int> @ "10" = 10
    <tmp#5: Unit> = Unit
    <tmp#6: Int> @ "20" = 20
    <tmp#7: Unit> = Unit
    <tmp#9: Map[Text, Int]> = {"a": 1}
    <tmp#11: Text> @ ""a"" = "a"
    <tmp#12: Int> @ "1" = 1
  …(3 more)
Entered crash debugger. 1 frame(s). Type `help` for commands.
Praxis crash> type text.get(1)
Char
Praxis crash> type v.get(0)
Int
Praxis crash> type m.get("a")
Option[Int]
Praxis crash> type v.map(|n| n * 2)
Vec[Int]
Praxis crash> type m.keys()
Vec[Text]
Praxis crash> quit

Arity is part of the key, so [1, 2].get() is not “wrong number of arguments” but “no such row” — Y110.

Once the row is found, its receiver pattern, its parameters and its result are instantiated from one shared name map and unified with what the call site holds. That is why two occurrences of T in a row are one type, and why an argument closure’s parameter is already pinned before its body is inferred: [[1, 2], [3]].map(|inner| inner.len()) knows inner is a Vec[Int] — so len on it resolves — because the receiver was bound first.

Iterable is a receiver shape, not a type

Most receiver patterns name a constructor: Vec[T], Map[K, V], Text. The sequence rows do not. Their receiver is written Iterable[T], which stands for ten receivers — the nine collections Vec, Deque, Set, MinHeap, MaxHeap, Range, BitSet, Map and Counter, plus Text, which walks its characters — bound to what each of them yields. No annotation can name it, because it is not a type.

It is also the one receiver pattern that is not unified with the call site’s type. Unifying Iterable[T] against a Vec would pin every other constructor out. What is unified instead is the row’s item, against the same answer for x in receiver would bind.

var v = [1, 2, 3]

var s = Set()
s.insert(5)

var m = Map()
m.insert("a", 1)

out(v.map(|n| n * 2))
out(s.map(|n| n * 2))
out((0..3).map(|n| n * 2))
out(m.map(|entry| entry.0))
[2, 4, 6]
[10]
[0, 2, 4]
[a]

One row, four receivers, and a Vec out of every one of them — a pipeline’s currency is Vec, whatever it started as.

The ten are not the for loop’s list. Grid is iterable and is deliberately not an Iterable receiver: a generic map row would claim the name and answer a flat Vec[U], throwing away the two dimensions that make a grid a grid. So a grid has no map at all.

var g = read grid(char)

out(g.map(|c| c))
$ praxis check grid-is-not-a-pipeline.px --color never
error[Y110]: no method `map` on type `Grid[Char]` taking 1 argument(s)

  grid-is-not-a-pipeline.px:3:7
  3 | out(g.map(|c| c))
    |       ^^^ no method `map` on type `Grid[Char]` taking 1 argument(s)

praxis: 1 error(s)

A grid enters a pipeline through grid.cells() or grid.positions(), which already answer Vecs.

Because the constraint is on the item, a row can require a shape there. Iterable[(K, V)].to_map() means “a Map or a Counter”, because those are the two whose item is a pair, and asking it of anything else is a type error at the method name rather than a missing method:

out([1, 2].to_map())
$ praxis check iterable-item-must-fit.px --color never
error[Y001]: expected (?T, ?U), found Int

  iterable-item-must-fit.px:1:12
  1 | out([1, 2].to_map())
    |            ^^^^^^ expected (?T, ?U), found Int

praxis: 1 error(s)

A receiver that is not known yet

A method call whose receiver is still a type variable cannot be looked up: a variable is not a shape the table can be keyed by. The call does not fail — inference records the requirement (this method, this arity, these arguments, this result) against the variable and resolves it later, when the program says what the receiver is.

fn top_three(rows) {
    rows.sorted().take(3)
}

out(top_three([5, 9, 1, 7]))
[1, 5, 7]

rows was never annotated. The call site pins it to Vec[Int], discharge looks sorted and take up against that, and the unification of the row’s result is what gives the whole chain its type.

Discharge pins the receiver to the declaration group’s level, so generalization cannot quantify it. That is why the same function cannot be called on a Vec[Int] and a Vec[Float] in one program — Generalization has the reasoning and the diagnostic.

The requirements a receiver’s own type carries reach through the same channel. fn remember(table, key) { table.insert(key, 1) } learns that table is a Map only at the call, and the key rule is applied there anyway:

fn remember(table, key) {
    table.insert(key, 1)
}

var seen = Map()
remember(seen, [3, 4])
$ praxis check requirement-through-a-parameter.px --color never
error[Y014]: a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

  requirement-through-a-parameter.px:2:11
  2 |     table.insert(key, 1)
    |           ^^^^^^ a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

help: use a value that cannot change — a number, `Text`, or a tuple of those

praxis: 1 error(s)

That rule is Capabilities.

When it cannot resolve: Y110

A call that cannot find a row is Y110, reported by inference at praxis check time — not at run, and not by lowering. There is one emitter and it has two wordings.

The receiver is known. The message names the type and the arity, and offers the nearest row this receiver actually has:

var v = [1, 2, 3]

out(v.lenn())
$ praxis check no-such-method.px --color never
error[Y110]: no method `lenn` on type `Vec[Int]` taking 0 argument(s)

  no-such-method.px:3:7
  3 | out(v.lenn())
    |       ^^^^ no method `lenn` on type `Vec[Int]` taking 0 argument(s)

help: did you mean `len`?
      len

praxis: 1 error(s)

The suggestion is drawn from the rows dispatch would have searched — this receiver’s, not the whole catalog’s — so v.lenght() is never offered a Map method.

No receiver has that name at all. Because the catalog is the complete method universe, a name it does not hold at that arity can never resolve against anything, so it is refused before the receiver is known:

fn describe(thing) {
    thing.frobnicate()
}

out(1)
$ praxis check no-method-anywhere.px --color never
error[Y110]: no type has a method `frobnicate` taking 0 argument(s)

  no-method-anywhere.px:2:11
  2 |     thing.frobnicate()
    |           ^^^^^^^^^^ no type has a method `frobnicate` taking 0 argument(s)

praxis: 1 error(s)

describe is never called and thing is never pinned. There is nothing to name in the sentence, so the wording drops the receiver half rather than printing ?T into a message that is supposed to be concrete.

The two wordings divide by whether the receiver is known, not by whether the call is reached. A name the catalog does hold stays deferred — fn total(values) { values.sum() } with no call site is clean, because sum exists on Vec[T] at arity 0 and a later call site may still answer it. The body of a function nothing calls compiles, at check and at run both; it is unreachable by construction, since any call — even one through a value, var g = total then g([1, 2]) — is what pins the receiver.

A receiver derived from a parameter resolves like the parameter

The receiver does not have to be the unannotated parameter. A subscript result, a method result and a for item are receivers in their own right, and each resolves one discharge round after the thing it came from: fn f(v) { v[0].len() } is (Vec[Vec[T]]) -> Int once a call site says what the rows hold.

Which means the report follows too. If the call site says the element is an Int, len is asked of an Int:

fn width(rows) {
    rows[0].len()
}

out(width([1, 2, 3]))
$ praxis check derived-receiver-no-row.px --color never
error[Y110]: no method `len` on type `Int` taking 0 argument(s)

  derived-receiver-no-row.px:2:13
  2 |     rows[0].len()
    |             ^^^ no method `len` on type `Int` taking 0 argument(s)

praxis: 1 error(s)

The caret is on len and the type named is the element, because that is the receiver the program actually built — rows is a perfectly good Vec[Int].

A subscript is a catalog row too, dispatched under the name [], but it has its own code and wording: s[0] on a Set is Y020, “values of type Set[Int] cannot be indexed with 1 index(es)”, rather than a missing method nobody wrote.

A call has parentheses; a bare dot is a field

v.len() is a method call. v.len is a field read, and it is only that. There is no property form, and none of the zero-argument accessors have one: grid.width(), grid.height(), v.len(), text.is_empty().

var v = [1, 2, 3]

out(v.len)
$ praxis check accessor-is-a-call.px --color never
error[Y112]: no field `len` on type `Vec[Int]`

  accessor-is-a-call.px:3:7
  3 | out(v.len)
    |       ^^^ no field `len` on type `Vec[Int]`

praxis: 1 error(s)

Three reasons the rule is worth having, in the order they bind.

The two lower differently. A field read carries a slot index taken from the record’s definition and becomes a load; a method call is a catalog row and a runtime call. Letting one syntax mean either would need a tie-break, and the only available one is “whichever the receiver happens to have” — so adding a field to a struct whose name matched a catalog row would silently change what an existing expression does.

A receiver whose type is not yet known could not tell them apart. A field read on an unresolved receiver rides the same deferral channel a method call does. Under a property form, fn f(v) { v.len } would emit a requirement with two possible discharges — a field of that name, or a nullary row of that name — and nothing at the read site could choose.

The catalog is the dispatch table. A property read would be a second dispatch surface over the same names, keyed differently, and the language server would have to render one row two ways.

The consequence is that a record field may be called len, and the two spellings stay apart:

struct Reading { len: Int, label: Text }

var r = Reading { len: 7, label: "north" }

out(r.len)
out(r.label)
out(r.label.len())
7
north
5

r.len reads the field. r.len() looks for a row on Reading, finds none — records carry no rows — and is Y110.

Capabilities

Praxis has no trait, no impl, no interface and no where clause. It also has values you can compare, values you can sort, values you can use as a Map key, and values you cannot — and something has to decide which is which. That something is a closed table inside the compiler: for equality, hashing, ordering, iteration and arithmetic, the language ships one answer per shape and no way for a program to add another.

The compiler calls these capabilities internally. You will never see the word. A diagnostic says what the program did and why it cannot work — “values of type Point cannot be ordered” — and never mentions a trait, a bound, or the name of the requirement. This chapter is what those requirements are and what they look like when one is not met.

Equality and hashing are structural

Tuples, records, enums and collections get their == and their hash from the compiler. Nothing is derived and nothing is written down: a composite is comparable when every component is, recursively, and hashable on exactly the same terms. Scalars and Unit are both; functions and closures are neither. [1, 2] == [1, 2] is true, and so is the same comparison between two separately built Maps with the same entries.

struct Point { x: Int, y: Int }
enum Move { Step(Int), Stop }

out((1, "a") == (1, "a"))
out((1, "a") == (1, "b"))
out(Point { x: 1, y: 2 } == Point { x: 1, y: 2 })
out(Step(3) == Step(3))
out(Step(3) == Stop)

var seen = Set()
seen.insert(Point { x: 1, y: 2 })
seen.insert(Point { x: 1, y: 2 })
seen.insert(Point { x: 3, y: 4 })
out(seen.len())
true
false
true
true
false
2

Equality compares contents, not identity: two separately built Points with the same fields are equal and hash alike, which is what makes the third insert above the only one that grows the set.

Put a function anywhere in the structure and the whole thing stops being comparable. The report names the component that failed, not the type you wrote:

struct Rule { name: Text, apply: (Int) -> Int }

var double = Rule { name: "double", apply: |n| n * 2 }
var triple = Rule { name: "triple", apply: |n| n * 3 }

out(double == triple)
$ praxis check compare-functions.px --color never
error[Y004]: values of type `(Int) -> Int` cannot be compared with `==`

  compare-functions.px:6:15
  6 | out(double == triple)
    |               ^^^^^^ values of type `(Int) -> Int` cannot be compared with `==`

praxis: 1 error(s)

Ordering is not structural

Equality recurses; ordering does not. The orderable types are exactly the scalars Int, UInt, Byte, Float, Char and Text. Bool and Unit have no defined order, and no composite has one: not a tuple, not a record, not an enum, not a collection.

That is a statement about the source language<, sorted(), a heap element. A container is a different question, and there the answer recurses: a Map, Set or Counter has to walk and print its keys in one reproducible sequence, so every type that can be a key has a container order, tuples and records included, computed element-wise. That order is over the value and not over its printing — a Set[Int] walks 2 before 10 — and Collections gives it in full. Having one does not make a type comparable with <; the example below stays exactly as it is.

struct Point { x: Int, y: Int }

var points = [Point { x: 3, y: 1 }, Point { x: 1, y: 2 }]

out(points.sorted())
$ praxis check order-a-record.px --color never
error[Y006]: values of type `Point` cannot be ordered

  order-a-record.px:5:12
  5 | out(points.sorted())
    |            ^^^^^^ values of type `Point` cannot be ordered

praxis: 1 error(s)

The answer is to say what to order by. sorted_by_key moves the requirement from the element to whatever the closure extracts, which is where it can be a scalar:

struct Point { x: Int, y: Int }

var points = [Point { x: 3, y: 1 }, Point { x: 1, y: 2 }]

out(points.sorted_by_key(|p| p.x))
out([3, 1, 2].sorted())
out(["pear", "apple"].sorted())
[{ x: 1, y: 2 }, { x: 3, y: 1 }]
[1, 2, 3]
[apple, pear]

min_by and max_by are the same move for the same reason. A MinHeap or MaxHeap element carries the requirement from the heap’s own type, because a heap orders it whether or not you ever call a comparison.

A key must be hashable and immutable

Hashing and equality are one question about a value’s representation — a descriptor’s hash and equals callbacks are written together — so anything comparable is hashable. That is exactly why “hashable” is the wrong requirement for a Map key.

A Vec hashes fine. What it cannot do is stay findable: key.push(2) after table.insert(key, v) moves the entry’s bucket without moving the entry, and nothing will ever look there again. So the rule is mutability, not container-ness.

  • Out, as a Map key, a Set element or a Counter key: Vec, Map, Set, Deque, Grid, Counter, MinHeap, MaxHeap, BitSet.
  • In, structurally: scalars, Text, tuples, records and enums — a tuple or a record is a key exactly when every component is.
  • In, and the one collection that is: Range. It has no mutator at all, so its two bounds are as fixed as a tuple’s elements.

That is Python’s rule (list, dict and set set __hash__ = None for this reason). Rust’s HashMap<Vec<i32>, V> is the counterexample that does not transfer: it is legal only because the borrow checker makes mutating a held key impossible, and Praxis has assignment and no borrow checker.

var seen = Set()
var path = [1, 2]

seen.insert(path)
$ praxis check mutable-key.px --color never
error[Y014]: a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

  mutable-key.px:4:6
  4 | seen.insert(path)
    |      ^^^^^^ a value of type `Vec[Int]` can change after it is stored, so it cannot be used as a key

help: use a value that cannot change — a number, `Text`, or a tuple of those

praxis: 1 error(s)

The message is the reason rather than the rule, and deliberately so: “not hashable” would be both jargon and a lie.

A tuple or a record of scalars is the everyday fix, and it is what a coordinate key wants anyway:

struct Point { x: Int, y: Int }

var seen = Set()
seen.insert((1, 2))
seen.insert((1, 2))
seen.insert((3, 4))

var visits = Map()
visits.insert(Point { x: 0, y: 0 }, 1)
visits.insert(Point { x: 0, y: 0 }, 2)

out(seen.len())
out(visits.get(Point { x: 0, y: 0 }))
2
Some(2)

The requirement is asked at the method call — the place a program actually puts a value into a collection — and after the arguments have unified, so m.insert(key, 1) has already decided what K is by then. var m = Map() mints two variables and the first insert is what says what they are.

The rule is about the type, not about what a particular value does next. A record’s fields are assignable, so writing one after it has been stored as a key loses the entry exactly the way pushing to a Vec would — the type check cannot see that, and Collections shows what it looks like. Prefer a tuple where the key is only a key.

A requirement rides the scheme that quantified it

The interesting case is a requirement discovered inside a generic function, about a variable that is then quantified. fn same(a, b) { a == b } needs a and b to be comparable — but at the point the body is checked, nothing has said what they are, and an unresolved variable is optimistically anything.

The requirement is not decided there and discarded. It is attached to the scheme that quantified the variable, and re-emitted at every instantiation against whatever that use site put in the variable’s place.

fn same(a, b) {
    a == b
}

fn double(n) {
    n * 2
}

out(same(1, 1))
out(same(double, double))
$ praxis check requirement-rides.px --color never
error[Y004]: values of type `(Int) -> Int` cannot be compared with `==`

  requirement-rides.px:10:5
  10 | out(same(double, double))
     |     ^^^^^^^^^^^^^^^^^^^^ values of type `(Int) -> Int` cannot be compared with `==`

note: this is the operation that requires it

  requirement-rides.px:2:10
  2 |     a == b
    |          ^

praxis: 1 error(s)

same(1, 1) is fine; same(double, double) is not; and the report is at the call, with the == as a note. Reporting at a == b alone would name code that is correct for every other instantiation of same. Reporting at the call alone would leave you asking why. Both spans, one diagnostic.

Iteration and arithmetic

The same machinery covers the other two closed questions.

Iterating something that is not one of the ten iterable collections — or Text, the one scalar with members — is Y005:

for x in 5 { out(x) }
$ praxis check not-iterable.px --color never
error[Y005]: values of type `Int` cannot be iterated

  not-iterable.px:1:1
  1 | for x in 5 { out(x) }
    | ^^^^^^^^^^^^^^^^^^^^^ values of type `Int` cannot be iterated

praxis: 1 error(s)

Arithmetic on something that has none is Y010 when the target’s type is already known:

var flag = true
flag += false
$ praxis check not-numeric.px --color never
error[Y010]: values of type `Bool` do not support this operation

  not-numeric.px:2:1
  2 | flag += false
    | ^^^^ values of type `Bool` do not support this operation

praxis: 1 error(s)

The numeric set is Int, UInt, Byte and Float — and % is narrower still, undefined for Float, which is a rule at one operator rather than a capability at all. Orderable and numeric are different sets: Text and Char are ordered and are not numbers, Bool is neither.

When the target’s type is not known at the operation, the requirement rides the scheme like any other and reports at the call, as Y015:

fn combine(a, b) {
    a += b
    a
}

out(combine(1, 2))
out(combine(true, false))
$ praxis check deferred-numeric.px --color never
error[Y015]: values of type `Bool` cannot be used in arithmetic

  deferred-numeric.px:7:5
  7 | out(combine(true, false))
    |     ^^^^^^^^^^^^^^^^^^^^ values of type `Bool` cannot be used in arithmetic

note: this is the operation that requires it

  deferred-numeric.px:2:5
  2 |     a += b
    |     ^

praxis: 1 error(s)

The two codes are the same rule at two moments: Y010 when the operation can name the type, Y015 when a later use pinned it. Pinning the target to Int at the operation instead would narrow every unannotated numeric parameter in the language, which is why the requirement waits.

The whole list

CodeWhenMessage shape
Y004== / != on a type with no equalityvalues of type `T` cannot be compared with `==`
Y005for over something not iterablevalues of type `T` cannot be iterated
Y006sorting, a heap, or < on an unordered typevalues of type `T` cannot be ordered
Y010arithmetic on a known non-numeric typevalues of type `T` do not support this operation
Y014a Map/Set/Counter key that can changea value of type `T` can change after it is stored, so it cannot be used as a key
Y015arithmetic discovered after a later use pinned the typevalues of type `T` cannot be used in arithmetic

Each of them names a concrete type and a concrete operation. None of them names the requirement, because the requirement has no name a program could write.

The remaining requirements the same channel carries are not yes/no questions at all — they produce something when they hold. “This receiver has this method” resolves to a catalog row, “this receiver is iterable” resolves to an item type, and “this receiver has this field” resolves to a field type. Those are method resolution and the for half of generalization.

Records without names

There are two kinds of record type in Praxis. A struct declaration makes a nominal one: Point is Point because it is that declaration, and a second declaration with identical fields is a different type. The other kind is anonymous: { x: Int, y: Int } is that field set and nothing else, and any two of them with the same fields are the same type.

Two things produce an anonymous record, and they produce the same one. You write a literal with no name in front of it — { x: 1, y: 2 }, whose type is the fields it just listed. Or a named capture in a parser expression derives one with no literal at all:

var rows = read lines(`{x:int},{y:int}`)

for r in rows {
    out(r.x * r.y)
}
out(rows)

Given

1,2
3,4
5,6

it prints

2
12
30
[{ x: 1, y: 2 }, { x: 3, y: 4 }, { x: 5, y: 6 }]

rows is a Vec[{ x: Int, y: Int }], and it is that type before the program runs. How a parser expression arrives at it is type derivation.

Writing one

A record literal with no name in front of it builds an anonymous record. Nothing is declared first, because there is nothing to declare: the fields are the type.

var p = { x: 1, y: 2 }
out(p.x + p.y)

p.x = 9
out(p)

var name = "origin"
var tagged = { name, pos: p }
out(tagged)
out(tagged.pos.y)
3
{ x: 9, y: 2 }
{ name: origin, pos: { x: 9, y: 2 } }
2

Fields are read and assigned like any other record’s, they nest, and { name } puns — it takes the field from the binding of that name, exactly as the headed form does. The parser and the literal build the same type, so a helper written for rows a template produced takes one you wrote by hand:

fn area(r) { r.w * r.h }

var rooms = read lines(`{w:int}x{h:int}`)
var default_room = { w: 2, h: 5 }

for room in rooms {
    out(area(room))
}
out(area(default_room))

Given

3x4
10x10

it prints

12
100
10

The one brace that stays a block

A { where an expression must begin could open either a block or a record, and the tie is broken by what a block cannot be: a name followed by a :, or a name followed by a ,. So { x: 1 }, { x: 1, y: 2 } and { x, y } are records, and everything else at that position is the block it always was.

{ x } is the one case the rule cannot have both ways. It is a well-formed block whose value is x and a well-formed one-field punned record, and blocks had the spelling first:

var x = 7

// A block, whose value is its last statement.
var from_block = { x }
out(from_block)

// A record with one field, which needs the field written out.
var from_record = { x: x }
out(from_record)
7
{ x: 7 }

The other one is { x:bp }, which is a block holding the statement x with a :bp marker on it. { x: bp } with a space is the record whose field is the binding bp — the same adjacency that separates min= from min =.

A keyword head does not suppress the literal, and does not need to. What the record-literal suppression protects is p { … }, a name followed by the brace that could be the if’s own block; a brace where an operand is still required cannot be that block, because the block comes after a complete condition. So if { hit: true }.hit { … } reads both braces the way you would expect.

Same fields, same type

Two anonymous records are the same type when their field-name sets match and their field types unify. Identity is established by unification rather than by a lookup at construction, which is what makes the field types get checked rather than assumed.

The practical effect is that a helper written for one parser’s rows works for another’s, with no declaration in between:

fn area(r) {
    r.w * r.h
}

var rooms = read lines(`{w:int}x{h:int}`)
var default_room = parse("2x5", `{w:int}x{h:int}`)

for room in rooms {
    out(area(room))
}
out(area(default_room))

Given

3x4
10x10

it prints

12
100
10

rooms’s element type and default_room’s type were synthesized by two separate walks over two separate parser expressions, and they are one type.

Disagree about a field name and they are not:

fn area(r) {
    r.w * r.h
}

var rooms = parse("3x4", `{w:int}x{h:int}`)
var boxes = parse("3x4", `{w:int}x{d:int}`)

out(area(rooms))
out(area(boxes))
$ praxis check different-fields.px --color never
error[Y001]: expected ({ w: Int, h: Int }) -> Int, found ({ w: Int, d: Int }) -> ?T

  different-fields.px:9:5
  9 | out(area(boxes))
    |     ^^^^^^^^^^^ expected ({ w: Int, h: Int }) -> Int, found ({ w: Int, d: Int }) -> ?T

praxis: 1 error(s)

The expected half is area’s inferred signature — the first call pinned its parameter, because a field read pins its receiver the same way a method call pins its own (see Generalization).

An anonymous record is an ordinary record

It has fields you read and assign, it matches a record pattern, it compares structurally, and it can be a Map key or a Set element. The only thing it lacks is a name.

var p = read `{x:int},{y:int}`

match p {
    { x, y } => out(x + y)
}

p.x = 9
out(p)

var seen = Set()
seen.insert(p)
seen.insert(read `{x:int},{y:int}`)
out(seen.len())

Given

1,2

it prints

3
{ x: 9, y: 2 }
2

Note the last read: every read parses the whole input from its start, so the second one produces a fresh { x: 1, y: 2 } — a different value of the same type, which is why the set holds two.

Field order does not decide the type, and the type decides field order

{ w: Int, h: Int } and { h: Int, w: Int } are one type. Unification matches fields by name, not by position.

That has a second half worth stating outright, because it is the one people do not expect: one type has one field order, and it is the order the shape was first written in anywhere in the program. Every value of it is laid out and printed that way, whichever spelling built it.

fn width_of(r) { r.w }

var a = parse("3x4", `{w:int}x{h:int}`)
var b = parse("4x3", `{h:int}x{w:int}`)

out(a)
out(b)
out(width_of(a))
out(width_of(b))
{ w: 3, h: 4 }
{ w: 3, h: 4 }
3
3

b’s template writes h first and b prints w first, because a got there first and fixed the shape’s order. The values are still what the input said: b read 4 as its h and 3 as its w, so width_of(b) is 3.

The order has to come from something that has seen every spelling, and within one compile that is the type arena, which registered a definition for each. It cannot be a property of the template or literal doing the building, because the whole point of the rule above is that those all produce one type — and a field read compiles to a slot index against that one type. A value laid out in its own producer’s order would put w in h’s slot for whichever spelling was not canonical, and the read would answer the wrong field with nothing to report.

All this costs is that display order depends on where the first spelling appears in the file. A program with a single spelling of each shape — which is nearly all of them — cannot tell.

Nominal identity is a definition applied to arguments

A struct or enum type is a definition plus its type arguments, and its identity is the definition — not its name and not its shape. Two declarations with identical fields are two definitions and therefore two types:

struct Point { x: Int, y: Int }
struct Velocity { x: Int, y: Int }

fn magnitude(p) {
    abs(p.x) + abs(p.y)
}

out(magnitude(Point { x: 1, y: 2 }))
out(magnitude(Velocity { x: 3, y: 4 }))
$ praxis check two-structs.px --color never
error[Y001]: expected (Point) -> Int, found (Velocity) -> ?T

  two-structs.px:9:5
  9 | out(magnitude(Velocity { x: 3, y: 4 }))
    |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected (Point) -> Int, found (Velocity) -> ?T

praxis: 1 error(s)

The same rule separates a declared record from a derived one, even when the fields line up exactly:

struct Room { w: Int, h: Int }

fn area(r) {
    r.w * r.h
}

var parsed = parse("3x4", `{w:int}x{h:int}`)

out(area(parsed))
out(area(Room { w: 2, h: 5 }))
$ praxis check nominal-is-not-structural.px --color never
error[Y001]: expected ({ w: Int, h: Int }) -> Int, found (Room) -> ?T

  nominal-is-not-structural.px:10:5
  10 | out(area(Room { w: 2, h: 5 }))
     |     ^^^^^^^^^^^^^^^^^^^^^^^^^ expected ({ w: Int, h: Int }) -> Int, found (Room) -> ?T

praxis: 1 error(s)

There is no conversion and no coercion between the two. A parser derives an anonymous record; a struct literal builds a nominal one; nothing turns one into the other except writing the fields out.

Option is the one generic definition in the language: Option[Int] and Option[Text] are one definition at two arguments, which is why they print their argument and why the monomorphizer can tell them apart. There is no struct P[T] syntax — a user definition has no parameters, so substitution is free and identity is just the definition.

When to declare a struct instead

Use an anonymous record — derived or written — when the shape appears once and is used near where it was made. That is most of a puzzle solution, and declaring a struct to hold what lines(`{x:int},{y:int}`) already produces buys nothing.

Declare a struct when you want one of these two things:

A name in every diagnostic. expected (Point) -> Int reads better than expected ({ x: Int, y: Int }) -> Int, and the difference grows with the field count.

Two shapes kept apart. This is the real one, and it is the only thing a literal cannot do for you. Two anonymous records with the same fields are the same type, so nothing stops a { x: Int, y: Int } meant as a position being passed where one meant as a velocity is expected. Two structs make that a compile error, as above.

Carrying a field the parser did not produce is not a third reason: an anonymous record literal holds whatever fields you write into it.

Converting to a declared one is a loop and a literal:

struct Segment { x1: Int, y1: Int, x2: Int, y2: Int }

var rows = read lines(`{x1:int},{y1:int} -> {x2:int},{y2:int}`)

var segments = Vec()
for r in rows {
    segments.push(Segment { x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2 })
}

for s in segments {
    out(s)
}

Given

0,9 -> 5,9
8,0 -> 0,8

it prints

{ x1: 0, y1: 9, x2: 5, y2: 9 }
{ x1: 8, y1: 0, x2: 0, y2: 8 }

Note that a nominal record prints as its fields too — out shows { x1: 0, … }, with no Segment in front of it. The name is for the type system and the diagnostics, not for the output. Records covers the declaration form in full.

Reading a type error

praxis check runs the whole front end — lex, parse, name resolution, inference — and prints everything it found, sorted by position. A type error tells you three things: a code, the two types unification was trying to make equal, and the expression it blames. Most of the work of reading one is knowing which of those two types came from where, because it is often not the line under the carets.

The shape of a diagnostic

fn checksum(rows: Vec[Int]) -> Int {
    out(rows.len())
}

out(checksum([3, 1, 2]))
error[Y001]: expected Int, found Unit

  unit-body.px:2:5
  2 |     out(rows.len())
    |     ^^^^^^^^^^^^^^^ expected Int, found Unit

help: this value is `Unit`; the function body expected `Int` — make the last expression produce a value, or change the declared type to `Unit`

praxis: 1 error(s)

Five parts, in order:

  • error[Y001] — the severity and the code. The letter is the category: T lex, P parse, N name resolution, Y type, I input parser. (R, runtime, is a declared category with no members.) A code is a permanent identifier and is never reissued, even when the report behind it is retired; Diagnostic codes is the list.
  • The message. It appears twice — in the header, and again as the label after the carets — so a diagnostic reads the same whether you are looking at the top of it or at the line.
  • file:line:col. Both numbers count from one.
  • The carets, under the primary span. A span crossing several lines is underlined on each of them; a long one shows its first three lines and its last, with an ellipsis between.
  • help:, when there is a concrete suggestion. An advisory one is a sentence. A machine-applicable one prints its replacement text on an indented line underneath, and that is what the editor offers as a quick fix.

A note: block, when a diagnostic has one, sits between the snippet and the help: and carries a second span with its own snippet. It is how a report says “the mistake is here, and the requirement it broke was written over there” — see capabilities, below.

The trailer counts errors, and the exit code follows it: 1 if there were any, 0 if not.

expected and found are positions, not judgements

Unification is symmetric — it makes two types equal and neither is privileged. The message is not symmetric, and the rule is mechanical: the type the context already required is printed first, and the type the expression just brought is printed second. An annotation comes before its initializer, a parameter before its argument, a comparison’s left operand before its right.

Arithmetic does not go by position at all. + - * / % settle on one target type for the whole operation — Text if either operand is a Text, Float if either is a float, Int otherwise — and check both operands against that. The type printed first is the operator’s, and the blame falls on whichever operand disagrees with it, on whichever side that operand stands:

var value = "12"
out(1 + value)
error[Y001]: expected Text, found Int

  operand-order.px:2:5
  2 | out(1 + value)
    |     ^ expected Text, found Int

praxis: 1 error(s)

The Text came from value, which is what made this a Text addition. The Int is the literal, and the carets are under it even though it is on the left.

So expected does not mean “what you wanted”. It means “what inference had already decided by the time it got here”, and when that decision is the wrong one, the error lands downstream of it.

var value = "12"

if value.len() == 2 {
    var value = 12
    out(value + 1)
}

out(value + 1)
error[Y001]: expected Text, found Int

  shadowed.px:8:13
  8 | out(value + 1)
    |             ^ expected Text, found Int

praxis: 1 error(s)

The blame is on 1, an integer literal that is not wrong about anything. The Text in the message comes from line 1. Line 4 declares a second binding called value, and it goes out of scope with the if — so the last line is about the first value, which never stopped being a Text.

Shadowing is legal and deliberate: a var may redeclare a name in the same scope or in an inner one, and the two are different bindings with different types. That is what you want when you are narrowing a value step by step, and a trap when you did not mean it. The editor tells them apart — hover over each value above and you get value: Text and value: Int, because a name’s identity is its symbol and not its spelling. See Inference in the editor.

A wrong argument blames the whole call

fn double(n: Int) -> Int {
    n * 2
}

var raw = "21"
out(double(raw))
error[Y001]: expected (Int) -> Int, found (Text) -> ?T

  wrong-argument.px:6:5
  6 | out(double(raw))
    |     ^^^^^^^^^^^ expected (Int) -> Int, found (Text) -> ?T

praxis: 1 error(s)

This shape surprises people, so it is worth knowing why it happens. A call to a named function does not check its arguments one at a time. It builds the function type the call site implies — (the argument types) -> ?result — and unifies the callee against it in one step. The span is therefore the whole call, and the two types in the message are two whole function types.

Read it by diffing them left to right. (Int) -> Int against (Text) -> ?T: the first parameter is where they part, so the first argument is the one to look at. ?T is not a mistake in your program — it is the fresh variable standing for the call’s result, which nothing pinned because unification stopped before reaching it. Every ? in a rendered type means the same thing: a variable inference has not resolved.

A method call is dispatched through the catalog instead, which unifies the parameters one by one, so a method’s wrong argument is blamed on the argument itself. element-pinned.px, below, is that shape.

The wrong number of arguments: Y024

var total = 41 + 1
assert(total == 42, "the total is wrong")
out(total)
error[Y024]: this function takes 1 argument(s), but 2 were given

  wrong-arity.px:2:1
  2 | assert(total == 42, "the total is wrong")
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this function takes 1 argument(s), but 2 were given

praxis: 1 error(s)

A name in Praxis has exactly one signature. There is no arity-based overloading, no optional parameters and no default arguments, so a count mismatch is never a near miss for another version of the name — it is arithmetic, and Y024 says so rather than showing two function types to compare by eye.

The same rule settles the case above. assert takes a condition and nothing else; the name that carries a sentence is panic. A failed assert already prints the condition’s own source text, its evaluated value and every local in the frame, which is more than a hand-written message would have said.

Y024 is raised inside unification rather than at the call site, so calling a closure value with the wrong number of arguments reports it too, not just a call to a named fn. It fires when the two function types being matched are the ones that differ in length; a wrong-arity closure passed as an argument is a mismatch one level down, and comes back as the whole-type Y001 above.

A method that does not exist: Y110

var counts = Map[Text, Int]()
counts.insert("ada", 1)
out(counts.contain("ada"))
error[Y110]: no method `contain` on type `Map[Text, Int]` taking 1 argument(s)

  no-such-method.px:3:12
  3 | out(counts.contain("ada"))
    |            ^^^^^^^ no method `contain` on type `Map[Text, Int]` taking 1 argument(s)

help: did you mean `contains`?
      contains

praxis: 1 error(s)

The message names the receiver’s type and the arity, because both are part of what selects a method: v.get(0) and v.get() are different questions about one name. The help: is a machine-applicable fix — the label is the sentence, the indented line under it is the replacement — and it is offered only when what you wrote is within an edit distance of max(1, len / 3) of a real name. Two short names that differ everywhere are not neighbours, so no suggestion appears.

When nothing has pinned the receiver there is no type to name, and the message says the other true thing instead:

fn shout(word) {
    word.upper()
}

out("nothing calls shout")
error[Y110]: no type has a method `upper` taking 0 argument(s)

  method-nowhere.px:2:10
  2 |     word.upper()
    |          ^^^^^ no type has a method `upper` taking 0 argument(s)

praxis: 1 error(s)

Nothing calls shout, so nothing says what word is — and the call is refused anyway, because the method catalog is the complete method universe of this language. There is no user-defined impl, a record carries no methods, and so a name the catalog holds at no arity can never resolve against any receiver. Waiting for a call site would be waiting forever.

The rule is narrow on purpose. It asks “does any row hold this name at this arity”, not “does any row match this receiver”, so fn total(values) { values.sum() } still checks with no call site: sum exists, the requirement is deferred, and a caller answers it later. Only a name that exists nowhere is refused early, and inference is what refuses it. Y110 has one emitter, and it is not lowering’s, so praxis check sees every missing method without compiling anything.

Method resolution covers how a receiver picks a row.

A capability the type does not have

Some requirements are not “this type equals that type” but “this type can do this”. They are recorded where the operation is written and discharged when something finally says what the type is — so the two ends can be a long way apart, and the diagnostic shows both.

fn largest(values) {
    var best = 0
    for v in values {
        if v > best {
            best = v
        }
    }
    best
}

out(largest(42))
error[Y005]: values of type `Int` cannot be iterated

  not-iterable.px:11:5
  11 | out(largest(42))
     |     ^^^^^^^^^^^ values of type `Int` cannot be iterated

note: this is the operation that requires it

  not-iterable.px:3:14
  3 |     for v in values {
    |              ^^^^^^

praxis: 1 error(s)

The primary span is the call, because the call is what supplied the offending type. The note: is the for that wanted an iterable in the first place. Read the two as one sentence: this argument cannot be iterated, and here is the loop that needs to iterate it.

The wording never names the mechanism. There is no “Int does not implement Iterable” in this compiler; a message says what the program did and what the type cannot do. The family:

codewhen
Y004compared with ==, and its values cannot be compared
Y005iterated, and it is not iterable
Y006ordered — sorted, heaped, < — and it has no ordering
Y014used as a Map key or Set element, and it can change after it is stored
Y015used in arithmetic, and it is not numeric
Y016given an operator the language does not define for it

Y016 is not a mismatch: both operands agree and the operation still has no meaning. Capabilities is the chapter on what each one requires.

A value used at two types

var widths = []
widths.push(3)
widths.push("4")
out(widths.len())
error[Y001]: expected Int, found Text

  element-pinned.px:3:13
  3 | widths.push("4")
    |             ^^^ expected Int, found Text

praxis: 1 error(s)

[] mints a Vec whose element type is a variable. Line 2 pins that variable to Int — a variable resolves once — and line 3 is where the consequence is noticed. The blame is on the second use and the decision was made at the first, which is the general shape of this error: when the type in expected is not one you wrote down anywhere, look for the earlier line that implied it.

A fn is different. It can be used at several types, because its scheme is generalized at the declaration and instantiated fresh at every call. Generalization is the chapter on where that line falls.

Text and numbers

The most common mismatch in a puzzle program is a Text where an Int was wanted, and it carries a help::

var raw = "12"
var count: Int = raw
out(count)
error[Y001]: expected Int, found Text

  text-to-number.px:2:18
  2 | var count: Int = raw
    |                  ^^^ expected Int, found Text

help: this is `Text`; `.int()` answers `Option[Int]`, so take it apart with `match` (or use `read lines(int)`)

praxis: 1 error(s)

Both halves of that help are real, and they answer different questions.

Text.int() reads the number a text spells, and Text.float() is its twin. Both answer an Option rather than the scalar, because a text that is not a number is absence and not a fault — input is routinely not what a program hoped, and a conversion that crashed would give you no way to ask first.

var raw = "12"
var count = match raw.int() { Some(n) => n, None => 0 }
out(count + 1)

// Whitespace is trimmed; anything that is not a number is `None`.
out(" 42 ".int())
out("abc".int())
out("1.5".float())
13
Some(42)
None
Some(1.5)

What counts as a number is the input parser’s answer, not a second one: the two methods run the same scanner the int and float atoms do, over the whole trimmed text. So "1 2", "12abc", "0x10" and a value past Int’s range are None — and so are "+5".int() and "inf".float(), which surprise people until you know where the rule comes from.

The other half is the input parser, and it is the one to reach for when the text came from input in the first place: read lines(int) never produces the value at all if the line is not a number, and reports where it broke.

var raw = "12"
var count = parse(raw, int)
out(count + 1)
13

A name that is not defined

Not a type error, but the one you will hit beside them, and the clearest example of a machine-applicable fix:

var total = 0

for n in [1, 2, 3] {
    totl += n
}

out(total)
error[N001]: `totl` is not defined

  misspelled.px:4:5
  4 |     totl += n
    |     ^^^^ `totl` is not defined

help: did you mean `total`?
      total

praxis: 1 error(s)

N0xx is the name-resolution category: a name that is not in scope, a name in type position that names a value, a second fn of a name already declared (a var may redeclare, a fn may not), a fn body reaching for a binding declared outside it. They are mistakes about what was declared, which is why they are not Y0xx — there is no pair of types that failed to unify.

check, run and the editor cannot disagree

Every diagnostic in this chapter is produced by praxis check, and praxis run prints exactly the same text before declining to run the program. Both exit 1.

That is not discipline, it is construction. The set of diagnostics, their order, and the decision to analyze a file even when parsing has already complained are stated once, in the query layer that both commands call. The editor calls the same query, so the squiggle under your cursor carries the same code, the same message and the same span the terminal would print. That is the subject of the next chapter.

Inference in the editor

Praxis programs are mostly unannotated, which means the types are real but invisible. The language server’s job here is to put them back on the screen: an inlay hint beside every binding whose type the source does not state, a hover that answers what any expression is, and an edit that writes a hint into the file when you want it permanent.

praxis lsp is the server; it speaks LSP over stdio and is not meant to be run by hand. Wiring it into an editor, and everything the extension does that is not about types — semantic tokens, rename, code actions, completion, signature help — is Editor support. This chapter is about inference.

What the hints say

fn area(w, h) {
    w * h
}

var side = 4
out(area(side, side + 1))

Three bindings, no annotations, and the server answers with three hints:

wherelabelwrites an edit
after w, line 1: Intyes
after h, line 1: Intyes
after side, line 5: Intyes

So fn area(w, h) reads on screen as fn area(w: Int, h: Int), and var side reads as var side: Int. A hint sits at the end of the name, which is where the annotation would go.

The rule is one rule: every binding whose type the source does not already state. A fn parameter, a closure parameter, a var, a for variable, and a name a pattern introduces are all the same thing — a name bound to a value — and they are all read off the same table inference filled. A binding you annotated yourself gets no hint, because that would be the editor reading the source back to you.

One thing that is not a binding is hinted as well: a read or parse whose result nothing binds. out(parse("1", int)) gets a : Int after the whole expression, because there is no name to hang it on. When there is one, the binding’s hint already says it and the second is suppressed.

var pairs = [(1, "a"), (2, "b")]
for (n, label) in pairs {
    out(label)
}
var f = |q| q + 1
out(f(1))
a
b
2

Five hints on that file: pairs: Vec[(Int, Text)], then n: Int and label: Text from the tuple pattern in the for header, then f: (Int) -> Int and q: Int. The destructured names are hinted individually because each is a binding in its own right.

A variable is shown, never hidden

fn pair(item) {
    [item, item]
}

out("pair is declared but never called")
pair is declared but never called

Nothing calls pair, so nothing says what item is — and that is not a gap in the answer. pair generalizes to forall T. (T) -> Vec[T], so the hint on item is : T: the name pair’s own scheme gives the variable. Hover over pair and you see the same T, because it is the same variable.

?T is the other case, and the question mark is the whole difference:

var v = Vec()
out(v.len())
0

v is Vec[?T]. The binding is expansive, so the value restriction does not generalize it (Generalization), and nothing in the program pins the element — so no scheme quantifies that variable and none is going to. ? says exactly that, in a hint as in hover as in praxis check’s own output, where the previous chapter’s found (Text) -> ?T is the same spelling.

Neither is hidden. Hiding one would make “no hint” mean two different things: a type the source already states, and a type nothing named. Those are precisely the two cases worth telling apart.

Accepting a hint

A hint carries a text edit that inserts its own label at its own position, so accepting it writes the annotation into the file. Accept all three from the first example and you get exactly this, which behaves identically:

fn area(w: Int, h: Int) {
    w * h
}

var side: Int = 4
out(area(side, side + 1))
20

The three hints are gone from that version: the file states its own types now, and repeating them back would be noise.

The edit is offered only where the annotation would be both legal and spellable.

  • Legal: on a fn or closure parameter, or a var. A for variable has no annotation syntax, so its hint shows and cannot be accepted — the n and label above are in that state.
  • Spellable: the rendered type has to be one the parser reads back. ?T is not, and neither is the T of a scheme — the language has no syntax for writing a type variable, so pair’s item: T above shows with no edit. Neither is an anonymous record. Neither is a function type, whose spelling this module deliberately does not guess — which is why f: (Int) -> Int above shows with no edit while q: Int beside it has one.
var points = read lines(`{x:int},{y:int}`)
var first = points[0]
out(points.len() + first.x + first.y)

On the two-line input 1,2 / 3,4:

5

Neither hint on that file can be accepted, for the second reason: points: Vec[{ x: Int, y: Int }] and first: { x: Int, y: Int } name anonymous records, which the language has no annotation syntax for. Showing a hint that cannot be applied is better than offering an edit that would not compile.

A language-server test — applying_a_hints_edit_keeps_the_file_clean — applies every edit a file’s hints carry and asserts the result still checks with no diagnostics. It is the only thing that would catch an annotation the grammar refuses.

Hover

Hover answers with the type, rendered by the same function praxis check prints through. A second renderer here would be a second opinion about what Vec[{ x: Int }] is called.

A hover answer is Markdown, and the type is inside a fenced praxis block so the editor colours it. On points in the file above the server sends:

```praxis
points: Vec[{ x: Int, y: Int }]
```

On the len of points.len() it sends the catalog row itself — receiver, name, parameters, result — and the row’s own documentation:

```praxis
Vec[{ x: Int, y: Int }].len() -> Int
```

Number of elements in the vector.

That sentence is not written in the language server. It is the catalog entry’s doc field, taken from the entry method resolution actually selected, so the row that runs and the sentence you read are the same row.

A prelude name keeps its scheme and gains the prelude’s own sentence under it, and a name in type position — the Int in var n: Int, the Vec in Vec[Text] — answers with what the type is. Neither sentence is written in the language server either; both come from the same crates/praxis-stdlib/src/prelude.rs table name resolution seeds the root scope from.

```praxis
abs: (Int) -> Int
```

Absolute value of an `Int`. Faults on `Int`'s minimum, which has no positive counterpart. `Float` has its own `x.abs()`.

The preference order is innermost-wins: a parser expression, then a method name, then a name reference, then a declaration site, then a name in type position, then the innermost expression node with a recorded type. The last of those is why hover works on things that are not names at all — a list literal, a subexpression, a call.

Hover inside a read

var groups = read sections(lines(`{a:int},{b:int}`))
out(groups.len())

On an input of two blank-line-separated groups:

2

An input parser is a tree of constructors, and each node has a type of its own. Hovering sections gives the constructor’s signature, its documentation, and the whole expression’s result:

```praxis
sections(parser) -> Vec[T]
```

Split the region on blank lines and apply the parser to each section. With named arguments, parses fixed sections in order into a record.

---

```praxis
Vec[Vec[{ a: Int, b: Int }]]
```

*input parser result*

Hovering the lines inside it gives that node’s type, not the root’s, and the label under it says which of the two you are looking at:

```praxis
lines(parser) -> Vec[T]
```

Split the region into lines and apply the parser to each. Every line must be consumed whole.

---

```praxis
Vec[{ a: Int, b: Int }]
```

*parser expression*

This works because inference keeps the parser AST it built, along with the synthesized type of every node in it, keyed by span. The alternative is a second scanner over template interiors living in the language server, free to disagree with the compiler about where a capture ends. The index means “which parser node is the cursor in” is a lookup against spans the compiler computed, so it cannot disagree.

Two bindings with one name

var value = "12"

if value.len() == 2 {
    var value = 12
    out(value + 1)
}

out(value + 1)

Hover over the value on line 1 and you get value: Text. Hover over the one on line 4, or its use on line 5, and you get value: Int. Line 8 is value: Text again — it is the outer binding, which the inner one shadowed only for the length of the if.

Hints agree: two of them on this file, : Text on line 1 and : Int on line 4, because those are the two declarations. This is worth knowing because the shadowing error in the previous chapter is exactly the case where hovering the name is faster than reading upwards for it. A name’s identity in this compiler is its symbol, never its spelling, and every editor feature keyed on identity — hover, rename, find-references, inlay hints — reads that symbol.

The editor and praxis check cannot disagree

That file reports one error at the terminal:

error[Y001]: expected Text, found Int

  shadowed.px:8:13
  8 | out(value + 1)
    |             ^ expected Text, found Int

praxis: 1 error(s)

Open it in an editor and the server publishes one diagnostic: code Y001, message expected Text, found Int, severity error, source praxis, over the range that starts at line 8 column 13 and ends one character later. The same code, the same message, the same span.

This is structural, not a coincidence that holds today. The front-end query layer lives in the praxis-lsp crate and praxis check calls it: the CLI builds a snapshot of the file and asks it for diagnostics, and the server’s publish path does the same thing to the same snapshot type. Which diagnostics exist, what order they come in, and whether a file whose parse already failed still gets analyzed are decided in one place. A divergence is not unlikely; it is unrepresentable.

Two consequences worth knowing:

  • A file with a syntax error still gets its type errors. Parse recovery keeps the tree usable, and the editor going blank on one stray character is worse than a slightly confused analysis.
  • Nothing is executed to produce them. The language server’s manifest does not depend on the MIR, code generation or runtime crates at all, and a test reads the manifest and asserts it — so “diagnostics without running your program” holds by construction rather than by observation.

What is memoized

A snapshot is one file at one revision, and it runs the parse once and inference once no matter how many questions you ask it. Hover, hints, diagnostics and go-to-definition on an unedited file all read the same analysis. An edit builds a new snapshot and drops the old one — with its tree, its types and its source map together — which is what keeps an editor session that has been open for an hour from holding an hour of keystrokes.

The fault model

Praxis has no exceptions, no try, and no error-carrying return type. An operation that cannot produce an answer — an add that overflows, an index past the end, a key that is not in the map — raises a fault. A fault stops the program where it happened. Nothing catches it, nothing recovers from it, and there is no syntax that would let you.

That is a deliberate trade. Because a fault is never caught, the runtime is free to keep the whole call chain and every named value in it, and hand that to a debugger instead of to an unwinder. What you give up is recovery; what you get is a crash report that knows your variables by name.

var budget = 100
var people = 0
out(budget / people)
error: program faulted: division by zero

Backtrace:
#0   <entry>

  locals:
    budget: Int = 100
    people: Int = 0
  temps:
    <tmp#1: Int> @ "100" = 100
    <tmp#3: Int> @ "0" = 0
    <tmp#5: Int> @ "budget / people" = <uninit>
    <tmp#6: Unit> @ "out(budget / people)" = <uninit>

praxis run exits 1. That report is the noninteractive form; run the same program at a terminal and the same text is followed by a Praxis crash> prompt. Entering the debugger covers when you get which, and the command reference covers what to type at the prompt.

The rest of this chapter is the taxonomy: every fault the runtime can raise, what raises it, and the exact words it says.

The kinds

The fault line is always error: program faulted: followed by one of these. panic is the only kind that appends a message.

messageraised by
integer overflow+, -, *, /, %, unary - and the prelude’s abs on Int, when the true result does not fit 64 signed bits
division by zero/ and % on Int with a zero divisor
index out of boundsxs[i] past the end or negative, and m[k] for a key the map does not hold
input parse mismatcha read or parse whose input does not match the parser
empty collectionan operation that needs at least one element: min, max, pop, peek on an empty one
stack overflow (recursion limit)recursion that exhausts the native-stack budget
float-to-int conversion out of rangeFloat.to_int() on NaN, ±infinity, or a value outside the Int range
not a Unicode scalar valuea code point that is negative, above 0x10FFFF, or a surrogate
size or extent out of rangea size the runtime cannot serve: a BitSet member, or a Vec(n, …) or Grid(w, h, …) extent — negative, or too large to address
value does not have the declared typea value stored where its destination declared another type
panic: <message>panic(value)
assertion failedassert(condition) with a false condition
empty rangeclamp(v, low, high) with low > high
an argument this algorithm has no answer fora negative edge weight or a negative heuristic in dijkstra / a_star

invalid UTF-8 in Text is the fifteenth and last kind. praxis run rejects input that is not UTF-8 with error: failed to read input from stdin: stream did not contain valid UTF-8 and exit 2, so that fault is unreachable from the CLI; it exists for an embedder that hands the runtime bytes it did not check.

The rest of this chapter shows the ones you will actually hit.

Integer overflow

Int is a signed 64-bit integer and its arithmetic is checked, not wrapping.

var total = 9223372036854775807
out(total + 1)
error: program faulted: integer overflow

Backtrace:
#0   <entry>

  locals:
    total: Int = 9223372036854775807
  temps:
    <tmp#1: Int> @ "9223372036854775807" = 9223372036854775807
    <tmp#3: Int> @ "1" = 1
    <tmp#4: Int> @ "total + 1" = <uninit>
    <tmp#5: Unit> @ "out(total + 1)" = <uninit>

abs is in the same family: the negation of the most negative Int is not an Int, so abs(x) on it overflows rather than answering itself. abs is a prelude function on Int, not a method — x.abs() is a Y110 at check time, because .abs() is Float’s.

Float arithmetic never faults — IEEE-754 answers inf and NaN, and the language lets it. Only the narrowing Float.to_int() can, and it does so as float-to-int conversion out of range.

Division by zero

/ and % on Int both raise it. There is no “returns zero” convention and no checked_div. The other way those two operators fail is integer overflow, for the one pair that has no answer: the most negative Int divided or remaindered by -1.

Index out of bounds — and the missing key

xs[i] past the end raises it:

var xs = [10, 20, 30]
out(xs[3])
error: program faulted: index out of bounds

Backtrace:
#0   <entry>

  locals:
    xs: Vec[Int] = [10, 20, 30]
  temps:
    <tmp#1: Vec[Int]> @ "[10, 20, 30]" = [10, 20, 30]
    <tmp#2: Int> @ "10" = 10
    <tmp#3: Unit> = Unit
    <tmp#4: Int> @ "20" = 20
    <tmp#5: Unit> = Unit
    <tmp#6: Int> @ "30" = 30
    <tmp#7: Unit> = Unit
    <tmp#9: Int> @ "3" = 3
    <tmp#10: Int> @ "xs[3]" = <uninit>
    <tmp#11: Unit> @ "out(xs[3])" = <uninit>

Indexing a map with a key it does not hold raises the same kind, in the same words:

var ages = Map[Text, Int]()
ages["ada"] = 36
out(ages["alan"])
error: program faulted: index out of bounds

Backtrace:
#0   <entry>

  locals:
    ages: Map[Text, Int] = {"ada": 36}
  temps:
    <tmp#1: Map[Text, Int]> = {"ada": 36}
    <tmp#3: Text> @ ""ada"" = "ada"
    <tmp#4: Int> @ "36" = 36
    <tmp#5> @ "ages["ada"] = 36" = Unit
    <tmp#6: Text> @ ""alan"" = "alan"
    <tmp#7: Int> @ "ages["alan"]" = <uninit>
    <tmp#8: Unit> @ "out(ages["alan"])" = <uninit>

The words do not say “key”, which is the one place the fault line is less specific than it could be. The temps block is what tells you which access it was: the faulting expression is named there, as @ "ages["alan"]".

Indexing a map is the assertive read. Map.get is the other one — it answers Option[V] and never faults. Choosing between them is choosing whether absence is a bug or a case.

An empty min, max, pop or peek

A collection with no elements has no minimum, so asking for one is a fault rather than a zero:

var readings = Vec[Int]()
out(readings.min())
error: program faulted: empty collection

Backtrace:
#0   <entry>

  locals:
    readings: Vec[Int] = []
  temps:
    <tmp#1: Vec[Int]> = []
    <tmp#3: Int> = 0
    <tmp#4: Int> = 0
    <tmp#8: Unit> @ "out(readings.min())" = <uninit>

sum() on an empty collection is 0 and len() is 0 — those have answers. min, max, pop_front, pop_back, and heap pop/peek do not.

A failed assert

assert(condition) takes a condition and nothing else. There is no message argument, and the fault carries none: assertion failed beside a temps line naming the condition that was false is already the whole story.

var checksum = 41
assert(checksum == 42)
out(checksum)
error: program faulted: assertion failed

Backtrace:
#0   <entry>

  locals:
    checksum: Int = 41
  temps:
    <tmp#1: Int> @ "41" = 41
    <tmp#3: Int> @ "42" = 42
    <tmp#4: Bool> @ "checksum == 42" = false
    <tmp#5: Unit> @ "assert(checksum == 42)" = <uninit>
    <tmp#6: Unit> @ "out(checksum)" = <uninit>

<tmp#4: Bool> @ "checksum == 42" = false is the assertion’s own condition, evaluated, kept, and shown.

An explicit panic

panic(value) is the one that carries words. The value is rendered through its descriptor — exactly as out would render it — and appended to the fault line.

var mode = "diagonal"
panic("unsupported mode: " + mode)
error: program faulted: panic: unsupported mode: diagonal

Backtrace:
#0   <entry>

  locals:
    mode: Text = "diagonal"
  temps:
    <tmp#1: Text> @ ""diagonal"" = "diagonal"
    <tmp#3: Text> @ ""unsupported mode: "" = "unsupported mode: "
    <tmp#4: Text> @ ""unsupported mode: " + mode" = "unsupported mode: diagonal"
    <tmp#5> @ "panic("unsupported mode: " + mode)" = <uninit>

The argument does not have to be Text: panic(xs) on a Vec[Int] produces error: program faulted: panic: [1, 2, 3].

unreachable does not exist. There is no such function in the prelude, and unreachable() is an ordinary undefined-name error at check time. Write panic("unreachable: ...") instead.

A parse fault

An input parser that does not match its input raises input parse mismatch, and the fault line grows two more: where in the input it stopped, what it wanted there, and a preview of the bytes.

var rows = read lines(`{name:word} {score:int}`)
out(rows.len())

with the input

ada 36
alan oops
error: program faulted: input parse mismatch
       at input offset 12..12: expected int
       actual: ada 36⏎alan oops⏎

Backtrace:
#0   <entry>

  locals:
    rows: Vec[{ name: Text, score: Int }] = <uninit>
  temps:
    <tmp#1> = "ada 36\nalan oops\n"
    <tmp#2: Int> = 1
    <tmp#5: Int> @ "rows.len()" = <uninit>
    <tmp#6: Unit> @ "out(rows.len())" = <uninit>

The offset is a byte offset into the whole input, and is how the preview draws a newline. Offset 12 is the o of oops. Note rows itself: the binding the read was assigned to is <uninit>, because the parse never produced a value to assign. At the prompt, the input and parser commands render the same detail without the arithmetic; see inspecting the input parser and when a parse fails.

Recursion depth

Recursion is bounded by a byte budget, not a call count. Every generated function’s prologue charges its own frame against what is left and faults before the native stack can overflow and take the host process down with it.

fn down(n) { if n == 0 { 0 } else { down(n - 1) + 1 } }
out(down(1000000))

That faults with error: program faulted: stack overflow (recursion limit). The budget buys 8000 frames of an ordinary narrow function and fewer of a wide one — a frame’s cost grows with the number of heap values it holds live — so the depth you reach is a property of the function, not a constant worth quoting.

The backtrace that follows it has one line per frame, which for this program is exactly eight thousand lines of #N down. That is worth knowing before you run it at a terminal, and it is why this chapter quotes the fault line and not the report.

Allocation size

A size the runtime cannot serve is a fault rather than an abort. An Int reaches an allocation only through a validated extent, and the bound is a cap rather than “whatever a machine word holds” — so a request past it stops the program with a report you can read, instead of killing the process with no diagnostic at all.

var seen = BitSet()
seen.insert(1000000000000000000)
out(seen.len())
error: program faulted: size or extent out of range

Backtrace:
#0   <entry>

  locals:
    seen: BitSet = {}
  temps:
    <tmp#1: BitSet> = {}
    <tmp#3: Int> @ "1000000000000000000" = 1000000000000000000
    <tmp#4: Unit> @ "seen.insert(1000000000000000000)" = <uninit>
    <tmp#5: Int> @ "seen.len()" = <uninit>
    <tmp#6: Unit> @ "out(seen.len())" = <uninit>

A negative member raises it too. The same guard covers the sized collection constructors, and there it is the ordinary way to reach this fault: Vec(n, fill) and Grid(w, h, fill) take extents the program computes, so a negative one — or a width * height past the cap of 2^28 cells — stops the program the same way. It cannot be a check-time refusal: a size is an Int like any other, and its value is not known until it runs. Grid() and a read grid(…) never raise it — the first asks for 0×0, and the second builds its payload from input it has already read.

<uninit>: the value that was never produced

Look again at the temp for the faulting expression. budget / people, total + 1, xs[3] and ages["alan"] are all <uninit>, and so is every temp above them that was waiting on one. <uninit> means the value was never produced, and it is the same answer however the operation failed.

The two ways it can fail are worth knowing anyway, because they are why the debugger can say this at all. Checked Int arithmetic is inline machine code with an inline overflow test and a branch to a cold block, and that cold block goes straight to the fault epilogue — the operation is not a call whose result gets stored, so on the faulting path nothing is stored into the destination slot. Everything else — indexing, min, insert, to_int — is a call into a runtime wrapper, and a wrapper that raises still has to return something across the ABI boundary. The debugger’s store for the destination comes after the fault check rather than before it, so the sentinel the wrapper returned is never written down. Both paths end in the same place: no value was produced, so no value was recorded, and the frame says <uninit>.

A slot whose type genuinely is Unit and whose value is Unit — the temp for a seen.insert(…) statement that ran, say — is an ordinary value and tells you nothing either way.

What is not a fault

  • A type error. Everything the checker can prove wrong is a compile-time diagnostic and the program never starts. See reading a type error.
  • Absence. Map.get and Grid.find answer Option[T]; a None is a case to match, not a failure. See enums and Option.
  • An unreadable input file. praxis run reports it and exits 2 before the program runs. A missing --input file is not a parse fault.
  • A Rust panic inside the runtime. Every runtime entry point is wrapped so a panic cannot unwind into generated frames. If one ever escaped it would arrive as a panic fault whose message begins internal error: a panic escaped the runtime wrapper — or, where generated code would never look at the fault slot after that call, be printed and the process aborted. Seeing either is a bug in Praxis, not in your program.

Entering the debugger

When a program faults, praxis run either prints the crash report and exits 1, or prints the crash report and then hands you the debugger at the point of the crash. Which one you get is the --debug flag, and its default reads the terminal.

$ praxis run day07.px --input day07.txt              # --debug auto
$ praxis run day07.px --input day07.txt --debug always
$ praxis run day07.px --input day07.txt --debug never
  • auto — the default. Enter the debugger if both standard input and standard output are a terminal. Anything else — a pipe, a redirect, a CI runner, an editor’s task pane — declines.
  • always — enter the debugger regardless. This is what makes the sessions in this book reproducible, because it lets you feed commands in on a pipe.
  • never — never enter. Print the report, exit 1.

The test is stdin && stdout, not stderr. The report itself goes to standard error and so does everything the debugger prints, so 2>&1 is how you capture a session and > out.txt does not swallow it.

Exit is 1 on a fault either way. Quitting the debugger does not change that: a program that faulted has still faulted.

The same flag decides what a :bp breakpoint does, which is the other way into the debugger — the one that does not need the program to have gone wrong first.

Two surfaces

Entering the debugger on a terminal opens the full-screen debugger — the frame chain, source and locals at once, with the arrow keys moving between frames. Entering it on a pipe gives the Praxis crash> prompt that the command reference documents.

Both drive the same commands, so nothing in this chapter is true of only one of them. The difference is presentation, and it follows the terminal rather than a flag: --debug always reaching a pipe still takes the prompt, which is what keeps every scripted session in this book reproducible.

Driving it from a pipe

--debug always reads commands from standard input, one per line, and does not echo them. That makes a session scriptable:

$ printf 'bt\nlocals\nquit\n' | praxis run entering.px --input entering.in --debug always

The program:

var depths = read lines(int)

fn ratio(a, b) {
    a / (b - a)
}

fn step(values, i) {
    ratio(values[i], values[i + 1])
}

var total = 0
for i in 0..depths.len() - 1 {
    total = total + step(depths, i)
}
out(total)

with 10, 20, 20 as its input. The third reading equals the second, so b - a is zero on the second iteration and the divide faults. The full session, with the commands written back in after the prompts they were typed at:

error: program faulted: division by zero

Backtrace:
#0   ratio
#1   step
#2   <entry>

  locals:
    a: Int = 20
    b: Int = 20
  temps:
    <tmp#3: Int> @ "b - a" = 0
    <tmp#4: Int> @ "a / (b - a)" = <uninit>
Entered crash debugger. 3 frame(s). Type `help` for commands.
Praxis crash> bt
#0   ratio
#1   step
#2   <entry>
  (frame 0 selected)
Praxis crash> locals
  locals:
    a: Int = 20
    b: Int = 20
  temps:
    <tmp#3: Int> @ "b - a" = 0
    <tmp#4: Int> @ "a / (b - a)" = <uninit>
Praxis crash> quit

Every transcript in these chapters was produced that way. See noninteractive mode for what to do with that in a script.

What is printed before the prompt

Everything above Entered crash debugger. is the same text --debug never prints, in the same order, and it is printed before the debugger starts. You have already been told the answer to bt and to locals by the time you get a prompt; the prompt is for the second question.

The report is four parts:

  1. The fault line. error: program faulted: and the kind. A panic appends its message here. error: is colored like a compiler error when standard error is a terminal; --color never turns that off.
  2. The parse detail, for an input parse mismatch only: the input offset, what the parser expected there, and a preview.
  3. The backtrace, innermost first, under a Backtrace: header.
  4. Frame 0’s locals, split into locals: and temps:.

Part 4 is capped at twelve entries in the banner, with a …(N more) line if there are more. The locals command at the prompt has no cap, which is the one place the two renderings differ.

Then:

Entered crash debugger. 3 frame(s). Type `help` for commands.
Praxis crash>

The prompt is Praxis crash> with a trailing space. A blank line at it is ignored. End-of-file has the same effect as quit, which is why a .cmds file that forgets to end with quit still terminates.

The backtrace, and what a frame is

A frame is one call that had not returned when the fault fired. #0 is the function that faulted; the last frame is the program’s entry point.

#0   ratio
#1   step
#2   <entry>

<entry> is the name of a file’s top-level statements, and it is the last frame of every backtrace: a program is its top-level statements, so the outermost frame is always the generated one. It is not a name a program can spell, which is how you can tell the frame is not yours.

There is no line number in the backtrace. The equivalent is the source command, which prints the selected frame’s function with a caret under the extent the frame covers, and the @ "expr" annotations on the temps, which name the exact subexpression each slot materialized.

A frame knows five things, and every debugger command is a way of asking for one of them:

the frame knowsthe command that shows it
the function’s namebt
the caller it will return toup, down
the function’s source extentsource
its locals: name, static type, current valuelocals
its temporaries: id, static type, materializing expression, valuelocals

The static type on each local is the compiler’s, resolved against the same type table the program was compiled with, which is why locals can print Vec[{ name: Text, score: Int }] and not just “a vector”.

Selecting a frame

frame N, up and down move the selection. locals, p, type, heap and source all act on whichever frame is selected; bt marks it. Here is the top of the frames example — the <entry> frame’s locals run on for another twenty lines of temps, which are cut here:

Entered crash debugger. 3 frame(s). Type `help` for commands.
Praxis crash> bt
#0   ratio
#1   step
#2   <entry>
  (frame 0 selected)
Praxis crash> frame 2
frame 2: <entry>
Praxis crash> locals
  locals:
    depths: Vec[Int] = [10, 20, 20]
    total: Int = 1
    i: Int = 1

Two things in it are worth naming.

total: Int = 1 is the partial answer: one loop iteration had completed and added its 1 before the second one faulted. That is the whole point of the debugger — the state is the state at the moment of the fault, not a reconstruction.

i: Int = 1 is the loop variable, and it is in locals: for the same reason total is: a for variable is a binding, in exactly the sense a var is. The locals: section is every binding the program wrote, whatever syntax introduced it.

Every binding form, in one frame

pattern-bindings.px writes all of them and then reads past the end of a vector:

var xs = [1, 2, 3]
var total = 0

for item in xs {
    total = total + item
}

var pairs = [(2, 3), (4, 5)]
for (a, b) in pairs {
    total = total + a * b
}

match Some(total) {
    Some(sum) => { total = sum * 2 }
    None => {}
}

out(xs[9])
  locals:
    xs: Vec[Int] = [1, 2, 3]
    total: Int = 64
    item: Int = 3
    pairs: Vec[(Int, Int)] = [(2, 3), (4, 5)]
    a: Int = 4
    b: Int = 5
    sum: Int = 32

item is a plain for variable, a and b are a destructuring for’s two components, and sum is a match arm’s payload. Each holds its last value, and each is a name p will bind: p a * b answers 20 at this prompt.

The pair the second loop is walking has no row of its own, and that is deliberate. Nothing in the source named it — the names are a and b — so it is a compiler temp, and it shows up in the temps: section below as <tmp#31: (Int, Int)> @ "pairs" = (4, 5), which says what it holds and where it came from. A binding is what you wrote a name for.

One binding form still reads oddly: a var that a closure both captures and writes is stored in a cell, and the frame shows the cell as a temp rather than the binding’s value.

What survives the fault

Nothing about a fault is a stack unwind in the C++ or Rust sense. Each generated function’s fault epilogue returns normally, and the innermost one — the first to run, while the whole chain is still linked — deep-copies the entire frame chain into a crash snapshot before it goes. By the time control is back in the host, the native frames are gone and the snapshot is what you are talking to.

Two consequences you can see.

The heap is still there, and the snapshot roots it. Every value named by a frame in the snapshot is a garbage-collection root, so a Vec you built ten statements ago is still readable at the prompt, and p can allocate — it compiles and runs a real function against the real heap — without the values you are inspecting being collected out from under it.

A value the collector already took shows as an absence, not as a lie. A local’s debug slot keeps its value after the local’s last use, so you can still see it; but a collection between that last use and the fault is entitled to reclaim the object, because nothing else refers to it. The debug slots are the collector’s one weak arm: a collection clears the slots whose objects it reclaimed, so the snapshot copies None rather than a pointer into storage that has since been handed to something else.

var xs = Vec[Int]()
var i = 0
while i < 200 {
    xs.push(i + 2000)
    i = i + 1
}

var sum = xs.len()
var j = 0
while j < 40000 {
    var junk = Vec[Int]()
    junk.push(j + 2000)
    sum = sum + junk.len()
    j = j + 1
}

var ys = [sum]
out(ys[99])

xs is filled, read once into sum, and never touched again. The second loop allocates forty thousand short-lived vectors, which is more than enough to trigger a collection, and then the program faults on ys[99].

error: program faulted: index out of bounds

Backtrace:
#0   <entry>

  locals:
    xs: Vec[Int] = <collected>
    i: Int = 200
    sum: Int = 40200
    j: Int = 40000
    junk: Vec[Int] = [41999]
    ys: Vec[Int] = [40200]
  temps:
    <tmp#1: Vec[Int]> = <collected>
    <tmp#3: Int> @ "0" = 0
    <tmp#5: Int> @ "200" = 200
    <tmp#6: Bool> @ "i < 200" = false
    <tmp#7: Int> @ "2000" = 2000
    <tmp#8: Int> @ "i + 2000" = <collected>
  …(24 more)
Entered crash debugger. 1 frame(s). Type `help` for commands.
Praxis crash> p ys
[40200]
Praxis crash> p xs
error: type error: `xs` is not defined
Praxis crash> p sum
40200
Praxis crash> quit

ys is live and reads back. xs reads back as <uninit> and is not a name p will bind, because the two-hundred-element vector it named no longer exists. The alternative would be to print xs as a one-element vector holding a number from the second loop, whose memory block it had been reissued into. A crash debugger that occasionally invents a plausible value is worse than one that occasionally says nothing.

The rule to take away: a binding you can still see in your source may read as <uninit> if the program stopped using it long before the fault. Read it as “the collector got here first”, not as “it was never assigned”.

Breakpoints

Everything else in this chapter starts with a program that has already gone wrong. A breakpoint starts with one that has not: you mark a statement, and when the program reaches it, it stops and shows you the frame.

The mark is :bp, written after a statement:

var doubled = seed * 2 :bp

It is syntax, not a function. There is nothing to import, nothing to call, and no argument to pass — which also means it cannot be shadowed, passed around, or accidentally left inside a data structure. It is a mark on a line, and it costs one call at that line and nothing anywhere else in the program.

Where it stops

After the statement it marks, not before. That is the useful order: the statement’s effect has happened, so the binding it created is in locals with the value it created.

// `:bp` marks a statement. The program runs it, stops, and shows you the frame.
fn grow(seed: Int) -> Int {
    var doubled = seed * 2 :bp
    doubled + 1
}

out(grow(20))
$ praxis run bp-trace.px
stop: breakpoint
grow:
  <debug>:3:28
  3 |     var doubled = seed * 2 :bp
    |                            ^^^

Backtrace:
#0   grow
#1   <entry>

  locals:
    seed: Int = 20
    doubled: Int = 40
  temps:
    <tmp#2: Int> @ "2" = 2
    <tmp#3: Int> @ "seed * 2" = 40
    <tmp#5: Int> @ "1" = <uninit>
    <tmp#6: Int> @ "doubled + 1" = <uninit>
41

doubled: Int = 40 is there because the stop is after the var. To see the state before a statement, mark the one above it.

The rule holds for every statement form, including a block’s trailing expression: m + 1 :bp stops once m + 1 has been computed, and the block still yields it. What a marker never does is change what the program computes.

Where you can write it

Anywhere a statement ends: after a var, after an assignment, after a bare expression, after a block. The : and the bp must be adjacent — : bp with a space is not a marker, the same rule min= lives under.

var xs = [1, 2, 3] :bp        // after a binding
xs.push(4) :bp                // after a call statement
total = total + n :bp         // after an assignment
counts[key] += 1 :bp          // after a store through a place

A marker is not an expression, so it does not go inside one: f(x :bp) does not parse. Mark the statement that contains the call instead.

Three ways a stop is served

Which surface you get follows --debug, exactly as a fault does — there is no second rule to learn.

--debugterminal?at a :bp
nevernothing at all; the marker is inert
auto (default)noprint the frame to stderr, keep running
auto / alwaysyesthe full-screen debugger
alwaysnothe Praxis stop> prompt on stdin

The second row is the one worth naming: at the default, in a pipe or a script or CI, a marker is a trace point. It prints where the program is and what it holds, and the program carries on. That is the output shown above, and it is why :bp is useful in a program you are not sitting in front of.

--debug never makes every marker inert without touching the source, which is what you want when the program is going somewhere else and you have not deleted the marks yet.

Continuing

At a prompt, continue (or c, or cont) lets the program go. It stops again at the next marker it reaches — including the same one, on the next pass of a loop, which the banner numbers for you.

// A marker inside a loop stops on every pass, and the stop is numbered.
var total = 0
for n in [4, 7] {
    total = total + n :bp
}
out(total)
$ printf 'bt\nlocals\ncontinue\nquit\n' | praxis run bp-loop.px --debug always
Stopped at a breakpoint. 1 frame(s). `continue` resumes; `help` lists commands.
Praxis stop> #0   <entry>
  (frame 0 selected)
Praxis stop>   locals:
    total: Int = 4
    n: Int = 4
  temps:
    <tmp#1: Int> @ "0" = 0
    <tmp#3: Vec[Int]> @ "[4, 7]" = [4, 7]
    <tmp#4: Int> @ "4" = 4
    <tmp#5: Unit> = Unit
    <tmp#6: Int> @ "7" = 7
    <tmp#7: Unit> = Unit
    <tmp#8: Int> = 0
    <tmp#9: Int> = 2
    <tmp#10: Int> = 4
    <tmp#12: Int> @ "total + n" = 4
    <tmp#14: Unit> @ "for n in [4, 7] { total = total + n :bp }" = <uninit>
    <tmp#15: Unit> @ "out(total)" = <uninit>
    <tmp#17: Int> = 0
    <tmp#18: Int> @ "var total = 0" = <uninit>
Praxis stop> continuing.
Stopped at a breakpoint (stop #2). 1 frame(s). `continue` resumes; `help` lists commands.
Praxis stop> leaving the debugger; the program runs on and will not stop again.
11

quit at a stop does not end the program — it ends the debugging. The program runs to completion and no later marker takes the terminal again. There is no command that kills a running program from a stop: a Praxis frame unwinds by faulting, and reporting a fault the program did not have would be a lie about what happened. Ctrl-C is what ends a run.

In the full-screen debugger the key is c, and the status bar leads with it.

What a stop cannot do

A stop is the middle of a program, not the end of one, and that costs it three commands:

  • p EXPR and heap EXPR. Evaluating an expression means compiling a function and running it, and the program’s own frames are still on the stack underneath you. type EXPR still works — it type-checks against the frame’s locals and runs nothing.
  • restart and reload. Both re-run the program from the beginning, and there is a program in progress. Continue to the end, and if it faults you have the crash debugger with both.

Everything else is the same command against the same kind of snapshot: bt, frame N, up/down, locals, source, help, quit.

What a marker costs

One call, at the marked line, and nothing else. The wrapper it calls allocates nothing and cannot fault, so the compiler emits no root spill before it and no fault check after — a marked statement is the unmarked statement plus a call. A program with no marker in it emits nothing at all.

That is worth knowing because it means you can leave a marker in a loop that runs a million times and, under --debug never, pay only the call. What you should not do is leave one in and commit it: a marker in a program somebody else runs stops their program.

The full-screen debugger

On a terminal, a fault opens a full-screen debugger: the frame chain, the selected frame’s source, and its locals, all on screen at once, with the arrow keys moving between frames.

 ✗ division by zero  ·  3 frame(s)
╭ backtrace ───────────────────────╮╭ ratio · ratio.px ────────────────────────────────────────╮
│▶ #0  ratio   :4                  ││  3 │ fn ratio(a, b) {                                    │
│  #1  step    :8                  ││▶ 4 │     a / (b - a)                                     │
│  #2  <entry> :15                 ││  5 │ }                                                   │
│                                  ││                                                          │
╰──────────────────────────────────╯│                                                          │
╭ locals ──────────────────────────╮│                                                          │
│ bindings                         ││                                                          │
│  a     Int = 20                  ││                                                          │
│  b     Int = 20                  ││                                                          │
│ temps                            │╰──────────────────────────────────────────────────────────╯
│  tmp#3 Int = 0                   │╭ output ──────────────────────────────────────────────────╮
│  tmp#4 Int = <uninit>            ││Type `:` to run a command, `?` for keys.                   │
│                                  ││↑↓ or j/k select a frame; u/d walk the call stack.        │
╰──────────────────────────────────╯╰──────────────────────────────────────────────────────────╯
 backtrace   ↑↓ frame  tab pane  : cmd  r restart  ? keys  q quit

That screen is the whole diagnosis of this crash. b - a is tmp#3, and it is 0; tmp#4 is the divide that never produced a value; and the is on the line both of them came from.

This is a view over the same engine the command reference documents — every command still runs through it, so the two surfaces cannot answer the same question differently. What the screen adds is that you do not have to ask: moving to a frame shows you its source and its locals together, which on the line-oriented prompt took up, locals, source, and holding the results in your head.

Which surface you get

A terminal gets the full-screen debugger. Anything else gets the line-oriented prompt, and that is the correct surface for it rather than a lesser one — a pipe has no keystrokes to read and no screen to draw on.

Standard input and output--debugWhat you get
A terminalauto or alwaysThe full-screen debugger
A pipe, a redirect, a CI runneralwaysThe Praxis crash> prompt
A pipe, a redirect, a CI runnerautoThe report, then exit 1

A :bp stop reads the same table, with the last row’s exit replaced by “and the program carries on”.

So the scripted sessions throughout this chapter still behave exactly as written: printf 'bt\nquit\n' | praxis run … --debug always is a pipe, and takes the prompt.

The crash report is printed before the screen opens, and the screen is an alternate one — so quitting the debugger reveals the report still sitting in your scrollback. You keep both.

The panes

backtrace — every frame, innermost first, with the line each one faulted on. marks the selection. That line number is the faulting line, not the line the function is declared on: for frame 0 it is where the fault happened, and for a caller it is the call that led there.

source — the selected frame’s function, with on the marked line and the subexpression underlined inside it. The frame’s recorded span covers the whole function, so the marked line comes from somewhere else, and which somewhere depends on what the frame is doing.

A caller is in a call, and the compiler recorded which function each call targets, so the frame above names the call this one is inside — even in a loop, where the temps hold values from an earlier pass. A frame that is in no call — the innermost frame of a fault, or a caller whose call went through a closure value and so has no name to match — is recovered from the temps instead: a temp that carries a source span but never received a value is an expression that started evaluating and did not finish, and the narrowest one is the innermost such expression.

The pane opens on the marked line, not on the function’s first line — in anything longer than the pane those are not the same place, and the fault is the part you came to see. If the marked line already fits on the first screenful the pane stays at the top, so the signature stays visible; past that it centres the fault. and scroll from wherever that lands, and changing frame returns to it.

locals — the selected frame’s slots, in the same two sections locals prints: bindings for what you wrote, temps for the compiler’s intermediates with the source expression each materialized. Values are cut to the width of the column at an element boundary, so a long collection reads [0, 1, 2, ...] rather than running off the pane mid-element.

output — a transcript of the commands you have run and what they answered.

Keys

Press ? for this list without leaving the debugger.

KeyDoes
/ kSelect the frame above — toward #0
/ jSelect the frame below
home / endThe first / last frame in the list
u / dUp / down the call stack, from whichever pane has focus
tab / shift-tabMove focus between panes
pgup / pgdnA page of whatever the focused pane counts in — frames in the backtrace, lines elsewhere
ccontinue — let a program stopped at a :bp marker run on
pOpen the command line already primed with p
r / Rrestart / reload
l / bRun locals / bt into the output pane
i / Pinput / parser context
:Type any command
?The key list; any key dismisses it
q, ctrl-cQuit

The arrows are spatial: they move the highlight the way they point. Since the backtrace is drawn innermost-first, goes to a higher frame number and back toward #0. home and end are the two ends of that list.

u and d are the other thing you might mean — the call stack, in the sense the up and down commands use, so a keypress and a typed command never disagree. u selects the caller, which on an innermost-first list is downward on screen; that contradiction is why the call-stack motion has its own pair of keys rather than being hung on the arrows.

They are also the way to change frame without first moving focus: arrows scroll whichever pane holds focus, while u and d move frames from anywhere.

The command line

: opens a command line that accepts everything in the command reference, including p EXPR, type EXPR, heap EXPR, restart and reload. Results land in the output pane.

╭ output ──────────────────────────────────────────────────╮
│Type `:` to run a command, `?` for keys.                  │
│↑↓ or j/k select a frame; u/d walk the call stack.        │
│❯ p b - a                                                 │
│0                                                         │
╰──────────────────────────────────────────────────────────╯

and walk the command history while you are typing, esc abandons the line without running it, and ctrl-c does the same. Since p EXPR is the command you reach for most, p on its own opens the line with that prefix already typed.

quit typed as a command does what q does.

What is not here

No continue, step, next, or breakpoints, and no way to change a value — for the same reasons the command reference gives. A full screen does not change what a faulted program can be asked to do.

Command reference

Fifteen commands, and help lists all of them. They are the same fifteen on either surface: the transcripts here show the Praxis crash> prompt, and the full-screen debugger runs each of them from its : line.

This is the list for a program that faulted. A program stopped at a :bp marker gets a shorter one — it has a continue, and it has no p, restart or reload.

Crash debugger commands:
  bt              show the numbered backtrace
  frame N         select frame N
  up              move the selection toward the caller
  down            move the selection toward the callee
  locals          show the selected frame's locals
  p EXPR          evaluate a read-only expression
  type EXPR       show the inferred expression type
  heap EXPR       inspect a value with its type
  source [N]      show the selected (or Nth) frame's source
  input           show the input near the active parser cursor
  parser          show the active input parser near the fault
  restart         rerun the program with the same input
  reload          recompile source and rerun with the same input
  help            show this message
  quit            exit the debugger

A command is one line: the first word is the command and the rest is its argument, whitespace-trimmed at both ends. There is no history, no completion, no multi-line input and no abbreviation — b is not bt. A blank line does nothing. A word that is not a command gets

unknown command `frobnicate`. Type `help` for the list.

Three commands have aliases: bt/backtrace, help/?, quit/exit/q. End-of-file is quit.

Almost every transcript below is against the same three-frame program, which divides by the difference between two equal readings:

var depths = read lines(int)

fn ratio(a, b) {
    a / (b - a)
}

fn step(values, i) {
    ratio(values[i], values[i + 1])
}

var total = 0
for i in 0..depths.len() - 1 {
    total = total + step(depths, i)
}
out(total)

Its input is 10, 20, 20. The fault banner each session opens with is the same one every time, so it is cut from most of the transcripts below; each block starts at Entered crash debugger.. See entering the debugger for what the banner says.


bt, backtrace

Print every frame in the snapshot, innermost first, then a line naming the selected one. The number sits in a three-column field so the names line up.

#{N}   {function name}
  (frame {selected} selected)

frame N

Select frame N. Prints frame N: name. An N past the end is an error and leaves the selection alone; an N that is not a number is a usage line.

up, down

up moves one frame toward the caller (a higher number), down one frame toward the callee. Each prints the newly selected frame the way frame N does, or refuses at the end of the chain:

already at the outermost frame
already at the innermost frame

All four navigation commands in one session:

Entered crash debugger. 3 frame(s). Type `help` for commands.
Praxis crash> bt
#0   ratio
#1   step
#2   <entry>
  (frame 0 selected)
Praxis crash> frame 1
frame 1: step
Praxis crash> bt
#0   ratio
#1   step
#2   <entry>
  (frame 1 selected)
Praxis crash> up
frame 2: <entry>
Praxis crash> up
already at the outermost frame
Praxis crash> down
frame 1: step
Praxis crash> down
frame 0: ratio
Praxis crash> down
already at the innermost frame
Praxis crash> frame 9
error: frame 9 out of range (0..=2)
Praxis crash> frame x
usage: frame N
Praxis crash> quit

locals

Print the selected frame’s slots, in two labeled sections.

  locals:
    {name}: {Type} = {value}
  temps:
    <tmp#{id}: {Type}> @ "{expression}" = {value}

locals: is the bindings you wrote — a var, a parameter, a for variable and a name a pattern introduces, all of which are bindings in the same sense. temps: is the compiler’s intermediates, each tagged with its per-frame id, its static type, and — this is the useful part — the source expression it materialized. A value is rendered through the same descriptor out uses. A slot nothing was written into is <uninit>.

Either section is omitted when it is empty; a frame with no slots at all prints (no locals in this frame). Temps that hold nothing and explain nothing (no value and no source span) are dropped rather than shown as noise.

Unlike the banner, locals caps no number of slots: it prints the whole frame. Each individual value is bounded, though, and cut at an element boundary with ... marking the remainder — so a Vec of ten thousand elements reads [0, 1, 2, ...] instead of burying the rest of the frame under itself.

Entered crash debugger. 3 frame(s). Type `help` for commands.
Praxis crash> locals
  locals:
    a: Int = 20
    b: Int = 20
  temps:
    <tmp#3: Int> @ "b - a" = 0
    <tmp#4: Int> @ "a / (b - a)" = <uninit>
Praxis crash> frame 1
frame 1: step
Praxis crash> locals
  locals:
    values: Vec[Int] = [10, 20, 20]
    i: Int = 1
  temps:
    <tmp#3: Int> @ "values[i]" = 20
    <tmp#4: Int> @ "1" = 1
    <tmp#5: Int> @ "i + 1" = 2
    <tmp#6: Int> @ "values[i + 1]" = 20
    <tmp#7: Int> @ "ratio(values[i], values[i + 1])" = <uninit>
Praxis crash> quit

Frame 1’s temps read as a small trace of the call that faulted: values[i] was 20, values[i + 1] was 20, and the call whose result they were arguments to never returned a value.

One known rough edge in this output: shadowing. Two bindings that shadow each other print as two lines with the same name, in declaration order, and nothing distinguishes them:

var count = 1
if count > 0 {
    var count = count + 40
    var zero = 0
    out(count / zero)
}
Entered crash debugger. 1 frame(s). Type `help` for commands.
Praxis crash> locals
  locals:
    count: Int = 1
    count: Int = 41
    zero: Int = 0
  temps:
    <tmp#1: Int> @ "1" = 1
    <tmp#3: Int> @ "0" = 0
    <tmp#4: Bool> @ "count > 0" = true
    <tmp#6: Int> @ "40" = 40
    <tmp#7: Int> @ "count + 40" = 41
    <tmp#9: Int> @ "0" = 0
    <tmp#11: Int> @ "count / zero" = <uninit>
    <tmp#12: Unit> @ "out(count / zero)" = <uninit>
Praxis crash> p count
41
Praxis crash> quit

The frame’s metadata does carry a distinct symbol id for each count, and this renderer does not print it — so the order of the lines is what tells you which is which, and p count answers for the inner one only. Read the outer one off locals. See bindings and shadowing.

p EXPR

Evaluate EXPR against the selected frame and print the result. The expression is ordinary Praxis; the frame’s named locals are in scope; the result is printed through its descriptor, one line, no type.

p compiles and runs real code — it synthesizes a function whose parameters are the locals your expression mentions, type-checks it, JIT-compiles it, and calls it against the live heap. What it will not do is change anything: a mutating method or a call to one of your own functions is refused before it runs. Empty argument prints usage: p EXPR.

Entered crash debugger. 3 frame(s). Type `help` for commands.
Praxis crash> p a
20
Praxis crash> p b - a
0
Praxis crash> type a
Int
Praxis crash> frame 1
frame 1: step
Praxis crash> p values
[10, 20, 20]
Praxis crash> type values
Vec[Int]
Praxis crash> p values.len() * 2
6
Praxis crash> p values.push(9)
error: method `push` is impure (may mutate state) — `p` rejects mutating expressions
Praxis crash> quit

p b - a answering 0 is the whole diagnosis of this crash in one line.

Errors come back as error: and a message. A name the frame does not bind gives:

error: type error: `xs` is not defined

Evaluating expressions covers what p accepts, what the purity gate rejects, and where the limits are.

type EXPR

The same pipeline as p, stopped after type-checking: it prints the inferred type of EXPR and never JIT-compiles or runs it. Usage line is usage: type EXPR.

Because nothing runs, type also skips the purity gate — type values.push(9) answers Unit where p values.push(9) refuses. It is the safe way to ask what a method would give you back.

heap EXPR

p with the type in front, separated by : .

{Type}: {value}

The value part is the same text p prints, through the same recursive descriptor — a map of vectors comes back with the vectors in it either way. What heap adds is the type, which is what you want when the question is what shape a structure has rather than what number it holds. Against a different program:

var counts = Map[Text, Vec[Int]]()
counts["ada"] = [1, 2, 3]
counts["alan"] = [4, 5]
out(counts["ada"].sum() + counts["turing"].sum())
Entered crash debugger. 1 frame(s). Type `help` for commands.
Praxis crash> p counts
{ada: [1, 2, 3], alan: [4, 5]}
Praxis crash> heap counts
Map[Text, Vec[Int]]: {ada: [1, 2, 3], alan: [4, 5]}
Praxis crash> heap counts["ada"]
Vec[Int]: [1, 2, 3]
Praxis crash> type counts
Map[Text, Vec[Int]]
Praxis crash> quit

heap is p and type in one line. It runs the expression, so the purity gate applies to it too. Usage line is usage: heap EXPR.

source [N]

Print the selected frame’s function — the function’s name, a file:line:column header, and the source lines with a caret rule under the extent the frame covers. With an argument, print frame N instead, without changing the selection.

Entered crash debugger. 3 frame(s). Type `help` for commands.
Praxis crash> source
ratio:
  <debug>:3:1
  3 | fn ratio(a, b) {
    | ^^^^^^^^^^^^^^^^...
  4 |     a / (b - a)
    | ^^^^^^^^^^^^^^^...
  5 | }
    | ^
Praxis crash> source 1
step:
  <debug>:7:1
  7 | fn step(values, i) {
    | ^^^^^^^^^^^^^^^^^^^^...
  8 |     ratio(values[i], values[i + 1])
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^...
  9 | }
    | ^
Praxis crash> quit

Three things about that output are worth knowing before they surprise you.

The filename is literally <debug>, not your file’s name: the snippet is rendered against a throwaway source map built from the text the session is holding. The line numbers are real.

The carets cover the frame’s extent, which is the whole function, not the faulting line. Each line is underlined to its own end and a ... marks that the span continues onto the next one. To find the faulting subexpression, read the @ "expr" annotations in locals — or use the full-screen debugger, whose source pane marks the faulting line and underlines the subexpression, having recovered both from those same annotations.

An out-of-range or non-numeric N is not an error — source 99 and source zz both fall back to the selected frame. And the entry frame’s extent is the whole file, so source on <entry> prints your program, entire.

A frame with no recorded span prints (no source span recorded for this frame).

input

Show the input around the point an input parser stopped. It answers for an input parse mismatch fault and says so for any other kind:

(no input context — not a parse failure)

parser

Show what the parser wanted at that point. Same rule: it answers for a parse fault and otherwise prints (no parser context — not a parse failure).

Both against the parse-fault program from the fault model:

var rows = read lines(`{name:word} {score:int}`)
out(rows.len())
Entered crash debugger. 1 frame(s). Type `help` for commands.
Praxis crash> input
input at offset 12..12:
  ada 36⏎alan oops⏎
Praxis crash> parser
expected: int
parser expression: <unknown parser>
Praxis crash> quit

parser expression: <unknown parser> is what you get for every parse failure: the failing parser’s source span is not threaded through to the runtime, so there is no expression text to print in its place. What the command gives you is the expected description. Inspecting the input parser goes into the detail.

restart

Re-run the same compiled code against the same input. No recompilation. The fault, snapshot and parse detail are cleared first, the original input bytes are re-installed, and the program’s entry point is called again.

If the re-run faults, the new snapshot replaces the old one and the frame cursor resets to 0:

program faulted: {kind}
{N} frame(s); frame 0 selected.

If it completes, the debugger prints program completed: {value} and stays at the prompt.

reload

Re-read the source file from disk, recompile it, and then do what restart does. The input bytes and the input filename are retained; only the code changes. This is the edit-and-retry loop: leave the debugger open in one window, fix the source in another, type reload.

A compilation that fails leaves everything as it was:

error: {diagnostic}
(session unchanged — the old snapshot is still active)

The old JIT code and the old snapshot are only discarded once the new compilation has succeeded, so a typo in your fix costs you nothing.

Entered crash debugger. 3 frame(s). Type `help` for commands.
Praxis crash> restart
program faulted: division by zero
3 frame(s); frame 0 selected.
Praxis crash> bt
#0   ratio
#1   step
#2   <entry>
  (frame 0 selected)
Praxis crash> reload
program faulted: division by zero
3 frame(s); frame 0 selected.
Praxis crash> quit

Nothing changed on disk between those two commands, so the program faulted identically twice. That is the point of the guarantee: a restart is reproducible, and a reload that reports a different fault reports it because you changed something.

One caveat about “the same input”. What is retained is what the program actually read. A program that faulted before its first read recorded nothing, so a reload that moves the read earlier in the program sees empty input rather than the original standard input — that stream is gone. Running with --input FILE avoids the question entirely.

help, ?

Print the command list at the top of this chapter.

quit, exit, q

Leave the debugger. The heap and the JIT are torn down in order, and praxis run exits 1, because the program still faulted.

The full set of error and usage lines, in one session:

Entered crash debugger. 3 frame(s). Type `help` for commands.
Praxis crash> help
Crash debugger commands:
  bt              show the numbered backtrace
  frame N         select frame N
  up              move the selection toward the caller
  down            move the selection toward the callee
  locals          show the selected frame's locals
  p EXPR          evaluate a read-only expression
  type EXPR       show the inferred expression type
  heap EXPR       inspect a value with its type
  source [N]      show the selected (or Nth) frame's source
  input           show the input near the active parser cursor
  parser          show the active input parser near the fault
  restart         rerun the program with the same input
  reload          recompile source and rerun with the same input
  help            show this message
  quit            exit the debugger
Praxis crash> frobnicate
unknown command `frobnicate`. Type `help` for the list.
Praxis crash> p
usage: p EXPR
Praxis crash> input
(no input context — not a parse failure)
Praxis crash> quit

What is not here

There is no step and no next, and at a fault there is no continue either: the faulting operation has no answer to resume with, and by the time you see the prompt every frame has unwound. restart and reload are the only ways to run anything again, and both start from the top.

continue does exist, at the other prompt. A :bp breakpoint stops a program that has not faulted, and that one has frames to return to — so Praxis stop> has continue and does not have p, restart or reload. The two prompts are the same commands minus whatever the situation cannot support.

There is also no way to change a value, at either prompt. p is read-only by construction, and the reason is the same one — a state that cannot be resumed cannot usefully be edited.

Evaluating expressions

The three commands that make the crash debugger more than a stack dump are p, type and heap. Each takes an expression written in ordinary Praxis syntax, evaluates it against the selected frame’s locals, and prints the result — the value for p, the inferred type for type, the type and the value for heap.

There is no interpreter behind them. p EXPR synthesizes a one-function module, fn __p_expr(<the locals you named>) { EXPR }, and runs it through the whole compiler: parse, resolve, type-check, monomorphize, MIR, Cranelift, call. The value you get back was computed by machine code generated for the question you just asked. That is why p type-checks the way the rest of the language does, and why its errors are the compiler’s errors.

Asking about a value

Here is a program with one local of each interesting shape. It faults on the last line.

// A frame carrying one local of each interesting shape, so a debugger session
// can ask `p` / `type` / `heap` about all of them. The last line faults.
struct Reading {
    site: Text
    depth: Int
}

enum Signal {
    Ping(Int)
    Quiet
}

var readings = [Reading { site: "north", depth: 12 }, Reading { site: "south", depth: 30 }]
var totals = Map()
totals["north"] = 12
totals["south"] = 30
var latest = Ping(30)
var label = "sonar"
out(readings[9].depth)

Every kind of expression you would write in the program works at the prompt: field access, indexing, pure method calls, arithmetic, tuples, if, match, record and enum construction.

Praxis crash> p label
sonar
Praxis crash> p label.len()
5
Praxis crash> p readings.len()
2
Praxis crash> p readings[0]
{ site: north, depth: 12 }
Praxis crash> p readings[0].depth + readings[1].depth
42
Praxis crash> p (readings[0].site, readings[1].depth)
(north, 30)
Praxis crash> p if label.len() > 3 { "long" } else { "short" }
long
Praxis crash> p Reading { site: "east", depth: 1 }
{ site: east, depth: 1 }
Praxis crash> type readings
Vec[Reading]
Praxis crash> heap readings
Vec[Reading]: [{ site: north, depth: 12 }, { site: south, depth: 30 }]
Praxis crash> p totals["north"]
12
Praxis crash> type totals
Map[Text, Int]
Praxis crash> p latest
Ping(30)
Praxis crash> heap latest
Signal: Ping(30)
Praxis crash> p match latest { Ping(n) => n, Quiet => 0 }
30
Praxis crash> quit

heap EXPR is p EXPR with the type printed in front of the value. It is the command to reach for when a value’s shape is ambiguous on sight — [] could be a Vec[Int] or a Vec[Text], Ping(30) says nothing about which enum it came from — and the type answers it on the same line. It does not print more of a large value than p does; see Rendering, and what it does not truncate.

type EXPR stops the pipeline before code generation, so it answers for any expression that type-checks, whether or not that expression could be run.

What is in scope

The names p can see are the named bindings of the selected frame, and nothing else. Not the other frames’ locals, not the program’s functions — a synthesized module declares the locals your expression mentioned, and the record and enum types it needs to spell them, and nothing else. (A type your expression names is declared too, whether or not a local has it, which is what makes p Reading { site: "east", depth: 1 } above work.)

// Three frames, each with its own locals: `p` sees exactly the selected
// frame's, and nothing else in the program.
fn cell(grid: Vec[Int], at: Int) -> Int {
    grid[at]
}

fn edge(grid: Vec[Int], width: Int) -> Int {
    var row = 2
    cell(grid, row * width)
}

var grid = [1, 2, 3, 4, 5, 6]
var width = 3
out(edge(grid, width))
Praxis crash> bt
#0   cell
#1   edge
#2   <entry>
  (frame 0 selected)
Praxis crash> p at
6
Praxis crash> p grid
[1, 2, 3, 4, 5, 6]
Praxis crash> p row
error: type error: `row` is not defined
Praxis crash> up
frame 1: edge
Praxis crash> p row
2
Praxis crash> p row * width
6
Praxis crash> p at
error: type error: `at` is not defined
Praxis crash> up
frame 2: <entry>
Praxis crash> p grid.len()
6
Praxis crash> p width
3
Praxis crash> p edge(grid, 1)
error: type error: `edge` is not defined
Praxis crash> p widht
error: type error: `widht` is not defined
Praxis crash> frame 0
frame 0: cell
Praxis crash> p grid[at - 1]
6
Praxis crash> p grid[at]
error: expression faulted: index out of bounds
Praxis crash> quit

Four things in that session are worth naming.

row is not in scope in frame 0 and at is not in scope in frame 1: moving the selection with up, down or frame N changes what p can name. The navigation commands are in the command reference.

p edge(grid, 1) fails with the message a misspelling gets. The synthesized module never declares your program’s functions, so there is no edge to call — the read-only gate below never gets a chance to have an opinion about it. A typo (widht) and a real function you cannot call look identical from the prompt.

p grid[at - 1] succeeds and p grid[at] does not. A p expression runs against the same runtime that faulted, so it can fault itself: an out-of-bounds index, a division by zero, an overflow. The debugger reports the fault kind, clears it, and stays at the prompt. Reproducing the fault in one line — with the index nudged by one — is the fastest way to be sure you have found it.

And p at answers 6 for a function that has already returned in the native sense. There is no suspended stack. The frame chain you are walking is a crash snapshot, a copy the innermost fault epilogue took before any frame popped; its values are rooted for the collector, so they are still there and still valid.

Shadowed names

A snapshot frame is flat. Every scope of the function contributes its bindings to one list, so a name that was shadowed appears once per binding, with its own type.

// A snapshot frame is flat: every scope of the function contributes its
// bindings, so a shadowed name is listed once per binding.
var n = 1
if n > 0 {
    var n = "two"
    out(n)
}
var xs = [1]
out(xs[9])
Praxis crash> locals
  locals:
    n: Int = 1
    n: Text = "two"
    xs: Vec[Int] = [1]

locals shows you both. p picks the innermost — the last binding of that name in the frame — and type agrees with it:

Praxis crash> p n
two
Praxis crash> type n
Text

There is no syntax for naming the outer one. Read its value off the locals listing.

What p cannot bind

Only user-written bindings are candidates. The compiler temporaries locals lists as <tmp#4: Bool> @ "n > 0" are shown but not nameable: they have no source name to write.

The number of locals one expression may name is capped at six. The cap is on the expression, not on the frame — a function with twenty bindings is fine as long as each p you type mentions at most six of them. It is checked where the locals are passed as arguments, so it binds p and heap; type never makes the call and answers a seven-local expression without complaint.

// Seven bindings and one long vector: enough to hit the two ceilings `p` has —
// six named locals per expression, and the twelve-local cap the banner (but not
// the `locals` command) applies.
var a = 1
var b = 2
var c = 3
var d = 4
var e = 5
var f = 6
var g = 7
var squares = (0..40).map(|n| n * n)
out(squares[500])
Praxis crash> p a + b + c + d + e + f
21
Praxis crash> p a + b + c + d + e + f + g
error: the expression names 7 locals; `p` supports up to 6

The set of names an expression “mentions” is over-approximated from its tokens, so p rec.g counts a local called g against the six even though the g there is a field name. Splitting the question into two ps is the fix.

The read-only gate

A faulted program cannot be resumed, so the debugger must not let you change what it computed. Every p and heap expression is walked before it is compiled, and anything that could mutate, consume input, diverge, or run code whose effects cannot be proved is rejected. The gate sits between type-checking and code generation, so a rejected expression never executes.

// The purity gate has to have something to refuse, so this frame holds a
// mutable collection, and the session below asks it to mutate.
var seen = Set()
seen.insert(3)
var queue = [1, 2, 3]
var steps = 0
out(queue[7])
Praxis crash> p queue.len()
3
Praxis crash> p queue.sorted()
[1, 2, 3]
Praxis crash> p queue.push(4)
error: method `push` is impure (may mutate state) — `p` rejects mutating expressions
Praxis crash> p seen.insert(9)
error: method `insert` is impure (may mutate state) — `p` rejects mutating expressions
Praxis crash> p { steps = 9; steps }
error: assignment mutates — `p` rejects mutating expressions
Praxis crash> p abs(steps - 5)
error: call to `abs` — `p` cannot prove a user function is read-only
Praxis crash> p |x| x + steps
error: closure literals may capture and mutate — `p` rejects them
Praxis crash> p for q in queue { q }
error: `for` diverges — `p` evaluates a single value
Praxis crash> p read int
error: `read` consumes input and may fault — `p` rejects it
Praxis crash> p while steps < 3 { steps }
error: `while` diverges — `p` evaluates a single value
Praxis crash> p { var t = steps + 1; t * 2 }
2
Praxis crash> p queue.map(|x| x + 1)
error: closure literals may capture and mutate — `p` rejects them
Praxis crash> type queue.push(4)
Unit
Praxis crash> type read int
Int
Praxis crash> quit

What the gate refuses, roughly in the order you are likely to meet it:

rejectedwhy
a method the catalog tags impure (push, insert, set, …)it mutates its receiver
an assignment inside a blockit mutates a binding
a call to a named function, prelude functions like abs and min includedthe purity of a function body is not analyzed
a closure literalit may capture and mutate
for, while, loop, break, continue, returnthey do not yield one value
read, parsethey consume input and can fault on the cursor

Everything else passes: literals, local reads, arithmetic and comparison, if, match, tuples, list literals, ranges, field and tuple-element reads, record and enum construction, blocks — including var declarations inside them, which is what p { var t = steps + 1; t * 2 } is doing — and any method the catalog tags pure.

The rejection that surprises people is p abs(steps - 5). It looks like the most innocent expression in the world; it is refused because Praxis has no purity analysis for function bodies, and the gate’s answer for anything it cannot prove is no. Write the arithmetic out instead. Sorting and summing through a method are catalog entries and are fine; it is the free-function form that is not.

Mapping is not a way round that. p queue.map(|x| x + 1) is refused for the closure and not for the method, and naming one of your own functions instead does not help — the synthesized module never declares them, so it is not defined. Every higher-order method is out of reach at the prompt.

Note also that p queue.sorted() is allowed even though it builds a new Vec. Allocating is not mutating: a debugger expression allocates on the main GC heap like any other code, and what the gate protects is the state the snapshot holds.

type is not gated at all. The walk runs only on the paths that execute, and the last two lines of that session are the proof — type queue.push(4) answers Unit, type read int answers Int. Nothing runs, so there is nothing to protect, and when p refuses an expression you can still ask what it would have produced.

Rendering, and what it does not truncate

Values are formatted through the same descriptor machinery out uses, so what p prints for a value is what the program would have printed for it. That formatting is recursive and complete: no element cap, no depth cap, no ellipsis. A forty-element vector prints as forty elements.

Praxis crash> p squares.len()
40
Praxis crash> p squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225, 256, 289, 324, 361, 400, 441, 484, 529, 576, 625, 676, 729, 784, 841, 900, 961, 1024, 1089, 1156, 1225, 1296, 1369, 1444, 1521]

There is no truncating mode to turn on and no deeper-inspection command to escalate to: heap differs from p only by the type prefix. If you want less, ask for less — p xs.len(), p xs[0], p xs.sorted()[0].

The one cap that does exist counts locals, not elements, and it applies to the printed diagnostic rather than to the locals command. It is covered in Noninteractive mode.

Cost

Each p, type or heap runs the front end, and p and heap also run a fresh Cranelift compile of a one-function module. At a human-paced prompt this is not noticeable, and the module is dropped when the command returns; the schemas and debug metadata it minted are interned into one shared generation, so a long session does not grow without bound. Values a p allocated stay on the main heap and outlive the module that built them, which is what lets the debugger print the result at all.

Inspecting the input parser

The most common way a puzzle program fails is not a bug in the program. It is that the input did not look the way you thought it did — a semicolon where a comma was promised, a stray letter in a column of digits, a blank line that is not blank. Praxis turns that into a fault of its own kind, input parse mismatch, and the debugger has two commands for it: input, which shows the input around the byte where the parser stopped, and parser, which shows what the parser wanted there.

Both are read-only and neither needs a frame selected; they read a record the runtime keeps of the parse, not the stack.

The fault carries the offset

Here is a program declaring a format that one line of its input does not have.

// Every line of the input is meant to be `x,y`. One of them is not.
var points = read lines(`{x:int},{y:int}`)
out(points.len())
12,7
5,3
9;1
4,4

The fault line already tells you most of it, before any command is typed:

error: program faulted: input parse mismatch
       at input offset 10..11: expected literal ","
       actual: 12,7⏎5,3⏎9;1⏎4,4⏎

Three facts, and they are the three you need. Offset 10..11 is where in the input the parser stopped, in bytes from the start — byte 10 is the ; on the third line. expected literal "," is what the template wanted at that point. And the actual line is a preview of the input around the offset, with newlines drawn as so the whole thing stays on one line.

The two commands print the same information one piece at a time, which is what you want once you have scrolled past the banner:

Praxis crash> input
input at offset 10..11:
  12,7⏎5,3⏎9;1⏎4,4⏎
Praxis crash> parser
expected: literal ","
parser expression: <unknown parser>
Praxis crash> bt
#0   <entry>
  (frame 0 selected)
Praxis crash> quit

parser expression: <unknown parser> is what that command prints for every parse failure. The input-parser interpreter does not carry a parser expression’s source span into the failure, so there is no source text to name there. What parser actually gives you is the expected description, which is the same one the fault line printed. Treat it as a shorthand, not as a second source of information.

Walking it back to the byte

An offset is not a line and a column, and for a real puzzle input you will want one. The preview helps you recognise the neighbourhood; the offset is what locates it exactly.

// One integer per line, twenty of them.
var depths = read lines(int)
out(depths.len())

The input is twenty three-digit numbers, one of which contains a capital O instead of a zero. Nothing about the fault says which:

Praxis crash> input
input at offset 49..51:
  06⏎107⏎108⏎109⏎110⏎111⏎1O2⏎113⏎114⏎115⏎116⏎117⏎1
Praxis crash> parser
expected: the rest of the line
parser expression: <unknown parser>
Praxis crash> quit

Read that carefully, because both halves of it are informative.

The preview is a window, not the input. It is at most 24 bytes on each side of the failure offset, clipped at the ends of the buffer, which is why it begins mid-number at 06. Do not count characters from the left of the preview to find your line; the window’s own start is arbitrary.

The span starts where the parser gave up, and that start is the fact to rely on. Here it is 49..51, two bytes wide, with expected: the rest of the line — the shape of a lines(...) element that matched something and then found the line was not over. int read the 1, wanted the line to end, and found O2 still sitting there, so the span covers what was left over.

The width means something different in each case, so do not read it as “how much matched”. It is zero when the parser matched nothing at all; it is the width of the expected text when a template literal did not match (expected literal "," at 10..11 above is one byte because , is one byte); it is the width of the unconsumed remainder when a line or region was not used up. Only the start is uniformly “the byte the parser was looking at when it stopped”.

To turn the offset into a line, count the newlines before it:

$ head -c 49 docs/book/examples/debugger-b/parse-depths.in | wc -l
      12
$ sed -n 13p docs/book/examples/debugger-b/parse-depths.in
1O2

Twelve newlines precede byte 49, so byte 49 is on line 13, and line 13 is 1O2 — the O is a letter. That is the whole bug, and the parser was right.

The raw input is also in the frame, if you would rather look at it than at a window. The temp that holds the input buffer is listed by locals, and its value is the entire text:

  temps:
    <tmp#1> = 100
101
102
103
104
105
106
107
108
109
110
111
1O2
113
114
115
116
117
118
119

For a real puzzle input that is thousands of lines and you will not want it. For a fixture you are debugging by hand, it is often quicker than switching windows.

The deepest failure is the one reported

A structural parser fails at several levels at once. sections(lines(csv(int))) can fail because a section did not end, because a line did not split, or because a token was not an integer — and the outer failures are always less informative than the inner one. The runtime keeps the failure whose input offset is furthest into the buffer, on the argument that the point at which parsing genuinely broke is the deepest point it reached.

// Blank-line-separated sections of comma-separated integers.
var groups = read sections(lines(csv(int)))
out(groups.len())
1,2,3
4,5,6

7,8,x
Praxis crash> input
input at offset 17..17:
  1,2,3⏎4,5,6⏎⏎7,8,x⏎
Praxis crash> parser
expected: int
parser expression: <unknown parser>
Praxis crash> quit

expected: int at offset 17 — the x, the third field of the second section’s only line — and not “expected a section” at offset 0. The span is zero-width here because int found no digits at all at that byte, which is the clearest kind of parse failure there is: the innermost parser, at the exact byte, saying what it wanted.

Locals during a parse failure

A frame that faulted inside read has a distinctive shape. The binding the read was going to fill is <uninit>, because the parse never produced a value to assign:

  locals:
    points: Vec[{ x: Int, y: Int }] = <uninit>

The type is still there, and it is worth reading. Vec[{ x: Int, y: Int }] is what the template `{x:int},{y:int}` derives — a vector of anonymous two-field records — and if that is not the type you expected, the parser you wrote is not the parser you meant, whatever the input says. See how a parser gets its type.

When there is nothing to inspect

The two commands are only meaningful for a parse failure. Every other fault kind gets a note saying so, including in a program that reads its input successfully and then fails at something else:

// The input parses. The fault comes later, from the program.
var depths = read lines(int)
out(depths[99])
Praxis crash> input
(no input context — not a parse failure)
Praxis crash> parser
(no parser context — not a parse failure)
Praxis crash> quit

That is a useful negative result and worth typing early: it tells you the input matched the parser, so whatever went wrong is in the program. The rest of the debugger — bt, locals, p — is where you go next.

What the fault does not tell you

Three limits, stated plainly so you do not go looking.

There is no partial value. A parse either produces its whole result or produces nothing, so the binding is <uninit> and there is no half-built structure behind it. You cannot ask for the two lines that did parse.

There is no caret. The compiler underlines a span in your source; input prints an offset and a preview, and you locate the column yourself.

And there is no parser span, as above — parser expression: <unknown parser>, every time. For a program with one read this costs nothing. For a program with several, the expected description is what tells them apart.

A walkthrough

This chapter is one session, start to finish: a puzzle program that runs, faults, and gets fixed at the prompt it faulted into. Nothing in it is arranged for effect. The program is the obvious first draft, the bug is the one that draft has, and every transcript below is what the debugger printed when the session was run.

The program

Sonar sweep, part two. You are given a column of depth measurements and asked how many three-measurement sliding windows sum to more than the previous window’s sum.

// Sonar sweep, part two: count the three-measurement windows whose sum is
// larger than the previous window's.
fn window(depths: Vec[Int], i: Int) -> Int {
    depths[i] + depths[i + 1] + depths[i + 2]
}

fn count_increases(depths: Vec[Int]) -> Int {
    var larger = 0
    var i = 1
    while i < depths.len() {
        if window(depths, i) > window(depths, i - 1) {
            larger = larger + 1
        }
        i = i + 1
    }
    larger
}

var depths = read lines(int)
out(count_increases(depths))

The input is the ten-line sample:

199
200
208
210
200
207
240
269
260
263

Run it in a terminal — praxis run walkthrough.px --input walkthrough.in — and it faults. Stdin and stdout are a terminal, so the default --debug auto puts you at the prompt without your asking for it. (--debug always forces it when they are not; see entering the debugger.)

The banner

Before the prompt appears, the debugger prints what the noninteractive mode would have printed: the fault, the backtrace, and the innermost frame’s state.

error: program faulted: index out of bounds

Backtrace:
#0   window
#1   count_increases
#2   <entry>

  locals:
    depths: Vec[Int] = [199, 200, 208, 210, 200, 207, 240, 269, 260, 263]
    i: Int = 8
  temps:
    <tmp#3: Int> @ "depths[i]" = 260
    <tmp#4: Int> @ "1" = 1
    <tmp#5: Int> @ "i + 1" = 9
    <tmp#6: Int> @ "depths[i + 1]" = 263
    <tmp#7: Int> @ "depths[i] + depths[i + 1]" = 523
    <tmp#8: Int> @ "2" = 2
    <tmp#9: Int> @ "i + 2" = 10
    <tmp#10: Int> @ "depths[i + 2]" = <uninit>
    <tmp#11: Int> @ "depths[i] + depths[i + 1] + depths[i + 2]" = <uninit>
Entered crash debugger. 3 frame(s). Type `help` for commands.

An honest reading of that, before touching anything: an index went out of bounds, in window, with i at 8 and a ten-element vector. The last three temporaries say where it stopped. <tmp#9> @ "i + 2" = 10 computed the index 10; <tmp#10> @ "depths[i + 2]" is the indexing itself and is <uninit>, because it faulted and never produced a value; and <tmp#11>, the three-term sum that would have consumed it, is <uninit> for the same reason.

The temps are why the banner is worth reading rather than skipping. Most are labelled with the source expression that produced them as well as the value they hold, so the frame is not just “these variables” but “this arithmetic, and how far it got”. The last temp with a real value is the last thing that worked.

Confirming it

Start from bt anyway. It costs a line and it tells you which frame the selection is on.

Praxis crash> bt
#0   window
#1   count_increases
#2   <entry>
  (frame 0 selected)

Frame 0 is window, the innermost. locals re-prints what the banner showed — and, unlike the banner, prints all of it, with no twelve-local cap.

Praxis crash> locals
  locals:
    depths: Vec[Int] = [199, 200, 208, 210, 200, 207, 240, 269, 260, 263]
    i: Int = 8
  temps:
    <tmp#3: Int> @ "depths[i]" = 260
    <tmp#4: Int> @ "1" = 1
    <tmp#5: Int> @ "i + 1" = 9
    <tmp#6: Int> @ "depths[i + 1]" = 263
    <tmp#7: Int> @ "depths[i] + depths[i + 1]" = 523
    <tmp#8: Int> @ "2" = 2
    <tmp#9: Int> @ "i + 2" = 10
    <tmp#10: Int> @ "depths[i + 2]" = <uninit>
    <tmp#11: Int> @ "depths[i] + depths[i + 1] + depths[i + 2]" = <uninit>

Now ask the two numbers the fault is about, and then reproduce it:

Praxis crash> p i
8
Praxis crash> p depths.len()
10
Praxis crash> p depths[i + 1]
263
Praxis crash> p depths[i + 2]
error: expression faulted: index out of bounds

That is the whole diagnosis of what happened. depths[i + 1] is the last element; depths[i + 2] is one past the end and faults on demand, in the debugger, from the same values the program had. A p expression runs against the crash snapshot’s own locals, so this is not a re-derivation — it is the faulting expression, run again.

Two more cheap checks. The type is what you assumed:

Praxis crash> type depths
Vec[Int]

and source shows the frame’s function, so you do not have to go and find it:

Praxis crash> source
window:
  <debug>:3:1
  3 | fn window(depths: Vec[Int], i: Int) -> Int {
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^...
  4 |     depths[i] + depths[i + 1] + depths[i + 2]
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^...
  5 | }
    | ^

The span is the whole function, not the faulting line, and the carets are clamped to each line — source answers “which function is this frame”, not “which expression faulted”. The temps answered the second question already.

Finding the caller that is actually wrong

window is not the bug. window(depths, 8) on a ten-element vector is a perfectly reasonable thing to refuse; the question is who asked. Go up a frame.

Praxis crash> up
frame 1: count_increases
Praxis crash> locals
  locals:
    depths: Vec[Int] = [199, 200, 208, 210, 200, 207, 240, 269, 260, 263]
    larger: Int = 5
    i: Int = 8
  temps:
    <tmp#2: Int> @ "0" = 0
    <tmp#4: Int> @ "1" = 1
    <tmp#6: Int> @ "depths.len()" = 10
    <tmp#7: Bool> @ "i < depths.len()" = true
    <tmp#8: Int> @ "window(depths, i)" = 792
    <tmp#9: Int> @ "1" = 1
    <tmp#10: Int> @ "i - 1" = 6
    <tmp#11: Int> @ "window(depths, i - 1)" = 769
    <tmp#12: Bool> @ "window(depths, i) > window(depths, i - 1)" = true
    <tmp#13: Unit> = Unit
    <tmp#14: Int> @ "1" = 1
    <tmp#15: Int> @ "larger + 1" = 5
    <tmp#16: Unit> = Unit
    <tmp#17: Unit> = Unit
    <tmp#18: Int> @ "1" = 1
    <tmp#19: Int> @ "i + 1" = 8
    <tmp#20: Unit> = Unit
    <tmp#21: Unit> @ "while i < depths.len() { if window(depths, i) > window(depths, i - 1) { larger = larger + 1 } i = i + 1 }" = <uninit>

The line that answers it is in there:

    <tmp#7: Bool> @ "i < depths.len()" = true

The loop guard evaluated to true on the iteration that faulted. That is the bug stated in one line — the guard let i = 8 through, and window needs i + 2 to be a valid index. The right-hand side of the guard is one temp above: depths.len() is 10, so the guard admits i up to 9, and the largest i the body can survive is 7.

Ask for the bound the loop should have had:

Praxis crash> p depths.len() - 2
8

Eight — which is exactly the i that faulted. i < depths.len() - 2 would have stopped one iteration earlier.

Note also larger: Int = 5. The counting was finished before the crash: the program had the right answer and then walked off the end anyway. That is a common shape for an off-by-one, and a reason not to trust “it printed the right number for the sample” as evidence of anything.

One more command, to close off the other explanation. The program reads its input, so it is worth ruling the input out:

Praxis crash> input
(no input context — not a parse failure)

The input parsed. Whatever is wrong is in the program. (When it is not, the parser commands are where the session goes instead.)

And because a bug that only happens sometimes is a different bug, confirm it is deterministic:

Praxis crash> restart
program faulted: index out of bounds
3 frame(s); frame 0 selected.
Praxis crash> bt
#0   window
#1   count_increases
#2   <entry>
  (frame 0 selected)

restart re-ran the same compiled code against the same input and got the same fault, with a fresh snapshot and the frame cursor back at 0.

Fixing it without leaving the prompt

The fix is four characters: while i < depths.len() - 2.

    while i < depths.len() - 2 {

Edit the file in your editor — the debugger holds no lock on it — and type reload. It re-reads the source from disk, recompiles it, and, if the compile succeeds, discards the old machine code and snapshot and re-runs against the same input the first run was given:

Praxis crash> reload
5
program completed: Unit
Praxis crash> quit

The 5 is the program’s own output, on stdout, and it is the right answer. program completed: Unit is the debugger saying the run finished without a fault and that the entry point returned Unit. You are still at the prompt — a clean run does not exit the debugger — so quit is how the session ends.

If the edit does not compile, nothing is lost:

Praxis crash> reload
error: parse error: expected `)`, found unexpected token
(session unchanged — the old snapshot is still active)
Praxis crash> p i
8

The old snapshot, the old locals and the old p are all still there; fix the edit and type reload again. The same holds for a type error:

Praxis crash> reload
error: type error: expected Text, found Int
(session unchanged — the old snapshot is still active)

The fixed program

For completeness, the program that comes out the other end:

// Sonar sweep, part two: count the three-measurement windows whose sum is
// larger than the previous window's.
fn window(depths: Vec[Int], i: Int) -> Int {
    depths[i] + depths[i + 1] + depths[i + 2]
}

fn count_increases(depths: Vec[Int]) -> Int {
    var larger = 0
    var i = 1
    while i < depths.len() - 2 {
        if window(depths, i) > window(depths, i - 1) {
            larger = larger + 1
        }
        i = i + 1
    }
    larger
}

var depths = read lines(int)
out(count_increases(depths))
5

What that session cost

Fifteen commands and one edit, and at no point did the program have to be re-run with a print statement in it. That is the trade the crash debugger is making: a fault is not a report about a program that has gone, it is a program that has stopped, with its values still in the heap and its frames still readable.

The three habits worth taking from it:

  • Read the temps. They carry the source expression that produced each value. The last temp with a value is where the computation got to, and the first one without is the operation that failed.
  • Reproduce the fault with p. One line, against the real values. If it does not fault, your theory is wrong.
  • Go up. The innermost frame is where the fault fired, and it is very rarely where the mistake is.

Noninteractive mode

A prompt is no use to a build server. When there is nobody there to type, praxis run prints the same information the debugger would have shown you on arrival — the fault, the backtrace, and the faulting frame’s locals — and exits 1.

That happens in two cases: you passed --debug never, or you left --debug at its default of auto and either stdin or stdout is not a terminal. A pipe, a redirect, a CI runner and a test harness all land in the second case without being asked to, which is the point.

--debugon a fault
auto (default)enter the prompt iff stdin and stdout are a terminal
alwaysenter the prompt regardless
nevernever enter the prompt; print the diagnostic and exit

The same three rows govern a :bp breakpoint, where the declining case prints the frame and then keeps running — a marker in a script is a trace point rather than a prompt nobody is there to answer.

What gets printed

// A fault two frames deep, with something worth reading in each frame.
fn mean(xs: Vec[Int], n: Int) -> Int {
    xs.sum() / n
}

fn summarize(xs: Vec[Int]) -> Int {
    var kept = xs.filter(|x| x > 100)
    mean(kept, kept.len())
}

var readings = [3, 9, 27]
out(summarize(readings))
$ praxis run nonint-fault.px --debug never
error: program faulted: division by zero

Backtrace:
#0   mean
#1   summarize
#2   <entry>

  locals:
    xs: Vec[Int] = []
    n: Int = 0
  temps:
    <tmp#3: Int> = 0
    <tmp#4: Int> = 0
    <tmp#7: Int> @ "xs.sum() / n" = <uninit>
$ echo $?
1

Four parts, in order:

  1. The fault line. error: in the same red the compiler uses for an error, then the fault kind. --color never turns the colour off, --color always forces it on; auto colours iff stderr is a terminal.
  2. The backtrace, innermost first, one line per frame. <entry> is the synthetic function a file’s top-level statements compile into.
  3. The innermost frame’s locals, split into the bindings you wrote and the compiler’s temporaries. Most temps carry the source expression that produced them, as @ "expr", alongside the value they hold; one the lowering minted with no expression of its own — <tmp#3> and <tmp#4> above — shows the value alone. <uninit> means the slot was never written, so the last few temps trace out exactly how far the faulting expression got before it stopped.
  4. Nothing about the other frames. Only frame 0’s state is printed; if you need frame 1’s, you need the prompt.

All four go to stderr. Whatever the program wrote before it faulted is already on stdout and stays there:

// Output written before the fault is already on stdout; the diagnostic is on
// stderr. A script that captures them separately keeps both.
var totals = [10, 20, 30]
out(totals[0])
out(totals[1])
out(totals[5])
$ praxis run nonint-partial.px --debug never
10
20
error: program faulted: index out of bounds
...

panic and assert

For those two fault kinds the message is the diagnosis, so it is appended to the fault line rather than buried:

// `panic` and `assert` put their message on the fault line itself.
var node = "start"
var visited = [1, 2]
panic("no route from " + node)
error: program faulted: panic: no route from start

An assert that fails prints error: program faulted: assertion failed and carries no message — assert takes one argument.

A parse failure adds two lines

When the fault kind is input parse mismatch, the input offset and the expectation are printed under the fault line, before the backtrace:

error: program faulted: input parse mismatch
       at input offset 10..11: expected literal ","
       actual: 12,7⏎5,3⏎9;1⏎4,4⏎

That is the whole of what the input and parser commands would have told you, which makes a parse failure the one fault kind you can usually diagnose without the prompt. Inspecting the input parser covers reading it.

The twelve-local cap

The printed diagnostic shows at most twelve locals per frame, user bindings first, and counts the rest. It is a glance, not a dump.

// Fourteen bindings; the noninteractive render shows twelve and counts the rest.
var alpha = 1
var bravo = 2
var charlie = 3
var delta = 4
var echo = 5
var foxtrot = 6
var golf = 7
var hotel = 8
var india = 9
var juliett = 10
var kilo = 11
var lima = 12
var mike = 13
var november = 14
out(alpha / (mike - 13))
error: program faulted: division by zero

Backtrace:
#0   <entry>

  locals:
    alpha: Int = 1
    bravo: Int = 2
    charlie: Int = 3
    delta: Int = 4
    echo: Int = 5
    foxtrot: Int = 6
    golf: Int = 7
    hotel: Int = 8
    india: Int = 9
    juliett: Int = 10
    kilo: Int = 11
    lima: Int = 12
  …(20 more)

Twelve shown, twenty hidden: mike, november, and eighteen temporaries. Note that user bindings get priority — you never lose a variable you wrote to a temp.

The cap applies to the printed diagnostic only. The interactive locals command has no limit, which is one concrete reason to re-run a fault under --debug always when the twelve were not the twelve you wanted. There is an uncapped locals in the walkthrough: frame 1 prints three bindings and eighteen temps in one go.

Values themselves are never truncated, in either mode. A thousand-element vector prints as a thousand elements; see Evaluating expressions.

Backtraces are not capped either, and one fault kind makes that visible. A runaway recursion faults when it has spent its native-stack budget. The budget is denominated in bytes and each call is charged for its own width, so an ordinary recursive function gets about eight thousand frames and a function with many live collections per frame gets fewer. Every one of those frames is in the backtrace. A function that recurses past its budget

fn depth(n: Int) -> Int {
    if n == 0 { 0 } else { 1 + depth(n - 1) }
}

out(depth(100000))

faults with stack overflow (recursion limit), and the backtrace under it is eight thousand lines long: 7999 depth frames and the <entry> beneath them. Pipe the run through head and read the first few; the interesting ones are the handful at the very bottom, where the recursion started.

Exit codes

codemeaning
0the program ran to completion
1a compile error, or a runtime fault
2the source file or the --input file could not be read

A fault exits 1 whether the diagnostic was printed noninteractively or the prompt was entered and left — --debug always followed by quit (or by EOF on stdin) still exits 1. There is no exit code that distinguishes “faulted” from “did not compile”; if a script needs to tell them apart, run praxis check first, which exits 1 only on a compile error.

Using it in a script

The shape that works is: let the fault print, keep both streams, and let the exit code decide.

$ praxis run solve.px --input day01.txt --debug never --color never \
    > answer.txt 2> fault.txt
$ test -s fault.txt && cat fault.txt

--debug never is worth passing explicitly even though auto would do the same thing under a redirect: it makes the intent local to the command, and it does not change behaviour if somebody later runs the script from a terminal. --color never keeps ANSI escapes out of the captured file.

The diagnostic is written to stderr line by line and is short — the whole thing for a typical fault is under twenty lines — so capturing it in a CI log costs nothing and is usually enough to identify the failure without re-running.

restart and reload

These two are the prompt’s, not the noninteractive path’s, but they belong here because they are the mechanism a faulted session uses to become a working one.

restart re-runs the same compiled machine code. reload re-reads the source file from disk, recompiles it, and then re-runs. Both keep the input bytes the first run was given and the path they came from, and that is the whole of what is retained — no command changes how a value prints, so there is no display state to carry across.

Both clear the fault, the crash snapshot and the parse detail before the run, so a second fault captures a fresh frame chain and leaves you at the prompt with the frame cursor back at 0.

// `restart` reruns the same machine code; `reload` recompiles the file first.
// Both keep the input the first run was given.
var budget = read int
var spend = [40, 40, 40]
var left = budget
for s in spend {
    left = left - s
}
out(100 / left)
Praxis crash> p left
0
Praxis crash> restart
program faulted: division by zero
1 frame(s); frame 0 selected.
Praxis crash> p left
0
Praxis crash> reload
program faulted: division by zero
1 frame(s); frame 0 selected.
Praxis crash> p budget
120
Praxis crash> quit

Both re-faulted, because nothing changed between them — the source on disk is the same source. The line that carries information is the last one: p budget answers 120, the number the first run read from the input file. The input was retained across two re-executions and re-fed to read int each time, which is what makes restart a controlled experiment rather than a coin toss.

Three details that matter in practice.

A clean run does not exit. When a re-run finishes without faulting, the debugger prints program completed: <value> and stays at the prompt. Its stdout goes to stdout as usual. The walkthrough ends this way. The snapshot from the run that faulted is still the one you are inspecting — there is no new one to replace it, so bt and p keep answering about the old frames.

A failed reload changes nothing. If the recompile produces a parse error or a type error, the diagnostic is printed, the old JIT code and the old snapshot stay live, and the session continues exactly as it was:

Praxis crash> reload
error: parse error: expected `)`, found unexpected token
(session unchanged — the old snapshot is still active)
Praxis crash> p i
8

New code is swapped in only after the compile has succeeded, so there is no window in which the session is half-reloaded.

“Same input” means the bytes the first run read. If the program faulted before it ever evaluated a read, nothing was read, and the retained input is empty — a reload whose edit moves the read earlier will see empty input rather than the original stdin, which by then is at EOF. With --input FILE this cannot happen: the file was read eagerly, before the program started.

restart and reload are the only commands that re-run the program, and neither of them resumes the faulted run. There is no continue, no step and no way to change a value and go on. A fault is terminal; what the debugger gives you is the state it left behind, and a fast way to try again.

Editor support

Praxis ships a language server in the same binary as the compiler. praxis lsp speaks JSON-RPC over stdin and stdout, and everything an editor knows about a .px file comes from it — the diagnostics are the ones praxis check prints, the types are the ones inference derived, the method list is the catalog dispatch searches. There is a thin VS Code extension in editors/vscode/ that launches it; any editor with an LSP client can do the same.

Praxis has no formatter. The language server does not advertise documentFormattingProvider, and it does not advertise the range or on-type variants either, so Format Document leaves your editor doing whatever it would have done by itself.

Starting it

$ praxis lsp

It reads framed LSP messages on stdin and writes them on stdout, so running it by hand gets you a process waiting for a Content-Length header. Point your editor’s LSP client at that command with no arguments. --stdio is accepted and ignored — several clients append it to the server’s argv to select a transport, and stdio is the only transport this server has, so refusing the flag would look like a crash before a byte of protocol was spoken.

In VS Code, install the extension (see The extension below) and set praxis.binaryPath to your praxis binary, or put it on PATH.

The process is a single synchronous loop on one thread. There is no async runtime, no worker pool, and no lock, because the whole working set is one file and a rowan syntax tree cannot cross a thread anyway: only the green tree is shareable, so a worker would have to re-root a cursor before it could answer a question about a tree it cannot hold. A $/cancelRequest that arrives while an earlier request is being served drops the queued request; a request already running finishes.

What it serves

This is exactly the capability set the server reports at initialize. Nothing is advertised that the server does not serve: an editor that is told the server handles something stops offering its own behaviour for it, so a capability claimed and not delivered is worse than one that is visibly missing.

RequestWhat you get
textDocument/publishDiagnosticsEvery error praxis check would print, after a 150 ms debounce
textDocument/hoverThe inferred type; a method’s signature and its one-line documentation; a parser constructor’s signature and result type
textDocument/completionReceiver methods and record fields after ., enum variants in a pattern, parser atomics and constructors inside a read, lexical names elsewhere
textDocument/signatureHelpThe callee’s signature and which parameter the cursor is in
textDocument/definitionThe declaration site of the name under the cursor
textDocument/documentSymbolTop-level fn, struct, enum and var, with fields and variants nested under them
textDocument/referencesEvery use of that binding — not every occurrence of the word
textDocument/rename and prepareRenameA whole-file rename, or a refusal that says what it would have broken
workspace/symbolThe same symbols across every .px file under the workspace roots
textDocument/inlayHintThe type of every binding the source does not annotate
textDocument/codeActionThe quick fixes carried by the diagnostics in the requested range
textDocument/semanticTokens/fullFourteen token classes, four of them for the input-parser sublanguage

Text is synchronized incrementally, and positionEncoding is negotiated: the server picks UTF-8 when the client offers it and falls back to UTF-16, the protocol’s default, when it does not. The conversion happens in one module at the protocol boundary, so nothing below the language server has an opinion about what a UTF-16 code unit is; a span is a byte range everywhere else. That is the difference between an underline that lands correctly on a line holding an é and one that lands two columns early.

Everything is scoped to one file. workspace/symbol is the only query that reads the disk, and it parses rather than analyzes.

Diagnostics

The editor’s underlines and praxis check’s output come from the same query layer. praxis check does not have a pipeline of its own: it builds a snapshot, asks it for diagnostics, and renders them. So the set, the order and the decision to analyze a tree that already has parse errors are stated once and read by both. A diagnostic you can see in the editor and not on the command line is not a thing that can happen.

Reports are published after a 150 ms pause in typing. The debounce is there so a half-typed . does not flash an error the next keystroke retracts.

Every code the server can publish is listed in Diagnostic codes. All of them are errors: the compiler emits no warnings, so an underline in your editor is never advisory.

Hover

Hover prefers the innermost thing it can name. Inside a read body that is the parser expression, because every other map is silent in there; then a method name; then a name reference or its declaration; then a name in type position; then the innermost expression with a recorded type.

A method hover is the catalog row dispatch selected, so the signature shown is the one the compiler will use, and the sentence under it is the catalog’s own:

Vec[Int].sum() -> Int

Sum the (Int) elements.

A parser constructor hovers as its signature and its documentation, followed by the type the whole expression synthesizes and what that type is the type of:

lines(parser) -> Vec[T]

Split the region into lines and apply the parser to each. Every line must be consumed whole.

Vec[Int]

input parser result

A prelude name keeps its scheme and gains the prelude’s own sentence, which for the graph helpers is most of what there is to know — the scheme names two type variables and does not say that the closure is the graph:

bfs: forall T. (T, (T) -> Vec[T]) -> Vec[T]

Breadth-first walk: `bfs(start, |s| neighbors(s))` answers every state reached, in the order it was reached.

A name in type position hovers too, and answers with what the type is:

Int

Signed 64-bit integer. Written `42` or `1_000_000`.

built-in type

Both sentences come from crates/praxis-stdlib/src/prelude.rs, the table name resolution seeds the root scope from — so a prelude name the compiler declares and a prelude name the editor can describe are the same list.

A binding that shadows one of these is described as itself. var out = 1 hovers as out: Int with no sentence under it: the lookup is by symbol, and a prelude symbol is the one with no declaration site. A lookup by spelling would put “Write one value to stdout” under a local that does nothing of the kind.

Completion

The context is decided before the list is built, and the order of the tests is the order of specificity: a . beats everything, then the parser sublanguage, then a record literal, then a match pattern, then the lexical fallback.

After a ., the receiver’s type is read from what inference already recorded for the expression to the left of the dot — rows. does not parse as an expression, but rows does, and that is enough. Fields come first, then every catalog method whose receiver pattern matches, each carrying its signature as the item’s detail and the catalog’s sentence as its documentation — so rows. on a Vec[Int] offers push as (T) -> Unit, len as () -> Int, get as (Int) -> T, map as ((T) -> U) -> Vec[U], and so on down the catalog.

The filter is pattern_matches — the same function method dispatch calls, not a restatement of it — so a method the list offers is a method the call will resolve. The index operators ([], []=, []min=, []max=) are catalog rows too and are excluded, because grid.[] is not syntax.

Inside a parser expression you get the atomics and the structural constructors, each with its own description as documentation, plus the enclosing constructor’s own keyword argument (skip: for chars, fill: for grid) and grid’s ragged flag. Those come from Constructor::keyword_arg, so a constructor added to the language is offered without anybody updating a list.

The lexical fallback offers what is in scope, and that is mostly the stdlib: thirty-one prelude names and seven built-in type names against however many the file declares. Each carries its description, and a type name — which has no scheme, because nothing instantiates Int — says type as its detail. A match over an Option offers Some and None with the prelude’s sentences; a user enum that happens to spell a variant Some gets nothing, because the description belongs to Option and not to the word.

Trigger characters are ., `, { and : — the last three because completion inside a template fires on text that is not yet an expression.

Signature help

Two kinds of callee. An ordinary call or method call answers with the scheme inference gave it or the catalog entry dispatch selected; a parser constructor answers from the constructor table’s own argument shapes — so a constructor added to the language has a signature without anybody writing one, and the cursor inside read lines(…) gets back lines(parser) -> Vec[T].

Each of the three carries its documentation, and this is where it is worth the most: clamp’s parameters render as Int, Int, Int and a_star’s as four bare closure types, so which one is the low bound and which is the heuristic is precisely what the labels cannot say. A constructor with two forms carries it on both, rather than on whichever the editor preselects.

The active parameter is counted from the top-level commas before the cursor, so it is the parameter you are actually typing. A comma nested inside another call’s arguments belongs to that call and is not counted here.

Definition, references and rename all start from the same lookup: the symbol the word denotes, not the word. Two shadowed bindings share a spelling and have distinct symbols, so asking about one never returns the other’s uses — which is the property a text search cannot have. references honours the client’s includeDeclaration flag rather than ignoring it.

workspace/symbol walks the workspace folders for .px files, skipping target/, node_modules/ and dotted directories, capped at 2000 files and 16 levels deep. There is no persistent index: the walk runs per query, because an AoC workspace is tens of small files and a cache would need file-system events the server would then have to be right about. An open buffer beats the file on disk, so a name you just deleted is not offered. With no workspace folders at all the picker answers from the open documents, which is what VS Code’s single-file mode needs.

Semantic tokens

Full-document only. The legend has fourteen entries: the ten ordinary ones (keyword, type, function, method, variable, parameter, property, enumMember, number, string) and four for the input parser — parserConstructor, parserTemplateText, parserCaptureName and parserCaptureType.

The four parser classes are read from the compiler’s own spanned index of the parser expression. Where a capture’s name stops and its type begins is something only the compiler knows, and a second scanner in the language server would be free to disagree with it. Parser tokens are collected first and win every overlap, because a backtick template is one token to the lexer and four to the editor.

Inlay hints

Hints are on, and the rule is one line: every binding whose type the source does not already state. A fn parameter, a closure parameter, a var, a for variable and a name a pattern introduces are all the same thing — a name bound to a value — and they are all in Analysis::decls, which is where the rule is read from. One hint belongs to no binding: a read or parse expression that nothing binds carries its result type at the end of the expression, because there is no name to hang it on.

fn add(a, b) { a + b }

var total = 0
for n in [1, 2, 3] {
  total = add(total, n)
}
out(total)
6

In the editor that file reads as fn add(a: Int, b: Int), var total: Int = 0 and for n: Int in [1, 2, 3].

Two details worth knowing. A type that is still a variable shows as one rather than being hidden — as T where the enclosing fn’s scheme quantifies it and as ?T where nothing does, which is the same spelling hover and praxis check use — because hiding it would make “no hint” mean both the source already says this and nothing named this. And a hint carries an edit that writes the annotation into the file only where the annotation is legal and spellable: a for variable has no annotation syntax, and neither a type variable nor an anonymous record is something the parser would read back. Those hints show and cannot be accepted, which beats an edit that does not compile.

The server has no setting to turn them off. editor.inlayHints.enabled is the editor’s, and a second switch would be a second place for the answer to live.

Code actions

A quick fix is a diagnostic’s machine-applicable suggestion, and it is written by the pass that found the mistake — not by a table of common errors kept in the language server. Such a table is a second opinion about which constructors exist and which variants a match is missing, held by the component least able to test it. The whole of code_action.rs is the twenty lines that turn a suggestion with a replacement into a WorkspaceEdit. It knows nothing about any particular diagnostic, and a suggestion with no replacement stays advice: it is already in the message as a help: line, and an action that changes nothing is a menu entry that does nothing.

The consequence is that every fix the editor offers is one praxis check also prints. Four families carry one.

An unknown parser constructor, atomic or capture kindI013, I010, I012. The atomic table and the constructor table are searched as one list, because the two are one thing to you: the word after read or inside {…}.

var rows = read line(int)
out(rows.len())
error[I013]: unknown parser constructor `line`

  quick-fix-constructor.px:1:17
  1 | var rows = read line(int)
    |                 ^^^^ unknown parser constructor `line`

help: did you mean `lines`?
      lines

praxis: 1 error(s)

The action is titled Did you mean lines? and replaces the four characters the caret sits under. The report points at the constructor’s name rather than the whole call, because a fix replaces what the report underlines.

An unknown nameN001 — against the scope chain the resolver was holding when the lookup failed:

var counts = [1, 2, 3]
var total = 0
for n in counts {
  total += n
}
out(totl)
error[N001]: `totl` is not defined

  quick-fix-name.px:6:5
  6 | out(totl)
    |     ^^^^ `totl` is not defined

help: did you mean `total`?
      total

praxis: 1 error(s)

An unknown methodY110 — against the catalog rows dispatch would have searched, so the offered call is one that would resolve:

var xs = [3, 1, 2]
out(xs.sortd())
error[Y110]: no method `sortd` on type `Vec[Int]` taking 0 argument(s)

  quick-fix-method.px:2:8
  2 | out(xs.sortd())
    |        ^^^^^ no method `sortd` on type `Vec[Int]` taking 0 argument(s)

help: did you mean `sorted`?
      sorted

praxis: 1 error(s)

A non-exhaustive matchY120 — from the same witnesses the message names:

enum Dir { North, South, East, West }

fn step(d: Dir) -> (Int, Int) {
  match d {
    North => (0, -1)
    South => (0, 1)
  }
}

out(step(North))
error[Y120]: non-exhaustive match: missing `East`, `West`

  quick-fix-match.px:4:3
  4 |   match d {
    |   ^^^^^^^^^ non-exhaustive match: missing `East`, `West`...
  5 |     North => (0, -1)
    | ^^^^^^^^^^^^^^^^^^^^...
  6 |     South => (0, 1)
    | ^^^^^^^^^^^^^^^^^^^...
  7 |   }
    | ^^^

help: add the missing match arms
          East => panic("todo")
          West => panic("todo")

praxis: 1 error(s)

One threshold decides when a near miss is near enough, for all four: an edit distance within max(1, len / 3), counted in characters so a non-ASCII identifier is not silently excluded. It refuses more than a reader expects — v.lenght() gets no fix, because lenght is three edits from len — and that is the rule working. At a budget wide enough to catch it, abc starts suggesting xyz.

Actions are computed from the server’s own diagnostics at the current revision, not from the context.diagnostics a client echoes back: those are from whatever version the client last received, and an edit computed against text that has since changed lands on the wrong bytes.

Rename

A rename is accepted when applying it changes nothing but the spelling. The server writes the edit into a copy of the file, analyzes the copy, and requires name resolution to come out the same: every reference resolving to the symbol it resolved to before, and no diagnostic code’s count going up.

The alternative would have been a list of collision kinds, and the argument against it is that nobody can be sure they finished the list. Asking the resolver directly covers the cases somebody would have written down and the ones they would not — including a reference to another binding of the new name that starts resolving to the renamed one.

A refusal comes back as a request error carrying a sentence, because a client shows an error and silently ignores an empty edit. Take the seven-line program from Inlay hints above, and put the cursor on its total. Renaming it to out:

renaming to `out` would change what `out` on line 7 refers to

Renaming it to add:

renaming to `add` would change what `add` on line 5 refers to

Renaming it to a keyword:

`match` is a keyword

Renaming it to sum is accepted, and rewrites all four occurrences.

Spelling is checked first, against the lexer’s own keyword table and its own identifier rule, so a keyword added to the language later is refused here without anybody remembering to update the server.

Two consequences. A rename costs one extra full analysis — a few milliseconds on a puzzle-sized file, for an operation you perform by hand and wait for. And it is conservative in one direction on purpose: renaming a binding to a typo somebody wrote elsewhere is refused, because the other name would start resolving to this binding, and that is a capture whether or not you meant it.

prepareRename refuses a position whose symbol has no declaration site. A prelude name like out or Vec is declared in the compiler, and renaming it in one file would rename nothing.

The extension

editors/vscode/ holds a VS Code extension that is intentionally thin. It registers .px, provides comment/bracket/indent configuration, launches praxis lsp, ships a TextMate grammar, and exposes three commands. There is no parsing and no type logic in TypeScript — everything the editor knows comes from the compiler over the protocol, so the two cannot disagree.

CommandWhat it runs
Praxis: Run Filepraxis run <file>, with --input input.txt appended when that file sits beside the source
Praxis: Check Filepraxis check <file>
Praxis: Restart Language ServerStops and relaunches the server process

The first two run in an integrated terminal rather than an output channel, because the crash debugger is interactive and an output channel cannot answer a prompt. The document is saved first: praxis reads the file from disk, so running an unsaved buffer would report on code you are no longer looking at.

Two settings. praxis.binaryPath (default praxis) is the one path every command and the server use; changing it restarts the server, because pointing at a different build is a relaunch and not a reconfiguration. praxis.trace.server turns on JSON-RPC tracing.

To install it, package the directory and load the result:

$ cd editors/vscode
$ npm install && npm run compile && npx @vscode/vsce package
$ code --install-extension praxis-0.1.0.vsix

Then point praxis.binaryPath at your build, or cargo install --path crates/praxis-cli to put praxis on PATH.

The TextMate grammar

Highlighting arrives twice. syntaxes/praxis.tmLanguage.json paints a file instantly — before the server attaches, while it is restarting, and anywhere that does not speak LSP — and semantic tokens refine it once analysis has run.

The two layers must not fight, so they emit the same TextMate scopes: the grammar directly, and the semantic tokens through the extension’s semanticTokenScopes map.

ConstructScope
Parser constructor (lines, grid, …)entity.name.function.parser.praxis
Template literal textstring.quoted.other.template.praxis
Capture name (n in {n:int})variable.other.capture.praxis
Capture type (int in {n:int})support.type.capture.praxis

Where they are allowed to disagree is which identifiers are parser constructors: lines is a constructor inside a parser expression and an ordinary name outside one, and a regular expression can only approximate the region. The disagreement resolves toward the compiler, because semantic tokens win.

A grammar’s keyword list is a copy of the lexer’s that no compiler checks, and the failure mode is invisible — a word quietly stops being coloured and nobody files it. So crates/praxis-cli/tests/grammar.rs reads these JSON files at test time and asserts that every keyword in the lexer’s table, every AtomicKind::keyword() and every Constructor::keyword() appears in the grammar, and that every custom semantic token type maps to a scope the grammar emits. That runs in the ordinary Rust test suite, so checking the extension for drift needs no Node toolchain.

Diagnostic codes

Every problem the Praxis compiler reports carries a code: a category letter and a three-digit number, such as Y001 or I013. The code is a permanent, user-facing identifier. It is allocated once and written in exactly one place in the compiler — a closed enum whose code() method is the only expression in the tree that pairs a category with a number. A number nobody registered has no route into a diagnostic, and a number spent once is never reissued: re-spending one is how an old message and a new one come to answer to the same name.

This chapter is the complete index. Every code below can be produced by the compiler; nothing is reserved for later.

What a diagnostic looks like

var lines = read lines(word)
var total = 0
for line in lines {
  total += line
}
out(total)
error[Y001]: expected Int, found Text

  diagnostic-anatomy.px:4:3
  4 |   total += line
    |   ^^^^^ expected Int, found Text

praxis: 1 error(s)

Five parts, and only the last two are optional:

  • The severity and the code. Always error. The compiler emits no warnings and no notes — every diagnostic in this chapter is fatal to the run.
  • The location, as file:line:column, one-based.
  • The snippet, with a caret run under the primary span.
  • Related spans, when inference connects two distant expressions. They render as extra snippets and arrive in the editor as LSP related information.
  • A help: line, when the compiler has a concrete suggestion. When the suggestion also carries a replacement, the replacement is printed under it — and that is the same value the editor offers as a quick fix.

Diagnostics come out in source order, and one mistake can produce more than one:

struct Point { x: Int, y: Int }

fn shift(p: Point, dx: Int) -> Point {
  Point { x: p.x + dx, z: p.y }
}

var p = shift(Point { x: 1, y: 2 })
out(p.x)
error[Y113]: `Point` literal is missing a field: y

  two-mistakes.px:4:3
  4 |   Point { x: p.x + dx, z: p.y }
    |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `Point` literal is missing a field: y

error[Y114]: `Point` has no field `z`

  two-mistakes.px:4:24
  4 |   Point { x: p.x + dx, z: p.y }
    |                        ^ `Point` has no field `z`

error[Y024]: this function takes 2 argument(s), but 1 were given

  two-mistakes.px:7:9
  7 | var p = shift(Point { x: 1, y: 2 })
    |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^ this function takes 2 argument(s), but 1 were given

praxis: 3 error(s)

praxis check exits 1 when it reported anything and 0 when it did not.

The categories

PrefixCategoryRaised by
T0xxLexThe lexer — a token that cannot be formed
P0xxParseThe parser — a token that cannot appear here
N0xxNameName resolution and declaration checking
Y0xxTypeInference, member lookup and match coverage
I0xxInputThe read/parse parser sublanguage
R0xxRuntimeDeclared, and has no members. Run-time failures are faults, not diagnostics: they carry a fault kind, not a code

The numbers inside a category are not contiguous and are not meant to be. Y09x is internal errors, Y11x member errors and Y12x match errors, and the gaps between those blocks are what keeps them blocks. Y009 is retired: it reported an assignment to a binding that cannot be written, and Praxis has no such binding. Its number stays spent.

Lex — T0xx

CodeMessageWhat it means
T001unterminated block commentA /* with no matching */ before end of file.
T002unterminated backtick templateA backtick template with no closing backtick. Nested templates count, so a ` inside a capture body does not close the outer one.
T003unexpected character in sourceA byte the lexer cannot classify. It becomes an ERROR token and lexing continues.
T004unterminated text literalA " string with no closing quote on its line. Also what an interpolated literal earns when a hole never closes. Interpolation adds no lex code: the lexer pre-scans a literal and splits it into fragments only once it has proved the literal closes on its line with every hole balanced, so one that does not close is a single text token and this code, holes or no holes.
T005invalid escape in text literal / invalid escape in character literalA \ followed by a character that is not a recognized escape. One code, two messages: "…" and '…' read one escape table, which holds \{ and \} as well — a language with two escape tables has two answers to what \n is, and the second answer is always found somewhere worse. Every fragment of an interpolated literal is validated on the same terms as a whole one; a \ inside a hole is not an escape at all, because a hole holds expression tokens.
T006unterminated character literalA ' with no closing quote before the end of its line.
T007a character literal holds exactly one character / empty character literal: `''` names no characterA '…' whose body is not exactly one Unicode scalar. Two messages under one code; the too-long form offers a machine-applicable rewrite to a text literal.

Parse — P0xx

CodeMessageWhat it means
P001expected an expression, expected a pattern, expected a type, expected a parser expression, expected `{` to begin function body, an interpolated text literal is not a pattern; a pattern tests a constant, unexpected token, skipping to recover, …The general “this token cannot appear here”. The message names what the grammar wanted; the parser then recovers and keeps going, so one P001 does not stop the rest of the file being checked.
P002expected `;` or a line break between statements, expected `,` or a line break between match arms, expected `,` or a line break between …Two things run together with no separator. Statements are separated by a newline or a ;; list elements and match arms by a newline or a ,.

Name — N0xx

CodeMessageWhat it means
N000internal: parse tree root is not a SOURCE_FILEAn internal error. It should be unreachable; seeing it is a compiler bug.
N001`{name}` is not definedA name that is not in scope. Carries a did you mean fix when a name in scope is close enough.
N002unknown type `{name}`A type annotation naming a type that does not exist — a typo, or a scalar name from another language (UInt, Double, Str).
N003`{name}` is a value, not a typeA name used in type position that resolves to a value. The name is known; it is the wrong sort of thing.
N004`{name}` is already declared in this scopeOne name declared twice in one scope.
N005`{name}` cannot be declared inside another function, `{name}` cannot be declared inside a functionA fn, struct or enum declared inside a function body. Only a source file’s own statements are a declaration position. The wording differs by whether the nested thing is a function or a type.
N006`{name}` refers to itself, and a self-referring type is not supported, `{name}` refers to itself through `{other}`, and …A struct or enum declaration in a reference cycle, directly or through other declarations. Vec[Node] inside Node is the same cycle: what is missing is the language feature, not the values.
N007`{fn}` cannot use `{name}`: a function does not capture the bindings around it (pass `{name}` as a parameter, or use a closure), … (pass `{name}` as a parameter)A fn body naming a binding declared outside it. A closure captures; a function does not. When the fn is recursive — directly or mutually — the closure half is dropped and a help: line says why: a closure cannot name itself, which is N001.
N008`{name}` is {kind}, so `{name} { … }` does not build a recordA record literal whose head names something that is not a struct — an enum, a value, a builtin.
N009`{name}` is not a keyword; a binding is written with `{replacement}`A keyword the language retired, written where a statement starts. let is the whole table. It is not a misspelling of anything, so it gets an exact fix rather than the near-miss search that answers N001. let is still a legal identifier, which is why the position matters.

N010 is the next free Name code.

Type — Y0xx, the user block

CodeMessageWhat it means
Y001expected {expected}, found {found}Two types that would not unify. The most common diagnostic in the language, and the one that carries a help: line when the fix is explanatory rather than mechanical.
Y002an infinite type would be required hereAn occurs-check failure: a type would have to contain itself, as in unifying a with (a) -> a.
Y003annotation says {annotated}, but use implies {derived}An explicit annotation conflicts with what inference derived from the uses.
Y004values of type `{ty}` cannot be compared with `==`== or != applied to a type with no structural equality — a function value, for instance.
Y005values of type `{ty}` cannot be iteratedA for over something that is not a collection or a range.
Y006values of type `{ty}` cannot be orderedA value used where an ordering is required — a sort, a heap, a < — whose type has none.
Y007`{ctor}` takes {want} type argument(s), but {got} were givenA type constructor in an annotation given the wrong arity: Map[Int], Vec[Int, Text], Option[Int, Text].
Y008duplicate {field|variant} `{name}` A struct or enum declaring one member twice.
Y010values of type `{ty}` do not support this operationA compound assignment (+=, -=, …) whose target is not numeric.
Y011`return` outside a functionA return at the top level of a file.
Y012`{break|continue}` outside a loopA break or continue with no loop to leave. A closure is a function boundary, so a loop outside a closure is not one a break inside it can leave.
Y013`{literal}` is outside the range of `Int`An integer literal too large for a 64-bit signed integer, in an expression or in a pattern.
Y014a value of type `{ty}` can change after it is stored, so it cannot be used as a keyA mutable value used as a Map key or Set element. It would hash to a different bucket than the one holding it once it changed, and the entry would become unreachable. Carries a help: naming what to use instead.
Y015values of type `{ty}` cannot be used in arithmeticArithmetic on a type that has none.
Y016`{op}` is not defined for `{ty}`An operator the language does not define for this operand type. Not a mismatch: both operands agree, and the operation still has no meaning.
Y017a `break` carrying a value needs a `loop`; a `{while|for}` produces `Unit`A break with a value out of a while or for. Only loop is an expression loop; the other two also leave by their condition failing, and there is no value to supply on that path.
Y018`{name}` is generic, so it has no single function value; write `|x| {name}(x)` to fix its type arguments at the callA generic fn used as a value. Monomorphization is driven by call sites and a bare value has none; a closure body is a call site.
Y019values of type `{ty}` have no element `{n}` — only a tuple does, a tuple of {arity} elements has no element `{n}` — its elements are `0` to `{arity-1}`A .n element access on a non-tuple, or past the end of a tuple. One code, two messages: the arity is the useful thing to say when there is one.
Y020values of type `{ty}` cannot be indexed with {n} index(es), … cannot be assigned through {n} index(es), … cannot be updated with `{min=|max=}` through {n} index(es)A subscript a type does not have, in any of three directions. The wrong arity on a receiver that does index is here too: grid[x] where a grid is written grid[x, y]. Three messages because the sets differ — a Text reads through t[0] and has no element store, and a Counter has a store but no updating store.
Y021the left side of an assignment must be a name, a field, or an indexAn assignment whose left side is not a place at all: f() = 1, a + b[0] = 1. A field is a place, so p.x = 1 is fine.
Y022`{name}` is {a builtin|an enum constructor}, so it has no function value; {call it: `{name}()`|write `|x| {name}(x)` to call it}A prelude builtin or an enum constructor named without being called. Y018’s neighbour, one symbol kind over: a monomorphic fn at least has a value, and these have none, so out(pi) and var h = abs name something there is nothing to hold. Which remedy the message names depends on the arity.
Y023a backtick template is a parser expression; write `read` before it, or pass it to `parse(text, ...)`A backtick template written where a value is expected. The parser sublanguage is entered at read or parse(text, …) and nowhere else.
Y024this function takes {expected} argument(s), but {found} were givenA call whose argument count does not match. A name in Praxis has exactly one signature — no overloading, no default parameters — so a count mismatch is never a candidate for some other signature.

Y025 is the next free code in this block. Y009 is retired and is not reissued.

Type — Y09x, internal

CodeMessageWhat it means
Y099internal: inference recorded no type for this {kind} expressionAn internal error: lowering asked for a type inference never recorded. It has its own number so that “did we emit an internal error?” stays a greppable question and never appears in the block a user is told to look up.

Type — Y11x, member errors

CodeMessageWhat it means
Y110no method `{name}` on type `{ty}` taking {n} argument(s), no type has a method `{name}` taking {n} argument(s)A method call that does not resolve. The second message is for the shape where nothing has pinned the receiver and the catalog holds that name at that arity on no receiver at all — printing ?T there would be the least useful half of the sentence. Carries a did you mean fix, drawn from the catalog rows dispatch would have searched.
Y112no field `{name}` on type `{ty}`A field read on a type that does not have it.
Y113`{Type}` literal is missing a field: {name}, `{Type}` literal is missing fields: {names}A record literal that does not initialize every declared field.
Y114`{Type}` has no field `{name}`A record literal or pattern naming a field the type does not have.
Y115field `{name}` is initialized more than once, field `{name}` is matched more than onceA record literal or pattern naming one field twice. In a pattern the second sub-pattern would silently replace the first, so one of the two bindings would never happen.

Y111 stays unallocated: Y110 and Y112 were assigned as “method” and “field” with a gap between them, and closing it now would make the two look like a range they never were. Y116 is the next free member code.

Type — Y12x, match errors

CodeMessageWhat it means
Y120non-exhaustive match: missing {witnesses}A match that does not cover every value. Carries a fix that writes the missing arms, built from the same witnesses the message names.
Y121unreachable match armAn arm an earlier arm already covers entirely.
Y122`{Type}` has no variant `{name}`A pattern naming a variant the scrutinee’s enum does not have.
Y123`{ … }` is not a pattern for `{ty}`, `{name}` is `{ty}`, which has no fields to match, a tuple pattern names two elements or more, `{ … }` cannot tell which record it matches here; name the record (`P { … }`) or annotate the valueA pattern whose shape cannot match: a record pattern against a non-record, a one-element tuple pattern, or an anonymous record pattern in a position where nothing says which record it is.
Y124`{Variant}` in `{Enum}` holds {want} value(s), but this pattern names {got}A variant pattern whose sub-patterns do not fit the payload: more than the variant holds, or a bare name for a variant that holds some. Naming fewer inside parentheses is legal and padded with wildcards, so Some(_) and Some(n) are one test; bare Some is not the third spelling of it. Carries a fix that writes one _ per slot.
Y125a `for` binding must match every item, and {reason} does not, a closure parameter must match every argument, and {reason} does notA pattern that can fail, in a position that has no second arm to fall through to.

Y126 is the next free match code.

Input — I0xx

These come from the read/parse sublanguage: the template scanner, the constructor tables, and the validator that checks a call’s shape before anything is built. See The read expression.

CodeMessageWhat it means
I000malformed parser expression, malformed parser constructor call, malformed `repeated(...)` tailThe lowerer could not read the parser expression at all — the tree it was handed is not one a parser can be built from.
I001a tuple type needs at least 2 elements, got {n}, `{ctor}` takes {want} type argument(s), got {got}, duplicate record field `{name}`, duplicate enum variant `{name}`, too many parser plans registered in one process (limit {n})A parser AST that could not be turned into a type or into a runnable plan.
I010unknown atomic parser `{name}`A word in atomic position that names no atomic parser. Carries a did you mean fix.
I011`{name}` at byte {n} is not a capture name: a capture name is an identifierThe text before the : in a {…} capture is not an identifier.
I012unknown parser `{name}` at byte {n}: no atomic or constructor is spelled that wayThe capture kind after the : in a {…} names neither an atomic nor a constructor. Carries a did you mean fix.
I013unknown parser constructor `{name}`, unknown parser constructor `{name}` at byte {n}A call in parser position whose head names no constructor. The span is the constructor’s name, not the whole call, because a fix replaces what the report underlines. Carries a did you mean fix.
I014`{ctor}` argument {n} is {what}, but {wanted}, `{ctor}` has an argument that is not a parser expression, `{name}:` takes a parser, not a literal value, `{name}:` needs a value, a parser constructor's literal argument must be a text literal, `{ctor}` does not take {what}, `grid`'s ragged form is written `grid(P, ragged, fill: value)` — `ragged` and `fill:` come together or not at allA constructor argument that is the wrong kind of thing, or one the constructor does not take. Every call is checked against the constructor’s own argument shape before a parser is built.
I020named and anonymous captures may not be mixed in one templateOne template with both {n:int} and {int}.
I021duplicate capture name `{name}` in templateOne capture name used twice in a template.
I022`{ctor}` expects {expected}, got {n}, `repeated` expects 1 argument, got {n}A constructor called with the wrong number of arguments.
I023`sep` needs a non-empty separator: an empty one never advancesAn empty separator, which cannot move a cursor.
I024duplicate section field `{name}`, duplicate block field `{name}`A sections or block declaring one field name twice. The repeated(…) tail is a field of the generated record too, and is counted.
I025named `sections` requires at least one field, `choice` requires at least one caseA sections or choice with nothing in it.
I026a positional `block` item returning a scalar must be namedA positional block item whose parser returns a scalar. There is no field name to give it. A template is exempt: a no-capture template contributes no field and still consumes input.
I027duplicate choice case `{name}`A choice declaring one case name twice.
I028`repeated(...)` is only the final named argument of a `sections` call, `sections` takes at most one `repeated(...)` tail, a `repeated(...)` tail may appear only as the final named argument: it consumes every remaining section, so nothing can follow itA repeated(…) somewhere it cannot go. It consumes every remaining section, so nothing can follow it, and there can be only one.
I030invalid escape `{seq}` at byte {n}, unterminated capture starting at byte {n}, empty capture `{}` at byte {n}, malformed capture body at byte {n}: {detail}, {what} nesting is deeper than {limit} at byte {n}A backtick template the scanner could not read. The byte offset is into the template’s own text.

An input mistake is reported against the template or the call that contains it:

var moves = read lines(`move {count:int} from {int} to {target:int}`)
out(moves.len())
error[I020]: named and anonymous captures may not be mixed in one template

  input-mistake.px:1:24
  1 | var moves = read lines(`move {count:int} from {int} to {target:int}`)
    |                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ named and anonymous captures may not be mixed in one template

praxis: 1 error(s)

Failures while a parser is running are not diagnostics. They are input faults, they carry a fault kind rather than a code, and they open the crash debugger.

Which command sees what

praxis check and the editor run the same front end and report the same set — they share one query layer, so a diagnostic in one and not the other is not representable.

praxis run adds nothing to that set. Every diagnostic a well-formed program can earn is decided during analysis, so praxis check exiting 0 is a claim about your program rather than about which passes happened to run. The sharpest case is a pattern in a binding position, because deciding it needs the scrutinee’s type and the variant’s payload — the sort of question a compiler is tempted to leave until it is building code:

enum Shape { Circle(Int), Square(Int) }

var shapes = [Circle(1), Square(2)]
for Circle(r) in shapes {
  out(r)
}

praxis check reports it and exits 1, and praxis run refuses it with the same text at the same span before executing a line of it:

error[Y125]: a `for` binding must match every item, and a variant pattern does not

  once-lowering-only.px:4:5
  4 | for Circle(r) in shapes {
    |     ^^^^^^^^^ a `for` binding must match every item, and a variant pattern does not

praxis: 1 error(s)

The one code raised past analysis is Y099, and it is not an exception to the rule: it says inference recorded no type for a node lowering reached, which is a compiler bug rather than a mistake in your program. No program you can write earns it.

Adding a code

A code is allocated by adding a variant to DiagCode in praxis-source. DiagnosticCode::new is crate-private, so there is no way to construct an unregistered number, and DiagCode::code()’s exhaustive match is the single place a (category, number) pair is written. Attaching a Suggestion with a replacement where the mistake is detected is all it takes for the new code to have a quick fix in the editor — there is no table in the language server to update.

The compiler pipeline

praxis run foo.px is one process that does everything: it lexes, parses, resolves names, infers types, lowers twice, generates machine code with Cranelift, and calls the result. There is no object file, no linker and no cache on disk. A program that compiles at all compiles in a few milliseconds, which is why the design never bothered with separate compilation.

This chapter walks that path stage by stage — what each stage consumes, what it produces, and which crate owns it. It is for someone who wants to change the compiler, or who wants to know where a particular behaviour is decided.

The stages

stagecratewhat comes out
lexpraxis-parser (lex.rs)a token stream including trivia, plus T0xx
parsepraxis-parser (parse.rs)a rowan green/red tree, plus P0xx
typed ASTpraxis-asttyped wrappers over syntax nodes — nothing copied
resolvepraxis-hir (resolve.rs)a scope tree, a SymbolId per declaration, plus N0xx
inferpraxis-hir (infer.rs), praxis-typecka type per expression node, plus Y0xx
coveragepraxis-hir (exhaustive.rs)match exhaustiveness and reachability
typed HIRpraxis-hir (lower.rs)a TypedModule: every node carries a Type
monomorphizepraxis-hir (mono.rs)one clone of each generic function per concrete use
MIRpraxis-mir (build.rs)basic blocks over slots, with safepoints and fault edges
livenesspraxis-mir (liveness.rs)the root set and the debugger’s set at each safepoint
verifypraxis-mir (verify.rs)a refusal, if the MIR broke an invariant
codegenpraxis-codegen-craneliftfinalized machine code in memory
runpraxis-runtimethe heap, the collections, the faults

praxis check stops after coverage. Everything below that line is praxis run only, which is why the editor can never be slowed down by the back end: the language server’s manifest does not depend on praxis-mir, praxis-codegen-cranelift or praxis-runtime, and a test reads the manifest and says so rather than observing that one code path happened not to reach the JIT.

Source, tokens, tree

praxis-source is the leaf of the workspace and depends on no other Praxis crate. It owns files, byte spans, the line map, and the Diagnostic type together with its rendering. Every diagnostic carries a category letter and a number — T for token, P for parse, N for name, Y for type, I for the input parser — and the whole allocation is listed in the diagnostic index.

The lexer is hand-written and emits trivia as tokens rather than discarding it. A backtick template is lexed as one token, interior and all; the interior is re-scanned later by a different crate, and the two agree about where nested templates end because they share praxis-syntax’s template module.

The parser is recursive descent for statements with a Pratt loop for operators, emitting into a rowan::GreenNodeBuilder. The tree is lossless: node.to_string() reproduces the source byte for byte. On an unexpected token the parser reports, wraps the stray token in an error node, resynchronizes and continues, so one bad file still yields the rest of its diagnostics.

praxis-ast is a thin typed layer over that tree — SourceFile, FnItem, ParserExpr and friends — with no strings copied out of the source.

Names and types

praxis_hir::analyze runs three passes and returns one Analysis:

#![allow(unused)]
fn main() {
let resolution = resolve::resolve(file, root);
let mut inference = infer::infer_with_tree(file, resolution, root);
exhaustive::check_matches(/* … */);
}

Resolution builds the lexical scope tree and mints a distinct SymbolId for every declaration, which is what makes shadowing work: two var x in one block are two symbols, and every downstream consumer — go-to-definition, rename, the inlay hints — keys on the symbol and never on the spelling.

Inference is one file of about 3,800 lines (infer.rs) and it is where the interesting decisions live; see what inference does. praxis-typeck is the machinery it drives rather than the algorithm itself: the interned type arena (db.rs), unification, generalization by binding level, capability constraints, and pretty.rs, which is the single place that decides how a type prints.

Match coverage runs last, after the whole file is inferred, because a scrutinee’s type is not final until then. That ordering is what puts a non-exhaustive match in front of praxis check and the editor rather than only in front of praxis run.

Analysis is the front end’s whole output: the type arena, the symbol table, the scope tree, per-reference and per-node types, resolved method calls, the retained parser indexes, and every diagnostic.

Typed HIR

The back end needs the type of every node, not just of name references, and it must not re-run unification to get one. So a separate pass reads the finished Analysis and rebuilds the program as a typed tree — TypedModule, TypedItem, TypedStmt, TypedExpr — where each node carries an interned Type handle. That tree is the boundary between the front end and the back end, and it never unifies: it reads what inference recorded.

Typed HIR is still structured: if, while, for, loop, match and closures are all nodes. What it removes is name lookup and method resolution — a MethodCall node carries the catalog row’s runtime symbol where the row is an intrinsic — and it wraps a file’s top-level statements in a function of their own.

That function is called <entry>, and a crash backtrace names it:

// The top-level statements of a file are a function the compiler wrote, and
// the crash backtrace names it `<entry>`. The temps under it are MIR locals.
fn half(n: Int) -> Int {
    return n / 0
}

var xs = [4, 8]
out(half(xs[0]))
error: program faulted: division by zero

Backtrace:
#0   half
#1   <entry>

  locals:
    n: Int = 4
  temps:
    <tmp#2: Int> @ "0" = 0
    <tmp#3: Int> @ "n / 0" = <uninit>
    <tmp#4: Unit> @ "return n / 0" = <uninit>

The three <tmp#N> lines are MIR slots, not source variables: lowering materializes every intermediate node of an expression tree into its own local, and the debugger prints each with the expression that produced it. <entry> is the generated function holding the file’s top-level statements, and it is the only entry point there is.

Monomorphization sits between typed HIR and MIR: each polymorphic function is cloned once per distinct set of concrete type arguments at its call sites, so MIR never sees a type variable. You never write a call’s type arguments, and there is no syntax to: brackets after a name are type arguments only where the name is a type, so writing id[Int] on a function is reported as a subscript — Y020.

MIR

MIR is a control-flow graph over slots, and it is deliberately not SSA: Cranelift builds the SSA a stage later, and duplicating that work here would buy nothing. A function is a list of Locals plus a list of Blocks; a block is instructions and a terminator. Every local is one of two kinds:

  • LocalKind::Gc — holds a uniform GcRef. These are the only locals the collector ever sees.
  • LocalKind::Scalar — a transient i64/f64/u32/u8/bool payload pulled out of an object for a local computation. It must not survive a safepoint; the builder materializes a fresh GcRef before any call, store or return.

So a + b lowers to ExtractScalar, ExtractScalar, IntBinOp, CheckFault, Materialize — and a chain of arithmetic emits a Materialize immediately followed by an ExtractScalar of the same value at every interior node. A block-local forwarding pass deletes those cancelling pairs before anything else runs, because deleting a Materialize deletes a safepoint, and liveness must see the safepoints that survive rather than the ones the builder emitted.

This is also where a pipeline chain becomes one loop. The builder recognizes v.map(f).filter(p).sum() on the typed tree it was handed and emits a single fused loop over the source with no intermediate collection, and there is no second, per-combinator lowerer behind it — a chain the recognizer declines is a compiler bug that says so, not a silently wrong answer.

Then annotate runs backward-dataflow liveness and records, at every safepoint, two sets: what the collector must keep alive, and what the debugger must be able to render. They are deliberately different — the first is minimal so that dead values are collectable, the second is over-approximate so that a crash can still show you a local the program has finished with.

Finally verify checks the invariants: no scalar live across a safepoint, no ExtractScalar whose width contradicts what the slot provably holds, every safepoint annotated. A verifier failure is reported as an internal error and no code is generated from it. It is never a program error.

Cranelift

praxis-codegen-cranelift maps each MIR Local to a Cranelift Variable and lets Cranelift’s builder construct SSA, including the block parameters for loop backedges. Every generated function has the same signature:

fn(RuntimeContext*, GcRef...) -> GcRef

GcRef is a pointer, and Cranelift carries it — and every scalar payload — as i64. The JIT refuses to initialize on a target whose pointer is not 64 bits or whose endianness is not little, because those are host assumptions written as constants rather than derived from the ISA.

Runtime calls resolve through one manifest. praxis-stdlib’s abi.rs has one row per praxis_* symbol — 184 of them — giving the exact linker name, the parameter and return kinds, and whether the wrapper can allocate, can fault, both or neither. That last column is what MIR consults to decide whether a call site is a safepoint and whether a fault check follows it, so a wrapper’s effect is a fact in a table rather than a property of the instruction shape. Every wrapper in that table is extern "C", never panics, and reports a problem by setting a pending fault rather than by returning one.

Not everything is a call. The backend compiles at Cranelift’s opt_level = "speed", and several hot operations are inlined branches: a scalar load proves the object’s type with one compare, a small Int comes from an interned table behind the pacing test, and generated code claims an allocation block inline. The proofs are branches rather than calls because the branch predicts and the call does not; none of them is elided, because a wrong one is a memory-safety bug and not a slow path.

Everything the backend mints for the runtime to read by raw pointer — record and tuple schemas, field names, debug metadata — belongs to a Generation, an arena with interning. Reclaiming one requires proof that the heap has been drained, because live objects point into it; a generation that is merely dropped leaks on purpose.

The read sub-pipeline

A read or parse expression is a second small compiler running beside the first, and it finishes at praxis check time — a parser that does not check is a compile error, not a runtime one.

The ordinary parser produces PARSER_EXPR nodes and one opaque BacktickTemplate token. praxis-hir’s parser_lower.rs converts those nodes into praxis-input-parser’s own AST, and that crate does the rest: scan.rs re-scans a template’s interior into literal runs and captures, body.rs parses a capture’s body as a full parser expression, validate.rs and call.rs check the shape before anything is built, synthesize.rs computes the result type — which is where a Vec[Int] comes from when you never wrote one — and plan.rs lowers it to a ParserPlan registered under a PlanId.

MIR carries that PlanId as an immediate. At run time praxis-runtime’s parser.rs interprets the plan against the input buffer. The plan is interpreted, not compiled, and that is a current implementation choice rather than a property of the design. See how a parser gets its type.

The shared query layer

The CLI and the language server run the same front end through one query API. It lives in praxis-lsp — the crate that needs it most — with praxis-cli depending on praxis-lsp rather than the other way round. Two front ends would be two places to teach every new rule, and praxis check and the editor would be one forgotten edit away from disagreeing about the same file.

query::Snapshot is one file at one revision with the front end memoized on it: parse and analyze each run at most once per snapshot, and a test asserts the run counts rather than assuming them. diagnostics() is the one place that decides which diagnostics exist and in what order — parse first by construction, then names and types, all sorted by span — and the one place that decides analysis runs even when parsing reported, because recovery keeps the tree usable and an editor must not go blank on one stray character.

The whole of praxis check is then:

#![allow(unused)]
fn main() {
let snapshot = Snapshot::new(file, text, Revision(0));
let diagnostics = snapshot.diagnostics();
}

so a divergence between what praxis check prints and what the editor underlines is unrepresentable rather than merely unlikely.

praxis run does not route through the snapshot. It calls praxis_parser::parse and praxis_hir::analyze_root directly, because it needs the Analysis by value to hand to lowering and then to the crash debugger, and it re-states the sort. That is the one place the sequence is written twice.

A rowan::SyntaxNode never leaves the query layer. It is !Send and it is a cursor into thread-local state, so Snapshot::parse is crate-private and every public answer is owned data or a range. The server itself is a synchronous, single-threaded stdio loop with no async runtime, and keeping syntax nodes crate-private is what makes moving the front end onto its own thread a move rather than a rewrite.

Reading what the back end emitted

Three environment variables dump the compiler’s own output, on stderr, from the real compile path. Each takes 1/all or a comma-separated list of function names.

variablewhat it prints
PRAXIS_DUMP_CLIFthe Cranelift IR, post-optimization, with an instruction count per block
PRAXIS_DUMP_VCODEthe machine-level listing, same header
PRAXIS_DUMP_SLOTSone census line per function
$ PRAXIS_DUMP_SLOTS=all praxis run references-are-copied.px
;; praxis-dump slots `push_two`: gcloc=6 rootc=2 live=2 dbgvis=5 nameless=1 unrenderable=0
;; praxis-dump slots `rebind`: gcloc=11 rootc=2 live=2 dbgvis=10 nameless=4 unrenderable=0
;; praxis-dump slots `<entry>`: gcloc=10 rootc=2 live=2 dbgvis=7 nameless=2 unrenderable=0
[1, 2]
[1, 2]
[1, 2]

gcloc is the function’s count of Gc locals, rootc the shadow stack’s claim width — the colours the interference relation needs — live the largest root set live at any one safepoint, which equals rootc wherever the colouring is optimal, and dbgvis the largest set the crash debugger must be able to render. The gap between gcloc and rootc is the colouring: a shadow slot is a live range and not a name, so locals that are never live at the same safepoint share one.

These hooks are in the tree permanently, because an instruction count is a deterministic result for a change that removes three instructions from a loop, and a wall clock is not.

The crate graph

Sixteen crates. The stage table at the top of this chapter names the ones that are a stage, and praxis-source is the leaf underneath all of them. Five more are worth knowing by name:

  • praxis-syntax owns the SyntaxKind vocabulary, the identifier character class, and the one rule for where a template or an interpolated literal ends — which is why the lexer and the input parser agree about where a nested template closes instead of each having an opinion.
  • praxis-repr holds the one total, bidirectional bridge between a static Type and a runtime TypeDescriptor. The two directions have to be inverses, and two independently written halves are each locally plausible while failing to compose, so they live in one module with an exhaustive match on each side: a new built-in type is a compile error there until both directions know it.
  • praxis-stdlib is the single source of truth for what built-in methods exist, what their types are, and how they lower — consumed by inference, lowering, codegen and the language server’s completion alike, so that method knowledge is never written down twice.
  • praxis-debugger is the crash debugger: the snapshot, the command loop, the read-only expression evaluator and the full-screen view.
  • praxis-lsp is LSP transport and the shared query layer above, with the CLI as its consumer.

One direction in that graph is worth stating outright. praxis-input-parser depends on neither praxis-parser nor praxis-hir: the ordinary lexer hands it a template as a single token and the ordinary parser hands it the parser-expression nodes, so the second compiler needs no lexer of its own and cannot drift from the first about what a token is.

The object heap and the collector

Every value in a running Praxis program is a heap object behind a pointer. An Int is an object. A Bool is an object. A Vec[Int] is an object holding pointers to objects. There is one reference type, GcRef, and generated code treats it as opaque.

The collector behind that is precise, non-moving, single-threaded mark-and-sweep over size-class pages, with the roots supplied by the compiler. None of which you can observe from a Praxis program, and the point of this chapter is largely to say exactly what “none of which” covers.

What it means for a program

Three things, and then a fourth that is not quite nothing.

A call copies the reference, never the object. Passing a collection to a function does not copy it, and mutating it through the parameter is visible to the caller. Rebinding the parameter is not — that changes one slot in one frame.

// A call copies the reference, never the object. `xs` and `v` name
// one Vec, so a push through either is visible through both.
fn push_two(xs: Vec[Int]) {
    xs.push(1)
    xs.push(2)
}

// Rebinding the parameter changes only this function's slot.
fn rebind(xs: Vec[Int]) {
    xs = [9, 9, 9]
    xs.push(9)
}

var v = Vec[Int]()
var alias = v
push_two(v)
out(v)
out(alias)

rebind(v)
out(v)
[1, 2]
[1, 2]
[1, 2]

There is nothing to run when an object dies. No finalizers, no destructors, no close, no scope-exit hook. The collector calls internal drop functions during sweep so that a Vec’s Rust backing allocation is released, and that is invisible: nothing you wrote runs, and nothing you can print changes.

There is no way to ask about identity. The language has no is, no ===, no ref_eq. == on scalars compares payloads and == on composites compares structurally, so two separately built vectors with the same elements are equal. That is what makes it safe for the runtime to hand out one shared object for every true, every Unit, every code point below 128 and every integer from −256 to 1024: an optimization that would be observable in a language with reference equality is unobservable here.

The fourth thing is memory. The collector cannot change what a program prints, but it decides how much memory the program holds while printing it — and its schedule is tunable, which is a convenient way to show that the schedule is not part of the program’s meaning:

// One live vector, and a great deal of garbage made around it.
var kept = Vec[Int]()
kept.push(7)

var total = 0
var i = 0
while i < 200000 {
    var scratch = [i, i + 1, i + 2]
    total = total + scratch.sum()
    i = i + 1
}

out(total)
out(kept)
60000300000
[7]

PRAXIS_GC_PACER replaces the collector’s schedule. doubling is the unbounded rule the language used to have; bounded:64K:1 collects as often as it possibly can. On one machine that program peaks at 21 MiB, 12 MiB and 7 MiB of resident set under doubling, the default, and bounded:64K:1 — and prints the same two lines in all three.

$ PRAXIS_GC_PACER=doubling praxis run gc-is-invisible.px
60000300000
[7]
$ PRAXIS_GC_PACER=bounded:64K:1 praxis run gc-is-invisible.px
60000300000
[7]

That variable is the only environment variable the runtime reads, and it exists for A/B measurement rather than for tuning your program.

An object

An allocation is a GcHeader followed by its payload in the same block. The header is 16 bytes and has three fields, each with a reader on a hot path:

fieldwidthwhat it is for
descriptor8pointer to the TypeDescriptor; null means swept
payload_offset2where the payload starts, as the allocator laid it out
heap_id4which heap owns this block

Two fields that used to be there are not. The mark colour moved into the page, because a colour byte in the header costs a random-access store per surviving object per collection. The payload size was deleted outright because nothing read it, which took the header from 24 bytes to 16 and every block in the heap down by eight. Adding a field back is not a local decision: it moves the size-class ladder and the immediate that generated code folds to reach a payload, so it owes an ABI version bump.

payload_offset is the single layout authority — written by the allocator from the same calculation that produced the address it initialized, and read by everything else. Nothing downstream re-derives it, so nothing downstream can derive it differently. heap_id is allocation provenance, and it is the one field two rounds of shrinking refused to spend: the mark phase reads it before it dereferences anything the header points at, so a reference from another heap or into swept storage is rejected rather than followed.

Everything payload-aware is centralized in the descriptor rather than scattered across type switches: trace, drop_value, format, and optional equals, hash, compare and owned_bytes callbacks. There are exactly 22 built-in descriptors — the six scalars, Text, the nine collections, Range, and the generic Record, Tuple, Enum, Closure and VarCell shapes. They are static, because descriptor pointer identity is what the runtime compares.

compare is carried by exactly the eleven a Map key or Set member can be — the scalars, Text, Range, Record, Tuple and Enum — and is None on the nine collections, Closure and VarCell, none of which can ever be a key. That is what makes the order a container walks its keys in total, and it is a deliberately wider set than the source language’s <.

An Int is therefore 24 bytes: 16 of header and 8 of payload.

Pages

Every block lives on a page: one 32 KiB allocation, aligned to 32 KiB, whose first bytes are a PageHeader and whose remainder is an array of equal-sized blocks. Because the base is aligned to the page size, finding an address’s page is a mask and finding its block index is a multiply-shift. No side table, no hash lookup.

The size-class ladder is 8-byte granular from 16 bytes (a bare header, which is what a Unit is) to 128 bytes: 15 rungs. Deliberately not powers of two — the ladder exists to make composites smaller, and a power-of-two ladder would round a Vec’s block up to 64 and a Map’s to 128. A payload larger than 128 bytes, or aligned more strictly than a header, gets a page to itself; no descriptor in the language takes that path.

A page carries two bitmaps. allocated says which blocks hold an initialized object, and mark says which the mark phase reached this cycle. allocated is the free list as well as the liveness record: allocation claims the lowest clear bit at or above a cursor. It is a bitmap rather than a list threaded through dead blocks specifically because threading a next-pointer through a dead block would overwrite its descriptor, and swept storage that claims to be a typed object is the failure mode the whole design is arranged to avoid.

An emptied page goes back to the heap’s own pool and is re-classed on demand, so storage is reusable across layouts. A page is never returned to the operating system while its heap lives — that is soundness, not policy, because the story “a stale reference masks to a page that is still mapped and is rejected there” requires the page to still be mapped.

Mark and sweep

The collector is precise (it knows exactly which words are references, because the compiler tells it), non-moving (an object’s address is fixed for its lifetime), single-threaded, and needs no write barrier.

Mark starts from the roots and drains a worklist. Per object: check heap_id against this heap’s; mask to the page; test-and-set the mark bit; if it was clear, call the descriptor’s trace, which pushes children onto the worklist. The worklist is the grey set — a third colour would say nothing extra in a collector with no concurrency, which is why the header never had a byte for one.

Sweep walks the pages a word of bitmap at a time. allocated & !mark is the dead set; each dead block gets its payload finalized, its header poisoned (descriptor nulled, heap_id zeroed) and only then its bit cleared. A page in which nothing died costs two tests and at most one store per 64 blocks, and sweep never touches a survivor. That property is what makes measuring the live set free: sweep also accumulates live_count × block_size per page, one multiply per page and nothing per object.

Non-moving is the load-bearing choice. Stable addresses are why the Rust collection wrappers can be simple, why spilled roots never need updating, and why a crash snapshot can hold references safely.

Roots

Roots come from six places, and the set is exhaustive by construction: the type the collector accepts is built only from a live runtime context and destructures all six arms, so “collect against a partial root set” does not compile.

Five are strong — the collector keeps them alive:

  1. The shadow stack, which is where generated code puts its live locals.
  2. The process input buffer.
  3. A failed parse’s partial value, so the crash debugger can show it.
  4. The crash snapshot, once one has been taken.
  5. Native scopes — the run of entries a runtime helper claims while it builds a value across an allocation. They live in one growable store whose depth is what is bounded rather than its size, and holding a payload reference across a safepoint without rooting its owner does not type-check.

The sixth is weak: the crash debugger’s per-call value slots. The collector never traces them — that would merge the two slot sets the compiler deliberately keeps apart — but it scans them once per collection, immediately after the sweep, and turns every entry naming reclaimed storage into an absence. So a debug value is always a live object or nothing, never a dangling reference.

The shadow stack

The compiler computes, per safepoint, the minimal set of live Gc locals, and generated code stores exactly those into a frame before the safepointing call.

A frame is not an object. The runtime owns one contiguous region of slots for the whole program; a function’s frame is the run between the top it found on entry and the top it left behind. The prologue loads top, zeroes exactly the slots it claims, and stores the bumped top; the epilogue stores the saved base back. No call, no allocation, no catch_unwind. The collector scans [base, top) in one linear pass, skipping nulls, which yields exactly what a walk of per-frame objects yielded and allocates nothing.

A slot is a live range, not a name. Locals that are never live at the same safepoint share a slot, assigned by colouring the interference relation, so a frame’s width is its peak simultaneous liveness rather than its count of locals. Over the AoC corpus that took the summed declared width from 1925 slots to 216.

Shadow-stack exhaustion is unrepresentable rather than handled, and the argument runs through the recursion guard. Every prologue refuses before it pushes anything if the remaining stack budget will not cover this frame’s cost, where the cost is a measured floor of 160 bytes plus 2 bytes per Gc local past the eleventh — the floor being the high-water mark across both targets the backend supports, so a program faults at the same depth on either. The budget is 8000 reference-width frames’ worth. The shadow-stack reservation is sized from that same arithmetic plus one frame of headroom, so there is no bounds check in the prologue, because there is nothing left to check.

This is the one place the machinery becomes a fault you can hit:

error: program faulted: stack overflow (recursion limit)

A wide frame reaches it sooner than a narrow one, because it costs more, which is the whole reason the guard charges by shape rather than counting calls.

Safepoints and pacing

A safepoint is a point where a collection may happen, and that is exactly a point where something may allocate: an Alloc, a Materialize, or a call to a runtime wrapper whose manifest row says it allocates. The compiler spills roots immediately before each one.

On the runtime side, allocation is gated by a token. Heap::alloc takes a Safepoint, and the only way to obtain one is Heap::pace, which is where the collection test runs. Obtaining the token is the pacing, and the token is neither Copy nor Clone — one token, one allocation. So “allocate on the paced path without pacing” has no spelling.

The test itself is two words: has this heap allocated more bytes since the last collection than its current threshold. After each collection the threshold is recomputed as

max( min(previous × 2, ceiling), live × 2, 64 KiB )

Three terms, each there for its own reason. The doubling ratchet keeps allocations per collection amortized constant. The ceiling — 4 MiB — bounds speculative growth, because being wrong about the future costs only a collection that finds nothing. live × 2 is not speculative: those bytes are provably reachable now, so a program legitimately holding more than the ceiling must be allowed to exceed it, or every collection would prove it can reclaim nothing and the next allocation would trigger another. The ceiling clamps the doubling term and never the whole expression, which is what keeps those two rules from fighting.

The resident set a program holds is therefore floor + live + that threshold, where the floor is what the process costs before the program does anything, JIT included: out(1) costs 5.5 MiB on the machine the numbers above come from, and that is a figure which moves with the host. The ceiling is a tuned constant of the same kind. It is a bet on what a collection costs — hold more garbage and you collect less often — so it moves whenever a collection gets cheaper.

Generated code does not call the pacing function. It transcribes it: two loads and a compare, with the allocation’s fast path on the not-due side and the out-of-line wrapper on the other. Which means the collector runs on a branch that generated code took, not on a call it made. What makes that sound is that nothing between the pacing branch and the last store into the new object can collect, so there is no window in which a half-initialized block is reachable.

Where it does show through

Four places, all of them about memory rather than meaning.

A Text slice keeps its owner alive. A Text is either an owned UTF-8 payload or a zero-copy (owner, start, length) view into another Text. The input parser produces slices into the immutable input buffer, so holding one word out of a 10 MB input holds the 10 MB. Non-moving addresses are what make that representation sound in the first place.

Interning is bounded and permanent. The interned integers, characters and singletons live on pages flagged immortal: no bit of their allocated bitmap is ever cleared and nothing on them is ever finalized. They are never collected and never intended to be.

Deep recursion faults. See above; the program reports and the debugger opens, rather than the process dying on a native stack overflow.

Running out of memory is not modelled. A size the host cannot serve — a BitSet insert of 10^18, say — is a fault checked before anything is allocated, and reads program faulted: size or extent out of range. But a page the operating system refuses is not: the process aborts. Nothing in the language observes heap exhaustion.

Current choice versus permanent property

Permanent, in the sense that the language is defined around it:

  • Every value is a reference to a heap object, with reference semantics on assignment and argument passing.
  • There is no identity operator, and equality is structural.
  • There are no user-visible finalizers.
  • Object addresses are stable for an object’s lifetime, and no interior pointer is ever exposed as a long-lived value.

A current implementation choice, and most of these have already changed once:

  • Mark-and-sweep, non-moving, single-threaded, no write barrier. It was chosen for stable addresses and a small surface; a generational collector would take the stable addresses with it, and everything above that leans on them.
  • Size-class pages of 32 KiB with a 15-rung ladder. This replaced a bump arena with a side registry, and the alternative of segregating by descriptor was weighed and rejected on provenance grounds rather than on memory: it deletes the header, and with no per-object word there is nothing left to read before masking an unvalidated reference to a page.
  • Which values are interned, and the 4 MiB pacing ceiling.
  • The 16-byte header. Two fields have left it, and the language reserves the right to intern, tag or eliminate small objects entirely, provided reference and aliasing semantics survive — and for Int there are none to survive, which is a fact about the language rather than an assumption about the program.

Appendix A: Complete programs

Seven programs, in rough order of size. Every one is a real file under docs/book/examples/appendix/, re-run by docs/book/examples/verify.sh against the input and the output printed here. So this is not a sketch of what a Praxis program might look like; it is what target/release/praxis does with that text.

Run any of them:

$ praxis run docs/book/examples/appendix/depths.px --input docs/book/examples/appendix/depths.in

The first four are puzzle-sized. The fifth is a whole puzzle in thirty lines. The sixth is a breadth-first search written twice, and the seventh is a bytecode interpreter that retires 1.15 million instructions on the input shown.

depths.px — one integer per line

The floor of the language. A whole day’s input is lines(int), and the type Vec[Int] comes from the parser expression rather than from an annotation. The three lines of output are len, sum, and a pipeline that zips the vector with itself offset by one and counts the pairs that increase.

This program has no fn main, and there is none to write: a file is a program and its top-level statements run in order (A file is a program).

// The smallest complete program that reads input: one integer per line.
//
// `read lines(int)` is the whole parser. Its type — `Vec[Int]` — is derived
// from the parser expression, so nothing is annotated. What follows is three
// pipelines over that vector.

var depths = read lines(int)

out(depths.len())
out(depths.sum())
// A window over consecutive pairs: how many readings are larger than the one
// before. `zip` pairs the vector with itself offset by one, and `count` takes
// the predicate.
out(depths.zip(depths.skip(1)).count(|p| p.1 > p.0))

depths.in:

199
200
208
210
200
207
240
269
260
263

Output:

10
2256
7

Explained in The read expression, Atomic parsers and Pipelines.

calories.px — blank-line-separated sections

The other structural shape every puzzle set contains: groups separated by blank lines. sections(lines(int)) nests two structural parsers, and the nesting is the type — Vec[Vec[Int]], one inner vector per group.

sorted_by_key(|t| 0 - t) is how this program writes a descending sort. There is a second spelling, sorted().reversed(), and the example keeps the first: negating the key is one pass over the group where sorting and then reversing is two.

// A sections day: blank-line-separated groups of integers.
//
// `sections(lines(int))` nests two structural parsers, so the result is
// `Vec[Vec[Int]]` — one inner vector per group. Nothing in the program says
// that type; it is read off the parser expression.

var groups = read sections(lines(int))

out(groups.len())
// The largest group total, and the sum of the three largest.
var totals = groups.map(|g| g.sum())
out(totals.max())
out(totals.sorted_by_key(|t| 0 - t).take(3).sum())

calories.in:

1000
2000
3000

4000

5000
6000

7000
8000
9000

10000

Output:

5
24000
45000

Explained in Structural parsers and How a parser gets its type.

toboggan.px — a character grid

A grid day. read grid(char) yields a Grid[Char], indexed map[x, y] — a subscript taking two arguments, which is why a subscript’s index list is an argument list rather than a single expression.

Two details here are worth naming. '#' is how a program writes a character it chose, and "#"[0] — subscripting a one-character Text — still names the same Char, which is what a program reaches for when the character came out of text it did not write down. And trees_on_slope takes the grid as a parameter even though map is in scope at the file level — a fn does not capture the bindings around it, and reading one from inside a function is N007 with a message telling you to pass it in (and, when the function is recursive, saying why a closure is not an option).

// A grid day: count the trees hit by descending a slope of (right 3, down 1).
//
// `read grid(char)` yields a `Grid[Char]`, indexed `map[x, y]` — a subscript
// with two arguments, which is why a subscript's index list is an argument list
// and not a single expression. The map repeats horizontally forever, so the
// column wraps with `%`.

var map = read grid(char)

fn trees_on_slope(map, right, down) {
    var x = 0
    var y = 0
    var hits = 0
    while y < map.height() {
        if map[x % map.width(), y] == '#' {
            hits = hits + 1
        }
        x = x + right
        y = y + down
    }
    hits
}

out(map.width())
out(map.height())
out(trees_on_slope(map, 3, 1))
// Part two multiplies five slopes together.
var product = 1
for slope in [(1, 1), (3, 1), (5, 1), (7, 1), (1, 2)] {
    product = product * trees_on_slope(map, slope.0, slope.1)
}
out(product)

toboggan.in:

..##.......
#...#...#..
.#....#..#.
..#.#...#.#
.#...##..#.
..#.##.....
.#.#.#....#
.#........#
#.##...#...
#...##....#
.#..#...#.#

Output:

11
11
7
336

Explained in Grids and graphs, Text and Char and Functions and closures.

pipeline.px — records from a template, then closures

A named-capture template turns each line into a record: {name:word}: {score:int} produces {name: Text, score: Int}, and the whole read is a vector of those. Field access is e.name, and the record type was never declared.

above is a function that returns a closure. cut is captured and the closure outlives the call that built it — which is the difference between a closure and a fn, and the reason trees_on_slope above had to take its grid as a parameter.

// A closure pipeline over records read from a template.
//
// The template `{name:word}: {score:int}` names its captures, so each line
// parses into a record `{name: Text, score: Int}` and the whole read is a
// `Vec[{name: Text, score: Int}]`. Everything after that is pipeline
// combinators and closures — including one closure returned from a function,
// which captures the parameter it was built with.

var entries = read lines(`{name:word}: {score:int}`)

// A function that returns a closure. `cut` is captured by value; the closure
// outlives the call that made it.
fn above(cut) {
    |e| e.score > cut
}

var passing = above(50)

out(entries.len())
out(entries.filter(passing).map(|e| e.name))
out(entries.map(|e| e.score).fold(0, |a, s| a + s))
out(entries.sorted_by_key(|e| 0 - e.score).take(2).map(|e| e.name))
// `frequencies` counts, and a `Counter` reads absent keys as zero.
var initials = entries.map(|e| e.name[0]).frequencies()
out(initials['a'])
out(initials['z'])

pipeline.in:

ada: 91
alan: 47
grace: 88
alonzo: 63
edsger: 12

Output:

5
[ada, grace, alonzo]
301
[ada, grace]
3
0

The last two lines are the Counter rule: a key that was counted reads its count, and a key that was never inserted reads 0 instead of faulting.

Explained in Templates and captures, Records without names, Functions and closures and Collections.

segments.px — a whole puzzle in thirty lines

What a finished puzzle solution looks like at full size, top-level statements and all: the template read, a function over the records it produced, Counter[(Int, Int)]() with an explicit type argument, counts[point] += 1 storing through a subscript, 0..=distance, a trailing comma in the max(…) call, and two out calls at file scope.

The one type argument is not forced by anything: Counter() on its own infers (Int, Int) from counts[point] += 1 and prints the same two answers. It is written out because this is the only shape a type argument has — a compiler-owned constructor name, a bracket list, and then the call. Nothing else in the program is annotated.

// segments — the shape of a whole puzzle in thirty lines: a template read, a
// function over the records it produced, a `Counter` keyed by a tuple, a
// compound assignment through a subscript, an inclusive range, and two calls
// printing the two parts. Nothing in it is annotated.
//
// Input: line segments, `x1,y1 -> x2,y2`. Output: the number of points covered
// by two or more segments, first ignoring diagonals and then including them.
var segments = read lines(`{x1:int},{y1:int} -> {x2:int},{y2:int}`)

fn overlaps(segments, diagonals) {
    var counts = Counter[(Int, Int)]()

    for segment in segments {
        var dx = sign(segment.x2 - segment.x1)
        var dy = sign(segment.y2 - segment.y1)

        if !diagonals && dx != 0 && dy != 0 {
            continue
        }

        var distance = max(
            abs(segment.x2 - segment.x1),
            abs(segment.y2 - segment.y1),
        )

        for step in 0..=distance {
            var point = (
                segment.x1 + dx * step,
                segment.y1 + dy * step,
            )
            counts[point] += 1
        }
    }

    counts.values().count(|n| n >= 2)
}

out(overlaps(segments, false))
out(overlaps(segments, true))

segments.in:

0,9 -> 5,9
8,0 -> 0,8
9,4 -> 3,4
2,2 -> 2,1
7,0 -> 7,4
6,4 -> 2,0
0,9 -> 2,9
3,4 -> 1,4
0,0 -> 8,8
5,5 -> 8,2

Output:

5
12

!diagonals && dx != 0 && dy != 0 leans on the precedence table: ! binds tighter than every infix operator, && binds looser than !=, and && is left-associative — so it reads as ((!diagonals) && (dx != 0)) && (dy != 0). The whole table is in Appendix B.

Explained in Templates and captures, Collections and Bindings and shadowing.

maze.px — breadth-first search, twice

The same search written both ways. The first is the loop every puzzle starts with: a Deque as the FIFO frontier, a Set of visited cells, a Map of distances. The second is the prelude’s bfs_distance, which takes a start state, a closure answering a state’s neighbours, and a closure saying whether a state is the goal — the graph is never built.

A cell is a (Int, Int) tuple. That is what lets it be a Set member and a Map key: a key has to be hashable and unable to change after it is stored, and a tuple of scalars is both. A Vec is not: a Vec[Int] key is Y014 — “a value of type Vec[Int] can change after it is stored, so it cannot be used as a key”. Hashable is not orderable: a tuple has no < — though the collections above still walk and print their tuple keys element-wise, because a container needs a reproducible order whatever the source language permits.

bfs_distance answers Option[Int], so the third line of output is Some(22) and not 22: a goal that cannot be reached has no distance.

// Breadth-first search over a maze read as a character grid.
//
// Two ways to write the same search. The first is the loop every puzzle starts
// with: a `Deque` as the FIFO frontier, a `Set` of visited cells, a `Map` of
// distances. The second is the prelude's `bfs_distance`, which takes the start,
// a closure answering the neighbours of a state, and a closure saying whether a
// state is the goal — the graph is never materialized.
//
// A cell is a `(Int, Int)` tuple, which is what lets it be a `Set` member and a
// `Map` key: tuples are values compared by their elements.

var maze = read grid(char)

fn open_cell(maze, p) {
    p.0 >= 0 && p.1 >= 0 && p.0 < maze.width() && p.1 < maze.height()
        && maze[p.0, p.1] != '#'
}

fn neighbours(maze, p) {
    var found = Vec()
    for step in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
        var q = (p.0 + step.0, p.1 + step.1)
        if open_cell(maze, q) { found.push(q) }
    }
    found
}

var start = (0, 0)
var goal = (maze.width() - 1, maze.height() - 1)

// The explicit loop.
var frontier = Deque()
frontier.push_back(start)
var seen = Set()
seen.insert(start)
var dist = Map()
dist[start] = 0
var answer = -1
while frontier.len() > 0 {
    var here = frontier.pop_front()
    if here == goal {
        answer = dist[here]
        break
    }
    for next in neighbours(maze, here) {
        if !seen.contains(next) {
            seen.insert(next)
            dist[next] = dist[here] + 1
            frontier.push_back(next)
        }
    }
}
out(answer)
out(seen.len())

// The same answer from the prelude helper. It answers an `Option[Int]`,
// because a goal that is not reachable has no distance.
out(bfs_distance(start, |p| neighbours(maze, p), |p| p == goal))

maze.in:

.....#....
.###.#.##.
.#...#..#.
.#.#####.#
.#.......#
.#.#####.#
...#...#..
.###.#.##.
.....#....
.#####....

Output:

22
51
Some(22)

Explained in Grids and graphs, Collections and Enums and Option.

vm.px — a stack bytecode interpreter

The one program here that is not puzzle-sized. Ten opcodes as an enum with payloads, one match per executed instruction, an operand stack in a Deque, four registers in plain bindings, and a hand-assembled program in a Vec. On the input below it retires 1,150,005 instructions and finishes in about thirty milliseconds, compilation included.

It is copied from benchmarks/praxis/vm.px, where it is the dispatch benchmark — the closest thing in the benchmark set to a “simulate this machine” puzzle part.

Three rules are load-bearing. The match over Op is checked for exhaustiveness, so an opcode added to the enum and forgotten in the loop is a compile error rather than a runtime surprise. Push(k) in a pattern binds the payload. And prog[pc] is a bounds-checked subscript: a jump target past the end of the program faults with index out of bounds and enters the crash debugger, rather than reading whatever is there.

// vm — a stack bytecode interpreter.
//
// Ten opcodes as an `enum` with payloads, one `match` per executed instruction,
// an operand stack in a `Deque`, and four registers held in plain bindings.
// This is the "simulate this machine" half of a puzzle at full size: the loop
// below retires 1.15 million instructions on the input in `vm.in`, and the
// exhaustiveness check on the `match` is what says no opcode was forgotten.
//
// It is copied from `benchmarks/praxis/vm.px`, where it is the dispatch
// benchmark.
//
// The interpreted program computes a rolling modular hash over `0..limit`; the
// interpreter reports its result and the number of instructions it retired.
//
// Input: the interpreted loop's iteration count, as one integer on stdin.
// Output: the interpreted program's result, then the instruction count.

enum Op {
    Push(Int)
    Load(Int)
    Store(Int)
    Add
    Mul
    Mod
    Lt
    JmpZ(Int)
    Jmp(Int)
    Halt
}

var limit = read int

// The program, hand-assembled. Register 0 is the loop counter, register 1 the
// accumulator; the loop head is instruction 4 and the exit target is 27.
var prog = Vec()
prog.push(Push(0))      //  0
prog.push(Store(0))     //  1   i = 0
prog.push(Push(1))      //  2
prog.push(Store(1))     //  3   acc = 1
prog.push(Load(1))      //  4   <- loop head
prog.push(Push(31))     //  5
prog.push(Mul)          //  6   acc * 31
prog.push(Load(0))      //  7
prog.push(Push(7))      //  8
prog.push(Mul)          //  9   i * 7
prog.push(Push(13))     // 10
prog.push(Add)          // 11   i * 7 + 13
prog.push(Push(1000003)) // 12
prog.push(Mod)          // 13   (i * 7 + 13) % 1000003
prog.push(Add)          // 14   acc * 31 + that
prog.push(Push(1000003)) // 15
prog.push(Mod)          // 16
prog.push(Store(1))     // 17   acc = ...
prog.push(Load(0))      // 18
prog.push(Push(1))      // 19
prog.push(Add)          // 20
prog.push(Store(0))     // 21   i = i + 1
prog.push(Load(0))      // 22
prog.push(Push(limit))  // 23
prog.push(Lt)           // 24   i < limit
prog.push(JmpZ(27))     // 25
prog.push(Jmp(4))       // 26
prog.push(Load(1))      // 27   <- exit
prog.push(Halt)         // 28

var stack = Deque()
var pc = 0
var r0 = 0
var r1 = 0
var r2 = 0
var r3 = 0
var steps = 0
var running = true

while running {
    var op = prog[pc]
    pc = pc + 1
    steps = steps + 1
    match op {
        Push(k) => { stack.push_back(k) }
        Load(k) => {
            if k == 0 { stack.push_back(r0) }
            else if k == 1 { stack.push_back(r1) }
            else if k == 2 { stack.push_back(r2) }
            else { stack.push_back(r3) }
        }
        Store(k) => {
            var v = stack.pop_back()
            if k == 0 { r0 = v }
            else if k == 1 { r1 = v }
            else if k == 2 { r2 = v }
            else { r3 = v }
        }
        Add => {
            var b = stack.pop_back()
            var a = stack.pop_back()
            stack.push_back(a + b)
        }
        Mul => {
            var b = stack.pop_back()
            var a = stack.pop_back()
            stack.push_back(a * b)
        }
        Mod => {
            var b = stack.pop_back()
            var a = stack.pop_back()
            stack.push_back(a % b)
        }
        Lt => {
            var b = stack.pop_back()
            var a = stack.pop_back()
            if a < b { stack.push_back(1) } else { stack.push_back(0) }
        }
        JmpZ(t) => {
            var v = stack.pop_back()
            if v == 0 { pc = t }
        }
        Jmp(t) => { pc = t }
        Halt => { running = false }
    }
}

out(stack.pop_back())
out(steps)

vm.in:

50000

Output:

990539
1150005

The enum’s variants are separated by line breaks rather than commas. Either works: a comma or a line break separates the members of a struct or enum body, and a trailing comma closes the list either way — the same rule that separates statements and match arms.

Explained in Enums and Option, Pattern matching and The fault model.

Where the rest of the corpus lives

These seven are a selection. The repository carries larger sets, run by the test suite rather than by this book:

directorywhat is in it
tests/aoc-corpus/31 fixtures, one per input shape and per language feature, each with its .out and with an .in when it reads input
tests/input-parsers/12 fixtures for the read DSL: one constructor, template or whitespace rule each
benchmarks/praxis/eight larger programs: bfs, collatz, hashwork, mandelbrot, pipeline, primes, tree, vm
crates/praxis-cli/tests/fixtures/programs whose diagnostics are the fixture, and run/, whose programs are driven end to end

None of those directories is documentation. Where one of them disagrees with a chapter of this book, the chapter is the one that was checked against a running compiler.

Appendix B: Grammar

The concrete grammar, derived from the code that implements it: crates/praxis-parser/src/lex.rs for the token set, crates/praxis-parser/src/parse.rs for the productions and the precedence table, crates/praxis-syntax/src/kind.rs for the node kinds, and crates/praxis-input-parser/ for the read DSL.

This is a reference, not a specification. The parser is recursive descent with a Pratt loop for infix operators, it produces a lossless tree that retains every byte including trivia, and it recovers from an error rather than stopping — so a production below describes what is accepted, and every rejection carries a P0xx or T0xx diagnostic rather than a silent reinterpretation.

Notation

x?          zero or one
x*          zero or more
x+          one or more
a | b       alternatives
"..."       a literal token
UPPER       a lexical class (Ident, IntLit, …)
NEWLINE     a line break in the trivia before the next token — not a token

NEWLINE is written where the grammar consults the line break, which is only in three places (see Two ambiguity rules). It is trivia everywhere else.

Lexical structure

Trivia

kindspelling
whitespaceany run of space, tab, CR, LF
line comment// to the end of the line
block comment/* … */, nestable

/* outer /* inner */ still outer */ is one comment. Trivia is kept in the syntax tree, which is what lets the editor tooling and the crash debugger point at exact source ranges.

Identifiers

An identifier is a Unicode identifier: one XID_Start scalar (or _) followed by XID_Continue scalars. λ, _x, snake_case and x_ are all names.

A lone _ is not an identifier. It is its own token and it is legal only in binding positions — var _ = f(), fn g(_), |_| 0, and a wildcard pattern — where it introduces no name. Reading _ as a value is P001: expected an expression.

Keywords

Seventeen, and that is the whole list:

var  fn  if  else  while  for  in  loop  match  return  break  continue
read  struct  enum  true  false

Everything else is an identifier. out, panic, dbg, assert, Int, Text, Vec, Map, min, max, parse, lines, int — none of them is a keyword. They are prelude names, type names, or names the grammar recognizes by position: parse is syntax only when followed directly by (, min/max form an assignment operator only when followed immediately by =, and the parser-DSL names mean anything only inside a read or parse body.

Literals

digits    := digit ("_"? digit)*
IntLit    := digits
FloatLit  := digits "." digits exponent?
           | digits exponent
exponent  := ("e" | "E") ("+" | "-")? digits
TextLit   := '"' (char | escape)* '"'          -- no unescaped "{" in it
CharLit   := "'" (char | escape) "'"

interp    := InterpOpen expr (InterpMiddle expr)* InterpClose
InterpOpen   := '"' (char | escape)* "{"
InterpMiddle := "}" (char | escape)* "{"
InterpClose  := "}" (char | escape)* '"'

A { in a text literal opens an interpolation hole, so a literal holding one is not a TextLit at all: it lexes as the fragment run above, with the hole’s ordinary expression tokens between the fragments. Each fragment carries a delimiter at both ends, so the token stream still tiles the source. A \{ is a literal brace and opens nothing; a } outside a hole closes nothing and needs no escape. A literal that does not close on its line is one TextLit plus T004, holes or not — the lexer splits only a literal it has already proved closes.

A _ between digits belongs to the literal: 1_000, 3.141_592 and 1e1_0 are each one token. A trailing _ is not — 1_ is 1 followed by the wildcard token.

Three rules keep a . out of a number where it should not be:

  • A . opens a fraction only when a digit follows it. So 1..5 is a range, 1.method() is a method call, and 2. is the integer 2 followed by .. There are no leading-dot floats: write 0.5.
  • A digit run immediately after a bare . token is a tuple index and takes no fraction. t.0.1 is two indices, not an index and the float 0.1.
  • 1.5..2.5 is two floats and a range, because the . in front of the 2 was consumed into the ...

A text literal’s escapes are \", \\, \`, \n, \r, \t, \0. Any other backslash is T005. A raw newline inside a text literal is not allowed.

A character literal holds exactly one Unicode scalar value, and takes a text literal’s escapes plus \' — there are no \x or \u{…} forms, because a text literal has none either. One escape table serves both spellings, so \n cannot mean one thing inside '…' and another inside "…". 'é' is one character, not two bytes. A body that names no character ('') or more than one ('ab') is T007, and an unterminated literal is T006 naming its own line — the same rule a template follows. Between them those two codes are why "##"[0] is a diagnostic rather than a silent truncation, and ""[0] a diagnostic rather than an index fault at run time.

A backtick template is one token, interior and all. It ends at the first backtick at brace depth zero — so a capture may hold a nested template (`{g:choice(A: `{x:int}`)}`) and a brace inside a string (`{c:one_of("{")}`) does not extend it. A template ends at the line it opens on; a raw newline may not appear inside one, and an unterminated template is T002 naming its own line. A raw newline has no whitespace policy of its own, so one inside a template would fall through to literal text and match an LF but not a CRLF. \n matches either, and it is how a template spells a line ending.

Operators and punctuation

Lexed by longest match, so the multi-character forms are never their prefixes:

->   =>   ==   !=   <=   >=   ..   ..=   ||   &&
+=   -=   *=   /=   %=
(  )  {  }  [  ]  ,  .  :  ;  |  &  +  -  *  /  %  =  !  <  >  ?  #

&& and || are single tokens, so a bare & is never half of one. #, & and ? are lexed but have no production: writing one is a parse error where it stands.

Two ambiguity rules

Two shapes in this grammar are genuinely ambiguous, and both are settled by position rather than by a new token. They are stated here because both change how ordinary code parses.

A newline ends a statement, and never an expression

Statements are separated by ;, a line break, or the closing }/end of file — and by nothing else. Two statements run together on one line is P002.

// A newline ends a statement, and nothing else does except `;` and the closing
// brace. Two statements run together on one line have no separator, and the
// parser says so rather than guessing.
var a = 1 var b = 2
out(a + b)
$ praxis check statement_separator.px --color never
error[P002]: expected `;` or a line break between statements

  statement_separator.px:4:11
  4 | var a = 1 var b = 2
    |           ^^^ expected `;` or a line break between statements

praxis: 1 error(s)

The line break is consulted in exactly three places, and nowhere in the infix operator loop:

  1. Between statements, and between the members of a struct/enum body and the arms of a match (where it is interchangeable with a comma).
  2. After break and return: a value follows only if it is on the same line.
  3. In front of a ( or a [, and in front of the ( of a would-be method call. A line-leading bracket begins an expression instead of continuing the one above it, so a tuple pattern on the line after a match arm body is not read as a call.

The stated cost of rule 3: a call whose callee ends one line and whose argument list begins the next is two expressions, and so is a subscript whose receiver and bracket are split the same way. Nothing warns about it — the third case in the program below type-checks and runs, and praxis check says nothing at all — and the fix is to move the bracket up a line.

// A newline ends a *statement*. It never ends an expression — but a `(` or a
// `[` that begins a line starts something new rather than continuing the
// expression above it.

// One expression across three lines: the operator loop never looks at line
// breaks.
var a = 1 +
    2 +
    3
out(a)

// A method chain across lines, for the same reason: the `.` continues.
var b = [1, 2, 3]
    .map(|x| x * 2)
    .sum()
out(b)

// But a line-leading `[` opens a list literal. `d` is `c`, and the `[0]` on the
// next line is a separate expression statement — not a subscript.
var c = [1, 2, 3]
var d = c
    [0]
out(d)

// `break` takes a value only when the value is on the same line. Here it is,
// so the loop yields it.
var n = 0
var found = loop {
    n = n + 1
    if n > 2 { break n }
}
out(found)

// Here it is not: the `break` is value-less and the loop yields Unit. The line
// after it is a separate statement, and unreachable.
var m = 0
var nothing = loop {
    m = m + 1
    if m > 2 {
        break
        out(m * 100)
    }
}
out(nothing)
6
12
[1, 2, 3]
3
Unit

if p { … } could be a record literal p { … }, or the condition p followed by the then-block. Four keyword heads have the problem — the conditions of if and while, for’s iterator, match’s scrutinee — and all four resolve it by suppressing a bare Name { … } in the head expression and in its operands.

Every bracket re-admits it. Inside (…), […], an argument list, a block or a match arm body, the grammar already knows what closes the enclosing construct, so no { there can be the block a keyword is waiting for.

A closure body inherits the ambient suppression rather than resetting it: | is not a bracket the grammar closes over.

// A record literal is legal wherever the `{` cannot be a block.
//
// The four keyword heads — `if` and `while` conditions, `for`'s iterator,
// `match`'s scrutinee — claim the next `{` as their body, so a bare
// `Name { … }` is suppressed there and in every operand of the head expression.
// Every bracket re-admits it: inside `(…)`, `[…]`, an argument list, a block,
// or a match arm body, nothing else is waiting for that brace.

struct Point { x: Int, y: Int }

var origin = Point { x: 0, y: 0 }

// Suppressed at the head, allowed again inside the parentheses.
if (Point { x: 0, y: 0 }) == origin { out("same") }

// Allowed in an argument list, in a list literal, and in a match arm body.
fn shift(p, dx) { Point { x: p.x + dx, y: p.y } }
out(shift(Point { x: 1, y: 2 }, 3).x)
out([Point { x: 4, y: 5 }].len())
out(match origin.x {
    0 => Point { x: 9, y: 9 }
    _ => origin
}.x)
same
4
1
9

Files, statements and declarations

source_file  := statement*

statement    := var_stmt
              | fn_item
              | struct_item
              | enum_item
              | assign_stmt
              | expr_stmt
              -- separated by ";" | NEWLINE | end of block

var_stmt     := "var" binder (":" type)? "=" expr breakpoint?
binder       := Ident | "_"

assign_stmt  := Ident assign_op expr breakpoint?    -- a bare name target
              | expr assign_op expr breakpoint?     -- a place: m[k], p.x
              | expr update_op expr breakpoint?     -- m[k] min= v
assign_op    := "=" | "+=" | "-=" | "*=" | "/=" | "%="
update_op    := ("min" | "max") "="                 -- adjacent, no space

expr_stmt    := expr breakpoint?

breakpoint   := ":" "bp"                            -- adjacent, no space

fn_item      := "fn" Ident ("(" param_list? ")")? ("->" type)? block
param_list   := param ("," param)* ","?
param        := binder (":" type)?

struct_item  := "struct" Ident "{" (field (member_sep field)* member_sep?)? "}"
field        := Ident ":" type

enum_item    := "enum" Ident "{" (variant (member_sep variant)* member_sep?)? "}"
variant      := Ident ("(" type ("," type)* ","? ")")?

member_sep   := "," | NEWLINE

Parameter and return annotations are both optional, and so is the parameter list itself; an unannotated parameter’s type is inferred from use.

A min= / max= operator is two tokens because min is an ordinary identifier, so the grammar decides it by adjacency exactly as it does for +=: the = must immediately follow the name with no trivia between. Written with a space, m[k] min = v is not that operator, and the run-on is P002.

The :bp breakpoint marker is settled the same way, for the same reason: bp is an ordinary identifier, so the : and the name must be adjacent, and : bp is not a marker. There is one position in the grammar where a : can follow a finished statement, which is why the marker needs no keyword of its own.

A block’s value is its trailing expression:

block        := "{" statement* "}"

Expressions

expr         := prefix (infix_op expr)*     -- folded by the precedence table

prefix       := "read" parser_expr
              | closure
              | ("-" | "!") expr
              | atom
              postfix*

postfix      := "(" arg_list? ")"           -- same line as what it follows
              | "[" arg_list "]"            -- same line
              | "." IntLit                  -- tuple element
              | "." Ident "(" arg_list? ")" -- method call, "(" on the same line
              | "." Ident                   -- field

arg_list     := expr ("," expr)* ","?

atom         := literal
              | interp                      -- "a{expr}b"
              | "(" ")"                     -- Unit
              | "(" expr ")"                -- grouping
              | "(" expr ("," expr)* ","? ")"  -- tuple: the first "," makes it one
              | "[" arg_list? "]"           -- list literal (a Vec)
              | anon_record_lit             -- a `{` a block cannot explain
              | block
              | if_expr | while_expr | for_expr | loop_expr
              | break_expr | continue_expr | return_expr | match_expr
              | name_or_call

literal      := IntLit | FloatLit | TextLit | CharLit | BacktickTemplate
              | "true" | "false"

-- `interp` is an atom, not a literal: its holes are expression subtrees, so it
-- has children where a literal is a leaf.

name_or_call := "parse" "(" expr "," parser_expr ")"
              | Ident type_arg_list? "(" arg_list? ")"
              | Ident "{" record_field_list? "}"   -- record literal, if allowed
              | Ident

type_arg_list    := "[" type ("," type)* ","? "]"
record_field_list:= record_field ("," record_field)* ","?
record_field     := Ident (":" expr)?        -- `{ x }` puns, `{ x: e }` is explicit

-- The anonymous form has no head, so nothing separates it from a block but what
-- follows the `{`: a name then a `:` (that is not the `:bp` marker), or a name
-- then a `,`. Neither can begin a statement, so neither can begin a block; every
-- other `{` here is the block it already was, `{ x }` included.
anon_record_lit  := "{" Ident ":" expr ("," record_field)* ","? "}"
                  | "{" Ident "," record_field ("," record_field)* ","? "}"

closure      := "|" (cparam ("," cparam)* ","?)? "|" expr
              | "||" expr                    -- the zero-parameter form
cparam       := pattern (":" type)?

if_expr      := "if" expr_no_record block ("else" (if_expr | block))?
while_expr   := "while" expr_no_record block
for_expr     := "for" pattern "in" expr_no_record block
loop_expr    := "loop" block
break_expr   := "break" expr?                -- value only on the same line
continue_expr:= "continue"
return_expr  := "return" expr?               -- value only on the same line
match_expr   := "match" expr_no_record "{" (arm (arm_sep arm)* arm_sep?)? "}"
arm          := pattern "=>" expr
arm_sep      := "," | NEWLINE

expr_no_record is expr with the record-literal suppression of the second ambiguity rule.

A ( with no comma in it is a grouping; the first comma makes it a tuple, and a trailing comma closes the list. A tuple has two elements or more, so (1,) is refused at the comma — P001: a tuple has two elements or more, so this comma names nothing — and the node recovers as the grouping (1). The same rule holds in type position: (Int,) is refused where (Int, Text,) is fine.

Refusing it at the comma is what keeps the tree and the type agreeing about one node. There is no arity-one tuple type for such a node to have: inference collapses it back to the element type, while lowering, reading the node kind, builds a tuple object — and two passes that disagree about one node do not say so until several passes later, nowhere near the comma.

An empty () parses and evaluates to Unit. A [ always builds a list, at every arity including the empty [], whose element type comes from its use.

type_arg_list is legal after exactly eleven names — Vec, Deque, Map, Set, Counter, MinHeap, MaxHeap, BitSet, Grid, Range, Option — and only immediately before a (. Nothing else can tell Counter[(Int, Int)]() from m[key]: the brackets are the same two characters and (Int, Int) is a legal tuple expression, so the name breaks the tie. The stated cost is that a binding shadowing one of those names cannot be subscripted.

read takes a parser expression, not an ordinary one, so its operand ends where the parser grammar ends: read int + 1 is (read int) + 1.

Operator precedence

The Pratt table, loosest first. Every infix operator is left-associative.

binding poweroperatorsnotes
1||logical or
3.. ..=a range is its own node, not a binary operator
5&&logical and
7== != < > <= >=parsed left-associative
9+ -
11* / %
13prefix - !binds tighter than every infix operator

Consequences worth knowing:

  • a || b && c is a || (b && c), and a == b && c == d is (a == b) && (c == d).
  • 0..n - 1 is 0..(n - 1). A range bound is an arithmetic expression, and this is the precedence that lets one be written without parentheses.
  • Comparison binds tighter than .., so 0..3 == 0..3 parses as (0..(3 == 0))..3 and is a type error rather than a range comparison.
  • -a * b is (-a) * b, and !p && q is (!p) && q.

Where && sits relative to .. is arbitrary: a range of Bools and a range bound that is a && are both nonsense, so no program can tell the difference.

Assignment is not an infix operator. = and += are statement-level, so a = b = c does not parse as an expression.

Patterns

pattern       := "_"
               | IntLit | TextLit | CharLit | "true" | "false"
               | Ident                                       -- bind, or a payload-less variant
               | Ident "(" pattern ("," pattern)* ","? ")"   -- variant with payload
               | Ident "{" pattern_field_list "}"            -- record
               | "{" pattern_field_list "}"                  -- headless record
               | "(" pattern ("," pattern)* ","? ")"         -- tuple

pattern_field_list := pattern_field ("," pattern_field)* ","?
pattern_field      := Ident (":" pattern)?     -- `{ x }` puns, `{ x: p }` is explicit

A pattern’s { is never ambiguous the way a record literal’s is: a pattern is followed by =>, by in, or by the | that closes a closure’s parameters — never by a block. That is also what makes the head optional — a leading { in pattern position can only open fields, so for {x, y} in points and |{x, y}: Point| x + y need no new token. A headless record pattern still has to learn which record it matches from somewhere: the for gets it from the iterator, and the closure needs the annotation shown, or it is Y123: { … } cannot tell which record it matches here.

Parentheses in pattern position are always a tuple. There is no grouping form, because a pattern has no precedence to override, so (p) is a one-element tuple pattern — which is Y123: a tuple pattern names two elements or more, whatever the scrutinee is. () and a headless {} are rejected at the parser: they bind nothing and test nothing, and the pattern that matches anything is spelled _.

An interpolated text literal is not a pattern either. A pattern tests a constant and a hole is an expression evaluated where it stands, so match s { "{x}" => … } is refused at the literal rather than read as an arm that binds x and matches everything.

Patterns appear in three binding positions and they are one grammar in all three: match arms, the for binding, and closure parameters.

Types

type      := atom_type ("->" type)?                -- function type, right-associative
atom_type := Ident ("[" type ("," type)* ","? "]")?
           | "(" (type ("," type)* ","?)? ")"      -- tuple (2+) or grouped type

A -> B -> C is A -> (B -> C). A parenthesized single type is that type; two or more is a tuple. An unknown identifier parses fine here and is rejected by name resolution — so a typo in a type annotation is N002: unknown type and not a syntax error. Unlike N001, which suggests the nearest name in scope, N002 carries no suggestion.

The input-parser grammar

read and parse are the two doors into a second grammar, implemented in crates/praxis-input-parser/. Whitespace and comments between its tokens are insignificant; whitespace inside a backtick template is significant and has its own rules.

read_expr    := "read" parser_expr
parse_expr   := "parse" "(" expr "," parser_expr ")"

parser_expr  := atomic | template | call

atomic       := Ident                          -- one of the ten names below
call         := Ident "(" (arg ("," arg)* ","?)? ")"

arg          := parser_expr                    -- positional parser, or a bare flag
              | TextLit                        -- a string literal
              | Ident ":" parser_expr          -- a named argument
              | Ident ":" literal              -- a keyword argument's value

Atomic parsers

A closed set of ten:

namematchesyields
intsigned decimal integerInt
uintnon-negative decimal integerInt
floatdecimal floating-point numberFloat
bytea decimal integer in 0..=255Byte
charone Unicode scalarChar
digitone decimal digitInt
worda run up to the next space, tab, comma or line breakText
identifieran identifier run (the language’s own rule)Text
textthe whole region, which the literal after it boundsText
restthe whole regionText

text and rest are one rule — take the region — and differ only in what usually surrounds them. The bound is the first occurrence of the literal that follows, and there is no backtracking: `{a:text}-{b:word}` reads x-y-5 as a = x, b = y-5, and `{a:text}-{b:int}` on the same line faults rather than trying the second -.

A name that is not on this list and is not followed by ( is I010: unknown atomic parser, reported at the name — not a silent fallback to int.

Templates

template      := "`" template_part* "`"
template_part := literal_run | ws_escape | capture
capture       := "{" Ident ":" parser_expr "}"    -- named
               | "{" parser_expr "}"              -- anonymous

A capture body is a full parser expression, including a constructor call and a nested template: {items:csv(int)} and {g:choice(A: `{x:int}`)} both parse. The closing } is found brace-, paren-, string- and template-aware, so {c:one_of("}")} and {xs:sep(",", int)} close where they should. Nesting — of templates, of {, and of ( — is bounded at 32 levels, and exceeding it is a diagnostic rather than a stack overflow.

Whitespace inside a template:

writtenmatches
a run of spaces or tabsone or more spaces or tabs (flexible, for column alignment)
\s*zero or more spaces or tabs
\s+one or more spaces or tabs
\x20exactly one ASCII space
\none line ending
\tone tab

A space run at either end of a literal is flexible whitespace and not part of the text it borders.

The only other escapes inside a template are \` and \\, which stand for those characters. There is no escape for { or }\{ is I030: invalid escape.

Named and anonymous captures may not be mixed in one template — that is I020. All-named produces a record, one anonymous capture produces the scalar, and several anonymous captures produce a tuple.

Constructors and their argument shapes

Thirteen constructors, with the shape each one’s argument list must have, plus the repeated(P) marker below the table:

constructorshape
lines(P)one parser
sections(P)one parser (homogeneous)
sections(name: P, …)named arguments only (heterogeneous); any may be name: repeated(P, N), and the last may be name: repeated(P)
csv(P)one parser
ws(P)one parser
sep("s", P)a string literal, then a parser
grid(P)one parser
grid(P, ragged, fill: v)ragged and fill: come together or not at all
matrix(P)one parser
chars(P) / chars(P, skip: policy)one parser and an optional skip:
one_of("set")one string literal
block(item, …)one or more parsers and/or name: P items
choice(Name: P, …)named arguments only, at least one
optional(P)one parser
scan(P)one parser
repeated(P)a sections named argument, and only its last: it takes every section left
repeated(P, N)any sections named argument: it takes exactly N sections

skip: takes none, whitespace or newlines. fill: takes a non-empty literal, which is the text a short row is padded with — it is not checked against the cell parser, so grid(char, ragged, fill: 0) pads with the character 0. repeated(...) is not a parser in its own right: outside a named argument of a sections call it is I028. The uncounted repeated(P) is greedy, so it is also I028 anywhere but last; repeated(P, N) is bounded and may be followed. N is a whole-number literal of at least 1 — the parser plan is built when the program is compiled, so a count read from a value cannot exist.

A constructor’s argument-list shape is checked before anything is built, so a wrong argument is reported rather than dropped. That table is also what decides which of three things a bare identifier is: an atomic parser, a flag (ragged), or a keyword value (whitespace, newlines, none). The grammar cannot tell them apart and does not try — ragged is a flag in grid and a name lines will reject — so the constructor it was written in settles it, after parsing.