The input
is a type.
Praxis is a small, statically typed, garbage-collected language for Advent-of-Code-style puzzle solving. You describe the shape of the input once, in a sublanguage of templates and captures — and from that one line the compiler derives the type, parses the bytes, and names the byte that broke.
Parsing is not a phase of your program.
There is no scanner, no line iterator, no Result to unwrap. A backtick
template is a parser: a comma matches a comma, a space matches a run of horizontal
whitespace, and {name:atom} is a capture. Everything outside the backticks is a
grammar of constructors — lines, sections, grid,
csv, repeated, choice — whose own whitespace means nothing.
The type falls out of the shape. Four named captures become a record with four
fields; lines wraps it in a Vec. So a typo in a field name is a
compile error naming the record the template built, and it never gets as far
as running.
- lines(int) one number per line
- lines(`{left:int} {right:int}`) two columns
- csv(int) one comma-separated line
- sections(lines(int)) blank-line groups
- grid(char) a rectangle of characters
- sections(draws: csv(int), boards: repeated(matrix(int))) a header and N boards
- scan(choice(Mul: `mul({a:int},{b:int})`, Enable: `do()`)) instructions in noise
The template is the only place the shape is written down, and the error quotes the record it produced.
When the input is what's wrong, the fault
carries the byte offset, what the parser wanted there, and a window of the real bytes with
newlines drawn as ⏎.
One screen, whether you asked to stop or the program did.
Write :bp at the end of a statement and the
program pauses after it runs; index out of bounds, division by zero or a parse mismatch and it stops
on its own. Either way it is the same full-screen debugger over the same snapshot: every frame, the
selected frame's source with the line it is on marked — and at a fault the subexpression that failed
— and its locals and the compiler's temporaries, each labelled with the expression that
produced it. The last temp with a real value is the last thing that worked.
The recording below is one run: two stops at the one marker, then the division the second stop was standing in front of.
The compiler's intermediates are kept with the source expression each one materialized, so a
frame reads as this arithmetic, and how far it got — not just "these variables". At a
stop it reads forwards as well: what the next line has yet to compute stands at
<uninit>, spelled with the expression that will fill it.
At a fault an expression runs against the crash snapshot's own locals, in whichever frame is selected; re-running the faulting expression faults again, on demand. At a stop it refuses and says why — those frames are live, and evaluating would run code underneath them.
Two tokens at the end of a statement, in the source rather than in a command. The program
pauses after that statement runs, the banner counts the hits, and c puts it
back — until the run ends, or ends badly.
After a parse mismatch, two commands show the bytes around the failure and what the parser wanted there — no frame required.
A pipe gets the line-oriented prompt; CI gets the printed fault report and exit 1. The surface changes, the engine does not.
The crash report is printed before the screen opens, and the screen is an alternate one — so quitting leaves the report in your scrollback.
Small enough to hold in your head at 6am.
var is the only binding form. Types are inferred
and only written when you want them read. A match must be exhaustive, and the compiler writes the
missing arms for you.
A chain compiles to one loop. It is eager — it runs where it is written — and it materializes
on its own, so there is no collect. Answers 147.
var readings = [3, -1, 4, 1, -5, 9, 2, 6]
var answer = readings
.filter(|x| x > 0)
.map(|x| x * x)
.sum()
out(answer)
grid(char) reads the rectangle; neighbors4 and
bfs_distance are in the prelude. The search never sees the grid, so the graph is
just that closure. Answers Some(8).
var maze = read grid(char)
var wall = '#'
// The search never sees the grid. It only asks
// what is next to here.
fn open(g: Grid[Char], w: Char, p: (Int, Int)) {
g.neighbors4(p).filter(|q| g[q.0, q.1] != w)
}
out(bfs_distance(
(0, 0),
|p| open(maze, wall, p),
|p| p == (4, 4),
))
One pattern grammar, used by match arms, for headers and closure
parameters alike. A bare name that is a variant of the scrutinee's enum is that variant, not a
binding.
struct Point { x: Int, y: Int }
var points = [
Point { x: 1, y: 2 },
Point { x: 3, y: 4 },
]
for { x, y } in points {
out(x * y)
}
A record type needs no declaration — the parser's captures are already
{ x: Int, y: Int }, and { x: 2, y: 5 } writes one by hand. Another
anonymous record with the same fields is the same type, however it was built;
a struct that happens to look like it is not.
// No struct declared anywhere: the template
// derived the type and the literal writes it.
var points = read lines(`{x:int},{y:int}`)
var origin = { x: 2, y: 5 }
fn area(r) -> Int { r.x * r.y }
out(area(points[0]) + area(origin))
A language server ships in the box: hover, completion with receiver methods, rename,
references, semantic tokens, and inlay hints that show what inference concluded — ?T
where it has not pinned one.
you write fn foo(a, b) you read fn foo(a: Int, b: Int)
Every quick fix the editor offers is a suggestion praxis check also prints, and each
one is gated by applying it and re-analyzing. The two surfaces cannot disagree.
Fast enough that the puzzle is the slow part.
Eight benchmarks, each written three times — in Praxis, in Rust and in Python — with byte-identical output enforced. Wall clock, whole process, best of five.
| benchmark | Rust | Python | Praxis | vs Rust | vs Python |
|---|---|---|---|---|---|
| primes | 65 ms | 3.48 s | 225 ms | 3× | 0.1× |
| mandelbrot | 60 ms | 2.50 s | 252 ms | 4× | 0.1× |
| collatz | 42 ms | 2.68 s | 123 ms | 3× | 0.0× |
| vm | 114 ms | 8.12 s | 968 ms | 9× | 0.1× |
| hashwork | 337 ms | 4.81 s | 1.76 s | 5× | 0.4× |
| tree | 86 ms | 3.71 s | 1.01 s | 12× | 0.3× |
| pipeline | 64 ms | 2.62 s | 1.52 s | 24× | 0.6× |
| bfs | 144 ms | 3.97 s | 1.44 s | 10× | 0.4× |
| geometric mean | 7× | 0.2× |
Read the last two columns as how many times longer
than: below 1.0 means Praxis was faster. The JIT is not hiding in these numbers — the whole fixed
cost of praxis run, lexing through Cranelift codegen, is between 0.41% and 3.92% of each
measured run.
Everything the language does, in one place.
A manual rather than a tour: every construct has a chapter, every chapter has programs you can paste and run, and the appendices carry the complete grammar and a set of worked solutions.
- Getting started installing, a first program, the command line
- The language bindings through pipelines, grids and graphs, the prelude, the method catalog
- Reading input the
readexpression, templates and captures, a cookbook of input shapes - Type inference generalization, method resolution, capabilities, and how to read a type error
- The crash debugger the fault model, breakpoints, the full-screen UI, a walkthrough
- Tooling editor support, and every diagnostic code the compiler can emit
- Under the hood the compiler pipeline and the object heap
Install it, then solve something.
Install
From crates.io, with a stable Rust toolchain and nothing else. Code generation is Cranelift, so there is no LLVM to install.
$ cargo install praxis-cli # the crate is `praxis-cli`; the binary # it puts in ~/.cargo/bin is `praxis`
Run
Standard input is read lazily; --input is the eager half.
$ praxis run day05.px < input.txt $ praxis run day05.px --input in.txt $ praxis check day05.px $ praxis run day05.px --debug never
Read
The book is 46 chapters, and every one of its 410 examples is re-run against the compiler and diffed on every change rather than being illustrative.
A file is a program
Top-level statements are the entry point — there is no main
to declare — and out is in the prelude. This is the segments program from the top of
the page, run against those three lines.
The command surface
check routes through the same query layer the language
server uses, so what the CLI prints and what the editor underlines cannot diverge.
- praxis run lex, parse, infer, lower, monomorphize, MIR, Cranelift, execute
- praxis check the front end only — no code generated
- praxis lsp JSON-RPC on stdio; the editor extension launches it
- exit codes 0 clean, 1 a language error or a runtime fault, 2 the job could not start