praxis

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.

compiled — Cranelift JIT full type inference crash debugger, not a stack trace language server included
input.txt
day05.px
the compiler already knows
readthe input parser

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
day05.px — one field renamed

The template is the only place the shape is written down, and the error quotes the record it produced.

a semicolon where a comma was promised

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 .

crashrecorded from a real terminal

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.

temps are named

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.

p — evaluate

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.

:bp — stop anywhere

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.

input / parser

After a parse mismatch, two commands show the bytes around the failure and what the parser wanted there — no frame required.

not a terminal

A pipe gets the line-oriented prompt; CI gets the printed fault report and exit 1. The surface changes, the engine does not.

the report survives

The crash report is printed before the screen opens, and the screen is an alternate one — so quitting leaves the report in your scrollback.

languageone binding form, everything inferred

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.

pipelines

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)
grids and graphs

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),
))
pattern matching

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)
}
records without names

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))
the editor knows too

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)
a fix is a diagnostic

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.

an enum grew two variants
speedthree implementations, byte-identical output

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.

faster than CPython 3.14, geomean
8 / 8benchmarks faster than Python
slower than Rust, geomean
1.09×CPython's peak resident set
benchmarkRustPythonPraxisvs Rustvs Python
primes65 ms3.48 s225 ms0.1×
mandelbrot60 ms2.50 s252 ms0.1×
collatz42 ms2.68 s123 ms0.0×
vm114 ms8.12 s968 ms0.1×
hashwork337 ms4.81 s1.76 s0.4×
tree86 ms3.71 s1.01 s12×0.3×
pipeline64 ms2.62 s1.52 s24×0.6×
bfs144 ms3.97 s1.44 s10×0.4×
geometric mean0.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.

bookthe manual

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.

46chapters, plus two appendices
410runnable examples
7parts, from installing to the heap
starta stable Rust toolchain, and nothing else

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.

Read the book →

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.

day05.px

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