Fix CI and Documentation issues

This commit is contained in:
Levi Neuwirth 2026-05-04 10:19:19 -04:00
parent c8d0d67615
commit 291eb0fd8d
9 changed files with 826 additions and 161 deletions

View File

@ -15,31 +15,49 @@
--- ---
--- Two accepted shapes: --- Two accepted shapes:
--- ---
--- - Table with positional address at `[1]`: `{ "github:owner/repo", version = "^1.0.0" }`. --- - Table with positional address at `[1]`. The pin is one of three
--- The `version` field defaults to `"*"` (any tag) when omitted. The --- mutually-exclusive fields:
--- `install_project` variant additionally **requires** `project_root = "..."` --- - `version = "<semver>"` (default; constrains to a tag).
--- (no default; see the field doc below). --- - `branch = "<name>"` (HEAD of the named branch at install time).
--- - Shorthand string `"github:owner/repo@^1.0.0"`. The separator is the **last** `@` in the --- - `commit = "<sha>"` (specific revision; full or partial SHA).
--- string, so addresses containing an `@` (SSH shorthand `git@host:path`) parse correctly. --- Specifying more than one of these errors with a "must specify
--- The shorthand form is not accepted by `install_project` (no place to put `project_root`). --- exactly one" message naming every conflicting field. With none
--- specified, the pin defaults to `version = "*"` (any tag).
--- The `install_project` variant additionally **requires**
--- `project_root = "..."` (no default).
--- - Shorthand string `"github:owner/repo@^1.0.0"`. **Version-pin only.**
--- The separator is the **last** `@` in the string, so addresses
--- containing an `@` (SSH shorthand `git@host:path`) parse correctly.
--- Branch and commit pins must use the table form. The shorthand
--- form is also not accepted by `install_project` (no place to put
--- `project_root`).
--- ---
--- @class PackageInstallSpec --- @class PackageInstallSpec
--- @field [1] string Positional address (e.g., `"github:owner/repo"`). --- @field [1] string Positional address (e.g., `"github:owner/repo"`).
--- @field address string|nil Alternative to the positional `[1]`. --- @field address string|nil Alternative to the positional `[1]`.
--- @field version string|nil Semver constraint (e.g., `"^1.0.0"`, `"=1.2.3"`, `"*"`). Defaults to `"*"`. --- @field version string|nil Semver constraint (e.g., `"^1.0.0"`, `"=1.2.3"`, `"*"`). Mutually exclusive with `branch` / `commit`. Defaults to `"*"` when none of the three are specified.
--- @field branch string|nil Branch name (e.g., `"main"`). The install resolves the branch's HEAD at install time; not reproducible across time. Mutually exclusive with `version` / `commit`.
--- @field commit string|nil Commit SHA (full or partial). The install pins to that exact revision. Mutually exclusive with `version` / `branch`.
--- @field project_root string|nil `install_project` only: REQUIRED project root. Absolute paths used as-is. Relative paths resolve against the directory of the loading `init.lua` (not against CWD). Common patterns: `os.getenv("PMACS_PROJECT")`, or a literal subdirectory like `"."` for "alongside this init.lua". --- @field project_root string|nil `install_project` only: REQUIRED project root. Absolute paths used as-is. Relative paths resolve against the directory of the loading `init.lua` (not against CWD). Common patterns: `os.getenv("PMACS_PROJECT")`, or a literal subdirectory like `"."` for "alongside this init.lua".
--- The pin info on an [`InstalledPackage`].
---
--- @class InstalledPackagePin
--- @field kind "version"|"branch"|"commit" Which pin kind the user supplied.
--- @field value string The user-supplied value: the semver constraint (e.g. `"^1.0.0"`), the branch name (e.g. `"main"`), or the commit SHA. Echoes the field on `PackageInstallSpec` exactly.
--- A successful-install record returned by `install` and listed by `installed`. --- A successful-install record returned by `install` and listed by `installed`.
--- ---
--- @class InstalledPackage --- @class InstalledPackage
--- @field name string Package name from `pmacs.toml` (e.g., `"samplepkg"` or `"user/samplepkg"`). --- @field name string Package name from `pmacs.toml` (e.g., `"samplepkg"` or `"user/samplepkg"`).
--- @field version string Semver of the resolved tag (canonical numeric form, e.g., `"1.2.3"`). --- @field version string Manifest-declared version of the installed snapshot (canonical semver, e.g. `"1.2.3"`).
--- @field tag string The tag that was matched (e.g., `"v1.2.3"`). --- @field tag string Resolution descriptor: matched tag (`"v1.2.3"`) for version pins, `"branch:<name>"` for branch pins, `"commit:<short-sha>"` for commit pins. Always non-empty.
--- @field commit string 40-character commit SHA of the installed snapshot. --- @field commit string 40-character commit SHA of the installed snapshot.
--- @field install_path string Absolute on-disk install directory. --- @field install_path string Absolute on-disk install directory.
--- @field entry string Absolute path to the package's `entry` Lua module. --- @field entry string Absolute path to the package's `entry` Lua module.
--- @field scope "user"|"project" Which scope the package was installed under. --- @field scope "user"|"project" Which scope the package was installed under.
--- @field summary string One-line description from the manifest. --- @field summary string One-line description from the manifest.
--- @field pin InstalledPackagePin Structured pin info (the user's request, distinct from the resolved descriptor).
local pmacs = pmacs or {} local pmacs = pmacs or {}
pmacs.packages = pmacs.packages or {} pmacs.packages = pmacs.packages or {}

View File

@ -19,12 +19,47 @@ pmacs.packages.install {
} }
``` ```
The shorthand string form is also accepted: The shorthand string form is also accepted (version pins only):
```lua ```lua
pmacs.packages.install "github:owner/repo@^1.0.0" pmacs.packages.install "github:owner/repo@^1.0.0"
``` ```
### Pin kinds: `version`, `branch`, `commit`
Each install pins exactly one revision. The spec table chooses the
pin via one of three mutually-exclusive fields:
```lua
-- Highest semver tag matching the constraint. Recommended default.
pmacs.packages.install { "github:owner/repo", version = "^1.0.0" }
-- HEAD of the named branch at install time. Not reproducible across
-- time --- the upstream's branch HEAD moves --- so use sparingly.
pmacs.packages.install { "github:owner/repo", branch = "main" }
-- Exact commit. Reproducible: the same SHA always installs the same
-- snapshot. Useful for pinning to a known-good state before the
-- upstream has tagged a release.
pmacs.packages.install { "github:owner/repo", commit = "abc1234" }
```
Mutual exclusion is enforced: a spec table with two of these fields
errors with a "must specify exactly one" message naming every
conflicting field. With none of the three, the pin defaults to
`version = "*"` (any tag).
The shorthand string form (`"address@^1.0"`) is **version-pin only**.
Branch and commit pins must use the table form because there is no
unambiguous sigil that distinguishes a branch named "main" from a
malformed semver constraint without surprising users.
For version pins, pmacs additionally cross-checks that the
manifest's `version` field at the matched tag satisfies the user's
constraint, catching upstreams whose tag and `pmacs.toml` disagree.
Branch and commit pins skip that check (the user explicitly asked
for that revision regardless of what the manifest says).
## `pmacs.packages.install_project { ... }` — project scope ## `pmacs.packages.install_project { ... }` — project scope
Installs to `<project_root>/.pmacs/packages/<basename>/`. Project Installs to `<project_root>/.pmacs/packages/<basename>/`. Project
@ -105,12 +140,77 @@ field, since it has no implicit project context.
The change will be relaxation, not breakage: code that explicitly The change will be relaxation, not breakage: code that explicitly
passes `project_root` keeps working unchanged. passes `project_root` keeps working unchanged.
## How `require` resolution works
pmacs uses Lua's standard require machinery, augmented at install
time:
1. **Path-based search.** Each install prepends
`<install_root>/?.lua;<install_root>/?/init.lua` to
`package.path`. Packages with the conventional layout
(`<basename>.lua` or `<basename>/init.lua`) resolve via this
path with no further machinery — exactly as a hand-written Lua
project would.
2. **Custom searcher.** When the path-based search misses (e.g.
the manifest declares `entry = "main.lua"` or `entry =
"lib/foo.lua"`), a custom searcher pmacs registered in
`package.searchers` (Lua 5.4) / `package.loaders` (LuaJIT and
Lua 5.1) consults the install roster, finds the matching
package by basename, and returns a loader for the exact entry
path declared in the manifest.
The searcher iterates the roster in install order, most-recent
first, so a project-scope install of a basename overrides a prior
user-scope install of the same basename — mirroring the
"newer-installs-prepend-to-path" semantics of the path-based
search.
When `require` cannot find a name through any searcher, the
combined error message names every searcher's contribution; the
custom searcher's contribution looks like:
```
no installed pmacs package named 'whatever'
```
so a user with a typo can spot it without digging into pmacs's
internals.
## `pmacs.packages.installed()` ## `pmacs.packages.installed()`
Returns an array of records describing every package installed Returns an array of records describing every package installed
during the current init pass. Each record has the same shape as during the current init pass. Each record has the same shape as
`install`'s return value (`name`, `version`, `commit`, `install`'s return value:
`install_path`, `entry`, `scope`, `summary`).
```lua
{
name = "samplepkg", -- manifest's name
version = "1.0.0", -- manifest's declared version
tag = "v1.0.0", -- resolution descriptor (see below)
commit = "abc...", -- full SHA of the installed snapshot
install_path = "...",
entry = "...",
scope = "user", -- or "project"
summary = "...",
pin = { -- structured user request
kind = "version", -- or "branch" or "commit"
value = "^1.0.0", -- echoes the spec field exactly
},
}
```
The `tag` field is a stable, non-empty descriptor:
- For version pins: the matched tag (`"v1.2.3"`).
- For branch pins: `"branch:<name>"`.
- For commit pins: `"commit:<short-sha>"`.
The `pin` table is the source of truth for "what did the user
request." The flat fields (`tag`, `version`, `commit`) record the
resolution. They differ for branch/commit pins, where the resolved
commit is what got installed but the user's request was the branch
name or SHA prefix.
## `pmacs.packages.update(...)` ## `pmacs.packages.update(...)`

