Elle is a Lisp. What separates it from other Lisps is the depth of its static analysis: full binding resolution, capture analysis, and effect inference happen at compile time, before any code runs. This gives Elle a sound effect 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 and Plugins
- Tooling
- Getting Started
- License
-
Static analysis is a first-class feature. The compiler performs full binding resolution, capture analysis, effect 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 effects every function can produce.
More: Compile-Time Analysis
The compilation pipeline is: Source → Reader → Syntax → Expander → Analyzer → HIR → Lowerer → LIR → Emitter → Bytecode → VM. Each stage infers more than the last. The analyzer resolves all bindings to their definitions, computes which variables each closure captures, infers the effect of every expression, and flags lint violations — all before bytecode is emitted. This is why the linter catches errors at compile time, why the effect system is sound, and why the JIT can make intelligent decisions about what to compile natively.
-
A sound effect system, inferred not declared. Every function is automatically classified as
Pure,Yields, orPolymorphic. The compiler enforces this: a pure context cannot call a yielding function. No annotations required. This is what makes the fiber/concurrency story coherent — the compiler knows which functions can suspend.# Pure function — inferred automatically (defn add (a b) (+ a b)) # Yielding function — inferred from yield call (defn fetch-data (url) (yield :http-request url) (yield :http-wait)) # Polymorphic — effect depends on callback (defn map-effect (f xs) (map f xs)) # effect = effect of f
More: Effect Enforcement
The compiler enforces effect contracts: a pure context cannot call a yielding function. This is checked at compile time.
-
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. This is stronger than Janet's macros, which are s-expression templates.(defmacro my-swap (a b) `(let ((tmp ,a)) (set ,a ,b) (set ,b tmp))) (let ((tmp 100) (x 1) (y 2)) (my-swap x y) tmp) # => 100, not 1
More: Scope Sets
The
tmpbinding introduced by the macro does not shadow the caller'stmp. This is guaranteed by the scope set mechanism, 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.# A pure function (defn add (a b) (+ a b)) # A yielding function — suspends to fetch a value (defn fetch (key) (yield :get key)) # Both called identically — compute is not marked async/await (defn compute (a b key) (+ (add a b) (fetch key)))
More: Colorless Functions
In Rust/JS/Python,
fetchwould beasync fn/async def, forcingcomputeto beasynctoo, and every caller toawaitit. In Elle, the effect is inferred by the compiler, not declared by the programmer. Callers are unaffected. -
Structured concurrency via fibers with per-fiber memory. Each fiber has its own heap arena. When a fiber finishes, its memory is reclaimed in O(1) — no GC pause, no reference counting. The compiler's escape analysis drives scope-level reclamation within fibers.
(defn make-producer [] (coro/new (fn [] (each i in (range 5) (yield i))))) (def co (make-producer)) (forever (if (coro/done? co) (break) (print (coro/resume co))))
More: Fiber Memory
Fibers are independent execution contexts. Each has its own stack, call frames, and heap. When a fiber finishes, its entire heap is freed in O(1). No garbage collection, no reference counting, no pause.
-
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](tuple),{:key val}(struct),"hello"(string).@-prefixed are mutable:@[1 2 3](array),@{:key val}(table),@"hello"(buffer).# Immutable (def t [1 2 3]) # tuple (def s {:name "Bob"}) # struct (def str "hello") # string # Mutable (def a @[1 2 3]) # array (def tbl @{:name "Bob"}) # table (def buf @"hello") # buffer # Bytes and blobs (no literal syntax) (def b (bytes 1 2 3)) # immutable bytes (def bl (blob 1 2 3)) # mutable blob
-
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 (string/char-at "café" 3) # => "é" (string/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 [] (set n (+ n 1)) n)) (def c (make-counter 0)) (c) # => 1 (c) # => 2
More: Automatic Cell Wrapping
The closure captures
nby value. The compiler detects thatnis mutated, so it wraps it in a cell 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.
make-parametercreates a parameter,parameterizesets it in a scope, child fibers inherit parent parameter frames.(def *port* (make-parameter :stdout)) (parameterize ((*port* :stderr)) (print "to stderr")) # uses *port* = :stderr (print "to stdout") # uses *port* = :stdout
Every Elle value is a NaN-boxed 64-bit word. Immediates (nil, booleans, integers, floats, symbols, keywords, empty list) fit inline with no allocation. Everything else is a reference-counted pointer into a heap.
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 |
|---|---|---|---|
| tuple | array | [1 2 3] |
@[1 2 3] |
| struct | table | {:a 1} |
@{:a 1} |
| string | buffer | "hello" |
@"hello" |
| bytes | blob | (no literal) | (no literal) |
The @ prefix means "mutable version of this literal." The types within each pair share the same logical structure but differ in mutability.
| Type | Literal | Notes |
|---|---|---|
| nil | nil |
Absence of a value. Falsy. |
| boolean | true, false |
false is falsy; true is truthy. |
| integer | 42, -17 |
48-bit signed. No auto-coercion to float. Overflow panics. |
| 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. |
# Immutable # Mutable
[1 2 3] @[1 2 3]
{:name "Bob" :age 25} @{:name "Bob" :age 25}
"hello" @"hello"
(bytes 1 2 3) (blob 1 2 3)Tuple — fixed-length immutable sequence. Error values are tuples: [:division-by-zero "message"]. Bracket destructuring works on both tuples and arrays.
Array — mutable resizable sequence. (array-set! a 0 99), (array-ref a 0), (array-length a).
Struct — immutable ordered dictionary. (get s :key). Keys are typically keywords.
Table — mutable ordered dictionary. (get t :key), (put t :key val), (del t :key), (keys t), (values t), (has-key? t :key).
String — immutable interned text. Equality is O(1). Indexing and length count grapheme clusters, not bytes.
Buffer — mutable byte sequence. @"hello" desugars to (string->buffer "hello"). Supports get, put, push, pop, length, append, concat.
Bytes — immutable binary data. No literal syntax; constructed via (bytes 1 2 3) or (string->bytes "hello"). Displays as #bytes[hex ...].
Blob — mutable binary data. No literal syntax; constructed via (blob 1 2 3) or (string->blob "hello"). Displays as #blob[hex ...].
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. (make-parameter default) creates one; calling it reads the current value. parameterize sets it within a scope. Child fibers inherit parent parameter frames.
Cell — mutable box. User cells are explicit (box/unbox/set-box!). Local cells 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 tuple, empty table, empty struct, empty buffer |
array? |
array |
tuple? |
tuple |
table? |
table |
struct? |
struct |
buffer? |
buffer |
bytes? |
bytes |
blob? |
blob |
function? |
closure or native function |
closure? |
closure only |
primitive? |
native function only |
fiber? |
fiber |
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 |
| tuple | [1 2 3] |
| array | @[1 2 3] |
| struct | {:a 1} |
| table | @{:a 1} |
| bytes | #bytes[01 02 03] |
| blob | #blob[01 02 03] |
| closure | <closure> |
| native fn | <native-fn> |
| fiber | <fiber:status> |
| cell | <cell value> |
| buffer | @"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) (set 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.
-
NaN-boxed values: 8 bytes per value. Integers, floats, booleans, nil, symbols, keywords, and short strings (≤6 bytes) fit inline. Everything else is a pointer into a heap.
-
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 effect 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 effect system. Compiler knows where Elle's safety guarantees end and C's begin.
-
Module system is minimal by design.
import-fileloads 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-file "math.lisp") 2)) (add 1 2) # => 6
-
Native plugins are Rust cdylib crates. Link against
elle, export an init function. Plugins register primitives through the samePrimitiveDefmechanism as builtins — same effect declarations, same doc strings, same arity checking. Work directly withValue. No intermediate serialization format, no separate process, no generated bindings.(def re (import-file "target/release/libelle_regex.so")) (def pat (re:compile "\\d+")) (re:find-all pat "a1b2c3") # => ({:match "1" ...} {:match "2" ...} ...)
-
Five plugins ship with Elle: regex, sqlite, crypto, random, selkie.
-
Module system is user-replaceable.
import-fileis an ordinary primitive. You can wrap it with caching, path resolution, sandboxing, or shadow it entirely.
-
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, effect 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.
make # build elle + plugins + docs
./target/release/elle examples/hello.lisp # run a file
./target/release/elle # REPL
./target/release/elle lint <file|dir> # static analysis
./target/release/elle lsp # language server
./target/release/elle rewrite <file> # source-to-source rewritingThe examples/ directory is executable documentation. Each file demonstrates a feature and asserts its own correctness — they run as part of CI.
elle [file...]— Run Elle files or start the REPL if no files givenelle lint [options] <file|dir>...— Static analysis and lintingelle lsp— Start the language server protocol serverelle rewrite [options] <file...>— Source-to-source rewriting with ruleselle format [options] <file...>— Format Elle source files
MIT