The whole language in 15 minutes.
Penelope has seven runtime value tags: int, bool, str, unit, list, dict, closure. Everything is one of these.
42 // int
true // bool
"hello" // str
() // unit
list_new(1, 2, 3) // list
dict_set(dict_new(), "k", 1) // dict
fn(x) { x + 1 } // closure
Decimal, hex (0x), binary (0b). Underscores anywhere as visual separators. Negative literals via leading -.
42 // decimal
0xff // hex → 255
0b1010 // binary → 10
1_000_000 // readable big number → 1000000
-5 // negative literal
0 - x // unary minus on an expr (no \! or unary -)
Double-quoted strings. Escape sequences: \n, \t, \r, \\, \", \$ (escaped dollar for use next to template interpolation).
"hello"
"line1\nline2"
"\$50" // literal $50 — \$ escapes interpolation
"path: C:\\Users\\me"
let name = expr;. Bindings are immutable — re-assigning isn't a thing; shadow with a new let instead.
let x = 10;
let y = x + 5;
let x = "now a string"; // shadowing — totally fine
print(x); // → now a string
The _ identifier is the conventional discard name. Useful for sequencing an effectful expression you don't need the value of:
let _ = print("side effect");
let _ = pause;
Almost everything is an expression. The two things that aren't: let and an expression-followed-by-; (an ExprStmt). Inside a block, the last expression with no trailing ; is the block's value:
let r = {
let a = 1;
let b = 2;
a + b // ← no semicolon: this IS the block's value
};
print(to_str(r)); // → 3
So if, match, and block { ... } all produce values:
let msg = if (n < 0) { "neg" } else { "non-neg" };
let kind = match x { 0 => "zero", _ => "other" };
A ; after a value-producing expression discards the value (the expression runs for its effects). A block with all statements ending in ; evaluates to ().
let add = fn(a, b) { a + b };
print(to_str(add(2, 3))); // → 5
// First-class — pass fns, return fns.
let apply = fn(f, x) { f(x) };
print(to_str(apply(fn(n) { n * n }, 7))); // → 49
Closures capture the surrounding lexical scope through the frame chain. Variables looked up at call time walk up the chain.
let x = 5;
let r = (fn(y) { x + y })(7); // → 12
Tail-call optimization (TCO). A call in tail position (the last thing evaluated before returning) reuses the current frame instead of pushing a new one. Deep tail recursion is bounded by heap, not stack:
let go = fn(i, acc) {
if (i > 100000) { acc }
else { go(i + 1, acc + i) } // tail position — TAILCALL, no stack growth
};
print(to_str(go(1, 0)));
if (cond) { ... } else { ... } — parens around the condition, braces around both arms. The two arms are blocks; both are mandatory.
let abs = fn(n) {
if (n < 0) { 0 - n } else { n }
};
Penelope intentionally has no unary ! and no else if sugar. Negate by comparing: x == false. Nest else { if (...) { ... } else { ... } } explicitly. This keeps the grammar uniform; the self-hosted compiler depends on it.
pausepause is a runtime primitive. When evaluated, it serializes the entire program state and exits the process. A later pen resume picks up exactly where it left off.
// pause.pen
let x = 10;
let y = pause;
print(to_str(x + y));
$ bin/penelope run pause.pen
paused at ip 2 → pause.penz
# process exits — could be hours, days, years later
$ bin/penelope resume pause.penz
10
# x survived; bare pause returns unit; the print fires on resume
For value injection: pen resume pause.penz --event y=5 binds y = 5 before running.
All side effects flow through an effect log. On resume, completed effects are replayed from the log — they don't re-execute. This guarantees idempotency across pause boundaries.
let response = net_fetch("https://example.test/decision");
let ok = wait_for("approval"); // pauses here
write_file("/tmp/audit.log", response);
print("audit complete");
The 8 effects:
| Effect | Signature | Effect category |
|---|---|---|
print | (any) → unit | io |
net_fetch | (str) → str | net |
now | () → int | time |
random_int | (int, int) → int | random |
read_file | (str) → str | fs |
write_file | (str, str) → unit | fs |
wait_until | (int) → unit | pause + time |
wait_for | (str) → any | pause |
When a program hits wait_for(name), the runtime pauses. A later pen resume --event name=value injects the value and resumes.
$ pen run hitl.pen # pauses on wait_for("approval")
$ pen resume hitl.penz --event approval=true
$ pen fork pause.penz pause-copy.penz
# Now you have two snapshots. Resume each independently — they diverge.
Strings may embed expressions with ${...}. Each ${expr} desugars to to_str(expr) + concat. Arbitrary expressions are allowed inside ${...} — nested calls, arithmetic, anything.
let name = "Penelope";
let age = 42;
print("hello ${name}, you are ${age + 1}");
// → hello Penelope, you are 43
let n = 7;
print("sum 1..${n} = ${n * (n + 1) / 2}");
// → sum 1..7 = 28
Escape a literal $ with \$. Newlines inside ${...} are fine; brace depth is tracked so ${ {dict: "literal"} } works.
match expr { pattern => body, ... }. Arms are checked top-to-bottom; the first match wins. No exhaustiveness check — a non-matching scrutinee with no wildcard arm returns unit.
let describe = fn(n) {
match n {
0 => "zero",
1 => "one",
x => "many: ${x}", // var pattern: matches anything, binds to x
}
};
| Pattern | Example | Matches |
|---|---|---|
| int literal | 42, -3 | exact int |
| bool literal | true, false | exact bool |
| string literal | "hi" | exact string |
| unit literal | () | the unit value |
| wildcard | _ | anything; no binding |
| var | x, n | anything; binds the scrutinee to that name |
| or | 1 | 2 | 3 | any alternative (no bindings inside) |
| guard | n if n > 100 | pattern matches AND guard expression is true (after bindings) |
| list (fixed) | [a, b] | list of length 2, binds a and b |
| list (rest) | [h, ...t] | list with ≥1 elements; binds head to h, rest list to t |
| dict | {name: n} | dict containing key name; binds value to n |
Examples of each in combination:
let classify_req = fn(method) {
match method {
"GET" | "HEAD" | "OPTIONS" => "safe",
m if str_starts_with(m, "POST") => "writes",
_ => "other",
}
};
let sum_list = fn(xs) {
match xs {
[] => 0,
[h, ...t] => h + sum_list(t),
}
};
let greet_user = fn(u) {
match u {
{name: n, age: a} if a >= 18 => "hi ${n}",
{name: n} => "hi minor ${n}",
_ => "anonymous",
}
};
pure fnEvery function's type carries an effect set. By default it's inferred from the body. Mark a function pure to enforce an empty set:
let square = pure fn(n) { n * n }; // OK — body is pure
let log_sq = pure fn(n) { print(to_str(n * n)); n };
// type error: pure fn body has effects [io]
// — remove 'pure' or the effectful operations
Effect inference is transitive: a fn that calls another fn picks up the callee's effects.
The effect categories: io, net, time, random, fs, pause, panic. str_*, list_*, dict_*, char_*, to_str, type_of, int_of_str are all pure.
Inspect inferred effects from the CLI:
$ pen check my-program.pen --show-effects
my-program.pen: ok
Program effects: [io]
Fn bindings:
1: 1 greet : fn(unknown) -> unit / [io]
2: 1 [pure] square : fn(unknown) -> int
3: 1 dbl : fn(unknown) -> int
import "./path.pen"; expands the imported file's top-level bindings into the current scope. Paths are file-relative. Re-imports are idempotent; cycles are safe.
// math.pen
let square = fn(n) { n * n };
let cube = fn(n) { n * n * n };
// main.pen
import "./math.pen";
print(to_str(square(7))); // 49
The loader expands imports before tokenization. Diagnostics use a cross-import source map so errors point at the original file:line, not the inlined offset.
Three flavors:
// line comment
/* block comment
spanning lines */
/// doc comment — collected by `pen doc` into Markdown
/// Use on the line directly above a `let` binding.
let noteworthy = fn() { ... };
Lists and dicts are constructed and manipulated through builtins (no literal syntax — that ambiguity with blocks and patterns isn't worth it).
let xs = list_new(1, 2, 3);
let ys = list_push(xs, 4); // → [1, 2, 3, 4]
let n = list_len(ys); // → 4
let x = list_get(ys, 2); // → 3
let zs = list_slice(ys, 1, 3); // → [2, 3]
let d = dict_set(dict_new(), "a", 1);
let d2 = dict_set(d, "b", 2);
let v = dict_get(d2, "a"); // → 1
let ok = dict_has(d2, "c"); // → false
let ks = dict_keys(d2); // → ["a", "b"]
Both are immutable: list_push / dict_set return new values. The VM shares structure where it can.
Compile with -O2 to enable all five passes:
1 + 2 collapses to 3 at compile timeLOAD_VAR gets an IC slot for the resolved frame-up countPOP+PUSH)$ pen build -O2 fib.pen
wrote fib.penc (14 opcodes, 2 constants, -O2)
$ pen disasm fib.penc
For an additional speedup beyond the optimizer, switch to the JIT: pen exec --jit fib.penc runs the bytecode through a generated JS function (~2.4× faster than the -O2 interpreter on fib(25)). For an int-only program, pen wasm emits a native WASM module.
pen subcommand and flag..penz file.