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