Embedding

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.

From Rust

[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

The JSON boundary

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.

Holding it across threads

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

From any other language

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.

Giving the language new powers

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.

Building it

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.