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
readexpression 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:
| recipe | what it does |
|---|---|
just build | cargo build --workspace |
just test | cargo test --workspace |
just clippy | cargo clippy --workspace --all-targets -- -D warnings |
just fmt | cargo fmt — modifies files |
just fmt-check | cargo fmt --check |
just book | renders the book to docs/book/book |
just book-verify | re-runs every example in the book against this compiler |
just ci | fmt-check, clippy, test, then the book’s examples |
just asan | the whole suite under AddressSanitizer, on a nightly toolchain |
just publish-plan | the crates.io publish order, and what is already up |
just publish-dry | packages 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.
| value | behaviour |
|---|---|
auto (default) | enter the interactive crash debugger iff stdin and stdout are both terminals |
always | enter the debugger regardless, reading commands from stdin |
never | print 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.
| value | behaviour |
|---|---|
auto (default) | style output iff stderr is a terminal |
always | style even when piped |
never | plain 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
| code | meaning |
|---|---|
0 | success — the program ran to completion, or check found no errors |
1 | the file has errors and was not run, or it ran and faulted |
2 | usage 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
Never — var 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”:
%=isInt-only, because%is.f %= 2.0isY016, the same refusalf % 2.0gets. The other four are defined forFloat.+=on aTextis 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.
| Type | Payload | Written as |
|---|---|---|
Int | signed 64-bit | 42, 1_000_000 |
Float | IEEE-754 binary64 | 3.5, 1e10, 2e-3 |
Bool | true or false | true, false |
Char | one Unicode scalar value | 'p' |
Text | immutable UTF-8 | "praxis" |
Unit | nothing | () |
// 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.
Intcompares as a signed 64-bit number.Floatcompares by IEEE-754, so any comparison involvingNaNisfalse.Charcompares by Unicode scalar value.Textcompares 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
Intarithmetic,Floatsemantics, and the full operator and precedence table. - Text and Char — concatenation, indexing, iteration, and the two
Charconversions. - 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:
add | sub | mul | |
|---|---|---|---|
wrapping_ | wrapping_add | wrapping_sub | wrapping_mul |
saturating_ | saturating_add | saturating_sub | saturating_mul |
checked_ | checked_add | checked_sub | checked_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:
| Operators | Kind | Assoc. | Notes |
|---|---|---|---|
f(x) x[i] x.name x.name(...) x.0 | postfix | left | a ( or [ continues the expression before it only on the same line |
read | prefix | — | its body is a parser expression |
- ! | prefix | — | - negates a number, ! negates a Bool |
* / % | infix | left | % is Int only |
+ - | infix | left | + also concatenates Text |
== != < > <= >= | infix | left | result is Bool |
&& | infix | left | short-circuits |
.. ..= | infix | left | builds a Range |
|| | infix | left | short-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-only — min(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 theCharwith that scalar value. It is the narrowing half, so it faults on a negative value, on anything above0x10FFFF, and on a surrogate.Char.to_text()answers the one-characterTextholding it — the same characteroutwrites. It never faults, because aCharis 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_lower — to_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:
| Method | Result | Notes |
|---|---|---|
t.len() | Int | number of Unicode scalars |
t.is_empty() | Bool | true iff no scalars |
t.get(i) | Char | faults if out of range |
t[i] | Char | the 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:
| Written | Answers |
|---|---|
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 text | t.len() | t[i] | for c in t |
|---|---|---|---|
| owned, all ASCII | O(1) after the first call | O(1) | O(n) |
| owned, has a multi-byte scalar | O(1) after the first call | O(i) | O(n²) |
| slice of an ASCII owner | O(1) | O(1) | O(n) |
| slice of a multi-byte owner | O(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
- Scalars — literals, escapes, and why there is no
'a'. - The
readexpression — how text turns into structure. - Pipelines — the combinators a
Textaccepts.
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
Bool — if 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.
| Iterable | Order |
|---|---|
Vec, Deque, Range, Text | in place, by index |
Set | ascending by member |
Map, Counter | ascending by key |
BitSet | ascending bit |
MinHeap, MaxHeap | pop order |
Grid | row-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 }isInt.loop { break }isUnit— a barebreakleaves with nothing, so mixingbreakandbreak 1in one loop is aY001rather than a coincidence that happens to work.loop { }isNever: it produces no value at all, so it absorbs into whatever sits beside it.if n > 0 { n } else { loop { } }is anInt.
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 thatfn’s own local, is fine. It captures something the function has. - A closure inside a
fn, using a binding declared outside thatfn, isN007. The closure is insideg; 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
| Signature | Absent 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,
trueandfalse. There is no float pattern, and no negative literal —-1in 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 isP001: 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 isY115, fieldxis matched more than once, because the second binding would silently replace the first. P {}is a record pattern, not a binding. BarePbinds the whole value under the nameP;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 isY121.- 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 andY123reports it:a tuple pattern names two elements or more.()gets the sameY123, 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.
| type | what it is | key or element requirement |
|---|---|---|
Vec[T] | growable ordered sequence; has a literal | none |
Deque[T] | double-ended queue | none |
Map[K, V] | hash map | K immutable and hashable |
Set[T] | hash set | T immutable and hashable |
Counter[T] | map whose absent values read as zero | T immutable and hashable |
MinHeap[T] | priority queue, smallest first | T orderable |
MaxHeap[T] | priority queue, largest first | T orderable |
BitSet | compact set of non-negative Ints | — (members are Int) |
Grid[T] | rectangular 2-D array | none |
Range | half-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.
| receiver | x[i] reads | x[i] = v stores | min= / max= |
|---|---|---|---|
Vec[T] | T, faults out of range | replaces, faults out of range | — |
Deque[T] | T at 0-based front offset | replaces, faults out of range | — |
Text | Char by Unicode scalar | — (a Text is immutable) | — |
Map[K, V] | V, faults if absent | sets, replacing any prior value | yes, when V is Int |
Counter[T] | Int, zero if absent | sets 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
| method | answer |
|---|---|
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] = x | see 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
| method | answer |
|---|---|
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] = x | 0-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
| method | answer |
|---|---|
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= v | see 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
| method | answer |
|---|---|
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.
| method | answer |
|---|---|
get(T) | Int — zero if absent, never faults |
inc(T) | Unit — add one |
len(), is_empty() | Int, Bool — len counts distinct keys |
keys() | Vec[T] |
values() | Vec[Int] |
c[k], c[k] = n | see 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
| method | answer |
|---|---|
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.
| method | answer |
|---|---|
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.
| receiver | for yields | order | how |
|---|---|---|---|
Vec[T] | T | index order | walked in place |
Deque[T] | T | front to back | walked in place |
Range | Int | ascending | walked in place |
Text | Char | by Unicode scalar | walked in place |
Set[T] | T | ascending by member | one snapshot |
BitSet | Int | ascending numerically | one snapshot |
MinHeap[T] | T | pop order (ascending) | one snapshot |
MaxHeap[T] | T | pop order (descending) | one snapshot |
Grid[T] | T | cells, row-major | one snapshot |
Map[K, V] | (K, V) | ascending by key | two aligned snapshots |
Counter[T] | (T, Int) | ascending by key | two 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
GridandRange. There is noto_grid()— a grid needs a width and an item sequence does not carry one — and noto_range(), because aRangeis writtena..brather than built from members. - The conversion has to typecheck:
to_mapneeds an item that is a(K, V)pair andto_countera(T, Int)pair, so[1, 2, 2].to_counter()reportsexpected (?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 ofText, andchars.to_text()on aVec[Char]. They are two rows rather than one because a genericjoinand aChar-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:
| Receiver | Item |
|---|---|
Vec[T], Deque[T], Set[T], MinHeap[T], MaxHeap[T] | T |
Range, BitSet | Int |
Text | Char |
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.
| Stage | Argument | Result |
|---|---|---|
map(f) | (T) -> U | Vec[U] |
filter(p) | (T) -> Bool | Vec[T] |
filter_map(f) | (T) -> Option[U] | Vec[U] |
flat_map(f) | (T) -> Vec[U] | Vec[U] |
take(n) | Int | Vec[T] |
skip(n) | Int | Vec[T] |
take_while(p) | (T) -> Bool | Vec[T] |
enumerate() | — | Vec[(Int, T)] |
zip(other) | Vec[U] | Vec[(T, U)] |
Sinks. Each ends the chain with one value.
| Sink | Argument | Result |
|---|---|---|
sum() | — | Int — the item must be Int |
product() | — | Int — the item must be Int |
count() | — | Int |
count(p) | (T) -> Bool | Int |
min(), max() | — | Int — the item must be Int; faults on an empty sequence |
min_by(lt), max_by(lt) | (T, T) -> Bool | T — faults on an empty sequence |
any(p), all(p) | (T) -> Bool | Bool |
find(p) | (T) -> Bool | Option[T] |
position(p) | (T) -> Bool | Option[Int] |
fold(init, f) | Acc, (Acc, T) -> Acc | Acc |
reduce(f) | (T, T) -> T | T — faults on an empty sequence |
Conversions. Also sinks, so they fuse into the same loop.
| Conversion | Result |
|---|---|
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.
| Barrier | Argument | Result |
|---|---|---|
sorted() | — | Vec[T] — T must be orderable |
sorted_by_key(f) | (T) -> K | Vec[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) | Text | Text — the items must be Text |
chunks(n) | Int | Vec[Vec[T]] — consecutive runs of n, last may be short |
windows(n) | Int | Vec[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 Counter — sorted 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.
| Call | Answers | Notes |
|---|---|---|
grid[x, y] | T | faults off the grid |
grid[x, y] = v | Unit | faults off the grid |
grid.get(x, y) | T | the same read as the subscript |
grid.set(x, y, v) | Unit | the same store as the subscript |
grid.width() | Int | columns |
grid.height() | Int | rows |
grid.contains(x, y) | Bool | never 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) | Int | orthogonal neighbours holding v; off-grid does not count |
grid.count8(p, v) | Int | all eight; off-grid does not count |
grid.count4_where(p, f) | Int | orthogonal neighbours whose cell f accepts |
grid.count8_where(p, f) | Int | all 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.
| Helper | Signature |
|---|---|
bfs | forall T. (T, (T) -> Vec[T]) -> Vec[T] |
bfs_distance | forall T. (T, (T) -> Vec[T], (T) -> Bool) -> Option[Int] |
bfs_path | forall T. (T, (T) -> Vec[T], (T) -> Bool) -> Option[Vec[T]] |
dfs | forall T. (T, (T) -> Vec[T]) -> Vec[T] |
dfs_distance | forall T. (T, (T) -> Vec[T], (T) -> Bool) -> Option[Int] |
dfs_path | forall T. (T, (T) -> Vec[T], (T) -> Bool) -> Option[Vec[T]] |
dijkstra | forall T. (T, (T) -> Vec[T], (T, T) -> Int) -> Map[T, Int] |
dijkstra_distance | forall T. (T, (T) -> Vec[T], (T, T) -> Int, (T) -> Bool) -> Option[Int] |
dijkstra_path | forall T. (T, (T) -> Vec[T], (T, T) -> Int, (T) -> Bool) -> Option[Vec[T]] |
a_star_distance | forall T. (T, (T) -> Vec[T], (T, T) -> Int, (T) -> Int, (T) -> Bool) -> Option[Int] |
a_star_path | forall T. (T, (T) -> Vec[T], (T, T) -> Int, (T) -> Int, (T) -> Bool) -> Option[Vec[T]] |
flood_fill | forall 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.
bfsanddfsanswer aVec[T]in the order they reached it,flood_fillaSet[T],dijkstraaMap[T, Int]of least costs. None of them needs anOption, because all of them contain the state you started from. _distanceanswersOption[Int]— the cost of the route the search reaches a goal by, orNonewhen no goal is reachable. Forbfs_distanceanddfs_distancethe cost is the step count, one per edge._pathanswersOption[Vec[T]]— that same route, start to goal inclusive, so a route holds exactly one more state than the matching_distancecounts steps. TheOptionis the sameOption: a found route always holds at least its own start, so an emptyVeccould 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:
| Cause | What the program prints |
|---|---|
| a negative edge weight | an argument this algorithm has no answer for |
a negative heuristic, which makes g + h fall along a path | an argument this algorithm has no answer for |
a path cost or step count that leaves Int | integer overflow |
| a fault raised inside one of your closures | that 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
| Name | Signature | What it does |
|---|---|---|
out | (T) -> Unit | Write one value to stdout, followed by a newline. |
dbg | (T) -> T | Write one value to stderr and return it unchanged. |
panic | (T) -> Never | Stop with an explicit message and raise a fault. |
assert | (Bool) -> Unit | Stop 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.
| Name | Signature | What it does |
|---|---|---|
abs | (Int) -> Int | Absolute value. Faults on Int’s minimum, which has no positive counterpart. |
sign | (Int) -> Int | -1, 0 or 1. Total. |
min | (Int, Int) -> Int | The smaller of two. |
max | (Int, Int) -> Int | The larger of two. |
clamp | (Int, Int, Int) -> Int | clamp(value, low, high). Faults if low > high. |
gcd | (Int, Int) -> Int | Non-negative greatest common divisor. gcd(0, 0) is 0. |
lcm | (Int, Int) -> Int | Non-negative least common multiple; 0 if either operand is 0. Faults if the result leaves Int. |
pi | () -> Float | π. |
e | () -> Float | Euler’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.
| Name | Signature | Notes |
|---|---|---|
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 | () -> BitSet | Compact 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
| Name | Signature | What it is |
|---|---|---|
Option | — | The type name. Option[T] is a legal annotation. |
Some | (T) -> Option[T] | Wrap a value. |
None | Option[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.
| Name | Signature | Answers |
|---|---|---|
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:
| Name | Fault | When |
|---|---|---|
panic | panic | Always. That is the point. |
assert | assertion failed | The condition was false. |
abs | integer overflow | The argument is Int’s minimum. |
clamp | empty range | low > high. There is no value to answer with, and inventing one would be a guess. |
gcd | integer overflow | Only for Int’s minimum with itself, whose answer is 2⁶³. |
lcm | integer overflow | The multiple does not fit an Int, which happens easily. |
| the twelve graph walks | whatever the closures raise | Your neighbour, weight, heuristic or goal function faulted. |
Vec(n, fill) | size or extent out of range | n is negative, or larger than the runtime will allocate at a stroke (2²⁸). |
Grid(w, h, fill) | size or extent out of range | An 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]
| Method | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|
get(Int) | T | no | yes | no | The element at index; faults IndexOutOfBounds if out of range. |
is_empty() | Bool | no | no | no | True iff the vector has no elements. |
len() | Int | no | no | yes | Number of elements in the vector. |
push(T) | Unit | yes | yes | yes | Append a value to the end; returns Unit. |
to_text() | Text | no | yes | yes | These 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]
| Method | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|
get(Int) | T | no | yes | no | The element at index (0-based from the front); faults if out of range. |
is_empty() | Bool | no | no | no | True iff the deque has no elements. |
len() | Int | no | no | yes | Number of elements in the deque. |
pop_back() | T | yes | yes | no | Remove and return the back element; faults if empty. |
pop_front() | T | yes | yes | no | Remove and return the front element; faults if empty. |
push_back(T) | Unit | yes | yes | yes | Append a value to the back; returns Unit. |
push_front(T) | Unit | yes | yes | yes | Prepend 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]
| Method | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|
contains(K) | Bool | no | no | no | True iff key is present in the map. |
get(K) | Option[V] | no | no | yes | The value for key as Some(value), or None if absent. |
insert(K, V) | Unit | yes | no | yes | Set key to value, replacing any prior value; returns Unit. |
is_empty() | Bool | no | no | no | True iff the map has no entries. |
keys() | Vec[K] | no | no | yes | Every key, as a Vec[K], ordered with values(). |
len() | Int | no | no | yes | Number of entries in the map. |
remove(K) | Unit | yes | no | no | Remove key if present; returns Unit. |
values() | Vec[V] | no | no | yes | Every 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]
| Method | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|
contains(T) | Bool | no | no | no | True iff value is in the set. |
insert(T) | Unit | yes | no | yes | Add value to the set; returns Unit. |
is_empty() | Bool | no | no | no | True iff the set has no elements. |
len() | Int | no | no | yes | Number of elements in the set. |
remove(T) | Unit | yes | no | no | Remove 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]
| Method | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|
get(T) | Int | no | no | yes | The count for key, or zero if absent (never faults). |
inc(T) | Unit | yes | yes | yes | Increment the count for key by one; returns Unit. |
is_empty() | Bool | no | no | no | True iff the counter has no keys. |
keys() | Vec[T] | no | no | yes | Every key, as a Vec[T], ordered with values(). |
len() | Int | no | no | yes | Number of distinct keys in the counter. |
values() | Vec[Int] | no | no | yes | Every 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]
| Method | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|
is_empty() | Bool | no | no | no | True iff the min-heap has no elements. |
len() | Int | no | no | yes | Number of elements in the min-heap. |
peek() | T | no | yes | no | The smallest element without removing it; faults if empty. |
pop() | T | yes | yes | no | Remove and return the smallest element; faults if empty. |
push(T) | Unit | yes | no | yes | Push a value onto the min-heap; returns Unit. |
MaxHeap[T]
| Method | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|
is_empty() | Bool | no | no | no | True iff the max-heap has no elements. |
len() | Int | no | no | yes | Number of elements in the max-heap. |
peek() | T | no | yes | no | The largest element without removing it; faults if empty. |
pop() | T | yes | yes | no | Remove and return the largest element; faults if empty. |
push(T) | Unit | yes | no | yes | Push 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
| Method | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|
contains(Int) | Bool | no | no | no | True iff the bit for the integer is set. |
insert(Int) | Unit | yes | yes | yes | Set the bit for a non-negative integer; returns Unit. |
is_empty() | Bool | no | no | no | True iff no bits are set. |
len() | Int | no | no | yes | Number of set bits (popcount). |
remove(Int) | Unit | yes | no | no | Clear 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]
| Method | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|
around4((Int, Int)) | Around4 | no | no | yes | The 4 orthogonal neighbors by name: { up, left, right, down }, each Some((x, y)) or None off the grid. |
around8((Int, Int)) | Around8 | no | no | yes | The 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] | no | no | yes | All cells in row-major order, as a Vec. |
column(Int) | Vec[T] | no | yes | yes | Column x as a Vec; faults if out of range. |
contains(Int, Int) | Bool | no | no | no | True iff (x, y) is within the grid. |
count4((Int, Int), T) | Int | no | no | yes | How 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) | Int | no | yes | yes | How 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) | Int | no | no | yes | How 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) | Int | no | yes | yes | How 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)] | no | no | yes | The first (x, y) whose cell equals value as Some((x, y)), or None. |
find_all(T) | Vec[(Int, Int)] | no | no | yes | All (x, y) positions whose cell equals value, as a Vec. |
get(Int, Int) | T | no | yes | no | The cell at (x, y); faults if out of range. |
height() | Int | no | no | yes | The number of rows. |
neighbors4((Int, Int)) | Vec[(Int, Int)] | no | no | yes | The 4 orthogonal in-bounds neighbors of a point, as a Vec of (x, y). |
neighbors8((Int, Int)) | Vec[(Int, Int)] | no | no | yes | The 8 in-bounds neighbors of a point, as a Vec of (x, y). |
positions() | Vec[(Int, Int)] | no | no | yes | All (x, y) positions in row-major order, as a Vec. |
rotate_left() | Grid[T] | no | no | yes | A copy rotated 90° counter-clockwise. |
rotate_right() | Grid[T] | no | no | yes | A copy rotated 90° clockwise. |
row(Int) | Vec[T] | no | yes | yes | Row y as a Vec; faults if out of range. |
set(Int, Int, T) | Unit | yes | yes | no | Set the cell at (x, y); faults if out of range. |
transpose() | Grid[T] | no | no | yes | A transposed copy (rows ↔ columns). |
width() | Int | no | no | yes | The 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
| Method | Result | Requires | Faults | What it does |
|---|---|---|---|---|
enumerate() | Vec[(Int, T)] | — | no | Pair each element with its index. |
filter((T) -> Bool) | Vec[T] | — | no | Keep elements satisfying a predicate, collecting into a Vec. |
filter_map((T) -> Option[U]) | Vec[U] | — | no | Map each element to an Option and keep the Some payloads. |
flat_map((T) -> Vec[U]) | Vec[U] | — | no | Map each element to a Vec and concatenate the results. |
frequencies() | Counter[T] | items usable as keys | no | A Counter holding how many times each element occurs. |
map((T) -> U) | Vec[U] | — | no | Apply a function to each element, collecting into a Vec. |
reversed() | Vec[T] | — | no | A new Vec holding these elements in reverse order. |
skip(Int) | Vec[T] | — | no | Drop the first n elements. |
sorted() | Vec[T] | items orderable | yes | A new Vec holding these elements in ascending order. |
sorted_by_key((T) -> K) | Vec[T] | the extracted key is orderable | yes | A new Vec ordered by the key the closure extracts. |
take(Int) | Vec[T] | — | no | Keep at most the first n elements. |
take_while((T) -> Bool) | Vec[T] | — | no | Keep elements until the predicate is false. |
unique() | Vec[T] | items usable as keys | no | A new Vec with duplicate elements removed, keeping first occurrences. |
zip(Vec[U]) | Vec[(T, U)] | — | no | Pair 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
| Method | Result | Requires | Faults | What it does |
|---|---|---|---|---|
all((T) -> Bool) | Bool | — | no | True if all elements satisfy the predicate (short-circuits). |
any((T) -> Bool) | Bool | — | no | True if any element satisfies the predicate (short-circuits). |
count() | Int | — | no | Number of elements. |
count((T) -> Bool) | Int | — | no | Number of elements satisfying the predicate. |
find((T) -> Bool) | Option[T] | — | no | The first matching element, or None. |
fold(Acc, (Acc, T) -> Acc) | Acc | — | no | Reduce elements left-to-right with an accumulator and combining closure. |
join(Text) | Text | items are Text | yes | These Text items concatenated with the separator between them. |
max() | Int | items are Int | yes | Largest (Int) element. Faults on an empty sequence. |
max_by((T, T) -> Bool) | T | — | yes | Largest element per a (T, T) -> Bool “less-than” comparator. |
min() | Int | items are Int | yes | Smallest (Int) element. Faults on an empty sequence. |
min_by((T, T) -> Bool) | T | — | yes | Smallest element per a (T, T) -> Bool “less-than” comparator. |
position((T) -> Bool) | Option[Int] | — | no | The index of the first matching element, or None. |
product() | Int | items are Int | yes | Multiply the (Int) elements. |
reduce((T, T) -> T) | T | — | yes | Reduce left-to-right, seeded with the first element. |
sum() | Int | items are Int | yes | Sum 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
| Method | Result | Requires | Faults | What it does |
|---|---|---|---|---|
to_bitset() | BitSet | items are Int | yes | A 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 key | no | A Counter built from (key, count) pairs. Duplicate keys: last wins. |
to_deque() | Deque[T] | — | no | A Deque holding these items, in order. |
to_map() | Map[K, V] | items are (K, V) pairs; K usable as a key | no | A Map built from (key, value) pairs. Duplicate keys: last wins. |
to_max_heap() | MaxHeap[T] | items orderable | no | A MaxHeap holding these items. |
to_min_heap() | MinHeap[T] | items orderable | no | A MinHeap holding these items. |
to_set() | Set[T] | items usable as keys | no | A Set holding these items, duplicates dropped. |
to_vec() | Vec[T] | — | no | The 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
| Method | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|
get(Int) | Char | no | yes | yes | The Char at index; faults if out of range. t[index] is the same row and the same answer. |
float() | Option[Float] | no | no | yes | The Float this text spells as Some(x), or None if it spells none. |
int() | Option[Int] | no | no | yes | The Int this text spells as Some(n), or None if it spells none. |
is_empty() | Bool | no | no | no | True iff the text has no chars. |
len() | Int | no | no | yes | Number 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()isNone. Theintatomic takes a leading-and not a+."+5.0".float()is a value, because thefloatatomic does take one."inf".float()and"nan".float()areNone.Floathas those values —1.0 / 0.0is one, andto_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
| Method | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|
checked_add(Int) | Option[Int] | no | no | yes | Add, answering None where the checked + would fault. |
checked_mul(Int) | Option[Int] | no | no | yes | Multiply, answering None where the checked * would fault. |
checked_sub(Int) | Option[Int] | no | no | yes | Subtract, answering None where the checked - would fault. |
saturating_add(Int) | Int | no | no | yes | Add, clamping to Int’s ends instead of faulting. |
saturating_mul(Int) | Int | no | no | yes | Multiply, clamping to Int’s ends instead of faulting. |
saturating_sub(Int) | Int | no | no | yes | Subtract, clamping to Int’s ends instead of faulting. |
to_char() | Char | no | yes | yes | The 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() | Float | no | no | yes | Widen to Float; the explicit Int→Float conversion. |
to_text() | Text | no | no | yes | Format as Text — the same digits out writes. |
wrapping_add(Int) | Int | no | no | yes | Add with two’s-complement wraparound instead of a fault. |
wrapping_mul(Int) | Int | no | no | yes | Multiply 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) | Int | no | no | yes | Subtract with two’s-complement wraparound instead of a fault. |
Float
| Method | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|
abs() | Float | no | no | yes | Absolute value. |
ceil() | Float | no | no | yes | Round toward positive infinity. |
floor() | Float | no | no | yes | Round toward negative infinity. |
is_infinite() | Bool | no | no | no | True iff ±infinity. |
is_nan() | Bool | no | no | no | True iff NaN. |
max(Float) | Float | no | no | yes | The larger of two floats. If either is NaN, returns the other. |
min(Float) | Float | no | no | yes | The smaller of two floats. If either is NaN, returns the other. |
round() | Float | no | no | yes | Round half away from zero. |
sign() | Float | no | no | yes | Sign as -1.0 / 0.0 / 1.0. NaN yields NaN. |
sqrt() | Float | no | no | yes | Square root. Negative inputs yield NaN (IEEE-754). |
to_int() | Int | no | yes | yes | Truncate toward zero to an Int. Faults on NaN, ±inf, or out of i64 range. |
to_text() | Text | no | no | yes | Format as Text (shortest round-trip form; inf/-inf/NaN as literals). |
Char
| Method | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|
to_int() | Int | no | no | yes | The Unicode scalar value, as an Int. Never faults. |
to_text() | Text | no | no | yes | The 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.
| Receiver | Spelling | Result | Mutates | Faults | Allocates | What it does |
|---|---|---|---|---|---|---|
Counter[T] | c[key] | Int | no | no | yes | c[key] — the count for key, or zero if absent; never faults. |
Deque[T] | d[i] | T | no | yes | no | d[i] — the element at i (0-based from the front); faults if out of range. |
Grid[T] | g[x, y] | T | no | yes | no | g[x, y] — the cell at (x, y); faults if out of range. |
Map[K, V] | m[key] | V | no | yes | no | m[key] — the value for key; faults if absent. |
Text | t[i] | Char | no | yes | yes | t[i] — the Char at i, indexing by Unicode scalar value and not by byte; faults if out of range. |
Vec[T] | v[i] | T | no | yes | no | v[i] — the element at i; faults if out of range. |
Counter[T] | c[key] = n | Unit | yes | no | yes | c[key] = n — set the count for key. |
Deque[T] | d[i] = value | Unit | yes | yes | no | d[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] = value | Unit | yes | yes | no | g[x, y] = value — set the cell at (x, y); faults if out of range. |
Map[K, V] | m[key] = value | Unit | yes | no | yes | m[key] = value — set key, replacing any prior value. |
Vec[T] | v[i] = value | Unit | yes | yes | no | v[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= n | Unit | yes | no | yes | m[key] max= n — keep the larger value; an absent entry accepts the first value. |
Map[K, Int] | m[key] min= n | Unit | yes | no | yes | m[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 — the ten leaves every parser is built from.
- Templates and captures — backtick syntax and what it produces.
- Structural parsers —
lines,sections,grid,block,choiceand the rest of the constructors. - Whitespace, lines and positions — the rules that decide which bytes are data.
- Cookbook: input shapes — the shapes puzzle input actually comes in.
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.
| parser | what it reads | type |
|---|---|---|
int | an optional -, then decimal digits | Int |
uint | decimal digits; a leading - is refused | Int |
float | an optional sign, digits, an optional . fraction, an optional exponent | Float |
byte | a decimal integer in 0..=255 | Byte |
char | one Unicode scalar value, whatever it is | Char |
digit | one decimal digit | Int |
word | a non-empty run up to a space, tab, comma, CR or LF | Text |
identifier | an identifier, by the language’s own identifier rule | Text |
text | the region it is given | Text |
rest | the region it is given | Text |
// 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.
| template | result |
|---|---|
| no captures | Unit |
| one anonymous capture | the captured value itself |
| two or more anonymous captures | a tuple, in order |
| named captures | an 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:
{a:text} barstopsaat the space beforebar, not at the first space. The bound is where the run matches, and the run is a space and thenbar.{a:text}\s+baris the explicit spelling of the same policy and boundsain the same place. What decides the bound is that the run must match something, not which way it was written.{a:text}\s*barboundsaatx, because the earliest place the whole run can start is right after it:\s*eats the two spaces andbarlands. The spaces belong to the policy, not toa.{a:text}a{b:rest}stops at the firsta, which is position zero, soais 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.
| Call | Shape |
|---|---|
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:
| Policy | Skips |
|---|---|
none | nothing — every byte of the region belongs to P |
whitespace | spaces and tabs (the default) |
newlines | spaces, 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
| written | matches |
|---|---|
| a run of spaces | one or more spaces or tabs |
\s* | zero or more whitespace characters, line endings included |
\s+ | one or more whitespace characters, line endings included |
\x20 | exactly one space |
\t | exactly one tab |
\n | one line ending, CRLF included |
| nothing | nothing — 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
Textis a slice of the one input buffer, with the right offset. Awordin 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-levelchoice(...)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.
| Template | Type |
|---|---|
| no captures | Unit |
| one anonymous capture | that capture’s type |
| several anonymous captures | a tuple, in order |
| named captures | an 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.
| Parser | Type |
|---|---|
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 aVec[result(P)]. A countedrepeated(P, N)holds the sameVec[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, sosections(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:
| Message | The construct that bounded the region |
|---|---|
expected the rest of the line | lines |
expected the rest of the section | sections |
expected the rest of the field | csv |
expected the rest of the token | ws, sep, matrix |
expected the rest of the capture | a template capture |
expected a grid row of the same cell count as the first | grid |
expected rectangular matrix row | matrix’s width check |
expected section header | a 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
| Code | Meaning |
|---|---|
I000 | a parser expression the lowerer cannot read at all (read 42 is one) |
I001 | a parser AST that could not be converted to a type or a plan |
I010 | an atomic parser name that does not exist |
I011 | an invalid capture name in a template |
I012 | a capture kind that does not exist |
I013 | a parser constructor that does not exist |
I014 | a constructor argument that is invalid or in excess |
I020 | named and anonymous captures mixed in one template |
I021 | one capture name used twice in a template |
I022 | a constructor called with the wrong number of arguments |
I023 | an empty separator, which cannot advance a cursor |
I024 | a section or block field declared twice |
I025 | a sections or choice with no field or case at all |
I026 | a positional block item returning a scalar with no name |
I027 | a choice case declared twice |
I028 | a misplaced or duplicated unbounded repeated(...) tail |
I030 | a 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.
| Shape | Parser |
|---|---|
| one number per line | lines(int) |
| two columns | lines(`{left:int} {right:int}`) |
| one comma-separated line | csv(int) |
| blank-line groups | sections(lines(int)) |
| two named sections | sections(rules: …, updates: …) |
| a header and N boards | sections(draws: …, boards: repeated(matrix(int))) |
| a map and a command stream | sections(map: grid(char), moves: chars(…)) |
| repeated labelled blocks | sections(…, maps: repeated(block(…))) |
| instructions in noise | scan(choice(…)) |
| a name, an arrow, a list | lines(`{from:word} -> {to:ws(word)}`) |
| mixed instruction lines | lines(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.
Annotations are optional, and legal
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:
| Position | Spelling | Optional? |
|---|---|---|
| Binding | var name: T = … | yes |
| Function parameter | fn f(a: T, b: U) | yes |
| Function result | fn f(…) -> T | yes |
| Closure parameter | |n: T| … | yes |
struct field | struct S { f: T } | required |
enum variant payload | enum 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
scheme — forall 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
Mapkey, aSetelement or aCounterkey: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
| Code | When | Message shape |
|---|---|---|
Y004 | == / != on a type with no equality | values of type `T` cannot be compared with `==` |
Y005 | for over something not iterable | values of type `T` cannot be iterated |
Y006 | sorting, a heap, or < on an unordered type | values of type `T` cannot be ordered |
Y010 | arithmetic on a known non-numeric type | values of type `T` do not support this operation |
Y014 | a Map/Set/Counter key that can change | a value of type `T` can change after it is stored, so it cannot be used as a key |
Y015 | arithmetic discovered after a later use pinned the type | values 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:Tlex,Pparse,Nname resolution,Ytype,Iinput 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:
| code | when |
|---|---|
Y004 | compared with ==, and its values cannot be compared |
Y005 | iterated, and it is not iterable |
Y006 | ordered — sorted, heaped, < — and it has no ordering |
Y014 | used as a Map key or Set element, and it can change after it is stored |
Y015 | used in arithmetic, and it is not numeric |
Y016 | given 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:
| where | label | writes an edit |
|---|---|---|
after w, line 1 | : Int | yes |
after h, line 1 | : Int | yes |
after side, line 5 | : Int | yes |
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
fnor closure parameter, or avar. Aforvariable has no annotation syntax, so its hint shows and cannot be accepted — thenandlabelabove are in that state. - Spellable: the rendered type has to be one the parser reads back.
?Tis not, and neither is theTof a scheme — the language has no syntax for writing a type variable, sopair’sitem: Tabove shows with no edit. Neither is an anonymous record. Neither is a function type, whose spelling this module deliberately does not guess — which is whyf: (Int) -> Intabove shows with no edit whileq: Intbeside 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.
| message | raised 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 bounds | xs[i] past the end or negative, and m[k] for a key the map does not hold |
input parse mismatch | a read or parse whose input does not match the parser |
empty collection | an 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 range | Float.to_int() on NaN, ±infinity, or a value outside the Int range |
not a Unicode scalar value | a code point that is negative, above 0x10FFFF, or a surrogate |
size or extent out of range | a 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 type | a value stored where its destination declared another type |
panic: <message> | panic(value) |
assertion failed | assert(condition) with a false condition |
empty range | clamp(v, low, high) with low > high |
an argument this algorithm has no answer for | a 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.getandGrid.findanswerOption[T]; aNoneis a case to match, not a failure. See enums and Option. - An unreadable input file.
praxis runreports it and exits 2 before the program runs. A missing--inputfile 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
panicfault whose message beginsinternal 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:
- The fault line.
error: program faulted:and the kind. Apanicappends its message here.error:is colored like a compiler error when standard error is a terminal;--color neverturns that off. - The parse detail, for an
input parse mismatchonly: the input offset, what the parser expected there, and a preview. - The backtrace, innermost first, under a
Backtrace:header. - Frame 0’s locals, split into
locals:andtemps:.
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 knows | the command that shows it |
|---|---|
| the function’s name | bt |
| the caller it will return to | up, down |
| the function’s source extent | source |
| its locals: name, static type, current value | locals |
| its temporaries: id, static type, materializing expression, value | locals |
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.
--debug | terminal? | at a :bp |
|---|---|---|
never | — | nothing at all; the marker is inert |
auto (default) | no | print the frame to stderr, keep running |
auto / always | yes | the full-screen debugger |
always | no | the 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 EXPRandheap 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 EXPRstill works — it type-checks against the frame’s locals and runs nothing.restartandreload. 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 | --debug | What you get |
|---|---|---|
| A terminal | auto or always | The full-screen debugger |
| A pipe, a redirect, a CI runner | always | The Praxis crash> prompt |
| A pipe, a redirect, a CI runner | auto | The 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.
| Key | Does |
|---|---|
↑ / k | Select the frame above — toward #0 |
↓ / j | Select the frame below |
home / end | The first / last frame in the list |
u / d | Up / down the call stack, from whichever pane has focus |
tab / shift-tab | Move focus between panes |
pgup / pgdn | A page of whatever the focused pane counts in — frames in the backtrace, lines elsewhere |
c | continue — let a program stopped at a :bp marker run on |
p | Open the command line already primed with p |
r / R | restart / reload |
l / b | Run locals / bt into the output pane |
i / P | input / parser context |
: | Type any command |
? | The key list; any key dismisses it |
q, ctrl-c | Quit |
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:
| rejected | why |
|---|---|
a method the catalog tags impure (push, insert, set, …) | it mutates its receiver |
| an assignment inside a block | it mutates a binding |
a call to a named function, prelude functions like abs and min included | the purity of a function body is not analyzed |
| a closure literal | it may capture and mutate |
for, while, loop, break, continue, return | they do not yield one value |
read, parse | they 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.
--debug | on a fault |
|---|---|
auto (default) | enter the prompt iff stdin and stdout are a terminal |
always | enter the prompt regardless |
never | never 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:
- The fault line.
error:in the same red the compiler uses for an error, then the fault kind.--color neverturns the colour off,--color alwaysforces it on;autocolours iff stderr is a terminal. - The backtrace, innermost first, one line per frame.
<entry>is the synthetic function a file’s top-level statements compile into. - 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. - 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
| code | meaning |
|---|---|
| 0 | the program ran to completion |
| 1 | a compile error, or a runtime fault |
| 2 | the 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.
| Request | What you get |
|---|---|
textDocument/publishDiagnostics | Every error praxis check would print, after a 150 ms debounce |
textDocument/hover | The inferred type; a method’s signature and its one-line documentation; a parser constructor’s signature and result type |
textDocument/completion | Receiver methods and record fields after ., enum variants in a pattern, parser atomics and constructors inside a read, lexical names elsewhere |
textDocument/signatureHelp | The callee’s signature and which parameter the cursor is in |
textDocument/definition | The declaration site of the name under the cursor |
textDocument/documentSymbol | Top-level fn, struct, enum and var, with fields and variants nested under them |
textDocument/references | Every use of that binding — not every occurrence of the word |
textDocument/rename and prepareRename | A whole-file rename, or a refusal that says what it would have broken |
workspace/symbol | The same symbols across every .px file under the workspace roots |
textDocument/inlayHint | The type of every binding the source does not annotate |
textDocument/codeAction | The quick fixes carried by the diagnostics in the requested range |
textDocument/semanticTokens/full | Fourteen 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.
Navigation
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 kind — I013, 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 name — N001 — 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 method — Y110 — 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 match — Y120 — 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.
| Command | What it runs |
|---|---|
Praxis: Run File | praxis run <file>, with --input input.txt appended when that file sits beside the source |
Praxis: Check File | praxis check <file> |
Praxis: Restart Language Server | Stops 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.
| Construct | Scope |
|---|---|
Parser constructor (lines, grid, …) | entity.name.function.parser.praxis |
| Template literal text | string.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
| Prefix | Category | Raised by |
|---|---|---|
T0xx | Lex | The lexer — a token that cannot be formed |
P0xx | Parse | The parser — a token that cannot appear here |
N0xx | Name | Name resolution and declaration checking |
Y0xx | Type | Inference, member lookup and match coverage |
I0xx | Input | The read/parse parser sublanguage |
R0xx | Runtime | Declared, 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
| Code | Message | What it means |
|---|---|---|
T001 | unterminated block comment | A /* with no matching */ before end of file. |
T002 | unterminated backtick template | A backtick template with no closing backtick. Nested templates count, so a ` inside a capture body does not close the outer one. |
T003 | unexpected character in source | A byte the lexer cannot classify. It becomes an ERROR token and lexing continues. |
T004 | unterminated text literal | A " 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. |
T005 | invalid escape in text literal / invalid escape in character literal | A \ 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. |
T006 | unterminated character literal | A ' with no closing quote before the end of its line. |
T007 | a character literal holds exactly one character / empty character literal: `''` names no character | A '…' 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
| Code | Message | What it means |
|---|---|---|
P001 | expected 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. |
P002 | expected `;` 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
| Code | Message | What it means |
|---|---|---|
N000 | internal: parse tree root is not a SOURCE_FILE | An internal error. It should be unreachable; seeing it is a compiler bug. |
N001 | `{name}` is not defined | A name that is not in scope. Carries a did you mean fix when a name in scope is close enough. |
N002 | unknown 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 type | A 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 scope | One name declared twice in one scope. |
N005 | `{name}` cannot be declared inside another function, `{name}` cannot be declared inside a function | A 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 record | A 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
| Code | Message | What it means |
|---|---|---|
Y001 | expected {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. |
Y002 | an infinite type would be required here | An occurs-check failure: a type would have to contain itself, as in unifying a with (a) -> a. |
Y003 | annotation says {annotated}, but use implies {derived} | An explicit annotation conflicts with what inference derived from the uses. |
Y004 | values of type `{ty}` cannot be compared with `==` | == or != applied to a type with no structural equality — a function value, for instance. |
Y005 | values of type `{ty}` cannot be iterated | A for over something that is not a collection or a range. |
Y006 | values of type `{ty}` cannot be ordered | A 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 given | A type constructor in an annotation given the wrong arity: Map[Int], Vec[Int, Text], Option[Int, Text]. |
Y008 | duplicate {field|variant} `{name}` | A struct or enum declaring one member twice. |
Y010 | values of type `{ty}` do not support this operation | A compound assignment (+=, -=, …) whose target is not numeric. |
Y011 | `return` outside a function | A return at the top level of a file. |
Y012 | `{break|continue}` outside a loop | A 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. |
Y014 | a value of type `{ty}` can change after it is stored, so it cannot be used as a key | A 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. |
Y015 | values of type `{ty}` cannot be used in arithmetic | Arithmetic 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. |
Y017 | a `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 call | A 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. |
Y019 | values 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. |
Y020 | values 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. |
Y021 | the left side of an assignment must be a name, a field, or an index | An 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. |
Y023 | a 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. |
Y024 | this function takes {expected} argument(s), but {found} were given | A 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
| Code | Message | What it means |
|---|---|---|
Y099 | internal: inference recorded no type for this {kind} expression | An 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
| Code | Message | What it means |
|---|---|---|
Y110 | no 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. |
Y112 | no 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. |
Y115 | field `{name}` is initialized more than once, field `{name}` is matched more than once | A 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
| Code | Message | What it means |
|---|---|---|
Y120 | non-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. |
Y121 | unreachable match arm | An 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 value | A 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. |
Y125 | a `for` binding must match every item, and {reason} does not, a closure parameter must match every argument, and {reason} does not | A 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.
| Code | Message | What it means |
|---|---|---|
I000 | malformed parser expression, malformed parser constructor call, malformed `repeated(...)` tail | The lowerer could not read the parser expression at all — the tree it was handed is not one a parser can be built from. |
I001 | a 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. |
I010 | unknown 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 identifier | The text before the : in a {…} capture is not an identifier. |
I012 | unknown parser `{name}` at byte {n}: no atomic or constructor is spelled that way | The capture kind after the : in a {…} names neither an atomic nor a constructor. Carries a did you mean fix. |
I013 | unknown 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 all | A 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. |
I020 | named and anonymous captures may not be mixed in one template | One template with both {n:int} and {int}. |
I021 | duplicate capture name `{name}` in template | One 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 advances | An empty separator, which cannot move a cursor. |
I024 | duplicate 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. |
I025 | named `sections` requires at least one field, `choice` requires at least one case | A sections or choice with nothing in it. |
I026 | a positional `block` item returning a scalar must be named | A 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. |
I027 | duplicate 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 it | A repeated(…) somewhere it cannot go. It consumes every remaining section, so nothing can follow it, and there can be only one. |
I030 | invalid 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
| stage | crate | what comes out |
|---|---|---|
| lex | praxis-parser (lex.rs) | a token stream including trivia, plus T0xx |
| parse | praxis-parser (parse.rs) | a rowan green/red tree, plus P0xx |
| typed AST | praxis-ast | typed wrappers over syntax nodes — nothing copied |
| resolve | praxis-hir (resolve.rs) | a scope tree, a SymbolId per declaration, plus N0xx |
| infer | praxis-hir (infer.rs), praxis-typeck | a type per expression node, plus Y0xx |
| coverage | praxis-hir (exhaustive.rs) | match exhaustiveness and reachability |
| typed HIR | praxis-hir (lower.rs) | a TypedModule: every node carries a Type |
| monomorphize | praxis-hir (mono.rs) | one clone of each generic function per concrete use |
| MIR | praxis-mir (build.rs) | basic blocks over slots, with safepoints and fault edges |
| liveness | praxis-mir (liveness.rs) | the root set and the debugger’s set at each safepoint |
| verify | praxis-mir (verify.rs) | a refusal, if the MIR broke an invariant |
| codegen | praxis-codegen-cranelift | finalized machine code in memory |
| run | praxis-runtime | the 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 uniformGcRef. These are the only locals the collector ever sees.LocalKind::Scalar— a transienti64/f64/u32/u8/boolpayload pulled out of an object for a local computation. It must not survive a safepoint; the builder materializes a freshGcRefbefore 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.
| variable | what it prints |
|---|---|
PRAXIS_DUMP_CLIF | the Cranelift IR, post-optimization, with an instruction count per block |
PRAXIS_DUMP_VCODE | the machine-level listing, same header |
PRAXIS_DUMP_SLOTS | one 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-syntaxowns theSyntaxKindvocabulary, 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-reprholds the one total, bidirectional bridge between a staticTypeand a runtimeTypeDescriptor. 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-stdlibis 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-debuggeris the crash debugger: the snapshot, the command loop, the read-only expression evaluator and the full-screen view.praxis-lspis 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:
| field | width | what it is for |
|---|---|---|
descriptor | 8 | pointer to the TypeDescriptor; null means swept |
payload_offset | 2 | where the payload starts, as the allocator laid it out |
heap_id | 4 | which 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:
- The shadow stack, which is where generated code puts its live locals.
- The process input buffer.
- A failed parse’s partial value, so the crash debugger can show it.
- The crash snapshot, once one has been taken.
- 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
Intthere 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:
| directory | what 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
| kind | spelling |
|---|---|
| whitespace | any 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. So1..5is a range,1.method()is a method call, and2.is the integer2followed by.. There are no leading-dot floats: write0.5. - A digit run immediately after a bare
.token is a tuple index and takes no fraction.t.0.1is two indices, not an index and the float0.1. 1.5..2.5is two floats and a range, because the.in front of the2was 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:
- Between statements, and between the members of a
struct/enumbody and the arms of amatch(where it is interchangeable with a comma). - After
breakandreturn: a value follows only if it is on the same line. - 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
A record literal is legal wherever the brace cannot be a block
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 power | operators | notes |
|---|---|---|
| 1 | || | logical or |
| 3 | .. ..= | a range is its own node, not a binary operator |
| 5 | && | logical and |
| 7 | == != < > <= >= | parsed left-associative |
| 9 | + - | |
| 11 | * / % | |
| 13 | prefix - ! | binds tighter than every infix operator |
Consequences worth knowing:
a || b && cisa || (b && c), anda == b && c == dis(a == b) && (c == d).0..n - 1is0..(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
.., so0..3 == 0..3parses as(0..(3 == 0))..3and is a type error rather than a range comparison. -a * bis(-a) * b, and!p && qis(!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:
| name | matches | yields |
|---|---|---|
int | signed decimal integer | Int |
uint | non-negative decimal integer | Int |
float | decimal floating-point number | Float |
byte | a decimal integer in 0..=255 | Byte |
char | one Unicode scalar | Char |
digit | one decimal digit | Int |
word | a run up to the next space, tab, comma or line break | Text |
identifier | an identifier run (the language’s own rule) | Text |
text | the whole region, which the literal after it bounds | Text |
rest | the whole region | Text |
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:
| written | matches |
|---|---|
| a run of spaces or tabs | one or more spaces or tabs (flexible, for column alignment) |
\s* | zero or more spaces or tabs |
\s+ | one or more spaces or tabs |
\x20 | exactly one ASCII space |
\n | one line ending |
\t | one 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:
| constructor | shape |
|---|---|
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.