Elle is a Lisp. What separates it from other Lisps is the depth of its static analysis: full binding resolution, capture analysis, and signal inference happen at compile time, before any code runs. This gives Elle a sound signal system, fully hygienic macros, colorless concurrency via fibers, and deterministic memory management — all derived from the same analysis pass.
- What Makes Elle Different
- Language
- Types
- Control Flow
- Memory
- JIT
- FFI
- Modules
- Plugins
- Epochs
- Tooling
- Getting Started
- License
-
Fibers are the concurrency primitive. A fiber is an independent execution context — its own stack, call frames, signal mask, and heap. Fibers are cooperative and explicitly resumed. The parent drives execution by calling
fiber/resume. When a fiber emits a signal, it suspends and the parent decides what to do next.Fibers run as coroutines. A parent spawns a child, drives it step by step, and reads each yielded value:
(defn produce [] (emit :yield 1) (emit :yield 2) (emit :yield 3)) (def f (fiber/new produce |:yield|)) (fiber/resume f) (print (fiber/value f)) # => 1 (fiber/resume f) (print (fiber/value f)) # => 2 (fiber/resume f) (print (fiber/value f)) # => 3
When a fiber finishes, its entire heap is freed in O(1) — no GC pause, no reference counting.
-
Signals are typed, cooperative flow-control interrupts. A signal is a keyword —
:error,:log,:abort, or any user-defined name — that a fiber emits to its parent. The parent's signal mask determines which signals surface; unmasked signals propagate further up. The compiler infers which functions can emit signals and enforces that silent contexts don't call yielding ones.Error handling — a fiber signals an error; the parent catches it:
(defn risky [x] (if (< x 0) (error {:error :bad-input :message "negative input"}) (* x x))) (def f (fiber/new (fn () (risky -1)) |:error|)) (fiber/resume f) (if (= (fiber/status f) :paused) (print "caught:" (fiber/value f)) # => caught: {:error :bad-input ...} (print "result:" (fiber/value f)))
Yielding — a fiber yields progress updates; the parent drives it to completion:
(defn process-items [items] (each item items (emit :progress {:item item :result (* item item)}))) (def f (fiber/new (fn () (process-items [1 2 3])) |:progress|)) (forever (fiber/resume f) (if (= (fiber/status f) :paused) (print "progress:" (fiber/value f)) (break)))
Parent/child — a fiber spawns a child and collects its log signals:
(defn child [] (emit :log "child starting") (emit :log "child done") 42) (defn parent [] (def f (fiber/new child |:log|)) (forever (fiber/resume f) (match (fiber/status f) (:paused (print "log:" (fiber/value f))) (_ (break)))) (fiber/value f)) # => 42 (parent)
See docs/signals/ for the full signal system: user-defined signals,
silencefor callback sandboxing, and composed signal masks. -
Static analysis is a first-class feature. The compiler performs full binding resolution, capture analysis, signal inference, and lint passes before any code runs. This is not optional tooling bolted on — it is the compilation pipeline. Most Lisps are dynamic; Elle knows at compile time what every binding refers to, what every closure captures, and what signals every function can emit.
-
A sound signal system, inferred not declared. Every function is automatically classified as
Silent,Yields, orPolymorphic. The compiler enforces this: a silent context cannot call a yielding function. No annotations required.# Silent — inferred automatically (defn add (a b) (+ a b)) # Yields — inferred from emit call (defn fetch-data (url) (emit :http-request url) (emit :http-wait)) # Polymorphic — signal depends on the callback (defn map-signal (f xs) (map f xs)) # signal = signal of f
-
Fully hygienic macros that operate on syntax objects, not text or s-expressions. Macros receive and return
Syntaxobjects carrying scope information (Racket-style scope sets). Name capture is structurally impossible, not just conventionally avoided.(defmacro my-swap (a b) `(let ((tmp ,a)) (assign ,a ,b) (assign ,b tmp))) (let ((tmp 100) (x 1) (y 2)) (my-swap x y) tmp) # => 100, not 1
The
tmpintroduced by the macro does not shadow the caller'stmp. This is guaranteed by scope sets, not by convention. -
Functions are colorless. Any function can be called from a fiber. There is no
async/awaitannotation that marks a function as suspending and forces all its callers to be marked too. Whether something runs concurrently is decided at the call site, not baked into the function definition. In Rust/JS/Python, a suspendingfetchforces every caller to beasynctoo; in Elle, the signal is inferred by the compiler and callers are unaffected. -
The Rust ecosystem. FFI without ceremony. Native plugins as Rust cdylib crates. Values are marshalled directly to C types via libffi — no intermediate serialization format, no separate process, no generated bindings.
-
Modern Lisp syntax with no parser ambiguity. Macros operate on syntax trees, not text. See
prelude.lispfor hygienic macros and standard forms. -
Collection literals with mutable/immutable split. Bare delimiters are immutable:
[1 2 3](array),{:key val}(struct),"hello"(string).@-prefixed are mutable:@[1 2 3](@array),@{:key val}(@struct),@"hello"(@string).# Immutable (def a [1 2 3]) # array (def s {:name "Bob"}) # struct (def str "hello") # string (def s |1 2 3|) # set # Mutable (def a @[1 2 3]) # @array (def tbl @{:name "Bob"}) # @struct (def buf @"hello") # @string (def ms @|1 2 3|) # @set # Bytes and @bytes (no literal syntax) (def b (bytes 1 2 3)) # bytes (def bl (@bytes 1 2 3)) # @bytes
-
Strings are sequences of grapheme clusters.
length, slicing, indexing, and iteration all count grapheme clusters — not bytes, not codepoints.(length "café") # => 4, not 5 bytes (get "café" 3) # => "é" (slice "café" 0 2) # => "ca" (first "café") # => "c" (rest "café") # => "afé" (length "👨👩👧") # => 1
-
Destructuring in all binding positions.
def,let,let*,var,fnparameters,matchpatterns — missing values becomenil, wrong types becomenil.(def (head & tail) (list 1 2 3 4)) (def [x _ z] [10 20 30]) (def {:name n :age a} {:name "Bob" :age 25}) (def {:config {:db {:host h}}} {:config {:db {:host "localhost"}}})
-
Closures with automatic capture analysis. The compiler tracks which variables each closure captures. Mutable captures use cells automatically. Enables escape analysis for scope-level memory reclamation.
(defn make-counter [start] (var n start) (fn [] (assign n (+ n 1)) n)) (def c (make-counter 0)) (c) # => 1 (c) # => 2
More: Automatic LBox Wrapping
The closure captures
nby value. The compiler detects thatnis mutated, so it wraps it in an lbox automatically. No explicitboxorrefneeded. -
Full tail-call optimisation. All tail calls are optimised — not just self-recursion. Mutually recursive functions, continuation-passing style, and trampolining all work without stack overflow.
-
Splice operator for array spreading.
;exprmarks a value for spreading at call sites and in data constructors.(splice expr)is the long form.(def args @[2 3]) (+ 1 ;args) # => 6, same as (+ 1 2 3) (def items @[1 2]) @[0 ;items 3] # => @[0 1 2 3]
-
Reader macros for quasiquote and unquote.
`for quasiquote,,for unquote,,;for unquote-splice (inside quasiquote). -
Parameters for dynamic binding.
parametercreates a parameter,parameterizesets it in a scope, child fibers inherit parent parameter frames.(def *port* (parameter :stdout)) (parameterize ((*port* :stderr)) (print "to stderr")) # uses *port* = :stderr (print "to stdout") # uses *port* = :stdout
Immediates (nil, booleans, integers, floats, symbols, keywords, empty list) fit inline with no allocation. Everything else is a reference-counted pointer into a heap.
| Type | Literal | Notes |
|---|---|---|
| nil | nil |
Absence of a value. Falsy. |
| boolean | true, false |
false is falsy; true is truthy. |
| integer | 42, -17 |
Full-range i64. No auto-coercion to float. Overflow wraps. |
| float | 3.14, 1e10 |
IEEE 754 double. NaN/Infinity are heap-allocated. |
| symbol | foo, 'foo |
Interned identifier. |
| keyword | :foo |
Self-evaluating interned name. Used for keys and tags. |
| empty list | (), '() |
Terminates proper lists. Truthy — not the same as nil. |
| pointer | — | Raw C pointer (FFI only). NULL becomes nil. |
Every collection type has an immutable variant and a mutable variant. Bare literal syntax is immutable; the @ prefix makes it mutable.
| Immutable | Mutable | Literal | @-literal |
|---|---|---|---|
| array | @array | [1 2 3] |
@[1 2 3] |
| struct | @struct | {:a 1} |
@{:a 1} |
| string | @string | "hello" |
@"hello" |
| bytes | @bytes | (no literal) | (no literal) |
| set | @set | |1 2 3| |
@|1 2 3| |
The @ prefix means "mutable version of this literal." The types within each pair share the same logical structure but differ in mutability.
string — interned text. Equality is O(1) via interning. Indexing and length count grapheme clusters, not bytes.
(def s "café")
(length s) # => 4 (grapheme clusters, not bytes)
(get s 3) # => "é"
(slice s 0 2) # => "ca"
(concat s "!") # => "café!"array — fixed-length sequence.
(def a [1 2 3])
(get a 0) # => 1
(length a) # => 3
(concat a [4 5]) # => [1 2 3 4 5]struct — ordered dictionary. Keys are typically keywords.
(def s {:name "Bob" :age 25})
(get s :name) # => "Bob"
(keys s) # => (:name :age)
(values s) # => ("Bob" 25)
(has? s :name) # => trueset — ordered collection of unique values. Mutable values are frozen on insertion.
(def s |1 2 3|)
(contains? s 2) # => true
(add s 4) # => |1 2 3 4|
(del s 1) # => |2 3|
(union |1 2| |2 3|) # => |1 2 3|
(intersection |1 2| |2 3|) # => |2|
(difference |1 2| |2 3|) # => |1|bytes — immutable binary data. No literal syntax. Displays as #bytes[hex ...].
(def b (bytes 1 2 3))
(def b2 (string->bytes "hello"))
(get b 0) # => 1
(length b) # => 5
(bytes->hex b2) # => "68656c6c6f"@bytes — mutable binary data. No literal syntax. Displays as #@bytes[hex ...].
(def b (@bytes 1 2 3))
(def b2 (string->@bytes "hello"))
(get b 0) # => 1
(length b) # => 5
(bytes->hex b2) # => "68656c6c6f"Singly-linked cons cells. Proper lists terminate with () (empty list), not nil.
(list 1 2 3) # => (1 2 3)
(cons 1 (list 2 3)) # => (1 2 3)
(first (list 1 2 3)) # => 1
(rest (list 1 2 3)) # => (2 3)
(rest (list 1)) # => () — empty list, not nilnil vs empty list — this is the most common gotcha.
nilrepresents absence and is falsy.()is the empty list and is truthy. Lists terminate with(). Useempty?to check for end-of-list, notnil?.nil?only matchesnil.
(nil? nil) # => true
(nil? ()) # => false — empty list is not nil
(empty? ()) # => true
(empty? nil) # => false — nil is not an empty listLists are linked; tuples and arrays are contiguous in memory. They are not interchangeable.
Closures — compiled functions with captured environment. Captures are by value; mutable captures use compiler-managed cells automatically.
(fn (x) (+ x 1)) # anonymous
(defn add1 (x) (+ x 1)) # named (macro)Native functions — Rust primitives (+, -, cons, etc.). Not constructible from Elle.
Fiber — independent execution context with its own stack, call frames, signal mask, and heap. See Memory.
(fiber/new (fn () body) mask)
(fiber/resume f value)
(fiber/status f)Parameter — dynamic binding. (parameter default) creates one; calling it reads the current value. parameterize sets it within a scope. Child fibers inherit parent parameter frames.
Box — mutable box. User boxes are explicit (box/unbox/rebox). Local boxes are compiler-created for mutable captures and auto-unwrapped — users never see them.
Exactly two values are falsy. Everything else is truthy.
| Value | Truthy? |
|---|---|
nil |
No |
false |
No |
(), 0, "", [], @[] |
Yes |
= is structural for collections, interned for strings/symbols/keywords (O(1) comparison), and pointer identity for other heap objects.
| Predicate | Matches |
|---|---|
nil? |
nil only |
boolean? |
true or false |
number? |
integer or float |
integer? |
integer only |
float? |
float only |
symbol? |
symbol |
keyword? |
keyword |
string? |
string |
pair? |
cons cell |
list? |
cons cell or empty list |
empty? |
empty list, empty @array, empty array, empty @struct, empty struct, empty @string |
array? |
array (immutable or @array) |
struct? |
struct (immutable or @struct) |
set? |
set (immutable or @set) |
bytes? |
bytes (immutable or @bytes) |
function? |
closure or native function |
closure? |
closure only |
primitive? |
native function only |
fiber? |
fiber |
box? |
box (mutable box) |
parameter? |
dynamic parameter |
mutable? |
any mutable value (@array, @string, @bytes, @struct, @set, box, parameter) |
ptr? / pointer? |
raw or managed C pointer |
zero? |
zero (integer or float) |
type / type-of |
returns type as keyword (:integer, :string, etc.) |
| Type | Display |
|---|---|
| nil | nil |
| boolean | true / false |
| integer | 42 |
| float | 3.14 |
| symbol | 'foo |
| keyword | :foo |
| empty list | () |
| string | hello (no quotes) |
| cons | (1 2 3) or (a . b) for improper |
| array | [1 2 3] |
| @array | @[1 2 3] |
| struct | {:a 1} |
| @struct | @{:a 1} |
| set | |1 2 3| |
| @set | @|1 2 3| |
| bytes | #bytes[01 02 03] |
| @bytes | #@bytes[01 02 03] |
| closure | <closure> |
| native fn | <native-fn> |
| fiber | <fiber:status> |
| box | <box value> |
| @string | @"hello" |
| pointer | <pointer 0x...> |
-
Conditionals:
if,cond,when,unless,case.ifis the primitive, others are macros or sugar.(if (> x 0) "positive" "non-positive") (cond ((< x 0) "negative") ((= x 0) "zero") (true "positive")) (case x (1 "one") (2 "two") ("other"))
-
Pattern matching with
match. Type guards, element extraction, nested patterns, wildcard_, and guard clauses.(match value (0 "zero") (n when (< n 0) "negative") (n when (> n 0) "positive") ([a b] (+ a b)) ({:x x :y y} (+ x y)) (_ "no match"))
-
Error handling:
try/catch,protect,defer. Built on fibers and signals, not exceptions.(try (if (< x 0) (error "negative")) (+ x 1) (catch e (print "error:" e) 0)) (protect (do-something)) # => [success? value] (defer (cleanup) (do-work)) # cleanup runs after do-work
-
Loops:
while,forever,break.whileis the primitive,foreveris a macro,breakexits a block.(while (< i 10) (print i) (assign i (+ i 1))) (forever (if (done?) (break) (step))) (block :outer (each x in xs (if (found? x) (break :outer x))))
-
No garbage collector. Memory is reclaimed deterministically through three mechanisms:
- Per-fiber heaps: Each fiber allocates into a bump arena. When it finishes, the entire heap is freed in O(1) — no traversal, no mark phase, no sweep. Fibers get strong cache locality.
- Zero-copy inter-fiber sharing: Yielding fibers route allocations to a shared arena; parents read directly from shared memory. No deep copy, no serialization.
- Escape-analysis-driven scope reclamation: Compiler analyzes every
let,letrec,blockscope. When it can prove no allocated value escapes — no captures, no suspension, no outward mutation — it frees allocations at scope exit.
-
Long-running fiber schedulers don't accumulate garbage. Each fiber's heap dies with it. Memory is reclaimed at scope exit or fiber death, without pausing the world.
- JIT compilation is fully automatic. Pure, non-suspending functions are compiled to native code via Cranelift at runtime. No annotations, no opt-in. The compiler's signal system identifies eligible functions; the JIT fires transparently.
-
Call C without ceremony. Load a library, bind a symbol, call it.
(def libc (ffi/native nil)) (ffi/defbind sqrt libc "sqrt" :double @[:double]) (sqrt 2.0) # => 1.4142135623730951
-
Struct marshalling, variadic calls, callbacks, manual memory management all work.
(def point-type (ffi/struct @[:double :double])) (def p (ffi/malloc (ffi/size point-type))) (ffi/write p point-type @[1.5 2.5]) (def point-val (ffi/read p point-type)) (ffi/free p) # Variadic: snprintf (def snprintf-ptr (ffi/lookup libc "snprintf")) (def snprintf-sig (ffi/signature :int @[:ptr :size :string :int] 3)) (def out (ffi/malloc 128)) (ffi/call snprintf-ptr snprintf-sig out 128 "answer: %d" 42) (ffi/free out) # Callbacks: qsort with Elle comparison function (def cmp (ffi/callback cmp-sig (fn [a b] (- (ffi/read a :i32) (ffi/read b :i32))))) (ffi/call qsort-ptr qsort-sig arr 5 4 cmp) (ffi/callback-free cmp)
-
FFI calls are tagged in the signal system. Compiler knows where Elle's safety guarantees end and C's begin.
-
Module system is minimal by design.
importloads a file — Elle source or native.soplugin — compiles and executes it, returns the last expression's value. No module declarations, no export lists, no special import form. It's a function call. -
Source modules return their last expression. A module that defines functions via
defmakes them available as globals; a module that ends with a struct or function hands that value back to the caller.# math.lisp (fn (scale) {:add (fn (a b) (* (+ a b) scale)) :mul (fn (a b) (* (* a b) scale))}) # Usage (def {:add add :mul mul} ((import "std/math") 2)) (add 1 2) # => 6
-
Module system is user-replaceable.
importis an ordinary primitive. You can wrap it with caching, path resolution, sandboxing, or shadow it entirely.
-
Native plugins are Rust cdylib crates. Link against
elle, export an init function. Plugins register primitives through the samePrimitiveDefmechanism as builtins — same signal declarations, same doc strings, same arity checking. Work directly withValue. No intermediate serialization format, no separate process, no generated bindings.(def re (import "plugin/regex")) (def pat (re:compile "\\d+")) (re:find-all pat "a1b2c3") # => ({:match "1" ...} {:match "2" ...} ...)
-
29 plugins ship with Elle:
Plugin Description arrowApache Arrow columnar data and Parquet serialization base64Base64 encoding/decoding clapDeclarative CLI argument parsing compressCompression (gzip, zstd, etc.) cryptoSHA-2 hashing and HMAC csvCSV reading and writing gitGit repository operations globFilesystem glob patterns hashUniversal hashing (MD5, SHA-1/2/3, BLAKE2/3, CRC32, xxHash) jiffDate, time, and duration arithmetic msgpackMessagePack serialization oxigraphRDF graph database (SPARQL) polarsPolars DataFrame operations (eager and lazy APIs) protobufProtocol Buffers serialization randomPseudo-random number generation regexRegular expressions selkieMermaid diagram rendering semverSemantic version parsing and comparison sqliteSQLite database synRust source code parsing tlsTLS client and server via rustls tomlTOML parsing and generation tree-sitterMulti-language parsing and structural queries uuidUUID generation xmlXML parsing and generation yamlYAML parsing and generation
-
Breaking changes are versioned. Each source file can declare an epoch —
(elle/epoch N)— to pin the syntax version it was written for. The compiler transparently rewrites old-epoch syntax before macro expansion. Files without an epoch declaration target the current epoch. -
Three migration rule types.
Renameswaps symbols mechanically.Replacerestructures call forms using templates with positional placeholders.Removeflags deleted forms with a compile error and guidance message. -
elle rewritemigrates source files. One command applies all epoch rules, preserves formatting, and strips the epoch tag.--checkmode verifies files are up to date in CI.See
docs/epochs.mdfor details.
-
Language server (LSP) for IDE integration. Real-time diagnostics, hover documentation, jump-to-definition, refactoring support.
-
Static linter catches errors at compile time. Wrong arity, unused bindings, signal violations, type mismatches in patterns, duplicate pattern variables.
# Compile-time errors caught by elle lint: (defn foo (x y) (+ x)) # Error: missing argument y (let ((unused 42)) 100) # Warning: unused binding (fn (a b) (yield)) # Error: pure context, can't yield (match x ([a b c] a) # Error: pattern expects 3 elements (v v)) # Error: duplicate pattern variable
-
Match exhaustiveness is checked at compile time. The compiler warns when a match expression has patterns that can never be reached, and when the match may not cover all cases for a known type.
-
Source-to-source rewriting tool. The
rewritesubcommand applies pattern-based rules to Elle source files for refactoring and code generation. Rules are pattern-action pairs that match syntax trees and produce transformed output. -
Formatter for consistent code style. The
formattersubcommand formats Elle source files. -
Compilation pipeline is fully documented. See
docs/pipeline.mdfor data flow across boundaries andAGENTS.mdfor architecture details.
All documentation lives in docs/ as literate markdown — every .md file
is runnable via elle docs/<file>.md. Code blocks tagged ```lisp are
extracted and executed; the rest is prose. This means examples are always
tested and never stale.
Start with QUICKSTART.md for the full table of contents.
| Directory | Content |
|---|---|
docs/*.md |
Language topics (one file per concept) |
docs/signals/ |
Signal system and fiber architecture |
docs/cookbook/ |
Recipes for common codebase changes |
docs/analysis/ |
Testing, debugging, semantic portraits |
docs/impl/ |
Implementation internals (reader, HIR, LIR, VM, JIT) |
- Rust (stable, 2021 edition) — install via rustup
- Linux — Elle uses io-uring for async I/O. macOS and aarch64 Linux are supported in CI but io-uring is Linux-only.
- GNU Make — for the build targets below.
make # build elle + plugins
echo '(println "hello")' | ./target/release/elle # one-liner
./target/release/elle # REPL
make smoke # run all tests (~30s)
make test # full test suite (~2min)elle [file...]— Run Elle files (.lisp,.lua,.md) or start the REPLelle lint [options] <file|dir>...— Static analysis and lintingelle lsp— Start the language server protocol serverelle rewrite [options] <file...>— Source-to-source rewriting with rules
elle lsp speaks standard LSP over stdio. Point your editor at it:
VS Code — add to .vscode/settings.json:
{
"elle.server.path": "/path/to/elle",
"elle.server.args": ["lsp"]
}Neovim — add to your LSP config:
vim.lsp.start({
name = "elle",
cmd = { "/path/to/elle", "lsp" },
filetypes = { "elle", "lisp" },
root_dir = vim.fs.dirname(vim.fs.find({ ".git" }, { upward = true })[1]),
})MIT