diff --git a/.gitignore b/.gitignore index 3227f56..3ca7f08 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,11 @@ # Internal-only working documents (not part of the public v0.1 surface). # Public contributions begin at 1.0; until then these stay local. /spec/ -/TRANSITION.md -/TRANSITION-M5.md -/SPIKE-M5.md +/TRANSITION*.md +/SPIKE*.md +/M*-AUDIT.md +/M*-SHIP-GATE.md +/V*-PREREQUISITES.md /MANUAL-TEST-CHECKLIST.md /tests/INDEX.md /.claude/ diff --git a/Cargo.lock b/Cargo.lock index 22b999f..ec055db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -112,6 +121,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -199,6 +217,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -280,6 +318,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -566,6 +614,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2", "signal-hook", "tempfile", "thiserror 2.0.18", @@ -918,6 +967,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shared_library" version = "0.1.9" @@ -1143,6 +1203,12 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unarray" version = "0.1.4" @@ -1167,6 +1233,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 959fb6a..3972503 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,10 @@ path = "src/lib.rs" name = "pmacs" path = "src/main.rs" +[[bin]] +name = "pmacs-audit" +path = "src/bin/pmacs_audit.rs" + [lints.rust] unsafe_code = "forbid" missing_docs = "warn" @@ -114,6 +118,10 @@ toml = "0.8" # `serde` feature gives us localized parse errors during deserialization # (rejected at parse time, not at install time). semver = { version = "1", features = ["serde"] } +# T M7.6 lockfile content-hashing. SHA-256 over `git archive --format=tar` +# bytes detects upstream tampering even when the host serves a SHA-1 +# collision. Pure-Rust implementation; no system dep. +sha2 = "0.10" [dev-dependencies] proptest = "1" diff --git a/builtin/runtime/async.lua b/builtin/runtime/async.lua index 2e5ab8b..9f43ece 100644 --- a/builtin/runtime/async.lua +++ b/builtin/runtime/async.lua @@ -305,6 +305,16 @@ pmacs.workers.compute_sum = dispatch_sum pmacs.workers.emit_n = dispatch_emit_n pmacs.workers.grep = dispatch_grep +-- Runtime-internal: expose the Handle / Stream factories so other +-- builtin runtime files (pmacs.fs in M8.1, future siblings) can +-- construct handles for ids dispatched through their own raw +-- _dispatch_* primitives without re-implementing the class. The +-- underscore prefix marks these as not part of the documented +-- package-author surface; package code uses :await() / :cancel() / +-- :on_complete() on the returned handles, never these factories. +pmacs.workers._new_handle = new_handle +pmacs.workers._new_stream = new_stream + -- Name-based dispatch matching the spec example: -- pmacs.workers.dispatch("grep", { ... }, { supersede = "grep" }):await() -- v0.1 ships with the two stub handlers above; M4 adds tree-sitter, diff --git a/src/async_runtime.rs b/src/async_runtime.rs index 7b76eeb..8b2c334 100644 --- a/src/async_runtime.rs +++ b/src/async_runtime.rs @@ -70,6 +70,10 @@ use std::time::{Duration, Instant}; use crossbeam::channel as cb_channel; use serde::{Deserialize, Serialize}; +use crate::fs::{ + FsDirEntry, FsError, chmod_blocking, read_dir_blocking, remove_blocking, rename_blocking, + stat_blocking, +}; use crate::message_bus::{BusEnd, MessageBus, SchemaRegistry}; use crate::syntax::{self as syntax_mod, ParseRequest, ParseTreeBundle}; use crate::worker::{CancellationToken, WorkerPool}; @@ -215,6 +219,18 @@ enum ReplyKind { /// queueing) and is what the M4.1 acceptance criteria measure. /// T M4.1. Parse { duration_ms: u64 }, + /// `dispatch_fs_read_dir` completed; payload is the directory + /// listing. The Vec is `Serialize` so it crosses the bus + /// directly --- no side handoff like parse trees need. T M8.1. + ReadDir(Vec), + /// `dispatch_fs_stat` completed; payload is the per-path + /// metadata. T M8.1. + Stat(FsDirEntry), + /// Generic completion-with-no-payload reply for the unit-result + /// fs primitives (`rename`, `chmod`, `remove`). Distinct from + /// [`Self::Sleep`] so the worker observability layer can label + /// fs jobs separately. T M8.1. + FsUnit, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -244,6 +260,15 @@ pub enum JobResult { /// Parse-only wall-clock duration in milliseconds. duration_ms: u64, }, + /// `dispatch_fs_read_dir` produced a directory listing. The + /// Lua boundary in [`crate::lua_bindings`] turns the Vec into a + /// per-entry table when `_take_result` consumes the result. + /// T M8.1. + ReadDir(Vec), + /// `dispatch_fs_stat` produced metadata for a single path. The + /// Lua boundary turns the [`FsDirEntry`] into the same table + /// shape `read_dir` entries use. T M8.1. + Stat(FsDirEntry), } /// Terminal state a [`PendingJob`] settles into. @@ -275,6 +300,16 @@ pub enum JobKind { Grep, /// `dispatch_parse` --- tree-sitter parse on a worker ([T M4.1]). Parse, + /// `dispatch_fs_read_dir` --- directory enumeration ([T M8.1]). + FsReadDir, + /// `dispatch_fs_stat` --- single-path metadata ([T M8.1]). + FsStat, + /// `dispatch_fs_rename` --- atomic rename ([T M8.1]). + FsRename, + /// `dispatch_fs_chmod` --- permission-bit replacement ([T M8.1]). + FsChmod, + /// `dispatch_fs_remove` --- delete a single object ([T M8.1]). + FsRemove, } impl JobKind { @@ -287,6 +322,11 @@ impl JobKind { JobKind::EmitN => "emit_n", JobKind::Grep => "grep", JobKind::Parse => "parse", + JobKind::FsReadDir => "fs_read_dir", + JobKind::FsStat => "fs_stat", + JobKind::FsRename => "fs_rename", + JobKind::FsChmod => "fs_chmod", + JobKind::FsRemove => "fs_remove", } } } @@ -743,6 +783,67 @@ impl AsyncRuntime { id } + /// Dispatch a `read_dir(path)` job. The worker enumerates + /// `path`, returning one [`FsDirEntry`] per child with + /// `lstat`-style metadata. Polls cancel every batch of + /// entries; supersede follows the same rule as the other + /// dispatchers. T M8.1. + pub fn dispatch_fs_read_dir(&self, path: PathBuf, supersede: Option<&str>) -> JobId { + let (id, cancel) = self.allocate(JobKind::FsReadDir, supersede, None); + let bus = self.workers.clone(); + self.pool.dispatch(move |_pool| { + let kind = run_fs_read_dir(&cancel, &path); + let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind }); + }); + id + } + + /// Dispatch a `stat(path)` job. Returns one [`FsDirEntry`] of + /// metadata for `path`. T M8.1. + pub fn dispatch_fs_stat(&self, path: PathBuf, supersede: Option<&str>) -> JobId { + let (id, cancel) = self.allocate(JobKind::FsStat, supersede, None); + let bus = self.workers.clone(); + self.pool.dispatch(move |_pool| { + let kind = run_fs_stat(&cancel, &path); + let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind }); + }); + id + } + + /// Dispatch a `rename(from, to)` job. Settles to + /// [`JobResult::Unit`] on success. T M8.1. + pub fn dispatch_fs_rename(&self, from: PathBuf, to: PathBuf, supersede: Option<&str>) -> JobId { + let (id, cancel) = self.allocate(JobKind::FsRename, supersede, None); + let bus = self.workers.clone(); + self.pool.dispatch(move |_pool| { + let kind = run_fs_rename(&cancel, &from, &to); + let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind }); + }); + id + } + + /// Dispatch a `chmod(path, mode)` job. T M8.1. + pub fn dispatch_fs_chmod(&self, path: PathBuf, mode: u32, supersede: Option<&str>) -> JobId { + let (id, cancel) = self.allocate(JobKind::FsChmod, supersede, None); + let bus = self.workers.clone(); + self.pool.dispatch(move |_pool| { + let kind = run_fs_chmod(&cancel, &path, mode); + let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind }); + }); + id + } + + /// Dispatch a `remove(path)` job. T M8.1. + pub fn dispatch_fs_remove(&self, path: PathBuf, supersede: Option<&str>) -> JobId { + let (id, cancel) = self.allocate(JobKind::FsRemove, supersede, None); + let bus = self.workers.clone(); + self.pool.dispatch(move |_pool| { + let kind = run_fs_remove(&cancel, &path); + let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind }); + }); + id + } + /// Drain the parse-tree bundle for `id` from the side handoff. /// Returns `None` if the job is unknown, still running, didn't /// produce a tree (cancelled or failed), or has already been @@ -812,16 +913,25 @@ impl AsyncRuntime { ReplyKind::Sleep | ReplyKind::Sum(_) | ReplyKind::Parse { .. } + | ReplyKind::ReadDir(_) + | ReplyKind::Stat(_) + | ReplyKind::FsUnit | ReplyKind::Cancelled | ReplyKind::Error(_) if matches!(job.state, PendingState::Running) => { job.state = match reply.kind { - ReplyKind::Sleep => PendingState::Complete(JobResult::Unit), + ReplyKind::Sleep | ReplyKind::FsUnit => { + PendingState::Complete(JobResult::Unit) + } ReplyKind::Sum(v) => PendingState::Complete(JobResult::Sum(v)), ReplyKind::Parse { duration_ms } => { PendingState::Complete(JobResult::Parse { duration_ms }) } + ReplyKind::ReadDir(entries) => { + PendingState::Complete(JobResult::ReadDir(entries)) + } + ReplyKind::Stat(entry) => PendingState::Complete(JobResult::Stat(entry)), ReplyKind::Cancelled => PendingState::Cancelled, ReplyKind::Error(msg) => PendingState::Failed(msg), _ => unreachable!("matched above"), @@ -1068,6 +1178,57 @@ fn run_sleep(cancel: &CancellationToken, total: Duration) -> ReplyKind { ReplyKind::Sleep } +/// Worker body for [`AsyncRuntime::dispatch_fs_read_dir`]. +/// Translates [`crate::fs::read_dir_blocking`]'s +/// [`FsError`] taxonomy into the bus reply enum: +/// [`FsError::Cancelled`] becomes [`ReplyKind::Cancelled`]; +/// [`FsError::Io`] becomes [`ReplyKind::Error`] with the +/// human-readable message attached. +fn run_fs_read_dir(cancel: &CancellationToken, path: &Path) -> ReplyKind { + match read_dir_blocking(path, cancel) { + Ok(entries) => ReplyKind::ReadDir(entries), + Err(FsError::Cancelled) => ReplyKind::Cancelled, + Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => { + ReplyKind::Error(e.to_string()) + } + } +} + +fn run_fs_stat(cancel: &CancellationToken, path: &Path) -> ReplyKind { + match stat_blocking(path, cancel) { + Ok(entry) => ReplyKind::Stat(entry), + Err(FsError::Cancelled) => ReplyKind::Cancelled, + Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => { + ReplyKind::Error(e.to_string()) + } + } +} + +fn run_fs_rename(cancel: &CancellationToken, from: &Path, to: &Path) -> ReplyKind { + fs_unit_to_reply(rename_blocking(from, to, cancel)) +} + +fn run_fs_chmod(cancel: &CancellationToken, path: &Path, mode: u32) -> ReplyKind { + fs_unit_to_reply(chmod_blocking(path, mode, cancel)) +} + +fn run_fs_remove(cancel: &CancellationToken, path: &Path) -> ReplyKind { + fs_unit_to_reply(remove_blocking(path, cancel)) +} + +/// Shared error-mapping for the unit-result fs primitives. Keeps +/// the rename/chmod/remove worker bodies one-liners so the table +/// of dispatchers reads at a glance. +fn fs_unit_to_reply(result: Result<(), FsError>) -> ReplyKind { + match result { + Ok(()) => ReplyKind::FsUnit, + Err(FsError::Cancelled) => ReplyKind::Cancelled, + Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => { + ReplyKind::Error(e.to_string()) + } + } +} + fn run_compute_sum(cancel: &CancellationToken, n: u64) -> ReplyKind { let mut acc: u64 = 0; // Granular: poll cancel every 1024 iterations to balance diff --git a/src/buffer.rs b/src/buffer.rs index e19e2fd..d3aa439 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -57,6 +57,13 @@ impl BufferId { pub const fn raw(self) -> u64 { self.0 } + + /// Rebuild an ID from a raw value for crate-internal references that + /// persist an already-issued buffer identity in generated text. + #[must_use] + pub(crate) const fn from_raw(raw: u64) -> Self { + Self(raw) + } } /// Opaque, per-buffer identifier for an attached view. diff --git a/src/editor.rs b/src/editor.rs index f71556e..8548a0c 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -97,6 +97,13 @@ impl EditorState { /// Panics only if Lua initialization or the builtin command/keymap /// chunks fail to load --- both indicate broken builds. #[must_use] + #[allow( + clippy::too_many_lines, + reason = "linear bootstrap sequence: registry → core → LuaHost → \ + per-builtin module installs (async/syntax/process/lsp/index/...). \ + Splitting into helpers fragments the wiring without removing \ + any single decision the reader needs to follow." + )] pub fn new() -> Self { // Build the buffer registry first so EditorCore and LuaHost // share the same `Rc`. Both reach buffers through this handle; @@ -132,6 +139,18 @@ impl EditorState { include_str!("../builtin/runtime/async.lua"), ) .expect("load async builtin chunk"); + // T M8.1 filesystem worker primitives. Sits on top of the + // raw `pmacs._async._dispatch_fs_*` bindings installed by + // `make_async_runtime` and reuses the Handle factory + // exposed at the end of async.lua. Loaded immediately after + // async.lua so `pmacs.fs.*` is available to every later + // builtin and to user init.lua. + lua_host + .eval( + Some("@pmacs/builtin/runtime/fs.lua"), + include_str!("../builtin/runtime/fs.lua"), + ) + .expect("load fs builtin chunk"); // T M4.1 tree-sitter Lua surface; M4.2 layers the Lua-side // auto-attach hook on top. The registry is empty at startup; // `pmacs.parse.language` lazy-loads from `BUILTIN_LANGUAGES` @@ -191,19 +210,52 @@ impl EditorState { include_str!("../builtin/runtime/lsp.lua"), ) .expect("load lsp builtin chunk"); - // T M6.4 REPL package skeleton. The package is the v0.1 - // ship-gate test (spec §sec:repl-audit); it lives under the - // builtin runtime so the audit's "zero direct calls into - // Rust core" invariant has a fixed surface area to measure. + // T M7.11 bundled-package bootstrap. Through M7.10 the REPL + // was loaded directly via `eval(include_str!(...))`; the + // M7.11 deliverable migrates it to the package system so it + // goes through the same manifest, exports, and per-package + // `_ENV` machinery a third-party package would. The + // sequence is: + // + // 1. Materialize each bundled package (currently just + // `repl`) to a process-stable directory under the OS + // temp dir. See `crate::builtin_packages` for the + // design rationale. + // 2. Push the resulting `InstalledPackage` records onto + // the `InstalledPackages` roster slot held in the + // Lua VM's app-data, so the M7.7 searcher finds them. + // 3. Drive the load via `pmacs.packages.load("repl")` so + // the load goes through the boundary `pmacs.packages` + // function (which catches load-time errors and routes + // them to *errors*) rather than a bare `require`. + // // Depends on `pmacs.buffer.add_intercept` (T M6.4 Stage 1) // and `pmacs.ansi.parser()` (T M6.4 Stage 2), both available - // by the time `install` returns above. - lua_host - .eval( - Some("@pmacs/builtin/runtime/repl.lua"), - include_str!("../builtin/runtime/repl.lua"), - ) - .expect("load repl builtin chunk"); + // by the time `attach_editor` returns above. + let bundled_root = crate::builtin_packages::bundled_runtime_dir(); + let bundled_packages = crate::builtin_packages::materialize_all(&bundled_root) + .expect("materialize bundled packages"); + { + let slot = lua_host + .lua() + .app_data_ref::() + .expect("InstalledPackages slot installed by attach_editor"); + for pkg in &bundled_packages { + slot.record(pkg.clone()); + } + } + for pkg in &bundled_packages { + let basename = pkg.install_basename().to_string(); + let script = format!( + "if not pmacs.packages.load({basename:?}) then \ + error('bundled package failed to load: ' .. {basename:?}) end" + ); + lua_host + .eval(Some("@pmacs/bundled-load"), &script) + .unwrap_or_else(|e| { + panic!("bundled package `{basename}` failed to load: {e}"); + }); + } // User config is loaded after the builtins so it can override // them. Failures inside `init.lua` are captured into the // `*errors*` buffer; the editor still starts. @@ -247,7 +299,7 @@ impl EditorState { /// tick releases its borrow. Lua subscribers typically own a /// `{[process_id] = handle}` registry and drain events via /// `pmacs.process.events_take(id)`; the REPL package - /// (`builtin/runtime/repl.lua`) is the first such consumer. + /// (`builtin/packages/repl/init.lua`) is the first such consumer. pub fn tick_processes(&mut self) { self.process_supervisor.borrow_mut().tick(); self.lua_host @@ -357,9 +409,16 @@ impl EditorState { return; } + // Buffer-scope keybindings need the active buffer id passed + // through the dispatcher (otherwise `keymap_stack::resolve` + // skips the buffer-local map entirely and every "scope = + // buffer" binding falls through to global). The id is read + // outside the keymap borrow so a single-buffer focus check + // doesn't collide with the stack lookup below. + let active_buffer = Some(self.core.borrow().active_buffer_id()); let action = { let stack = self.lua_host.keymaps().borrow(); - self.dispatcher.dispatch(chord, &stack, None, &[]) + self.dispatcher.dispatch(chord, &stack, active_buffer, &[]) }; // Snapshot the active buffer's edit revision before the command diff --git a/src/help.rs b/src/help.rs index a836060..9d82ab4 100644 --- a/src/help.rs +++ b/src/help.rs @@ -12,6 +12,7 @@ //! //! * `[command: cursor.left]` --- navigate to that command's help. //! * `[key: C-x C-s]` --- describe the chord. +//! * `[key: s @buffer:3]` --- describe a buffer-local chord. //! * `[buffer: *errors*]` --- describe a buffer by name. //! * `[mode: normal]`, `[hook: buffer.before-save]`, `[view: *help*]`. //! @@ -58,7 +59,7 @@ pub fn render_command( let _ = writeln!(text); let _ = writeln!(text, "{}", cmd.description); let _ = writeln!(text); - write_command_bindings(&mut text, &cmd.name, keymaps); + write_command_bindings(registry, &mut text, &cmd.name, keymaps); if cmd.predicate.is_some() { let _ = writeln!(text); let _ = writeln!(text, "Predicate: yes (this command can refuse to run)."); @@ -70,14 +71,22 @@ pub fn render_command( /// Render help for a chord sequence. Returns the help buffer id if /// the sequence resolves to a binding, [`None`] otherwise. +/// +/// `active_buffer` is the buffer scope to consult when resolving +/// the chord sequence. Pass `Some(id)` to surface buffer-local +/// bindings (matching what `dispatch_key` would see) and `None` +/// for global-only resolution. Buffer-scope keys (e.g., +/// `pmacs-magit.stage` bound to `s` on the magit buffer) are +/// invisible without this, which is the M8.7 describe-key gap. pub fn render_key( registry: &mut BufferRegistry, commands: &CommandRegistry, keymaps: &KeymapStack, + active_buffer: Option, sequence: &str, ) -> RenderResult { let chords = parse_sequence(sequence).ok()?; - let resolution = keymaps.resolve(&chords, None, &[]); + let resolution = keymaps.resolve(&chords, active_buffer, &[]); let StackResolution::Bound(rb) = resolution else { return None; }; @@ -198,7 +207,12 @@ fn format_view_text(buf: &Buffer) -> String { // Helpers // --------------------------------------------------------------------------- -fn write_command_bindings(out: &mut String, command: &str, keymaps: &KeymapStack) { +fn write_command_bindings( + registry: &BufferRegistry, + out: &mut String, + command: &str, + keymaps: &KeymapStack, +) { let bindings: Vec<(Scope, Sequence, Binding)> = keymaps .iter_all() .into_iter() @@ -209,16 +223,44 @@ fn write_command_bindings(out: &mut String, command: &str, keymaps: &KeymapStack } else { let _ = writeln!(out, "Bound to:"); for (scope, seq, _) in &bindings { - let _ = writeln!( - out, - " [key: {}] ({})", - display_sequence(seq), - scope.render() - ); + match scope { + Scope::Buffer(id) if registry.contains(*id) => { + let _ = writeln!( + out, + " [key: {} @buffer:{}] ({})", + display_sequence(seq), + id.raw(), + scope.render() + ); + } + _ => { + let _ = writeln!( + out, + " [key: {}] ({})", + display_sequence(seq), + scope.render() + ); + } + } } } } +fn parse_key_target(registry: &BufferRegistry, target: &str) -> Option<(String, Option)> { + let Some((sequence, raw)) = target.rsplit_once(" @buffer:") else { + return Some((target.to_owned(), None)); + }; + let Ok(raw) = raw.trim().parse::() else { + return None; + }; + let id = BufferId::from_raw(raw); + if registry.contains(id) { + Some((sequence.trim().to_owned(), Some(id))) + } else { + None + } +} + fn write_mode_bindings(out: &mut String, map: &Keymap) { let entries: Vec<_> = map.iter().collect(); if entries.is_empty() { @@ -366,7 +408,10 @@ pub fn follow_link_at( let link = link_at(&text, cursor)?; match link.kind.as_str() { "command" => render_command(registry, commands, keymaps, &link.target), - "key" => render_key(registry, commands, keymaps, &link.target), + "key" => { + let (sequence, active_buffer) = parse_key_target(registry, &link.target)?; + render_key(registry, commands, keymaps, active_buffer, &sequence) + } "buffer" => { let id = registry.find_by_name(&link.target)?; render_buffer(registry, id) @@ -476,7 +521,7 @@ mod tests { }, ) .unwrap(); - let id = render_key(&mut reg, &cmds, &kms, "C-x C-s").unwrap(); + let id = render_key(&mut reg, &cmds, &kms, None, "C-x C-s").unwrap(); let body = read_buffer_text(reg.get(id).unwrap()); assert!(body.contains("Key: C-x C-s")); assert!(body.contains("[command: save]")); @@ -488,7 +533,7 @@ mod tests { let mut reg = BufferRegistry::new(); let cmds = CommandRegistry::new(); let kms = KeymapStack::new(); - assert!(render_key(&mut reg, &cmds, &kms, "C-q").is_none()); + assert!(render_key(&mut reg, &cmds, &kms, None, "C-q").is_none()); } #[test] @@ -650,4 +695,40 @@ mod tests { let body = read_help(®); assert!(body.contains("Command: beta"), "{body}"); } + + #[test] + fn follow_link_at_chases_command_to_buffer_local_key() { + let lua = Lua::new(); + let mut reg = BufferRegistry::new(); + let target_buffer = reg.create("magit"); + let mut cmds = CommandRegistry::new(); + let mut kms = KeymapStack::new(); + let hooks = HookRegistry::new(); + cmds.define(make_command(&lua, "pmacs-magit.stage", "Stage item.")) + .unwrap(); + kms.bind_buffer( + target_buffer, + &parse_sequence("s").unwrap(), + "pmacs-magit.stage", + SourceLocation { + file: "magit.lua".into(), + line: 12, + }, + ) + .unwrap(); + + render_command(&mut reg, &cmds, &kms, "pmacs-magit.stage").unwrap(); + let body = read_help(®); + assert!( + body.contains(&format!("[key: s @buffer:{}]", target_buffer.raw())), + "buffer-local key link must carry its buffer scope: {body}" + ); + let cursor = body.find("s @buffer").unwrap() as u64; + let returned = follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap(); + assert_eq!(returned, reg.find_by_name(HELP_BUFFER_NAME).unwrap()); + let body = read_help(®); + assert!(body.contains("Key: s"), "{body}"); + assert!(body.contains("Scope: buffer"), "{body}"); + assert!(body.contains("[command: pmacs-magit.stage]"), "{body}"); + } } diff --git a/src/lib.rs b/src/lib.rs index 97098d7..43822c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,8 +29,10 @@ pub mod async_runtime; pub mod attach; pub mod attach_dispatch; pub mod attach_reconnect; +pub mod audit; pub mod buffer; pub mod buffer_registry; +pub mod builtin_packages; pub mod cell; pub mod command; pub mod completion; @@ -45,6 +47,7 @@ pub mod editor_core; pub mod file_io; pub mod formatting; pub mod frontend; +pub mod fs; pub mod help; pub mod highlight; pub mod hook; @@ -59,6 +62,7 @@ pub mod lsp; pub mod lsp_status; pub mod lua; pub mod lua_bindings; +pub mod lua_isolation; pub mod message_bus; pub mod minibuffer; pub mod overlay; diff --git a/src/lua.rs b/src/lua.rs index 4269923..bd6c20b 100644 --- a/src/lua.rs +++ b/src/lua.rs @@ -88,6 +88,10 @@ pub struct LuaHost { /// stale [`crate::text_view::TextView`] line cache). core: Option, errors: Vec, + /// T M7.8 cancel token. Owns the [`AtomicBool`] the count hook + /// polls. Hosts hand out [`crate::lua_isolation::CancelHandle`] + /// clones for cross-thread C-g delivery. + cancel: crate::lua_isolation::CancelToken, _not_send: PhantomData>, } @@ -125,6 +129,17 @@ impl LuaHost { /// boundary closures fail to register. pub fn with_registry(registry: SharedRegistry) -> mlua::Result { let lua = Lua::new(); + let cancel = crate::lua_isolation::CancelToken::new(); + // T M7.8: install the count hook before any chunk runs so even + // the first eval is interruptible. The hook closure captures + // an `Arc` clone of `cancel`'s flag; subsequent + // `cancel.cancel()` / `cancel.handle().cancel()` calls are + // observed within `DEFAULT_INSTRUCTION_BUDGET` instructions. + crate::lua_isolation::install_cancel_hook( + &lua, + &cancel, + crate::lua_isolation::DEFAULT_INSTRUCTION_BUDGET, + ); let commands: SharedCommandRegistry = Rc::new(RefCell::new(CommandRegistry::new())); let keymaps: SharedKeymapStack = Rc::new(RefCell::new(KeymapStack::new())); let hooks: SharedHookRegistry = Rc::new(RefCell::new(HookRegistry::new())); @@ -137,6 +152,7 @@ impl LuaHost { hooks, core: None, errors: Vec::new(), + cancel, _not_send: PhantomData, }) } @@ -147,6 +163,28 @@ impl LuaHost { &self.lua } + /// Cross-thread handle for flipping this VM's cancel flag. + /// + /// The returned [`crate::lua_isolation::CancelHandle`] is + /// `Send + Sync` and may be moved or cloned to other threads + /// (e.g. an input-watching thread that maps C-g to a cancel + /// request). The next time the count hook runs in the VM (within + /// [`crate::lua_isolation::DEFAULT_INSTRUCTION_BUDGET`] + /// instructions on lua54; see the LuaJIT-trace caveat in + /// [`crate::lua_isolation`]) the running chunk aborts with an + /// [`crate::lua_isolation::IsolationError::Cancelled`]. + #[must_use] + pub fn cancel_handle(&self) -> crate::lua_isolation::CancelHandle { + self.cancel.handle() + } + + /// Flip this VM's cancel flag in-process. Equivalent to + /// `self.cancel_handle().cancel()` for callers that already hold + /// `&self`. + pub fn request_cancel(&self) { + self.cancel.cancel(); + } + /// Shared handle to the buffer registry. Both Rust callers (e.g. the /// editor's file-open path) and Lua bindings (via app data) read and /// mutate this through the same `Rc>`. @@ -221,7 +259,16 @@ impl LuaHost { let snapshot = self.hooks.borrow().snapshot(name); let (kind, callbacks) = snapshot?; let outcome = crate::hook::run_snapshot(kind, &callbacks, args); + // T M7.8: if any callback observed a cancellation, the flag + // is still set — reset before the next eval. (Callbacks + // dispatched after the first cancel observed the still-set + // flag and aborted as well; that matches the user-intent + // semantics of C-g during a hook fan-out.) + let mut saw_cancel = false; for err in &outcome.errors { + if crate::lua_isolation::is_cancellation(&err.error) { + saw_cancel = true; + } let record = LuaErrorRecord { at: SystemTime::now(), source: Some(format!("hook:{name}")), @@ -230,6 +277,9 @@ impl LuaHost { self.append_to_errors_buffer(&record); self.errors.push(record); } + if saw_cancel { + self.cancel.reset(); + } Some(outcome) } @@ -261,7 +311,16 @@ impl LuaHost { .body .clone() }; - body.call::(args) + match body.call::(args) { + Ok(v) => Ok(v), + Err(e) => { + // T M7.8: consume the cancel signal once. + if crate::lua_isolation::is_cancellation(&e) { + self.cancel.reset(); + } + Err(e) + } + } } /// Evaluate a Lua chunk and return the resulting value. @@ -293,6 +352,14 @@ impl LuaHost { match loader.eval::() { Ok(v) => Ok(v), Err(e) => { + // T M7.8: a cancellation is consumed exactly once. + // Reset the flag here so the *next* eval starts + // fresh. If we left it set, the very first hook tick + // of the next chunk would abort it without anyone + // having asked. + if crate::lua_isolation::is_cancellation(&e) { + self.cancel.reset(); + } let record = LuaErrorRecord { at: SystemTime::now(), source: source.map(str::to_owned), @@ -355,6 +422,33 @@ impl LuaHost { self.registry.borrow().find_by_name(ERRORS_BUFFER_NAME) } + /// Snapshot the full text of the `*errors*` buffer as a UTF-8 + /// string (lossy on non-UTF-8 bytes — error messages are routinely + /// concatenations of arbitrary user data). + /// + /// Returns the empty string if the buffer hasn't been created + /// yet. Used by tests and any introspection tool that needs the + /// canonical error log without going through the buffer registry + /// directly. Does not consume or clear the buffer. + #[must_use] + pub fn errors_buffer_text(&self) -> String { + let Some(id) = self.errors_buffer_id() else { + return String::new(); + }; + let reg = self.registry.borrow(); + let Ok(buf) = reg.get(id) else { + return String::new(); + }; + let rope = buf.snapshot_rope(); + let len = rope.len(); + if len == 0 { + return String::new(); + } + let mut bytes = vec![0u8; usize::try_from(len).unwrap_or(usize::MAX)]; + rope.slice(0, len, &mut bytes); + String::from_utf8_lossy(&bytes).into_owned() + } + /// All captured errors, in arrival order. pub fn errors(&self) -> &[LuaErrorRecord] { &self.errors @@ -391,6 +485,20 @@ impl LuaHost { } } + /// Re-open the init phase (test/dev only). Counterpart to + /// [`Self::set_init_complete`]: integration tests that exercise + /// init-only Lua APIs against a fully-constructed + /// [`crate::editor::EditorState`] use this to reset the flag + /// the editor flips during startup. Marked `#[doc(hidden)]` to + /// keep it out of the user-facing surface; production code + /// never re-opens init phase after a single startup flip. + #[doc(hidden)] + pub fn reopen_init_phase_for_testing(&self) { + if let Some(flag) = self.lua.app_data_ref::() { + flag.reopen_for_testing(); + } + } + /// Whether the init phase has finished. Mirrors the /// [`InitCompleteFlag`] state for callers that need to introspect /// without going through Lua app data themselves. diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index a6e7ca0..8e140de 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -39,6 +39,7 @@ //! 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}; @@ -60,7 +61,7 @@ use crate::key::{display_sequence, parse_sequence}; use crate::keymap_stack::KeymapStack; use crate::packages::{ Address, Fetcher, InstallError, InstallPin, InstallScope, InstallSpec, InstalledPackage, - Installer, + Installer, LookupOutcome, ResolvedKind, lookup_in_roster, }; use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity}; use crate::rope::Range; @@ -125,6 +126,19 @@ impl InitCompleteFlag { 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 { @@ -285,11 +299,13 @@ impl Default for LocalInstanceInfo { /// 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. +/// 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>>); @@ -300,11 +316,50 @@ impl InstalledPackages { Self::default() } - /// Record a successful install. Order matches install order; that - /// matters for diagnostics ("which install errored?") more than - /// for resolution. + /// 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) { - self.0.borrow_mut().push(pkg); + 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. @@ -314,6 +369,114 @@ impl InstalledPackages { } } +/// 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. /// @@ -664,16 +827,126 @@ pub enum BindingError { #[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. + /// 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 is implemented in M7.6 (lockfile + \ - resolver). v0.1 / current builds: re-run `pmacs.packages.install` \ - with the new constraint to upgrade in place." + "pmacs.packages.update: no top-level packages in lockfile to update. \ + Run `pmacs.packages.install` first." )] - PackagesUpdateUnsupported, + 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 @@ -1020,19 +1293,27 @@ fn checked_range(start: i64, end: i64) -> mlua::Result { /// `kind` is one of `"insert"`, `"delete"`, `"replace"`. Position /// fields: /// -/// - `kind = "insert"`: `pos: integer`, `bytes_len: integer` +/// - `kind = "insert"`: `pos: integer`, `bytes: string`, `bytes_len: integer` /// - `kind = "delete"`: `start: integer`, `end: integer` -/// - `kind = "replace"`: `start: integer`, `end: integer`, `bytes_len: integer` +/// - `kind = "replace"`: `start: integer`, `end: integer`, `bytes: string`, `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. +/// `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: /// @@ -1098,12 +1379,19 @@ impl crate::view::View for LuaInterceptView { } /// 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 } => { @@ -1115,6 +1403,7 @@ fn build_intercept_input(lua: &Lua, op: &EditOp<'_>) -> mlua::Result
{ 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))?; } } @@ -1416,6 +1705,8 @@ pub fn install( 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()); let pmacs = lua.create_table()?; pmacs.set("buffer", install_buffer_module(lua, registry)?)?; @@ -2010,15 +2301,37 @@ fn install_ansi_module(lua: &Lua) -> mlua::Result
{ /// 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). +/// - `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()?; @@ -2059,10 +2372,125 @@ fn install_packages_module(lua: &Lua) -> mlua::Result
{ packages.set( "update", - lua.create_function(|_, _args: Variadic| -> mlua::Result<()> { - Err(mlua::Error::external( - BindingError::PackagesUpdateUnsupported, - )) + 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) })?, )?; @@ -2075,34 +2503,43 @@ fn install_packages_module(lua: &Lua) -> mlua::Result
{ /// `package.loaders` (Lua 5.1, `LuaJIT`) that consults the /// [`InstalledPackages`] roster at require time. /// -/// # Why +/// # Three responsibilities (T M7.7) /// -/// `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. +/// 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 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. +/// 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. /// -/// 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. +/// 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 /// @@ -2120,51 +2557,289 @@ fn register_package_searcher(lua: &Lua) -> mlua::Result<()> { 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). + // 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(); - // 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; + + 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)) } - 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)); + 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(), + }, + )); } - }; - let chunk_name = format!("@{}", entry.display()); - let func = lua.load(&bytes).set_name(&chunk_name).into_function()?; - return Ok(mlua::Value::Function(func)); + 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, + })), } - // 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. + // 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(); - searchers.set(len + 1, searcher)?; + 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: @@ -2344,47 +3019,693 @@ fn current_eval_dir(lua: &Lua) -> Option { } /// 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. +/// `$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. /// -/// 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`). +/// 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); - let fetcher = match cache_override { + // 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.commit.clone()), + } + }; + let installed = installer + .install_at_commit(&install_spec, &rp.commit) + .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.commit.clone())); + let install_spec = InstallSpec { + address: rp.address.clone(), + pin: pin_to_use, + }; + let installed = installer + .replace_at_commit(&install_spec, &rp.commit) + .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, scope.clone()); - if let (InstallScope::User, Some(root)) = (scope, user_root_override) { + let mut installer = Installer::new(fetcher, InstallScope::User); + if let Some(root) = user_root_override { installer = installer.with_install_root_override(root); } - let installed = installer - .install(spec) + + // 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)))?; - // Extend package.path so the package's entry module is requireable. + // 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)?; } - // Record in the in-memory roster. 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` @@ -2409,6 +3730,23 @@ fn prepend_package_path(lua: &Lua, root: &std::path::Path) -> mlua::Result<()> { 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
{ @@ -2618,6 +3956,44 @@ fn install_command_module(lua: &Lua, commands: &SharedCommandRegistry) -> mlua:: )?; } + { + // `pmacs.command.unregister(name)` is the inverse of `define`. + // It exists for the M8.1 dev-loop story: a package that defines + // commands at top level cannot be reloaded otherwise (the second + // chunk run hits `DuplicateName`). Packages call this from their + // `pmacs.packages.on_unload` hook to drop ownership before the + // chunk is re-executed. + // + // Not init-phase gated. `define` is also not gated (the REPL, + // audit-lint, and packages all register commands during normal + // operation), and `unregister` is its symmetric inverse: + // both are registry-CRUD, not the attach/detach-style + // lifecycle calls that the init-phase gate exists for. + // Crucially, `pmacs.packages.reload(name)` is itself not + // init-gated --- the dev-loop story is "edit, save, reload" at + // any time --- so an init-gated `unregister` would break every + // package that defines commands and tries to clean them up + // from `on_unload` post-init. + // + // Returns `true` if a command was removed, `false` if `name` + // wasn't registered. We chose return-bool over erroring on + // `NotFound` so package authors can write idempotent teardown + // (`pcall` works too, but `if pmacs.command.exists(...)` reads + // worse than the natural drop-and-ignore). + let cmds = commands.clone(); + command.set( + "unregister", + lua.create_function(move |_, name: String| -> mlua::Result { + let mut r = cmds.borrow_mut(); + match r.remove(&name) { + Ok(_) => Ok(true), + Err(CommandError::NotFound { .. }) => Ok(false), + Err(e) => Err(mlua::Error::external(e)), + } + })?, + )?; + } + Ok(command) } @@ -2663,11 +4039,18 @@ fn install_help_module( help_t.set( "show_key", lua.create_function(move |lua, sequence: String| { + // Resolve against the active window's buffer so + // help.show_key surfaces buffer-local bindings --- + // mirrors the same fix as pmacs.describe.key (M8.8 + // audit finding 1). + let active_buffer = lua + .app_data_ref::() + .map(|core| core.borrow().active_buffer_id()); let result = { let mut r = reg.borrow_mut(); let c = cmds.borrow(); let k = kms.borrow(); - help::render_key(&mut r, &c, &k, &sequence) + help::render_key(&mut r, &c, &k, active_buffer, &sequence) }; if let Some(id) = result { rebuild_help_buffer_views(lua, id); @@ -2938,6 +4321,39 @@ fn log_hook_error(lua: &Lua, hook_name: &str, err: &crate::hook::HookCallbackErr } } +/// T M7.8: append a `[package ]` entry to `*errors*`. +/// +/// Mirrors `log_hook_error`'s implementation. Used by +/// `pmacs.packages.load` so a single failing package's error lands in +/// the canonical sink without abandoning the rest of the load list. +fn log_package_load_error(lua: &Lua, package: &str, err: &mlua::Error) { + let line = format!("[package {package}] load failed: {err}\n"); + let result = { + let Some(app) = lua.app_data_ref::() else { + return; + }; + let mut reg = app.borrow_mut(); + let id = match reg.find_by_name(crate::lua::ERRORS_BUFFER_NAME) { + Some(id) => id, + None => reg.create(crate::lua::ERRORS_BUFFER_NAME), + }; + let Ok(buf) = reg.get_mut(id) else { + return; + }; + let pos = buf.len(); + let edit = buf + .apply_edit(EditOp::Insert { + pos, + bytes: line.as_bytes(), + }) + .ok(); + edit.map(|e| (id, e)) + }; + if let Some((id, edit)) = result { + notify_buffer_edit_to_windows(lua, id, &edit); + } +} + fn install_describe_module( lua: &Lua, registry: &SharedRegistry, @@ -2970,11 +4386,20 @@ fn install_describe_module( "key", lua.create_function(move |lua, sequence: String| { let chords = parse_sequence(&sequence).map_err(mlua::Error::external)?; - // Active scopes are an editor-runtime concept; describe.key - // currently consults global only. Once the editor wires its - // active buffer/modes through, we'll thread them in. + // Resolve against the active window's buffer scope so + // buffer-local bindings (`scope = "buffer"`) actually + // surface --- without this, a `pmacs-magit.stage` + // binding on the magit buffer is invisible to + // describe-key, even when the user is sitting on + // that buffer with their cursor. `&[]` for the + // mode list mirrors what `dispatch_key` passes + // today (no mode system yet); when modes land, both + // call sites update together. + let active_buffer = lua + .app_data_ref::() + .map(|core| core.borrow().active_buffer_id()); let km = kms.borrow(); - let r = km.resolve(&chords, None, &[]); + let r = km.resolve(&chords, active_buffer, &[]); match r { crate::keymap_stack::StackResolution::Bound(rb) => { let cmds = cmds.borrow(); @@ -3327,6 +4752,24 @@ fn grep_spec_from_table(t: &Table) -> mlua::Result { /// callback sees. The shape is per-variant: `U64` becomes a Lua /// integer; `Match` becomes a `{ file, line, text, match_start, /// match_end }` table. New variants extend this match exhaustively. +/// Convert a [`crate::fs::FsDirEntry`] into the Lua-side table +/// `pmacs.fs.read_dir`'s callers consume. The shape pins the v0.1 +/// keys --- adding fields is non-breaking, removing or renaming +/// them is a breaking change for every dired/wdired-class package. +fn fs_dir_entry_to_lua(lua: &Lua, entry: &crate::fs::FsDirEntry) -> mlua::Result
{ + let t = lua.create_table_with_capacity(0, 7)?; + t.set("name", entry.name.as_str())?; + t.set("kind", entry.kind.as_str())?; + t.set("size", i64::try_from(entry.size).unwrap_or(i64::MAX))?; + t.set("mtime", entry.mtime_secs)?; + t.set("mtime_nsec", i64::from(entry.mtime_nsec))?; + t.set("mode", i64::from(entry.mode))?; + if let Some(target) = &entry.symlink_target { + t.set("symlink_target", target.as_str())?; + } + Ok(t) +} + fn stream_payload_to_lua(lua: &Lua, payload: StreamPayload) -> mlua::Result { match payload { StreamPayload::U64(v) => Ok(mlua::Value::Integer(i64::try_from(v).unwrap_or(i64::MAX))), @@ -3414,6 +4857,68 @@ pub fn install_async( )?; } + { + let rt = runtime.clone(); + async_mod.set( + "_dispatch_fs_read_dir", + lua.create_function(move |_, (path, key): (String, Option)| { + Ok(rt.dispatch_fs_read_dir(std::path::PathBuf::from(path), key.as_deref())) + })?, + )?; + } + + { + let rt = runtime.clone(); + async_mod.set( + "_dispatch_fs_stat", + lua.create_function(move |_, (path, key): (String, Option)| { + Ok(rt.dispatch_fs_stat(std::path::PathBuf::from(path), key.as_deref())) + })?, + )?; + } + + // Mutating fs raw dispatchers: no supersede parameter exposed + // at the Lua surface. The Rust runtime methods still accept + // `Option<&str>` for symmetry, but pmacs._async hard-codes + // `None` so packages reaching for the raw bindings can't + // bypass the no-supersede-on-mutation safety decision the + // wrappers in `builtin/runtime/fs.lua` document. Rationale: + // a "cancelled" syscall may have already run and changed + // disk; supersede semantics are misleading for ops that mutate. + { + let rt = runtime.clone(); + async_mod.set( + "_dispatch_fs_rename", + lua.create_function(move |_, (from, to): (String, String)| { + Ok(rt.dispatch_fs_rename( + std::path::PathBuf::from(from), + std::path::PathBuf::from(to), + None, + )) + })?, + )?; + } + + { + let rt = runtime.clone(); + async_mod.set( + "_dispatch_fs_chmod", + lua.create_function(move |_, (path, mode): (String, u32)| { + Ok(rt.dispatch_fs_chmod(std::path::PathBuf::from(path), mode, None)) + })?, + )?; + } + + { + let rt = runtime.clone(); + async_mod.set( + "_dispatch_fs_remove", + lua.create_function(move |_, path: String| { + Ok(rt.dispatch_fs_remove(std::path::PathBuf::from(path), None)) + })?, + )?; + } + { let rt = runtime.clone(); async_mod.set( @@ -3441,16 +4946,24 @@ pub fn install_async( Some(JobOutcome::Failed(msg)) => { ("failed", mlua::Value::String(lua.create_string(&msg)?)) } - // Unit, Parse, and "no recorded outcome" all - // surface as a clean ok-with-nil to Lua; - // streams that close without an explicit - // outcome (the typical emit_n case) are + // Unit, Parse, ReadDir, and "no recorded + // outcome" all surface as a clean + // ok-with-nil to Lua. Streams that close + // without an explicit outcome (the + // typical emit_n case) are // indistinguishable from ones that - // returned `Unit`. Parse jobs aren't - // streams in M4.1 but the arm is here - // for exhaustiveness. + // returned `Unit`. Parse and ReadDir + // jobs aren't streams; the arms are + // here for exhaustiveness, since a + // settled non-stream job's outcome + // could in principle reach this branch + // if the runtime were extended to ship + // a stream-closed reply for them. Some(JobOutcome::Complete( - JobResult::Unit | JobResult::Parse { .. }, + JobResult::Unit + | JobResult::Parse { .. } + | JobResult::ReadDir(_) + | JobResult::Stat(_), )) | None => ("ok", mlua::Value::Nil), }; @@ -3554,6 +5067,24 @@ pub fn install_async( i64::try_from(duration_ms).unwrap_or(i64::MAX), )); } + Some(JobOutcome::Complete(JobResult::ReadDir(entries))) => { + // Lua surface for fs.read_dir settle: + // status "ok", value = array of per-entry + // tables. T M8.1. + out.push_back(mlua::Value::String(lua.create_string("ok")?)); + let t = lua.create_table_with_capacity(entries.len(), 0)?; + for (i, entry) in entries.into_iter().enumerate() { + t.set(i + 1, fs_dir_entry_to_lua(lua, &entry)?)?; + } + out.push_back(mlua::Value::Table(t)); + } + Some(JobOutcome::Complete(JobResult::Stat(entry))) => { + // Lua surface for fs.stat settle: status + // "ok", value = single per-entry table + // (same shape as a read_dir entry). T M8.1. + out.push_back(mlua::Value::String(lua.create_string("ok")?)); + out.push_back(mlua::Value::Table(fs_dir_entry_to_lua(lua, &entry)?)); + } Some(JobOutcome::Cancelled) => { out.push_back(mlua::Value::String(lua.create_string("cancelled")?)); out.push_back(mlua::Value::Nil); @@ -3681,6 +5212,13 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res "ok", mlua::Value::Integer(i64::try_from(*duration_ms).unwrap_or(i64::MAX)), ), + JobOutcome::Complete(JobResult::ReadDir(entries)) => ( + "ok", + mlua::Value::Integer(i64::try_from(entries.len()).unwrap_or(i64::MAX)), + ), + JobOutcome::Complete(JobResult::Stat(entry)) => { + ("ok", mlua::Value::String(lua.create_string(&entry.name)?)) + } JobOutcome::Cancelled => ("cancelled", mlua::Value::Nil), JobOutcome::Failed(msg) => ("failed", mlua::Value::String(lua.create_string(msg)?)), }; @@ -8583,6 +10121,84 @@ mod tests { assert_eq!(len, 3); } + #[test] + fn m8_3_intercept_input_carries_inserted_bytes() { + // M8.3 enhancement: the intercept input table carries the + // proposed insert/replace bytes verbatim as a Lua string. + // The wdired layer relies on this to validate permission- + // column edits against the rwx alphabet without waiting for + // the chmod syscall. + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let observed: String = lua + .load( + r#" + local id = pmacs.buffer.create("scratch") + local seen = nil + pmacs.buffer.add_intercept(id, function(op) + if op.kind == "insert" then seen = op.bytes end + return nil + end) + id:insert(0, "hello") + return seen + "#, + ) + .eval() + .unwrap(); + assert_eq!(observed, "hello"); + } + + #[test] + fn m8_3_intercept_input_bytes_round_trip_non_utf8() { + // Lua strings are byte-clean; surfacing bytes as a Lua string + // must preserve arbitrary 8-bit content, not coerce to UTF-8. + // The dired-class package handles non-UTF-8 filename bytes + // (POSIX permits any byte except `/` and NUL). + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + // Insert two bytes: 0xC3 0x28, which is *not* a valid UTF-8 + // sequence (0xC3 starts a 2-byte form; 0x28 is below the + // 0x80..0xBF continuation range). + let observed_len: i64 = lua + .load( + r#" + local id = pmacs.buffer.create("scratch") + local seen_len = nil + pmacs.buffer.add_intercept(id, function(op) + if op.kind == "insert" then seen_len = #op.bytes end + return nil + end) + id:insert(0, string.char(0xC3, 0x28)) + return seen_len + "#, + ) + .eval() + .unwrap(); + assert_eq!(observed_len, 2, "non-UTF-8 bytes must round-trip 1:1"); + } + + #[test] + fn m8_3_intercept_input_bytes_for_replace() { + // Replace ops also carry the *incoming* bytes (not the bytes + // being overwritten). + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let observed: String = lua + .load( + r#" + local id = pmacs.buffer.create("scratch") + id:insert(0, "AAA") + local seen = nil + pmacs.buffer.add_intercept(id, function(op) + if op.kind == "replace" then seen = op.bytes end + return nil + end) + id:replace(0, 3, "ZZ") + return seen + "#, + ) + .eval() + .unwrap(); + assert_eq!(observed, "ZZ"); + } + #[test] fn m6_4_intercept_reject_via_error_propagates_to_lua() { // An intercept that raises an error stops the edit; the Rust @@ -9292,6 +10908,63 @@ mod tests { ); } + #[test] + fn command_unregister_then_redefine_round_trips() { + // Regression for the M8.2 reproducibility/reload finding: + // packages that define commands at top level need an inverse + // of `define` so re-running their chunk doesn't hit + // DuplicateName. + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let result: String = lua + .load( + r#" + pmacs.command.define { name = "x", description = "v1", fn = function() return "v1" end } + local removed = pmacs.command.unregister("x") + assert(removed == true, "first unregister must report removed") + pmacs.command.define { name = "x", description = "v2", fn = function() return "v2" end } + return pmacs.command.invoke("x") + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "v2"); + } + + #[test] + fn command_unregister_unknown_returns_false() { + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let result: bool = lua + .load(r#"return pmacs.command.unregister("nope")"#) + .eval() + .unwrap(); + assert!(!result); + } + + #[test] + fn command_unregister_works_post_init_phase() { + // unregister is registry CRUD, symmetric with define (which + // also runs post-init). Reload itself isn't init-gated, so a + // gate here would break the dev-loop for any package that + // defines commands and tries to clean them up from + // pmacs.packages.on_unload running post-startup. + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + lua.load(r#"pmacs.command.define { name = "x", description = "y", fn = function() end }"#) + .exec() + .unwrap(); + // Flip init-complete the way EditorState::new does in production. + lua.app_data_ref::() + .expect("init flag installed by fresh()") + .set_complete(); + let removed: bool = lua + .load(r#"return pmacs.command.unregister("x")"#) + .eval() + .unwrap(); + assert!( + removed, + "unregister must succeed after init-complete (parity with define)" + ); + } + #[test] fn command_list_returns_insertion_order() { let (lua, _reg, _cmds, _kms, _hks) = fresh(); diff --git a/src/packages/address.rs b/src/packages/address.rs index 409f3cf..ad1d913 100644 --- a/src/packages/address.rs +++ b/src/packages/address.rs @@ -2,12 +2,14 @@ //! Address parsing (T M7.2, spec §sec:packages-future). //! -//! v1.0 ships three address forms: +//! v1.0 ships four address forms: //! //! - `github:owner/repo` --- sugar that expands to //! `https://github.com/owner/repo.git`. The `.git` suffix is //! tolerated; `github:owner/repo.git` is accepted and treated as //! equivalent. +//! - `gitlab:owner/repo` --- the same sugar against `gitlab.com`. A +//! self-hosted GitLab instance is reachable via the `git:` form. //! - `git:` --- the prefix is stripped and whatever remains is //! passed to `git clone` as-is. This intentionally accepts anything //! `git clone` accepts: full URLs (`https://`, `ssh://`, `file://`, @@ -22,11 +24,12 @@ //! //! ## Forge aliases (deferred) //! -//! `gitlab:`, `codeberg:`, and `forgejo:` were considered for v1.0 and -//! deferred to a post-v1.0 patch release driven by user demand (see -//! T M7.2 box in `pmacs-tasks.tex`). Inputs starting with these -//! prefixes return [`AddressError::DeferredAlias`], whose message names -//! the alias and points at the `git:URL` fallback. +//! `codeberg:` and `forgejo:` were considered for v1.0 and deferred +//! to a post-v1.0 patch release driven by user demand. The +//! extension path is the same one match arm `gitlab:` takes below; +//! see T M7.2 box in `pmacs-tasks.tex`. Inputs starting with these +//! prefixes return [`AddressError::DeferredAlias`], whose message +//! names the alias and points at the `git:URL` fallback. //! //! ## Authentication //! @@ -36,6 +39,7 @@ //! does not handle credentials; it only produces the URL string that //! `git clone` will eventually receive. +use serde::{Deserialize, Serialize}; use thiserror::Error; // --------------------------------------------------------------------------- @@ -44,9 +48,10 @@ use thiserror::Error; /// A parsed package address. /// -/// Two variants in v1.0: a special-cased GitHub form (because it's the -/// most common) and an opaque URL form (everything else). -#[derive(Debug, Clone, Eq, PartialEq)] +/// Three variants in v1.0: a special-cased GitHub form (the most +/// common), a special-cased GitLab.com form (the second most +/// common), and an opaque URL form (everything else). +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub enum Address { /// `github:owner/repo` sugar. Github { @@ -56,6 +61,18 @@ pub enum Address { /// time but stores the bare name. repo: String, }, + /// `gitlab:owner/repo` sugar against `gitlab.com`. Self-hosted + /// GitLab instances must use the `git:` form with a full URL. + /// v1.0 admits a single owner segment (user or top-level group); + /// nested subgroups (`gitlab:group/subgroup/repo`) are out of + /// scope for the sugar and need the explicit + /// `git:https://gitlab.com/group/subgroup/repo.git` form. + Gitlab { + /// Repository owner (user or top-level group). + owner: String, + /// Repository name (with optional `.git` suffix tolerated). + repo: String, + }, /// Any clone-cloneable URL or shorthand. Stored as-is; passed to /// `git clone` verbatim. Url(String), @@ -92,6 +109,15 @@ impl Address { return parse_github(rest, s); } + // 2a. gitlab:owner/repo (with optional .git suffix). Same + // shape as the github sugar; the only difference is the + // expanded clone URL host. Self-hosted GitLab instances are + // not addressable via this prefix --- they must use the + // generic `git:https://gitlab.example/...` form. + if let Some(rest) = s.strip_prefix("gitlab:") { + return parse_gitlab(rest, s); + } + // 3. git: --- pass-through. Whatever follows is fed to // `git clone` as-is. Accepts SSH shorthand, file URLs, and // arbitrary clone targets. Validation that the target is @@ -133,43 +159,65 @@ impl Address { Self::Github { owner, repo } => { format!("https://github.com/{owner}/{repo}.git") } + Self::Gitlab { owner, repo } => { + format!("https://gitlab.com/{owner}/{repo}.git") + } Self::Url(u) => u.clone(), } } } -const DEFERRED_ALIASES: &[&str] = &["gitlab:", "codeberg:", "forgejo:"]; +const DEFERRED_ALIASES: &[&str] = &["codeberg:", "forgejo:"]; fn parse_github(rest: &str, original: &str) -> Result { - // Tolerate trailing `.git` --- users will type it by habit. + let (owner, repo) = parse_forge_pair( + rest, + original, + AddressError::InvalidGithub { + input: original.to_string(), + }, + )?; + Ok(Address::Github { owner, repo }) +} + +fn parse_gitlab(rest: &str, original: &str) -> Result { + let (owner, repo) = parse_forge_pair( + rest, + original, + AddressError::InvalidGitlab { + input: original.to_string(), + }, + )?; + Ok(Address::Gitlab { owner, repo }) +} + +/// Shared parser for the `:/` sugar. Tolerates a +/// trailing `.git` (users type it by habit) and rejects obviously +/// malformed inputs (missing slash, extra segment, suspicious +/// characters). Conservative `[A-Za-z0-9_.-]` character class +/// covers every realistic case; wider sets can be admitted later +/// if a real package surfaces a rejection. +fn parse_forge_pair( + rest: &str, + _original: &str, + err: AddressError, +) -> Result<(String, String), AddressError> { let body = rest.strip_suffix(".git").unwrap_or(rest); let mut parts = body.split('/'); let owner = parts.next().unwrap_or(""); let repo = parts.next().unwrap_or(""); if owner.is_empty() || repo.is_empty() || parts.next().is_some() { - return Err(AddressError::InvalidGithub { - input: original.to_string(), - }); + return Err(err); } - // Conservative character validation: GitHub itself allows a wider - // set, but accepting only `[A-Za-z0-9_.-]` covers every realistic - // case and rejects obvious typos (slashes inside segments, etc.) - // without spec churn. Wider sets can be admitted later if a real - // package surfaces a rejection. for seg in [owner, repo] { if !seg .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.') { - return Err(AddressError::InvalidGithub { - input: original.to_string(), - }); + return Err(err); } } - Ok(Address::Github { - owner: owner.to_string(), - repo: repo.to_string(), - }) + Ok((owner.to_string(), repo.to_string())) } // --------------------------------------------------------------------------- @@ -193,6 +241,12 @@ pub enum AddressError { /// The offending input. input: String, }, + /// `gitlab:owner/repo` form was malformed. + #[error("invalid gitlab address `{input}`: expected `gitlab:owner/repo`")] + InvalidGitlab { + /// The offending input. + input: String, + }, /// `git:` prefix was followed by an empty body. #[error("empty git target in `{input}`: expected `git:`")] EmptyGitTarget { @@ -205,16 +259,16 @@ pub enum AddressError { /// The offending input. input: String, }, - /// Address used a forge-alias prefix that v1.0 deferred (gitlab:, - /// codeberg:, forgejo:). The message points at the `git:URL` - /// fallback so the user knows what to type instead. + /// Address used a forge-alias prefix that v1.0 deferred + /// (`codeberg:`, `forgejo:`). The message points at the + /// `git:URL` fallback so the user knows what to type instead. #[error( "address scheme `{alias}` is deferred for v1.0; \ use `git:` instead (e.g. `git:https://gitlab.com/owner/repo.git`). \ Offending input: `{input}`" )] DeferredAlias { - /// The deferred alias prefix (e.g. `"gitlab:"`). + /// The deferred alias prefix (e.g. `"codeberg:"`). alias: String, /// The full offending input. input: String, @@ -222,7 +276,8 @@ pub enum AddressError { /// Address did not match any v1.0 scheme. #[error( "unknown address scheme in `{input}`; \ - expected `github:owner/repo`, `git:`, `https://...`, or `git://...`" + expected `github:owner/repo`, `gitlab:owner/repo`, \ + `git:`, `https://...`, or `git://...`" )] UnknownScheme { /// The offending input. @@ -373,23 +428,55 @@ mod tests { assert!(matches!(err, AddressError::MalformedHttps { .. })); } - // -- Forge aliases rejected with helpful pointer ------------------------ + // -- gitlab sugar ------------------------------------------------------- #[test] - fn gitlab_alias_rejected_with_pointer_to_git_fallback() { - let err = Address::parse("gitlab:owner/repo").unwrap_err(); - let msg = err.to_string(); - assert!(matches!(err, AddressError::DeferredAlias { .. })); - assert!( - msg.contains("gitlab:"), - "error should name the alias: {msg}" - ); - assert!( - msg.contains("git:"), - "error should point at fallback: {msg}" + fn gitlab_simple_form_parses() { + let a = Address::parse("gitlab:user/repo").unwrap(); + assert_eq!( + a, + Address::Gitlab { + owner: "user".into(), + repo: "repo".into(), + } ); } + #[test] + fn gitlab_to_git_url_canonicalizes_to_gitlab_com() { + let a = Address::parse("gitlab:user/repo").unwrap(); + assert_eq!(a.to_git_url(), "https://gitlab.com/user/repo.git"); + } + + #[test] + fn gitlab_dot_git_suffix_tolerated() { + let a = Address::parse("gitlab:user/repo.git").unwrap(); + assert_eq!( + a, + Address::Gitlab { + owner: "user".into(), + repo: "repo".into(), + } + ); + assert_eq!(a.to_git_url(), "https://gitlab.com/user/repo.git"); + } + + #[test] + fn gitlab_rejects_missing_slash() { + let err = Address::parse("gitlab:user").unwrap_err(); + assert!(matches!(err, AddressError::InvalidGitlab { .. })); + } + + #[test] + fn gitlab_rejects_extra_segment() { + // Subgroups (`group/subgroup/repo`) aren't supported by the + // sugar; users with subgroups go through `git:https://...`. + let err = Address::parse("gitlab:group/sub/repo").unwrap_err(); + assert!(matches!(err, AddressError::InvalidGitlab { .. })); + } + + // -- Forge aliases rejected with helpful pointer ------------------------ + #[test] fn codeberg_alias_rejected_with_pointer_to_git_fallback() { let err = Address::parse("codeberg:owner/repo").unwrap_err(); diff --git a/src/packages/installer.rs b/src/packages/installer.rs index b8d9e6a..8def8a9 100644 --- a/src/packages/installer.rs +++ b/src/packages/installer.rs @@ -23,19 +23,30 @@ //! deferred-dispatcher pattern used for `pmacs.attach` does not //! apply --- attach is a transport handoff, install is just I/O, //! and a synchronous failure pins the offending `init.lua` line. -//! - **No transitive resolution**: each spec is resolved and -//! installed independently. Dependency closure / lockfile come in -//! T M7.5 / T M7.6. -//! - **Tag-only resolution**: we pick the highest-numbered semver tag -//! that satisfies the constraint. Branch / commit pinning is the -//! resolver's job in M7.5; for v0.1 the install API only takes -//! `version` constraints, which by definition target tags. +//! - **Standalone vs resolver-driven**. The installer has two +//! entry points. [`Installer::install`] is standalone: it +//! independently picks a tag for [`InstallPin::Version`] via +//! [`best_match`] and refuses to overwrite an existing install +//! at a different commit. [`Installer::install_at_commit`] / +//! [`Installer::replace_at_commit`] are the resolver-driven +//! paths: they trust the supplied commit and (for replace) +//! stage-and-swap an existing install. The Lua surface +//! (`pmacs.packages.install` / `update`) goes through the +//! resolver-driven paths so the resolver's revision choice is +//! authoritative; transitive resolution and lockfile writes +//! live in the surrounding orchestration code. +//! - **All three pin kinds supported**. [`InstallPin::Version`] +//! maps to `best_match` over upstream tags; +//! [`InstallPin::Branch`] resolves to the named branch's HEAD; +//! [`InstallPin::Commit`] resolves to a specific revision. The +//! user surface accepts all three via the table form (`{ ..., +//! branch = ... }` / `{ ..., commit = ... }`). //! - **Install dir naming**: `//`, where -//! `basename` is the package name's last `/`-segment. Two installs -//! with the same basename collide on disk; for v0.1 we accept the -//! collision (the caller can `pmacs.packages.installed()` to spot -//! conflicts before they bite). Proper handling lands with the -//! M7.5 resolver. +//! `basename` is the package name's last `/`-segment. Two +//! packages with the same basename collide on disk; for v0.1 we +//! accept the collision (the resolver / caller can +//! `pmacs.packages.installed()` to spot conflicts before they +//! bite). use std::fs; use std::io::{self, Write}; @@ -43,6 +54,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use semver::{Version, VersionReq}; +use serde::{Deserialize, Serialize}; use thiserror::Error; use super::address::{Address, AddressError}; @@ -58,7 +70,7 @@ use super::manifest::{ManifestError, PackageManifest}; /// `User` resolves to `$XDG_DATA_HOME/pmacs/packages/` (or /// `$HOME/.local/share/pmacs/packages/` if `XDG_DATA_HOME` is unset). /// `Project` resolves to `/.pmacs/packages/`. -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub enum InstallScope { /// User-config install (per-user, persistent across projects). User, @@ -125,7 +137,7 @@ fn xdg_data_root() -> Result { /// Useful for pinning to a known-good state before the upstream /// has tagged a release, or for reproducing a colleague's /// environment exactly without semver drift. -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub enum InstallPin { /// Highest semver tag satisfying the constraint. Version(VersionReq), @@ -134,6 +146,18 @@ pub enum InstallPin { /// Specific commit (full or partial SHA; the fetcher accepts /// either via `git rev-parse`). Commit(String), + /// Working-tree symlink installed via + /// [`pmacs.packages.install_local`] (T M8.1c). Carries the + /// source path so the Lua-visible roster entry can show where + /// the live tree lives. Local-pinned packages are *ephemeral*: + /// they never enter the lockfile and aren't reproducible across + /// machines --- they exist for the M8 dev loop ("edit a + /// package's source on disk and reload without restarting") and + /// nothing else. + Local { + /// Source path the install dir symlinks to. + source_path: PathBuf, + }, } impl InstallPin { @@ -145,22 +169,65 @@ impl InstallPin { Self::Version(_) => "version", Self::Branch(_) => "branch", Self::Commit(_) => "commit", + Self::Local { .. } => "local", } } /// User-supplied value as a string: the constraint for /// [`Self::Version`], the branch name for [`Self::Branch`], the - /// SHA for [`Self::Commit`]. + /// SHA for [`Self::Commit`], the source path for [`Self::Local`]. #[must_use] pub fn value(&self) -> String { match self { Self::Version(req) => req.to_string(), Self::Branch(b) => b.clone(), Self::Commit(c) => c.clone(), + Self::Local { source_path } => source_path.display().to_string(), } } } +/// Result of [`Installer::plan_local`]: everything needed to +/// commit a working-tree symlink install, with no disk changes +/// yet performed (T M8.1c). +/// +/// The plan/commit split lets the Lua binding layer interleave +/// `on_unload`-hook execution between validation and the disk +/// swap. If a hook fails, the caller can drop the plan and the +/// disk is unchanged. +#[derive(Debug, Clone)] +pub struct LocalInstallPlan { + /// Parsed manifest from `/pmacs.toml`. + pub manifest: PackageManifest, + /// Where the symlink will be placed: + /// `/`. + pub install_path: PathBuf, + /// Canonicalized source path the symlink will point at. Holds + /// an absolute path so the symlink resolves regardless of + /// where the editor's CWD ends up. + pub canonical_source: PathBuf, + /// Install basename (the last `/`-segment of `manifest.name`). + /// Useful to the binding layer for keying registry slots + /// (`PackageUnloadHooks`, etc.) without re-deriving from the + /// manifest. + pub basename: String, + /// Scope (user / project) this install will land in. + pub scope: InstallScope, +} + +/// A local install whose new symlink has already been created at a +/// sibling staging path, but has not yet been published over +/// [`LocalInstallPlan::install_path`]. +/// +/// The Lua binding uses this to front-load the fallible symlink +/// creation before it runs the prior package's `on_unload` hooks. +/// After hooks complete, publishing is a same-directory `rename(2)`. +#[derive(Debug, Clone)] +pub struct StagedLocalInstall { + plan: LocalInstallPlan, + staging_path: PathBuf, +} + /// A normalized install request: where to fetch and how to pin the /// revision. #[derive(Debug, Clone)] @@ -271,6 +338,25 @@ impl InstalledPackage { // Installer // --------------------------------------------------------------------------- +/// Internal options passed through [`Installer::install_with`]. +/// Public callers use [`Installer::install`] / +/// [`Installer::install_at_commit`] / +/// [`Installer::replace_at_commit`] which set these flags +/// appropriately. +#[derive(Debug, Default)] +struct InstallOptions { + /// If `Some`, treat this commit as the resolver's choice and + /// skip the installer's own tag/branch/commit lookup. The + /// manifest is read at this commit. + resolved_commit: Option, + /// If `true`, an existing install at the same path with a + /// different commit is replaced rather than rejected with + /// [`InstallError::AlreadyInstalled`]. The replacement is + /// staged at `.new` and only swapped in after + /// successful extraction. + replace_existing: bool, +} + /// Installer: pairs a [`Fetcher`] with an [`InstallScope`]. /// /// One `Installer` per scope; `LuaHost` constructs two (user-scoped and @@ -326,43 +412,387 @@ impl Installer { } /// Install one package. See module docs for the step-by-step flow. - #[allow(clippy::too_many_lines)] + /// + /// `install()` is the standalone entry point: the installer + /// independently picks the tag for `InstallPin::Version` via + /// [`best_match`]. Resolver-driven flows + /// (`pmacs.packages.install` / `pmacs.packages.update`) instead + /// call [`Self::install_at_commit`] / [`Self::replace_at_commit`] + /// so the installer honors the resolver's revision choice rather + /// than re-deriving it. pub fn install(&self, spec: &InstallSpec) -> Result { + self.install_with(spec, &InstallOptions::default()) + } + + /// Install at a commit pre-chosen by the resolver. The displayed + /// `pin` field on the returned [`InstalledPackage`] still + /// reflects `spec.pin` (so a `Version(^1.0.0)` request shows up + /// as a version pin), but the installer skips its own tag + /// enumeration and checks out the supplied commit directly. + /// The displayed `tag` is synthesized from `manifest.version` for + /// Version pins, or kept as `branch:`/`commit:` for + /// the other variants. + /// + /// Refuses to overwrite an existing install at a different + /// commit; for that path see [`Self::replace_at_commit`]. + pub fn install_at_commit( + &self, + spec: &InstallSpec, + commit: &str, + ) -> Result { + self.install_with( + spec, + &InstallOptions { + resolved_commit: Some(commit.to_string()), + replace_existing: false, + }, + ) + } + + /// Install or replace at a commit pre-chosen by the resolver. + /// Differs from [`Self::install_at_commit`] only in that an + /// existing install at the same path with a different commit is + /// replaced rather than erroring. The replacement is staged at + /// `.new` and only swapped in after extraction + /// succeeds, so a failing update leaves the prior install intact. + /// + /// Used by `pmacs.packages.update`, which by definition expects + /// to overwrite a prior install when upstream has moved. + pub fn replace_at_commit( + &self, + spec: &InstallSpec, + commit: &str, + ) -> Result { + self.install_with( + spec, + &InstallOptions { + resolved_commit: Some(commit.to_string()), + replace_existing: true, + }, + ) + } + + /// Install from a local working-tree path by symlinking it + /// into the install root (T M8.1c). The dev-loop counterpart + /// to [`Self::install`]: edits to files under `source_path` + /// become live in the editor without re-running the package + /// pipeline; `pmacs.packages.reload(name)` (M8.1d) picks them + /// up without restarting the session. + /// + /// Semantics: + /// + /// - `source_path` must contain a readable `pmacs.toml`. + /// Anything else fails with [`InstallError::LocalManifestMissing`]. + /// - The install dir is `/`. If a + /// symlink already lives there, it is replaced by staging a + /// sibling symlink and atomically renaming it into place. + /// - If a *real* directory lives at the install path, + /// [`InstallError::LocalRealInstallInWay`] surfaces. The user + /// removes that install first (manually or via a future + /// uninstall API). + /// - The returned [`InstalledPackage`] carries + /// [`InstallPin::Local`] so the Lua-visible roster entry + /// names the source. No lockfile work is done; `install_local` + /// is explicitly ephemeral. + pub fn install_local(&self, source_path: &Path) -> Result { + let plan = self.plan_local(source_path)?; + self.commit_local(plan) + } + + /// Validate `source_path` and compute where its symlink should + /// land, **without making any disk changes**. The returned + /// [`LocalInstallPlan`] is consumed by [`Self::commit_local`], + /// which performs the symlink swap. + /// + /// The plan/commit split exists so the Lua binding layer can + /// run prior-install `on_unload` hooks between the two steps: + /// if any hook fails, the disk symlink hasn't moved, so disk + /// and runtime state remain in sync. Without the split, a + /// failing hook leaves the symlink at the new source while the + /// roster / `package.loaded` / per-package env still track the + /// old one --- a desync the user can only resolve by + /// restarting. + pub fn plan_local(&self, source_path: &Path) -> Result { + // Manifest must exist and parse. A friendly error here + // beats a surprising error later when the searcher tries + // to load a non-existent entry. + let manifest_path = source_path.join("pmacs.toml"); + let manifest_str = std::fs::read_to_string(&manifest_path).map_err(|e| { + InstallError::LocalManifestMissing { + source_path: source_path.to_path_buf(), + cause: e.to_string(), + } + })?; + let manifest = PackageManifest::from_toml(&manifest_str).map_err(|e| { + InstallError::LocalManifestMissing { + source_path: source_path.to_path_buf(), + cause: e.to_string(), + } + })?; + + // pmacs_required check, identical to the fetched-install + // path. install_local doesn't bypass any compatibility + // gate; the dev-loop story doesn't extend to "ignore the + // version constraint." + let running_pmacs = running_pmacs_version(); + if !manifest.pmacs_required.matches(&running_pmacs) { + return Err(InstallError::PmacsVersionIncompatible { + address: source_path.display().to_string(), + tag: format!("local:{}", source_path.display()), + required: manifest.pmacs_required.to_string(), + running: running_pmacs.to_string(), + }); + } + + let install_root = self.install_root()?; + let basename = package_basename(manifest.name.as_str()).to_string(); + let install_path = install_root.join(&basename); + + // Probe the install path. We don't mutate it here --- the + // commit step does. We do reject the real-directory case + // up front so the Lua binding layer can refuse before + // running any unload hooks (a hook running and then the + // commit failing because of a real-dir collision would be + // worse than refusing immediately). + match std::fs::symlink_metadata(&install_path) { + Ok(meta) if meta.file_type().is_symlink() => { /* ok, we'll replace */ } + Ok(_) => { + return Err(InstallError::LocalRealInstallInWay { install_path }); + } + Err(e) if e.kind() == io::ErrorKind::NotFound => { /* ok, we'll create */ } + Err(source) => { + return Err(InstallError::Io { + path: install_path.clone(), + source, + }); + } + } + + // Canonicalize the source so the symlink points at an + // absolute path. Without this, a relative source resolves + // against the install dir's parent rather than the user's + // CWD, and the user's CWD is the contract here. + let canonical_source = + std::fs::canonicalize(source_path).map_err(|source| InstallError::Io { + path: source_path.to_path_buf(), + source, + })?; + + Ok(LocalInstallPlan { + manifest, + install_path, + canonical_source, + basename, + scope: self.scope.clone(), + }) + } + + /// Commit a [`LocalInstallPlan`] in one step: stage a new symlink + /// at a sibling temp path, then atomically rename it over + /// `plan.install_path`. After this returns, the plan's bytes are + /// live on disk; the caller is responsible for cache invalidation. + /// + /// Callers that need to interleave package teardown hooks between + /// staging and publishing should use [`Self::stage_local`] followed + /// by [`Self::publish_local`]. + /// + /// **Atomicity.** The replacement uses `rename(2)` to swap the + /// staged symlink over the existing one. On the same + /// filesystem (which it is by construction --- the staging + /// path is in the same directory as `install_path`), + /// `rename(2)` is atomic with respect to other observers: at + /// any moment, `install_path` either holds the old symlink or + /// the new one, never neither. This is the upgrade from the + /// prior remove-then-create shape, where a `symlink(2)` failure + /// after the `unlink(2)` left the install path missing while + /// the runtime still tracked the old install. + /// + /// Re-checks the install path's symlink-vs-real-dir state at + /// commit time: belt-and-braces against a TOCTOU between plan + /// and commit (the dev-loop is single-user, so a real + /// race is unlikely, but a `LocalRealInstallInWay` returned + /// here keeps the contract symmetric with [`Self::plan_local`]). + pub fn commit_local(&self, plan: LocalInstallPlan) -> Result { + let staged = self.stage_local(plan)?; + self.publish_local(staged) + } + + /// Stage a [`LocalInstallPlan`] by creating the new symlink at a + /// hidden sibling path, but do not publish it over the live + /// install path yet. + /// + /// This performs the fallible symlink-creation work before the + /// binding layer runs `on_unload` hooks. If staging fails, the old + /// package is still live and no teardown hooks have fired. + pub fn stage_local(&self, plan: LocalInstallPlan) -> Result { + // Re-check the install path. A real dir surfacing here + // would indicate either a TOCTOU race or a bug in the + // plan/commit caller; either way refuse rather than + // silently overwrite. Symlinks and missing paths are both + // valid commit destinations; the atomic rename below + // handles both shapes uniformly. + match std::fs::symlink_metadata(&plan.install_path) { + Ok(meta) if meta.file_type().is_symlink() => { /* ok, atomic swap */ } + Ok(_) => { + return Err(InstallError::LocalRealInstallInWay { + install_path: plan.install_path, + }); + } + Err(e) if e.kind() == io::ErrorKind::NotFound => { /* ok, fresh create */ } + Err(source) => { + return Err(InstallError::Io { + path: plan.install_path.clone(), + source, + }); + } + } + + // Stage the new symlink at a sibling path. Same directory + // as the install path means rename(2) is atomic. The + // sentinel-prefix (`..swap.tmp`) is hidden in + // ls(1) output and namespaced so concurrent commits for + // different basenames don't collide. A leftover from a + // prior crashed commit would be unlinked here before we + // re-stage. + let staging_path = plan + .install_path + .with_file_name(format!(".{}.swap.tmp", plan.basename)); + if let Err(e) = std::fs::remove_file(&staging_path) { + if e.kind() != io::ErrorKind::NotFound { + return Err(InstallError::Io { + path: staging_path, + source: e, + }); + } + } + symlink_create(&plan.canonical_source, &staging_path)?; + + Ok(StagedLocalInstall { plan, staging_path }) + } + + /// Best-effort cleanup for a staged local install that will not be + /// published, typically because an `on_unload` hook failed. + pub fn discard_staged_local(&self, staged: StagedLocalInstall) { + let _ = std::fs::remove_file(staged.staging_path); + } + + /// Publish a staged local install with a same-directory atomic + /// rename, returning the Lua-visible package record. + pub fn publish_local( + &self, + staged: StagedLocalInstall, + ) -> Result { + let StagedLocalInstall { plan, staging_path } = staged; + + // Atomic swap. rename(2) replaces install_path + // (whether or not it currently exists) in a single + // observable step. On failure the staged symlink is + // unlinked so we don't leave a dangling .swap.tmp file + // behind; the original install_path is untouched. + if let Err(source) = std::fs::rename(&staging_path, &plan.install_path) { + let _ = std::fs::remove_file(&staging_path); + return Err(InstallError::Io { + path: plan.install_path.clone(), + source, + }); + } + + Ok(InstalledPackage { + version: plan.manifest.version.clone(), + manifest: plan.manifest, + install_path: plan.install_path, + // No commit. The synthetic `local` token marks this as + // an ephemeral install in the Lua-visible roster + // (callers compare the `pin.kind` field, not commit). + commit: "local".to_string(), + tag: format!("local:{}", plan.canonical_source.display()), + scope: plan.scope, + pin: InstallPin::Local { + source_path: plan.canonical_source, + }, + }) + } + + /// Unified install flow used by [`Self::install`], + /// [`Self::install_at_commit`], and [`Self::replace_at_commit`]. + /// The three differ only in `opts`. + #[allow(clippy::too_many_lines)] + fn install_with( + &self, + spec: &InstallSpec, + opts: &InstallOptions, + ) -> Result { + // Reject Local pins early: the fetched-install path needs a + // clone URL, and Local pins don't have one. install_local() + // owns the working-tree symlink path. T M8.1c. + if let InstallPin::Local { source_path } = &spec.pin { + return Err(InstallError::LocalPinNotSupported { + source_path: source_path.clone(), + }); + } let url = spec.address.to_git_url(); let bare = self.fetcher.fetch(&url).map_err(InstallError::Fetch)?; // Resolve the user's pin to a concrete (commit, tag-descriptor) - // pair. The descriptor is what we display to users in the - // `tag` field of the resulting `InstalledPackage`. - let (commit, tag_descriptor) = match &spec.pin { - InstallPin::Version(req) => { - let tags = self.fetcher.list_tags(&bare).map_err(InstallError::Fetch)?; - let chosen = - best_match(&tags, req).ok_or_else(|| InstallError::NoMatchingVersion { - address: url.clone(), - req: req.to_string(), - available: tags.clone(), - })?; - let commit = self - .fetcher - .resolve(&bare, &RefSpec::Tag(chosen.tag.clone())) - .map_err(InstallError::Fetch)?; - (commit, chosen.tag) - } - InstallPin::Branch(name) => { - let commit = self - .fetcher - .resolve(&bare, &RefSpec::Branch(name.clone())) - .map_err(InstallError::Fetch)?; - (commit, format!("branch:{name}")) - } - InstallPin::Commit(sha) => { - let commit = self - .fetcher - .resolve(&bare, &RefSpec::Commit(sha.clone())) - .map_err(InstallError::Fetch)?; - let short = commit.get(..7).unwrap_or(commit.as_str()).to_string(); - (commit, format!("commit:{short}")) + // pair. When the resolver has supplied a commit, we use it + // directly: this keeps the installer aligned with the + // resolver's choice for InstallPin::Version (where re-running + // best_match() could otherwise diverge if upstream tagged a + // newer version that the resolver rejected for compatibility + // reasons). The descriptor for Version pins is synthesized + // from manifest.version after we read the manifest. + let (commit, tag_descriptor) = if let Some(forced) = opts.resolved_commit.as_deref() { + let commit = self + .fetcher + .resolve(&bare, &RefSpec::Commit(forced.to_string())) + .map_err(InstallError::Fetch)?; + let descriptor = match &spec.pin { + // Replaced post-manifest-read below. + InstallPin::Version(_) => String::new(), + InstallPin::Branch(name) => format!("branch:{name}"), + InstallPin::Commit(_) => { + let short = commit.get(..7).unwrap_or(commit.as_str()).to_string(); + format!("commit:{short}") + } + InstallPin::Local { .. } => { + unreachable!("Local pins refused at install_with entry") + } + }; + (commit, descriptor) + } else { + match &spec.pin { + InstallPin::Version(req) => { + let tags = self.fetcher.list_tags(&bare).map_err(InstallError::Fetch)?; + let chosen = + best_match(&tags, req).ok_or_else(|| InstallError::NoMatchingVersion { + address: url.clone(), + req: req.to_string(), + available: tags.clone(), + })?; + let commit = self + .fetcher + .resolve(&bare, &RefSpec::Tag(chosen.tag.clone())) + .map_err(InstallError::Fetch)?; + (commit, chosen.tag) + } + InstallPin::Branch(name) => { + let commit = self + .fetcher + .resolve(&bare, &RefSpec::Branch(name.clone())) + .map_err(InstallError::Fetch)?; + (commit, format!("branch:{name}")) + } + InstallPin::Commit(sha) => { + let commit = self + .fetcher + .resolve(&bare, &RefSpec::Commit(sha.clone())) + .map_err(InstallError::Fetch)?; + let short = commit.get(..7).unwrap_or(commit.as_str()).to_string(); + (commit, format!("commit:{short}")) + } + InstallPin::Local { .. } => { + unreachable!("Local pins refused at install_with entry") + } } }; @@ -385,6 +815,16 @@ impl Installer { })?; let manifest = PackageManifest::from_toml(manifest_str).map_err(InstallError::Manifest)?; + // Synthesize the Version-pin descriptor now that we know the + // manifest version. Mirrors the conventional `v{X.Y.Z}` form + // produced by the standalone tag-matching path. + let tag_descriptor = + if opts.resolved_commit.is_some() && matches!(spec.pin, InstallPin::Version(_)) { + format!("v{}", manifest.version) + } else { + tag_descriptor + }; + // Refuse to install a package whose `pmacs_required` constraint // does not match the running pmacs version. Applies to every // pin kind: a package's declared API requirements are @@ -422,10 +862,14 @@ impl Installer { let basename = package_basename(manifest.name.as_str()); let install_path = install_root.join(basename); - // If the install path already exists with the same commit, treat - // as idempotent. With a different commit, we refuse rather than - // overwrite --- callers ask for `update` (M7.6), not silent - // replacement. + // If the install path already exists with the same commit, + // treat as idempotent. With a different commit, behavior + // depends on `opts.replace_existing`: the standalone install + // path refuses (`pmacs.packages.install` callers reach this + // when re-running install with a moved upstream and should + // be told to use `update`); the resolver-driven update path + // proceeds to a staged replacement. + let mut needs_replace = false; if install_path.exists() { let existing = read_install_marker(&install_path).ok(); match existing { @@ -440,6 +884,9 @@ impl Installer { pin: spec.pin.clone(), }); } + _ if opts.replace_existing => { + needs_replace = true; + } _ => { return Err(InstallError::AlreadyInstalled { path: install_path, @@ -454,17 +901,80 @@ impl Installer { .fetcher .archive_commit(&bare, &commit) .map_err(InstallError::Fetch)?; - fs::create_dir_all(&install_path).map_err(|source| InstallError::Io { - path: install_path.clone(), + + // Staging path: when replacing, extract to a sibling dir and + // only rename into place after success, so a failing replace + // leaves the prior install untouched. Same-filesystem rename + // makes the swap visible atomically; a crash between removing + // the old dir and renaming the staged dir leaves the staged + // dir in place, which is recoverable on next run. + let extract_target = if needs_replace { + let staged = install_path.with_extension("new"); + // A leftover staging dir from a prior crash would + // confuse `create_dir_all` semantics; clear it first. + if staged.exists() { + fs::remove_dir_all(&staged).map_err(|source| InstallError::Io { + path: staged.clone(), + source, + })?; + } + staged + } else { + install_path.clone() + }; + + fs::create_dir_all(&extract_target).map_err(|source| InstallError::Io { + path: extract_target.clone(), source, })?; - if let Err(e) = extract_tar(&archive, &install_path) { - // Roll back partial extraction: an empty install dir is more - // recoverable than a half-populated one. - let _ = fs::remove_dir_all(&install_path); + if let Err(e) = extract_tar(&archive, &extract_target) { + // Roll back partial extraction. For the staging path + // this leaves the prior install untouched; for the + // direct path this leaves the install root clean. + let _ = fs::remove_dir_all(&extract_target); return Err(e); } - write_install_marker(&install_path, &commit)?; + write_install_marker(&extract_target, &commit)?; + + if needs_replace { + // Swap with rollback: rename the old install aside, + // rename the staged dir into place, then remove the + // backup. If the second rename fails, restore from the + // backup so the prior install survives the failed + // update. The two renames are individually atomic on the + // same filesystem; the only window where neither dir + // sits at `install_path` is between them, and a crash in + // that window leaves both `.old` and `.new` siblings + // for manual recovery. + let backup = install_path.with_extension("old"); + // Clear any leftover backup from a prior crash. + if backup.exists() { + fs::remove_dir_all(&backup).map_err(|source| InstallError::Io { + path: backup.clone(), + source, + })?; + } + fs::rename(&install_path, &backup).map_err(|source| InstallError::Io { + path: install_path.clone(), + source, + })?; + if let Err(source) = fs::rename(&extract_target, &install_path) { + // Restore. If even the restore fails, surface the + // original error --- the operator now needs to + // manually swap `.old` back into place, but + // we've at least preserved the bytes. + let _ = fs::rename(&backup, &install_path); + return Err(InstallError::Io { + path: install_path.clone(), + source, + }); + } + // Both renames succeeded --- safe to drop the backup. + // A failure here leaves `.old` behind (best- + // effort): the new install is correct on disk, just a + // disk-space leak. + let _ = fs::remove_dir_all(&backup); + } Ok(InstalledPackage { version: manifest.version.clone(), @@ -527,7 +1037,13 @@ fn parse_tag_as_semver(tag: &str) -> Option { Version::parse(stripped).ok() } -fn package_basename(name: &str) -> &str { +/// Strip a `/` namespace prefix from a manifest name and +/// return the trailing segment used for the on-disk install dir +/// and for `require()` lookup. `"magit"` → `"magit"`, +/// `"user/magit"` → `"magit"`. The `pub(crate)` exposure lets +/// `lua_bindings::do_update` derive the basename for a lockfile +/// entry without re-implementing the rule. +pub(crate) fn package_basename(name: &str) -> &str { match name.rsplit_once('/') { Some((_, last)) => last, None => name, @@ -580,6 +1096,31 @@ fn extract_tar(archive: &[u8], dest: &Path) -> Result<(), InstallError> { const MARKER_NAME: &str = ".pmacs-install"; +/// Create a symlink at `link` pointing at `target`. Unix-only in +/// v0.1; pmacs doesn't ship Windows builds and `std::os::unix`'s +/// symlink semantics are what dired/wdired need (the link is the +/// thing being managed; the target is data). +fn symlink_create(target: &Path, link: &Path) -> Result<(), InstallError> { + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link).map_err(|source| InstallError::Io { + path: link.to_path_buf(), + source, + }) + } + #[cfg(not(unix))] + { + let _ = (target, link); + Err(InstallError::Io { + path: link.to_path_buf(), + source: io::Error::new( + io::ErrorKind::Unsupported, + "install_local requires Unix symlink support", + ), + }) + } +} + fn write_install_marker(install_path: &Path, commit: &str) -> Result<(), InstallError> { let p = install_path.join(MARKER_NAME); fs::write(&p, format!("{commit}\n")).map_err(|source| InstallError::Io { path: p, source }) @@ -601,6 +1142,46 @@ pub enum InstallError { /// `$XDG_DATA_HOME` and `$HOME` were both unset. #[error("cannot resolve XDG data directory: HOME and XDG_DATA_HOME are both unset")] NoDataHome, + /// [`Installer::install`] / [`Installer::install_at_commit`] / + /// [`Installer::replace_at_commit`] received an + /// [`InstallPin::Local`]. Local pins must go through + /// [`Installer::install_local`] (T M8.1c); routing them to the + /// fetched-install path would require a clone URL that doesn't + /// exist for working-tree installs. + #[error( + "InstallPin::Local cannot be installed via the fetched-install path; \ + use Installer::install_local for source path `{source_path}`" + )] + LocalPinNotSupported { + /// The source path the Local pin named. + source_path: PathBuf, + }, + /// [`Installer::install_local`] was given a path that doesn't + /// contain a readable `pmacs.toml`. The package layout + /// requirements are documented in the package author guide; the + /// user typically forgot to write the manifest or pointed at + /// the wrong directory. + #[error("install_local: no readable pmacs.toml at `{source_path}`: {cause}")] + LocalManifestMissing { + /// The source path the user passed. + source_path: PathBuf, + /// The underlying I/O or parse error message. + cause: String, + }, + /// [`Installer::install_local`] was asked to install at a name + /// that already has a real (non-symlink) install. The user must + /// uninstall the fetched copy first. We refuse rather than + /// silently replace because losing a fetched-install tree is a + /// real risk (it might contain manual edits the user made + /// before discovering `install_local`). + #[error( + "install_local: `{install_path}` is a real install, not a symlink; \ + remove it first, then re-run install_local" + )] + LocalRealInstallInWay { + /// The install dir that's blocking the new symlink. + install_path: PathBuf, + }, /// Underlying fetch/clone/resolve operation failed. #[error(transparent)] Fetch(#[from] FetchError), diff --git a/src/packages/manifest.rs b/src/packages/manifest.rs index a80118f..041835c 100644 --- a/src/packages/manifest.rs +++ b/src/packages/manifest.rs @@ -47,7 +47,7 @@ //! version = "*" //! ``` -use std::path::PathBuf; +use std::path::{Component, PathBuf}; use semver::{Version, VersionReq}; use serde::{Deserialize, Serialize}; @@ -62,7 +62,7 @@ use thiserror::Error; /// /// Construct via [`PackageName::new`]; deserialization runs the same /// validator and surfaces a parse-time error on invalid names. -#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize)] +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] pub struct PackageName(String); impl PackageName { @@ -189,9 +189,14 @@ impl PackageManifest { /// /// Validation happens during deserialization: missing required /// fields produce errors naming the field; invalid semver in - /// `version` or `pmacs_required` is rejected at parse time. + /// `version` or `pmacs_required` is rejected at parse time. After + /// deserialization the [`entry`](Self::entry) path is validated to + /// stay inside the package root --- an absolute path or any `..` + /// component is rejected with [`ManifestError::EscapingEntry`]. pub fn from_toml(s: &str) -> Result { - toml::from_str(s).map_err(ManifestError::from) + let m: Self = toml::from_str(s).map_err(ManifestError::from)?; + validate_entry_path(&m.entry)?; + Ok(m) } /// Serialize to canonical TOML form. @@ -200,6 +205,49 @@ impl PackageManifest { } } +/// Reject manifest `entry` paths that could escape the package +/// root. The loader joins this onto `install_path`; an absolute +/// path or `..` component would let a malicious manifest read +/// (and therefore execute) arbitrary code. +/// +/// Rules: +/// - Path must not be absolute. +/// - No component may be `..`. +/// - No component may be a Windows prefix (drive letter, UNC). +/// - The path must be non-empty. +fn validate_entry_path(p: &std::path::Path) -> Result<(), ManifestError> { + if p.as_os_str().is_empty() { + return Err(ManifestError::EscapingEntry { + value: String::new(), + reason: "empty path".into(), + }); + } + if p.is_absolute() { + return Err(ManifestError::EscapingEntry { + value: p.display().to_string(), + reason: "absolute paths are forbidden".into(), + }); + } + for c in p.components() { + match c { + Component::ParentDir => { + return Err(ManifestError::EscapingEntry { + value: p.display().to_string(), + reason: "`..` components are forbidden".into(), + }); + } + Component::Prefix(_) | Component::RootDir => { + return Err(ManifestError::EscapingEntry { + value: p.display().to_string(), + reason: "drive prefixes / root components are forbidden".into(), + }); + } + Component::CurDir | Component::Normal(_) => {} + } + } + Ok(()) +} + // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- @@ -224,6 +272,16 @@ pub enum ManifestError { /// TOML serialization failed (e.g., a non-UTF-8 path). #[error("manifest serialize error: {0}")] Serialize(#[from] toml::ser::Error), + /// `entry` was either absolute or contained a `..` component, both + /// of which would let a malicious manifest direct the loader to + /// load files outside the package root. + #[error("manifest entry path `{value}` escapes the package root: {reason}")] + EscapingEntry { + /// The offending path string. + value: String, + /// Which rule it violated (absolute, `..`, etc.). + reason: String, + }, } // --------------------------------------------------------------------------- @@ -490,6 +548,91 @@ mod tests { assert!(err.to_string().contains("BAD-CAPS") || err.to_string().contains("name")); } + // -- Entry-path validation: reject escapes ----------------------------- + + #[test] + fn entry_absolute_path_is_rejected() { + let s = r#" + name = "x" + version = "0.1.0" + summary = "y" + pmacs_required = ">=0.1.0" + entry = "/etc/passwd" + exports = [] + "#; + let err = PackageManifest::from_toml(s).unwrap_err(); + assert!( + matches!(err, ManifestError::EscapingEntry { .. }), + "got {err:?}" + ); + assert!(err.to_string().contains("absolute")); + } + + #[test] + fn entry_with_parent_dir_component_is_rejected() { + let s = r#" + name = "x" + version = "0.1.0" + summary = "y" + pmacs_required = ">=0.1.0" + entry = "../../escape.lua" + exports = [] + "#; + let err = PackageManifest::from_toml(s).unwrap_err(); + assert!( + matches!(err, ManifestError::EscapingEntry { .. }), + "got {err:?}" + ); + assert!(err.to_string().contains("`..`")); + } + + #[test] + fn entry_with_embedded_parent_dir_is_rejected() { + let s = r#" + name = "x" + version = "0.1.0" + summary = "y" + pmacs_required = ">=0.1.0" + entry = "subdir/../../etc/passwd" + exports = [] + "#; + let err = PackageManifest::from_toml(s).unwrap_err(); + assert!( + matches!(err, ManifestError::EscapingEntry { .. }), + "got {err:?}" + ); + } + + #[test] + fn entry_subdir_relative_path_is_accepted() { + let s = r#" + name = "x" + version = "0.1.0" + summary = "y" + pmacs_required = ">=0.1.0" + entry = "subdir/init.lua" + exports = [] + "#; + let m = PackageManifest::from_toml(s).expect("subdir entry should parse"); + assert_eq!(m.entry.to_str().unwrap(), "subdir/init.lua"); + } + + #[test] + fn entry_with_curdir_prefix_is_accepted() { + // `./init.lua` normalizes to `init.lua`. CurDir components are + // benign and shouldn't trip the validator. + let s = r#" + name = "x" + version = "0.1.0" + summary = "y" + pmacs_required = ">=0.1.0" + entry = "./init.lua" + exports = [] + "#; + let m = PackageManifest::from_toml(s).expect("./entry should parse"); + assert!(m.entry.to_str().unwrap().contains("init.lua")); + } + // -- Optional dependencies / conflicts respected ------------------------ #[test] diff --git a/src/packages/mod.rs b/src/packages/mod.rs index 2abf0d4..96e1fc1 100644 --- a/src/packages/mod.rs +++ b/src/packages/mod.rs @@ -14,11 +14,21 @@ pub mod address; pub mod fetcher; pub mod installer; +pub mod loader; +pub mod lockfile; pub mod manifest; +pub mod resolver; pub use address::{Address, AddressError}; pub use fetcher::{FetchError, Fetcher, RefSpec}; pub use installer::{ InstallError, InstallPin, InstallScope, InstallSpec, InstalledPackage, Installer, + LocalInstallPlan, +}; +pub use loader::{LookupOutcome, ResolvedKind, lookup_in_package, lookup_in_roster}; +pub use lockfile::{ + ContentHash, LOCKFILE_FILENAME, LOCKFILE_SCHEMA_VERSION, Lockfile, LockfileEntry, + LockfileError, LockfilePin, UpdatePolicy, }; pub use manifest::{DependencySpec, ManifestError, PackageManifest, PackageName}; +pub use resolver::{ResolveError, ResolvePlan, ResolveRequest, ResolvedPackage, Resolver, Source}; diff --git a/src/workers_buffer.rs b/src/workers_buffer.rs index 7498867..bc34575 100644 --- a/src/workers_buffer.rs +++ b/src/workers_buffer.rs @@ -165,6 +165,12 @@ fn format_outcome(outcome: &JobOutcome) -> String { JobOutcome::Complete(JobResult::Parse { duration_ms }) => { format!("ok (parse {duration_ms}ms)") } + JobOutcome::Complete(JobResult::ReadDir(entries)) => { + format!("ok ({} entries)", entries.len()) + } + JobOutcome::Complete(JobResult::Stat(entry)) => { + format!("ok (stat {:?})", entry.name) + } JobOutcome::Cancelled => "cancelled".to_string(), JobOutcome::Failed(msg) => { // Trim the failure message for the table; the full