View File

@ -29,12 +29,16 @@
//! later release will make it configurable. //! later release will make it configurable.
use std::collections::VecDeque; use std::collections::VecDeque;
use std::io::{Read, Write}; use std::io::{self, Read, Write};
use std::net::Shutdown; use std::net::Shutdown;
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex, mpsc}; use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
};
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
@ -210,7 +214,8 @@ impl From<TransportError> for AttachError {
/// ///
/// Each transport builds its own `AttachIo` from the primitives it has /// Each transport builds its own `AttachIo` from the primitives it has
/// available. The local-socket transport (M5.5g) clones a `UnixStream` /// available. The local-socket transport (M5.5g) clones a `UnixStream`
/// three ways and uses `shutdown(Read)` for the kick. The SSH /// three ways and uses a kick-aware reader plus socket shutdown for
/// the kick. The SSH
/// transport (M5.7e) takes a child process's `stdout` and `stdin` /// transport (M5.7e) takes a child process's `stdout` and `stdin`
/// halves and uses `SIGTERM` to the child for the kick. /// halves and uses `SIGTERM` to the child for the kick.
pub(crate) struct AttachIo { pub(crate) struct AttachIo {
@ -229,10 +234,9 @@ pub(crate) struct AttachIo {
/// The reader thread is presumed to be blocked on a read; this /// The reader thread is presumed to be blocked on a read; this
/// wakes it. /// wakes it.
/// ///
/// Implementations may be destructive (SSH transport `SIGTERM`s /// Implementations may be destructive. Callers do not distinguish
/// the child) or non-destructive (local-socket transport calls /// — by the time the kick runs, the pump has already decided to
/// `shutdown(Read)`). Callers do not distinguish — by the time /// exit.
/// the kick runs, the pump has already decided to exit.
pub kick: Box<dyn FnOnce() + Send>, pub kick: Box<dyn FnOnce() + Send>,
} }
@ -318,21 +322,83 @@ pub fn run_attach(socket_path: PathBuf) -> Result<(), AttachError> {
/// Build an [`AttachIo`] for a connected `UnixStream`. /// Build an [`AttachIo`] for a connected `UnixStream`.
/// ///
/// The kick clones a third handle for `shutdown(Read)`. Cloning may /// The kick sets a shared flag and clones a third handle for
/// `shutdown(Both)`. Cloning may
/// fail (rare — the kernel is out of file descriptors), in which /// fail (rare — the kernel is out of file descriptors), in which
/// case the caller propagates the error before raw mode engages. /// case the caller propagates the error before raw mode engages.
fn build_local_socket_io(stream: UnixStream) -> Result<AttachIo, std::io::Error> { fn build_local_socket_io(stream: UnixStream) -> Result<AttachIo, std::io::Error> {
let reader = stream.try_clone()?; let reader = stream.try_clone()?;
reader.set_nonblocking(true)?;
let kick_handle = stream.try_clone()?; let kick_handle = stream.try_clone()?;
let kicked = Arc::new(AtomicBool::new(false));
let reader_kicked = Arc::clone(&kicked);
Ok(AttachIo { Ok(AttachIo {
reader: Box::new(reader), reader: Box::new(KickAwareUnixReader {
stream: reader,
kicked: reader_kicked,
}),
writer: Box::new(stream), writer: Box::new(stream),
kick: Box::new(move || { kick: Box::new(move || {
let _ = kick_handle.shutdown(Shutdown::Read); kicked.store(true, Ordering::SeqCst);
let _ = kick_handle.shutdown(Shutdown::Both);
}), }),
}) })
} }
/// Non-blocking poll-based reader with a kick flag.
///
/// # Wake semantics
///
/// This reader has two cooperating wake paths, only one of which is
/// load-bearing:
///
/// 1. **Atomic flag (correctness):** the reader runs a non-blocking
/// poll loop with a 10ms sleep between iterations. After the kick
/// sets `kicked`, the next loop iteration observes it and returns
/// `Ok(0)`. Worst-case wake latency is one poll cycle (~10ms).
/// This path is platform-independent and is the mechanism the
/// caller relies on for correctness.
///
/// 2. **`shutdown(Both)` on a sibling clone (best-effort speedup):**
/// if the reader happens to be inside `self.stream.read()` when
/// the kick fires, and the platform honors cross-clone shutdown
/// wakes, the read returns `Ok(0)` immediately and the loop
/// skips its sleep. This path is **not** load-bearing — Unix
/// socket cross-clone shutdown semantics are not portably
/// guaranteed, and any wake it provides is a bonus on top of
/// path 1.
///
/// In other words: the atomic flag wakes the reader; the shutdown
/// just shaves up to ~10ms off the wake when the platform plays
/// along. Tests asserting wake bounds should treat the budget as
/// "≤ one poll cycle plus scheduler jitter," not as a measure of
/// shutdown latency.
struct KickAwareUnixReader {
stream: UnixStream,
kicked: Arc<AtomicBool>,
}
impl Read for KickAwareUnixReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
loop {
match self.stream.read(buf) {
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
if self.kicked.load(Ordering::SeqCst) {
return Ok(0);
}
thread::sleep(Duration::from_millis(10));
}
other => return other,
}
}
}
}
fn build_capabilities() -> FrontendCapabilities { fn build_capabilities() -> FrontendCapabilities {
// The v0.1 TUI implements all of these; we report them honestly // The v0.1 TUI implements all of these; we report them honestly
// so the daemon doesn't strip features that work fine. // so the daemon doesn't strip features that work fine.
@ -487,7 +553,7 @@ pub(crate) fn run_attach_pair(
// reader needs `kick` to wake. // reader needs `kick` to wake.
// //
// 2. `kick()` — wake the reader thread, by any means necessary // 2. `kick()` — wake the reader thread, by any means necessary
// (per the kick contract). For local-socket: `shutdown(Read)`. // (per the kick contract). For local-socket: `shutdown(Both)`.
// For SSH: a watchdog that SIGTERMs the child if the EOF // For SSH: a watchdog that SIGTERMs the child if the EOF
// cascade hasn't reached the reader within the watchdog's // cascade hasn't reached the reader within the watchdog's
// grace period. // grace period.
@ -1350,6 +1416,27 @@ mod tests {
} }
} }
/// Test-only `Read` wrapper that flips an `AtomicBool` whenever
/// its inner reader is called. Used to synchronize the test
/// against "the reader thread has entered its read call" without
/// resorting to wall-clock sleeps.
///
/// External wrapper by design: production types stay free of
/// test-only hooks. The signal fires on every `read` call (not
/// just the first); the test only cares about observing it
/// transition once, so cheap repeated stores are harmless.
struct EnteredReadSignaler<R: Read> {
inner: R,
entered: Arc<AtomicBool>,
}
impl<R: Read> Read for EnteredReadSignaler<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.entered.store(true, Ordering::Release);
self.inner.read(buf)
}
}
/// Build an `AttachIo` with a kick that increments `counter`. /// Build an `AttachIo` with a kick that increments `counter`.
fn pipe_io_with_counting_kick(socket: UnixStream, counter: Arc<AtomicUsize>) -> AttachIo { fn pipe_io_with_counting_kick(socket: UnixStream, counter: Arc<AtomicUsize>) -> AttachIo {
let reader = socket.try_clone().expect("try_clone reader"); let reader = socket.try_clone().expect("try_clone reader");
@ -1459,7 +1546,34 @@ mod tests {
} }
#[test] #[test]
fn kick_wakes_blocked_reader_within_one_second() { fn kick_wakes_blocked_reader() {
// What this test asserts: after `kick()` fires, the reader
// thread terminates. That is, the kick mechanism wakes a
// reader that would otherwise wait on the socket forever.
//
// What this test does NOT assert: a specific wake latency.
// The 5s join bound is intentionally generous so that CI
// scheduler jitter (heavily loaded VM hosts can stall threads
// for hundreds of ms cumulatively) does not turn a correctness
// test into a flake. The steady-state wake budget per
// `KickAwareUnixReader`'s contract is ~10ms (one poll cycle),
// but observing that bound under timing pressure is not what
// this test is for. **Do not tighten the 5s bound back toward
// 1s on the grounds that 5s is much larger than the
// steady-state budget** — the steady-state budget is not what
// is being tested. A real bug (kick mechanism is broken, the
// reader runs forever) hits this bound; CI jitter does not
// come close.
//
// Synchronization: rather than guessing how long the reader
// thread takes to start with `thread::sleep`, the test wraps
// the reader in an `EnteredReadSignaler` that flips an
// `AtomicBool` when the inner reader is first called. The
// test spins on that flag (bounded) so kick fires only once
// we know the reader is actively reading from the socket.
// No wall-clock guesses; no test-only paths in production
// types.
// Hold the daemon side so the kick is the only thing that can // Hold the daemon side so the kick is the only thing that can
// wake the reader. If we let the daemon side close, the reader // wake the reader. If we let the daemon side close, the reader
// sees EOF naturally and we'd be testing nothing. // sees EOF naturally and we'd be testing nothing.
@ -1471,11 +1585,25 @@ mod tests {
kick, kick,
} = io; } = io;
let (tx, _rx) = mpsc::channel::<InstanceMessage>(); let entered = Arc::new(AtomicBool::new(false));
let reader_handle = thread::spawn(move || run_reader(reader, tx)); let signaling_reader: Box<dyn Read + Send> = Box::new(EnteredReadSignaler {
inner: reader,
entered: Arc::clone(&entered),
});
// Let the reader actually start blocking on its read. let (tx, _rx) = mpsc::channel::<InstanceMessage>();
thread::sleep(Duration::from_millis(50)); let reader_handle = thread::spawn(move || run_reader(signaling_reader, tx));
// Wait for the reader to enter its read call. Bounded so a
// never-spawning reader fails the test instead of hanging.
let entry_deadline = Instant::now() + Duration::from_secs(1);
while !entered.load(Ordering::Acquire) {
assert!(
Instant::now() < entry_deadline,
"reader thread did not enter its read call within 1s",
);
thread::sleep(Duration::from_millis(1));
}
kick(); kick();
@ -1487,8 +1615,8 @@ mod tests {
let _ = done_tx.send(()); let _ = done_tx.send(());
}); });
done_rx done_rx
.recv_timeout(Duration::from_secs(1)) .recv_timeout(Duration::from_secs(5))
.expect("reader thread must exit within 1s after kick"); .expect("reader thread must exit within 5s after kick");
} }
#[test] #[test]

