The engine is a library first and a binary second. Hold it in your Rust program, or talk to it over stdin and stdout from anything else.
[dependencies]
eelisp = { git = "https://github.com/santacroce-tech/eelisp-rs" }
use eelisp::Interpreter;
let it = Interpreter::new(); // in-memory database
let it = Interpreter::with_database("notes.db"); // or on disk
let v = it.eval_str("(+ 1 2)")?; // → Value
let all = it.eval_all("(def x 1) (* x 41)")?; // → Vec<Value>
let out = it.take_output(); // whatever print/println produced
An application usually wants JSON rather than Value. Two methods give it to you:
let json = it.eval_json("(browse books)")?; // tagged JSON for one expression
let env = it.eval_host("(browse books)"); // {ok, result, output} envelope — never panics
eval_host is the one to build a UI on: it always returns a well-formed envelope,
putting the error message in the same shape rather than raising.
{ "ok": true,
"result": { "$tableView": { "table": "books", "columns": [...], "rows": [...] } },
"output": "" }
Values that have no JSON equivalent are tagged so a client can discriminate them:
$tableView, $formView, $record, $resultSet,
$dict, $item, $sym and $kw. That is how
EEditor turns (browse books) into a real grid instead of a string.
Interpreter is single-threaded by design. EngineHandle wraps it:
the interpreter lives on its own thread, and the handle you keep is Send + Sync,
which is what lets a Tauri application store it in application state.
use eelisp::server::EngineHandle;
let engine = EngineHandle::spawn(":memory:".to_string());
let envelope: String = engine.eval("(+ 1 2)"); // the {ok,result,output} JSON
eelisp --serve speaks one JSON object per line on stdin, and answers with one
JSON envelope per line on stdout. No sockets, no ports, no protocol library.
$ eelisp --serve
{"src": "(+ 1 2)"}
{"ok":true,"result":3,"output":""}
Driving it from Node is about ten lines:
import { spawn } from "node:child_process";
import readline from "node:readline";
const child = spawn("eelisp", ["--serve"], { stdio: ["pipe", "pipe", "inherit"] });
const rl = readline.createInterface({ input: child.stdout });
const queue = [];
rl.on("line", (l) => queue.shift()?.(JSON.parse(l)));
const ev = (src) =>
new Promise((res) => (queue.push(res), child.stdin.write(JSON.stringify({ src }) + "\n")));
await ev("(deftable books (title:string))");
This is exactly how EEditor's browser development mode works, while the packaged app calls the library directly.
The editor functions — buffer-text, cursor-pos,
replace-range and the rest — are not built into the language. They are installed
by the host through callbacks, which is the intended way to extend EELisp: the engine stays
free of I/O and platform types, and the embedder decides what the language can reach.
That constraint is deliberate. The library has no threads and no I/O of its own, and no platform types leak into the value representation, so the same engine can back a desktop app, a phone app, a command line — and, with no change to this design, WebAssembly.
git clone https://github.com/santacroce-tech/eelisp-rs
cd eelisp-rs
cargo test # 74 tests
cargo build --release --bin eelisp
SQLite is compiled in (rusqlite with the bundled feature), so the binary has no
system database dependency. MIT licensed.