Every subcommand of bin/penelope.
$ pen build [-O0|-O1|-O2] foo.pen # source → foo.penc bytecode
$ pen exec foo.penc # run pre-compiled bytecode
$ pen run [-O0|-O1|-O2] foo.pen # compile in memory + run (auto-build)
$ pen resume foo.penz [--time N] [--no-replay] [--event N=V]
$ pen fork src.penz dst.penz # cp snapshot
$ pen disasm foo.penc # pretty-print bytecode
$ pen inspect foo.penz # render v3 snapshot
$ pen check foo.pen # static type check
$ pen profile [-O0|-O1|-O2] foo.pen # opcode counts + hot ips
$ pen bench foo.pen # compare -O0/-O1/-O2 timings
$ pen repl # interactive REPL
$ pen fmt [--write] foo.pen # AST-based code formatter
$ pen test foo.pen # doctest runner (// EXPECT: lines)
$ pen run --watch foo.pen # re-run on file change
$ pen test --watch foo.pen # tdd loop
$ pen check --watch foo.pen # typecheck loop
$ pen doc foo.pen # /// → markdown
$ pen graph foo.pen > deps.dot # import graph (Graphviz DOT)
$ pen new my-project # scaffold a new project
| Flag | Where | Effect |
|---|---|---|
-O0 / -O1 / -O2 | build, run, profile | Optimization level (default -O1) |
--time N | run, resume | Override now() to N (ms since epoch) |
--no-replay | run, resume | Skip effect log; re-execute everything |
--event NAME=VAL | resume | Inject value for wait_for("NAME") |
--watch | run | Re-run on file change; clears screen between runs |
--write | fmt | Rewrite the file in place (without it: prints to stdout) |
NO_COLOR=1 env | any | Disable ANSI colors in error output |
Idempotent formatter — fmt(fmt(x)) === fmt(x). Round-trips through the AST, so comments are dropped (they don't survive parsing).
$ pen fmt examples/10-sort.pen # prints to stdout
$ pen fmt --write examples/10-sort.pen # rewrites in place
Annotate expected stdout lines with // EXPECT: <text> or // EXPECTS: <prefix>. pen test runs the program and asserts each emitted line matches in order.
// add.pen
print(to_str(1 + 2));
// EXPECT: 3
print("ok");
// EXPECT: ok
$ pen test add.pen
✓ add.pen (2 expectations)
Doc comments use a triple slash (///). pen doc walks top-level let bindings and emits a markdown page summarizing them.
/// Double the input. Useful for nothing in particular.
let double = fn(x) { x * 2 };
/// The official greeting.
let greeting = "hello";
$ pen doc demo.pen
# `demo.pen`
## `double(x)`
*defined at line 2*
Double the input. Useful for nothing in particular.
## `greeting`
*defined at line 5*
The official greeting.
Regular // comments survive pen fmt too (they used to be dropped).
The compiler tags tail-position function calls. The VM reuses the current call frame for them, so deep tail recursion doesn't blow the JS stack.
let sum = fn(n, acc) {
if (n == 0) { acc } else { sum(n - 1, acc + n) }
};
print(to_str(sum(100000, 0))); // 5000050000 — no stack overflow
A call is in tail position when it's the trailing expression of a function body, or recursively the trailing expression of an if-branch or block in tail position. When tail-calling a closure whose captured scope would be invalidated by frame reuse, the VM falls back to a regular call (no observable difference, just no stack savings).
Walks import statements from a root file and emits the dependency graph as Graphviz DOT.
$ pen graph main.pen > deps.dot
$ dot -Tpng deps.dot > deps.png
Scaffold a new project directory with main.pen, a README, and a .gitignore.
$ pen new my-project
created my-project/
my-project/main.pen
my-project/README.md
my-project/.gitignore
next:
cd my-project
pen run main.pen
Resume a paused snapshot against an edited source file. The old VMState is remapped onto the recompiled program by source position, with opcode-kind sanity checks to reject semantic drift. See Phase 4 for the model.
$ pen edit snapshot.penz
resumed (frame@line 14) — printing: 42
Compile a .pen source to a WebAssembly module via the Penelope-implemented backend (std/wasm.pen). Handles a large subset of the language — ints/bools, strings, lists/dicts, closures + first-class fns, match, and 6 effects via host imports. See the WASM page for the precise scope and what's still excluded (floats, snapshot/pause/resume). Output is a standard .wasm file loadable by any WASM runtime (Node, browsers, Cloudflare Workers, Wasmtime).
$ pen wasm examples/09-fib.pen --out fib.wasm
wrote fib.wasm
$ node -e "
const m = await WebAssembly.compile(require('fs').readFileSync('fib.wasm'));
const i = await WebAssembly.instantiate(m, { js: {
print: (p, l) => { /* read UTF-8 from memory[p..p+l] */ },
now: () => Date.now(),
random_int: (lo, hi) => lo + Math.floor(Math.random() * (hi - lo)),
net_fetch: () => 0, read_file: () => 0, write_file: () => {},
}});
console.log('fib(20) =', i.exports.main());
"
fib(20) = 6765
Three-stage self-hosting verification: (1) pen-built bytecode is byte-identical to ts-built on samples and on all std/*.pen files (self-bootstrap); (2) all four std/*.pen files round-trip; (3) the pen-built pen-frontend, when run, correctly compiles new programs (fixpoint). See Phase 4 for the diagram.
$ pen self-test
Stage 1: round-trip on inline samples
✓ …
Stage 2: round-trip on std/*.pen (self-bootstrap)
✓ std/iter.pen
✓ std/lexer.pen
✓ std/parser.pen
✓ std/compiler.pen
Stage 3: acid test — pen-built pen-frontend compiles a program
✓ …
12/12 checks passed — Penelope self-hosts ✓
Re-runs on file save. Clears the terminal between runs. Useful for tight feedback loops.
$ pen run --watch examples/09-fib.pen
watching examples/09-fib.pen (Ctrl-C to exit)
6765
[15:23:01] waiting for changes
# edit file...
6766
[15:23:14] waiting for changes
All compile-time and run-time errors come with a Rust-style source pointer:
$ pen run broken.pen
error: undefined variable 'foo'
--> broken.pen:3:9
|
3 | let x = foo + 1;
| ^
# Build with optimizations, then run the bytecode directly
$ pen build -O2 examples/09-fib.pen
wrote examples/09-fib.penc (14 opcodes, 2 constants, -O2)
$ pen exec examples/09-fib.penc
6765
# Run + pause + resume cycle
$ pen run examples/07-wait-for.pen
waiting for approval
paused at ip 5 → examples/07-wait-for.penz
$ pen resume examples/07-wait-for.penz --event approval=true
got: true
# Profile a recursive program
$ pen profile examples/09-fib.pen
profile: examples/09-fib.pen (-O1, 15.47 ms)
opcode counts (top 20):
LOAD_VAR 76618 22.6%
BIN_OP 54726 16.1%
...
# Static type check
$ pen check broken.pen
type error: binop '+' requires int+int or str+str, got int+str at line 1 col 11
1 type error
# Interactive
$ pen repl
pen> let x = 42
pen> x * 2
84