View File

@ -702,11 +702,6 @@ impl Buffer {
MarkGravity::Right => new_end, MarkGravity::Right => new_end,
} }
} }
} else if pos < end {
match mark.gravity {
MarkGravity::Left => start,
MarkGravity::Right => new_end,
}
} else { } else {
match mark.gravity { match mark.gravity {
MarkGravity::Left => start, MarkGravity::Left => start,

View File

@ -59,7 +59,8 @@ use crate::hook::{Hook, HookRegistry};
use crate::key::{display_sequence, parse_sequence}; use crate::key::{display_sequence, parse_sequence};
use crate::keymap_stack::KeymapStack; use crate::keymap_stack::KeymapStack;
use crate::packages::{ use crate::packages::{
Address, Fetcher, InstallError, InstallScope, InstallSpec, InstalledPackage, Installer, Address, Fetcher, InstallError, InstallPin, InstallScope, InstallSpec, InstalledPackage,
Installer,
}; };
use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity}; use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity};
use crate::rope::Range; use crate::rope::Range;
@ -626,6 +627,21 @@ pub enum BindingError {
)] )]
InstallSpecMissingAddress, InstallSpecMissingAddress,
/// A `pmacs.packages.install{...}` spec table specified more than
/// one of `version`, `branch`, `commit`. Each install must pin
/// exactly one revision; combining pin kinds is ambiguous (which
/// one wins?). The error message names every conflicting field
/// the spec actually carried.
#[error(
"pmacs.packages.install: spec must specify exactly one of \
`version`, `branch`, or `commit`; got: {fields}"
)]
InstallSpecConflictingPins {
/// Comma-separated list of the offending field names, in
/// the order they appeared on the table.
fields: String,
},
/// `install_project` was called without an explicit /// `install_project` was called without an explicit
/// `project_root` field. The CWD-fallback was removed because at /// `project_root` field. The CWD-fallback was removed because at
/// init time CWD is whatever directory the user happened to /// init time CWD is whatever directory the user happened to
@ -1744,6 +1760,7 @@ fn install_instance_show_binding(lua: &Lua, registry: &SharedRegistry) -> mlua::
}) })
} }
#[allow(clippy::too_many_lines)]
fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<Table> { fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<Table> {
let buffer = lua.create_table()?; let buffer = lua.create_table()?;
@ -1873,7 +1890,7 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
let handle = StyleOverlayHandleLua { let handle = StyleOverlayHandleLua {
spans: Arc::clone(&spans), spans: Arc::clone(&spans),
}; };
attach_style_overlay_to_visible_windows(lua, id.0, spans); attach_style_overlay_to_visible_windows(lua, id.0, &spans);
Ok(handle) Ok(handle)
}, },
)?, )?,
@ -1885,7 +1902,7 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
"attach_style_overlay", "attach_style_overlay",
lua.create_function( lua.create_function(
move |lua, (id, handle): (BufferIdLua, StyleOverlayHandleLua)| { move |lua, (id, handle): (BufferIdLua, StyleOverlayHandleLua)| {
attach_style_overlay_to_visible_windows(lua, id.0, Arc::clone(&handle.spans)); attach_style_overlay_to_visible_windows(lua, id.0, &handle.spans);
Ok(()) Ok(())
}, },
)?, )?,
@ -1912,7 +1929,7 @@ fn parse_mark_gravity(opts: Option<&Table>) -> mlua::Result<MarkGravity> {
fn attach_style_overlay_to_visible_windows( fn attach_style_overlay_to_visible_windows(
lua: &Lua, lua: &Lua,
buffer_id: BufferId, buffer_id: BufferId,
spans: crate::overlay::SharedBufferStyleSpans, spans: &crate::overlay::SharedBufferStyleSpans,
) { ) {
let Some(core) = lua.app_data_ref::<SharedCore>() else { let Some(core) = lua.app_data_ref::<SharedCore>() else {
return; return;
@ -1921,7 +1938,7 @@ fn attach_style_overlay_to_visible_windows(
for win in core.windows.values_mut() { for win in core.windows.values_mut() {
if win.buffer_id == buffer_id { if win.buffer_id == buffer_id {
win.push_overlay(Box::new(crate::overlay::BufferStyleOverlay::new( win.push_overlay(Box::new(crate::overlay::BufferStyleOverlay::new(
Arc::clone(&spans), Arc::clone(spans),
))); )));
} }
} }
@ -2055,7 +2072,7 @@ fn install_packages_module(lua: &Lua) -> mlua::Result<Table> {
} }
/// Register a custom searcher in `package.searchers` (Lua 5.4) / /// Register a custom searcher in `package.searchers` (Lua 5.4) /
/// `package.loaders` (Lua 5.1, LuaJIT) that consults the /// `package.loaders` (Lua 5.1, `LuaJIT`) that consults the
/// [`InstalledPackages`] roster at require time. /// [`InstalledPackages`] roster at require time.
/// ///
/// # Why /// # Why
@ -2089,7 +2106,7 @@ fn install_packages_module(lua: &Lua) -> mlua::Result<Table> {
/// ///
/// # 5.1 vs 5.4 names /// # 5.1 vs 5.4 names
/// ///
/// Lua 5.1 / LuaJIT exposes the searcher list as `package.loaders`; /// Lua 5.1 / `LuaJIT` exposes the searcher list as `package.loaders`;
/// Lua 5.2+ renamed it to `package.searchers`. Both are tables of /// Lua 5.2+ renamed it to `package.searchers`. Both are tables of
/// functions with the same callback shape. We probe `searchers` /// functions with the same callback shape. We probe `searchers`
/// first and fall back to `loaders` so the same code works under /// first and fall back to `loaders` so the same code works under
@ -2101,51 +2118,44 @@ fn register_package_searcher(lua: &Lua) -> mlua::Result<()> {
None => package.get::<Table>("loaders")?, None => package.get::<Table>("loaders")?,
}; };
let searcher = lua.create_function( let searcher = lua.create_function(|lua, name: String| -> mlua::Result<mlua::Value> {
|lua, name: String| -> mlua::Result<mlua::Value> { let Some(slot) = lua.app_data_ref::<InstalledPackages>() else {
let Some(slot) = lua.app_data_ref::<InstalledPackages>() else { // Slot uninstalled (shouldn't happen under
// Slot uninstalled (shouldn't happen under // production wiring, but a defensive nil keeps
// production wiring, but a defensive nil keeps // require working under unusual test setups).
// require working under unusual test setups). return Ok(mlua::Value::Nil);
return Ok(mlua::Value::Nil); };
}; let snapshot = slot.snapshot();
let snapshot = slot.snapshot(); // Most-recent-first: a project-scope install of a
// Most-recent-first: a project-scope install of a // basename overrides a prior user-scope install.
// basename overrides a prior user-scope install. for pkg in snapshot.iter().rev() {
for pkg in snapshot.iter().rev() { if pkg.install_basename() != name {
if pkg.install_basename() != name { continue;
continue;
}
let entry = pkg.entry_path();
let bytes = match std::fs::read(&entry) {
Ok(b) => b,
Err(e) => {
// Searcher convention: a non-function return
// is treated as "not found, here's why" and
// appended to the require error message.
let s = lua.create_string(&format!(
"\n\tinstalled pmacs package '{name}' \
entry `{}` could not be read: {e}",
entry.display()
))?;
return Ok(mlua::Value::String(s));
}
};
let chunk_name = format!("@{}", entry.display());
let func = lua
.load(&bytes)
.set_name(&chunk_name)
.into_function()?;
return Ok(mlua::Value::Function(func));
} }
// No installed package matches. Return a string so Lua let entry = pkg.entry_path();
// appends our reason to the aggregate require error. let bytes = match std::fs::read(&entry) {
let s = lua.create_string(&format!( Ok(b) => b,
"\n\tno installed pmacs package named '{name}'" Err(e) => {
))?; // Searcher convention: a non-function return
Ok(mlua::Value::String(s)) // is treated as "not found, here's why" and
}, // appended to the require error message.
)?; let s = lua.create_string(format!(
"\n\tinstalled pmacs package '{name}' \
entry `{}` could not be read: {e}",
entry.display()
))?;
return Ok(mlua::Value::String(s));
}
};
let chunk_name = format!("@{}", entry.display());
let func = lua.load(&bytes).set_name(&chunk_name).into_function()?;
return Ok(mlua::Value::Function(func));
}
// No installed package matches. Return a string so Lua
// appends our reason to the aggregate require error.
let s = lua.create_string(format!("\n\tno installed pmacs package named '{name}'"))?;
Ok(mlua::Value::String(s))
})?;
// Append to the searcher list. Lua tables are 1-indexed; the // Append to the searcher list. Lua tables are 1-indexed; the
// new searcher runs after every existing searcher (preload, // new searcher runs after every existing searcher (preload,
@ -2184,18 +2194,10 @@ fn parse_lua_install_spec(value: &Value) -> mlua::Result<InstallSpec> {
} }
}, },
}; };
let version_str: String = t
.get::<String>("version")
.unwrap_or_else(|_| "*".to_string());
let address = Address::parse(&address_str) let address = Address::parse(&address_str)
.map_err(|e| mlua::Error::external(BindingError::from(InstallError::Address(e))))?; .map_err(|e| mlua::Error::external(BindingError::from(InstallError::Address(e))))?;
let version = semver::VersionReq::parse(&version_str).map_err(|e| { let pin = parse_install_pin(t)?;
mlua::Error::external(BindingError::from(InstallError::InvalidVersionReq { Ok(InstallSpec { address, pin })
value: version_str,
cause: e.to_string(),
}))
})?;
Ok(InstallSpec { address, version })
} }
other => Err(mlua::Error::external(BindingError::InstallSpecWrongType { other => Err(mlua::Error::external(BindingError::InstallSpecWrongType {
got: other.type_name().to_string(), got: other.type_name().to_string(),
@ -2203,6 +2205,57 @@ fn parse_lua_install_spec(value: &Value) -> mlua::Result<InstallSpec> {
} }
} }
/// Parse the pin fields from a `pmacs.packages.install{...}` table.
///
/// A spec table may carry exactly one of:
/// - `version = "<semver constraint>"` (e.g. `"^1.0.0"`, `"=2.3.4"`).
/// - `branch = "<branch name>"` (e.g. `"main"`).
/// - `commit = "<sha>"` (full or partial; the fetcher accepts either).
///
/// If none are present the pin defaults to `version = "*"` (any
/// tag). If two or more are present the parse fails with
/// [`BindingError::InstallSpecConflictingPins`] naming every field
/// that conflicted.
fn parse_install_pin(t: &Table) -> mlua::Result<InstallPin> {
let version: Option<String> = t.get::<Option<String>>("version").unwrap_or(None);
let branch: Option<String> = t.get::<Option<String>>("branch").unwrap_or(None);
let commit: Option<String> = t.get::<Option<String>>("commit").unwrap_or(None);
let mut present: Vec<&'static str> = Vec::new();
let version = version.filter(|s| !s.is_empty());
let branch = branch.filter(|s| !s.is_empty());
let commit = commit.filter(|s| !s.is_empty());
if version.is_some() {
present.push("version");
}
if branch.is_some() {
present.push("branch");
}
if commit.is_some() {
present.push("commit");
}
if present.len() > 1 {
return Err(mlua::Error::external(
BindingError::InstallSpecConflictingPins {
fields: present.join(", "),
},
));
}
if let Some(b) = branch {
return Ok(InstallPin::Branch(b));
}
if let Some(c) = commit {
return Ok(InstallPin::Commit(c));
}
let value = version.unwrap_or_else(|| "*".to_string());
let req = semver::VersionReq::parse(&value).map_err(|e| {
mlua::Error::external(BindingError::from(InstallError::InvalidVersionReq {
value,
cause: e.to_string(),
}))
})?;
Ok(InstallPin::Version(req))
}
/// Read the required `project_root = "..."` field from the table form /// Read the required `project_root = "..."` field from the table form
/// of `install_project`'s spec. /// of `install_project`'s spec.
/// ///
@ -2374,6 +2427,15 @@ fn installed_package_to_lua(lua: &Lua, pkg: &InstalledPackage) -> mlua::Result<T
}, },
)?; )?;
t.set("summary", pkg.manifest.summary.as_str())?; t.set("summary", pkg.manifest.summary.as_str())?;
// Structured pin info: `{ kind = "version"|"branch"|"commit", value = <user-supplied string> }`.
// Existing flat fields (`tag`, `version`, `commit`) remain
// populated for backward-compatible introspection; the `pin`
// table is the source of truth for "what did the user request",
// distinct from "what got resolved".
let pin_table = lua.create_table_with_capacity(0, 2)?;
pin_table.set("kind", pkg.pin.kind())?;
pin_table.set("value", pkg.pin.value())?;
t.set("pin", pin_table)?;
Ok(t) Ok(t)
} }

