Skip to main content

escriba_vm/
lib.rs

1//! `escriba-vm` — escriba's embedded tatara-lisp runtime.
2//!
3//! Hosts a [`tatara_lisp_eval::Interpreter`] parameterized over
4//! [`EscribaHost`]. Lisp code runs the full language (arithmetic,
5//! lists, `if`/`let`/`lambda`/`begin` via `install_primitives`) and
6//! interacts with the editor through **native functions**:
7//!
8//! - **reads** (`cursor-line`, `current-line`, `editor-mode`, …) answer
9//!   from a [`EditorSnapshot`] captured *before* eval, so the host owns
10//!   no borrows and satisfies `Interpreter<H>`'s `H: 'static` bound;
11//! - **writes** (`message`, `insert`, `set-option`, `run-command`) push
12//!   typed [`Negai`](escriba_madoguchi::Negai) slips onto an accumulating log.
13//!
14//! The effect boundary is the **sandbox seam** (the "terreiro"): Lisp
15//! can never corrupt editor state directly — it can only request typed,
16//! validated mutations that the runtime applies after eval. It is also
17//! the seam through which polyglot **WASM/WASI** plugins are hosted: a
18//! plugin authored in any language is driven by the same tatara-lisp
19//! host and emits the same typed slips, so the editor's apply path is
20//! identical regardless of plugin language.
21//!
22//! This is the imperative tier of escriba's two-tier programmability
23//! model — the declarative tier is `escriba-lisp`'s def-forms.
24
25use escriba_madoguchi::Negai;
26use serde::{Deserialize, Serialize};
27use tatara_lisp_eval::{Arity, Interpreter, Value, install_full_stdlib_with};
28use thiserror::Error;
29
30#[derive(Debug, Error)]
31pub enum VmError {
32    #[error("tatara-lisp read error: {0}")]
33    Read(#[from] tatara_lisp::LispError),
34    #[error("tatara-lisp eval error: {0}")]
35    Eval(#[from] tatara_lisp_eval::EvalError),
36}
37
38// NOTE: `HostEffect` lived here — Message / RunCommand / SetOption /
39// InsertText. It was a THIRD mutation vocabulary beside the Action executor
40// and the slip interpreter, with its own `apply_host_effects` in the runtime
41// re-implementing message-push, option-insert and insert-text. That is the
42// exact shape that let `u` and `:undo` drift apart in M3, waiting to happen
43// again. The VM now emits `escriba_madoguchi::Negai` and the interpreter is
44// the single implementation.
45
46/// Read-side snapshot of editor state, captured before eval so Lisp can
47/// query without borrowing live state. Integer fields are `i64` to
48/// marshal directly to the Lisp `Int` value.
49#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
50pub struct EditorSnapshot {
51    pub cursor_line: i64,
52    pub cursor_column: i64,
53    pub current_line: String,
54    pub mode: String,
55    pub buffer_name: String,
56}
57
58/// The Lisp host: a read snapshot + an accumulating effect log. Owned
59/// (no borrows) so it satisfies `Interpreter<H>`'s `H: 'static`; passed
60/// by `&mut` per eval call.
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct EscribaHost {
63    pub snapshot: EditorSnapshot,
64    pub effects: Vec<Negai>,
65}
66
67impl EscribaHost {
68    #[must_use]
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// A host seeded with a read snapshot (and an empty effect log).
74    #[must_use]
75    pub fn with_snapshot(snapshot: EditorSnapshot) -> Self {
76        Self {
77            snapshot,
78            effects: Vec::new(),
79        }
80    }
81
82    /// Drain the accumulated effects, leaving the log empty — the
83    /// runtime calls this after eval to apply them.
84    pub fn take_effects(&mut self) -> Vec<Negai> {
85        std::mem::take(&mut self.effects)
86    }
87}
88
89/// escriba's embedded tatara-lisp runtime. Build once, eval many times;
90/// the registered native fns (the editor capability surface) persist
91/// across calls.
92pub struct EscribaVm {
93    interp: Interpreter<EscribaHost>,
94}
95
96impl Default for EscribaVm {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl EscribaVm {
103    #[must_use]
104    pub fn new() -> Self {
105        let mut interp: Interpreter<EscribaHost> = Interpreter::new();
106        // Install the FULL language stdlib — primitives + higher-order
107        // fns (map/filter/fold) + maps + channels + fibers + type-check
108        // + the Lisp-authored stdlib. The bootstrap host is a throwaway:
109        // stdlib install runs before the editor fns are registered, so
110        // it emits no editor effects.
111        let mut bootstrap = EscribaHost::new();
112        install_full_stdlib_with(&mut interp, &mut bootstrap);
113        register_editor_fns(&mut interp);
114        Self { interp }
115    }
116
117    /// Evaluate Lisp `src` against `host`. Returns the last form's
118    /// value; any effects the program requested accumulate on
119    /// `host.effects` (drain with [`EscribaHost::take_effects`]).
120    pub fn eval(&mut self, src: &str, host: &mut EscribaHost) -> Result<Value, VmError> {
121        let forms = tatara_lisp::read_spanned(src)?;
122        Ok(self.interp.eval_program(&forms, host)?)
123    }
124}
125
126/// Register the editor capability surface as native Lisp functions.
127/// Reads answer from the snapshot; writes push typed effects.
128fn register_editor_fns(interp: &mut Interpreter<EscribaHost>) {
129    // ── writes — emit typed effects ────────────────────────────────
130    interp.register_typed1(
131        "message",
132        |h: &mut EscribaHost, s: String| -> tatara_lisp_eval::Result<String> {
133            h.effects.push(Negai::Message(s.clone()));
134            Ok(s)
135        },
136    );
137    interp.register_typed1(
138        "insert",
139        |h: &mut EscribaHost, s: String| -> tatara_lisp_eval::Result<()> {
140            h.effects.push(Negai::InsertText(s));
141            Ok(())
142        },
143    );
144    interp.register_typed2(
145        "set-option",
146        |h: &mut EscribaHost, name: String, value: String| -> tatara_lisp_eval::Result<()> {
147            h.effects.push(Negai::SetOption { name, value });
148            Ok(())
149        },
150    );
151    // `run-command` is variadic (name + zero-or-more string args), so it
152    // uses the raw FFI rather than a fixed-arity typed registration.
153    interp.register_fn(
154        "run-command",
155        Arity::AtLeast(1),
156        |args: &[Value], h: &mut EscribaHost, span| {
157            let name = value_as_string(&args[0]).ok_or_else(|| {
158                tatara_lisp_eval::EvalError::native_fn(
159                    "run-command",
160                    "first argument must be a string command name",
161                    span,
162                )
163            })?;
164            let rest = args[1..].iter().filter_map(value_as_string).collect();
165            h.effects.push(Negai::RunCommand { name, args: rest });
166            Ok(Value::Nil)
167        },
168    );
169
170    // ── reads — answer from the pre-eval snapshot ──────────────────
171    interp.register_typed0(
172        "cursor-line",
173        |h: &mut EscribaHost| -> tatara_lisp_eval::Result<i64> { Ok(h.snapshot.cursor_line) },
174    );
175    interp.register_typed0(
176        "cursor-column",
177        |h: &mut EscribaHost| -> tatara_lisp_eval::Result<i64> { Ok(h.snapshot.cursor_column) },
178    );
179    interp.register_typed0(
180        "current-line",
181        |h: &mut EscribaHost| -> tatara_lisp_eval::Result<String> {
182            Ok(h.snapshot.current_line.clone())
183        },
184    );
185    interp.register_typed0(
186        "editor-mode",
187        |h: &mut EscribaHost| -> tatara_lisp_eval::Result<String> { Ok(h.snapshot.mode.clone()) },
188    );
189    interp.register_typed0(
190        "buffer-name",
191        |h: &mut EscribaHost| -> tatara_lisp_eval::Result<String> {
192            Ok(h.snapshot.buffer_name.clone())
193        },
194    );
195}
196
197/// Coerce a `Value` to a `String` at the FFI boundary. Accepts `Str`
198/// and `Symbol` verbatim, and stringifies `Int`/`Bool` for ergonomics
199/// (so `(run-command "goto" 42)` works). Other kinds → `None`.
200fn value_as_string(v: &Value) -> Option<String> {
201    match v {
202        Value::Str(s) | Value::Symbol(s) => Some(s.to_string()),
203        Value::Int(n) => Some(n.to_string()),
204        Value::Bool(b) => Some(b.to_string()),
205        _ => None,
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn evaluates_pure_arithmetic() {
215        let mut vm = EscribaVm::new();
216        let mut host = EscribaHost::new();
217        let v = vm.eval("(+ 1 2)", &mut host).unwrap();
218        assert!(matches!(v, Value::Int(3)), "got {v:?}");
219        assert!(host.effects.is_empty(), "pure compute emits no effects");
220    }
221
222    #[test]
223    fn full_stdlib_supports_let_binding() {
224        // Beyond bare primitives: `let` + arithmetic confirms the full
225        // language stdlib installed and evaluates.
226        let mut vm = EscribaVm::new();
227        let mut host = EscribaHost::new();
228        let v = vm.eval("(let ((x 5)) (* x x))", &mut host).unwrap();
229        assert!(matches!(v, Value::Int(25)), "got {v:?}");
230    }
231
232    #[test]
233    fn message_emits_effect() {
234        let mut vm = EscribaVm::new();
235        let mut host = EscribaHost::new();
236        vm.eval(r#"(message "hello from lisp")"#, &mut host)
237            .unwrap();
238        assert_eq!(host.effects, vec![Negai::Message("hello from lisp".into())]);
239    }
240
241    #[test]
242    fn run_command_with_args_emits_effect() {
243        let mut vm = EscribaVm::new();
244        let mut host = EscribaHost::new();
245        vm.eval(r#"(run-command "open" "README.md")"#, &mut host)
246            .unwrap();
247        assert_eq!(
248            host.effects,
249            vec![Negai::RunCommand {
250                name: "open".into(),
251                args: vec!["README.md".into()],
252            }]
253        );
254    }
255
256    #[test]
257    fn set_option_and_insert_emit_effects() {
258        let mut vm = EscribaVm::new();
259        let mut host = EscribaHost::new();
260        vm.eval(r#"(set-option "number" "true")"#, &mut host)
261            .unwrap();
262        vm.eval(r#"(insert "hello")"#, &mut host).unwrap();
263        assert_eq!(
264            host.effects,
265            vec![
266                Negai::SetOption {
267                    name: "number".into(),
268                    value: "true".into(),
269                },
270                Negai::InsertText("hello".into()),
271            ]
272        );
273    }
274
275    #[test]
276    fn reads_snapshot_and_branches() {
277        // Proves genuine eval: Lisp READS host state (cursor-line) and an
278        // `if` chooses which effect to emit — not a static transform.
279        let mut vm = EscribaVm::new();
280        let mut host = EscribaHost::with_snapshot(EditorSnapshot {
281            cursor_line: 7,
282            ..Default::default()
283        });
284        vm.eval(
285            r#"(if (> (cursor-line) 0) (message "below-top") (message "at-top"))"#,
286            &mut host,
287        )
288        .unwrap();
289        assert_eq!(host.effects, vec![Negai::Message("below-top".into())]);
290    }
291
292    #[test]
293    fn multi_form_program_sequences_effects() {
294        let mut vm = EscribaVm::new();
295        let mut host = EscribaHost::new();
296        vm.eval(r#"(message "first") (run-command "save")"#, &mut host)
297            .unwrap();
298        assert_eq!(
299            host.effects,
300            vec![
301                Negai::Message("first".into()),
302                Negai::RunCommand {
303                    name: "save".into(),
304                    args: vec![],
305                },
306            ]
307        );
308    }
309
310    #[test]
311    fn take_effects_drains_log() {
312        let mut vm = EscribaVm::new();
313        let mut host = EscribaHost::new();
314        vm.eval(r#"(message "x")"#, &mut host).unwrap();
315        let drained = host.take_effects();
316        assert_eq!(drained.len(), 1);
317        assert!(host.effects.is_empty());
318    }
319
320    #[test]
321    fn read_error_surfaces_as_vm_error() {
322        let mut vm = EscribaVm::new();
323        let mut host = EscribaHost::new();
324        let err = vm.eval("(((", &mut host).unwrap_err();
325        assert!(matches!(err, VmError::Read(_)));
326    }
327}