// 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::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::packages::{ Address, Fetcher, InstallError, InstallPin, InstallScope, InstallSpec, InstalledPackage, Installer, }; use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity}; use crate::rope::Range; use crate::syntax::{self, ParseTreeBundle, ParseView, ParseViewHandle, SharedSyntaxRegistry}; use crate::workers_buffer; // --------------------------------------------------------------------------- // 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, 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>; /// 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); } } 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 /// and by the future M7.6 lockfile writer to enumerate the resolved /// set. 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. Order matches install order; that /// matters for diagnostics ("which install errored?") more than /// for resolution. pub fn record(&self, pkg: InstalledPackage) { self.0.borrow_mut().push(pkg); } /// Snapshot the current roster for read-only consumers. #[must_use] pub fn snapshot(&self) -> Vec { self.0.borrow().clone() } } /// 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), /// Stub for `pmacs.packages.update(...)` which is implemented in /// T M7.6. Per the project's "stub posture" convention, we accept /// the call shape (so v0.1 init.lua's that try it get a clean /// error) and fail with the milestone target named. #[error( "pmacs.packages.update is implemented in M7.6 (lockfile + \ resolver). v0.1 / current builds: re-run `pmacs.packages.install` \ with the new constraint to upgrade in place." )] PackagesUpdateUnsupported, /// 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): (i64, mlua::String)| { let pos = u64_from_lua(pos)?; let payload = bytes.as_bytes(); let edit = run_managed_edit( lua, this.0, EditOp::Insert { pos, bytes: &payload, }, )?; notify_buffer_edit_to_windows(lua, this.0, &edit); Ok(()) }); methods.add_method("delete", |lua, this, (start, end): (i64, i64)| { let range = checked_range(start, end)?; let edit = run_managed_edit(lua, this.0, EditOp::Delete { range })?; notify_buffer_edit_to_windows(lua, this.0, &edit); Ok(()) }); methods.add_method( "replace", |lua, this, (start, end, bytes): (i64, i64, mlua::String)| { let range = checked_range(start, end)?; let payload = bytes.as_bytes(); let edit = run_managed_edit( lua, this.0, EditOp::Replace { range, bytes: &payload, }, )?; notify_buffer_edit_to_windows(lua, this.0, &edit); Ok(()) }, ); } /// 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. Without this, 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. /// /// 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; }; core.borrow_mut().notify_buffer_edit(buffer_id, edit); } /// 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_len: integer` /// - `kind = "delete"`: `start: integer`, `end: integer` /// - `kind = "replace"`: `start: integer`, `end: integer`, `bytes_len: integer` /// /// `bytes_len` is informational. The bytes themselves are not /// surfaced to Lua: [`crate::buffer::EditOp`] borrows them with a /// lifetime tied to the caller's `apply_edit` frame, and a v0.1 /// byte-mutating intercept would require either copying the bytes /// across the FFI boundary on every edit (expensive) or extending /// [`crate::buffer::EditOp`] to use [`std::borrow::Cow`] (a wider /// change than M6.4 needs). The byte stream is therefore immutable /// through the chain in M6.4; M8's dired-class package will revisit /// when it needs filename-edit-to-rename translation. /// /// 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`. 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_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_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, 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(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()); 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("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)?)?; lua.globals().set("pmacs", pmacs)?; Ok(()) } /// 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 = crate::instance_buffer::render(&mut reg.borrow_mut(), &identity, attachment.as_ref()); Ok(BufferIdLua(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( "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 |_, id: BufferIdLua| { reg.borrow_mut() .remove(id.0) .map(|_| ()) .map_err(mlua::Error::external) })?, )?; } { 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(...)` --- M7.6 stub; currently errors /// pointing at the workaround (re-running install with a new /// constraint). /// /// 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. 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(|_, _args: Variadic| -> mlua::Result<()> { Err(mlua::Error::external( BindingError::PackagesUpdateUnsupported, )) })?, )?; 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. /// /// # Why /// /// `prepend_package_path` (the existing mechanism) only handles the /// standard Lua layout: `/.lua` or /// `//init.lua`. A package whose manifest /// declares e.g. `entry = "main.lua"` or `entry = "lib/foo.lua"` /// has its entry file at a path the standard `?.lua;?/init.lua` /// pattern does not match, and `require("")` would fail /// even though the install completed. The custom searcher closes /// that gap by mapping `require("")` directly to the /// manifest's declared entry path. /// /// # Precedence /// /// The searcher is appended to the searchers/loaders list, after /// the path-based searcher. Standard layouts (`init.lua` etc.) /// continue to load via the path mechanism; the custom searcher /// only kicks in when the path search misses. This keeps /// drop-in-compatible packages on the well-trodden path and avoids /// a behavior change for anyone using the conventional layout. /// /// Within the searcher, the [`InstalledPackages`] roster is iterated /// in *reverse* so the most recently installed package wins on a /// basename collision. Combined with `init.lua`'s typical pattern /// (user install first, then project install), this makes /// project-scope installs override user-scope installs of the same /// basename --- mirroring `prepend_package_path`'s "newer /// installations prepend to package.path" semantics. /// /// # 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, but a defensive nil keeps // require working under unusual test setups). return Ok(mlua::Value::Nil); }; let snapshot = slot.snapshot(); // Most-recent-first: a project-scope install of a // basename overrides a prior user-scope install. for pkg in snapshot.iter().rev() { if pkg.install_basename() != name { continue; } let entry = pkg.entry_path(); let bytes = match std::fs::read(&entry) { Ok(b) => b, Err(e) => { // Searcher convention: a non-function return // is treated as "not found, here's why" and // appended to the require error message. let s = lua.create_string(format!( "\n\tinstalled pmacs package '{name}' \ entry `{}` could not be read: {e}", entry.display() ))?; return Ok(mlua::Value::String(s)); } }; let chunk_name = format!("@{}", entry.display()); let func = lua.load(&bytes).set_name(&chunk_name).into_function()?; return Ok(mlua::Value::Function(func)); } // No installed package matches. Return a string so Lua // appends our reason to the aggregate require error. let s = lua.create_string(format!("\n\tno installed pmacs package named '{name}'"))?; Ok(mlua::Value::String(s)) })?; // Append to the searcher list. Lua tables are 1-indexed; the // new searcher runs after every existing searcher (preload, // path-based, etc.), so standard layouts are unaffected. let len = searchers.raw_len(); searchers.set(len + 1, searcher)?; Ok(()) } /// 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/`, run [`Installer::install`], extend /// `package.path` so the entry module is requireable, and record the /// result in the [`InstalledPackages`] roster. /// /// 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()); let fetcher = match cache_override { 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, scope.clone()); if let (InstallScope::User, Some(root)) = (scope, user_root_override) { installer = installer.with_install_root_override(root); } let installed = installer .install(spec) .map_err(|e| mlua::Error::external(BindingError::from(e)))?; // Extend package.path so the package's entry module is requireable. if let Some(parent) = installed.install_path.parent() { prepend_package_path(lua, parent)?; } // Record in the in-memory roster. let slot = lua .app_data_ref::() .ok_or_else(|| mlua::Error::external(BindingError::NoInstalledPackagesSlot))?; slot.record(installed.clone()); installed_package_to_lua(lua, &installed) } /// 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(()) } /// 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=