Penelope — Tour

The whole language in 15 minutes.

Values

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

Numeric literals

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 -)

Strings & escapes

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 bindings

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;

Expressions vs. statements

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 ().

Functions are values

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)));

Conditionals

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.

The headline feature: pause

pause 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.

Effects: I/O that survives pause

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:

EffectSignatureEffect category
print(any) → unitio
net_fetch(str) → strnet
now() → inttime
random_int(int, int) → intrandom
read_file(str) → strfs
write_file(str, str) → unitfs
wait_until(int) → unitpause + time
wait_for(str) → anypause

wait_for: external value injection

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

Forking: two futures from one snapshot

$ pen fork pause.penz pause-copy.penz
# Now you have two snapshots. Resume each independently — they diverge.

String interpolation

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.

Pattern matching

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 catalogue

PatternExampleMatches
int literal42, -3exact int
bool literaltrue, falseexact bool
string literal"hi"exact string
unit literal()the unit value
wildcard_anything; no binding
varx, nanything; binds the scrutinee to that name
or1 | 2 | 3any alternative (no bindings inside)
guardn if n > 100pattern 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",
  }
};

Effect types & pure fn

Every 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

Modules

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.

Comments

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

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.

Optimizations

Compile with -O2 to enable all five passes:

$ 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.

What's next