pmacs/src/hook.rs

628 lines
20 KiB
Rust

// 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<RefCell<...>>` 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<Self, HookError> {
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<HookCallback>,
}
/// 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<HookCallbackError>,
}
/// 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<String, Hook>,
/// Insertion order for stable listing.
order: Vec<String>,
}
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<HookCallback>)> {
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::<MultiValue>(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::<MultiValue>(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<Value> = 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::<MultiValue>(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<_>>(),
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<HookCallback>) {
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::<Function>()
.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);
}
}