Epochs

<!-- audited: 2026-09-23 -->

Epochs are Elle's mechanism for making breaking changes to the language while preserving backwards compatibility. Each epoch is a numbered version of the language surface syntax. Source files can declare the epoch they target, and the compiler will transparently rewrite old-epoch syntax before compilation.

Declaring an epoch

Place (elle/epoch N) as the first form in a source file:

(elle/epoch 0)
(def x 10)
(println x)

The (elle/epoch N) declaration tells the compiler which epoch the file was written for. It is consumed during compilation and does not appear in the running program. Files without an epoch declaration target the current epoch.

To query the current epoch at runtime:

(elle/epoch)   # returns the current epoch number

Epochs version the syntax. The peer declaration (unicode! N) versions the string semantics: it selects the Unicode segmentation generation the program runs under. Where (elle/epoch N) is consumed and rewrites the tree, (unicode! N) is checked by the analyzer and locks the table generation. See compile-time.md and strings.md.

What happens at compile time

The epoch migration pass runs after parsing and before macro expansion:

Source → [epoch prescan] → Reader → [Epoch Migration] → Expander → HIR → LIR
       → Bytecode

If a file declares (elle/epoch N) where N is older than the current epoch, the compiler applies all migration rules from epoch N+1 through the current epoch to the parsed syntax tree. This is transparent — old-epoch code compiles and runs exactly as if it had been written using current-epoch syntax.

Because migration operates on parsed trees, it cannot express changes to tokenization itself. The reader therefore reads the declaration before it lexes, with a frozen micro-grammar, and tokenizes under the lexer rules that epoch selects — see impl/lexicon.md. Epoch 13 is the first epoch whose lexer rules differ from its predecessor's: it changes what a string escape means.

Migration rule types

Each epoch bump defines a set of migration rules. The MigrationRule enum in src/epoch/rules.rs has seven variants: Rename, Remove, Unwrap, Replace, FlattenBindings, FlattenClauses, and Desugar.

Rename

Mechanically renames a symbol everywhere it appears (except inside quotes):

MigrationRule::Rename { old: "old-name", new: "new-name" }

All occurrences of old-name become new-name. Renames chain across epochs: if epoch 1 renames A→B and epoch 2 renames B→C, a file at epoch 0 sees A→C.

Replace

Structurally rewrites a call form by matching the head symbol and argument count, then substituting into a template:

MigrationRule::Replace {
    symbol: "assert-eq",
    arity: 3,
    template: "(assert (= $1 $2) $3)",
}

This rewrites (assert-eq X Y msg) to (assert (= X Y) msg). Placeholders $1, $2, ... refer to arguments by position (1-indexed, after the head symbol). The template uses current-epoch syntax. Arguments are spliced in as complete subtrees.

If the arity does not match, the form is left unchanged — this allows a symbol to be used with different arities without triggering an unintended rewrite.

Remove

Flags a form as removed. Any use of the symbol produces a compile error with a message telling the author what to use instead:

MigrationRule::Remove {
    symbol: "old-fn",
    message: "use (new-fn ...) instead",
}

Removals require the author to manually update the code. They are also checked by elle rewrite (see below).

Desugar

Replaces a reader shorthand with the form it stands for. A shorthand is a prefix token that wraps the form written after it — ;expr stands for (splice expr):

MigrationRule::Desugar { shorthand: Token::Splice }

The rule names only the token. Token::shorthand_form says which form each shorthand stands for, so the rule cannot name the wrong one.

An epoch declares this when it takes the shorthand's spelling away, which makes the change lexical as well as structural. The rule is the half that Lexicon::respell cannot reach: a respelling answers about one token, and this rewrite has to reach past the prefix to the end of the form the prefix wraps. See impl/lexicon.md for the mechanism and for the condition a shorthand must meet before an epoch may desugar it.

The elle rewrite CLI tool

elle rewrite is a source-to-source migration tool that updates files in place. It applies the same rules as the compiler but modifies the source text directly (preserving comments, whitespace, and formatting) and updates the (elle/epoch N) tag to the current epoch.

elle rewrite [OPTIONS] <file...>

It reads each file under the lexicon that file's own epoch selects, so a file written before a token-level change still tokenizes the way its author meant (see impl/lexicon.md). Tokens whose spelling changed are rewritten in place alongside the tree rules.

