diff --git a/core/src/lambdadelta_host.rs b/core/src/lambdadelta_host.rs index b04f3fd..c2539df 100644 --- a/core/src/lambdadelta_host.rs +++ b/core/src/lambdadelta_host.rs @@ -23,7 +23,7 @@ use std::rc::Rc; use chrono::{DateTime, Utc}; use uuid::Uuid; -use crate::lambdadelta::{Interp, LdError, LdResult, Value}; +use crate::lambdadelta::{Budget, Interp, LdError, LdResult, Value}; use crate::note::Point2D; use crate::notebook::Notebook; @@ -46,32 +46,58 @@ use crate::notebook::Notebook; /// assert_eq!(out, Value::Int(1)); /// ``` pub fn register(interp: &mut Interp, nb: Rc>) { - // Readers (pure). - reader(interp, &nb, "notes", 0, Some(0), bi_notes); - reader(interp, &nb, "note", 1, Some(1), bi_note); - reader(interp, &nb, "title", 1, Some(1), bi_title); - reader(interp, &nb, "content", 1, Some(1), bi_content); - reader(interp, &nb, "attrs", 1, Some(1), bi_attrs); - reader(interp, &nb, "links", 1, Some(1), bi_links); - reader(interp, &nb, "backlinks", 1, Some(1), bi_backlinks); - reader(interp, &nb, "position", 1, Some(1), bi_position); - reader(interp, &nb, "attr", 2, Some(2), bi_attr); - reader(interp, &nb, "search", 1, Some(1), bi_search); - reader(interp, &nb, "resolve-title", 1, Some(1), bi_resolve_title); - reader(interp, &nb, "agents", 0, Some(0), bi_agents); - reader(interp, &nb, "run-agent", 1, Some(1), bi_run_agent); - - // Mutators (effects, `!`-suffixed). - mutator(interp, &nb, "create-note!", 1, Some(3), bi_create_note); - mutator(interp, &nb, "set-title!", 2, Some(2), bi_set_title); - mutator(interp, &nb, "set-content!", 2, Some(2), bi_set_content); - mutator(interp, &nb, "set-attr!", 3, Some(3), bi_set_attr); - mutator(interp, &nb, "remove-attr!", 2, Some(2), bi_remove_attr); - mutator(interp, &nb, "move-note!", 3, Some(3), bi_move_note); - mutator(interp, &nb, "resize-note!", 3, Some(3), bi_resize_note); - mutator(interp, &nb, "link!", 2, Some(2), bi_link); - mutator(interp, &nb, "unlink!", 2, Some(2), bi_unlink); - mutator(interp, &nb, "delete-note!", 1, Some(1), bi_delete_note); + register_readers(interp, &nb); + register_mutators(interp, &nb); +} + +/// Register only the pure reader builtins — the surface a **formula** or +/// **agent-predicate** context is allowed (spec §5). +pub fn register_readers(interp: &mut Interp, nb: &Rc>) { + reader(interp, nb, "notes", 0, Some(0), bi_notes); + reader(interp, nb, "note", 1, Some(1), bi_note); + reader(interp, nb, "title", 1, Some(1), bi_title); + reader(interp, nb, "content", 1, Some(1), bi_content); + reader(interp, nb, "attrs", 1, Some(1), bi_attrs); + reader(interp, nb, "links", 1, Some(1), bi_links); + reader(interp, nb, "backlinks", 1, Some(1), bi_backlinks); + reader(interp, nb, "position", 1, Some(1), bi_position); + reader(interp, nb, "attr", 2, Some(2), bi_attr); + reader(interp, nb, "search", 1, Some(1), bi_search); + reader(interp, nb, "resolve-title", 1, Some(1), bi_resolve_title); + reader(interp, nb, "agents", 0, Some(0), bi_agents); + reader(interp, nb, "run-agent", 1, Some(1), bi_run_agent); +} + +/// Register the `!`-suffixed mutators — permitted only in **action** contexts +/// (on-create / agent-action / stamp; spec §5). +pub fn register_mutators(interp: &mut Interp, nb: &Rc>) { + mutator(interp, nb, "create-note!", 1, Some(3), bi_create_note); + mutator(interp, nb, "set-title!", 2, Some(2), bi_set_title); + mutator(interp, nb, "set-content!", 2, Some(2), bi_set_content); + mutator(interp, nb, "set-attr!", 3, Some(3), bi_set_attr); + mutator(interp, nb, "remove-attr!", 2, Some(2), bi_remove_attr); + mutator(interp, nb, "move-note!", 3, Some(3), bi_move_note); + mutator(interp, nb, "resize-note!", 3, Some(3), bi_resize_note); + mutator(interp, nb, "link!", 2, Some(2), bi_link); + mutator(interp, nb, "unlink!", 2, Some(2), bi_unlink); + mutator(interp, nb, "delete-note!", 1, Some(1), bi_delete_note); +} + +/// Evaluate a **formula** (spec §5): a pure expression with `self` bound to a +/// note's snapshot map and only the *reader* builtins in scope — mutators are +/// deliberately absent, so a formula can never change the notebook. This is the +/// L1 surface: `(count (words (content self)))`, `(= (attr self :status) "todo")`. +pub fn eval_formula( + nb: Rc>, + self_id: &Uuid, + src: &str, + budget: Budget, +) -> LdResult { + let mut interp = Interp::new(); + register_readers(&mut interp, &nb); + let self_val = note_to_value(&nb.borrow(), self_id).unwrap_or(Value::Nil); + interp.global.set(Rc::from("self"), self_val); + interp.eval_str(src, budget) } type ReadFn = fn(&Notebook, &[Value]) -> LdResult; diff --git a/core/src/lambdadelta_host/tests.rs b/core/src/lambdadelta_host/tests.rs index 849be08..63c8fcd 100644 --- a/core/src/lambdadelta_host/tests.rs +++ b/core/src/lambdadelta_host/tests.rs @@ -175,3 +175,36 @@ fn mutator_on_unknown_id_errors() { ); assert!(matches!(r, Err(LdError::User(_)))); } + +#[test] +fn formula_binds_self_and_is_read_only() { + let (nb, _i) = setup(); + let a = id_of(&nb, "Alpha"); + assert!(a.is_some()); + let Some(a) = a else { return }; + + // `self` is bound to the note; readers are in scope. The canonical L1 fx. + assert_eq!( + super::eval_formula( + nb.clone(), + &a, + "(count (words (content self)))", + Budget::new() + ), + Ok(Value::Int(4)) + ); + assert_eq!( + super::eval_formula(nb.clone(), &a, "(:title self)", Budget::new()), + Ok(Value::str("Alpha")) + ); + + // Mutators are deliberately absent — a formula cannot change the notebook. + let m = super::eval_formula( + nb.clone(), + &a, + "(set-title! (:id self) \"X\")", + Budget::new(), + ); + assert!(matches!(m, Err(LdError::Unbound(_)))); + assert_eq!(id_of(&nb, "Alpha"), Some(a)); // title unchanged +} diff --git a/core/src/wasm.rs b/core/src/wasm.rs index 0be4ae3..ed70fd6 100644 --- a/core/src/wasm.rs +++ b/core/src/wasm.rs @@ -225,6 +225,26 @@ impl WasmNotebook { result.map(|v| v.to_string()).map_err(err) } + /// Evaluate a **formula** against note `note_id`: a pure λδ expression with + /// `self` bound to that note and only reader builtins in scope (spec §5). + /// Read-only — the notebook is never mutated. Returns the printed result. + /// This is the L1 "fx field" entrypoint. + #[wasm_bindgen(js_name = evalFormula)] + pub fn eval_formula(&mut self, note_id: &str, src: &str) -> Result { + use crate::lambdadelta::Budget; + use std::cell::RefCell; + use std::rc::Rc; + + let id = parse_id(note_id)?; + let shared = Rc::new(RefCell::new(std::mem::take(&mut self.inner))); + let result = crate::lambdadelta_host::eval_formula(shared.clone(), &id, src, Budget::new()); + match Rc::try_unwrap(shared) { + Ok(cell) => self.inner = cell.into_inner(), + Err(still_shared) => self.inner = still_shared.borrow().clone(), + } + result.map(|v| v.to_string()).map_err(err) + } + /// Export every note as a Markdown file: `[{ name, content }]`. pub fn export_markdown(&self) -> Result { let files: Vec = crate::exchange::to_markdown(&self.inner)