use serde::{Deserialize, Serialize};
use tatara_lisp_eval::{Arity, Interpreter, Value, install_full_stdlib_with};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum VmError {
#[error("tatara-lisp read error: {0}")]
Read(#[from] tatara_lisp::LispError),
#[error("tatara-lisp eval error: {0}")]
Eval(#[from] tatara_lisp_eval::EvalError),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HostEffect {
Message(String),
RunCommand { name: String, args: Vec<String> },
SetOption { name: String, value: String },
InsertText(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EditorSnapshot {
pub cursor_line: i64,
pub cursor_column: i64,
pub current_line: String,
pub mode: String,
pub buffer_name: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EscribaHost {
pub snapshot: EditorSnapshot,
pub effects: Vec<HostEffect>,
}
impl EscribaHost {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_snapshot(snapshot: EditorSnapshot) -> Self {
Self {
snapshot,
effects: Vec::new(),
}
}
pub fn take_effects(&mut self) -> Vec<HostEffect> {
std::mem::take(&mut self.effects)
}
}
pub struct EscribaVm {
interp: Interpreter<EscribaHost>,
}
impl Default for EscribaVm {
fn default() -> Self {
Self::new()
}
}
impl EscribaVm {
#[must_use]
pub fn new() -> Self {
let mut interp: Interpreter<EscribaHost> = Interpreter::new();
let mut bootstrap = EscribaHost::new();
install_full_stdlib_with(&mut interp, &mut bootstrap);
register_editor_fns(&mut interp);
Self { interp }
}
pub fn eval(&mut self, src: &str, host: &mut EscribaHost) -> Result<Value, VmError> {
let forms = tatara_lisp::read_spanned(src)?;
Ok(self.interp.eval_program(&forms, host)?)
}
}
fn register_editor_fns(interp: &mut Interpreter<EscribaHost>) {
interp.register_typed1(
"message",
|h: &mut EscribaHost, s: String| -> tatara_lisp_eval::Result<String> {
h.effects.push(HostEffect::Message(s.clone()));
Ok(s)
},
);
interp.register_typed1(
"insert",
|h: &mut EscribaHost, s: String| -> tatara_lisp_eval::Result<()> {
h.effects.push(HostEffect::InsertText(s));
Ok(())
},
);
interp.register_typed2(
"set-option",
|h: &mut EscribaHost, name: String, value: String| -> tatara_lisp_eval::Result<()> {
h.effects.push(HostEffect::SetOption { name, value });
Ok(())
},
);
interp.register_fn(
"run-command",
Arity::AtLeast(1),
|args: &[Value], h: &mut EscribaHost, span| {
let name = value_as_string(&args[0]).ok_or_else(|| {
tatara_lisp_eval::EvalError::native_fn(
"run-command",
"first argument must be a string command name",
span,
)
})?;
let rest = args[1..].iter().filter_map(value_as_string).collect();
h.effects.push(HostEffect::RunCommand { name, args: rest });
Ok(Value::Nil)
},
);
interp.register_typed0(
"cursor-line",
|h: &mut EscribaHost| -> tatara_lisp_eval::Result<i64> { Ok(h.snapshot.cursor_line) },
);
interp.register_typed0(
"cursor-column",
|h: &mut EscribaHost| -> tatara_lisp_eval::Result<i64> { Ok(h.snapshot.cursor_column) },
);
interp.register_typed0(
"current-line",
|h: &mut EscribaHost| -> tatara_lisp_eval::Result<String> {
Ok(h.snapshot.current_line.clone())
},
);
interp.register_typed0(
"editor-mode",
|h: &mut EscribaHost| -> tatara_lisp_eval::Result<String> { Ok(h.snapshot.mode.clone()) },
);
interp.register_typed0(
"buffer-name",
|h: &mut EscribaHost| -> tatara_lisp_eval::Result<String> {
Ok(h.snapshot.buffer_name.clone())
},
);
}
fn value_as_string(v: &Value) -> Option<String> {
match v {
Value::Str(s) | Value::Symbol(s) => Some(s.to_string()),
Value::Int(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn evaluates_pure_arithmetic() {
let mut vm = EscribaVm::new();
let mut host = EscribaHost::new();
let v = vm.eval("(+ 1 2)", &mut host).unwrap();
assert!(matches!(v, Value::Int(3)), "got {v:?}");
assert!(host.effects.is_empty(), "pure compute emits no effects");
}
#[test]
fn full_stdlib_supports_let_binding() {
let mut vm = EscribaVm::new();
let mut host = EscribaHost::new();
let v = vm.eval("(let ((x 5)) (* x x))", &mut host).unwrap();
assert!(matches!(v, Value::Int(25)), "got {v:?}");
}
#[test]
fn message_emits_effect() {
let mut vm = EscribaVm::new();
let mut host = EscribaHost::new();
vm.eval(r#"(message "hello from lisp")"#, &mut host).unwrap();
assert_eq!(
host.effects,
vec![HostEffect::Message("hello from lisp".into())]
);
}
#[test]
fn run_command_with_args_emits_effect() {
let mut vm = EscribaVm::new();
let mut host = EscribaHost::new();
vm.eval(r#"(run-command "open" "README.md")"#, &mut host)
.unwrap();
assert_eq!(
host.effects,
vec![HostEffect::RunCommand {
name: "open".into(),
args: vec!["README.md".into()],
}]
);
}
#[test]
fn set_option_and_insert_emit_effects() {
let mut vm = EscribaVm::new();
let mut host = EscribaHost::new();
vm.eval(r#"(set-option "number" "true")"#, &mut host).unwrap();
vm.eval(r#"(insert "hello")"#, &mut host).unwrap();
assert_eq!(
host.effects,
vec![
HostEffect::SetOption {
name: "number".into(),
value: "true".into(),
},
HostEffect::InsertText("hello".into()),
]
);
}
#[test]
fn reads_snapshot_and_branches() {
let mut vm = EscribaVm::new();
let mut host = EscribaHost::with_snapshot(EditorSnapshot {
cursor_line: 7,
..Default::default()
});
vm.eval(
r#"(if (> (cursor-line) 0) (message "below-top") (message "at-top"))"#,
&mut host,
)
.unwrap();
assert_eq!(host.effects, vec![HostEffect::Message("below-top".into())]);
}
#[test]
fn multi_form_program_sequences_effects() {
let mut vm = EscribaVm::new();
let mut host = EscribaHost::new();
vm.eval(r#"(message "first") (run-command "save")"#, &mut host)
.unwrap();
assert_eq!(
host.effects,
vec![
HostEffect::Message("first".into()),
HostEffect::RunCommand {
name: "save".into(),
args: vec![],
},
]
);
}
#[test]
fn take_effects_drains_log() {
let mut vm = EscribaVm::new();
let mut host = EscribaHost::new();
vm.eval(r#"(message "x")"#, &mut host).unwrap();
let drained = host.take_effects();
assert_eq!(drained.len(), 1);
assert!(host.effects.is_empty());
}
#[test]
fn read_error_surfaces_as_vm_error() {
let mut vm = EscribaVm::new();
let mut host = EscribaHost::new();
let err = vm.eval("(((", &mut host).unwrap_err();
assert!(matches!(err, VmError::Read(_)));
}
}