Options:

and lexical changes alike.

Example workflow:

# See what would change
elle rewrite --dry-run tests/*.lisp

# Apply rewrites
elle rewrite tests/*.lisp

# Verify in CI that all files are up to date
elle rewrite --check tests/*.lisp

After rewriting, the file's (elle/epoch N) tag is updated to the current epoch. Files without an epoch tag get one added.

Adding a new migration

To make a breaking change to Elle:

1. Bump CURRENT_EPOCH in src/epoch/rules.rs. 2. Add a Migration entry to the MIGRATIONS array with the new epoch number, a summary, and the rules describing the change. A change to tokenization also flips a Lexicon field in Lexicon::for_epoch and names itself in the entry's lexical list — see impl/lexicon.md. 3. Update (elle/epoch N) in stdlib.lisp to the new epoch. The WASM backend strips stdlib's epoch tag and concatenates the body as-is — it does not run migration on stdlib, so stdlib must already use current-epoch syntax. 4. Add tests in src/epoch/transform/tests.rs and src/rewrite/run/tests.rs. 5. Run make smoke to verify the full test suite still passes.

Example:

Migration {
    epoch: 1,
    summary: "consolidate assertion forms",
    rules: &[
        MigrationRule::Rename { old: "assert-true", new: "assert" },
        MigrationRule::Replace {
            symbol: "assert-eq",
            arity: 3,
            template: "(assert (= $1 $2) $3)",
        },
        MigrationRule::Remove {
            symbol: "assert-err",
            message: "use (let [[ok? _] (protect (f))] (assert (not ok?) msg)) instead",
        },
    ],
},

Files that declare (elle/epoch 0) will continue to compile — the compiler transparently applies the migration rules. Authors can run elle rewrite to update their source and move the epoch tag to the current epoch.

Epoch history

Epoch 1 — consolidate assertion helpers

Replaced assert-true, assert-false, assert-eq, assert-equal, assert-string-eq, assert-list-eq, assert-not-nil, assert-err, and assert-err-kind with the single (assert expr msg) form.

Epoch 2 — print→println, newline→println, drop write

Epoch 3 — display→print

After epoch 3, the output API is:

FunctionTargetNewline?
printstdoutno
printlnstdoutyes
eprintstderrno
eprintlnstderryes

All four respect parameterize rebinding of stdout/stderr.

Epoch 4 — stream/{read,read-line,read-all,write,flush} → port/...

Port I/O primitives moved from the stream/ namespace to port/:

Old nameNew name
stream/read-lineport/read-line
stream/readport/read
stream/read-allport/read-all
stream/writeport/write
stream/flushport/flush

These five operations act exclusively on ports, not on abstract streams. The stream/ namespace now contains only stream combinators (stream/map, stream/filter, stream/collect, etc.) which operate on lazy sequences. stream/read, stream/write and stream/flush remain as aliases.

Epoch 5 — polymorphic has?/put, retire string-specific containment

strings. contains? remains as a permanent alias.

add is rewritten to put by elle rewrite.

Epoch 6 — remove ev/run from user code

User code already runs in the async scheduler. (ev/run (fn [] body...)) is unwrapped to just body.... Non-lambda forms produce a compile error.

Epoch 7 — flat let bindings

let, let, letrec, if-let, when-let, and when-ok switch from nested-pair bindings to flat (Clojure-style) bindings. Each binding is a pattern/value pair laid out flat inside a single bracket form.

Old (epoch ≤ 6)New (epoch 7)
(let [[a 1] [b 2]] ...)(let [a 1 b 2] ...)
(let [[[x y] (foo)]] ...)(let [[x y] (foo)] ...)
(let [[a 1] [b (+ a 1)]] ...)(let [a 1 b (+ a 1)] ...)
(if-let [[x (find-it)]] ...)(if-let [x (find-it)] ...)

Destructuring is unambiguous: each form occupies exactly one syntactic position, alternating pattern then value. elle rewrite handles the migration mechanically.

Epoch 8 — immutable-by-default bindings

All bindings (def, let, let, letrec, function parameters) are immutable by default. var is replaced by def @, and mutable bindings use the @ prefix:

Old (epoch ≤ 7)New (epoch 8)
(var x 0)(def @x 0)
(let [x 0] (assign x 1) ...)(let [@x 0] (assign x 1) ...)
(defn f [n] (assign n 10))(defn f [@n] (assign n 10))

The @ prefix appears only at the binding site. All subsequent references omit it. Assigning to a binding without @ is a compile-time error:

cannot assign immutable binding 'x' (use @x to make it mutable)

Immutable bindings enable constant propagation by the compiler and eliminate the need for cell indirection when captured by closures.

elle rewrite handles the var → def @ migration. Mutable let and parameter bindings require manual @ annotation, guided by compile errors.

Epoch 9 — flat cond/match clauses

cond and match clauses switch from parenthesized groups to flat pairs:

Old (epoch ≤ 8)New (epoch 9)
(cond (test1 body1) (test2 body2))(cond test1 body1 test2 body2)
(match val (pat1 body1) (pat2 body2))(match val pat1 body1 pat2 body2)

Multi-body arms are wrapped in (begin ...). The (else body) form in cond becomes a trailing default expression.

Epoch 10 — cons→pair, car→first, cdr→rest

Classic Lisp pair operation names are replaced with descriptive alternatives:

Old (epoch ≤ 9)New (epoch 10)
conspair
carfirst
cdrrest

Epoch 11 — sys/spawn→sys/spawn-vm, os/spawn→os/spawn-vm

sys/spawn/os/spawn now load the standard library in the spawned worker (so eval/read resolve stdlib names there) and are correspondingly heavier. The original light, primitives-only worker is now sys/spawn-vm / os/spawn-vm. Renaming the old qualified names to their -vm form preserves the original (light) behavior of pre-epoch code; new code gets the heavy, stdlib-backed default via sys/spawn.

Old (epoch ≤ 10)New (epoch 11)
sys/spawnsys/spawn-vm
os/spawnos/spawn-vm

Only the qualified sys/spawn/os/spawn are renamed. The bare spawn symbol is deliberately left alone: Rename is not binding-aware, and spawn is also the (ev/scope (fn [spawn] …)) nursery parameter — a global rename would clobber that local. Bare spawn is no longer registered as a primitive alias at all, so top-level code must use sys/spawn / sys/spawn-vm.

Epoch 12 — remove coroutine API, use fibers directly

Coroutines were always trivial wrappers over yield-masking fibers and have been removed. All coro/ and legacy coroutine- / make-coroutine forms are rewritten to their fiber/ equivalents.

OldNewNotes
coro/new fn(fiber/new fn \:yield\)Adds yield capability
make-coroutine fn(fiber/new fn \:yield\)Gen-1 alias
coro/resumefiber/resumeRename
coro/statusfiber/statusRename
coro/done?fiber/done?Rename
coro/valuefiber/valueRename
coroutine-resumefiber/resumeGen-1 rename
coroutine-statusfiber/statusGen-1 rename
coroutine-done?fiber/done?Gen-1 rename
coroutine-valuefiber/valueGen-1 rename
coroutine?fiber?Rename
coro?fiber?Rename
yield-fromyieldRename
coro/>iterator—Removed (fibers are natively iterable)
coroutine->iterator—Removed
coroutine-next—Removed (use fiber/resume)

Epoch 13 — string escapes

A string literal gains three escapes, and an escape the reader does not know becomes a read error. syntax.md lists the escapes epoch 13 reads.

EscapeEpoch ≤ 12Epoch 13
\0the string 0NUL, U+0000
\x41the string x41A; \x80 and above is an error
\u{e9}the string u{e9}é
\q, \e, \', any otherthe character after the backslashread error

Up to epoch 12 the reader knows five escapes: \n, \t, \r, \\ and \". It drops the backslash of any other escape and reports nothing.

# The same text, read under each epoch's rules.
(assert (= (read-all "\"\\x41\"") (list "A")) "epoch 13 reads the escape")
(assert (= (read-all "(elle/epoch 12) \"\\x41\"") (list '(elle/epoch 12) "x41"))
        "epoch 12 drops the backslash")

The change is lexical, so a file that declares epoch 12 or earlier keeps its meaning. elle rewrite writes each escape whose meaning changed as the text epoch 12 read from it, so "\x41" becomes "x41". A file with no declaration reads under epoch 13. When such a file holds an escape that only an older epoch reads, the read error names the declaration that restores the old reading.