README.md

September 6, 2026 · View on GitHub

███████╗██╗     ██╗███████╗██████╗ ██████╗ ███████╗
██╔════╝██║     ██║██╔════╝██╔══██╗██╔══██╗██╔════╝
█████╗  ██║     ██║███████╗██████╔╝██████╔╝███████╗
██╔══╝  ██║     ██║╚════██║██╔═══╝ ██╔══██╗╚════██║
███████╗███████╗██║███████║██║     ██║  ██║███████║
╚══════╝╚══════╝╚═╝╚══════╝╚═╝     ╚═╝  ╚═╝╚══════╝

CI Docs License: MIT status

[EMACS LISP // RUN .EL OUTSIDE EMACS // LISP-2 + DYNAMIC SCOPE // RUST CORE]

"The editor's language — without the editor."

elisprs runs Emacs Lisp (.el) as standalone programs from the command line: a Lisp-2 obarray (separate value/function cells) with lexical and dynamic binding and an elisp-correct reader, compiled to — and run on — the fusevm bytecode VM, the same engine behind zshrs, stryke, awkrs, vimlrs, and a dozen other language frontends. elisprs is a pure frontend: no bespoke VM or JIT — each form lowers to a fusevm::Chunk, hot arithmetic/comparison lowers to native fusevm ops (JIT/AOT-able), and the elisp object heap rides the VM as Value::Obj handles reached through fusevm's extension handler. It AOT-compiles to standalone native binaries (--aot-exe) and caches lowered bytecode in an rkyv shard at ~/.elisprs.

┌──────────────────────────────────────────────────────────────┐ │ ENGINE: FUSEVM   FRONTEND: PURE   AOT: STANDALONE BIN   CACHE: RKYV │ └──────────────────────────────────────────────────────────────┘

Read the Docs · Engineering Report · Builtin Reference · strykelang · zshrs · fusevm


Table of Contents


[0x00] SYSTEM SCAN

Positioning: Emacs Lisp has only ever run inside Emacs. elisprs takes the language out of the editor and runs .el as ordinary programs — with a REPL, no Emacs process required. It is built to become the fifth language hosted on fusevm, after zshrs, stryke, awkrs, and vimlrs.

Why it's built this way: Emacs Lisp is a Lisp-2 (every symbol carries a separate value cell and function cell) and supports both lexical and dynamic scoping. Those facts are the whole personality of the language, so elisprs owns the value model and the semantics, and leans on fusevm purely for execution:

LayerWhere
Value model — interned symbols, real cons cells (dotted), vectors, hash tables, closuresours — an ElispHost object heap; objects ride the VM as Value::Obj(u32) handles
Reader (1+/1-, #'foo, ?c, :kw, backquote, dotted pairs)ours — an elisp-correct S-expression reader
Lisp-2 obarray, lexical+dynamic binding, special forms, macros, subrsourssrc/host.rs + src/compiler.rs
Bytecode execution, JIT, AOTfusevm — elisprs has no VM/JIT of its own

Status: self-hosting elisp on fusevm. Each top-level form is read, macro-expanded, and lowered to a fusevm::Chunk (src/compiler.rs); fusevm executes it and calls back into the object heap (src/host.rs) through a registered extension handler. Core arithmetic/comparison lower to native fusevm ops so hot loops are JIT/AOT-able; --aot-exe emits standalone native binaries; lowered bytecode + a heap image are cached in an rkyv shard at ~/.elisprs, bounded to 64 MiB (ELISPRS_CACHE_MAX_BYTES; ELISPRS_CACHE=0 disables it) and evicted oldest-first, because put rewrites the whole shard and an unbounded one made every run pay for every script ever cached. (An earlier bootstrap built on the rust_lisp crate; it was replaced by this own value model — rust_lisp is no longer a dependency.)


[0x01] SYSTEM REQUIREMENTS

  • Rust 2021 edition (stable). Builds on rustc 1.96+.
  • Platforms: macOS (aarch64 / x86_64) and Linux (x86_64 / aarch64).
  • Dependencies: fusevm (the bytecode VM elisp executes on, with jit-disk-cache + aot), rkyv + bincode (the ~/.elisprs bytecode cache), serde/serde_json, and lsp-server/lsp-types (the --lsp server). No rust_lisp.

[0x02] INSTALLATION // ARM THE PAYLOAD

git clone https://github.com/MenkeTechnologies/elisprs   # from source
cd elisprs && cargo build --release

The build produces the elisp binary:

elisp FILE.el            # evaluate a file
elisp --script FILE.el   # evaluate a file as `emacs --script` does: the forms run in the
                         #   ` *load*` buffer, so they read the STANDARD syntax table.
                         #   Plain `elisp FILE.el` is the `emacs -l FILE` column.
elisp -e "(+ 1 2)"       # evaluate an expression, print its value
elisp                    # REPL — reedline editor on a TTY, plain line reader when piped
elisp --repl             # force the reedline REPL: Tab-completion, live stats banner, ~/.elisprs/history
elisp --lsp              # language server over stdio        (completion/hover/diagnostics/signature help)
elisp --dap              # debug adapter over stdio          (breakpoints/stepping/variables)
elisp --aot FILE -o a.o  # AOT-compile to a native object via fusevm::aot
elisp --aot-exe FILE -o a.out  # AOT-compile to a standalone native executable
elisp --version

[0x03] LANGUAGE COVERAGE

Reader syntax. integers, floats, strings (with escapes), symbols (including 1+ / 1- / <= / :keywords), nil / t, 'quote, #'function, ?c char literals, ; comments.

Special forms (21). quote function lambda progn prog1 if when unless cond and or while setq let let* defun defmacro defvar defconst condition-case unwind-protect.

Subrs. (The live count is whatever elisp -e "(let ((n 0)) (mapatoms (lambda (s) (when (and (fboundp s) (subrp (symbol-function s))) (setq n (1+ n))))) n)" reports; it moves every round, so it is not written down here.)

GroupFunctions
Arithmetic+ - * / % mod 1+ 1- abs max min = /= < > <= >=
Listscar cdr cons list append nth nthcdr reverse length member memq assoc assq member-ignore-case
c*r combinatorscaar cadr cdar cddr caadr cadar cdaar cdadr cddar (+ cl-caar cl-cadr cl-cdar cl-cddr)
Mutationsetcar setcdr aset fillarray store-substring clear-string (a string is a mutable object: aset/store-substring/clear-string write through every reference to it)
Overlaysmake-overlay overlayp overlay-start overlay-end overlay-buffer overlay-get overlay-put overlay-properties delete-overlay move-overlay copy-overlay overlays-at overlays-in next-overlay-change previous-overlay-change remove-overlays (both ends move with edits, with FRONT-ADVANCE/REAR-ADVANCE deciding which side of an insertion each lands on)
Vectorsvector make-vector aref vectorp
Recordsrecord make-record recordp (a distinct type — slot 0 is the type symbol, vectorp is nil; backs cl-defstruct)
Bool-vectorsmake-bool-vector bool-vector bool-vector-p bool-vector-count-population bool-vector-subsetp bool-vector-not (#&N"…" syntax)
Advice (nadvice)advice-add advice-remove add-function remove-function define-advice advice-member-p (all :around/:before/:after/:override/:filter-*/:*-while/:*-until combinators)
Hash tablesmake-hash-table gethash puthash remhash clrhash maphash hash-table-count hash-table-size hash-table-test hash-table-weakness hash-table-rehash-size hash-table-rehash-threshold hash-table-keys hash-table-values hash-table-p copy-hash-table define-hash-table-test (Emacs's slot + free-list model, so maphash order and slot reuse match; a define-hash-table-test test's elisp functions are called from outside the host borrow)
Predicateseq eql equal null not numberp integerp floatp stringp symbolp consp listp atom functionp
Symbols/cellsset symbol-value symbol-function fset boundp fboundp symbol-name intern make-symbol
Stringsconcat string= string-equal string< upcase downcase number-to-string string-to-number string-split
IO/formatformat message princ prin1 prin1-to-string print terpri
Functionalfuncall apply mapcar mapc sort identity
Regexpstring-match string-match-p match-beginning match-end match-string match-data set-match-data replace-regexp-in-string regexp-quote regexp-opt regexp-opt-charset regexp-opt-depth looking-at looking-back re-search-forward re-search-backward (+ save-match-data; regexp-opt and rx reproduce Emacs's output, not just its language)
Markersmake-marker point-marker copy-marker set-marker move-marker marker-position marker-buffer markerp marker-insertion-type set-marker-insertion-type
Text propertiespropertize put-text-property get-text-property set-text-properties add-text-properties remove-text-properties text-properties-at next-single-property-change next-property-change previous-single-property-change get-char-property

defun/defmacro/lambda support &optional and &rest; macros expand and re-evaluate; condition-case matches the error umbrella and specific error symbols.

A taste (the examples/ directory has runnable, self-testing ERT versions — elisp examples/demo.el):

(defun fact (n) (if (<= n 1) 1 (* n (fact (1- n)))))
(fact 6)                                  ; => 720

(mapcar (lambda (x) (* x x)) '(1 2 3 4))  ; => (1 4 9 16)
(mapcar #'1+ '(10 20 30))                 ; => (11 21 31)

(let ((x 10) (y 20)) (+ x y))             ; => 30

(format "%s = %d (hex %x)" 'count 255 255); => "count = 255 (hex ff)"

(condition-case e (/ 1 0)
  (arith-error (format "caught %s" e)))   ; => "caught (arith-error)"

Now supported (own cons model — Obj::Cons(Value, Value) heap cells, not rust_lisp's list-only cdr):

  • Dotted pairs. (cons 1 2) / (a . b) read, print ((1 . 2)), and round-trip; alists may use (key . value).
  • Backquote / unquote. `, ,, and ,@ are read and expanded.
  • setcar / setcdr mutate cons cells in place.
  • pcase. Structural dispatch over _, literals, 'x, symbol binders, (pred FN), (guard EXPR), (and …), (or …), and backquote patterns `(,a ,b) / `(,a . ,rest) (incl. nested), recognized from the reader's eager backquote expansion.
  • Regexps. string-match & friends translate elisp regexp syntax (\( \| \{, \</\>, backreferences \1..\9) to a backing engine, honor case-fold-search, and record char-indexed match data; replace-regexp-in-string is the subr.el Lisp definition (function-valued REP, \&/\N templates, FIXEDCASE/LITERAL/SUBEXP/START). The syntax-class escapes \sC, \SC, \w and \W are resolved against the syntax table in force where the regexp is compiled, so with-syntax-table and modify-syntax-entry change what they match.
  • Vector literals. [1 2 3] reads as a self-evaluating vector (elements unevaluated); aref / elt / length / append / sort operate on it.
  • Generalized setf over the common places: car, cdr, nth, elt, aref, gethash, symbol-value, plus plain variables and multiple place/value pairs.
  • format field specs. %[-][0][width][.prec] with s S d o x X c e f g, e.g. (format "%05d" 42)00042. Width and precision are measured in display columns, as in Emacs: a TAB is 8, a control character 2, a newline 0 and an East-Asian wide character 2, so (format "%.3s" "\tXY") is "" and (format "%4c|" ?中) is " 中|".

Scope. Both lexical (lexical-binding: t) and dynamic binding are honored — lexical closures capture their defining environment, while defvar / special variables bind dynamically.

Buffers & text editing. A global registry of named live buffers, each with its own text, 1-based point, mark, and narrowing bounds. current-buffer / set-buffer / get-buffer-create / generate-new-buffer / kill-buffer / rename-buffer / buffer-list manage the registry; insert / delete-region / delete-char / point motion (forward-char, forward-line, beginning-of-line, …) / narrow-to-region / widen edit the current buffer. save-excursion restores point via a marker that tracks intervening edits; save-restriction / with-temp-buffer / with-current-buffer scope the buffer and its restriction. Buffer-local variables key off the current buffer.

Markers & text properties. First-class markers (make-marker / point-marker / copy-marker / set-marker / marker-position / marker-buffer / marker-insertion-type) auto-adjust when text is inserted or deleted, honoring insertion type, and serve as buffer positions. String and buffer text carry property intervals: propertize / put-text-property / get-text-property / set-text-properties / add-text-properties / remove-text-properties / text-properties-at / next-single-property-change / next-property-change / previous-single-property-change / get-char-property; buffer-substring and buffer-string carry properties (the -no-properties variant drops them), and so do the string functions that copy characters: concat, substring, upcase/downcase/capitalize, and a %s argument to format (with format's padding treated as Emacs does — trailing padding is inside the argument's interval, leading padding is outside). This is enough that tabulated-list-print produces byte-identical propertized output to GNU Emacs.

Syntax tables & sexp scanning. Syntax tables are char-tables of (CODE . MATCHING-CHAR) descriptors, built by string-to-syntax / modify-syntax-entry / make-syntax-table / copy-syntax-table and selected per buffer by set-syntax-table / with-syntax-table; standard-syntax-table reproduces init_syntax_once entry for entry. On top of them sits syntax.c's scanner: scan-lists / scan-sexps / parse-partial-sexp / syntax-ppss / forward-comment / backward-prefix-chars / matching-paren / syntax-after, and the motion commands forward-sexp / backward-sexp / forward-list / backward-list / down-list / up-list / backward-up-list. It is the real state machine, not a paren counter: two-character comment delimiters, comment styles a/b/c, nested comments, generic string and comment fences, the $ math class, escape and char-quote runs, parse-sexp-ignore-comments, and the syntax-table text property under parse-sexp-lookup-properties. A parse-partial-sexp state can be handed back in to resume a scan, including one stopped between the two halves of a comment delimiter.

AOP pattern intercepts (an elisprs extension, ported from zshrs; distinct from elisp's native per-symbol nadvice). Where advice-add attaches to one named symbol, an intercept fires on a glob across many function names at once"forward-*", "_*", or the catch-alls "*" / "all" — with before / after / around advice and a timing/proceed protocol. Advice bodies are ordinary elisp forms evaluated in the running host (no subprocess); a re-entrancy guard keeps advice that calls a matching function from recursing.

(defun greet (x) (concat "hi " x))
(intercept 'before "gr*"    '(message "calling %s with %s" intercept-name intercept-args))
(intercept 'around "greet"  '(upcase (intercept-proceed)))   ; => "HI BOB"
(intercept 'after  "greet"  '(message "took %s us" intercept-us))
(greet "bob")
(intercept-list)     ; => ((1 before "gr*" …) (2 around "greet" …) (3 after "greet" …))
(intercept-remove 1) ; => t
(intercept-clear)    ; => 2

Registration returns an integer ID; (intercept-list) returns (ID KIND PATTERN FORM) entries; advice reads the dynamic context vars intercept-name / intercept-args / intercept-cmd, and (in after) intercept-ms / intercept-us. (intercept-proceed) runs the original from inside an around body.

Not in scope — surfaced loudly rather than silently misread: this is a useful elisp core, not the ~1000-subr GNU Emacs surface. Within the editor layer, overlays, a real interval tree (properties use a per-character plist vector, observably identical for get/put/next-change but O(n) in storage), and redisplay (windows, header lines, faces) are not modeled.


[0x04] ARCHITECTURE // REUSE-OWN SPLIT

.el source  →  reader.rs  →  forms on the ElispHost heap  →  compiler.rs → fusevm::Chunk  →  fusevm executes (calls back into host.rs)

elisp cells (cons / symbol / vector / closure / macro / subr) live in the ElispHost object heap and ride the VM as Value::Obj(u32) handles, so elisprs gets full elisp semantics — including dynamic scope and Lisp-2 cells — without forking either rust_lisp's Value enum or the fusevm core.

FileRole
src/reader.rsElisp-correct S-expression reader → forms on the ElispHost heap
src/host.rsElispHost: the object heap, Lisp-2 obarray, dynamic binding, and the fusevm extension handler that runs elisp ops
src/compiler.rsLowers elisp forms to a fusevm::Chunk; lambda bodies become sub-chunks
src/builtins.rsThe subr standard library (reached host-side from the CALL extension op)
src/intercepts.rsAOP pattern-intercept layer (glob advice across many function names) — an elisprs extension ported from zshrs, fired on the call_function join point
src/prelude.rsThe [DERIVED] elisp prelude — breadth written in elisp on top of the primitives
src/aot.rs--aot / --aot-exe driver: lowers a .el file to a fusevm::Chunk, emits a native object via fusevm::aot::compile_object, and links a standalone executable
src/aot_runtime.rsThe AOT binary's runtime hook: rebuilds the elisp heap from the image embedded in the object, installs the subrs, extension handlers and numeric contract on the fresh VM, and reports an uncaught elisp error as the interpreter does (an error halts the VM cleanly, so without this the process exited 0 in silence)
src/lsp.rs / src/dap.rs--lsp (completion/hover/diagnostics/signature help) and --dap (breakpoints/stepping/variables) servers
src/main.rsThe elisp CLI + REPL

[0x05] STATUS // COMPONENT GRID

The grid reflects the current state of the tree.

ComponentState
Elisp-correct reader (1+/#'/?c/:kw, nil/t, 'quote)Working
Value / List / Symbol model (rust_lisp)Reused
Lisp-2 obarray (value + function cells)Working
Dynamic binding (let/let*, special vars)Working
Special forms (21) + macros (defmacro)Working
Subr standard libraryWorking
Hash tables (make-hash-table/gethash/puthash/maphash)Working
Dotted pairs, backquote/unquote, setcar/setcdrWorking
elisp CLI — file / -e / REPLWorking
ERT test surface (ert-deftest/should/should-error)Working (prelude)
--lsp / --dap serversWorking
Execution-tier report (--tiers)Working
elisp → fusevm::Chunk lowering + execution (compiler.rs / host.rs)Working
--aot / --aot-exe → native object + standalone executable via fusevm::aot::compile_objectWorking
lexical-binding (lexical + dynamic)Working

[0x06] ROADMAP

Done — elisp executes on fusevm (the reason elisprs exists), the same frontend pattern as the sibling languages:

  1. ✅ elisp cells (cons / symbol / vector / closure) live in the ElispHost heap (src/host.rs) and ride the VM as Value::Obj handles — no invasive fusevm core change was needed (it never had to learn dynamic scope or Lisp cells).
  2. ✅ An elisp Op::Extended(id, arg) range dispatches quote / funcall / special-var bind / cons navigation through a handler registered with vm.set_extension_handler(...).
  3. ✅ Every top-level form lowers in compiler.rs; lambda bodies become sub-chunks.
  4. ✅ The subr library is reachable host-side from the CALL extension op.
  5. JIT / AOT tiers. fusevm is built with jit-disk-cache + aot, so elisp chunks pick up the three-tier Cranelift JIT, and --aot / --aot-exe emit a native object and a standalone executable through fusevm::aot::compile_object.
  6. Lexical + dynamic binding. Lexical closures capture their defining environment; defvar / special variables bind dynamically.
  7. Tooling. --lsp (completion/hover/diagnostics/signature help over the obarray, mirroring awkrs --lsp) and --dap (breakpoints/stepping off eval + the dynamic specstack) both ship.

Next:

  • Coverage. Broaden special-form / macro / backquote lowering toward full milestone-2 elisp.
  • Editor plugins. vscode-elisp / vim-elisp / emacs-elisp over the --lsp server.

[0x07] BUILD // COMPILE THE PAYLOAD

cargo build --release

elisp --help / -h prints the usage screen; elisp --version prints the version.


[0x08] TEST // INTEGRITY VERIFICATION

cargo test

Coverage spans reader.rs unit tests (number-vs-symbol tokenization, #' desugaring, ?c char literals, dotted-pair reading) and the end-to-end evaluation suite in tests/eval.rs — arithmetic, recursion, higher-order functions, special forms, macros, and error handling driven through the public eval_str API.

The examples/*.el scripts are self-testing: each uses the prelude's ERT surface (ert-deftest / should / should-error) and ert-run-tests-batch-and-exit, which exits non-zero on any failure. tests/examples.rs runs every example through the built elisp binary as a cargo test gate, and the CI examples job runs them through the release binary on Linux + macOS.

Differential fuzzing against GNU Emacs

bash scripts/fuzz_parity.sh              # 500 random forms, seed 1
bash scripts/fuzz_parity.sh -n 5000 -s 42
bash scripts/fuzz_parity.sh -S 20        # shrink the first 20 hits

scripts/fuzz_parity.sh generates a corpus of random elisp forms from a seeded grammar (scripts/fuzz/gen.el), evaluates every form under both emacs -Q --batch -l (ground truth) and elisp through one shared driver (scripts/fuzz/drive.el), and reports every form whose value — or whose signalled error — differs. Errors are compared too: Emacs's error symbol and error data are as much of the contract as the return value.

The seed makes a divergence reproduce exactly on any machine, and a crash or hang in one form is isolated rather than losing the rest of the corpus. The parity gaps it has already closed are recorded in BUGS.md.

The oracle is a (binary, argv) pair, and the run header prints both. The binary is resolved to an absolute, symlink-free path and gated on the version pinned in BUGS.md, because a different Emacs does not fail loudly — it reports a different divergence set, which reads like a regression. The argv decides answers the version number never mentions: emacs -Q --batch -l FILE evaluates in *scratch* under lisp-interaction-mode while emacs --script FILE evaluates in a fundamental-mode *load* buffer, so (char-syntax ?.) is 95 in the first and 46 in the second — same binary, same version, different answer. The two flag vectors are named once in the script, printed in the header, and checked before the corpus runs: scripts/fuzz/entry.el reports the current buffer and a spread of char-syntax answers through the exact argv each engine will use, and a mismatch stops the run rather than producing a wall of syntax "divergences" that are really a door mismatch.

Hits are delta-debugged. A raw hit is a depth-3 tree with three unrelated distractions bolted onto the one call that actually diverges. -S N shrinks the first N of them: scripts/fuzz/shrink.el proposes strictly-smaller candidates — it is a purely syntactic generator and knows nothing about which head symbols matter, so it cannot shrink towards a bug anyone already believes in — and the differential oracle is the only accept test. A candidate is kept only if Emacs still answers with the same signature (a value, or the same error symbol), so the minimal form still explains the hit it came from rather than wandering off to a different bug. Minimal reproducers land in target/fuzz/shrunk.txt.

A zero divergence count only means something if the corpus actually exercised the engines, so each run also prints how many forms the reference evaluated. Two ways a run can score a false zero are reported rather than hidden:

  • Both engines failed the same way. A form that hangs or crashes under both produces <HANG> on both sides, and <HANG> compares equal to <HANG>. Those forms are counted separately and never counted as agreement. (The per-batch timeout also scales with corpus size, because a fixed one silently turned a slow run into "perfect parity".)
  • The form was never valid. If a corpus form names something Emacs does not have, both engines signal void-function and the failures match. The run reports that count and warns above 5%.

[0x09] DOCUMENTATION // RENDERED HTML + MARKDOWN

docs/ is published to GitHub Pages and is the authoritative source for the rendered reference + engineering report.

DocSourceLive URL
User reference (architecture, coverage, status, taste)docs/index.htmlhttps://menketechnologies.github.io/elisprs/
Builtin reference (every function, variable, and special form — generated from src/lsp.rs)docs/reference.htmlhttps://menketechnologies.github.io/elisprs/reference.html
Engineering report (reuse/own split, fusevm frontend design, dependency posture)docs/report.htmlhttps://menketechnologies.github.io/elisprs/report.html

The HUD-themed HTML docs share hud-static.css, hud-theme.js, and tutorial.css — open them locally via file:// or browse the GitHub Pages URL above.


[0xFF] LICENSE

┌──────────────────────────────────────────────────────────────┐ │ MIT // BUNDLES rust_lisp (MIT) // FREE / OSS │ └──────────────────────────────────────────────────────────────┘


░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░ >>> READ THE FORM. BIND THE SYMBOL. EVAL THE LIST. <<< ░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
created by MenkeTechnologies