1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub enum HostEffect {
44 Message(String),
46 RunCommand { name: String, args: Vec<String> },
48 SetOption { name: String, value: String },
50 InsertText(String),
52}
53
54#[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#[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 #[must_use]
83 pub fn with_snapshot(snapshot: EditorSnapshot) -> Self {
84 Self {
85 snapshot,
86 effects: Vec::new(),
87 }
88 }
89
90 pub fn take_effects(&mut self) -> Vec<HostEffect> {
93 std::mem::take(&mut self.effects)
94 }
95}
96
97pub 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 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 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
134fn register_editor_fns(interp: &mut Interpreter<EscribaHost>) {
137 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 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 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
205fn 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 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 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}