Elle is a Lisp with a compilation pipeline that does deep static analysis before any code runs: full binding resolution, capture analysis, and signal inference at compile time. 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.
The compiler already knows what every binding refers to, what every closure captures, and what signals every function can emit. This information is available to user code at runtime via compile/analyze and related functions, and exposed to AI agents via:
- Portrait — Signal profiles, captures, and composition properties of a single file
- MCP Server — Semantic knowledge graph of the entire codebase, queryable via SPARQL
- Agent Reasoning — How AI agents use these tools to understand, refactor, and verify code
Humans write readable code without type annotations or formal constraints. Agents query the semantic model the compiler produces. Neither compromises the other.
If you know Janet, think Janet on steroids — the same practical spirit (embeddable, batteries-included, modern syntax), with a compiler that understands your code deeply enough to expose it as structured data.
- What Makes Elle Different
- Language
- Types
- Control Flow
- Concurrency
- Memory
- Execution Backends
- FFI
- Module System
- Standard Library Modules
- Plugins
- Epochs — Versioned Syntax Migration
- Tooling
- Documentation
- For Agent Developers
- Alternative Surface Syntaxes
- Coming from Another Language
- Getting Started
- License
-
Fibers are the concurrency primitive. (docs/concurrency.md) 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. (docs/runtime.md) 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 — no primitive in the body can signal (defn pick (b x y) (if b x y)) # Yields — inferred from emit call (defn fetch-data (url) (emit :http-request url) (emit :http-wait)) # Errors — inferred from arithmetic (+ can fail on non-numbers) (defn add (a b) (+ a b))
-
Fully hygienic macros that operate on syntax objects, not text or s-expressions. (docs/macros.md) 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))) (def @tmp 100) (def @x 1) (def @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. -
Erlang-style processes fall out of the fiber model. (docs/processes.md) The same fibers that drive coroutines and I/O compose into a full process system: mailboxes, links, monitors, named registration, supervisors, and GenServers — implemented entirely in Elle as
lib/process.lisp. No VM changes, no special runtime support. A supervisor is a process that traps exits and restarts children; a GenServer is a process in a receive loop with call/cast dispatch. The signal system makes this possible:yielddelivers scheduler commands,:errorpropagates crashes through links,:fuelenables preemptive scheduling, and:iolets processes do async I/O without blocking the scheduler.(def process ((import "std/process"))) (process:start (fn [] # Start a supervised key-value server (process:supervisor-start-link [{:id :kv :restart :permanent :start (fn [] (process:gen-server-start-link {:init (fn [_] @{}) :handle-call (fn [req _from state] (match req ([:get k] [:reply (get state k) state]) ([:put k v] (put state k v) [:reply :ok state])))} nil :name :kv))}] :name :sup :max-restarts 3) (process:gen-server-call :kv [:put :lang "elle"]) (process:gen-server-call :kv [:get :lang]))) # => "elle" # If the kv server crashes, the supervisor restarts it automatically.
This is what Elle's design is for: fibers provide the mechanism, signals provide the control flow, and user-space libraries provide the policy. See
docs/processes.mdfor the full API. -
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. (docs/syntax.md) Macros operate on syntax trees, not text. See
prelude.lispfor hygienic macros and standard forms. -
Collection literals with mutable/immutable split. (docs/types.md) 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 (def b b[1 2 3]) # bytes (def bl @b[1 2 3]) # @bytes
-
Strings are sequences of grapheme clusters. (docs/strings.md)
length, slicing, indexing, and iteration all count grapheme clusters — not bytes, not codepoints.(length "café") # => 4, not 5 (get "café" 3) # => "é" (slice "café" 0 2) # => "ca" (first "café") # => "c" (rest "café") # => "afé" (length "👨👩👧") # => 1
-
Destructuring in all binding positions. (docs/destructuring.md)
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. (docs/functions.md) 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) # => 2More: Automatic Box Wrapping
The closure captures
nby value. The compiler detects thatnis mutated, so it wraps it in a box 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. (docs/parameters.md)
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 raw pointer into a bump-allocated HeapObject owned by the fiber's 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 |
|---|---|---|---|
| string | @string | "hello" |
@"hello" |
| array | @array | [1 2 3] |
@[1 2 3] |
| struct | @struct | {:a 1} |
@{:a 1} |
| bytes | @bytes | b[1 2 3] |
@b[1 2 3] |
| 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 — dictionary with deterministic key order. Keys are typically keywords.
(def s {:name "Bob" :age 25})
(get s :name) # => "Bob"
(keys s) # => (:age :name)
(values s) # => (25 "Bob")
(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. Literal syntax b[1 2 3].
(def b b[1 2 3])
(def b2 (bytes "hello"))
(get b 0) # => 1
(length b) # => 3
(bytes->hex b2) # => "68656c6c6f"@bytes — mutable binary data. Literal syntax @b[1 2 3].
(def b @b[1 2 3])
(def b2 (thaw (bytes "hello")))
(get b 0) # => 1
(length b) # => 3
(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) # => error — nil is not a collectionLists 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.
Every type has a predicate: nil?, integer?, string?, array?, struct?, pair?, bytes?, set?, fiber?, closure?, mutable?, etc. type-of returns the type as a keyword.
(type-of 42) # => :integer
(string? "hello") # => true
(mutable? @[1 2]) # => true
(mutable? [1 2]) # => falseSee docs/types.md for the full list.
| Type | Display |
|---|---|
| nil | nil |
| boolean | true / false |
| integer | 42 |
| float | 3.14 |
| symbol | 'foo |
| keyword | :foo |
| empty list | () |
| string | hello (no quotes) |
| @string | @"hello" |
| 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 | b[1 2 3] |
| @bytes | @b[1 2 3] |
| closure | <closure> |
| native fn | <native-fn> |
| fiber | <fiber:status> |
| box | <box value> |
| pointer | <pointer 0x...> |
-
Conditionals:
if,cond,when,unless,case. (docs/control.md)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. (docs/match.md) 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. (docs/errors.md) 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. (docs/loops.md)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))))
See docs/concurrency.md, docs/scheduler.md, and docs/io.md.
Elle has three concurrency layers, each built on the one below:
- Fibers — cooperative execution contexts with signal masks. The mechanism.
- Structured concurrency —
ev/spawn,ev/join,ev/race,ev/scope. Safe fork/join. - Processes — Erlang-style actors with mailboxes, supervision, and GenServers. The full model.
# Parallel work with automatic error propagation
(ev/scope (fn [spawn]
(let [users (spawn (fn [] (fetch-users)))
settings (spawn (fn [] (fetch-settings)))]
{:users (ev/join users) :settings (ev/join settings)})))
# Race: first to complete wins, rest are aborted
(ev/race [(ev/spawn (fn [] (fetch-from-primary)))
(ev/spawn (fn [] (fetch-from-replica)))])lib/process.lisp provides a complete Erlang/OTP-style
process system: lightweight processes with mailboxes, links, monitors,
named registration, GenServer, Actor, Task, Supervisor, and EventManager.
(def process ((import "std/process")))
(process:start (fn []
# Supervisor manages worker processes
(process:supervisor-start-link
[{:id :cache :restart :permanent
:start (fn []
(process:gen-server-start-link
{:init (fn [_] @{})
:handle-call (fn [req _from state]
(match req
([:get k] [:reply (get state k) state])
([:put k v] (put state k v) [:reply :ok state])))}
nil :name :cache))}]
:name :app-sup
:max-restarts 5
:logger (fn [event] (println "sup:" event)))
(process:gen-server-call :cache [:put :version 1])
(process:gen-server-call :cache [:get :version]))) # => 1Supervisors can also manage OS subprocesses:
(process:supervisor-start-link
[(process:make-subprocess-child :nginx "/usr/sbin/nginx" ["-g" "daemon off;"])
(process:make-subprocess-child :redis "/usr/bin/redis-server" [])]
:name :daemon-sup :max-restarts 3)See docs/processes.md for the full API including
GenServer callbacks, Actor state management, Task async/await, supervision
strategies, restart intensity limits, and structured logging.
See docs/concurrency.md for the structured
concurrency layer.
-
No garbage collector. (docs/memory.md) Memory is reclaimed deterministically through three mechanisms, all derived from the same static analysis that drives the signal system:
-
Per-fiber bump arenas: Each fiber owns a
FiberHeapbacked by a bump arena (sequential 64KB pages). When a fiber finishes, its entire arena is freed — no traversal, no mark phase, no sweep. Bump allocation is O(1) with strong cache locality and zero fragmentation. -
Zero-copy inter-fiber sharing: The compiler knows at fiber-creation time whether a fiber can yield (signal inference). Yielding fibers route all allocations to a
SharedAllocatorowned by the parent — the parent reads yielded values directly from shared memory. Silent fibers skip this entirely and allocate into their own arena with no indirection. No deep copy, no serialization, no runtime decision. -
Escape-analysis-driven scope reclamation: The compiler analyzes every
let,letrec,blockscope. When it can prove no allocated value escapes — no captures, no suspension, no outward mutation — it emitsRegionEnter/RegionExitbytecodes that rewind the arena to a mark at scope exit, recycling memory without waiting for fiber death.
-
-
Long-running fiber schedulers don't accumulate garbage. Each fiber's heap dies with it. Scope reclamation recycles memory within a fiber's lifetime. The ownership topology — private arena per fiber, shared arena per yield boundary — is the minimal structure that gives per-fiber lifecycle management and zero-copy yield simultaneously. See
docs/memory.mdfor the full model.
Elle has four execution tiers. All share the same front end (reader → expander → analyzer → HIR → LIR); they diverge at code generation. The VM tries tiers in order and falls through automatically — no annotations needed.
The default backend emits bytecode from LIR and runs it on a stack-based VM. Hot functions are automatically compiled to native code via Cranelift on a background thread — no annotations, no opt-in, no event-loop stall. The compiler's signal system identifies eligible functions; the JIT fires transparently. The interpreter continues running hot functions while Cranelift compiles them; compiled code is picked up on the next call.
Pure numeric functions (arithmetic, comparison, local variables, control
flow — no heap allocation, no calls, no signals beyond :error) are
compiled through MLIR → LLVM → native code. This runs before the
Cranelift JIT in the dispatch chain: hot eligible functions get MLIR
instead of Cranelift.
Eligible functions may capture variables (passed as extra parameters)
and return booleans. The caller reboxes the raw i64 result based on
the return type. Non-numeric arguments fall through to bytecode.
Requires --features mlir and LLVM 22 + MLIR at build time.
See docs/impl/mlir.md for details.
The same eligibility predicate drives SPIR-V emission: a pure numeric
closure is lowered to a compute kernel and dispatched to the GPU via
Vulkan. gpu:map applies a scalar function across arrays in parallel —
each workgroup thread runs the function on one element.
(def gpu ((import "std/gpu")))
(gpu:map (fn [x] (* x x)) [1 2 3 4]) # => [1 4 9 16]The fiber suspends on the GPU fence fd — no thread pool thread is
held while the GPU works. SPIR-V can also be written by hand via
lib/spirv.lisp for fused or custom kernels.
Requires --features mlir and the vulkan plugin.
See docs/impl/gpu.md and
docs/impl/spirv.md for details.
The WASM backend compiles the entire program (stdlib + user code) into a
single WebAssembly module and executes it via Wasmtime. It supports
closures, fibers, tail calls, I/O, and the async scheduler — everything
except eval.
elle --wasm=full script.lispA tiered mode compiles individual hot closures to WASM during bytecode VM execution:
elle --wasm=11 script.lispSee docs/impl/wasm.md for details.
-
Call C without ceremony. (docs/ffi.md) 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.
-
Minimal and parametric. (docs/modules.md)
importloads a file — Elle source or native.soplugin — compiles and executes it, returns the last expression's value. Elle modules are closures that return structs; call the closure to instantiate. Parameters to the closure configure the module — inject dependencies, toggle features, pass credentials.## Simple module — call the returned closure (def b64 ((import "std/base64"))) (b64:encode "hello") ## Parametric module — pass the hash plugin to enable UUID v5 (def hash-plugin (import "plugin/hash")) (def uuid ((import "std/uuid") hash-plugin)) (uuid:v5 "6ba7b810-9dad-11d1-80b4-00c04fd430c8" "example.com") ## Plugin — import returns a struct directly (no closure call) (def re (import "plugin/regex")) (re:match "\\d+" "abc123")
-
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
-
includesplices source at compile time. Unlikeimportwhich compiles and runs a separate file,includeinserts another file's forms directly into the current compilation unit — they share scope. Useincludefor splitting large files; useimportfor separate modules. -
Module system is user-replaceable.
importis an ordinary primitive. You can wrap it with caching, path resolution, sandboxing, or shadow it entirely.
See docs/libraries.md for full documentation.
-
Pure Elle and FFI modules require no compilation. Import with the
std/prefix. Modules that wrap C libraries (sqlite, compress, git) use Elle's FFI — the system library must be installed, but no Rust build step is needed.(def b64 ((import "std/base64"))) (b64:encode "hello") # => "aGVsbG8=" (def db ((import "std/sqlite"))) (def conn (db:open ":memory:")) (db:exec conn "CREATE TABLE t (id INTEGER, name TEXT)")
Module Description awsElle-native AWS client (SigV4, HTTPS) base64Base64 encoding/decoding cliDeclarative CLI argument parsing colorColor spaces, mixing, gradients, perceptual distance compressGzip, zlib, deflate, zstd (FFI to libz + libzstd) contractCompositional validation for function boundaries dnsPure Elle DNS client (RFC 1035) eguiImmediate-mode GUI wrapping the eguiplugingitGit repository operations (FFI to libgit2) globFilesystem glob pattern matching gtk4GTK4 bindings via FFI (pure Elle, no plugin) hashStreaming hash helpers (ports, coroutines) grpcgRPC client over HTTP/2 with length-prefixed framing httpPure Elle HTTP/1.1 client and server http2HTTP/2 client and server (h2 over TLS + h2c cleartext) ircCoroutine-based IRCv3 client with SASL luaLua compatibility prelude mqttMQTT client (uses the mqttplugin for packet codec)portraitSemantic portraits from compile/analyzeprocessErlang-style GenServer, Supervisor, Actor, Task rdfRDF triple generation for the Elle knowledge graph redisPure Elle Redis client (RESP2) resourceDeterministic resource consumption measurement sdl3SDL3 bindings via FFI semverSemantic version parsing and comparison sqliteSQLite database (FFI to libsqlite3) svgSVG construction and emission (pure Elle) syncLocks, semaphores, condvars, barriers, queues telemetryOpenTelemetry metrics (OTLP/HTTP JSON export) tlsTLS client and server (wraps tlsplugin)uuidUUID generation and parsing watchEvent-driven filesystem watcher gpuGPU compute via MLIR → SPIR-V → Vulkan spirvHand-written SPIR-V compute shader DSL waylandWayland compositor bindings via FFI websocketWebSocket client and server (RFC 6455, ws:// and wss://) zmqZeroMQ bindings via FFI
-
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" ...} ...)
-
Plugins are maintained in a separate repository. See docs/plugins.md for details.
Plugin Description arrowApache Arrow columnar data and Parquet serialization cryptoSHA-2 hashing and HMAC csvCSV reading and writing eguiImmediate-mode GUI (egui + winit + glow) hashUniversal hashing (MD5, SHA-1/2/3, BLAKE2/3, CRC32, xxHash) imageRaster image I/O, transforms, drawing, and analysis plottersChart and plot generation (line, bar, histogram, scatter) jiffDate, time, and duration arithmetic mqttMQTT packet codec 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 svgSVG rasterization via resvg (construction lives in lib/svg.lisp)synRust source code parsing tlsTLS client and server via rustls tomlTOML parsing and generation tree-sitterMulti-language parsing and structural queries vulkanVulkan compute dispatch (async fence, buffer pooling) waylandWayland compositor interaction xmlXML parsing and generation yamlYAML parsing and generation
-
Breaking changes are versioned. (docs/epochs.md) 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)) # Warning: + expects 2 arguments, got 1 (cons 1) # Error: cons expects 2 arguments, got 1 (defn f [x] x) (f 1 2) # Error: f expects 1 argument, got 2
-
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.
-
Opinionated code formatter.
elle fmtformats Elle source to a single canonical style with zero configuration. Wadler-style pretty printing with column-aware alignment. Idempotent — formatting already-formatted code produces identical output. Seedocs/fmt.mdfor the rule set and examples.elle fmt lib/*.lisp # format in place elle fmt --check lib/*.lisp # CI: exit 1 if any file needs formatting cat file.lisp | elle fmt # stdin → stdout -
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. -
Compilation pipeline is fully documented. See
docs/pipeline.mdfor data flow across boundaries andAGENTS.mdfor architecture details. -
MCP server for AI coding assistants. (docs/mcp.md) An MCP server written in Elle that gives AI agents deep structural access to the codebase. Maintained in a separate repository and included as a git submodule. Maintains a persistent RDF knowledge graph of both Elle and Rust source. 21 tools for static analysis, evaluation, refactoring, test orchestration, and cross-language tracing. Complements the LSP server — LSP handles real-time editing; MCP handles AI-driven code understanding.
What can an AI agent do with it?
- "What does
folddo?" —portraitreturns the full effect profile, failure modes, and composition properties. - "What breaks if I change
prim_first?" —impacttraces all callers and downstream signal changes. - "Trace
mapfrom Elle through primitives into Rust." —tracefollows the call chain: Elle stdlib →cons/first/restprimitives → Rustprim_cons/prim_first/prim_rest→Value::cons()/as_cons(). - "Which functions are JIT-eligible?" —
signal_querywithjit-eligiblereturns all silent functions. - "Rename
helpertoutilsacross the whole file." —compile_renamerewrites all references, respecting lexical scope. - "Find all Rust structs that have a
signalfield." — direct SPARQL:SELECT ?name WHERE { ?s a rust:Struct ; rust:field "signal" ; rust:name ?name }
See
docs/mcp.mdfor the full tool reference. - "What does
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) |
| DEVLOG.md | Per-PR development log (368 entries from diffs) |
| CHANGELOG.md | Changelog by subsystem arc (agent-optimized) |
The compiler computes signal inference, capture analysis, and call graphs for every file. The MCP server makes all of this queryable.
- Agent Reasoning Guide — Workflow: understand locally via
portrait, reason globally via SPARQL, refactor via compile-aware tools - MCP Server — 15 tools:
portrait,signal_query,impact,trace,compile_rename,compile_extract,compile_parallelize,verify_invariants, and SPARQL - Analysis overview — How portrait, MCP, and agent reasoning fit together
Elle's native syntax is s-expressions. If you find parentheses unfamiliar, you can write Elle programs using Python, JavaScript, or Lua syntax instead. These are purely cosmetic — the reader translates them into the same syntax trees as s-expressions, and the rest of the pipeline (macro expansion, analysis, compilation, execution) is unchanged.
Note that not all semantics map cleanly to other syntaxes. The alternative readers support a common subset of syntax features for testing purposes, but they are not as fully-featured as the s-expression reader.
To use an alternative syntax, just name your file with the appropriate extension:
elle program.py # Python syntax
elle program.js # JavaScript syntax
elle program.lua # Lua syntax
elle program.lisp # s-expression syntax (default)Each surface syntax maps its idioms to Elle primitives:
| Python | JS | Lua | Elle |
|---|---|---|---|
x = 42 |
const x = 42 |
local x = 42 |
(def x 42) |
lambda x: x + 1 |
(x) => x + 1 |
function(x) return x + 1 end |
(fn (x) (+ x 1)) |
if c: a / else: b |
if (c) { a } else { b } |
if c then a else b end |
(if c a b) |
for x in arr: |
for (const x of arr) |
for x in arr do ... end |
(each x in arr ...) |
{"x": 1, "y": 2} |
{x: 1, y: 2} |
{x = 1, y = 2} |
@{:x 1 :y 2} |
[1, 2, 3] |
[1, 2, 3] |
{1, 2, 3} |
@[1 2 3] |
See demos/syntax.py, demos/syntax.js,
and demos/syntax.lua for comprehensive examples of
every syntax feature.
Orientation guides for programmers arriving from other languages — key differences, concept mappings, and gotchas: Python · JavaScript · Rust · Go · Clojure · Common Lisp / Scheme · Erlang / Elixir · Janet · C
See INSTALL.md for full build instructions, system
dependencies, and optional features (WASM, MLIR).
cargo build --release -p elle # build elle
echo '(println "hello")' | ./target/release/elle # one-liner
./target/release/elle # REPL
make smoke # run all tests (~30s)Plugins live in a separate repository and use a stable ABI — they can be built independently from elle. The MCP server is also maintained separately. Both are included as git submodules.
elle [file...]— Run Elle files (.lisp,.py,.js,.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