View File

@ -104,13 +104,71 @@ fn xdg_data_root() -> Result<PathBuf, InstallError> {
// InstallSpec // InstallSpec
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// A normalized install request: where to fetch and what version to pick. /// What the user pinned the install to. Mutually exclusive on the
/// Lua side: a spec table may carry exactly one of `version`,
/// `branch`, or `commit`.
///
/// # Why three kinds
///
/// - [`Self::Version`] is the default, recommended path: the
/// installer picks the highest semver tag matching the constraint
/// and validates the manifest's declared version against the same
/// constraint. Lockfile reproduction (M7.6) records the resolved
/// commit so a later install at the same constraint yields the
/// same revision.
/// - [`Self::Branch`] follows a moving target. Each install
/// re-resolves the branch's HEAD; the install is *not*
/// reproducible across time. Useful for development against an
/// upstream's `main` or for a private package whose semver
/// discipline is not yet established.
/// - [`Self::Commit`] freezes the install at a specific revision.
/// 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)]
pub enum InstallPin {
/// Highest semver tag satisfying the constraint.
Version(VersionReq),
/// HEAD of the named branch at install time.
Branch(String),
/// Specific commit (full or partial SHA; the fetcher accepts
/// either via `git rev-parse`).
Commit(String),
}
impl InstallPin {
/// Stable string discriminator used at the Lua boundary
/// (`installed_package_to_lua`) and in error messages.
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::Version(_) => "version",
Self::Branch(_) => "branch",
Self::Commit(_) => "commit",
}
}
/// User-supplied value as a string: the constraint for
/// [`Self::Version`], the branch name for [`Self::Branch`], the
/// SHA for [`Self::Commit`].
#[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(),
}
}
}
/// A normalized install request: where to fetch and how to pin the
/// revision.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct InstallSpec { pub struct InstallSpec {
/// Address (resolved via `Address::parse`). /// Address (resolved via `Address::parse`).
pub address: Address, pub address: Address,
/// Semver constraint to match against the upstream's tags. /// What the user pinned the install to. See [`InstallPin`].
pub version: VersionReq, pub pin: InstallPin,
} }
impl InstallSpec { impl InstallSpec {
@ -126,6 +184,14 @@ impl InstallSpec {
/// has no `@` separator and [`InstallError::Address`] / /// has no `@` separator and [`InstallError::Address`] /
/// [`InstallError::InvalidVersionReq`] for the underlying parse /// [`InstallError::InvalidVersionReq`] for the underlying parse
/// failures. /// failures.
///
/// The shorthand string form is **version-pin only**. Branch and
/// commit pins must use the Lua table form
/// (`{ "addr", branch = "..." }` / `{ "addr", commit = "..." }`)
/// because there is no concise sigil that disambiguates a
/// branch/commit value from a semver constraint without
/// surprising users (`@main` could be a branch named "main" or
/// a malformed semver --- ambiguous).
pub fn parse_shorthand(s: &str) -> Result<Self, InstallError> { pub fn parse_shorthand(s: &str) -> Result<Self, InstallError> {
let (addr, ver) = let (addr, ver) =
s.rsplit_once('@') s.rsplit_once('@')
@ -142,7 +208,10 @@ impl InstallSpec {
value: ver.to_string(), value: ver.to_string(),
cause: e.to_string(), cause: e.to_string(),
})?; })?;
Ok(Self { address, version }) Ok(Self {
address,
pin: InstallPin::Version(version),
})
} }
} }
@ -159,12 +228,26 @@ pub struct InstalledPackage {
pub install_path: PathBuf, pub install_path: PathBuf,
/// 40-char commit hash of the installed snapshot. /// 40-char commit hash of the installed snapshot.
pub commit: String, pub commit: String,
/// The tag that was matched (e.g., `v1.0.0` or `1.0.0`). /// A descriptor of what was installed:
/// - For [`InstallPin::Version`]: the matched tag, e.g. `"v1.0.0"`.
/// - For [`InstallPin::Branch`]: `"branch:<name>"`.
/// - For [`InstallPin::Commit`]: `"commit:<short-sha>"`.
///
/// Always non-empty so Lua callers can use it as a stable
/// "what got installed" label without nil-checking.
pub tag: String, pub tag: String,
/// The semver value parsed from `tag` (canonical numeric form). /// Semver version of the installed snapshot. For
/// [`InstallPin::Version`] this is the version parsed from the
/// matched tag; for [`InstallPin::Branch`] / [`InstallPin::Commit`]
/// it falls back to `manifest.version` (the package's declared
/// version at the resolved revision).
pub version: Version, pub version: Version,
/// The install scope this package was installed under. /// The install scope this package was installed under.
pub scope: InstallScope, pub scope: InstallScope,
/// What the user originally pinned this install to. Useful for
/// lockfile generation (M7.6) and for surfacing to the Lua
/// `installed()` snapshot.
pub pin: InstallPin,
} }
impl InstalledPackage { impl InstalledPackage {
@ -243,33 +326,54 @@ impl Installer {
} }
/// Install one package. See module docs for the step-by-step flow. /// Install one package. See module docs for the step-by-step flow.
#[allow(clippy::too_many_lines)]
pub fn install(&self, spec: &InstallSpec) -> Result<InstalledPackage, InstallError> { pub fn install(&self, spec: &InstallSpec) -> Result<InstalledPackage, InstallError> {
let url = spec.address.to_git_url(); let url = spec.address.to_git_url();
let bare = self.fetcher.fetch(&url).map_err(InstallError::Fetch)?; let bare = self.fetcher.fetch(&url).map_err(InstallError::Fetch)?;
// 2. Pick best matching tag. // Resolve the user's pin to a concrete (commit, tag-descriptor)
let tags = self.fetcher.list_tags(&bare).map_err(InstallError::Fetch)?; // pair. The descriptor is what we display to users in the
let chosen = // `tag` field of the resulting `InstalledPackage`.
best_match(&tags, &spec.version).ok_or_else(|| InstallError::NoMatchingVersion { let (commit, tag_descriptor) = match &spec.pin {
address: url.clone(), InstallPin::Version(req) => {
req: spec.version.to_string(), let tags = self.fetcher.list_tags(&bare).map_err(InstallError::Fetch)?;
available: tags.clone(), 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}"))
}
};
// 3. Resolve to commit. // Read the manifest at this commit so we know the install dir name.
let commit = self
.fetcher
.resolve(&bare, &RefSpec::Tag(chosen.tag.clone()))
.map_err(InstallError::Fetch)?;
// 4. Read manifest at this commit so we know the install dir name.
let manifest_bytes = self let manifest_bytes = self
.fetcher .fetcher
.show_blob(&bare, &commit, "pmacs.toml") .show_blob(&bare, &commit, "pmacs.toml")
.map_err(|e| match e { .map_err(|e| match e {
FetchError::GitInvocation { stderr, .. } => InstallError::ManifestMissing { FetchError::GitInvocation { stderr, .. } => InstallError::ManifestMissing {
address: url.clone(), address: url.clone(),
tag: chosen.tag.clone(), tag: tag_descriptor.clone(),
cause: stderr, cause: stderr,
}, },
other => InstallError::Fetch(other), other => InstallError::Fetch(other),
@ -277,42 +381,43 @@ impl Installer {
let manifest_str = let manifest_str =
std::str::from_utf8(&manifest_bytes).map_err(|_| InstallError::ManifestNotUtf8 { std::str::from_utf8(&manifest_bytes).map_err(|_| InstallError::ManifestNotUtf8 {
address: url.clone(), address: url.clone(),
tag: chosen.tag.clone(), tag: tag_descriptor.clone(),
})?; })?;
let manifest = PackageManifest::from_toml(manifest_str).map_err(InstallError::Manifest)?; let manifest = PackageManifest::from_toml(manifest_str).map_err(InstallError::Manifest)?;
// Refuse to install a package whose `pmacs_required` constraint // Refuse to install a package whose `pmacs_required` constraint
// does not match the running pmacs version. The manifest field // does not match the running pmacs version. Applies to every
// is a hard contract, not advisory: if a package declares // pin kind: a package's declared API requirements are
// `pmacs_required = ">=2.0.0"` and we're 1.x, the package is // independent of how the user pinned the revision.
// free to call APIs we do not yet expose, and the failure
// would manifest as a runtime Lua traceback rather than a
// typed install-time error.
let running_pmacs = running_pmacs_version(); let running_pmacs = running_pmacs_version();
if !manifest.pmacs_required.matches(&running_pmacs) { if !manifest.pmacs_required.matches(&running_pmacs) {
return Err(InstallError::PmacsVersionIncompatible { return Err(InstallError::PmacsVersionIncompatible {
address: url.clone(), address: url.clone(),
tag: chosen.tag.clone(), tag: tag_descriptor.clone(),
required: manifest.pmacs_required.to_string(), required: manifest.pmacs_required.to_string(),
running: running_pmacs.to_string(), running: running_pmacs.to_string(),
}); });
} }
// Sanity: the manifest's `version` should equal the resolved tag. // For version pins only: cross-check that the manifest's
// We don't reject mismatches (some upstreams version-tag asymmetrically), // declared version satisfies the constraint. The matched tag
// but we do require it to satisfy the requested constraint. The tag // already satisfies it (we chose it that way); the strict
// already satisfies the constraint (we chose it that way), so the // check is on the manifest, which catches packages whose tag
// strict check is on the manifest. // and pmacs.toml version disagree. Branch and commit pins
if !spec.version.matches(&manifest.version) { // skip this check --- the user explicitly asked for that
return Err(InstallError::ManifestVersionMismatch { // revision regardless of what the manifest says.
address: url.clone(), if let InstallPin::Version(req) = &spec.pin {
tag: chosen.tag.clone(), if !req.matches(&manifest.version) {
manifest_version: manifest.version.to_string(), return Err(InstallError::ManifestVersionMismatch {
req: spec.version.to_string(), address: url.clone(),
}); tag: tag_descriptor.clone(),
manifest_version: manifest.version.to_string(),
req: req.to_string(),
});
}
} }
// 5. Archive + extract. // Archive + extract.
let install_root = self.install_root()?; let install_root = self.install_root()?;
let basename = package_basename(manifest.name.as_str()); let basename = package_basename(manifest.name.as_str());
let install_path = install_root.join(basename); let install_path = install_root.join(basename);
@ -326,12 +431,13 @@ impl Installer {
match existing { match existing {
Some(prev) if prev == commit => { Some(prev) if prev == commit => {
return Ok(InstalledPackage { return Ok(InstalledPackage {
version: manifest.version.clone(),
manifest, manifest,
install_path, install_path,
commit, commit,
tag: chosen.tag, tag: tag_descriptor,
version: chosen.version,
scope: self.scope.clone(), scope: self.scope.clone(),
pin: spec.pin.clone(),
}); });
} }
_ => { _ => {
@ -361,12 +467,13 @@ impl Installer {
write_install_marker(&install_path, &commit)?; write_install_marker(&install_path, &commit)?;
Ok(InstalledPackage { Ok(InstalledPackage {
version: manifest.version.clone(),
manifest, manifest,
install_path, install_path,
commit, commit,
tag: chosen.tag, tag: tag_descriptor,
version: chosen.version,
scope: self.scope.clone(), scope: self.scope.clone(),
pin: spec.pin.clone(),
}) })
} }
} }
@ -782,7 +889,10 @@ exports = ["samplepkg"]
fn shorthand_parses_github_address_with_caret_constraint() { fn shorthand_parses_github_address_with_caret_constraint() {
let s = InstallSpec::parse_shorthand("github:user/repo@^1.0.0").unwrap(); let s = InstallSpec::parse_shorthand("github:user/repo@^1.0.0").unwrap();
assert!(matches!(s.address, Address::Github { .. })); assert!(matches!(s.address, Address::Github { .. }));
assert_eq!(s.version.to_string(), "^1.0.0"); match &s.pin {
InstallPin::Version(req) => assert_eq!(req.to_string(), "^1.0.0"),
other => panic!("expected Version pin, got {other:?}"),
}
} }
#[test] #[test]
@ -790,7 +900,10 @@ exports = ["samplepkg"]
// SSH shorthand: `git:git@host:path`. The `@` in `git@host` // SSH shorthand: `git:git@host:path`. The `@` in `git@host`
// must not be confused with the version separator. // must not be confused with the version separator.
let s = InstallSpec::parse_shorthand("git:git@host:path/repo.git@=1.2.3").unwrap(); let s = InstallSpec::parse_shorthand("git:git@host:path/repo.git@=1.2.3").unwrap();
assert_eq!(s.version.to_string(), "=1.2.3"); match &s.pin {
InstallPin::Version(req) => assert_eq!(req.to_string(), "=1.2.3"),
other => panic!("expected Version pin, got {other:?}"),
}
if let Address::Url(u) = s.address { if let Address::Url(u) = s.address {
assert_eq!(u, "git@host:path/repo.git"); assert_eq!(u, "git@host:path/repo.git");
} else { } else {
@ -876,7 +989,7 @@ exports = ["samplepkg"]
let spec = InstallSpec { let spec = InstallSpec {
address: Address::Url(file_url(&bare)), address: Address::Url(file_url(&bare)),
version: VersionReq::parse("^1.0").unwrap(), pin: InstallPin::Version(VersionReq::parse("^1.0").unwrap()),
}; };
let installed = installer.install(&spec).unwrap(); let installed = installer.install(&spec).unwrap();
@ -918,7 +1031,7 @@ exports = ["samplepkg"]
let spec = InstallSpec { let spec = InstallSpec {
address: Address::Url(file_url(&bare)), address: Address::Url(file_url(&bare)),
version: VersionReq::parse("=1.0.0").unwrap(), pin: InstallPin::Version(VersionReq::parse("=1.0.0").unwrap()),
}; };
let installed = installer.install(&spec).unwrap(); let installed = installer.install(&spec).unwrap();
assert_eq!(installed.tag, "v1.0.0"); assert_eq!(installed.tag, "v1.0.0");
@ -933,7 +1046,7 @@ exports = ["samplepkg"]
let spec = InstallSpec { let spec = InstallSpec {
address: Address::Url(file_url(&bare)), address: Address::Url(file_url(&bare)),
version: VersionReq::parse(">=2.0.0").unwrap(), pin: InstallPin::Version(VersionReq::parse(">=2.0.0").unwrap()),
}; };
let err = installer.install(&spec).unwrap_err(); let err = installer.install(&spec).unwrap_err();
match err { match err {
@ -953,7 +1066,7 @@ exports = ["samplepkg"]
let spec = InstallSpec { let spec = InstallSpec {
address: Address::Url(file_url(&bare)), address: Address::Url(file_url(&bare)),
version: VersionReq::parse("=1.0.0").unwrap(), pin: InstallPin::Version(VersionReq::parse("=1.0.0").unwrap()),
}; };
let first = installer.install(&spec).unwrap(); let first = installer.install(&spec).unwrap();
// Drop a sentinel; idempotent re-install should not blow it away. // Drop a sentinel; idempotent re-install should not blow it away.
@ -976,14 +1089,14 @@ exports = ["samplepkg"]
// First install at 1.0.0. // First install at 1.0.0.
let spec_v1 = InstallSpec { let spec_v1 = InstallSpec {
address: Address::Url(file_url(&bare)), address: Address::Url(file_url(&bare)),
version: VersionReq::parse("=1.0.0").unwrap(), pin: InstallPin::Version(VersionReq::parse("=1.0.0").unwrap()),
}; };
installer.install(&spec_v1).unwrap(); installer.install(&spec_v1).unwrap();
// Second install at 1.1.0 to the same install path: refuse. // Second install at 1.1.0 to the same install path: refuse.
let spec_v2 = InstallSpec { let spec_v2 = InstallSpec {
address: Address::Url(file_url(&bare)), address: Address::Url(file_url(&bare)),
version: VersionReq::parse("=1.1.0").unwrap(), pin: InstallPin::Version(VersionReq::parse("=1.1.0").unwrap()),
}; };
let err = installer.install(&spec_v2).unwrap_err(); let err = installer.install(&spec_v2).unwrap_err();
assert!(matches!(err, InstallError::AlreadyInstalled { .. })); assert!(matches!(err, InstallError::AlreadyInstalled { .. }));
@ -1057,7 +1170,7 @@ exports = ["samplepkg"]
let spec = InstallSpec { let spec = InstallSpec {
address: Address::Url(file_url(&bare)), address: Address::Url(file_url(&bare)),
version: VersionReq::parse("^1.0.0").unwrap(), pin: InstallPin::Version(VersionReq::parse("^1.0.0").unwrap()),
}; };
match installer.install(&spec).unwrap_err() { match installer.install(&spec).unwrap_err() {
@ -1086,7 +1199,7 @@ exports = ["samplepkg"]
let spec = InstallSpec { let spec = InstallSpec {
address: Address::Url(file_url(&bare)), address: Address::Url(file_url(&bare)),
version: VersionReq::parse("^1.0.0").unwrap(), pin: InstallPin::Version(VersionReq::parse("^1.0.0").unwrap()),
}; };
installer installer
.install(&spec) .install(&spec)

View File

@ -18,5 +18,7 @@ pub mod manifest;
pub use address::{Address, AddressError}; pub use address::{Address, AddressError};
pub use fetcher::{FetchError, Fetcher, RefSpec}; pub use fetcher::{FetchError, Fetcher, RefSpec};
pub use installer::{InstallError, InstallScope, InstallSpec, InstalledPackage, Installer}; pub use installer::{
InstallError, InstallPin, InstallScope, InstallSpec, InstalledPackage, Installer,
};
pub use manifest::{DependencySpec, ManifestError, PackageManifest, PackageName}; pub use manifest::{DependencySpec, ManifestError, PackageManifest, PackageName};

View File

@ -30,9 +30,13 @@
//! marker is `"\n[<basename(argv[0])> exited with code N]\n"`. //! marker is `"\n[<basename(argv[0])> exited with code N]\n"`.
use pmacs::editor::EditorState; use pmacs::editor::EditorState;
use std::fmt::Write as _;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
static PUMP_TEST_LOCK: Mutex<()> = Mutex::new(());
/// Locate a shell binary for tests that require one. Returns the /// Locate a shell binary for tests that require one. Returns the
/// resolved path or `None` if the shell is neither at `PMACS_TEST_<NAME>` /// resolved path or `None` if the shell is neither at `PMACS_TEST_<NAME>`
/// nor on `PATH`. Per-test selective skipping (rather than skipping /// nor on `PATH`. Per-test selective skipping (rather than skipping
@ -80,6 +84,7 @@ fn run(chunk: &str) {
/// `poll_until` pattern but routes through `tick_processes` so the M6.5 /// `poll_until` pattern but routes through `tick_processes` so the M6.5
/// after-tick contract is exercised end-to-end. /// after-tick contract is exercised end-to-end.
fn run_with_pump(setup_chunk: &str, predicate_chunk: &str, timeout_ms: u64) { fn run_with_pump(setup_chunk: &str, predicate_chunk: &str, timeout_ms: u64) {
let _guard = PUMP_TEST_LOCK.lock().expect("pump test lock");
let mut editor = EditorState::new(); let mut editor = EditorState::new();
editor editor
.lua_host .lua_host
@ -328,7 +333,6 @@ fn m6_5_exit_marker_uses_basename_with_leading_newline() {
/// running-state (process must have started before we type) and then /// running-state (process must have started before we type) and then
/// on history matching the expected output. /// on history matching the expected output.
fn run_shell_smoke_test(shell_path: &std::path::Path, argv_extra: &[&str]) { fn run_shell_smoke_test(shell_path: &std::path::Path, argv_extra: &[&str]) {
use std::fmt::Write as _;
let mut argv_lua = String::new(); let mut argv_lua = String::new();
write!(&mut argv_lua, r#""{}""#, shell_path.display()).unwrap(); write!(&mut argv_lua, r#""{}""#, shell_path.display()).unwrap();
for a in argv_extra { for a in argv_extra {

View File

@ -40,11 +40,7 @@ use tempfile::TempDir;
/// (relative to the package root) with the supplied Lua body. /// (relative to the package root) with the supplied Lua body.
/// Returns `(tempdir, bare_path)` --- the tempdir owns both the work /// Returns `(tempdir, bare_path)` --- the tempdir owns both the work
/// tree and the bare clone. /// tree and the bare clone.
fn make_package_with_entry( fn make_package_with_entry(name: &str, entry_path: &str, entry_body: &str) -> (TempDir, PathBuf) {
name: &str,
entry_path: &str,
entry_body: &str,
) -> (TempDir, PathBuf) {
let td = tempfile::tempdir().expect("tempdir"); let td = tempfile::tempdir().expect("tempdir");
let work = td.path().join("work"); let work = td.path().join("work");
let bare = td.path().join("upstream.git"); let bare = td.path().join("upstream.git");
@ -595,3 +591,250 @@ fn searcher_misses_for_unknown_name_with_pmacs_specific_message() {
"error must mention the pmacs searcher's contribution: {msg}" "error must mention the pmacs searcher's contribution: {msg}"
); );
} }
// ---------------------------------------------------------------------------
// Reviewer-flagged item 11: branch/commit install pins.
// ---------------------------------------------------------------------------
//
// The fetcher already supports `RefSpec::Branch` and `RefSpec::Commit`;
// item 11 is the Lua-surface plumbing that exposes those resolutions
// to user init.lua. The acceptance shape: a spec table with
// `branch = "..."` or `commit = "..."` (instead of `version = "..."`)
// installs that exact revision. The two are mutually exclusive --- a
// table with both must error.
/// Build a sample-package bare repo with two tagged versions plus a
/// `feature` branch carrying a third commit. Returns
/// `(tempdir, bare_path, feature_branch_commit_sha)`. Every field
/// caller may need to verify a branch/commit pin came out of the
/// install path correctly.
fn make_branched_sample_package(name: &str) -> (TempDir, PathBuf, String) {
let td = tempfile::tempdir().expect("tempdir");
let work = td.path().join("work");
let bare = td.path().join("upstream.git");
run_git(&[
OsStr::new("init"),
OsStr::new("--initial-branch=main"),
work.as_os_str(),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("config"),
OsStr::new("user.email"),
OsStr::new("test@example.com"),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("config"),
OsStr::new("user.name"),
OsStr::new("Tester"),
]);
// First tagged release on `main`.
write_manifest(&work, name, "1.0.0");
std::fs::write(work.join("init.lua"), b"return { from = 'main@v1.0.0' }\n")
.expect("write init");
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("add"),
OsStr::new("."),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("commit"),
OsStr::new("-m"),
OsStr::new("v1.0.0"),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("tag"),
OsStr::new("v1.0.0"),
]);
// Branch off `main` and commit a different init.lua. The branch
// remains untagged --- only a branch ref points at it.
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("checkout"),
OsStr::new("-b"),
OsStr::new("feature"),
]);
std::fs::write(
work.join("init.lua"),
b"return { from = 'feature-branch' }\n",
)
.expect("write feature init");
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("add"),
OsStr::new("."),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("commit"),
OsStr::new("-m"),
OsStr::new("feature work"),
]);
let feature_sha = git_rev_parse_head(&work);
// Bare clone after both refs exist.
run_git(&[
OsStr::new("clone"),
OsStr::new("--bare"),
work.as_os_str(),
bare.as_os_str(),
]);
(td, bare, feature_sha)
}
fn write_manifest(work: &Path, name: &str, version: &str) {
let manifest = format!(
"name = \"{name}\"\n\
version = \"{version}\"\n\
summary = \"acceptance fixture\"\n\
pmacs_required = \">= 0.1.0\"\n\
entry = \"init.lua\"\n\
exports = [\"{name}\"]\n"
);
std::fs::write(work.join("pmacs.toml"), manifest).expect("write pmacs.toml");
}
fn git_rev_parse_head(work: &Path) -> String {
let out = Command::new("git")
.arg("-C")
.arg(work)
.arg("rev-parse")
.arg("HEAD")
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.output()
.expect("git rev-parse spawn");
assert!(out.status.success(), "git rev-parse failed");
String::from_utf8(out.stdout)
.expect("rev-parse stdout utf8")
.trim()
.to_string()
}
#[test]
fn install_with_branch_pin_uses_branch_head() {
let (_pkg_td, bare, _feature_sha) = make_branched_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let script = format!(
r#"
local installed = pmacs.packages.install {{
"git:{url}",
branch = "feature",
}}
assert(installed.pin.kind == "branch",
"pin.kind must be branch, got " .. tostring(installed.pin.kind))
assert(installed.pin.value == "feature",
"pin.value must echo the branch name, got " .. tostring(installed.pin.value))
assert(installed.tag == "branch:feature",
"tag descriptor must be branch:feature, got " .. tostring(installed.tag))
local mod = require("samplepkg")
assert(mod.from == "feature-branch",
"module body must come from the feature branch's init.lua, got " .. tostring(mod.from))
"#
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("branch pin install failed: {e}");
});
}
#[test]
fn install_with_commit_pin_uses_exact_revision() {
let (_pkg_td, bare, feature_sha) = make_branched_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let script = format!(
r#"
local installed = pmacs.packages.install {{
"git:{url}",
commit = "{feature_sha}",
}}
assert(installed.pin.kind == "commit",
"pin.kind must be commit, got " .. tostring(installed.pin.kind))
assert(installed.pin.value == "{feature_sha}",
"pin.value must echo the SHA, got " .. tostring(installed.pin.value))
assert(installed.commit == "{feature_sha}",
"resolved commit must equal the pinned SHA, got " .. tostring(installed.commit))
assert(installed.tag:sub(1, 7) == "commit:",
"tag descriptor must start with commit:, got " .. tostring(installed.tag))
local mod = require("samplepkg")
assert(mod.from == "feature-branch",
"module body must come from the pinned commit, got " .. tostring(mod.from))
"#
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("commit pin install failed: {e}");
});
}
#[test]
fn install_with_conflicting_pins_errors_with_field_list() {
// Specifying more than one pin is ambiguous (which one wins?).
// The error must name every conflicting field so the user can
// see which to keep without re-reading the docs.
let (_pkg_td, bare, _) = make_branched_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let script = format!(
r#"
pmacs.packages.install {{
"git:{url}",
version = "^1.0.0",
branch = "feature",
}}
"#
);
let err = host
.eval(Some("test"), &script)
.expect_err("conflicting pins must error");
let msg = err.to_string();
assert!(
msg.contains("version") && msg.contains("branch"),
"error must name both conflicting fields: {msg}"
);
assert!(
msg.contains("exactly one"),
"error must explain the mutual-exclusion rule: {msg}"
);
}
#[test]
fn install_with_default_version_pin_when_no_pin_field_supplied() {
// The reviewer's wording: existing default is `version = "*"`.
// This test pins that contract: a spec table with no
// version/branch/commit field defaults to "any tag".
let (_pkg_td, bare) = make_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let script = format!(
r#"
local installed = pmacs.packages.install {{ "git:{url}" }}
assert(installed.pin.kind == "version",
"default pin must be a version pin, got " .. tostring(installed.pin.kind))
assert(installed.pin.value == "*",
"default constraint must be `*`, got " .. tostring(installed.pin.value))
"#
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("default-pin install failed: {e}");
});
}