// lua_bindings.rs --- Hand-curated Lua surface (R51). //! The Rust/Lua boundary surface: `pmacs.buffer.*`, `pmacs.command.*`, //! and `pmacs.describe.*`. //! //! Per R51 we do not auto-derive `UserData` on core types --- the Lua //! API is its own design rather than a leaked Rust shape. Bindings live //! here; core types stay where they belong. //! //! # Lifetime contracts (R53) //! //! * [`BufferIdLua`] is a `Copy` handle. Lua may store, pass, and re-use //! it freely, but the underlying [`Buffer`] lives in the registry. If //! the buffer is removed (`pmacs.buffer.remove(id)`), all live handles //! become stale; the next method call on a stale handle returns a //! typed error ([`BindingError::StaleId`]). There is never a //! use-after-free. //! * The registry itself lives behind a [`SharedRegistry`] //! (`Rc>`). The single-threaded main-thread //! invariant means borrow conflicts only arise from re-entrant Lua //! calls; those are caller bugs and will surface as panics from //! [`RefCell::borrow_mut`]. M2.5+ may revisit if Lua-from-Lua //! re-entry becomes a real pattern. //! //! # Ownership (R52) //! //! Bytes flowing across the boundary are copied. When Lua passes a //! string to `id:insert`, the Rust side copies the contents into the //! rope's leaf chunks; the Lua string remains owned by Lua. When Rust //! returns bytes via `id:slice`, a fresh Lua string is created --- the //! Rust slice does not escape. //! //! # Error mapping (R52, R53) //! //! Every Rust error visible to Lua is wrapped via //! [`mlua::Error::external`]. The structured fields of the original //! error (e.g. [`RopeError::OutOfBounds { pos, len }`]) are preserved //! by Display, so `tostring(err)` carries them, and the original error //! chain is reachable from Rust via `error.source()`. use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::rc::Rc; use mlua::{FromLua, Function, Lua, Table, UserData, UserDataMethods, Value, Variadic}; use thiserror::Error; use std::sync::{Arc, Mutex}; use crate::async_runtime::{ AsyncRuntime, GrepMatch, GrepSpec, JobOutcome, JobResult, SharedAsyncRuntime, StreamPayload, }; use crate::buffer::{BufferId, EditOp, MarkGravity, MarkId}; use crate::buffer_registry::BufferRegistry; use crate::cell::{Color, Style, UnderlineStyle}; use crate::command::{Command, CommandError, CommandRegistry, SourceLocation}; use crate::editor_core::EditorCore; use crate::highlight::{SyntaxHighlightView, Theme}; use crate::hook::{Hook, HookRegistry}; use crate::key::{display_sequence, parse_sequence}; use crate::keymap_stack::KeymapStack; use crate::menu::{MenuItem, MenuRegistry}; use crate::packages::{ Address, Fetcher, InstallError, InstallPin, InstallScope, InstallSpec, InstalledPackage, Installer, LookupOutcome, ResolvedKind, lookup_in_roster, }; use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity}; use crate::rope::Range; use crate::syntax::{self, ParseTreeBundle, ParseView, ParseViewHandle, SharedSyntaxRegistry}; use crate::workers_buffer; // Domain submodules split out of this file (audit F-016). Each owns one // `pmacs.` API surface and is installed from the `install()` spine // / editor wiring below; the shared core (registry alias, `BindingError`, // `BufferIdLua`, state holders, helpers, and `install()`) stays here. // Submodules reach shared-core items via `super::` (a child module can see // its ancestors' private items), so the split needs no visibility widening // beyond call seams. Public entry points a domain owns are re-exported here // so external `crate::lua_bindings::` paths (and in-file uses) stay // stable. mod diag; mod index; mod mcp; // Every `pub` item a moved domain owned is re-exported so its prior // `crate::lua_bindings::` path still resolves — the split must not // shrink the public API surface. That includes the `install_*` wiring fns: // they take crate-internal handle types (so external callers can't invoke // them), but they were `pub`, so their paths are preserved for // compile-compatibility; any deliberate narrowing is a separate change. pub use diag::install_diag; pub use index::{SharedProjectIndexer, install_project_index, make_project_indexer}; pub use mcp::{McpServerIdLua, install_mcp, make_mcp_manager}; // --------------------------------------------------------------------------- // Shared registry alias // --------------------------------------------------------------------------- /// Shared, single-threaded handle to the editor's buffer registry. /// /// Held by [`crate::lua::LuaHost`] and by every closure captured during /// [`install`]. `Rc>` is correct for the main-thread /// invariant: not `Send`, but cheaply cloneable and interior-mutable /// for the closure soup that mlua's `create_function` produces. pub type SharedRegistry = Rc>; /// Shared, single-threaded handle to the command registry. Same /// rationale as [`SharedRegistry`] --- single-thread, interior /// mutability for closure capture. pub type SharedCommandRegistry = Rc>; /// Shared, single-threaded handle to the keymap stack. pub type SharedKeymapStack = Rc>; /// Shared handle to the context-menu registry. Cloned into the Lua /// `pmacs.menu.*` closures and stored as app data alongside the command /// and keymap registries. pub type SharedMenuRegistry = Rc>; /// Shared, single-threaded handle to the editor core --- the world /// state mutated by `pmacs.editor.*` primitives invoked from inside /// command bodies. pub type SharedCore = Rc>; /// Shared, single-threaded handle to the hook registry. Same /// rationale as the other `Rc>` aliases. pub type SharedHookRegistry = Rc>; #[derive(Clone)] struct BufferRemoveCallbacks(Rc>); struct BufferRemoveCallbackState { next_id: u64, callbacks: HashMap>, } #[derive(Clone)] struct BufferRemoveCallback { id: u64, body: Function, source: SourceLocation, } impl BufferRemoveCallbacks { fn new() -> Self { Self(Rc::new(RefCell::new(BufferRemoveCallbackState { next_id: 1, callbacks: HashMap::new(), }))) } fn add(&self, buffer: BufferId, body: Function, source: SourceLocation) -> u64 { let mut state = self.0.borrow_mut(); let id = state.next_id; state.next_id = state.next_id.saturating_add(1); state .callbacks .entry(buffer) .or_default() .push(BufferRemoveCallback { id, body, source }); id } fn remove(&self, buffer: BufferId, callback_id: u64) -> bool { let mut state = self.0.borrow_mut(); let Some(callbacks) = state.callbacks.get_mut(&buffer) else { return false; }; let before = callbacks.len(); callbacks.retain(|callback| callback.id != callback_id); let removed = callbacks.len() != before; if callbacks.is_empty() { state.callbacks.remove(&buffer); } removed } fn take(&self, buffer: BufferId) -> Vec { self.0 .borrow_mut() .callbacks .remove(&buffer) .unwrap_or_default() } } struct BufferRemoveCallbackHandleLua { buffer: BufferId, callback_id: u64, } impl UserData for BufferRemoveCallbackHandleLua { fn add_methods>(methods: &mut M) { methods.add_method("remove", |lua, this, ()| { Ok(remove_buffer_removed_callback(lua, this)) }); } } /// Init-phase tracker. The user's `init.lua` runs while this is `false`; /// [`crate::editor::EditorState::new`] flips it to `true` after the /// init chunk returns. Lua bindings that gate on init phase /// (e.g. `pmacs.attach`) read it via [`Lua::app_data_ref`] and use /// [`require_init_phase`] to short-circuit with a typed error. /// /// Newtype around `Rc>` so the typed app-data lookup is /// unambiguous; raw `Rc>` would collide with any other /// flag using the same primitive shape. #[derive(Debug, Clone)] pub struct InitCompleteFlag(Rc>); impl InitCompleteFlag { /// Construct a fresh flag in the "init in progress" state. #[must_use] pub fn new() -> Self { Self(Rc::new(Cell::new(false))) } /// Whether the init phase has finished. #[must_use] pub fn is_complete(&self) -> bool { self.0.get() } /// Mark init complete. Idempotent — calling twice is fine. pub fn set_complete(&self) { self.0.set(true); } /// Re-open the init phase. Test/dev-only escape hatch: /// integration tests that exercise init-only Lua APIs /// (`pmacs.packages.install_local`, `pmacs.attach`) against a /// fully-constructed [`crate::editor::EditorState`] need a way /// to reset the flag the editor flips during startup. /// Production code flips this once and never re-opens; the /// `_for_testing` suffix and the `#[doc(hidden)]` mark this /// as not part of the user-facing surface. #[doc(hidden)] pub fn reopen_for_testing(&self) { self.0.set(false); } } impl Default for InitCompleteFlag { fn default() -> Self { Self::new() } } /// Slot for an init-time attach request. Written by `pmacs.attach{...}` /// (M5.6d), read by the post-init dispatcher (M5.6g) to decide whether /// the local frontend should run against its own [`crate::editor_core::EditorCore`] /// or hand off to attach mode against a remote daemon. /// /// v0.1 supports a single attach request per init.lua: a second call /// errors via [`BindingError::AttachAlreadyRequested`] so a typo in /// the user's config can't silently override an earlier choice. #[derive(Debug, Clone, Default)] pub struct RequestedAttach(Rc>>); impl RequestedAttach { /// Construct an empty request slot. #[must_use] pub fn new() -> Self { Self(Rc::new(RefCell::new(None))) } /// Read the currently-requested target without consuming it. #[must_use] pub fn get(&self) -> Option { self.0.borrow().clone() } /// Set the request iff the slot is empty. Returns `Err(existing)` /// if a prior request is already recorded; the slot is unchanged /// in that case. /// /// # Errors /// /// Returns `Err(existing)` carrying the prior request when the /// slot is already populated. pub fn try_set(&self, target: AttachTarget) -> Result<(), AttachTarget> { let mut slot = self.0.borrow_mut(); if let Some(prev) = slot.as_ref() { return Err(prev.clone()); } *slot = Some(target); Ok(()) } /// Consume the requested target. Subsequent calls return `None` /// until a new request is recorded — but post-init Lua calls are /// gated by [`require_init_phase`], so re-population is unreachable /// in normal flow. #[must_use] pub fn take(&self) -> Option { self.0.borrow_mut().take() } } /// Slot for the current outbound attachment, if any. /// /// Read by `pmacs.current_attachment()` (M5.6e). v0.1 has no /// production-side producer — Local mode runs as its own instance /// (no remote attachment), Attach mode has no `LuaHost`, and Daemon /// mode is the *target* of attachments rather than the source. The /// slot exists so the Lua API has a stable shape now; future modes /// (e.g. a Lua VM running on the daemon side that reflects which /// frontend is currently dispatching, or a v0.2 flow where a daemon /// chains upstream) can populate it without changing the binding. /// /// `LuaHost::set_current_attachment` / `clear_current_attachment` /// drive the slot from Rust; in v0.1 these are used primarily in /// tests and by the future M5.6g dispatcher. #[derive(Debug, Clone, Default)] pub struct CurrentAttachmentSlot(Rc>>); impl CurrentAttachmentSlot { /// Construct an empty slot. #[must_use] pub fn new() -> Self { Self::default() } /// Read the current attachment, if any. #[must_use] pub fn get(&self) -> Option { self.0.borrow().clone() } /// Set (or replace) the current attachment. pub fn set(&self, handle: AttachmentHandle) { *self.0.borrow_mut() = Some(handle); } /// Clear the current attachment. No-op if already empty. pub fn clear(&self) { self.0.borrow_mut().take(); } } /// Identity facts about the running pmacs process. /// /// Populated by [`install`] with `(name: None, started: Instant::now())` /// — correct for the Local-mode editor whose `LuaHost` is constructed /// at process boot. Daemon mode overrides via /// [`crate::lua::LuaHost::set_local_instance_info`] so the uptime /// reported by `pmacs.instance.identity()` matches what the daemon /// hands back over its `Hello`. /// /// Read by `pmacs.instance.identity()` (M5.6f) to build the /// [`InstanceIdentity`] returned to Lua. #[derive(Debug, Clone)] pub struct LocalInstanceInfo(Rc>); #[derive(Debug, Clone)] struct LocalInstanceData { name: Option, started: std::time::Instant, } impl LocalInstanceInfo { /// Construct with `(name: None, started: Instant::now())`. #[must_use] pub fn new() -> Self { Self(Rc::new(RefCell::new(LocalInstanceData { name: None, started: std::time::Instant::now(), }))) } /// Set the user-facing instance name (typically `--socket NAME`). pub fn set_name(&self, name: Option) { self.0.borrow_mut().name = name; } /// Override the `started` anchor. Daemon mode uses this so the /// uptime reported by `pmacs.instance.identity()` matches the /// `DaemonState`'s own clock. pub fn set_started(&self, started: std::time::Instant) { self.0.borrow_mut().started = started; } /// Build an [`InstanceIdentity`] reflecting the running process at /// the moment of the call. Subsequent calls re-evaluate uptime /// against the same anchor. #[must_use] pub fn build_identity(&self) -> InstanceIdentity { let data = self.0.borrow(); InstanceIdentity::for_running_process(data.name.clone(), data.started) } } impl Default for LocalInstanceInfo { fn default() -> Self { Self::new() } } /// In-memory roster of packages installed during the init phase. /// /// Populated by `pmacs.packages.install{...}` and /// `install_project{...}` (T M7.3). Read by /// `pmacs.packages.installed()` for introspection, by the M7.7 /// require-searcher to resolve `require("")`, and by /// `pmacs.packages.update` to determine which on-disk installs /// need to be reinstalled or pruned. Single-threaded /// `Rc>` per the boundary's main-thread invariant. #[derive(Debug, Clone, Default)] pub struct InstalledPackages(Rc>>); impl InstalledPackages { /// Construct an empty roster. #[must_use] pub fn new() -> Self { Self::default() } /// Record a successful install. If a previous entry has the /// same `install_path` it is replaced in place; otherwise the /// new package is appended. Replacement (rather than blind /// append) keeps the roster a unique set of currently-installed /// packages, which is what the M7.7 searcher and /// `pmacs.packages.update` both rely on --- a stale duplicate /// would surface either through `pmacs.packages.installed()` or /// through the searcher's most-recent-first lookup. /// /// Keying by `install_path` (not just by basename) preserves the /// legitimate case of the same package installed at both user /// and project scope: both rosters reside in the same slot but /// at different paths, and both should be visible to /// `pmacs.packages.installed()`. pub fn record(&self, pkg: InstalledPackage) { let path = pkg.install_path.clone(); let mut roster = self.0.borrow_mut(); if let Some(slot) = roster.iter_mut().find(|p| p.install_path == path) { *slot = pkg; } else { roster.push(pkg); } } /// Remove every roster entry whose `install_path` matches /// `path` exactly. Used by `pmacs.packages.update` when a /// transitive dependency drops out of the new resolve plan: /// the on-disk install dir is removed, and the matching roster /// entry must follow so the searcher stops finding it. /// /// Path-scoped (not basename-scoped) so a project-scope /// install sharing the basename of a pruned user-scope install /// is preserved --- the two paths are distinct, and `update` /// only owns the user-scope set. pub fn remove_by_install_path(&self, path: &std::path::Path) { self.0.borrow_mut().retain(|p| p.install_path != path); } /// Replace the roster wholesale from a previously captured /// snapshot. Used by `pmacs.packages.update` rollback: if a later /// mutation or lockfile write fails, the in-memory search roster /// must return to the same state as the still-current lockfile. pub fn replace_snapshot(&self, packages: Vec) { *self.0.borrow_mut() = packages; } /// Snapshot the current roster for read-only consumers. #[must_use] pub fn snapshot(&self) -> Vec { self.0.borrow().clone() } } /// Stack of currently-loading package basenames (T M8.1d). /// /// Pushed by the wrapped loader returned from /// [`load_package_chunk`] before the package's chunk runs; /// popped after the chunk returns (or errors). Used by /// [`pmacs.packages.on_unload`] as a fallback when /// [`mlua::Function::environment`] returns `None` --- under Lua /// 5.4, a closure that doesn't reference any global doesn't /// capture `_ENV` as an upvalue, so the env-identity check can't /// find the owning package. The stack lets the binding still /// recover the basename for the typical case (`on_unload` called /// at chunk top-level or from a chunk-direct function call). /// /// A stack rather than a single slot because `require()` chains /// can be re-entrant (package A's chunk requires B; B's chunk /// runs nested inside A's). Each push corresponds to one chunk /// invocation. #[derive(Default)] pub struct CurrentlyLoadingPackage(Rc>>); impl CurrentlyLoadingPackage { /// Construct an empty stack. Installed once per Lua state. #[must_use] pub fn new() -> Self { Self::default() } fn push(&self, basename: String) { self.0.borrow_mut().push(basename); } fn pop(&self) { self.0.borrow_mut().pop(); } fn top(&self) -> Option { self.0.borrow().last().cloned() } } /// Registry of `pmacs.packages.on_unload` hooks (T M8.1d). /// /// Keyed by package basename --- each package registers zero or more /// callbacks via `pmacs.packages.on_unload(fn)`, and /// `pmacs.packages.reload(name)` runs them in registration order /// before invalidating `package.loaded` and re-`require`-ing. /// /// Hooks are *consumed* on reload: after running, the entry is /// cleared, so a re-loaded package re-registers fresh hooks. This /// keeps each reload cycle self-contained --- a stale closure that /// captures the prior chunk's locals can't fire on a later reload. #[derive(Default)] pub struct PackageUnloadHooks(Rc>>>); impl PackageUnloadHooks { /// Construct an empty registry. Installed once per Lua state via /// `lua.set_app_data` during the binding bootstrap. #[must_use] pub fn new() -> Self { Self::default() } /// Append `hook` for `basename`. Order matters --- hooks run in /// registration order on reload, so a "shut down workers, then /// flush state" pair stays in that order. pub fn register(&self, basename: &str, hook: mlua::Function) { self.0 .borrow_mut() .entry(basename.to_string()) .or_default() .push(hook); } /// Drain the entire hook list for `basename`, returning it /// as a Vec. The registry's slot for `basename` is empty /// afterward. /// /// Used by [`run_unload_hooks`] to snapshot the cycle's hooks /// at start; new `on_unload` registrations during the cycle /// land in the (now empty) registry slot instead of extending /// the current queue. A successful reload / replacement then /// clears that old-env slot before the fresh chunk registers /// its next-cycle hooks. This prevents a self-replicating hook /// from extending the current unload cycle indefinitely. pub fn drain(&self, basename: &str) -> Vec { self.0.borrow_mut().remove(basename).unwrap_or_default() } /// Insert `hooks` at the front of the existing list for /// `basename`. Existing entries (typically registered by the /// chunk during the cycle that just failed) shift to follow /// the prepended list. /// /// Used by [`run_unload_hooks`] on a hook failure: the unrun /// tail (including the failed hook at index 0) is pushed back /// to the front of the registry so a retry re-attempts them /// in order before any newly-registered hooks fire. pub fn prepend(&self, basename: &str, mut hooks: Vec) { if hooks.is_empty() { return; } let mut map = self.0.borrow_mut(); let existing = map.remove(basename).unwrap_or_default(); hooks.extend(existing); map.insert(basename.to_string(), hooks); } } /// Source label of the currently-evaluating chunk, populated by /// [`crate::lua::LuaHost::eval`] before each evaluation. /// /// The label follows Lua's `@` convention for file-loaded /// chunks (see [`crate::config::load_user_config_at`]); the /// install-API binding strips the `@` and takes the parent /// directory to resolve relative `project_root` values in /// `pmacs.packages.install_project`. Without this slot we'd be /// unable to recover the chunk source from a Rust callback because /// pmacs's Lua state intentionally omits the `debug` library /// (`forbid(unsafe_code)` rules out `Lua::unsafe_new`, and /// `debug.getinfo` is not available in the safe stdlib subset). /// /// Single-slot state, no stack: nested `eval` calls overwrite the /// outer chunk's source for the duration of the inner call. v0.1 /// has no nested-eval flow that consults this slot, so the /// simplification is sound. #[derive(Debug, Clone, Default)] pub struct CurrentEvalSource(pub Option); /// Override hook for the `pmacs.packages.install{...}` machinery. /// /// In production this slot is empty: `install` builds a [`Fetcher`] /// rooted at `$XDG_CACHE_HOME/pmacs/git/` and an [`InstallScope::User`] /// rooted at `$XDG_DATA_HOME/pmacs/packages/`. Tests cannot mutate /// `XDG_CACHE_HOME` / `XDG_DATA_HOME` because `std::env::set_var` is /// `unsafe` since Rust 2024 and the project forbids unsafe; instead /// they install a [`PackageInstallOverride`] with explicit paths. /// /// Set via [`crate::lua::LuaHost::set_package_install_override`]; read /// by [`do_install`]. #[derive(Debug, Clone, Default)] pub struct PackageInstallOverride { /// Override the bare-mirror cache dir. Defaults to /// `$XDG_CACHE_HOME/pmacs/git/` when absent. pub cache_dir: Option, /// Override the user-scope install root. Defaults to /// `$XDG_DATA_HOME/pmacs/packages/` when absent. pub user_install_root: Option, } impl PackageInstallOverride { /// Empty override (production default behavior). #[must_use] pub fn new() -> Self { Self::default() } /// Set the cache-dir override. Builder-style. #[must_use] pub fn with_cache_dir(mut self, p: std::path::PathBuf) -> Self { self.cache_dir = Some(p); self } /// Set the user-install-root override. Builder-style. #[must_use] pub fn with_user_install_root(mut self, p: std::path::PathBuf) -> Self { self.user_install_root = Some(p); self } } /// Short-circuit a binding when the init phase has completed. /// /// Lifecycle-affecting Lua APIs (currently just `pmacs.attach`; M5.6d+) /// must be called from `init.lua` so they take effect before the /// editor's main loop starts. Calls after init produce /// [`BindingError::InitOnlyApi`] with the named op for diagnostics. /// /// `op_name` is the `pmacs.foo` style identifier the user would /// recognize from their config (e.g. `"pmacs.attach"`). /// /// # Errors /// /// Returns [`BindingError::InitOnlyApi`] wrapped via /// [`mlua::Error::external`] when init has completed. Returns /// [`BindingError::NoInitFlag`] (also wrapped) if the flag is missing /// from app data — that indicates a setup ordering bug, not a user /// error. pub fn require_init_phase(lua: &Lua, op_name: &'static str) -> mlua::Result<()> { let flag = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoInitFlag))?; if flag.is_complete() { return Err(mlua::Error::external(BindingError::InitOnlyApi { op: op_name, })); } Ok(()) } // --------------------------------------------------------------------------- // Boundary errors // --------------------------------------------------------------------------- /// Errors specific to the Lua boundary, distinct from the buffer/rope /// layer's own errors. These surface to Lua via [`mlua::Error::external`] /// and preserve their structured fields through `Display`. #[derive(Debug, Error)] pub enum BindingError { /// The Lua state is missing its `BufferRegistry` app data. Indicates /// [`install`] was never called for this state, which is a /// programming error rather than user input. #[error("Lua app data missing: BufferRegistry was not installed on this Lua state")] NoRegistry, /// The supplied [`BufferId`] no longer resolves --- the buffer was /// removed. Surface this rather than `RegistryError::Missing` /// directly so the boundary message reads naturally to Lua users. #[error("stale buffer handle: id {id:?} (the buffer was removed)")] StaleId { /// The offending ID, preserved for callers that want to report /// it (most commonly via `tostring(err)`). id: BufferId, }, /// A Lua mark handle refers to a mark that has already been removed. #[error("stale mark handle: mark {mark:?} no longer exists on buffer {buffer:?}")] StaleMark { /// Buffer that originally owned the mark. buffer: BufferId, /// Removed mark ID. mark: MarkId, }, /// A position argument was negative; positions are byte offsets and /// must be `>= 0`. #[error("position must be non-negative; got {got}")] NegativePosition { /// The offending Lua integer. got: i64, }, /// A range had `start > end` after coercion to byte offsets. #[error("invalid range: start {start} > end {end}")] InvalidRange { /// Start byte offset. start: u64, /// End byte offset. end: u64, }, /// A command spec table contained a non-string key. Lua tables can /// be keyed by anything; the command spec is named-args only /// (R49/R50) so we reject other key types. #[error("command spec key must be a string; got {got}")] NonStringSpecKey { /// The Lua type of the offending key. got: String, }, /// A command spec field was missing or had the wrong type. Used /// when the field's absence isn't covered by a more specific /// [`CommandError`] (e.g. `name` being absent when we need to /// build the error message). #[error("command spec field `{field}` is missing or not a {expected}")] SpecFieldType { /// The offending field name. field: &'static str, /// The expected type name. expected: &'static str, }, /// A keymap spec contained an unknown `scope` value. Accept-list: /// `global`, `buffer`, `mode`. #[error("unknown keymap scope `{got}`; expected one of: global, buffer, mode")] UnknownScope { /// The offending scope name. got: String, }, /// A buffer-local bind/unbind didn't supply a `buffer` field, or /// a mode bind didn't supply `mode`. #[error("keymap scope `{scope}` requires field `{field}`")] MissingScopeField { /// The scope that needed the field. scope: &'static str, /// The missing field name. field: &'static str, }, /// `pmacs.editor.*` was called before [`install_editor`] attached /// the [`SharedCore`] to the Lua app data. Indicates a setup /// ordering bug rather than user input. #[error("Lua app data missing: editor core was not installed on this Lua state")] NoCore, /// A Lua integer passed to `pmacs.editor.insert_char` did not /// represent a valid Unicode scalar value. #[error("invalid codepoint: {value}")] InvalidCodepoint { /// The integer that was supplied (cast back to `i64`). value: i64, }, /// A `pmacs.minibuffer.read { source = "..." }` argument was a /// string outside the accepted vocabulary. #[error( "unknown completion source `{got}`; expected one of: none, commands, buffers, files, or a function" )] UnknownCompletionSource { /// The offending string. got: String, }, /// A lifecycle-affecting Lua API (e.g. `pmacs.attach`) was called /// after the init phase completed. v0.1 routes these through /// [`require_init_phase`] so they only run while `init.lua` is /// executing; mid-session calls error here. /// /// The message names a workaround (the equivalent CLI flag) so /// users have a path forward, per the project's "errors point at /// the workaround" convention. #[error( "{op} must be called from init.lua, before the editor starts \ (after-init calls are not supported in v0.1; \ restart pmacs with the equivalent CLI flag to change attachment)" )] InitOnlyApi { /// The Lua-facing name of the op being gated, e.g. `"pmacs.attach"`. op: &'static str, }, /// The Lua state is missing its [`InitCompleteFlag`] app data. /// Indicates [`install`] was never called for this state — a /// programming error rather than user input. #[error("Lua app data missing: InitCompleteFlag was not installed on this Lua state")] NoInitFlag, /// The Lua state is missing its [`RequestedAttach`] app data. /// Programming error, not user input. #[error("Lua app data missing: RequestedAttach slot was not installed on this Lua state")] NoRequestedAttachSlot, /// The Lua state is missing its [`CurrentAttachmentSlot`] app data. /// Programming error, not user input. #[error("Lua app data missing: CurrentAttachmentSlot was not installed on this Lua state")] NoCurrentAttachmentSlot, /// The Lua state is missing its [`LocalInstanceInfo`] app data. /// Programming error, not user input. #[error("Lua app data missing: LocalInstanceInfo was not installed on this Lua state")] NoLocalInstanceInfo, /// `pmacs.attach{...}` was given a spec table with neither a /// `target` string nor a `kind` string. The user has to provide /// one or the other; the message names both forms. #[error( "pmacs.attach: spec must contain either `target` (e.g. \"local:/path/to.sock\" or \"ssh:host\") \ or `kind` (one of \"local\", \"ssh\", \"tls\", \"custom\")" )] AttachSpecMissingKindOrTarget, /// A `pmacs.attach{...}` spec used a `kind` that isn't one of the /// four recognized values. #[error( "pmacs.attach: unknown kind `{got}` (expected one of: \"local\", \"ssh\", \"tls\", \"custom\")" )] AttachSpecUnknownKind { /// The offending kind string. got: String, }, /// A `pmacs.attach{ kind = ... }` spec was missing a required /// field for that kind, or a field had the wrong Lua type. #[error("pmacs.attach{{ kind = \"{kind}\" }}: field `{field}` is missing or not a {expected}")] AttachSpecField { /// The kind whose schema requires this field. kind: &'static str, /// The field name. field: &'static str, /// The expected Lua type. expected: &'static str, }, /// A second `pmacs.attach{...}` was made while a request from an /// earlier call site is still pending. v0.1 supports a single /// attach per init.lua to make typos visible. #[error( "pmacs.attach has already been called in this init phase (current request: `{prior}`); \ remove the earlier call before adding a new one" )] AttachAlreadyRequested { /// `Display` form of the existing target, for diagnostics. prior: String, }, /// The Lua state is missing its [`InstalledPackages`] roster. /// Programming error, not user input. #[error("Lua app data missing: InstalledPackages roster was not installed on this Lua state")] NoInstalledPackagesSlot, /// `pmacs.packages.install{...}` was passed something that wasn't /// a string (shorthand) or a table (kwargs). #[error( "pmacs.packages.install: spec must be a string \ (e.g. \"github:user/repo@^1.0.0\") or a table \ (e.g. {{ \"github:user/repo\", version = \"^1.0.0\" }}); got {got}" )] InstallSpecWrongType { /// The Lua type of the offending value. got: String, }, /// A `pmacs.packages.install{...}` table form omitted the address /// (no positional `[1]` and no `address = "..."` kwarg). #[error( "pmacs.packages.install: spec table must contain either a \ positional address at [1] or an `address` field" )] InstallSpecMissingAddress, /// A `pmacs.packages.install{...}` spec table specified more than /// one of `version`, `branch`, `commit`. Each install must pin /// exactly one revision; combining pin kinds is ambiguous (which /// one wins?). The error message names every conflicting field /// the spec actually carried. #[error( "pmacs.packages.install: spec must specify exactly one of \ `version`, `branch`, or `commit`; got: {fields}" )] InstallSpecConflictingPins { /// Comma-separated list of the offending field names, in /// the order they appeared on the table. fields: String, }, /// `install_project` was called without an explicit /// `project_root` field. The CWD-fallback was removed because at /// init time CWD is whatever directory the user happened to /// invoke pmacs from --- almost never a meaningful project /// root. The message names two concrete patterns for filling in /// a value, so users hitting this in a CI log or stack trace /// can fix it without context. #[error( "pmacs.packages.install_project requires an explicit \ `project_root` field. \ Pass `project_root = \"/path/to/your/project\"` (often \ `os.getenv(\"PMACS_PROJECT\")` or a path relative to the \ directory containing your init.lua)." )] InstallProjectMissingProjectRoot, /// The package install layer surfaced a typed error. The display /// chain reproduces the inner [`InstallError`]'s message verbatim, /// so callers see e.g. "no tag for X satisfies ^1.0". #[error("{0}")] PackageInstall(#[from] InstallError), /// The dependency resolver surfaced a typed error. #[error("{0}")] PackageResolve(#[from] crate::packages::ResolveError), /// The lockfile machinery surfaced a typed error (parse, I/O, /// content-hash mismatch, missing manifest, etc.). #[error("{0}")] PackageLockfile(#[from] crate::packages::LockfileError), /// `pmacs.packages.update` was called but the lockfile contains /// no top-level entries to re-resolve. Either no `install` has /// completed yet, or the lockfile was hand-edited. #[error( "pmacs.packages.update: no top-level packages in lockfile to update. \ Run `pmacs.packages.install` first." )] PackagesUpdateNoEntries, /// `pmacs.packages.update("name")` was passed a value that /// failed [`PackageName`](crate::packages::PackageName) /// validation. #[error("pmacs.packages.update: invalid package name `{name}`: {reason}")] PackagesUpdateBadName { /// The offending value. name: String, /// Why it was rejected. reason: String, }, /// `pmacs.packages.update("name")` was called for a package /// that isn't in the lockfile. Surfaces the typo loudly rather /// than silently no-op-ing. #[error( "pmacs.packages.update: package `{name}` is not in the lockfile. \ Available names come from `pmacs.packages.installed()`." )] PackagesUpdateUnknownName { /// The unknown name the caller passed. name: String, }, /// `pmacs.packages.reload("name")` was called but no installed /// package has that basename. The roster is the source of /// truth; if the user expects the package to be there, they /// either misspelled the name or the package failed to /// install at startup. #[error( "pmacs.packages.reload: no installed package named `{name}`. \ Available names come from `pmacs.packages.installed()`." )] PackagesReloadUnknownName { /// The unknown name the caller passed. name: String, }, /// `pmacs.packages.on_unload(fn)` was called but the runtime /// can't recover the calling package's basename via identity /// against the registered per-package env tables. This /// shouldn't fire under normal use --- it indicates the call /// ran outside any package's chunk (e.g. directly from /// `init.lua` or a non-package Lua chunk), where there's no /// owning package to attach the hook to. The error message /// names the workaround. #[error( "pmacs.packages.on_unload must be called from inside a package's chunk; \ the calling function's environment isn't one of the registered \ per-package _ENV tables, so there's no owning package to attach the \ hook to. If you need a teardown hook for editor-shutdown cleanup \ from non-package code, use \ `pmacs.hook.add('editor.before-quit', function() ... end)`." )] PackagesOnUnloadOutsidePackage, /// The `on_unload` registry slot wasn't installed. Programming /// error like [`Self::NoInstalledPackagesSlot`]. #[error("Lua app data missing: PackageUnloadHooks slot was not installed on this Lua state")] NoUnloadHooksSlot, /// `require("pkg.x")` for a submodule the package's manifest does /// not list in `exports`. The error surfaces both the missing /// export and the available ones so the user can fix their /// require or update the manifest. #[error( "pmacs package `{package}` does not export `{requested}`. \ Available exports: {exports_display}. \ If `{requested}` should be public, add it to the package's \ `exports` list in pmacs.toml; otherwise this require is \ reaching into the package's internals.", exports_display = format_exports_for_error(exports), )] PackageNotExported { /// Package basename. package: String, /// The full require name as written. requested: String, /// Sorted exports list from the manifest. exports: Vec, }, /// `require("pkg.x")` named an export the manifest declared, but /// neither `/x.lua` nor `/x/init.lua` exists on disk. /// Indicates the package's tree is missing a file the manifest /// promised — broken upstream, not a user error. #[error( "pmacs package export `{requested}` is declared in the manifest \ but the file is missing on disk (expected `{expected_path}`). \ The package's installed snapshot is incomplete; reinstall or \ report this to the package maintainer." )] PackageExportFileMissing { /// The full require name. requested: String, /// The path the searcher tried to read first (the `/x.lua` /// form; the `init.lua` fallback was also absent). expected_path: String, }, /// Lua app data missing: `pmacs.packages.describe` was called but /// the [`InstalledPackages`] roster slot is unpopulated. Same /// programming-error category as [`Self::NoInstalledPackagesSlot`]. #[error("pmacs.packages.describe: InstalledPackages roster is not installed on this Lua state")] DescribeNoRoster, /// A Lua intercept returned a table that was missing one of the /// required position/range fields. The intercept contract requires /// the returned table to carry the same fields as the input /// (`pos` for insert, `start`/`end` for delete, `start`/`end` + /// optional `bytes_len` for replace). #[error("intercept return table missing required field `{field}`")] InterceptResultMissingField { /// The missing field name. field: &'static str, }, /// A Lua intercept tried to change the op's `kind` (e.g. return /// `delete` from a `replace` input). M6.4 forbids kind-changing /// intercepts: the lifetime contract on /// [`crate::buffer::EditOp`] is preserved only when bytes are /// passed through unchanged, and a kind change would require /// inventing or dropping bytes. Same-kind transforms (modifying /// `pos` / `start` / `end`) are permitted. /// /// The message names a workaround (the input kind that the /// returned table must use) so users have a path forward, per /// the project's "errors point at the workaround" convention. #[error( "intercept changed op kind from `{from}` to `{to}`; M6.4 intercepts may only modify \ positions/ranges, not the op kind. Return a table with kind=\"{from}\" or raise an \ error to reject the edit." )] InterceptKindChange { /// The original kind (the one Lua must return). from: &'static str, /// The kind Lua tried to return. to: String, }, /// The buffer registry is already borrowed --- typically because a /// `pmacs.buffer.X` call was made from inside a buffer intercept /// callback. Intercepts run while the registry is locked so the /// edit can be applied atomically; calling back into /// `pmacs.buffer.X` from the intercept body would deadlock. We /// detect the recursive borrow attempt and surface a typed error /// instead of letting `RefCell::borrow_mut` panic. /// /// The structural fix (let intercepts re-enter the buffer API /// safely) is tracked as a deferred audit task; until then, /// intercept bodies must operate only on the `op` parameter and /// any state captured in their closure --- not call back through /// the public surface synchronously. #[error( "buffer registry already borrowed (likely a re-entrant call from \ inside a buffer intercept); intercepts cannot call pmacs.buffer.X \ synchronously --- defer the work to a hook or callback that runs \ after the edit completes" )] Reentrant, } // --------------------------------------------------------------------------- // BufferIdLua: the userdata wrapper // --------------------------------------------------------------------------- /// Lua-facing wrapper around [`BufferId`]. /// /// We could implement `UserData` directly on `BufferId`, but R51 wants /// the Lua surface to be its own design. Wrapping lets the Lua API /// evolve independently of the Rust shape and lets us add Lua-only /// metamethods (e.g., `__tostring`, equality) without touching /// [`BufferId`]. /// /// `Copy` because `BufferId` is `Copy`. Lua scripts can freely pass /// the handle around. #[derive(Copy, Clone)] pub struct BufferIdLua(pub BufferId); impl BufferIdLua { /// The wrapped [`BufferId`]. #[must_use] pub fn id(self) -> BufferId { self.0 } } impl FromLua for BufferIdLua { fn from_lua(value: Value, _: &Lua) -> mlua::Result { match value { Value::UserData(ud) => Ok(*ud.borrow::()?), other => Err(mlua::Error::FromLuaConversionError { from: other.type_name(), to: "BufferIdLua".to_string(), message: Some( "expected a buffer handle (returned by pmacs.buffer.create / from_bytes)" .to_string(), ), }), } } } impl UserData for BufferIdLua { fn add_methods>(methods: &mut M) { add_query_methods(methods); add_mutation_methods(methods); add_history_methods(methods); add_meta_methods(methods); } } fn add_query_methods>(methods: &mut M) { methods.add_method("len", |lua, this, ()| { with_registry(lua, |r| { // Buffer lengths in practice fit comfortably in i64; we pin // the boundary at i64::MAX rather than wrapping because Lua // integers are i64 on every supported backend. i64::try_from(resolve(r, this.0)?.len()).map_err(mlua::Error::external) }) }); methods.add_method("name", |lua, this, ()| { with_registry(lua, |r| Ok(resolve(r, this.0)?.name().to_owned())) }); methods.add_method("is_modified", |lua, this, ()| { with_registry(lua, |r| Ok(resolve(r, this.0)?.is_modified())) }); methods.add_method("is_valid", |lua, this, ()| { with_registry(lua, |r| Ok(r.contains(this.0))) }); methods.add_method("slice", |lua, this, (start, end): (i64, i64)| { let bytes = with_registry(lua, |r| slice_buffer(r, this.0, start, end))?; lua.create_string(&bytes) }); } fn add_mutation_methods>(methods: &mut M) { methods.add_method( "insert", |lua, this, (pos, bytes, opts): (i64, mlua::String, Option)| { let pos = u64_from_lua(pos)?; let bypass_intercept = parse_bypass_intercept(opts.as_ref())?; let payload = bytes.as_bytes(); let edit = run_buffer_edit( lua, this.0, EditOp::Insert { pos, bytes: &payload, }, bypass_intercept, )?; notify_buffer_edit_to_windows(lua, this.0, &edit); Ok(()) }, ); methods.add_method( "delete", |lua, this, (start, end, opts): (i64, i64, Option
)| { let range = checked_range(start, end)?; let bypass_intercept = parse_bypass_intercept(opts.as_ref())?; let edit = run_buffer_edit(lua, this.0, EditOp::Delete { range }, bypass_intercept)?; notify_buffer_edit_to_windows(lua, this.0, &edit); Ok(()) }, ); methods.add_method( "replace", |lua, this, (start, end, bytes, opts): (i64, i64, mlua::String, Option
)| { let range = checked_range(start, end)?; let bypass_intercept = parse_bypass_intercept(opts.as_ref())?; let payload = bytes.as_bytes(); let edit = run_buffer_edit( lua, this.0, EditOp::Replace { range, bytes: &payload, }, bypass_intercept, )?; notify_buffer_edit_to_windows(lua, this.0, &edit); Ok(()) }, ); } fn parse_bypass_intercept(opts: Option<&Table>) -> mlua::Result { Ok(match opts { Some(opts) => opts .get::>("bypass_intercept")? .unwrap_or(false), None => false, }) } fn run_buffer_edit( lua: &Lua, id: BufferId, op: EditOp<'_>, bypass_intercept: bool, ) -> mlua::Result { if bypass_intercept { run_bypass_edit(lua, id, op) } else { run_managed_edit(lua, id, op) } } fn run_bypass_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result { with_registry_mut(lua, |r| { let buf = resolve_mut(r, id)?; buf.begin_edit().map_err(mlua::Error::external)?; let result = buf .apply_edit_skip_intercepts(op) .map_err(mlua::Error::external); buf.end_edit(); result }) } /// Three-phase edit flow that lets intercepts re-enter `pmacs.buffer.X` /// safely (T M7.4). /// /// Phase 1: borrow the registry, mark the buffer mid-edit /// (`begin_edit`), take its views out, snapshot the /// [`InterceptContext`], drop the borrow. /// /// Phase 2: run the intercept chain against the snapshot context, /// without holding the registry borrow. An intercept body that calls /// back into `pmacs.buffer.X` on a different buffer succeeds /// transparently; on the same buffer it hits the `editing_in_progress` /// gate and returns `BufferError::ConcurrentEdit`. /// /// Phase 3: re-borrow, restore the views (preserving any new views /// added during phase 2), clear the mid-edit flag, and run /// `apply_edit_skip_intercepts` --- which performs the rope edit, the /// undo bookkeeping, the revision bump, and the `on_edit` broadcast. fn run_managed_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result { // Phase 1: borrow, begin_edit, take views, snapshot context. let (mut views, ctx) = with_registry_mut(lua, |r| { let buf = resolve_mut(r, id)?; buf.begin_edit().map_err(mlua::Error::external)?; let ctx = crate::view::InterceptContext::snapshot(buf); let views = buf.take_views(); Ok((views, ctx)) })?; // Phase 2: run intercepts. Registry borrow is released, so the // intercept body may re-enter `pmacs.buffer.X`. The bytes // referenced by `op` are owned by the caller's `mlua::String`, // which lives across the whole call --- the borrow stays valid. let intercept_result: Result, crate::buffer::BufferError> = (|| { let mut current = op; for (_, view) in &mut views { current = view.intercept_edit(&ctx, current)?; } Ok(current) })(); // Phase 3: re-borrow, restore views, clear mid-edit flag, apply. // We restore views and clear the flag even on intercept error, // so the buffer is left in a usable state. with_registry_mut(lua, |r| { let buf = resolve_mut(r, id)?; buf.restore_views(views); buf.end_edit(); match intercept_result { Ok(final_op) => buf .apply_edit_skip_intercepts(final_op) .map_err(mlua::Error::external), Err(e) => Err(mlua::Error::external(e)), } }) } fn add_history_methods>(methods: &mut M) { methods.add_method("undo", |lua, this, ()| { let edit = with_registry_mut(lua, |r| Ok(resolve_mut(r, this.0)?.undo().ok()))?; if let Some(edit) = edit.as_ref() { notify_buffer_edit_to_windows(lua, this.0, edit); } Ok(edit.is_some()) }); methods.add_method("redo", |lua, this, ()| { let edit = with_registry_mut(lua, |r| Ok(resolve_mut(r, this.0)?.redo().ok()))?; if let Some(edit) = edit.as_ref() { notify_buffer_edit_to_windows(lua, this.0, edit); } Ok(edit.is_some()) }); } /// Notify every window currently displaying `buffer_id` that the /// buffer was just edited via the Lua surface, AND queue the edit's /// CRDT op (if any) for broadcast to replica frontends. /// /// Without the window notification, a window already displaying the /// edited buffer would keep a stale /// [`crate::text_view::TextView`] line cache — cursor motions stop /// updating the screen until the window switches buffers. /// /// # Post-audit-round-5 F28: daemon-origin CRDT op broadcast /// /// Lua-driven edits (`buf:insert`, `buf:delete`, `buf:replace`, /// `buf:undo`, `buf:redo`) on CRDT-backed buffers produce Edits with /// `crdt_op` populated. Without explicit broadcast queueing, those /// ops never reach replica frontends — their `BufferMirror`s see the /// resulting `CellDelta` repaint but never import the CRDT op, so /// subsequent optimistic edits on the replica are generated against /// stale mirror content. /// /// We push the op as /// [`crate::editor_core::CrdtOpOrigin::DaemonKey`] (via /// `EditorCore::queue_daemon_origin_crdt_op`) so the broadcast sweep /// includes every replica with no sender exclusion: no frontend /// applied the op locally; every replica's mirror needs the bytes. /// /// # No-op cases /// /// No-op when no [`SharedCore`] has been registered as Lua app data /// (the shape used by the early-stage tests that exercise the /// registry without an editor core). fn notify_buffer_edit_to_windows(lua: &Lua, buffer_id: BufferId, edit: &crate::rope::Edit) { let Some(core) = lua.app_data_ref::() else { return; }; let mut core = core.borrow_mut(); core.notify_buffer_edit(buffer_id, edit); // F28 — queue for broadcast. `queue_daemon_origin_crdt_op` is a // no-op when the edit doesn't carry a `crdt_op` (the buffer // wasn't CRDT-backed at edit time). core.queue_daemon_origin_crdt_op(buffer_id, edit); } fn remove_buffer_removed_callback(lua: &Lua, handle: &BufferRemoveCallbackHandleLua) -> bool { let Some(callbacks) = lua.app_data_ref::() else { return false; }; callbacks.remove(handle.buffer, handle.callback_id) } fn remove_buffer_and_fire(lua: &Lua, registry: &SharedRegistry, id: BufferId) -> mlua::Result<()> { registry .borrow_mut() .remove(id) .map(|_| ()) .map_err(mlua::Error::external)?; after_buffer_removed(lua, id); Ok(()) } fn after_buffer_removed(lua: &Lua, id: BufferId) { if let Some(keymaps) = lua.app_data_ref::() { keymaps.borrow_mut().remove_buffer(id); } let callbacks = match lua.app_data_ref::() { Some(callbacks) => callbacks.take(id), None => Vec::new(), }; for callback in callbacks { if let Err(err) = callback.body.call::<()>(BufferIdLua(id)) { log_buffer_removed_error(lua, &callback.source, &err); } } } fn run_hook_if_defined(lua: &Lua, name: &str, args: mlua::MultiValue) { let snapshot = match lua.app_data_ref::() { Some(hooks) => hooks.borrow().snapshot(name), None => None, }; let Some((kind, callbacks)) = snapshot else { return; }; let outcome = crate::hook::run_snapshot(kind, &callbacks, args); for err in &outcome.errors { log_hook_error(lua, name, err); } } /// Force any window showing `buffer_id` to rebuild its `TextView`. /// /// Called by `pmacs.help.*` after a render rewrites `*help*` end to /// end. The render does delete-all + insert; tracking the individual /// edits would be more code than this is worth, and a full rebuild /// is what was implicitly happening anyway. Without this, the /// regression we hit on `*errors*` and `*buffer-list*` recurs on /// `*help*`: a window already displaying it keeps the pre-render /// line cache and cursor motions stop updating the screen. fn rebuild_help_buffer_views(lua: &Lua, buffer_id: BufferId) { let Some(core) = lua.app_data_ref::() else { return; }; core.borrow_mut().rebuild_views_for(buffer_id); } fn add_meta_methods>(methods: &mut M) { methods.add_meta_method(mlua::MetaMethod::ToString, |_, this, ()| { // `BufferId` has a private inner u64 (R22), so we don't expose // the value; the Debug repr is for human-readable identity. Ok(format!("BufferId({:?})", this.0)) }); methods.add_meta_method(mlua::MetaMethod::Eq, |_, this, other: BufferIdLua| { Ok(this.0 == other.0) }); } fn slice_buffer(r: &BufferRegistry, id: BufferId, start: i64, end: i64) -> mlua::Result> { let buf = resolve(r, id)?; let len = buf.len(); let start = u64_from_lua(start)?; let end = u64_from_lua(end)?; if start > end { return Err(mlua::Error::external(BindingError::InvalidRange { start, end, })); } if end > len { return Err(mlua::Error::external(crate::rope::RopeError::OutOfBounds { pos: end, len, })); } let mut out = vec![0u8; (end - start) as usize]; if !out.is_empty() { buf.snapshot_rope().slice(start, end, &mut out); } Ok(out) } fn checked_range(start: i64, end: i64) -> mlua::Result { let start = u64_from_lua(start)?; let end = u64_from_lua(end)?; if start > end { return Err(mlua::Error::external(BindingError::InvalidRange { start, end, })); } Ok(Range::new(start, end)) } // --------------------------------------------------------------------------- // LuaInterceptView: a Lua function as a buffer intercept_edit chain entry // --------------------------------------------------------------------------- /// Wraps a Lua function as a [`crate::view::View`] whose only behavior /// is to participate in the buffer's `intercept_edit` chain. Other /// view callbacks (`on_edit`, `render`) take their default no-op /// implementations. /// /// # Lua-side contract /// /// On every `apply_edit`, the wrapped Lua function is invoked with /// one argument: a table describing the proposed op. The table's /// `kind` is one of `"insert"`, `"delete"`, `"replace"`. Position /// fields: /// /// - `kind = "insert"`: `pos: integer`, `bytes: string`, `bytes_len: integer` /// - `kind = "delete"`: `start: integer`, `end: integer` /// - `kind = "replace"`: `start: integer`, `end: integer`, `bytes: string`, `bytes_len: integer` /// /// `bytes` is the literal byte content the caller proposes to insert /// (for `insert`) or replace into the range (for `replace`). It is /// the inserted/incoming bytes, *not* the bytes being overwritten; /// `delete` carries no `bytes` field because deletion has no /// payload. Surfaced as a Lua string (which is byte-clean: Lua /// strings hold arbitrary 8-bit data, not UTF-8). /// /// **Why M6.4 punted on `bytes`, and why M8.3 added it.** The /// concern was per-edit FFI cost: every keystroke would copy the /// inserted bytes across the boundary. In practice typical inserts /// are 1 byte (a typed character), and the wdired pattern (M8.3) /// genuinely needs the bytes — the dired-class package validates /// permission-column edits against the rwx alphabet by inspecting /// the proposed bytes, which the spec explicitly requires /// ("rejection at the `intercept_edit` layer, not at the syscall"). /// `bytes_len` is retained for the rare case where an intercept /// only needs the size and wants to skip a length lookup. /// /// The function returns one of: /// /// - `nil` --- pass through the original op unchanged. The common /// case for "this edit is fine, I have nothing to say." /// - a table with the same shape as the input --- override `pos` / /// `start` / `end` (bytes preserved). The `kind` field on the /// returned table must equal the input kind: M6.4 does not support /// kind-changing intercepts (lifetime-clean only because bytes are /// immutable; see above). /// - raise an error --- reject the edit with the raised message /// surfaced as [`crate::buffer::BufferError::Intercepted`]. /// /// Multiple `LuaInterceptView`s may be attached to the same buffer. /// They run in attach order, threading the (possibly-modified) op /// through the chain; the first to raise stops the chain. This /// matches the buffer's existing view-chain semantics --- the M6.4 /// REPL package just happens to be the first user. struct LuaInterceptView { /// The owning Lua state. `Lua` is `Clone` in mlua 0.10 (refcounted /// internally), so each view holds its own handle without forcing /// the View trait to thread a `&Lua` parameter through. lua: Lua, /// The Lua function. Held alive across calls; refcounted by mlua. body: Function, } impl crate::view::View for LuaInterceptView { fn intercept_edit<'a>( &mut self, _ctx: &crate::view::InterceptContext, op: EditOp<'a>, ) -> Result, crate::buffer::BufferError> { let input = build_intercept_input(&self.lua, &op).map_err(|e| { crate::buffer::BufferError::Intercepted { reason: format!("failed to build intercept input table: {e}"), } })?; let result: Value = self.body.call(input).map_err(|e| { // Lua-raised errors and Rust-side coercion errors land // here. We surface the Lua message verbatim so the user // sees the intercept's reason, not an opaque "intercept // failed." crate::buffer::BufferError::Intercepted { reason: format!("{e}"), } })?; match result { Value::Nil => Ok(op), Value::Table(t) => { apply_op_overrides(op, &t).map_err(|e| crate::buffer::BufferError::Intercepted { reason: format!("{e}"), }) } other => Err(crate::buffer::BufferError::Intercepted { reason: format!( "intercept must return nil or a table; got {}", other.type_name() ), }), } } } /// Build the table passed to a Lua intercept on each `apply_edit`. /// /// `bytes` is surfaced as a Lua string for `insert` and `replace` /// (M8.3 enhancement, see [`LuaInterceptView`] doc). Lua strings are /// byte-clean — `lua.create_string(&[u8])` preserves arbitrary /// non-UTF-8 content — so a `name` field containing a non-UTF-8 /// byte from an exotic filesystem still round-trips correctly. fn build_intercept_input(lua: &Lua, op: &EditOp<'_>) -> mlua::Result
{ let t = lua.create_table()?; match *op { EditOp::Insert { pos, bytes } => { t.set("kind", "insert")?; t.set("pos", i64_clamp(pos))?; t.set("bytes", lua.create_string(bytes)?)?; t.set("bytes_len", i64_clamp(bytes.len() as u64))?; } EditOp::Delete { range } => { t.set("kind", "delete")?; t.set("start", i64_clamp(range.start))?; t.set("end", i64_clamp(range.end))?; } EditOp::Replace { range, bytes } => { t.set("kind", "replace")?; t.set("start", i64_clamp(range.start))?; t.set("end", i64_clamp(range.end))?; t.set("bytes", lua.create_string(bytes)?)?; t.set("bytes_len", i64_clamp(bytes.len() as u64))?; } } Ok(t) } /// Coerce a `u64` to `i64`, saturating at `i64::MAX`. Buffer positions /// in practice fit comfortably in `i64`; we pin the boundary rather /// than wrapping because Lua integers are `i64` on every supported /// backend. fn i64_clamp(v: u64) -> i64 { i64::try_from(v).unwrap_or(i64::MAX) } /// Read the override table returned by a Lua intercept and produce a /// new [`EditOp`] with the same lifetime as the input. Same `kind` /// only: M6.4 forbids kind-changing transforms (see [`LuaInterceptView`] /// docs for the lifetime rationale). fn apply_op_overrides<'a>(op: EditOp<'a>, t: &Table) -> mlua::Result> { let kind: String = t.get("kind").map_err(|_| { mlua::Error::external(BindingError::InterceptResultMissingField { field: "kind" }) })?; match (kind.as_str(), op) { ("insert", EditOp::Insert { bytes, .. }) => { let pos: i64 = t.get("pos").map_err(|_| { mlua::Error::external(BindingError::InterceptResultMissingField { field: "pos" }) })?; Ok(EditOp::Insert { pos: u64_from_lua(pos)?, bytes, }) } ("delete", EditOp::Delete { .. }) => { let start: i64 = t.get("start").map_err(|_| { mlua::Error::external(BindingError::InterceptResultMissingField { field: "start" }) })?; let end: i64 = t.get("end").map_err(|_| { mlua::Error::external(BindingError::InterceptResultMissingField { field: "end" }) })?; Ok(EditOp::Delete { range: checked_range(start, end)?, }) } ("replace", EditOp::Replace { bytes, .. }) => { let start: i64 = t.get("start").map_err(|_| { mlua::Error::external(BindingError::InterceptResultMissingField { field: "start" }) })?; let end: i64 = t.get("end").map_err(|_| { mlua::Error::external(BindingError::InterceptResultMissingField { field: "end" }) })?; Ok(EditOp::Replace { range: checked_range(start, end)?, bytes, }) } (other_kind, op) => { let original_kind = match op { EditOp::Insert { .. } => "insert", EditOp::Delete { .. } => "delete", EditOp::Replace { .. } => "replace", }; Err(mlua::Error::external(BindingError::InterceptKindChange { from: original_kind, to: other_kind.to_owned(), })) } } } /// Userdata returned by `pmacs.buffer.add_intercept`; consumed by /// `pmacs.buffer.remove_intercept`. Holds the buffer ID and the /// view ID so a stale handle (referring to a removed buffer or /// already-detached intercept) can be detected and reported via /// [`BindingError::StaleId`] rather than silently no-op-ing. #[derive(Copy, Clone)] pub struct InterceptHandleLua { buffer: BufferId, view: crate::buffer::ViewId, } #[derive(Clone)] /// Lua handle for a shared buffer-byte style overlay. pub struct StyleOverlayHandleLua { /// Shared style spans rendered by every attached overlay view. spans: crate::overlay::SharedBufferStyleSpans, } impl FromLua for StyleOverlayHandleLua { fn from_lua(value: Value, _: &Lua) -> mlua::Result { match value { Value::UserData(ud) => Ok(ud.borrow::()?.clone()), other => Err(mlua::Error::FromLuaConversionError { from: other.type_name(), to: "StyleOverlayHandleLua".to_string(), message: Some( "expected a style overlay handle (returned by pmacs.buffer.add_style_overlay)" .to_string(), ), }), } } } impl UserData for StyleOverlayHandleLua { fn add_methods>(methods: &mut M) { methods.add_method( "add", |_, this, (start, end, style): (i64, i64, Table)| -> mlua::Result<()> { let start = u64_from_lua(start)?; let end = u64_from_lua(end)?; if start > end { return Err(mlua::Error::external(BindingError::InvalidRange { start, end, })); } if start == end { return Ok(()); } this.spans .lock() .expect("style overlay mutex poisoned") .push(crate::overlay::BufferStyleSpan { start, end, style: lua_to_style(&style)?, }); Ok(()) }, ); methods.add_method("clear", |_, this, ()| { this.spans .lock() .expect("style overlay mutex poisoned") .clear(); Ok(()) }); methods.add_method("clear_before", |_, this, pos: i64| -> mlua::Result<()> { let pos = u64_from_lua(pos)?; this.spans .lock() .expect("style overlay mutex poisoned") .retain(|span| span.end > pos); Ok(()) }); methods.add_method("spans", |lua, this, ()| { let spans = this.spans.lock().expect("style overlay mutex poisoned"); let out = lua.create_table_with_capacity(spans.len(), 0)?; for (i, span) in spans.iter().enumerate() { let row = lua.create_table_with_capacity(0, 3)?; row.set("start", i64::try_from(span.start).unwrap_or(i64::MAX))?; row.set("end", i64::try_from(span.end).unwrap_or(i64::MAX))?; row.set("style", style_to_lua(lua, span.style)?)?; out.set(i + 1, row)?; } Ok(out) }); } } impl FromLua for InterceptHandleLua { fn from_lua(value: Value, _: &Lua) -> mlua::Result { match value { Value::UserData(ud) => Ok(*ud.borrow::()?), other => Err(mlua::Error::FromLuaConversionError { from: other.type_name(), to: "InterceptHandleLua".to_string(), message: Some( "expected an intercept handle (returned by pmacs.buffer.add_intercept)" .to_string(), ), }), } } } impl UserData for InterceptHandleLua { fn add_methods>(methods: &mut M) { methods.add_meta_method(mlua::MetaMethod::ToString, |_, this, ()| { Ok(format!( "InterceptHandle({:?},{:?})", this.buffer, this.view )) }); } } /// Userdata handle for a buffer-owned mark. #[derive(Copy, Clone)] pub struct MarkHandleLua { buffer: BufferId, mark: MarkId, } impl FromLua for MarkHandleLua { fn from_lua(value: Value, _: &Lua) -> mlua::Result { match value { Value::UserData(ud) => Ok(*ud.borrow::()?), other => Err(mlua::Error::FromLuaConversionError { from: other.type_name(), to: "MarkHandleLua".to_string(), message: Some( "expected a mark handle (returned by pmacs.buffer.mark_create)".to_string(), ), }), } } } impl UserData for MarkHandleLua { fn add_methods>(methods: &mut M) { methods.add_method("get", |lua, this, ()| { with_registry(lua, |r| { let buf = resolve(r, this.buffer)?; let pos = buf.mark_pos(this.mark).ok_or_else(|| { mlua::Error::external(BindingError::StaleMark { buffer: this.buffer, mark: this.mark, }) })?; Ok(i64_clamp(pos)) }) }); methods.add_method("pos", |lua, this, ()| { with_registry(lua, |r| { let buf = resolve(r, this.buffer)?; let pos = buf.mark_pos(this.mark).ok_or_else(|| { mlua::Error::external(BindingError::StaleMark { buffer: this.buffer, mark: this.mark, }) })?; Ok(i64_clamp(pos)) }) }); methods.add_method("set", |lua, this, pos: i64| { let pos = u64_from_lua(pos)?; with_registry_mut(lua, |r| { let buf = resolve_mut(r, this.buffer)?; let ok = buf .set_mark(this.mark, pos) .map_err(mlua::Error::external)?; if !ok { return Err(mlua::Error::external(BindingError::StaleMark { buffer: this.buffer, mark: this.mark, })); } Ok(()) }) }); methods.add_method("remove", |lua, this, ()| { with_registry_mut(lua, |r| { let buf = resolve_mut(r, this.buffer)?; Ok(buf.remove_mark(this.mark)) }) }); methods.add_meta_method(mlua::MetaMethod::ToString, |_, this, ()| { Ok(format!("MarkHandle({:?},{:?})", this.buffer, this.mark)) }); } } // --------------------------------------------------------------------------- // Module install // --------------------------------------------------------------------------- /// Install the `pmacs.buffer.*` table and register the registry on the /// Lua state's app data. /// /// Idempotent within a single process *modulo the registry*: calling /// `install` again replaces the app-data registry with the new one and /// rebuilds the `pmacs` global. Tests rely on this; production code /// calls it exactly once at [`crate::lua::LuaHost`] construction. /// /// `registry`, `commands`, and `keymaps` are taken by reference and /// cloned internally for each captured closure --- the caller keeps /// ownership of its handles. pub fn install( lua: &Lua, registry: &SharedRegistry, commands: &SharedCommandRegistry, keymaps: &SharedKeymapStack, menus: &SharedMenuRegistry, hooks: &SharedHookRegistry, ) -> mlua::Result<()> { lua.set_app_data(registry.clone()); lua.set_app_data(commands.clone()); lua.set_app_data(keymaps.clone()); lua.set_app_data(menus.clone()); lua.set_app_data(hooks.clone()); lua.set_app_data(InitCompleteFlag::new()); lua.set_app_data(RequestedAttach::new()); lua.set_app_data(CurrentAttachmentSlot::new()); lua.set_app_data(LocalInstanceInfo::new()); lua.set_app_data(InstalledPackages::new()); lua.set_app_data(PackageUnloadHooks::new()); lua.set_app_data(CurrentlyLoadingPackage::new()); lua.set_app_data(BufferRemoveCallbacks::new()); let pmacs = lua.create_table()?; pmacs.set("buffer", install_buffer_module(lua, registry)?)?; pmacs.set("command", install_command_module(lua, commands)?)?; pmacs.set("keymap", install_keymap_module(lua, keymaps)?)?; pmacs.set("menu", install_menu_module(lua, menus)?)?; pmacs.set("hook", install_hook_module(lua, hooks)?)?; // Wall-clock millis (since UNIX epoch). Used by builtin runtime // chunks for timeout loops; `os.clock()` only counts CPU time and // is a poor fit for "wait until something arrives over I/O". pmacs.set( "now_ms", lua.create_function(|_, ()| { let ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_millis()); Ok(i64::try_from(ms).unwrap_or(i64::MAX)) })?, )?; pmacs.set( "describe", install_describe_module(lua, registry, commands, keymaps, hooks)?, )?; pmacs.set( "help", install_help_module(lua, registry, commands, keymaps, hooks)?, )?; pmacs.set("attach", install_attach_binding(lua)?)?; pmacs.set( "current_attachment", install_current_attachment_binding(lua)?, )?; pmacs.set("instance", install_instance_module(lua, registry)?)?; pmacs.set("ansi", install_ansi_module(lua)?)?; pmacs.set("packages", install_packages_module(lua)?)?; pmacs.set("state", install_state_module(lua)?)?; pmacs.set("session", install_session_module(lua)?)?; lua.globals().set("pmacs", pmacs)?; Ok(()) } /// Marker app-data set by `pmacs.session.arm_restore()` (Arc 3 phase 2, /// Q#DS7). Its presence tells the `RunLocal` startup trigger to attempt /// a desktop restore; `desktop_mode(true)` in init.lua arms it. pub struct DesktopRestoreArmed; /// Marker app-data set by `run_daemon` (Arc 3 phase 2, Q#DS9). Desktop /// save/restore is local-only in v1 (the daemon has a layout per /// attached frontend and no frontend at construction), so `desktop.lua` /// checks `pmacs.session.is_daemon()` and no-ops there. pub struct DaemonMode; /// Fire `buffer.after-load` from Rust with the current active buffer — /// the seam desktop-restore uses (Q#DS3). `pub(crate)` so /// [`crate::desktop`] can drive it. pub(crate) fn fire_after_load_hook(lua: &Lua) { run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new()); } /// `pmacs.session.*` — desktop-save (Arc 3 phase 2). All-Rust because /// the layout serde + structural rebuild can't live in Lua (Q#DS1). /// The thin `desktop.lua` builtin wires `desktop_mode` on top of these. fn install_session_module(lua: &Lua) -> mlua::Result
{ let m = lua.create_table()?; // arm_restore(on): arm (or, with `false`, unarm) restore-on-startup. // A boolean app-data path so `desktop_mode(false)` can undo a prior // `desktop_mode(true)` — the marker is not one-way. m.set( "arm_restore", lua.create_function(|lua, on: Option| { if on.unwrap_or(true) { lua.set_app_data(DesktopRestoreArmed); } else { lua.remove_app_data::(); } Ok(()) })?, )?; // is_daemon(): keep desktop save/restore local-only in v1 (Q#DS9). m.set( "is_daemon", lua.create_function(|lua, ()| Ok(lua.app_data_ref::().is_some()))?, )?; // save_desktop(): serialize the current session. Returns true when // a desktop was written (false = nothing to save / no state dir). m.set( "save_desktop", lua.create_function(|lua, ()| { crate::desktop::save_session(lua).map_err(mlua::Error::external) })?, )?; // restore_desktop(): rebuild the saved session (manual command; // the startup path goes through EditorState::restore_desktop_if_armed). m.set( "restore_desktop", lua.create_function(|lua, ()| { crate::desktop::restore_session(lua).map_err(mlua::Error::external) })?, )?; Ok(m) } /// The configured base state directory (Arc 3, Q#PS2). Present as Lua /// app-data only when a real dir was resolved at startup; its absence /// (the `cfg(test)` case, and any host without `HOME`/`XDG_STATE_HOME`) /// makes every `pmacs.state.*` call a no-op, so default-on persistence /// builtins never touch disk in `cargo test`. pub struct StateDir(pub std::path::PathBuf); /// `pmacs.state.{write,read,remove,path}` — the confined key→file store /// (Q#PS2). All keys pass [`crate::state::validate_name`], so a state /// call can never read or write outside the state directory. When the /// state dir is unconfigured every call is inert: `write`/`remove` /// return `false`, `read`/`path` return `nil`. fn install_state_module(lua: &Lua) -> mlua::Result
{ let m = lua.create_table()?; m.set( "write", lua.create_function(|lua, (name, content): (String, mlua::String)| { let Some(base) = lua.app_data_ref::() else { return Ok(false); }; crate::state::write(&base.0, &name, &content.as_bytes()) .map_err(mlua::Error::external)?; Ok(true) })?, )?; m.set( "read", lua.create_function(|lua, name: String| { let Some(base) = lua.app_data_ref::() else { return Ok(None); }; crate::state::read(&base.0, &name).map_err(mlua::Error::external) })?, )?; m.set( "remove", lua.create_function(|lua, name: String| { let Some(base) = lua.app_data_ref::() else { return Ok(false); }; crate::state::remove(&base.0, &name).map_err(mlua::Error::external)?; Ok(true) })?, )?; m.set( "path", lua.create_function(|lua, name: String| { let Some(base) = lua.app_data_ref::() else { return Ok(None); }; match crate::state::resolve(&base.0, &name) { Ok(p) => Ok(Some(p.display().to_string())), Err(e) => Err(mlua::Error::external(e)), } })?, )?; // True when a state directory is configured — lets Lua modules tell // "unconfigured (test / no HOME)" from "configured but empty". m.set( "available", lua.create_function(|lua, ()| Ok(lua.app_data_ref::().is_some()))?, )?; Ok(m) } /// Build the `pmacs.attach` Lua function (T M5.6d). /// /// Init-time-only: refuses to run after [`InitCompleteFlag`] has been /// flipped. Accepts either a `target` string (parsed via /// [`AttachTarget::parse`]) or kwargs of the form `{ kind = "...", ... }`. /// On success, records the validated target in the [`RequestedAttach`] /// slot for the post-init dispatcher (M5.6g) to consume. /// /// # v0.1 stub posture /// /// Per the project's "validate locally, defer activation" rule, all /// four kinds (`local`, `ssh`, `tls`, `custom`) parse and validate /// here. Activation-time errors for the not-yet-implemented transports /// surface from [`AttachTarget::check_v01`] when the dispatcher /// (M5.6g, M5.7) tries to act on the stored target — not from this /// binding. This keeps the upgrade path "v0.2 swaps the activation /// path; init.lua doesn't change." fn install_attach_binding(lua: &Lua) -> mlua::Result { lua.create_function(|lua, spec: Table| { require_init_phase(lua, "pmacs.attach")?; let target = parse_attach_spec(&spec)?; let slot = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoRequestedAttachSlot))?; if let Err(prev) = slot.try_set(target) { return Err(mlua::Error::external( BindingError::AttachAlreadyRequested { prior: prev.to_string(), }, )); } Ok(()) }) } /// Parse a `pmacs.attach{...}` spec table into an [`AttachTarget`]. /// /// Two accepted forms: /// /// - **Target string:** `{ target = "kind:body" }` — delegates to /// [`AttachTarget::parse`], which round-trips through /// [`AttachTarget::Display`]. /// - **Kwargs:** `{ kind = "...", ... }` with kind-specific fields. /// See the per-kind branches below for the schema. /// /// All four kinds are accepted by this parser. Whether they activate /// in v0.1 is decided later by [`AttachTarget::check_v01`]. fn parse_attach_spec(spec: &Table) -> mlua::Result { if let Ok(s) = spec.get::("target") { return AttachTarget::parse(&s).map_err(mlua::Error::external); } let Ok(kind) = spec.get::("kind") else { return Err(mlua::Error::external( BindingError::AttachSpecMissingKindOrTarget, )); }; let target = match kind.as_str() { "local" => { let socket = spec.get::("socket").map_err(|_| { mlua::Error::external(BindingError::AttachSpecField { kind: "local", field: "socket", expected: "string", }) })?; AttachTarget::LocalSocket(std::path::PathBuf::from(socket)) } "ssh" => { let host = spec.get::("host").map_err(|_| { mlua::Error::external(BindingError::AttachSpecField { kind: "ssh", field: "host", expected: "string", }) })?; let user = spec.get::("user").ok(); let instance_name = spec.get::("instance").ok(); AttachTarget::Ssh { host, user, instance_name, } } "tls" => { let endpoint = spec.get::("endpoint").map_err(|_| { mlua::Error::external(BindingError::AttachSpecField { kind: "tls", field: "endpoint", expected: "string", }) })?; let cert = spec.get::("cert").map_err(|_| { mlua::Error::external(BindingError::AttachSpecField { kind: "tls", field: "cert", expected: "string", }) })?; AttachTarget::Tls { endpoint, cert: std::path::PathBuf::from(cert), } } "custom" => { let cmd = spec.get::
("command").map_err(|_| { mlua::Error::external(BindingError::AttachSpecField { kind: "custom", field: "command", expected: "table (sequence of strings)", }) })?; let mut command = Vec::with_capacity(cmd.raw_len()); for v in cmd.sequence_values::() { command.push(v.map_err(|_| { mlua::Error::external(BindingError::AttachSpecField { kind: "custom", field: "command", expected: "table of strings (each element a string)", }) })?); } AttachTarget::Custom { command } } other => { return Err(mlua::Error::external(BindingError::AttachSpecUnknownKind { got: other.to_string(), })); } }; target.validate().map_err(mlua::Error::external)?; Ok(target) } /// Build the `pmacs.current_attachment` Lua function (T M5.6e). /// /// Returns `nil` when no outbound attachment is recorded; otherwise /// returns a freshly-built Lua table mirroring the [`AttachmentHandle`] /// fields. The table is regenerated on each call — there is no /// stable handle reference per the v0.1 stability disclaimer in the /// [`AttachmentHandle`] doc comment. /// /// In v0.1 the slot is empty in the typical lifecycle (Local mode is /// its own instance, Daemon mode is a target not a source, Attach /// mode has no Lua), so the function virtually always returns `nil`. /// The shape exists for forward compatibility and so describe-instance /// (M5.6f) can use a single getter that gracefully degrades to `nil` /// on the in-process case. fn install_current_attachment_binding(lua: &Lua) -> mlua::Result { lua.create_function(|lua, ()| -> mlua::Result { let slot = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoCurrentAttachmentSlot))?; match slot.get() { Some(h) => Ok(Value::Table(handle_to_lua_table(lua, &h)?)), None => Ok(Value::Nil), } }) } /// Convert an [`AttachmentHandle`] into a fresh Lua table snapshot. fn handle_to_lua_table(lua: &Lua, h: &AttachmentHandle) -> mlua::Result
{ let t = lua.create_table()?; t.set( "frontend_id", i64::try_from(h.frontend_id.0).unwrap_or(i64::MAX), )?; t.set("identity", identity_to_lua_table(lua, &h.identity)?)?; t.set("target", target_to_lua_table(lua, &h.target)?)?; Ok(t) } fn identity_to_lua_table(lua: &Lua, id: &InstanceIdentity) -> mlua::Result
{ let t = lua.create_table()?; t.set("pmacs_version", id.pmacs_version.as_str())?; // Optional fields are encoded as the underlying string or `nil`. // mlua maps Option<&str> to nil-or-string, which matches Lua's // idiom of "missing field" for absent metadata. match id.build_hash.as_deref() { Some(s) => t.set("build_hash", s)?, None => t.set("build_hash", Value::Nil)?, } match id.instance_name.as_deref() { Some(s) => t.set("instance_name", s)?, None => t.set("instance_name", Value::Nil)?, } t.set( "uptime_secs", i64::try_from(id.uptime_secs).unwrap_or(i64::MAX), )?; t.set("working_directory", id.working_directory.as_str())?; Ok(t) } fn target_to_lua_table(lua: &Lua, target: &AttachTarget) -> mlua::Result
{ let t = lua.create_table()?; t.set("kind", target.kind_name())?; // `display` round-trips through `AttachTarget::parse`, so a Lua // caller can serialize / persist it and feed it back into // `pmacs.attach{ target = ... }` without ambiguity. t.set("display", target.to_string())?; match target { AttachTarget::LocalSocket(p) => { t.set("path", p.display().to_string())?; } AttachTarget::Ssh { host, user, instance_name, } => { t.set("host", host.as_str())?; match user.as_deref() { Some(s) => t.set("user", s)?, None => t.set("user", Value::Nil)?, } match instance_name.as_deref() { Some(s) => t.set("instance", s)?, None => t.set("instance", Value::Nil)?, } } AttachTarget::Tls { endpoint, cert } => { t.set("endpoint", endpoint.as_str())?; t.set("cert", cert.display().to_string())?; } AttachTarget::Custom { command } => { t.set( "command", lua.create_sequence_from(command.iter().cloned())?, )?; } } Ok(t) } /// Build the `pmacs.instance.*` Lua surface (T M5.6f). /// /// Three functions are exposed: /// /// * `pmacs.instance.identity()` — always returns a Lua table mirroring /// [`InstanceIdentity::for_running_process`], built from the /// [`LocalInstanceInfo`] app-data slot. Uptime is re-evaluated on /// each call; the rest of the fields are stable across the process. /// * `pmacs.instance.echo_line()` — returns the single-line summary /// string ([`crate::instance_buffer::format_echo_line`]) used by the /// `editor.describe-instance` command. The Lua command body owns /// the choice of how to surface the string (status row, log, etc.). /// * `pmacs.instance.show()` — populates / refreshes the /// `*pmacs-instance*` buffer ([`crate::instance_buffer::render`]) /// and returns its `BufferIdLua` so the caller can switch to it. fn install_instance_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result
{ let instance = lua.create_table()?; instance.set("identity", install_instance_identity_binding(lua)?)?; instance.set("echo_line", install_instance_echo_line_binding(lua)?)?; instance.set("show", install_instance_show_binding(lua, registry)?)?; Ok(instance) } /// `pmacs.instance.identity()` -> table. fn install_instance_identity_binding(lua: &Lua) -> mlua::Result { lua.create_function(|lua, ()| -> mlua::Result
{ let info = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoLocalInstanceInfo))?; let id = info.build_identity(); identity_to_lua_table(lua, &id) }) } /// `pmacs.instance.echo_line()` -> string. fn install_instance_echo_line_binding(lua: &Lua) -> mlua::Result { lua.create_function(|lua, ()| -> mlua::Result { let info = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoLocalInstanceInfo))?; let identity = info.build_identity(); let attachment = lua .app_data_ref::() .and_then(|s| s.get()); Ok(crate::instance_buffer::format_echo_line( &identity, attachment.as_ref(), )) }) } /// `pmacs.instance.show()` -> `BufferIdLua`. fn install_instance_show_binding(lua: &Lua, registry: &SharedRegistry) -> mlua::Result { let reg = registry.clone(); lua.create_function(move |lua, ()| -> mlua::Result { let info = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoLocalInstanceInfo))?; let identity = info.build_identity(); let attachment = lua .app_data_ref::() .and_then(|s| s.get()); let (id, edits) = crate::instance_buffer::render(&mut reg.borrow_mut(), &identity, attachment.as_ref()); queue_generated_buffer_edits(lua, id, &edits); if !edits.is_empty() { rebuild_generated_buffer_views(lua, id); } Ok(BufferIdLua(id)) }) } /// T M10.10 post-audit-round-6 F31 — queue every CRDT op produced /// by a generated-buffer render to the daemon's broadcast queue. /// /// The three generated buffers (`*help*`, `*workers*`, /// `*pmacs-instance*`) get upgraded to CRDT-backed at every /// replica's attach via `send_buffer_snapshots`. Each subsequent /// regenerate (delete-all + insert-new) produces zero, one, or two /// `Edit`s carrying `crdt_op`. Replicas need every `CrdtOp` so their /// `BufferMirror`s converge with the daemon's new content for /// these buffers; without queueing, the replicas see the /// `CellDelta` repaint but their mirrors permanently desync. /// /// Caller: every site in `lua_bindings.rs` that drives one of the /// render functions. The render functions return their Edits /// alongside the `BufferId` so this helper can queue them via /// `EditorCore::queue_daemon_origin_crdt_op`. /// /// No-op when: /// - No `SharedCore` is registered as Lua app data (early-stage /// tests use the registry without an editor core). /// - The edits' buffer wasn't CRDT-backed (`queue_daemon_origin_crdt_op` /// itself early-returns when the edit has no `crdt_op`). fn queue_generated_buffer_edits(lua: &Lua, buffer_id: BufferId, edits: &[crate::rope::Edit]) { let Some(core) = lua.app_data_ref::() else { return; }; let mut core = core.borrow_mut(); for edit in edits { core.queue_daemon_origin_crdt_op(buffer_id, edit); } } fn rebuild_generated_buffer_views(lua: &Lua, buffer_id: BufferId) { let Some(core) = lua.app_data_ref::() else { return; }; core.borrow_mut().rebuild_views_for(buffer_id); } #[allow(clippy::too_many_lines)] fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result
{ let buffer = lua.create_table()?; { let reg = registry.clone(); buffer.set( "create", lua.create_function(move |_, name: String| { let id = reg.borrow_mut().create(name); Ok(BufferIdLua(id)) })?, )?; } { let reg = registry.clone(); buffer.set( "from_bytes", lua.create_function(move |_, (name, bytes): (String, mlua::String)| { let id = reg.borrow_mut().create_from_bytes(name, &bytes.as_bytes()); Ok(BufferIdLua(id)) })?, )?; } { let reg = registry.clone(); buffer.set( "from_file", lua.create_function(move |lua, path: String| -> mlua::Result { let path_buf = std::path::PathBuf::from(&path); let (bytes, meta) = crate::file_io::load_file(&path_buf).map_err(|source| { mlua::Error::external(std::io::Error::new( source.kind(), format!("failed to load {path}: {source}"), )) })?; let id = reg.borrow_mut().create_from_bytes(path.clone(), &bytes); if let Some(core) = lua.app_data_ref::() { let mut core = core.borrow_mut(); core.switch_active_buffer(id) .map_err(mlua::Error::external)?; core.set_buffer_path(id, Some(path_buf)); core.set_buffer_meta(id, Some(meta)); } run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new()); Ok(BufferIdLua(id)) })?, )?; } { // Arc 1b Q#P6: mark (or unmark) a buffer as requiring // round-trip input. While a marked buffer is active, // `dispatch_idle` reports false, so semantic frontends' // optimistic-apply stays off — RET reaches the buffer-local // bindings (a panel's visit) and typing reaches the read-only // intercept instead of landing as a CRDT import that bypasses // it. `pmacs.listview` marks every panel it creates. buffer.set( "set_round_trip_input", lua.create_function(move |lua, (id, on): (BufferIdLua, bool)| { if let Some(core) = lua.app_data_ref::() { core.borrow_mut().set_round_trip_input(id.0, on); } Ok(()) })?, )?; } { // T M4.5 L1: find-or-open. If a buffer is already bound to // `path`, switch to it (preserving unsaved edits — no // reload); otherwise behave like `from_file`. The dedup is // what makes cross-file navigation reuse an open file // instead of spawning a duplicate buffer (SP-4 Gap A). let reg = registry.clone(); buffer.set( "find_or_open", lua.create_function(move |lua, path: String| -> mlua::Result { let path_buf = std::path::PathBuf::from(&path); if let Some(existing) = reg.borrow().find_by_path(&path_buf) { if let Some(core) = lua.app_data_ref::() { core.borrow_mut() .switch_active_buffer(existing) .map_err(mlua::Error::external)?; } // Arc 1b: switching clears the window's overlays; // subscribers (syntax highlight, LSP style/diag // views) re-attach theirs. The fresh-load branch // below fires `buffer.after-load` instead. run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); return Ok(BufferIdLua(existing)); } let (bytes, meta) = crate::file_io::load_file(&path_buf).map_err(|source| { mlua::Error::external(std::io::Error::new( source.kind(), format!("failed to load {path}: {source}"), )) })?; let id = reg.borrow_mut().create_from_bytes(path.clone(), &bytes); if let Some(core) = lua.app_data_ref::() { let mut core = core.borrow_mut(); core.switch_active_buffer(id) .map_err(mlua::Error::external)?; core.set_buffer_path(id, Some(path_buf)); core.set_buffer_meta(id, Some(meta)); } run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new()); Ok(BufferIdLua(id)) })?, )?; } { let reg = registry.clone(); buffer.set( "list", lua.create_function(move |lua, ()| { let r = reg.borrow(); let t = lua.create_table()?; for (i, id) in r.ids().iter().enumerate() { t.set(i + 1, BufferIdLua(*id))?; } Ok(t) })?, )?; } { let reg = registry.clone(); buffer.set( "remove", lua.create_function(move |lua, id: BufferIdLua| { remove_buffer_and_fire(lua, ®, id.0) })?, )?; } { // T M4.5 L4 — apply one LSP `WorkspaceEdit` resource op. // Callers (the `apply_workspace_edit` Lua applier) resolve // URIs to paths first, so this works in plain filesystem // paths and also reconciles any open buffer: a renamed file's // buffer is rebound to the new path; a deleted file's buffer // is removed. `spec.kind` is "create" | "rename" | "delete". let reg = registry.clone(); buffer.set( "apply_resource_op", lua.create_function(move |lua, spec: Table| -> mlua::Result<()> { let io_err = |ctx: &str, e: std::io::Error| { mlua::Error::external(std::io::Error::new( e.kind(), format!("apply_resource_op {ctx}: {e}"), )) }; let kind: String = spec.get("kind")?; match kind.as_str() { "create" => { let path: String = spec.get("path")?; let pb = std::path::PathBuf::from(&path); let overwrite: bool = spec.get("overwrite").unwrap_or(false); let ignore_if_exists: bool = spec.get("ignore_if_exists").unwrap_or(false); if pb.exists() && ignore_if_exists && !overwrite { return Ok(()); } if let Some(parent) = pb.parent() { std::fs::create_dir_all(parent) .map_err(|e| io_err("create (parents)", e))?; } // Create, or truncate when overwrite is set / // implied (no options ⇒ overwrite per spec). std::fs::write(&pb, b"").map_err(|e| io_err("create", e))?; } "rename" => { let old_p: String = spec.get("old_path")?; let new_p: String = spec.get("new_path")?; let from = std::path::PathBuf::from(&old_p); let to = std::path::PathBuf::from(&new_p); let overwrite: bool = spec.get("overwrite").unwrap_or(false); let ignore_if_exists: bool = spec.get("ignore_if_exists").unwrap_or(false); if to.exists() && ignore_if_exists && !overwrite { return Ok(()); } if let Some(parent) = to.parent() { std::fs::create_dir_all(parent) .map_err(|e| io_err("rename (parents)", e))?; } std::fs::rename(&from, &to).map_err(|e| io_err("rename", e))?; let bid = reg.borrow().find_by_path(&from); if let Some(id) = bid && let Some(core) = lua.app_data_ref::() { core.borrow_mut().set_buffer_path(id, Some(to.clone())); } } "delete" => { let path: String = spec.get("path")?; let pb = std::path::PathBuf::from(&path); let recursive: bool = spec.get("recursive").unwrap_or(false); let ignore_if_not_exists: bool = spec.get("ignore_if_not_exists").unwrap_or(false); match std::fs::symlink_metadata(&pb) { Ok(md) => { let r = if md.is_dir() { if recursive { std::fs::remove_dir_all(&pb) } else { std::fs::remove_dir(&pb) } } else { std::fs::remove_file(&pb) }; r.map_err(|e| io_err("delete", e))?; } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { if !ignore_if_not_exists { return Err(io_err("delete", e)); } } Err(e) => return Err(io_err("delete (stat)", e)), } let bid = reg.borrow().find_by_path(&pb); if let Some(id) = bid { remove_buffer_and_fire(lua, ®, id)?; } } other => { return Err(mlua::Error::external(format!( "apply_resource_op: unknown kind {other:?}" ))); } } Ok(()) })?, )?; } { let reg = registry.clone(); buffer.set( "on_removed", lua.create_function( move |lua, (id, body): (BufferIdLua, Function)| -> mlua::Result { { let r = reg.borrow(); resolve(&r, id.0)?; } let callbacks = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoRegistry))?; let callback_id = callbacks.add(id.0, body, caller_source(lua, 2)); Ok(BufferRemoveCallbackHandleLua { buffer: id.0, callback_id, }) }, )?, )?; } { let reg = registry.clone(); buffer.set( "mark_create", lua.create_function( move |_, (id, pos, opts): (BufferIdLua, i64, Option
)| { let gravity = parse_mark_gravity(opts.as_ref())?; let pos = u64_from_lua(pos)?; let mut r = reg.borrow_mut(); let buf = resolve_mut(&mut r, id.0)?; let mark = buf .create_mark(pos, gravity) .map_err(mlua::Error::external)?; Ok(MarkHandleLua { buffer: id.0, mark }) }, )?, )?; } // M6.4: chained intercept registration. The view chain in // `crate::buffer::Buffer` is the underlying primitive; each // registered Lua function becomes a `LuaInterceptView` attached // via `Buffer::attach_view`. Multiple intercepts run in attach // order, threading the (possibly transformed) op through; any // intercept may reject by raising. The M6.4 REPL package uses // exactly one of these per REPL buffer. { let reg = registry.clone(); buffer.set( "add_intercept", lua.create_function( move |lua, (id, body): (BufferIdLua, Function)| -> mlua::Result { let mut r = reg.borrow_mut(); let buf = resolve_mut(&mut r, id.0)?; let view = LuaInterceptView { lua: lua.clone(), body, }; let view_id = buf.attach_view(Box::new(view)); Ok(InterceptHandleLua { buffer: id.0, view: view_id, }) }, )?, )?; } { let reg = registry.clone(); buffer.set( "remove_intercept", lua.create_function(move |_, handle: InterceptHandleLua| -> mlua::Result { let mut r = reg.borrow_mut(); // Stale buffer handle — treat as already-removed, // matching the contract that detach_view returns // None for unknown view IDs (idempotent removal). let Ok(buf) = r.get_mut(handle.buffer) else { return Ok(false); }; Ok(buf.detach_view(handle.view).is_some()) })?, )?; } { buffer.set( "add_style_overlay", lua.create_function( move |lua, id: BufferIdLua| -> mlua::Result { let spans = Arc::new(Mutex::new(Vec::new())); let handle = StyleOverlayHandleLua { spans: Arc::clone(&spans), }; attach_style_overlay_to_visible_windows(lua, id.0, &spans); Ok(handle) }, )?, )?; } { buffer.set( "attach_style_overlay", lua.create_function( move |lua, (id, handle): (BufferIdLua, StyleOverlayHandleLua)| { attach_style_overlay_to_visible_windows(lua, id.0, &handle.spans); Ok(()) }, )?, )?; } Ok(buffer) } fn parse_mark_gravity(opts: Option<&Table>) -> mlua::Result { let Some(opts) = opts else { return Ok(MarkGravity::Right); }; let gravity = opts.get::>("gravity")?; match gravity.as_deref().unwrap_or("right") { "left" => Ok(MarkGravity::Left), "right" => Ok(MarkGravity::Right), other => Err(mlua::Error::external(format!( "pmacs.buffer.mark_create: opts.gravity must be \"left\" or \"right\"; got {other:?}" ))), } } fn attach_style_overlay_to_visible_windows( lua: &Lua, buffer_id: BufferId, spans: &crate::overlay::SharedBufferStyleSpans, ) { let Some(core) = lua.app_data_ref::() else { return; }; let mut core = core.borrow_mut(); for win in core.windows.values_mut() { if win.buffer_id == buffer_id { win.push_overlay(Box::new(crate::overlay::BufferStyleOverlay::new( Arc::clone(spans), ))); } } } // --------------------------------------------------------------------------- // pmacs.ansi: M6.4-side exposure of the M6.3 parser // --------------------------------------------------------------------------- /// Lua-facing wrapper around [`crate::ansi::AnsiParser`]. /// /// Constructed via `pmacs.ansi.parser()`; methods `feed(bytes)` and /// `reset()` mirror the Rust API. `feed` returns an array of event /// tables --- see [`event_to_lua_table`] for the schema. The wrapper /// is `RefCell`-internal so multiple Lua-side methods can borrow /// safely; the Lua VM is single-threaded so the borrow can never /// race. pub struct AnsiParserLua(RefCell); impl UserData for AnsiParserLua { fn add_methods>(methods: &mut M) { methods.add_method("feed", |lua, this, bytes: mlua::String| { let raw = bytes.as_bytes(); let events = this.0.borrow_mut().feed(&raw); let out = lua.create_table_with_capacity(events.len(), 0)?; for (i, ev) in events.iter().enumerate() { out.set(i + 1, event_to_lua_table(lua, ev)?)?; } Ok(out) }); methods.add_method("reset", |_, this, ()| { this.0.borrow_mut().reset(); Ok(()) }); methods.add_meta_method(mlua::MetaMethod::ToString, |_, _this, ()| { Ok("AnsiParser".to_string()) }); } } /// Build the `pmacs.ansi.*` table. The only entry today is /// `parser()`; future additions (e.g. an event-table-validator /// helper) live alongside it. fn install_ansi_module(lua: &Lua) -> mlua::Result
{ let ansi = lua.create_table()?; ansi.set( "parser", lua.create_function(|_, ()| { Ok(AnsiParserLua(RefCell::new(crate::ansi::AnsiParser::new()))) })?, )?; Ok(ansi) } // --------------------------------------------------------------------------- // pmacs.packages module (T M7.3) // --------------------------------------------------------------------------- /// Build the `pmacs.packages.*` table. /// /// Surface: /// /// - `pmacs.packages.install(spec)` --- install to user-config root /// (`$XDG_DATA_HOME/pmacs/packages/`). /// - `pmacs.packages.install_project(spec)` --- install to the project /// root (`/.pmacs/packages/`, override with `project_root` in /// the spec). /// - `pmacs.packages.installed()` --- snapshot of packages that /// completed install during the init phase. /// - `pmacs.packages.update(name?)` --- re-resolve top-level /// packages against the current upstream and replace the /// on-disk install. With no argument, updates every top-level /// entry recorded in `/pmacs.lock`; with a name, /// updates only that entry (`UpdatePolicy::UpdateOne`). Returns /// a Lua summary table reporting `version`, `commit`, /// `prior_commit`, and `changed` per package. /// /// Both install variants are init-time-only via [`require_init_phase`]; /// mid-session calls produce [`BindingError::InitOnlyApi`] naming /// the equivalent CLI flag (none yet --- restart with an updated /// init.lua). Each install is synchronous: errors raise back at the /// call site so the offending init.lua line is named in the traceback. /// Comma-separated quoted list of available exports, with `(none)` /// when the manifest declares no exports. Used by /// [`BindingError::PackageNotExported`]'s `Display`. fn format_exports_for_error(exports: &[String]) -> String { if exports.is_empty() { return "(none)".to_string(); } let mut quoted: Vec = exports.iter().map(|e| format!("`{e}`")).collect(); quoted.sort(); quoted.dedup(); quoted.join(", ") } #[allow( clippy::too_many_lines, reason = "linear list of packages.set(...) bindings, each a small closure; \ splitting into helpers fragments the surface without removing decisions" )] fn install_packages_module(lua: &Lua) -> mlua::Result
{ let packages = lua.create_table()?; packages.set( "install", lua.create_function(|lua, spec: Value| -> mlua::Result
{ require_init_phase(lua, "pmacs.packages.install")?; let install_spec = parse_lua_install_spec(&spec)?; do_install(lua, &install_spec, &InstallScope::User) })?, )?; packages.set( "install_project", lua.create_function(|lua, spec: Value| -> mlua::Result
{ require_init_phase(lua, "pmacs.packages.install_project")?; let install_spec = parse_lua_install_spec(&spec)?; // Allow `project_root = "..."` override in the table form. let project_root = install_spec_project_root(lua, &spec)?; do_install(lua, &install_spec, &InstallScope::Project { project_root }) })?, )?; packages.set( "installed", lua.create_function(|lua, ()| -> mlua::Result
{ let slot = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoInstalledPackagesSlot))?; let snapshot = slot.snapshot(); let t = lua.create_table()?; for (i, pkg) in snapshot.iter().enumerate() { t.set(i + 1, installed_package_to_lua(lua, pkg)?)?; } Ok(t) })?, )?; packages.set( "update", lua.create_function(|lua, target: Option| -> mlua::Result
{ require_init_phase(lua, "pmacs.packages.update")?; do_update(lua, target.as_deref()) })?, )?; packages.set( "install_local", lua.create_function(|lua, source_path: String| -> mlua::Result
{ require_init_phase(lua, "pmacs.packages.install_local")?; do_install_local(lua, std::path::Path::new(&source_path)) })?, )?; packages.set( "on_unload", lua.create_function(|lua, callback: Function| -> mlua::Result<()> { // Recover the calling package's basename via *identity* // comparison against the cached per-package _ENV // tables. The M7.7 searcher attaches each package's // private env (`pmacs.pkgenvs/`) as the // chunk's `_ENV`; a closure created inside that chunk // inherits that exact table by reference. Walking the // cache and matching `candidate == callback_env` finds // the basename without trusting any field the chunk // could fabricate. // // Calls from non-package code (init.lua's top level, // the REPL) hit a callback whose env is _G or some // non-package table; that doesn't match anything in the // cache, so the lookup falls through to the // PackagesOnUnloadOutsidePackage error. A user setting // `_PACKAGE = { name = "victim" }` in _G doesn't // matter --- _G is never a registered env table. // Primary: identity-check the callback's env against // the cached per-package envs. Works whenever the // closure references any global (so Lua compiles it // with `_ENV` as an upvalue) --- the typical case. let basename_from_env = match callback.environment() { Some(env) => env_table_basename(lua, &env)?, None => None, }; // Fallback: under Lua 5.4 a closure that touches only // locals doesn't capture `_ENV`, so // `Function::environment` returns `None` and the // identity check has nothing to compare. Recover the // owning package from the [`CurrentlyLoadingPackage`] // stack: if we're inside a chunk-load (the typical // moment for `on_unload` registration), the wrapped // loader has pushed the basename for us. Calls from // outside any package's chunk have an empty stack and // surface the standard error. let basename = if let Some(b) = basename_from_env { b } else { let from_stack = lua .app_data_ref::() .and_then(|s| s.top()); from_stack.ok_or_else(|| { mlua::Error::external(BindingError::PackagesOnUnloadOutsidePackage) })? }; let slot = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoUnloadHooksSlot))?; slot.register(&basename, callback); Ok(()) })?, )?; packages.set( "reload", lua.create_function(|lua, name: String| -> mlua::Result { do_reload(lua, &name) })?, )?; packages.set( "load", lua.create_function(|lua, name: String| -> mlua::Result { // T M7.8 isolation boundary for load-time errors. Wraps // `require(name)` in a Rust-side catch so a single broken // package doesn't abort the surrounding init.lua: caller // gets `false` and the error lands in `*errors*` tagged // with the package name. Successful loads return `true`. // // Cancellations propagate (return Err) rather than being // logged as a load error: a C-g during `require` is a // user-initiated abort, not a package failure, and the // outer eval's error path resets the flag. let require: Function = lua.globals().get("require")?; match require.call::(name.clone()) { Ok(_) => Ok(true), Err(e) => { if crate::lua_isolation::is_cancellation(&e) { return Err(e); } log_package_load_error(lua, &name, &e); Ok(false) } } })?, )?; packages.set( "describe", lua.create_function(|lua, name: String| -> mlua::Result { let slot = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::DescribeNoRoster))?; let snapshot = slot.snapshot(); // Most-recent-first match, mirroring searcher precedence. for pkg in snapshot.iter().rev() { if pkg.install_basename() != name { continue; } return Ok(mlua::Value::Table(installed_package_describe_table( lua, pkg, )?)); } Ok(mlua::Value::Nil) })?, )?; register_package_searcher(lua)?; Ok(packages) } /// Register a custom searcher in `package.searchers` (Lua 5.4) / /// `package.loaders` (Lua 5.1, `LuaJIT`) that consults the /// [`InstalledPackages`] roster at require time. /// /// # Three responsibilities (T M7.7) /// /// 1. **Resolve `require("")` to the manifest's `entry`.** /// Carried over from T M7.3: a package whose entry isn't at the /// standard `/init.lua` path needs the searcher to map /// the require to the manifest's declared file. /// 2. **Gate access via the `exports` whitelist.** Per spec, only /// submodules listed in `manifest.exports` are visible to other /// packages. `require(".")` for an unlisted /// `` raises a clear error naming the package and the /// available exports — the searcher takes responsibility for the /// require-name (rather than returning "not found" and letting /// `package.path` find the file anyway, which would defeat the /// whitelist). /// 3. **Set a per-package environment table** on every loaded /// chunk. Each package gets its own `_ENV` (cached by basename in /// the Lua registry), with `__index = _G` so reads still see the /// standard library and pmacs API but writes stay local. This /// enforces the "package A cannot accidentally pollute package B's /// globals" boundary called out in the M7.7 spec without requiring /// a Lua sandbox. /// /// # Precedence /// /// The searcher is **inserted at the front** of the searchers list /// (position 1, before the path-based searcher). This makes the /// pmacs roster authoritative for installed packages: `require("foo")` /// where `foo` is an installed package goes through this searcher /// even if a `foo.lua` exists somewhere on `package.path`. Requires /// for non-installed names return a string (Lua's "not found" idiom) /// so subsequent searchers — preload, path-based — get a turn. /// /// In M7.3 the searcher was *appended*; that ordering predates /// `exports` enforcement. The path searcher would happily find /// `/internal.lua` regardless of whether `internal` was in /// the exports list, defeating the whitelist. M7.7 needs the pmacs /// searcher to win first or `exports` is decorative. /// /// # 5.1 vs 5.4 names /// /// Lua 5.1 / `LuaJIT` exposes the searcher list as `package.loaders`; /// Lua 5.2+ renamed it to `package.searchers`. Both are tables of /// functions with the same callback shape. We probe `searchers` /// first and fall back to `loaders` so the same code works under /// both feature flags. fn register_package_searcher(lua: &Lua) -> mlua::Result<()> { let package: Table = lua.globals().get("package")?; let searchers: Table = match package.get::>("searchers")? { Some(t) => t, None => package.get::
("loaders")?, }; let searcher = lua.create_function(|lua, name: String| -> mlua::Result { let Some(slot) = lua.app_data_ref::() else { // Slot uninstalled (shouldn't happen under production // wiring; defensive nil keeps require working under // unusual test setups). return Ok(mlua::Value::Nil); }; let snapshot = slot.snapshot(); match lookup_in_roster(&name, &snapshot) { LookupOutcome::NotInstalled => { // Fall through to the next searcher. Lua appends // this string to the aggregate require-error // message if no later searcher succeeds either. let s = lua.create_string(format!("\n\tno installed pmacs package named '{name}'"))?; Ok(mlua::Value::String(s)) } LookupOutcome::EntryModule { entry_path } => { load_package_chunk(lua, &name, &entry_path, package_basename_from_name(&name)) } LookupOutcome::ExportedModule { file_path, kind } => { if matches!(kind, ResolvedKind::MissingBoth) { // Manifest promises this export, but neither // `/x.lua` nor `/x/init.lua` exists. // Surface the broken-package state with the // declared-but-absent file path so the // packager can fix the manifest or ship the // missing file. return Err(mlua::Error::external( BindingError::PackageExportFileMissing { requested: name.clone(), expected_path: file_path.display().to_string(), }, )); } load_package_chunk(lua, &name, &file_path, package_basename_from_name(&name)) } LookupOutcome::NotExported { package, requested, exports, } => Err(mlua::Error::external(BindingError::PackageNotExported { package, requested, exports, })), } })?; // Insert at position 1, before all existing searchers, so the // pmacs roster is authoritative for installed packages. Lua // tables are 1-indexed; we shift the existing entries up by one. let len = searchers.raw_len(); for i in (1..=len).rev() { let existing: mlua::Value = searchers.get(i)?; searchers.set(i + 1, existing)?; } searchers.set(1, searcher)?; Ok(()) } /// First segment of a dotted require name (or the whole name if no /// dot). Used to identify which package a chunk belongs to so its /// environment table can be cached and shared across the package's /// modules. fn package_basename_from_name(name: &str) -> &str { name.split_once('.').map_or(name, |(h, _)| h) } /// Load a chunk from disk, set its environment to the package's /// per-package env table, and return it wrapped as a Lua function /// that the searcher hands back to `require`. fn load_package_chunk( lua: &Lua, require_name: &str, file_path: &std::path::Path, package_basename: &str, ) -> mlua::Result { let bytes = match std::fs::read(file_path) { Ok(b) => b, Err(e) => { // Searcher convention: a string return becomes a "not // found, here's why" reason appended to the require // error. The package was correctly identified but its // file isn't readable; that's a packager / disk error, // not a "wrong basename" error, so we still surface a // searcher-style message rather than raising. let s = lua.create_string(format!( "\n\tpmacs package '{require_name}' \ file `{}` could not be read: {e}", file_path.display(), ))?; return Ok(mlua::Value::String(s)); } }; let chunk_name = format!("@{}", file_path.display()); let func = lua.load(&bytes).set_name(&chunk_name).into_function()?; let env = package_env_for(lua, package_basename)?; func.set_environment(env)?; // Wrap the chunk function so the package's basename is pushed // onto the [`CurrentlyLoadingPackage`] stack before the chunk // runs and popped after it returns (or errors). This is the // fallback path `pmacs.packages.on_unload` uses when the // callback doesn't carry an `_ENV` upvalue --- a Lua 5.4 // closure that touches only locals/upvalues compiles without // an `_ENV` capture, and `Function::environment` then returns // `None`, defeating the env-identity ownership check. The push // makes "I am loading right now" recoverable from // the binding without depending on closure-time env capture. // // The stack is popped on both success and error paths so a // failing chunk can't leak a basename into the next load. let basename_owned = package_basename.to_string(); let wrapped = lua.create_function( move |lua, args: mlua::MultiValue| -> mlua::Result { if let Some(slot) = lua.app_data_ref::() { slot.push(basename_owned.clone()); } let result = func.call::(args); if let Some(slot) = lua.app_data_ref::() { slot.pop(); } result }, )?; Ok(mlua::Value::Function(wrapped)) } /// Registry key under which per-package `_ENV` tables are cached. /// Owned by [`package_env_for`] and cleared by /// [`clear_package_env`] on reload / `install_local` replacement. const PACKAGE_ENVS_REGISTRY_KEY: &str = "pmacs.pkgenvs"; /// Get (or lazily create) the per-package environment table for /// `basename`. Cached in the Lua registry under /// [`PACKAGE_ENVS_REGISTRY_KEY`]`/`. /// /// The env table has `__index = _G` so reads see the standard /// library and the pmacs API; writes stay local. A `_PACKAGE` table /// inside the env carries the package's basename for introspection. fn package_env_for(lua: &Lua, basename: &str) -> mlua::Result
{ let envs = package_envs_table(lua)?; if let Some(env) = envs.get::>(basename)? { return Ok(env); } let env = lua.create_table()?; let mt = lua.create_table()?; mt.set("__index", lua.globals())?; env.set_metatable(Some(mt)); let info = lua.create_table_with_capacity(0, 1)?; info.set("name", basename)?; env.set("_PACKAGE", info)?; envs.set(basename, env.clone())?; Ok(env) } /// Lazily get-or-create the env-cache table at /// [`PACKAGE_ENVS_REGISTRY_KEY`]. Shared between /// [`package_env_for`] and [`clear_package_env`] / /// [`callback_belongs_to_package`] so the registry layout is owned /// in exactly one place. fn package_envs_table(lua: &Lua) -> mlua::Result
{ if let Some(t) = lua.named_registry_value::>(PACKAGE_ENVS_REGISTRY_KEY)? { return Ok(t); } let t = lua.create_table()?; lua.set_named_registry_value(PACKAGE_ENVS_REGISTRY_KEY, t.clone())?; Ok(t) } /// Drop the cached `_ENV` table for `basename`, so the next /// [`package_env_for`] call constructs a fresh one. Called from /// `pmacs.packages.reload(name)` (after `on_unload` hooks run, /// before re-`require`) and from `pmacs.packages.install_local` /// when it replaces an existing symlink to a different source. /// /// Without this, removed-from-source globals stay visible after /// reload because the env table outlives the chunk that wrote /// them. The dev-loop story ("edit on disk, see new behavior") is /// only honest if env globals reset on reload. /// /// Also drops any `on_unload` hooks still registered for /// `basename`. Hooks that survived the just-run cycle were /// registered by closures whose env-table is the *old* env we're /// about to discard --- they reference a chunk that no longer /// exists. Firing them on the next cycle would either trip the /// identity check in `pmacs.packages.on_unload` (because the env /// is no longer in the cache) or call into resources the dead /// chunk thinks are gone. The freshly-required chunk will /// re-register whatever hooks it still wants. fn clear_package_env(lua: &Lua, basename: &str) -> mlua::Result<()> { let envs = package_envs_table(lua)?; envs.set(basename, mlua::Value::Nil)?; if let Some(slot) = lua.app_data_ref::() { let _ = slot.drain(basename); } Ok(()) } /// Run the registered `on_unload` hooks for `basename` in /// registration order. The cycle's hooks are *snapshotted* at /// start (drained from the live registry into a local queue); /// new `on_unload` registrations made by hook bodies land in the /// now-empty live registry slot instead of extending the current /// queue. On a successful reload / replacement, [`clear_package_env`] /// drops those old-env survivors before the freshly-required chunk /// registers next-cycle hooks. This prevents a self-replicating hook /// from looping the current cycle indefinitely. /// /// Each queued hook is called in order. A successful call drops /// the hook from the queue and is returned to the caller as /// completed. A failing hook stays at the front of the queue; the /// unrun queue (including the failed hook) is prepended back onto /// the live registry so a retry of the surrounding operation /// re-attempts the failed cleanup in order before any newly- /// registered hooks fire. /// /// **Idempotence contract.** `on_unload` hooks must be safe to /// call more than once. Under retry-preserving semantics, a hook /// that fails will be re-attempted on the next reload / /// `install_local` replacement. A hook that's not idempotent /// (e.g. `worker:terminate()` followed by something that asserts /// the worker is still alive) will see a different observable /// state on the retry than on the original attempt; the package /// author has to handle that. /// /// Used by both [`do_reload`] and [`do_install_local`] so the /// hook semantics are identical whether the package is being /// re-loaded against fresh disk or being swapped out for a /// different working tree at the same name. fn run_unload_hooks(lua: &Lua, basename: &str) -> mlua::Result> { let mut queue: Vec = { let slot = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoUnloadHooksSlot))?; slot.drain(basename) }; let mut completed = Vec::new(); while !queue.is_empty() { // Clone the front (cheap — Function is a Lua reference // handle). Don't pop yet; we only consume on success so // a failing hook stays at the front of the queue for // restoration to the registry. let hook = queue[0].clone(); if let Err(e) = hook.call::<()>(()) { // Push the unrun queue back onto the live registry's // front. Any hooks the chunk registered during the // cycle (in the now-non-empty registry slot) shift // to follow them — they'll fire after the retry // completes the original queue. let slot = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoUnloadHooksSlot))?; slot.prepend(basename, queue); return Err(e); } completed.push(queue.remove(0)); } Ok(completed) } /// If `env` is one of the cached per-package `_ENV` tables, return /// the basename it's stored under. Otherwise `Ok(None)`. /// /// Identity-based comparison (mlua's `Table: PartialEq` is reference /// equality on the underlying Lua reference). This is what makes /// `pmacs.packages.on_unload` unspoofable: a chunk in `_G` can /// fabricate a table with `_PACKAGE.name = "victim"`, but it can't /// fabricate the actual cached env-table reference for `victim`, /// because that table is constructed by the searcher in Rust and /// only handed out as the `_ENV` of `victim`'s chunk. fn env_table_basename(lua: &Lua, env: &Table) -> mlua::Result> { let envs = package_envs_table(lua)?; for pair in envs.pairs::() { let (basename, candidate) = pair?; if candidate == *env { return Ok(Some(basename)); } } Ok(None) } /// Parse the Lua-side `install(...)` argument into an [`InstallSpec`]. /// /// Two accepted forms: /// /// - **Shorthand string**: `"github:user/repo@^1.0.0"`. Split on the /// last `@` (so SSH-style addresses like `git:git@host:path@=1.2.3` /// parse as expected). /// - **Table**: `{ "github:user/repo", version = "^1.0.0" }`. The /// address may also be passed as `address = "..."`. The `version` /// field defaults to `"*"` if omitted (any tag). fn parse_lua_install_spec(value: &Value) -> mlua::Result { match value { Value::String(s) => { let s = s.to_string_lossy(); InstallSpec::parse_shorthand(&s) .map_err(|e| mlua::Error::external(BindingError::from(e))) } Value::Table(t) => { let address_str: String = match t.get::(1) { Ok(s) => s, Err(_) => match t.get::("address") { Ok(s) => s, Err(_) => { return Err(mlua::Error::external( BindingError::InstallSpecMissingAddress, )); } }, }; let address = Address::parse(&address_str) .map_err(|e| mlua::Error::external(BindingError::from(InstallError::Address(e))))?; let pin = parse_install_pin(t)?; Ok(InstallSpec { address, pin }) } other => Err(mlua::Error::external(BindingError::InstallSpecWrongType { got: other.type_name().to_string(), })), } } /// Parse the pin fields from a `pmacs.packages.install{...}` table. /// /// A spec table may carry exactly one of: /// - `version = ""` (e.g. `"^1.0.0"`, `"=2.3.4"`). /// - `branch = ""` (e.g. `"main"`). /// - `commit = ""` (full or partial; the fetcher accepts either). /// /// If none are present the pin defaults to `version = "*"` (any /// tag). If two or more are present the parse fails with /// [`BindingError::InstallSpecConflictingPins`] naming every field /// that conflicted. fn parse_install_pin(t: &Table) -> mlua::Result { let version: Option = t.get::>("version").unwrap_or(None); let branch: Option = t.get::>("branch").unwrap_or(None); let commit: Option = t.get::>("commit").unwrap_or(None); let mut present: Vec<&'static str> = Vec::new(); let version = version.filter(|s| !s.is_empty()); let branch = branch.filter(|s| !s.is_empty()); let commit = commit.filter(|s| !s.is_empty()); if version.is_some() { present.push("version"); } if branch.is_some() { present.push("branch"); } if commit.is_some() { present.push("commit"); } if present.len() > 1 { return Err(mlua::Error::external( BindingError::InstallSpecConflictingPins { fields: present.join(", "), }, )); } if let Some(b) = branch { return Ok(InstallPin::Branch(b)); } if let Some(c) = commit { return Ok(InstallPin::Commit(c)); } let value = version.unwrap_or_else(|| "*".to_string()); let req = semver::VersionReq::parse(&value).map_err(|e| { mlua::Error::external(BindingError::from(InstallError::InvalidVersionReq { value, cause: e.to_string(), })) })?; Ok(InstallPin::Version(req)) } /// Read the required `project_root = "..."` field from the table form /// of `install_project`'s spec. /// /// Absolute paths are returned as-is. Relative paths are resolved /// against the directory of the currently-evaluating chunk /// (typically the user's `init.lua`) --- *not* against /// `std::env::current_dir()`. The init-script's directory is stable /// across invocations; CWD is whatever shell directory the user /// happened to start pmacs from and is rarely the right anchor. /// /// Returns [`BindingError::InstallProjectMissingProjectRoot`] when /// the field is absent or the spec was given as a shorthand string. /// Pre-v0.1.0 the fallback was `current_dir()`; that surprise was /// removed because CWD-at-startup is almost never the user's project /// root in any meaningful sense (see reviewer item 10). /// /// # How the chunk directory is recovered /// /// pmacs's Lua state is built with `Lua::new()`, which loads the /// safe stdlib subset and intentionally omits `debug` (the project /// forbids `unsafe_code`, so `Lua::unsafe_new` is not an option). /// Without `debug.getinfo` we cannot walk Lua's call stack at /// runtime. Instead, [`crate::lua::LuaHost::eval`] writes the /// chunk's source label into a [`CurrentEvalSource`] app-data /// slot before evaluating; this function reads it. The label /// follows Lua's `@` convention for file-loaded chunks (see /// [`crate::config::load_user_config_at`]), so stripping the `@` /// and taking the parent directory is well-defined. /// /// # Forward-planning note /// /// When project-local `init.lua` lands (post-v0.1; tracked /// separately in the milestone plan), this function should consult /// a thread-local "current project root" set by the project loader /// before falling through to the missing-field error. Until that /// machinery exists, `project_root` is unconditionally required; /// the global init.lua path is the only init.lua path, and there /// is no implicit "current project" to draw on. fn install_spec_project_root(lua: &Lua, value: &Value) -> mlua::Result { let field = match value { Value::Table(t) => t.get::("project_root").ok(), _ => None, }; let raw = match field { Some(s) if !s.is_empty() => s, _ => { return Err(mlua::Error::external( BindingError::InstallProjectMissingProjectRoot, )); } }; let candidate = std::path::PathBuf::from(&raw); if candidate.is_absolute() { return Ok(candidate); } if let Some(chunk_dir) = current_eval_dir(lua) { return Ok(chunk_dir.join(&candidate)); } // Fallback for evaluations without a file-shaped source label // (string-loaded test chunks, REPL one-liners, the M-x // command-line evaluator): the relative path is taken as-is. // The user's value is non-empty so they explicitly opted in; // this branch matches the pre-v0.1 CWD interpretation. Ok(candidate) } /// Read the parent directory of the currently-evaluating chunk's /// source label, if any. Returns `None` when no source has been /// pushed (e.g., the call stack came in via a non-`eval` entry /// point), or when the source label is not in `@` shape. /// /// The slot is populated by [`crate::lua::LuaHost::eval`] before /// it runs the chunk; see the docstring on /// [`install_spec_project_root`] for why we use this rather than /// `debug.getinfo`. fn current_eval_dir(lua: &Lua) -> Option { let slot = lua.app_data_ref::()?; let label = slot.0.as_deref()?; let path_str = label.strip_prefix('@')?; let path = std::path::PathBuf::from(path_str); let parent = path.parent()?; if parent.as_os_str().is_empty() { return None; } Some(parent.to_path_buf()) } /// Run the install end-to-end: build a fetcher rooted at /// `$XDG_CACHE_HOME/pmacs/git/`, resolve the spec's full dependency /// closure via [`crate::packages::Resolver`], call /// [`Installer::install_at_commit`] for every package in the /// resulting plan (so the installer honors the resolver's commit /// choice rather than independently re-running tag matching), /// extend `package.path` for each, and record each result in the /// [`InstalledPackages`] roster. /// /// The Lua-callable spec is a single top-level package; transitive /// dependencies declared in that package's manifest are pulled in /// by the resolver and installed in topological order. The Lua /// caller gets back the *top-level* package's metadata table (the /// thing they asked to install); transitive dependencies are /// observable through `pmacs.packages.installed()`. /// /// A [`PackageInstallOverride`] in app data, if present, redirects /// the fetcher's cache dir and the user-scope install root. Tests /// use this instead of mutating `XDG_*` env vars (which would /// require `unsafe`). fn do_install(lua: &Lua, spec: &InstallSpec, scope: &InstallScope) -> mlua::Result
{ let override_data = lua.app_data_ref::(); let cache_override = override_data.as_ref().and_then(|o| o.cache_dir.clone()); let user_root_override = override_data .as_ref() .and_then(|o| o.user_install_root.clone()); drop(override_data); // Build a fetcher to share with the resolver and the installer. // The resolver enumerates tags / reads manifests through it; the // installer reuses the same cache for the actual checkout. let resolver_fetcher = match &cache_override { Some(dir) => Fetcher::with_cache_dir(dir.clone()), None => Fetcher::from_xdg() .map_err(|e| mlua::Error::external(BindingError::from(InstallError::Fetch(e))))?, }; let installer_fetcher = match &cache_override { Some(dir) => Fetcher::with_cache_dir(dir.clone()), None => Fetcher::from_xdg() .map_err(|e| mlua::Error::external(BindingError::from(InstallError::Fetch(e))))?, }; // Resolve the full plan including transitive deps. let resolver = crate::packages::Resolver::new(resolver_fetcher); let request = crate::packages::ResolveRequest { address: spec.address.clone(), pin: spec.pin.clone(), }; let plan = resolver .resolve(&[request]) .map_err(|e| mlua::Error::external(BindingError::from(e)))?; // Install every plan entry in topological order. The plan // already orders dependencies before dependents, so an // installer that reads a freshly-installed dependency's // manifest finds it on disk by the time it's needed. let mut installer = Installer::new(installer_fetcher, scope.clone()); if let (InstallScope::User, Some(root)) = (scope, user_root_override) { installer = installer.with_install_root_override(root); } let top_level_url = spec.address.to_git_url(); let mut top_level_installed: Option = None; let slot = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoInstalledPackagesSlot))?; for rp in &plan.packages { // Top-level: install with the user's original pin so the // returned `tag` / `pin` fields reflect what the user // asked for ("version `^1.0.0`" or "branch `main`"). // Transitive deps: install at the resolver's chosen commit // (the commit is what's reproducible; transitive pins // don't carry forward branch/version semantics). // // In both cases we route through `install_at_commit` so // the installer honors the resolver's commit choice // rather than independently re-running its own tag // matching --- the resolver may have rejected a newer // upstream tag for compatibility reasons, and a divergent // installer pick would defeat that constraint. let is_top_level = rp.address.to_git_url() == top_level_url; let install_spec = if is_top_level { InstallSpec { address: rp.address.clone(), pin: spec.pin.clone(), } } else { InstallSpec { address: rp.address.clone(), pin: crate::packages::InstallPin::Commit(rp.revision.clone()), } }; let installed = installer .install_at_commit(&install_spec, &rp.revision) .map_err(|e| mlua::Error::external(BindingError::from(e)))?; if let Some(parent) = installed.install_path.parent() { prepend_package_path(lua, parent)?; } slot.record(installed.clone()); if is_top_level { top_level_installed = Some(installed); } } drop(slot); let top = top_level_installed .ok_or_else(|| mlua::Error::external(BindingError::NoInstalledPackagesSlot))?; // Lockfile write. Build a Lockfile from the just-installed plan, // merge any pre-existing lockfile entries that aren't in this // plan (so multiple installs in the same init.lua accumulate // rather than clobber), and write back. Failure to write here is // surfaced --- the install bytes are already on disk; an unwritten // lockfile means a future Frozen install can't reproduce this // state and the user should know about it. let install_root = installer .install_root() .map_err(|e| mlua::Error::external(BindingError::from(e)))?; let lockfile_fetcher = match &cache_override { Some(dir) => Fetcher::with_cache_dir(dir.clone()), None => Fetcher::from_xdg() .map_err(|e| mlua::Error::external(BindingError::from(InstallError::Fetch(e))))?, }; write_merged_lockfile(&plan, &lockfile_fetcher, &install_root) .map_err(|e| mlua::Error::external(BindingError::from(e)))?; installed_package_to_lua(lua, &top) } /// `pmacs.packages.update(name?)` --- re-resolve top-level /// packages against the current upstream and reinstall the result. /// /// Reads the user-scope lockfile at /// `/pmacs.lock` for the package set: every entry /// whose `top_level_pin` is set is treated as a user-stated /// install. Builds [`ResolveRequest`]s from those, dispatches to /// [`crate::packages::Resolver::resolve_with_policy`] under /// [`crate::packages::UpdatePolicy::UpdateOne`] (when `target` is /// `Some(name)`) or [`crate::packages::UpdatePolicy::UpdateAll`] /// (when `target` is `None`), reinstalls the resulting plan, and /// writes the new lockfile (replacing the old one --- update is a /// full re-resolve). /// /// Returns a Lua table with one entry per updated package, naming /// the prior commit, the new commit, and the new version. The /// `pmacs.packages.installed()` snapshot also reflects the change. #[allow( clippy::too_many_lines, reason = "single linear flow: read lockfile → build requests → resolve → install → write. \ Splitting helpers fragments the read without removing complexity." )] fn do_update(lua: &Lua, target: Option<&str>) -> mlua::Result
{ use crate::packages::{ Address, LOCKFILE_FILENAME, Lockfile, LockfileEntry, PackageName, ResolveRequest, Resolver, UpdatePolicy, }; let override_data = lua.app_data_ref::(); let cache_override = override_data.as_ref().and_then(|o| o.cache_dir.clone()); let user_root_override = override_data .as_ref() .and_then(|o| o.user_install_root.clone()); drop(override_data); let resolver_fetcher = match &cache_override { Some(dir) => Fetcher::with_cache_dir(dir.clone()), None => Fetcher::from_xdg() .map_err(|e| mlua::Error::external(BindingError::from(InstallError::Fetch(e))))?, }; let installer_fetcher = match &cache_override { Some(dir) => Fetcher::with_cache_dir(dir.clone()), None => Fetcher::from_xdg() .map_err(|e| mlua::Error::external(BindingError::from(InstallError::Fetch(e))))?, }; let lockfile_fetcher = match &cache_override { Some(dir) => Fetcher::with_cache_dir(dir.clone()), None => Fetcher::from_xdg() .map_err(|e| mlua::Error::external(BindingError::from(InstallError::Fetch(e))))?, }; // The Lua surface always targets user scope --- project-scope // updates would need a `project_root` argument, deferred to a // future `pmacs.packages.update_project`. let scope = InstallScope::User; let mut installer = Installer::new(installer_fetcher, scope.clone()); if let Some(root) = user_root_override { installer = installer.with_install_root_override(root); } let install_root = installer .install_root() .map_err(|e| mlua::Error::external(BindingError::from(e)))?; let lock_path = install_root.join(LOCKFILE_FILENAME); let lock = Lockfile::read_from(&lock_path) .map_err(|e| mlua::Error::external(BindingError::from(e)))?; // Collect ResolveRequests from top-level entries. let mut requests = Vec::new(); for entry in &lock.packages { if let Some(top_pin) = &entry.top_level_pin { let pin = top_pin .to_install_pin() .map_err(|e| mlua::Error::external(BindingError::from(e)))?; requests.push(ResolveRequest { address: Address::Url(entry.url.clone()), pin, }); } } if requests.is_empty() { return Err(mlua::Error::external(BindingError::PackagesUpdateNoEntries)); } // Capture prior commits so we can report what moved. let prior_commit_by_name: std::collections::BTreeMap = lock .packages .iter() .map(|e| (e.name.clone(), e.commit.clone())) .collect(); let policy = match target { None => UpdatePolicy::UpdateAll, Some(name) => { let pkg_name = PackageName::new(name).map_err(|e| { mlua::Error::external(BindingError::PackagesUpdateBadName { name: name.to_string(), reason: e.to_string(), }) })?; // Refuse to update a name that isn't in the lockfile --- // surfaces the typo loudly rather than silently doing // nothing. if lock.entry(&pkg_name).is_none() { return Err(mlua::Error::external( BindingError::PackagesUpdateUnknownName { name: name.to_string(), }, )); } UpdatePolicy::UpdateOne(pkg_name) } }; let resolver = Resolver::new(resolver_fetcher); let plan = resolver .resolve_with_policy(&requests, Some(&lock), &policy) .map_err(|e| mlua::Error::external(BindingError::from(e)))?; // Build and serialize the new lockfile before mutating the // install tree. Hashing/serialization failures should leave disk // and the in-memory roster exactly as they were. let new_lock = Lockfile::from_plan(&plan, &lockfile_fetcher) .map_err(|e| mlua::Error::external(BindingError::from(e)))?; let new_lock_bytes = new_lock .to_bytes() .map_err(|e| mlua::Error::external(BindingError::from(e)))?; // Build a path-keyed view of the OLD lockfile: each prior entry // resolves to `/`. Pruning by exact path // (rather than by basename) avoids touching a project-scope // roster entry whose basename happens to collide with a // user-scope dep that's about to disappear. let prior_by_path: std::collections::BTreeMap = lock .packages .iter() .map(|e| { let basename = crate::packages::installer::package_basename(e.name.as_str()); (install_root.join(basename), e) }) .collect(); // Reinstall every plan entry, tracking which paths the new plan // covers and which basenames need their `package.loaded` cache // cleared. A package is "stale in Lua" if (a) its commit moved // (an updated package whose old code is still cached in // package.loaded would otherwise return the prior module table) // or (b) it disappeared entirely from the plan (handled in the // prune loop below). let slot = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoInstalledPackagesSlot))?; let old_roster = slot.snapshot(); let mut new_paths: std::collections::BTreeSet = std::collections::BTreeSet::new(); let mut invalidate: std::collections::BTreeSet = std::collections::BTreeSet::new(); let update_result: mlua::Result<()> = (|| { for rp in &plan.packages { // For top-level entries, prefer the original top_level_pin // recorded in the lockfile (so the displayed `pin` field // matches what the user originally asked for); for // transitives, install at the resolver's commit. // // `replace_at_commit` is the update-aware path: when the // resolver picks a different commit than the on-disk // install, the existing tree is staged-and-swapped rather // than rejected as `AlreadyInstalled`. Without this, an // update couldn't actually move a package to a new version. let pin_to_use = rp .top_level_pin .clone() .unwrap_or_else(|| crate::packages::InstallPin::Commit(rp.revision.clone())); let install_spec = InstallSpec { address: rp.address.clone(), pin: pin_to_use, }; let installed = installer .replace_at_commit(&install_spec, &rp.revision) .map_err(|e| mlua::Error::external(BindingError::from(e)))?; if let Some(parent) = installed.install_path.parent() { prepend_package_path(lua, parent)?; } let path = installed.install_path.clone(); if prior_by_path .get(&path) .is_some_and(|old| old.commit != installed.commit) { invalidate.insert(installed.install_basename().to_string()); } new_paths.insert(path); slot.record(installed); } // Prune anything that disappeared from the plan: a transitive // dep the resolver no longer needs, or a package the user // dropped from their top-level set. Without this the on-disk // tree, the roster, and the searcher all keep stale state and // `require("dropped-pkg")` would still succeed against the old // install --- defeating the lockfile's claim of authority. for stale_path in prior_by_path.keys().filter(|p| !new_paths.contains(*p)) { if stale_path.exists() { std::fs::remove_dir_all(stale_path).map_err(|source| { mlua::Error::external(BindingError::from(crate::packages::InstallError::Io { path: stale_path.clone(), source, })) })?; } if let Some(basename) = stale_path.file_name().and_then(|s| s.to_str()) { invalidate.insert(basename.to_string()); } slot.remove_by_install_path(stale_path); } // Update is a full re-resolve --- write a fresh lockfile, no // merge. Any package that was in the old lockfile but isn't in // the new plan was a transitive dep that the resolver decided // is no longer needed. The bytes were precomputed above so the // only remaining failure class here is filesystem I/O, and the // write itself is atomic. Lockfile::write_bytes_to(&lock_path, &new_lock_bytes) .map_err(|e| mlua::Error::external(BindingError::from(e)))?; Ok(()) })(); if let Err(err) = update_result { restore_user_update_state( &mut installer, &lock, &old_roster, &install_root, &new_paths, &slot, ); drop(slot); return Err(err); } drop(slot); // Now that disk + roster + lockfile are durable, drop stale // entries from `package.loaded` so a subsequent `require()` // reroutes through the searcher and picks up the new code (or // fails if the package was pruned). Doing this *after* the // lockfile write means a partial failure earlier in update // doesn't leave Lua's module cache out of sync with what's on // disk. for basename in &invalidate { invalidate_loaded_package(lua, basename)?; } // Build the change summary table. let summary = lua.create_table()?; for (i, entry) in new_lock.packages.iter().enumerate() { let row = lua.create_table()?; row.set("name", entry.name.as_str())?; row.set("version", entry.version.to_string())?; row.set("commit", entry.commit.as_str())?; if let Some(prior) = prior_commit_by_name.get(&entry.name) { row.set("prior_commit", prior.as_str())?; row.set("changed", *prior != entry.commit)?; } else { // New entry --- e.g., a transitive dep that wasn't in // the prior lockfile. (Could happen if the previous // install used an older version that didn't depend on // this package.) row.set("changed", true)?; } summary.set(i + 1, row)?; } Ok(summary) } /// Best-effort rollback for `pmacs.packages.update`. /// /// `update` computes the new lockfile before touching disk, but the /// later install/prune/write steps still involve fallible filesystem /// operations. If any of them fails, the old lockfile remains the /// source of truth; this helper tries to make disk and the in-memory /// roster match it again before the original error is returned. fn restore_user_update_state( installer: &mut Installer, old_lock: &crate::packages::Lockfile, old_roster: &[InstalledPackage], install_root: &std::path::Path, new_paths: &std::collections::BTreeSet, slot: &InstalledPackages, ) { let old_paths: std::collections::BTreeSet = old_lock .packages .iter() .map(|entry| { let basename = crate::packages::installer::package_basename(entry.name.as_str()); install_root.join(basename) }) .collect(); for entry in &old_lock.packages { let spec = InstallSpec { address: Address::Url(entry.url.clone()), pin: InstallPin::Commit(entry.commit.clone()), }; let _ = installer.replace_at_commit(&spec, &entry.commit); } for path in new_paths.difference(&old_paths) { if path.exists() { let _ = std::fs::remove_dir_all(path); } } slot.replace_snapshot(old_roster.to_vec()); } /// Run `install_local` end-to-end: validate the source has a /// `pmacs.toml`, symlink `/` to a /// canonicalized form of the source path, record into the /// [`InstalledPackages`] roster, and skip the lockfile (Local pins /// are explicitly ephemeral). /// /// Replaces an existing symlink at the install path. Refuses if /// the install path holds a real directory (a previous fetched /// install) --- the user must clear that first to avoid surprising /// loss of an installed tree. /// /// Always invalidates `package.loaded[]` before returning, /// so a re-call against a different source picks up the new code on /// the next `require()`. (Strictly speaking, `init.lua` doesn't /// have a load order that gets two `install_local` calls into the /// same name --- the API is init-only --- but invalidating /// unconditionally is cheap and protects against user error.) fn do_install_local(lua: &Lua, source_path: &std::path::Path) -> mlua::Result
{ let override_data = lua.app_data_ref::(); let user_root_override = override_data .as_ref() .and_then(|o| o.user_install_root.clone()); drop(override_data); // install_local uses a fetcher only as a constructor argument // for Installer; the install_local() method itself never // touches the network. We pass the XDG-rooted fetcher (or its // override) for symmetry with the other install paths. let fetcher = match lua .app_data_ref::() .as_ref() .and_then(|o| o.cache_dir.clone()) { Some(dir) => Fetcher::with_cache_dir(dir), None => Fetcher::from_xdg() .map_err(|e| mlua::Error::external(BindingError::from(InstallError::Fetch(e))))?, }; let mut installer = Installer::new(fetcher, InstallScope::User); if let Some(root) = user_root_override { installer = installer.with_install_root_override(root); } // Phase 1: plan. Validates the manifest, computes the install // path, but makes no disk changes. If validation fails we // surface the error immediately; nothing's been mutated. let plan = installer .plan_local(source_path) .map_err(|e| mlua::Error::external(BindingError::from(e)))?; let basename = plan.basename.clone(); // Phase 2: stage the replacement symlink BEFORE unloading the // prior package. This front-loads fallible symlink creation while // the old package is still live; if staging fails, no hooks have // run and disk / roster / runtime state remain aligned. let staged = installer .stage_local(plan) .map_err(|e| mlua::Error::external(BindingError::from(e)))?; // Phase 3: run the prior install's on_unload hooks (if any) // BEFORE publishing the staged symlink. If a hook fails, we // discard the staged symlink, propagate the error, and leave the // live install path untouched. The failed hook stays in the // registry so retry re-attempts it. let completed_hooks = match run_unload_hooks(lua, &basename) { Ok(hooks) => hooks, Err(e) => { installer.discard_staged_local(staged); return Err(e); } }; // Phase 4: publish, then cache invalidation. If even the final // same-directory rename fails, restore the completed hooks so a // retry can re-run the prior package's idempotent teardown before // trying the publish again. let installed = installer.publish_local(staged).map_err(|e| { if let Some(slot) = lua.app_data_ref::() { slot.prepend(&basename, completed_hooks); } mlua::Error::external(BindingError::from(e)) })?; if let Some(parent) = installed.install_path.parent() { prepend_package_path(lua, parent)?; } let slot = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoInstalledPackagesSlot))?; slot.record(installed.clone()); drop(slot); // Invalidate package.loaded so a previous require() against an // earlier install_local target doesn't shadow the freshly // symlinked tree. Mirrors the post-update invalidation; // unconditional here because the cost is one Lua table walk. invalidate_loaded_package(lua, &basename)?; // Drop the cached per-package _ENV too. Same rationale as // `do_reload`: without this, globals from a prior install // (e.g. install_local against an old source, then again // against an updated one) would persist into the freshly // symlinked tree's chunk, and the dev-loop "edit on disk and // see only what's currently in the source" promise would // quietly fail. clear_package_env(lua, &basename)?; installed_package_to_lua(lua, &installed) } /// `pmacs.packages.reload(name)` --- run the package's `on_unload` /// hooks, drop its `package.loaded` cache entries, and call /// `require(name)` to load the freshly-readable bytes (T M8.1d). /// /// Returns the new module table the re-`require` produced. /// /// Reload is the dev-loop counterpart to `update`: where `update` /// re-resolves the package set against upstream and replaces the /// install bytes, `reload` works against whatever's already on disk /// (typically a working tree symlinked in via `install_local`, /// freshly edited). It does *not* re-run install machinery. /// /// Hooks run in registration order. Errors raised by an `on_unload` /// hook propagate to the caller --- a partial-teardown reload is a /// programming error in the package, not something the runtime can /// paper over. The hook list is consumed on reload, so a re-`require` /// re-registers fresh hooks; this keeps each reload cycle /// self-contained. fn do_reload(lua: &Lua, name: &str) -> mlua::Result { // Walk the roster to confirm the name exists (the searcher // would also catch a missing name on the require, but a clearer // up-front error helps users who typo). let slot = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoInstalledPackagesSlot))?; let snapshot = slot.snapshot(); drop(slot); if !snapshot.iter().any(|p| p.install_basename() == name) { return Err(mlua::Error::external( BindingError::PackagesReloadUnknownName { name: name.to_string(), }, )); } // Run on_unload hooks in registration order via the shared // peek-call-pop runner. A failing hook stays at the front of // the registry so a retry re-attempts the same cleanup; only // hooks that actually completed are popped. let _ = run_unload_hooks(lua, name)?; // Invalidate the module cache so the next require runs the // chunk against the freshly-readable bytes (the // install_local-symlinked working tree, or the // last-update-replaced install dir). invalidate_loaded_package(lua, name)?; // Drop the cached per-package _ENV table. Without this, // removed-or-renamed globals from the prior chunk would still // be visible after reload, because the env table outlives the // chunk that wrote them. The next package_env_for() call // (during the re-require below) constructs a fresh env. clear_package_env(lua, name)?; // Re-require. The M7.7 searcher resolves against the same // roster entry the original load used; we don't re-resolve via // the package system because reload's contract is "pick up // disk changes," not "switch packages." let require: Function = lua.globals().get("require")?; require.call::(name.to_string()) } /// Build a fresh [`Lockfile`] from `plan`, merge it with any /// pre-existing lockfile at `/pmacs.lock` (entries /// not in the new plan are preserved), and write the result back. /// /// Merge policy: by package name. A new-plan entry replaces a /// same-named existing entry; non-overlapping existing entries are /// preserved. Output is sorted alphabetically (matching /// [`Lockfile::from_plan`]'s contract). #[allow( clippy::result_large_err, reason = "Mirrors LockfileError's own carrier-of-diagnostic-context posture; \ boxing here would only hide the cost without changing the surface." )] fn write_merged_lockfile( plan: &crate::packages::ResolvePlan, fetcher: &Fetcher, install_root: &std::path::Path, ) -> Result<(), crate::packages::LockfileError> { use crate::packages::{LOCKFILE_FILENAME, Lockfile}; let mut new_lock = Lockfile::from_plan(plan, fetcher)?; let lock_path = install_root.join(LOCKFILE_FILENAME); if let Ok(existing) = Lockfile::read_from(&lock_path) { let new_names: std::collections::HashSet<_> = new_lock.packages.iter().map(|e| e.name.clone()).collect(); for entry in existing.packages { if !new_names.contains(&entry.name) { new_lock.packages.push(entry); } } new_lock.packages.sort_by(|a, b| a.name.cmp(&b.name)); } new_lock.write_to(&lock_path) } /// Drop `package.loaded[basename]` and every `package.loaded[basename.]` /// so a subsequent `require(basename)` re-runs the M7.7 searcher /// against the freshly-installed bytes (or fails if the package was /// pruned). Called from `pmacs.packages.update` for any basename /// whose commit moved or that disappeared from the new plan --- /// without this step, a `require()` after `update()` would return /// the cached module table from before the update, defeating the /// whole point of an in-process update. /// /// Submodules are matched by `.` prefix so a package with /// nested exports (e.g. `mypkg.submod` in /// `package.loaded["mypkg.submod"]`) is fully invalidated, not just /// at its top-level entry. Other packages' entries are untouched /// because the prefix match terminates at the dot boundary. fn invalidate_loaded_package(lua: &Lua, basename: &str) -> mlua::Result<()> { let package: Table = lua.globals().get("package")?; let loaded: Table = package.get("loaded")?; let prefix = format!("{basename}."); let mut keys: Vec = Vec::new(); for pair in loaded.clone().pairs::() { let (key, _) = pair?; if let mlua::Value::String(s) = key { let k = s.to_str()?; if *k == *basename || k.starts_with(&prefix) { keys.push(k.to_string()); } } } for key in keys { loaded.set(key, mlua::Value::Nil)?; } Ok(()) } /// Idempotently prepend `/?.lua;/?/init.lua` to /// `package.path`. The standard Lua require pattern: a package with /// `entry = "init.lua"` installed at `//init.lua` /// becomes findable as `require("")`. fn prepend_package_path(lua: &Lua, root: &std::path::Path) -> mlua::Result<()> { let package_global = lua.globals().get::
("package")?; let current_path: String = package_global.get::("path").unwrap_or_default(); let root_str = root.display().to_string(); let new_entries = format!("{root_str}/?.lua;{root_str}/?/init.lua"); if current_path .split(';') .any(|seg| seg == format!("{root_str}/?.lua") || seg == format!("{root_str}/?/init.lua")) { return Ok(()); } let combined = if current_path.is_empty() { new_entries } else { format!("{new_entries};{current_path}") }; package_global.set("path", combined)?; Ok(()) } /// Render the manifest's metadata + exports list as a Lua table, /// suitable for `pmacs.packages.describe(name)`. Extends the standard /// [`installed_package_to_lua`] record with `pmacs_required` and a /// 1-indexed `exports` array; the union of fields is what a /// "describe-package" caller needs to render a complete view of the /// package without reaching back through any other API. fn installed_package_describe_table(lua: &Lua, pkg: &InstalledPackage) -> mlua::Result
{ let t = installed_package_to_lua(lua, pkg)?; t.set("pmacs_required", pkg.manifest.pmacs_required.to_string())?; let exports = lua.create_table_with_capacity(pkg.manifest.exports.len(), 0)?; for (i, e) in pkg.manifest.exports.iter().enumerate() { exports.set(i + 1, e.as_str())?; } t.set("exports", exports)?; Ok(t) } /// Translate an [`InstalledPackage`] into the Lua-facing record /// returned by `pmacs.packages.install` and `pmacs.packages.installed`. fn installed_package_to_lua(lua: &Lua, pkg: &InstalledPackage) -> mlua::Result
{ let t = lua.create_table()?; t.set("name", pkg.manifest.name.as_str())?; t.set("version", pkg.version.to_string())?; t.set("tag", pkg.tag.as_str())?; t.set("commit", pkg.commit.as_str())?; t.set("install_path", pkg.install_path.display().to_string())?; t.set("entry", pkg.entry_path().display().to_string())?; t.set( "scope", match &pkg.scope { InstallScope::User => "user", InstallScope::Project { .. } => "project", }, )?; t.set("summary", pkg.manifest.summary.as_str())?; // Structured pin info: `{ kind = "version"|"branch"|"commit", value = }`. // Existing flat fields (`tag`, `version`, `commit`) remain // populated for backward-compatible introspection; the `pin` // table is the source of truth for "what did the user request", // distinct from "what got resolved". let pin_table = lua.create_table_with_capacity(0, 2)?; pin_table.set("kind", pkg.pin.kind())?; pin_table.set("value", pkg.pin.value())?; t.set("pin", pin_table)?; Ok(t) } /// Translate a single [`crate::ansi::AnsiEvent`] into a Lua table. /// /// The `kind` field is the discriminator; per-variant fields follow /// the M6.4 spec contract: /// /// - `text`: `{ kind="text", text= }` /// - `set_style`: `{ kind="set_style", style=