// hook.rs --- Hook registry and run engine (T M2.6). //! Typed hooks per spec ยง4.4. //! //! A hook is a named extension point. Lua code defines a hook with //! [`HookRegistry::define`], attaches callbacks via [`HookRegistry::add`], //! and the editor (or Lua) fires it via [`run_snapshot`] which executes //! every callback in registration order according to the hook's //! [`HookKind`]. //! //! # Composition kinds //! //! * [`HookKind::ShortCircuit`] --- callbacks are veto-able. The first //! callback that returns `false` stops the run; the [`HookOutcome`] //! reports the veto. Used for `before-save`, `before-quit`: any //! listener can refuse the action. A raised error counts as a veto. //! * [`HookKind::AllMustSucceed`] --- run every callback. Any errors //! are collected but do not stop later callbacks. The outcome is //! "ok" iff no callback raised. Used for `after-load`-style //! notifications where every listener should run. //! * [`HookKind::Accumulate`] --- thread a single value through the //! callbacks. The first callback receives the run's input args; each //! subsequent callback receives the previous callback's first return //! as its first argument (with the original trailing args). The run's //! return value is the final callback's first return. An error //! aborts the run; the partial accumulator is discarded. //! //! # Threading //! //! Single-threaded, like the buffer / command / keymap registries. //! Lives behind `Rc>` next to them inside //! [`crate::lua::LuaHost`]. Callbacks are `mlua::Function`s, which //! borrow Lua state; firing a hook re-enters Lua. use std::collections::HashMap; use mlua::{Function, MultiValue, Value}; use thiserror::Error; use crate::command::SourceLocation; /// Composition semantics, selected at [`HookRegistry::define`] time. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum HookKind { /// Stop on the first `false` return or raised error. Used for /// veto-able lifecycle hooks. ShortCircuit, /// Run every callback; aggregate errors. Used for notifications /// where each listener is independent. AllMustSucceed, /// Thread the first return value through the callbacks as their /// first argument. Used for transformation pipelines. Accumulate, } impl HookKind { /// Stable string identifier used by the Lua API and in introspection. #[must_use] pub fn as_str(self) -> &'static str { match self { Self::ShortCircuit => "short-circuit", Self::AllMustSucceed => "all-must-succeed", Self::Accumulate => "accumulate", } } /// Parse a Lua-side identifier into a kind. pub fn parse(s: &str) -> Result { match s { "short-circuit" => Ok(Self::ShortCircuit), "all-must-succeed" => Ok(Self::AllMustSucceed), "accumulate" => Ok(Self::Accumulate), other => Err(HookError::UnknownKind { got: other.to_owned(), }), } } } /// One callback registered against a hook. #[derive(Clone)] pub struct HookCallback { /// The Lua function body. Cloning is cheap (mlua refcounts internally). pub body: Function, /// Where the call to `pmacs.hook.add` originated. pub source: SourceLocation, } /// A defined hook: name, description, registered callbacks (in /// registration order). #[derive(Clone)] pub struct Hook { /// Unique name (e.g. `buffer.before-save`). pub name: String, /// One-line description (R42, mandatory). pub description: String, /// Composition semantics. pub kind: HookKind, /// Where the call to `pmacs.hook.define` originated. pub source: SourceLocation, /// Callbacks in registration order. Run in this order on every /// [`run_snapshot`] invocation. pub callbacks: Vec, } /// Errors raised by the hook registry. #[derive(Debug, Error)] pub enum HookError { /// `pmacs.hook.define` was called with no name. #[error("hook name must be non-empty")] EmptyName, /// R42: `define` was called without a description, or with one /// that's empty after trimming. #[error("hook \"{name}\" requires a non-empty description (R42)")] MissingDescription { /// The offending hook name. name: String, }, /// A hook with this name is already defined. #[error("hook \"{name}\" is already defined (refusing to redefine)")] DuplicateName { /// The offending hook name. name: String, }, /// `pmacs.hook.add` referenced an undefined hook. #[error("hook \"{name}\" is not defined")] NotFound { /// The offending hook name. name: String, }, /// R50: a spec table contained a key the registry doesn't know /// about. #[error("unknown field `{field}` in hook spec; supported: name, description, kind for define")] UnknownField { /// The offending key. field: String, }, /// `kind` was set to a value outside the [`HookKind`] vocabulary. #[error( "unknown hook kind `{got}`; expected one of: short-circuit, all-must-succeed, accumulate" )] UnknownKind { /// The offending kind string. got: String, }, } /// Result of running a hook. #[derive(Debug)] pub struct HookOutcome { /// `false` iff a [`HookKind::ShortCircuit`] callback returned /// `false` or raised. Always `true` for the other kinds (their /// "did it run cleanly" answer is encoded in [`Self::errors`]). pub proceed: bool, /// Final value: /// * [`HookKind::Accumulate`]: the last successful callback's /// first return; [`Value::Nil`] if there were no callbacks. /// * Other kinds: [`Value::Nil`]. pub value: Value, /// Errors raised during the run. For [`HookKind::ShortCircuit`] /// this is at most one (the first); for the others it lists every /// failure in callback order. pub errors: Vec, } /// One callback's failure during a hook run. #[derive(Debug)] pub struct HookCallbackError { /// Where the failed callback was registered. pub source: SourceLocation, /// The mlua error raised (or synthesized for a veto). pub error: mlua::Error, } /// Registry of named hooks and their callbacks. #[derive(Default)] pub struct HookRegistry { by_name: HashMap, /// Insertion order for stable listing. order: Vec, } impl HookRegistry { /// An empty registry. #[must_use] pub fn new() -> Self { Self::default() } /// Define a new hook. pub fn define( &mut self, name: String, description: String, kind: HookKind, source: SourceLocation, ) -> Result<(), HookError> { if name.is_empty() { return Err(HookError::EmptyName); } if description.trim().is_empty() { return Err(HookError::MissingDescription { name }); } if self.by_name.contains_key(&name) { return Err(HookError::DuplicateName { name }); } self.order.push(name.clone()); self.by_name.insert( name.clone(), Hook { name, description, kind, source, callbacks: Vec::new(), }, ); Ok(()) } /// Attach `body` to the hook named `name`. Returns /// [`HookError::NotFound`] if the hook hasn't been defined. pub fn add( &mut self, name: &str, body: Function, source: SourceLocation, ) -> Result<(), HookError> { let hook = self .by_name .get_mut(name) .ok_or_else(|| HookError::NotFound { name: name.to_owned(), })?; hook.callbacks.push(HookCallback { body, source }); Ok(()) } /// Look up a hook by name. #[must_use] pub fn get(&self, name: &str) -> Option<&Hook> { self.by_name.get(name) } /// Snapshot the kind + callbacks of a hook so the caller can drop /// the registry borrow before invoking user code (which may /// re-enter the registry, e.g. another `pmacs.hook.run` from /// inside a callback). #[must_use] pub fn snapshot(&self, name: &str) -> Option<(HookKind, Vec)> { self.by_name .get(name) .map(|h| (h.kind, h.callbacks.clone())) } /// Hook names in definition order. #[must_use] pub fn names(&self) -> &[String] { &self.order } /// Number of defined hooks. #[must_use] pub fn len(&self) -> usize { self.by_name.len() } /// True iff no hooks are defined. #[must_use] pub fn is_empty(&self) -> bool { self.by_name.is_empty() } } // --------------------------------------------------------------------------- // Runner // --------------------------------------------------------------------------- /// Run a snapshot of a hook's callbacks per its [`HookKind`]. The /// caller takes the snapshot via [`HookRegistry::snapshot`] and then /// drops the registry borrow before calling here, so callbacks may /// freely re-enter the registry. /// /// `args` is the input arg list; it is consumed (and rebuilt for each /// callback in [`HookKind::Accumulate`] mode). pub fn run_snapshot(kind: HookKind, callbacks: &[HookCallback], args: MultiValue) -> HookOutcome { match kind { HookKind::ShortCircuit => run_short_circuit(callbacks, &args), HookKind::AllMustSucceed => run_all_must_succeed(callbacks, &args), HookKind::Accumulate => run_accumulate(callbacks, args), } } fn run_short_circuit(callbacks: &[HookCallback], args: &MultiValue) -> HookOutcome { for cb in callbacks { match cb.body.call::(args.clone()) { Ok(rets) => { // A literal `false` veto stops the run; `nil` is a no-op // (callbacks that don't return are equivalent to "proceed"). if let Some(Value::Boolean(false)) = rets.iter().next() { return HookOutcome { proceed: false, value: Value::Nil, errors: Vec::new(), }; } } Err(e) => { return HookOutcome { proceed: false, value: Value::Nil, errors: vec![HookCallbackError { source: cb.source.clone(), error: e, }], }; } } } HookOutcome { proceed: true, value: Value::Nil, errors: Vec::new(), } } fn run_all_must_succeed(callbacks: &[HookCallback], args: &MultiValue) -> HookOutcome { let mut errors = Vec::new(); for cb in callbacks { if let Err(e) = cb.body.call::(args.clone()) { errors.push(HookCallbackError { source: cb.source.clone(), error: e, }); } } HookOutcome { proceed: errors.is_empty(), value: Value::Nil, errors, } } fn run_accumulate(callbacks: &[HookCallback], args: MultiValue) -> HookOutcome { let trailing: Vec = args.iter().skip(1).cloned().collect(); let mut acc: Value = args.into_iter().next().unwrap_or(Value::Nil); for cb in callbacks { let mut next_args = MultiValue::new(); next_args.push_back(acc.clone()); for v in &trailing { next_args.push_back(v.clone()); } match cb.body.call::(next_args) { Ok(rets) => { acc = rets.into_iter().next().unwrap_or(Value::Nil); } Err(e) => { return HookOutcome { proceed: false, value: Value::Nil, errors: vec![HookCallbackError { source: cb.source.clone(), error: e, }], }; } } } HookOutcome { proceed: true, value: acc, errors: Vec::new(), } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use mlua::Lua; fn src(line: i32) -> SourceLocation { SourceLocation { file: "test.lua".into(), line, } } #[test] fn define_then_get_round_trips() { let mut r = HookRegistry::new(); r.define( "buffer.before-save".into(), "Run before save.".into(), HookKind::ShortCircuit, src(1), ) .unwrap(); let h = r.get("buffer.before-save").unwrap(); assert_eq!(h.description, "Run before save."); assert_eq!(h.kind, HookKind::ShortCircuit); assert!(h.callbacks.is_empty()); } #[test] fn add_appends_in_registration_order() { let lua = Lua::new(); let mut r = HookRegistry::new(); r.define("h".into(), "desc".into(), HookKind::AllMustSucceed, src(1)) .unwrap(); for line in 10..14 { let f = lua.create_function(|_, ()| Ok(())).unwrap(); r.add("h", f, src(line)).unwrap(); } let cbs = &r.get("h").unwrap().callbacks; assert_eq!(cbs.len(), 4); assert_eq!( cbs.iter().map(|c| c.source.line).collect::>(), vec![10, 11, 12, 13] ); } #[test] fn empty_name_rejected() { let mut r = HookRegistry::new(); let err = r .define(String::new(), "ok".into(), HookKind::AllMustSucceed, src(1)) .unwrap_err(); assert!(matches!(err, HookError::EmptyName)); } #[test] fn whitespace_description_rejected() { let mut r = HookRegistry::new(); let err = r .define( "h".into(), " \n\t ".into(), HookKind::AllMustSucceed, src(1), ) .unwrap_err(); assert!(matches!(err, HookError::MissingDescription { .. })); } #[test] fn duplicate_define_rejected() { let mut r = HookRegistry::new(); r.define("h".into(), "ok".into(), HookKind::AllMustSucceed, src(1)) .unwrap(); let err = r .define("h".into(), "ok".into(), HookKind::AllMustSucceed, src(2)) .unwrap_err(); assert!(matches!(err, HookError::DuplicateName { name } if name == "h")); } #[test] fn add_to_undefined_hook_errors() { let lua = Lua::new(); let mut r = HookRegistry::new(); let f = lua.create_function(|_, ()| Ok(())).unwrap(); let err = r.add("nope", f, src(1)).unwrap_err(); assert!(matches!(err, HookError::NotFound { name } if name == "nope")); } #[test] fn names_in_definition_order() { let mut r = HookRegistry::new(); r.define("a".into(), "a".into(), HookKind::AllMustSucceed, src(1)) .unwrap(); r.define("b".into(), "b".into(), HookKind::AllMustSucceed, src(2)) .unwrap(); r.define("c".into(), "c".into(), HookKind::AllMustSucceed, src(3)) .unwrap(); assert_eq!(r.names(), &["a".to_owned(), "b".into(), "c".into()]); } #[test] fn parse_kind_round_trips() { for k in [ HookKind::ShortCircuit, HookKind::AllMustSucceed, HookKind::Accumulate, ] { assert_eq!(HookKind::parse(k.as_str()).unwrap(), k); } assert!(matches!( HookKind::parse("nope"), Err(HookError::UnknownKind { .. }) )); } // ---- runner ------------------------------------------------------------- fn snap(r: &HookRegistry, name: &str) -> (HookKind, Vec) { r.snapshot(name).expect("hook defined") } #[test] fn short_circuit_proceeds_when_all_return_true() { let lua = Lua::new(); let mut r = HookRegistry::new(); r.define("h".into(), "x".into(), HookKind::ShortCircuit, src(1)) .unwrap(); for _ in 0..3 { let f = lua.create_function(|_, ()| Ok(true)).unwrap(); r.add("h", f, src(0)).unwrap(); } let (k, cbs) = snap(&r, "h"); let out = run_snapshot(k, &cbs, MultiValue::new()); assert!(out.proceed); assert!(out.errors.is_empty()); } #[test] fn short_circuit_vetoes_on_false() { let lua = Lua::new(); let mut r = HookRegistry::new(); r.define("h".into(), "x".into(), HookKind::ShortCircuit, src(1)) .unwrap(); let f1 = lua.create_function(|_, ()| Ok(true)).unwrap(); let f2 = lua.create_function(|_, ()| Ok(false)).unwrap(); let f3 = lua .create_function(|_, ()| -> mlua::Result<()> { panic!("must not run after veto") }) .unwrap(); r.add("h", f1, src(0)).unwrap(); r.add("h", f2, src(0)).unwrap(); r.add("h", f3, src(0)).unwrap(); let (k, cbs) = snap(&r, "h"); let out = run_snapshot(k, &cbs, MultiValue::new()); assert!(!out.proceed); assert!(out.errors.is_empty()); } #[test] fn short_circuit_vetoes_on_error_and_records_it() { let mut r = HookRegistry::new(); r.define("h".into(), "x".into(), HookKind::ShortCircuit, src(1)) .unwrap(); // Build the failing callback through Lua so the error is a // genuine mlua::Error::CallbackError, like real user code. let lua = Lua::new(); let f: Function = lua.load("function() error('boom') end").eval().unwrap(); r.add("h", f, src(7)).unwrap(); let (k, cbs) = snap(&r, "h"); let out = run_snapshot(k, &cbs, MultiValue::new()); assert!(!out.proceed); assert_eq!(out.errors.len(), 1); assert_eq!(out.errors[0].source.line, 7); } #[test] fn all_must_succeed_runs_every_callback_collecting_errors() { let lua = Lua::new(); let mut r = HookRegistry::new(); r.define("h".into(), "x".into(), HookKind::AllMustSucceed, src(1)) .unwrap(); let raise: Function = lua.load("function() error('boom') end").eval().unwrap(); let counter = lua .load( " _G.hits = 0 return function() _G.hits = _G.hits + 1 end ", ) .eval::() .unwrap(); r.add("h", raise.clone(), src(11)).unwrap(); r.add("h", counter.clone(), src(12)).unwrap(); r.add("h", raise, src(13)).unwrap(); r.add("h", counter, src(14)).unwrap(); let (k, cbs) = snap(&r, "h"); let out = run_snapshot(k, &cbs, MultiValue::new()); assert!(!out.proceed); assert_eq!(out.errors.len(), 2); let hits: i64 = lua.load("return _G.hits").eval().unwrap(); assert_eq!(hits, 2, "every successful callback must run"); } #[test] fn accumulate_threads_value_through_callbacks() { let lua = Lua::new(); let mut r = HookRegistry::new(); r.define("h".into(), "x".into(), HookKind::Accumulate, src(1)) .unwrap(); let plus_one: Function = lua.load("function(n) return n + 1 end").eval().unwrap(); for _ in 0..4 { r.add("h", plus_one.clone(), src(0)).unwrap(); } let (k, cbs) = snap(&r, "h"); let mut args = MultiValue::new(); args.push_back(Value::Integer(10)); let out = run_snapshot(k, &cbs, args); assert!(out.proceed); match out.value { Value::Integer(n) => assert_eq!(n, 14), other => panic!("expected integer, got {other:?}"), } } #[test] fn accumulate_aborts_on_error() { let lua = Lua::new(); let mut r = HookRegistry::new(); r.define("h".into(), "x".into(), HookKind::Accumulate, src(1)) .unwrap(); let plus_one: Function = lua.load("function(n) return n + 1 end").eval().unwrap(); let raise: Function = lua.load("function(_) error('boom') end").eval().unwrap(); r.add("h", plus_one.clone(), src(0)).unwrap(); r.add("h", raise, src(0)).unwrap(); r.add("h", plus_one, src(0)).unwrap(); let (k, cbs) = snap(&r, "h"); let mut args = MultiValue::new(); args.push_back(Value::Integer(0)); let out = run_snapshot(k, &cbs, args); assert!(!out.proceed); assert_eq!(out.errors.len(), 1); } }