Fix PTY final-output drain race

This commit is contained in:
Levi Neuwirth 2026-05-04 09:44:30 -04:00
parent 4da4b09d5d
commit c8d0d67615
34 changed files with 7759 additions and 203 deletions

65
Cargo.lock generated
View File

@ -563,11 +563,13 @@ dependencies = [
"postcard", "postcard",
"proptest", "proptest",
"rmp-serde", "rmp-serde",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"signal-hook", "signal-hook",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"toml",
"tree-sitter", "tree-sitter",
"tree-sitter-lua", "tree-sitter-lua",
"tree-sitter-rust", "tree-sitter-rust",
@ -847,6 +849,10 @@ name = "semver"
version = "1.0.28" version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
dependencies = [
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde" name = "serde"
@ -892,6 +898,15 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serial2" name = "serial2"
version = "0.2.36" version = "0.2.36"
@ -1047,6 +1062,47 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "tree-sitter" name = "tree-sitter"
version = "0.26.8" version = "0.26.8"
@ -1300,6 +1356,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winreg" name = "winreg"
version = "0.10.1" version = "0.10.1"

View File

@ -106,6 +106,14 @@ portable-pty = "0.9"
# (spec §5.2). `preserve_order` is off --- LSP doesn't require it # (spec §5.2). `preserve_order` is off --- LSP doesn't require it
# and the default `BTreeMap` keeps allocation low. # and the default `BTreeMap` keeps allocation low.
serde_json = "1" serde_json = "1"
# T M7.1 package manifest format (spec §sec:packages-future): TOML at
# the package's root (`pmacs.toml`). Read at install time, not at every
# load.
toml = "0.8"
# T M7.1 semantic versioning for `version` and `pmacs_required` fields.
# `serde` feature gives us localized parse errors during deserialization
# (rejected at parse time, not at install time).
semver = { version = "1", features = ["serde"] }
[dev-dependencies] [dev-dependencies]
proptest = "1" proptest = "1"

View File

@ -45,6 +45,53 @@ Release-only perf gates (M5 keystroke-to-render, M6 ingest/RSS/cancel
and scrollback navigation/search) are `#[ignore]`'d during normal and scrollback navigation/search) are `#[ignore]`'d during normal
test runs and exercised in CI under dedicated jobs. test runs and exercised in CI under dedicated jobs.
## Runtime requirements
The pmacs binary depends on a small set of POSIX command-line tools
at runtime. The dependency exists because the project enforces
`#![forbid(unsafe_code)]` everywhere, including in tests; calls that
would otherwise need `unsafe` (PTY raw-mode setup, signal name
translation) are routed through trampolines that exec these tools.
- **`/bin/sh`** (POSIX shell). Used for the PTY raw-mode trampoline:
`/bin/sh -c 'stty raw -echo </dev/tty 2>/dev/null; exec "$@"' --`
configures the controlling TTY's line discipline before exec'ing
the actual subprocess. Required by the REPL package and any other
caller that spawns a process in raw PTY mode.
- **`stty`** (coreutils). The line-discipline configurator invoked
by the trampoline above.
- **`coreutils`** more broadly. The M6 process-supervisor tests
spawn `cat`, `yes`, and `which`; absent these the test suite (not
the editor itself) degrades. `which` is also used by the M6.5
shell-locator helper to find `bash` / `zsh` / `fish` for
per-shell integration tests. The M7.2 fetcher's timeout test
uses `sleep`.
- **`git`** (added in M7.2). Required for any package operation:
the package fetcher shells out to `git` to clone, fetch, and
resolve refs, with a deterministic environment
(`GIT_TERMINAL_PROMPT=0`, `GIT_CONFIG_NOSYSTEM=1`, `LC_ALL=C`,
inherited `GIT_*` variables stripped). Authentication for
private repositories rides the user's existing git configuration
(credential helpers, SSH agent), so packagers do not need a
separate auth story. Pre-M7 builds without package operations
do not need git.
- **`tar`** (added in M7.3). Required for `pmacs.packages.install`:
the installer materializes a snapshot via `git archive --format=tar`
piped into `tar -x -C <dest>`, which keeps the on-disk install
directory self-contained (no `.git` linkage back to the bare
cache, no working-tree state). GNU tar and bsdtar both work.
Pre-M7 builds and any path that doesn't call
`pmacs.packages.install{...}` do not need tar.
Distribution packagers should ensure these are runtime dependencies
of the pmacs package. On a typical Linux distribution, busybox or
GNU coreutils plus a shell of any kind satisfies the requirement; on
macOS the system shell and `/usr/bin/stty` are both standard.
The Lua VM (LuaJIT or Lua 5.4) is statically vendored via `mlua`'s
`vendored` feature, so there is no external Lua dependency at
runtime.
## What v0.1 ships with ## What v0.1 ships with
- **Editor core.** Persistent rope with O(log N) edits and snapshots; - **Editor core.** Persistent rope with O(log N) edits and snapshots;

113
builtin/api/packages.lua Normal file
View File

@ -0,0 +1,113 @@
--- @meta pmacs.packages
---
--- EmmyLua-style annotations for the `pmacs.packages.*` Lua surface
--- (T M7.3, spec §sec:packages-future). This file is a documentation
--- artifact: editor tooling (lua-language-server, EmmyLua) consumes it
--- to power completion and hover; the runtime implementation lives in
--- Rust (`src/lua_bindings.rs::install_packages_module`).
---
--- The runtime never `require`s this file. It is shipped under
--- `builtin/api/` so packagers know to include it alongside the
--- binary; users who want IDE completion add this directory to their
--- workspace's lua-language-server `Lua.workspace.library`.
--- A package install spec.
---
--- Two accepted shapes:
---
--- - Table with positional address at `[1]`: `{ "github:owner/repo", version = "^1.0.0" }`.
--- The `version` field defaults to `"*"` (any tag) when omitted. The
--- `install_project` variant additionally **requires** `project_root = "..."`
--- (no default; see the field doc below).
--- - Shorthand string `"github:owner/repo@^1.0.0"`. The separator is the **last** `@` in the
--- string, so addresses containing an `@` (SSH shorthand `git@host:path`) parse correctly.
--- The shorthand form is not accepted by `install_project` (no place to put `project_root`).
---
--- @class PackageInstallSpec
--- @field [1] string Positional address (e.g., `"github:owner/repo"`).
--- @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 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".
--- A successful-install record returned by `install` and listed by `installed`.
---
--- @class InstalledPackage
--- @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 tag string The tag that was matched (e.g., `"v1.2.3"`).
--- @field commit string 40-character commit SHA of the installed snapshot.
--- @field install_path string Absolute on-disk install directory.
--- @field entry string Absolute path to the package's `entry` Lua module.
--- @field scope "user"|"project" Which scope the package was installed under.
--- @field summary string One-line description from the manifest.
local pmacs = pmacs or {}
pmacs.packages = pmacs.packages or {}
--- Install a package to the user-config root (`$XDG_DATA_HOME/pmacs/packages/`).
---
--- Synchronous: clones / fetches the address, picks the highest semver tag
--- matching `version`, materializes the snapshot via `git archive | tar -x`,
--- and makes the package's entry module requireable as
--- `require(<package-name-basename>)`.
---
--- Resolution path: standard layouts (`<basename>.lua`,
--- `<basename>/init.lua`) are found via `package.path`. Non-standard
--- entries (e.g. `entry = "main.lua"` or `entry = "lib/foo.lua"`) are
--- found by a custom searcher pmacs registers in `package.searchers`
--- (Lua 5.4) / `package.loaders` (LuaJIT, Lua 5.1), which consults
--- the install roster and returns the manifest's exact entry path.
---
--- **Init-time-only.** Calling outside `init.lua` raises an error pointing
--- at the workaround (restart pmacs after editing `init.lua`). Mid-session
--- install is not supported in v0.1; M7.6 adds `pmacs.packages.update(...)`
--- for in-place version changes.
---
--- @param spec PackageInstallSpec|string Spec table, or shorthand string `"address@constraint"`.
--- @return InstalledPackage
--- @throws "init-only" if called after init has finished.
--- @throws "no matching version" if no tag satisfies `version`.
--- @throws "already installed" if a different commit occupies the install path.
function pmacs.packages.install(spec) end
--- Install a package to a project-scoped root (`<project_root>/.pmacs/packages/`).
---
--- Identical to `install` except for the on-disk root. **Requires** an
--- explicit `project_root` field in the spec table. Absolute paths are
--- used as-is; relative paths resolve against the directory of the
--- loading `init.lua` (not against the process CWD, which is rarely a
--- meaningful project root). The shorthand string form is not accepted.
---
--- Project installs override user installs of the same package basename
--- in `package.path` (project entries are prepended).
---
--- **Init-time-only.** See `install` for the gate.
---
--- @param spec PackageInstallSpec
--- @return InstalledPackage
--- @throws "init-only" if called after init has finished.
--- @throws "no matching version" if no tag satisfies `version`.
--- @throws "already installed" if a different commit occupies the install path.
--- @throws "missing project_root" if the spec table omits the `project_root` field. The error message names two patterns for filling it in: `os.getenv("PMACS_PROJECT")` for an env-var-driven setup, or a path relative to the loading `init.lua`'s directory.
function pmacs.packages.install_project(spec) end
--- Snapshot the in-memory roster of packages installed during this init pass.
---
--- Each entry is the same shape as `install`'s return value. The list is
--- ordered by install order (first call first).
---
--- @return InstalledPackage[]
function pmacs.packages.installed() end
--- Re-resolve and update an installed package to the latest commit
--- matching its constraint.
---
--- **Implemented in M7.6** (lockfile + resolver). v0.1 / current builds
--- raise an error pointing at the workaround: re-run `pmacs.packages.install`
--- with the new constraint to upgrade in place.
---
--- @param name string|nil Package name to update; omit to update all.
--- @throws "unsupported" until M7.6 ships.
function pmacs.packages.update(name) end
return pmacs.packages

View File

@ -44,6 +44,47 @@ cmd { name = "buffer.delete-backward", description = "Delete the codepoint befor
fn = function() ed.backspace() end } fn = function() ed.backspace() end }
cmd { name = "buffer.delete-forward", description = "Delete the codepoint at the cursor.", cmd { name = "buffer.delete-forward", description = "Delete the codepoint at the cursor.",
fn = function() ed.delete_forward() end } fn = function() ed.delete_forward() end }
cmd { name = "buffer.delete-word-backward",
description = "Delete from the cursor back to the start of the previous word.",
fn = function() ed.delete_word_backward() end }
cmd { name = "buffer.delete-word-forward",
description = "Delete from the cursor forward to the end of the next word.",
fn = function() ed.delete_word_forward() end }
-- Selection-extending motion (CUA-style Shift+motion). Each select-*
-- command anchors at the current cursor (if no region is already
-- active) and then performs the underlying motion. Plain motion
-- commands are unchanged: they preserve existing selections.
local function ensure_anchor()
if ed.region() == nil then
ed.begin_selection(ed.cursor())
end
end
cmd { name = "cursor.select-left",
description = "Extend selection by one codepoint left.",
fn = function() ensure_anchor(); ed.move_left() end }
cmd { name = "cursor.select-right",
description = "Extend selection by one codepoint right.",
fn = function() ensure_anchor(); ed.move_right() end }
cmd { name = "cursor.select-up",
description = "Extend selection upward by one line.",
fn = function() ensure_anchor(); ed.move_up() end }
cmd { name = "cursor.select-down",
description = "Extend selection downward by one line.",
fn = function() ensure_anchor(); ed.move_down() end }
cmd { name = "cursor.select-word-left",
description = "Extend selection by one word left.",
fn = function() ensure_anchor(); ed.move_word_left() end }
cmd { name = "cursor.select-word-right",
description = "Extend selection by one word right.",
fn = function() ensure_anchor(); ed.move_word_right() end }
cmd { name = "cursor.select-line-start",
description = "Extend selection to start of line.",
fn = function() ensure_anchor(); ed.move_line_start() end }
cmd { name = "cursor.select-line-end",
description = "Extend selection to end of line.",
fn = function() ensure_anchor(); ed.move_line_end() end }
cmd { name = "buffer.newline", description = "Insert a newline at the cursor.", cmd { name = "buffer.newline", description = "Insert a newline at the cursor.",
fn = function() ed.insert_char(10) end } fn = function() ed.insert_char(10) end }
cmd { name = "buffer.tab", description = "Insert a tab at the cursor.", cmd { name = "buffer.tab", description = "Insert a tab at the cursor.",

View File

@ -52,6 +52,40 @@ bind("C-d", "buffer.delete-forward")
bind("RET", "buffer.newline") bind("RET", "buffer.newline")
bind("TAB", "buffer.tab") bind("TAB", "buffer.tab")
-- CUA-style word-level deletion (the same shortcuts users expect from
-- IDEs, browsers, terminals on Linux/Windows). C-BS deletes back to
-- the start of the previous word; C-DEL deletes forward through the
-- next word. Emacs's classic M-BS and M-d remain bound below.
--
-- Why we also bind C-h: most terminals (anything not implementing the
-- kitty keyboard protocol) cannot disambiguate Ctrl+Backspace from
-- Ctrl+H — both legacy paths produce byte 0x08, which crossterm
-- surfaces as `Char('h') + CONTROL`. Binding C-h to the same command
-- makes the shortcut work on legacy terminals too. C-h was free
-- (pmacs does not use it as a help prefix); users wanting Emacs's
-- help-prefix can override.
bind("C-BS", "buffer.delete-word-backward")
bind("C-h", "buffer.delete-word-backward")
bind("C-DEL", "buffer.delete-word-forward")
bind("M-BS", "buffer.delete-word-backward")
bind("M-d", "buffer.delete-word-forward")
-- CUA-style Shift+motion selection. Each Shift+arrow extends a
-- selection from the cursor (anchoring at the current position if no
-- region is yet active). Ctrl+Shift+Left/Right extend by whole words;
-- Shift+Home/End extend to line edges. Plain motion (without Shift)
-- is unchanged --- it preserves any existing selection rather than
-- dropping it (Emacs-flavored default; users who want strict-CUA
-- "drop-on-plain-motion" can rebind their motion commands).
bind("S-<left>", "cursor.select-left")
bind("S-<right>", "cursor.select-right")
bind("S-<up>", "cursor.select-up")
bind("S-<down>", "cursor.select-down")
bind("S-<home>", "cursor.select-line-start")
bind("S-<end>", "cursor.select-line-end")
bind("C-S-<left>", "cursor.select-word-left")
bind("C-S-<right>", "cursor.select-word-right")
-- Undo / redo ---------------------------------------------------------------- -- Undo / redo ----------------------------------------------------------------
-- --
-- Multiple undo bindings exist because terminals translate Ctrl+/ -- Multiple undo bindings exist because terminals translate Ctrl+/

View File

@ -4,26 +4,15 @@
-- intercept that enforces read-only / truncate-to-input policy. -- intercept that enforces read-only / truncate-to-input policy.
-- Spec: §sec:repl-view. -- Spec: §sec:repl-view.
-- --
-- # Region tracking: byte offsets, not (yet) marks -- # Region tracking: marks
-- --
-- The spec says "regions are tracked as marks (M2 primitive)". M2's -- The history/prompt boundaries are backed by core buffer marks.
-- mark primitive doesn't yet exist; M6.4 tracks region boundaries as -- `_history_end` and `_prompt_end` remain as compatibility mirrors
-- byte offsets stored on the handle (`_history_end`, `_prompt_end`). -- for tests and package introspection, but the authoritative positions
-- This works correctly because: -- are `_history_end_mark` and `_prompt_end_mark`. This matters for
-- -- process prompts: user edits in the input region must not accidentally
-- * intercept_edit runs *before* the rope mutates. The package -- move the prompt boundary, while package output inserted before the
-- either pre-decides positions (its own write paths) or vetoes -- prompt must move both boundaries with the rope.
-- (in intercept). It does not need to react to a post-mutation
-- event to keep the offsets consistent.
-- * User-driven edits in the input region (pos >= prompt_end) do
-- not move history_end or prompt_end. So the handle's offsets
-- only need updates from inside the package's own write paths
-- (append_output, set_prompt, submit) --- never from user edits.
--
-- This is sufficient for M6.4. When real marks land (M2 future
-- work), the indirection becomes a one-line replacement: the offsets
-- get backed by mark handles instead of plain integers, and the
-- existing call sites work unchanged.
-- --
-- # Self-write bypass -- # Self-write bypass
-- --
@ -130,6 +119,8 @@ local function new_handle(buffer_id)
return setmetatable({ return setmetatable({
_buf = buffer_id, _buf = buffer_id,
_parser = pmacs.ansi.parser(), _parser = pmacs.ansi.parser(),
_history_end_mark = pmacs.buffer.mark_create(buffer_id, 0, { gravity = "left" }),
_prompt_end_mark = pmacs.buffer.mark_create(buffer_id, 0, { gravity = "left" }),
_history_end = 0, _history_end = 0,
_prompt_end = 0, _prompt_end = 0,
-- Latest SetStyle observed. M6.4 doesn't render this anywhere -- Latest SetStyle observed. M6.4 doesn't render this anywhere
@ -139,6 +130,9 @@ local function new_handle(buffer_id)
_current_style = nil, _current_style = nil,
_alt_screen = false, _alt_screen = false,
_title = nil, _title = nil,
_output_pos = 0,
_capture = "history",
_style_overlay = nil,
_self_write = false, _self_write = false,
_intercept_handle = nil, _intercept_handle = nil,
-- Scrollback block index (M6.7). The first block is degenerate -- Scrollback block index (M6.7). The first block is degenerate
@ -154,6 +148,33 @@ local function new_handle(buffer_id)
}, Handle) }, Handle)
end end
local function sync_marks(h)
h._history_end = h._history_end_mark:get()
h._prompt_end = h._prompt_end_mark:get()
end
local function history_end(h)
local pos = h._history_end_mark:get()
h._history_end = pos
return pos
end
local function prompt_end(h)
local pos = h._prompt_end_mark:get()
h._prompt_end = pos
return pos
end
local function set_history_end(h, pos)
h._history_end_mark:set(pos)
h._history_end = pos
end
local function set_prompt_end(h, pos)
h._prompt_end_mark:set(pos)
h._prompt_end = pos
end
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Construction / teardown -- Construction / teardown
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
@ -166,6 +187,9 @@ function repl.create(opts)
h._intercept_handle = pmacs.buffer.add_intercept(buf, function(op) h._intercept_handle = pmacs.buffer.add_intercept(buf, function(op)
return repl._intercept(h, op) return repl._intercept(h, op)
end) end)
if pmacs.buffer.add_style_overlay then
h._style_overlay = pmacs.buffer.add_style_overlay(buf)
end
return h return h
end end
@ -199,6 +223,24 @@ local function basename(s)
return (s:gsub("^.*/", "")) return (s:gsub("^.*/", ""))
end end
local function copy_env(env)
local out = {}
if env then
for k, v in pairs(env) do out[k] = v end
end
return out
end
local function shell_prompt_marker_env(argv, base_env)
local shell = basename(argv[1])
if shell ~= "bash" and shell ~= "zsh" then
return base_env
end
local env = copy_env(base_env)
env.PS1 = "\27]133;A\7$ \27]133;B\7"
return env
end
function repl.spawn(opts) function repl.spawn(opts)
opts = opts or {} opts = opts or {}
local argv = validate_argv(opts.argv) local argv = validate_argv(opts.argv)
@ -220,9 +262,14 @@ function repl.spawn(opts)
command = argv[1], command = argv[1],
args = args, args = args,
pty = { rows = rows, cols = cols, mode = "raw" }, pty = { rows = rows, cols = cols, mode = "raw" },
ansi = true,
} }
if opts.cwd then spec.cwd = opts.cwd end if opts.cwd then spec.cwd = opts.cwd end
if opts.env then spec.env = opts.env end local env = opts.env
if opts.prompt_markers ~= false then
env = shell_prompt_marker_env(argv, env)
end
if env then spec.env = env end
local proc_id = pmacs.process.spawn(spec) local proc_id = pmacs.process.spawn(spec)
h._proc_id = proc_id h._proc_id = proc_id
@ -236,6 +283,9 @@ function repl.spawn(opts)
if pmacs.window and pmacs.window.switch_buffer then if pmacs.window and pmacs.window.switch_buffer then
pcall(pmacs.window.switch_buffer, h._buf) pcall(pmacs.window.switch_buffer, h._buf)
end end
if pmacs.buffer.attach_style_overlay and h._style_overlay then
pcall(pmacs.buffer.attach_style_overlay, h._buf, h._style_overlay)
end
-- Buffer-scoped bindings. RET submits the input region to the -- Buffer-scoped bindings. RET submits the input region to the
-- process; C-c sends SIGINT; C-d closes stdin (when input empty) -- process; C-c sends SIGINT; C-d closes stdin (when input empty)
@ -295,11 +345,11 @@ function Handle:buffer_id()
end end
function Handle:history_end() function Handle:history_end()
return self._history_end return history_end(self)
end end
function Handle:prompt_end() function Handle:prompt_end()
return self._prompt_end return prompt_end(self)
end end
function Handle:title() function Handle:title()
@ -307,13 +357,18 @@ function Handle:title()
end end
function Handle:input_text() function Handle:input_text()
return self._buf:slice(self._prompt_end, self._buf:len()) return self._buf:slice(prompt_end(self), self._buf:len())
end end
function Handle:alt_screen_active() function Handle:alt_screen_active()
return self._alt_screen return self._alt_screen
end end
function Handle:style_spans()
if not self._style_overlay then return {} end
return self._style_overlay:spans()
end
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Package-driven writes -- Package-driven writes
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
@ -325,11 +380,18 @@ end
-- suppression at the parser level (so Text events between markers -- suppression at the parser level (so Text events between markers
-- never reach us). -- never reach us).
function Handle:append_output(bytes) function Handle:append_output(bytes)
local events = self._parser:feed(bytes) self:append_events(self._parser:feed(bytes))
end
function Handle:append_events(events)
for _, ev in ipairs(events) do for _, ev in ipairs(events) do
local kind = ev.kind local kind = ev.kind
if kind == "text" then if kind == "text" then
self:_emit_history(ev.text) if self._capture == "prompt" then
self:_emit_prompt(ev.text)
else
self:_emit_history(ev.text)
end
elseif kind == "set_style" then elseif kind == "set_style" then
self._current_style = ev.style self._current_style = ev.style
elseif kind == "alt_screen_enter" then elseif kind == "alt_screen_enter" then
@ -338,12 +400,28 @@ function Handle:append_output(bytes)
self._alt_screen = false self._alt_screen = false
elseif kind == "set_title" then elseif kind == "set_title" then
self._title = ev.title self._title = ev.title
-- carriage_return, backspace, erase_to_eol, erase_line, elseif kind == "prompt_start" then
-- bracketed_paste_*: parsed-and-acknowledged in M6.4. Their self:_begin_prompt_capture()
-- semantic effects (CR rewinds input cursor, erase rewrites elseif kind == "prompt_end" then
-- in-place output, etc.) are M6.5+ refinements where they self:_end_prompt_capture()
-- meet a real shell. M6.4's "synthetic stream" tests don't elseif kind == "command_start" or kind == "output_start" then
-- exercise them. self:_begin_command_output()
elseif kind == "carriage_return" then
self._output_pos = self:_current_line_start()
elseif kind == "backspace" then
local line_start = self:_current_line_start()
if self._output_pos > line_start then
self._output_pos = self._output_pos - 1
end
elseif kind == "erase_to_eol" then
self:_delete_history_range(self._output_pos, self:_current_line_end())
elseif kind == "erase_line" then
local line_start = self:_current_line_start()
local line_end = self:_current_line_end()
self:_delete_history_range(line_start, line_end)
self._output_pos = line_start
-- bracketed_paste_* markers are delimiters only; process-emitted
-- contents are ordinary text events between them.
end end
end end
end end
@ -352,10 +430,13 @@ end
-- untouched; the input region is preserved (it sits past prompt_end). -- untouched; the input region is preserved (it sits past prompt_end).
function Handle:set_prompt(text) function Handle:set_prompt(text)
text = text or "" text = text or ""
local h_end = history_end(self)
local p_end = prompt_end(self)
with_self_write(self, function() with_self_write(self, function()
self._buf:replace(self._history_end, self._prompt_end, text) self._buf:replace(h_end, p_end, text)
end) end)
self._prompt_end = self._history_end + #text set_prompt_end(self, history_end(self) + #text)
sync_marks(self)
end end
-- Pop the input region's text. Returns the popped string. Does NOT -- Pop the input region's text. Returns the popped string. Does NOT
@ -368,12 +449,14 @@ end
-- start_byte invariant. -- start_byte invariant.
function Handle:submit() function Handle:submit()
local text = self:input_text() local text = self:input_text()
local p_end = prompt_end(self)
with_self_write(self, function() with_self_write(self, function()
self._buf:delete(self._prompt_end, self._buf:len()) self._buf:delete(p_end, self._buf:len())
end) end)
local last = self._blocks[#self._blocks] local last = self._blocks[#self._blocks]
if self._history_end > last.start_byte then local h_end = history_end(self)
self._blocks[#self._blocks + 1] = { start_byte = self._history_end } if h_end > last.start_byte then
self._blocks[#self._blocks + 1] = { start_byte = h_end }
end end
return text return text
end end
@ -384,18 +467,132 @@ end
function Handle:_emit_history(text) function Handle:_emit_history(text)
if #text == 0 then return end if #text == 0 then return end
local h_end = history_end(self)
local pos = self._output_pos or h_end
if pos > h_end then pos = h_end end
local overwrite_len = math.min(#text, h_end - pos)
local insert_len = #text - overwrite_len
with_self_write(self, function() with_self_write(self, function()
self._buf:insert(self._history_end, text) if overwrite_len > 0 then
self._buf:replace(pos, pos + overwrite_len, text:sub(1, overwrite_len))
end
if insert_len > 0 then
self._buf:insert(pos + overwrite_len, text:sub(overwrite_len + 1))
end
end) end)
local n = #text if insert_len > 0 then
self._history_end = self._history_end + n self:_adjust_blocks_after_edit(pos + overwrite_len, 0, insert_len)
self._prompt_end = self._prompt_end + n end
sync_marks(self)
set_history_end(self, h_end + insert_len)
if prompt_end(self) < history_end(self) then
set_prompt_end(self, history_end(self))
end
self._output_pos = pos + #text
self:_add_style_span(pos, pos + #text)
-- M6.7: mark the handle for the next tick's truncation check. -- M6.7: mark the handle for the next tick's truncation check.
-- Per-byte work beyond this assignment regresses the M6.6 100 MB/s -- Per-byte work beyond this assignment regresses the M6.6 100 MB/s
-- ingest gate; line counting is deferred to _maybe_truncate. -- ingest gate; line counting is deferred to _maybe_truncate.
self._dirty_since_last_tick = true self._dirty_since_last_tick = true
end end
function Handle:_begin_prompt_capture()
self._capture = "prompt"
self:set_prompt("")
end
function Handle:_emit_prompt(text)
if #text == 0 then return end
local p_end = prompt_end(self)
with_self_write(self, function()
self._buf:insert(p_end, text)
end)
set_prompt_end(self, p_end + #text)
self:_add_style_span(p_end, p_end + #text)
end
function Handle:_end_prompt_capture()
self._capture = "history"
self._output_pos = history_end(self)
sync_marks(self)
end
function Handle:_begin_command_output()
self._capture = "history"
self:set_prompt("")
self._output_pos = history_end(self)
end
local function style_is_default(style)
if not style then return true end
return style.fg == "default"
and style.bg == "default"
and not style.bold
and not style.italic
and style.underline == "none"
and not style.reverse
end
function Handle:_add_style_span(start_pos, end_pos)
if not self._style_overlay then return end
if start_pos >= end_pos then return end
if style_is_default(self._current_style) then return end
self._style_overlay:add(start_pos, end_pos, self._current_style)
end
function Handle:_adjust_blocks_after_edit(start_pos, old_len, new_len)
local delta = new_len - old_len
if delta == 0 then return end
for i = 1, #self._blocks do
local b = self._blocks[i]
if b.start_byte > start_pos then
b.start_byte = b.start_byte + delta
if b.start_byte < start_pos then b.start_byte = start_pos end
end
end
end
function Handle:_current_line_start()
local h_end = history_end(self)
local pos = self._output_pos or h_end
if pos > h_end then pos = h_end end
local prefix = self._buf:slice(0, pos)
local start = 0
local search = 1
while true do
local idx = prefix:find("\n", search, true)
if not idx then return start end
start = idx
search = idx + 1
end
end
function Handle:_current_line_end()
local h_end = history_end(self)
local pos = self._output_pos or h_end
if pos > h_end then pos = h_end end
local suffix = self._buf:slice(pos, h_end)
local idx = suffix:find("\n", 1, true)
if idx then return pos + idx - 1 end
return h_end
end
function Handle:_delete_history_range(start_pos, end_pos)
if end_pos <= start_pos then return end
with_self_write(self, function()
self._buf:delete(start_pos, end_pos)
end)
local removed = end_pos - start_pos
sync_marks(self)
if self._output_pos > end_pos then
self._output_pos = self._output_pos - removed
elseif self._output_pos > start_pos then
self._output_pos = start_pos
end
self:_adjust_blocks_after_edit(start_pos, removed, 0)
self._dirty_since_last_tick = true
end
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Scrollback truncation (M6.7) -- Scrollback truncation (M6.7)
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
@ -420,7 +617,7 @@ end
-- (16 MiB / 10000 lines), and skipped entirely by the byte-only -- (16 MiB / 10000 lines), and skipped entirely by the byte-only
-- shortcut in within_limits. -- shortcut in within_limits.
local function history_lines(h) local function history_lines(h)
return count_newlines(h._buf:slice(0, h._history_end)) return count_newlines(h._buf:slice(0, history_end(h)))
end end
-- Both invariants in one predicate, with a fast path that avoids the -- Both invariants in one predicate, with a fast path that avoids the
@ -431,8 +628,9 @@ end
-- pay for the line scan. -- pay for the line scan.
local function within_limits(h) local function within_limits(h)
local cfg = repl.config local cfg = repl.config
if h._history_end > cfg.scrollback_bytes then return false end local h_end = history_end(h)
if h._history_end <= cfg.scrollback_lines then return true end if h_end > cfg.scrollback_bytes then return false end
if h_end <= cfg.scrollback_lines then return true end
return history_lines(h) <= cfg.scrollback_lines return history_lines(h) <= cfg.scrollback_lines
end end
@ -449,8 +647,8 @@ local function drop_oldest_block(h)
with_self_write(h, function() with_self_write(h, function()
h._buf:delete(first.start_byte, second.start_byte) h._buf:delete(first.start_byte, second.start_byte)
end) end)
h._history_end = h._history_end - removed_bytes sync_marks(h)
h._prompt_end = h._prompt_end - removed_bytes h._output_pos = math.max(0, (h._output_pos or history_end(h)) - removed_bytes)
table.remove(h._blocks, 1) table.remove(h._blocks, 1)
for i = 1, #h._blocks do for i = 1, #h._blocks do
h._blocks[i].start_byte = h._blocks[i].start_byte - removed_bytes h._blocks[i].start_byte = h._blocks[i].start_byte - removed_bytes
@ -489,7 +687,7 @@ function repl._intercept(h, op)
if h._self_write then if h._self_write then
return nil return nil
end end
local prompt_end = h._prompt_end local prompt_end = prompt_end(h)
if op.kind == "insert" then if op.kind == "insert" then
if op.pos < prompt_end then if op.pos < prompt_end then
error("REPL: history/prompt region is read-only (insert at " error("REPL: history/prompt region is read-only (insert at "
@ -547,6 +745,8 @@ local function drain_handle(h)
-- they do (regression / pipe-mode use), routing them through -- they do (regression / pipe-mode use), routing them through
-- append_output preserves user output rather than dropping it. -- append_output preserves user output rather than dropping it.
h:append_output(ev.bytes) h:append_output(ev.bytes)
elseif kind == "ansi" then
h:append_events(ev.events)
elseif kind == "exited" or kind == "signaled" or kind == "crashed" then elseif kind == "exited" or kind == "signaled" or kind == "crashed" then
h:_on_exit(ev) h:_on_exit(ev)
end end
@ -672,9 +872,9 @@ pmacs.command.define {
-- delete-char-forward when the input region is non-empty so users -- delete-char-forward when the input region is non-empty so users
-- never see C-d as broken. Empty case writes \x04 (EOT); raw-mode -- never see C-d as broken. Empty case writes \x04 (EOT); raw-mode
-- shells with a line editor interpret that as end-of-input. Non-empty -- shells with a line editor interpret that as end-of-input. Non-empty
-- case delegates to the existing pmacs.editor.delete_forward primitive -- case deletes through the REPL buffer at the cursor when it is inside
-- (which the M6.4 intercept policy guards: deletes inside the input -- the input region, falling back to the input start if the editor
-- region pass through, deletes into prompt/history are rejected). -- cursor is stale/outside the region.
pmacs.command.define { pmacs.command.define {
name = "pmacs.repl.send-eof-current", name = "pmacs.repl.send-eof-current",
description = "Close stdin on empty input region; delete-char-forward otherwise.", description = "Close stdin on empty input region; delete-char-forward otherwise.",
@ -685,7 +885,11 @@ pmacs.command.define {
if h:input_text() == "" then if h:input_text() == "" then
pmacs.process.write_stdin(h._proc_id, "\x04") pmacs.process.write_stdin(h._proc_id, "\x04")
else else
pmacs.editor.delete_forward() local start = h:prompt_end()
local len = h._buf:len()
local pos = pmacs.editor.cursor()
if pos < start or pos >= len then pos = start end
if pos < len then h._buf:delete(pos, pos + 1) end
end end
end, end,
} }

131
docs/packages.md Normal file
View File

@ -0,0 +1,131 @@
# Package installation
pmacs's package surface lives at `pmacs.packages.*` in Lua. Two
install variants ship in v0.1: `install` (user-config scope) and
`install_project` (project scope). Both run synchronously during
init; mid-session install is not supported.
## `pmacs.packages.install { ... }` — user-config scope
Installs to `$XDG_DATA_HOME/pmacs/packages/<basename>/` (or
`$HOME/.local/share/pmacs/packages/...` if `XDG_DATA_HOME` is unset).
The package's entry module is wired into Lua's require resolution so
`require("<basename>")` returns the module's table.
```lua
pmacs.packages.install {
"github:owner/repo",
version = "^1.0.0",
}
```
The shorthand string form is also accepted:
```lua
pmacs.packages.install "github:owner/repo@^1.0.0"
```
## `pmacs.packages.install_project { ... }` — project scope
Installs to `<project_root>/.pmacs/packages/<basename>/`. Project
installs are prepended to `package.path` so they take precedence
over user-config installs of the same basename.
```lua
pmacs.packages.install_project {
"github:owner/repo",
version = "^1.0.0",
project_root = "/abs/path/to/project",
}
```
### `project_root` is required
`install_project` requires an explicit `project_root`. There is **no
fallback to the process CWD**: at init time CWD is whatever shell
directory the user happened to invoke pmacs from, which is almost
never a meaningful project root.
If you omit the field you get a typed error that names two concrete
patterns for filling it in. Pick whichever fits:
#### Pattern A: an environment variable (CI, scripts, multiple machines)
```lua
pmacs.packages.install_project {
"github:owner/repo",
version = "^1.0.0",
project_root = os.getenv("PMACS_PROJECT"),
}
```
The user (or the CI runner) sets `PMACS_PROJECT=/path/to/project`
before invoking `pmacs`. The path is stable across invocations
regardless of where the shell happened to be.
#### Pattern B: a path relative to the loading `init.lua`
```lua
pmacs.packages.install_project {
"github:owner/repo",
version = "^1.0.0",
project_root = ".",
}
```
Relative paths in `project_root` resolve against the directory
**containing the loading `init.lua`**, *not* against CWD. So
`project_root = "."` means "alongside this init.lua";
`project_root = "subdir"` means a subdirectory of the init.lua's
directory.
This works because pmacs's loader stamps each chunk with a `@<path>`
source label (the standard Lua convention for file-loaded chunks);
the install binding reads that label back from a per-eval app-data
slot to recover the chunk's directory.
Edge case: when running pmacs's package API from a Lua chunk that
was *not* loaded from a file (e.g., via `pmacs --eval ...`, or from
the M-x command-line evaluator), there is no source label. Relative
`project_root` values then fall through to "as-is," matching the
pre-v0.1 CWD interpretation. This is intentionally ad-hoc: the only
flow that matters in v0.1 is init.lua, and string-loaded chunks
that need an exact path can use Pattern A or pass an absolute path
literally.
### Forward planning: project-local `init.lua`
When project-local `init.lua` lands (post-v0.1), the project loader
will set a "current project root" before evaluating the project's
init.lua, and `install_project` from inside that init.lua will pick
up the project root automatically — no `project_root` field needed.
The user-global init.lua path will continue to require an explicit
field, since it has no implicit project context.
The change will be relaxation, not breakage: code that explicitly
passes `project_root` keeps working unchanged.
## `pmacs.packages.installed()`
Returns an array of records describing every package installed
during the current init pass. Each record has the same shape as
`install`'s return value (`name`, `version`, `commit`,
`install_path`, `entry`, `scope`, `summary`).
## `pmacs.packages.update(...)`
Stubbed for v0.1. M7.6 implements re-resolution and lockfile
regeneration. Until then, re-running `install` with a new constraint
upgrades in place.
## Errors and how they're shaped
Every install error message names the operation, the input that
caused the failure, and (where applicable) the workaround. Examples:
- `pmacs.packages.install_project requires an explicit project_root field. Pass project_root = "/path/to/your/project" (often os.getenv("PMACS_PROJECT") or a path relative to the directory containing your init.lua).`
- `package at tag v2.0.0 of github:owner/repo requires pmacs ">= 2.0.0", but this pmacs is "0.1.0". Upgrade pmacs, or pin a package version compatible with "0.1.0".`
- `no tag for github:owner/repo satisfies "^99.0.0". Available tags: ["v1.0.0", "v1.1.0"]`
The convention is that the error stands on its own — read in a CI
log or stack trace, the user can see what to do without context.

102
docs/project.md Normal file
View File

@ -0,0 +1,102 @@
# Project detection
pmacs identifies project roots by walking upward from a file's
parent directory looking for a marker (`Cargo.toml` for Rust,
`package.json` for Node, `.git` as a generic VCS fallback, etc.).
The walk stops at the first match, with language-specific markers
preferred over generic VCS roots when both exist at the same level.
The default behavior matches `git rev-parse --show-toplevel`: walk
all the way to the filesystem root.
## When the default surprises you
A file under `/tmp/scratch.rs` will be classified as part of a
project rooted at `/tmp` if `/tmp/.git` exists — because the walk
finds the marker before hitting the filesystem root. This is the
same surprise `git`, `cargo`, and other tools produce; it's
predictable but occasionally inconvenient.
The escape hatch is `pmacs.project.set_search_boundary(path)`. Set
this in your `init.lua` to clamp the upward walk so a stray marker
high in the tree cannot capture unrelated files.
```lua
-- Restrict project detection to walk only within ~/code.
-- Files outside ~/code will not have a project root detected.
pmacs.project.set_search_boundary(os.getenv("HOME") .. "/code")
```
The boundary is *inclusive*: a marker located at the boundary path
itself is still found. Set the boundary to the directory that
contains your projects, not to one level above. To restore the
default behavior (walk all the way to the filesystem root), pass
`nil`:
```lua
pmacs.project.set_search_boundary(nil)
```
## Symlinks
The boundary applies *after* symlink resolution. When the
boundary is `/home/user/code` and a search starts from a symlinked
path that resolves into `/home/user/code/...`, the walk respects
the boundary correctly.
This matters for two common setups:
- Corporate `/home` mounts, where `/home/user` may be a symlink to
`/var/empire/users/user` or similar — the boundary you set
against your visible home path still works.
- User-organized symlink farms (e.g., `~/work/foo` linked to
`~/code/foo`) — search from the symlinked path still terminates
at the boundary you set against the canonical location.
If the boundary path or the search start does not exist on disk,
canonicalization falls through to the literal path; the comparison
becomes lexical. This affects pre-creation tests but not normal
operation.
## API summary
```lua
pmacs.project.set_search_boundary(path) -- set, or nil to clear
pmacs.project.search_boundary() -- current value, or nil
pmacs.project.detect(file_path) -- honors the boundary
```
`pmacs.project.detect(file_path)` returns
`{ root, kind, language_id }` for the detected project, or `nil` if
no marker matches before the boundary (or the filesystem root, when
no boundary is set).
## Design notes
We considered several alternatives to the unbounded walk, and
chose the opt-in boundary as the most predictable:
- **Hard-coded stops** at `$HOME` / `/tmp` / mount points break
legitimate cases (someone's project lives under `/srv/work`,
someone's `$HOME` is `/var/jeans` over SSH, etc.).
- **Ownership-based stops** ("walk while same uid as the start
file") break shared-dev setups and read-only repo mounts, and add
a `stat` per ancestor.
- **Confidence-weighted detection** (heuristically score "real
project-ness") sacrifices the property that makes detection
useful: predictability.
Matching `git`'s behavior keeps detection's failure mode consistent
with the rest of the user's toolchain. The boundary gives users
who care a precise, configurable opt-in without imposing a
specific policy on everyone.
## Forward planning
The boundary is workspace-scoped (one boundary per `Workspace`
instance). When project-local `init.lua` lands (post-v0.1) we may
extend this to a per-project boundary, or to a stack of boundaries
that nested project loads can push and pop. The v0.1 surface is
deliberately minimal so those future extensions don't break
existing user config: setting a single workspace-wide boundary in
your global `init.lua` continues to do exactly what it does today.

View File

@ -77,6 +77,14 @@ pub enum AnsiEvent {
/// Set the window title (OSC 0 / OSC 2 with terminator). /// Set the window title (OSC 0 / OSC 2 with terminator).
/// Exposed as a per-buffer attribute by the M6.4 REPL view. /// Exposed as a per-buffer attribute by the M6.4 REPL view.
SetTitle(String), SetTitle(String),
/// `OSC 133;A`: shell prompt begins.
PromptStart,
/// `OSC 133;B`: shell prompt ends.
PromptEnd,
/// `OSC 133;C`: command input begins.
CommandStart,
/// `OSC 133;D`: command output begins / command finished marker.
OutputStart,
/// `CSI 200 ~`: a process-emitted bracketed-paste begin marker. /// `CSI 200 ~`: a process-emitted bracketed-paste begin marker.
BracketedPasteBegin, BracketedPasteBegin,
/// `CSI 201 ~`: a process-emitted bracketed-paste end marker. /// `CSI 201 ~`: a process-emitted bracketed-paste end marker.
@ -286,8 +294,20 @@ pub struct AnsiParser {
ignore_byte_count: usize, ignore_byte_count: usize,
/// In-progress text run accumulator. Flushed as a single /// In-progress text run accumulator. Flushed as a single
/// [`AnsiEvent::Text`] when we leave Ground for any non-Ground /// [`AnsiEvent::Text`] when we leave Ground for any non-Ground
/// state. /// state. Holds only complete (valid UTF-8) characters; partial
/// multi-byte sequences live in `utf8_buf` until they complete.
text_run: String, text_run: String,
/// Pending UTF-8 bytes that haven't yet decoded to a complete
/// scalar. Cross-feed buffer: a multi-byte sequence split across
/// `feed()` calls accumulates here until the trailing byte
/// arrives (or until a non-continuation byte invalidates the
/// sequence, at which point we emit U+FFFD for the malformed
/// bytes and continue). The buffer holds at most a few bytes
/// (the longest valid UTF-8 sequence is 4 bytes; we cap at 8
/// defensively). This replaces the M6.3-stage-1 shortcut where
/// every non-ASCII byte was emitted as U+FFFD regardless of
/// whether it was actually malformed.
utf8_buf: Vec<u8>,
/// Suppress `Text` and `SetStyle` events while alternate-screen /// Suppress `Text` and `SetStyle` events while alternate-screen
/// is active. Spec §sec:ansi-scope: parser advances state /// is active. Spec §sec:ansi-scope: parser advances state
/// normally but emits no payload. /// normally but emits no payload.
@ -321,6 +341,7 @@ impl AnsiParser {
current_style: Style::default(), current_style: Style::default(),
ignore_byte_count: 0, ignore_byte_count: 0,
text_run: String::new(), text_run: String::new(),
utf8_buf: Vec::new(),
alt_screen_active: false, alt_screen_active: false,
csi: CsiCollector::default(), csi: CsiCollector::default(),
osc_body: Vec::new(), osc_body: Vec::new(),
@ -336,6 +357,7 @@ impl AnsiParser {
self.state = State::Ground; self.state = State::Ground;
self.ignore_byte_count = 0; self.ignore_byte_count = 0;
self.text_run.clear(); self.text_run.clear();
self.utf8_buf.clear();
self.csi.reset(); self.csi.reset();
self.osc_body.clear(); self.osc_body.clear();
self.escape_intermediates.clear(); self.escape_intermediates.clear();
@ -354,7 +376,20 @@ impl AnsiParser {
// resolve and the bytes belong to it). Since we only build // resolve and the bytes belong to it). Since we only build
// text_run while in Ground, this is safe to flush // text_run while in Ground, this is safe to flush
// unconditionally as a final step. // unconditionally as a final step.
self.flush_text_run(&mut events); //
// We do NOT flush `utf8_buf` here: an incomplete multi-byte
// sequence at the feed boundary is exactly the case the
// cross-feed buffer was added to handle --- the trailing
// bytes are expected to arrive in the next feed. The state
// transition path (flush_text_run) does emit U+FFFD for
// pending bytes because a non-text byte genuinely
// interrupts the sequence; feed-boundary doesn't.
if !self.text_run.is_empty() && !self.alt_screen_active {
let run = std::mem::take(&mut self.text_run);
events.push(AnsiEvent::Text(run));
} else {
self.text_run.clear();
}
events events
} }
@ -425,6 +460,11 @@ impl AnsiParser {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
fn flush_text_run(&mut self, events: &mut Vec<AnsiEvent>) { fn flush_text_run(&mut self, events: &mut Vec<AnsiEvent>) {
// Any pending UTF-8 prefix at this point is interrupted by
// a non-text byte (control byte, CSI start, etc.); the
// sequence can't continue across that boundary. Emit U+FFFD
// for the incomplete bytes before committing the run.
self.flush_pending_utf8_as_replacement();
if self.text_run.is_empty() { if self.text_run.is_empty() {
return; return;
} }
@ -498,58 +538,125 @@ impl AnsiParser {
// line break in the rope; HT as a literal tab. Other // line break in the rope; HT as a literal tab. Other
// C0 controls (0x00..=0x06, 0x0E..=0x1F) and DEL // C0 controls (0x00..=0x06, 0x0E..=0x1F) and DEL
// (0x7F) are dropped silently. // (0x7F) are dropped silently.
0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E => { //
self.text_run.push(b as char); // 0x80..=0xFF: UTF-8 lead or continuation byte. Goes
// through `push_text_byte`'s stateful decoder so
// multi-byte sequences across feeds are buffered until
// complete.
//
// All text bytes route through `push_text_byte` (not
// just non-ASCII): an ASCII byte arriving while a
// partial UTF-8 sequence is pending invalidates that
// sequence (the partial prefix's expected continuation
// didn't arrive), and `push_text_byte` is the only
// place that knows to flush the partial as `U+FFFD`.
// The fast path inside `push_text_byte` keeps the
// pure-ASCII case allocation-free.
0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => {
self.push_text_byte(b);
} }
0x00..=0x1F | 0x7F => {} 0x00..=0x1F | 0x7F => {}
// 0x80..=0xFF: UTF-8 continuation. Push raw bytes; we }
// commit on text-run flush. The push-as-char approach }
// is wrong for multi-byte UTF-8, so we route through a
// byte-level buffer that gets decoded on flush. /// Append a single text byte, handling multi-byte UTF-8
0x80..=0xFF => { /// correctly across feed boundaries.
// Append as a raw byte. We store text_run as a ///
// String, so push UTF-8 bytes via a fallback path: /// ASCII bytes (0x00..=0x7F) take a fast path directly into
// accumulate into a Vec<u8> first if we hit non-ASCII. /// `text_run` when no partial sequence is pending. Non-ASCII
// For simplicity, push the byte and let /// bytes (0x80..=0xFF) and any byte arriving while a partial
// String::push_byte handle it via a helper. /// sequence is pending go through `utf8_buf`, which is then
self.push_text_byte(b); /// greedily decoded by `try_decode_utf8_buf`. Complete scalars
/// flush to `text_run`; an incomplete trailing sequence stays
/// in `utf8_buf` for the next byte (whether next-feed or
/// later in the same feed).
///
/// Malformed sequences (lone continuation, invalid start byte,
/// non-continuation arriving where one was expected, overlong
/// encoding) emit `U+FFFD` for the offending bytes only and
/// resume decoding the rest --- they do not corrupt subsequent
/// valid UTF-8.
fn push_text_byte(&mut self, b: u8) {
if self.utf8_buf.is_empty() && b < 0x80 {
self.text_run.push(b as char);
return;
}
self.utf8_buf.push(b);
self.try_decode_utf8_buf();
}
/// Greedy UTF-8 decoder over `utf8_buf`. Pushes complete chars
/// to `text_run`; emits `U+FFFD` for malformed bytes; leaves
/// the trailing incomplete prefix in `utf8_buf` for the next
/// byte to complete.
///
/// Caps the buffer at 8 bytes defensively: a valid UTF-8
/// sequence is at most 4 bytes, so any trailing run longer
/// than that is malformed (we'd have hit either a complete
/// scalar or a `from_utf8` error before reaching 8). The cap
/// bounds the pathological-input memory cost.
fn try_decode_utf8_buf(&mut self) {
loop {
if self.utf8_buf.is_empty() {
return;
}
match std::str::from_utf8(&self.utf8_buf) {
Ok(s) => {
// Whole buffer is valid UTF-8: flush all of it.
self.text_run.push_str(s);
self.utf8_buf.clear();
return;
}
Err(e) => {
let valid = e.valid_up_to();
if valid > 0 {
// Push the valid prefix.
let prefix = std::str::from_utf8(&self.utf8_buf[..valid])
.expect("invariant: valid_up_to bytes are valid UTF-8");
self.text_run.push_str(prefix);
self.utf8_buf.drain(..valid);
}
match e.error_len() {
None => {
// Trailing incomplete sequence; wait for
// more bytes (next push_text_byte or next
// feed). The 8-byte defensive cap below
// guards against a pathological producer
// that never finishes a sequence.
if self.utf8_buf.len() >= 8 {
self.text_run.push('\u{FFFD}');
self.utf8_buf.clear();
}
return;
}
Some(n) => {
// n bytes after the valid prefix are an
// invalid sequence: emit U+FFFD for them
// and continue with whatever follows.
self.text_run.push('\u{FFFD}');
self.utf8_buf.drain(..n);
// Loop to retry.
}
}
}
} }
} }
} }
/// Append a single byte to the text run, handling UTF-8 /// Drain any pending UTF-8 prefix as `U+FFFD`. Called from
/// continuation correctly. ASCII bytes go directly; non-ASCII /// `flush_text_run` when the text run is being committed
/// bytes accumulate in a pending UTF-8 buffer that flushes /// because we're transitioning out of Ground (a control byte,
/// once a complete scalar arrives or recovers as U+FFFD on a /// a CSI start, etc.). At that boundary, an unfinished
/// malformed sequence. /// multi-byte sequence is genuinely interrupted --- it can't
fn push_text_byte(&mut self, b: u8) { /// continue across the non-text bytes --- so we emit the
// For correctness across multi-byte UTF-8 split across feeds /// replacement character and clear.
// we'd need a stateful UTF-8 decoder. For v0.1 / M6.3, we ///
// append the byte as-is by reinterpreting the String's /// Not called at end-of-feed: a sequence interrupted by feed
// backing buffer: since we always hit this path after an /// boundary may legitimately resume in the next feed.
// ASCII run, and the only non-ASCII source is text, the fn flush_pending_utf8_as_replacement(&mut self) {
// simple safe approach is to accumulate raw bytes in a if !self.utf8_buf.is_empty() {
// separate Vec until flush time and then convert. That's a
// bigger refactor; for stage 1 we use String::from_utf8_lossy
// at flush time. Implementation bridge: stash raw bytes in
// text_run via unsafe? No --- forbid(unsafe_code). Instead,
// route through a small helper that writes the byte as a
// single-byte char iff it's ASCII, else uses a
// String-extending fallback. For non-ASCII we append a
// valid char that we'll fix up at flush time.
//
// Stage 1 simplification: convert the byte to its ASCII
// approximation. Stage 2 will introduce a proper UTF-8
// continuation decoder.
//
// TODO(M6.3 stage 2): proper UTF-8 across feed boundaries.
if b.is_ascii() {
self.text_run.push(b as char);
} else {
// Fall back: render as Unicode replacement character.
// This is wrong for proper UTF-8 input but never panics
// and never corrupts state. Stage 2 fix.
self.text_run.push('\u{FFFD}'); self.text_run.push('\u{FFFD}');
self.utf8_buf.clear();
} }
} }
@ -896,10 +1003,22 @@ impl AnsiParser {
let num: Option<u32> = std::str::from_utf8(num_part) let num: Option<u32> = std::str::from_utf8(num_part)
.ok() .ok()
.and_then(|s| s.parse().ok()); .and_then(|s| s.parse().ok());
// Only OSC 0 (set icon name + window title) and OSC 2 (set if matches!(num, Some(133)) && !self.alt_screen_active {
// window title) produce a SetTitle event. Other OSC numbers match text_part.first().copied() {
// are parsed and discarded per spec §sec:ansi-scope, with Some(b'A') => events.push(AnsiEvent::PromptStart),
// the critical guarantee that state alignment is preserved. Some(b'B') => events.push(AnsiEvent::PromptEnd),
Some(b'C') => events.push(AnsiEvent::CommandStart),
Some(b'D') => events.push(AnsiEvent::OutputStart),
_ => {}
}
return;
}
// Only OSC 0 (set icon name + window title), OSC 2 (set
// window title), and the OSC 133 shell integration markers
// above produce events. Other OSC numbers are parsed and
// discarded per spec §sec:ansi-scope, with the critical
// guarantee that state alignment is preserved.
if matches!(num, Some(0 | 2)) && !self.alt_screen_active { if matches!(num, Some(0 | 2)) && !self.alt_screen_active {
let title = String::from_utf8_lossy(text_part).into_owned(); let title = String::from_utf8_lossy(text_part).into_owned();
events.push(AnsiEvent::SetTitle(title)); events.push(AnsiEvent::SetTitle(title));
@ -1256,6 +1375,33 @@ mod tests {
assert_eq!(collect_text(&evs), "hello"); assert_eq!(collect_text(&evs), "hello");
} }
#[test]
fn m6_3_osc_133_prompt_markers_are_structured_events() {
let mut p = AnsiParser::new();
let evs = p.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07\x1b]133;C\x07\x1b]133;D;0\x07");
let kinds: Vec<&str> = evs
.iter()
.map(|ev| match ev {
AnsiEvent::PromptStart => "prompt_start",
AnsiEvent::Text(s) if s == "$ " => "text",
AnsiEvent::PromptEnd => "prompt_end",
AnsiEvent::CommandStart => "command_start",
AnsiEvent::OutputStart => "output_start",
other => panic!("unexpected event: {other:?}"),
})
.collect();
assert_eq!(
kinds,
vec![
"prompt_start",
"text",
"prompt_end",
"command_start",
"output_start"
]
);
}
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Acceptance bullet 4: alternate-screen suppression // Acceptance bullet 4: alternate-screen suppression
// ----------------------------------------------------------------- // -----------------------------------------------------------------
@ -1383,6 +1529,177 @@ mod tests {
// Strikethrough advances state without corrupting running style // Strikethrough advances state without corrupting running style
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// ---- UTF-8 cross-feed handling (post-M6.10 audit fix) -----------------
/// Multi-byte UTF-8 in a single feed decodes to the correct
/// scalar, not U+FFFD. Catches the M6.3-stage-1 shortcut that
/// emitted U+FFFD for every non-ASCII byte regardless of whether
/// it was actually malformed.
#[test]
fn ansi_utf8_multibyte_in_single_feed_decodes_correctly() {
let mut p = AnsiParser::new();
// "café" --- the é is U+00E9, encoded as 0xC3 0xA9 in UTF-8.
let evs = p.feed("café".as_bytes());
assert_eq!(collect_text(&evs), "café");
}
/// Three-byte UTF-8 (e.g., CJK) decodes correctly. Same gate as
/// above but exercises the 1110xxxx start byte + two
/// continuations path.
#[test]
fn ansi_utf8_three_byte_sequence_decodes_correctly() {
let mut p = AnsiParser::new();
let evs = p.feed("日本語".as_bytes());
assert_eq!(collect_text(&evs), "日本語");
}
/// Four-byte UTF-8 (e.g., emoji past U+FFFF) decodes correctly.
/// Exercises the 11110xxx start byte + three continuations path.
#[test]
fn ansi_utf8_four_byte_sequence_decodes_correctly() {
let mut p = AnsiParser::new();
// U+1F600 = 😀 = 0xF0 0x9F 0x98 0x80
let evs = p.feed("😀".as_bytes());
assert_eq!(collect_text(&evs), "😀");
}
/// A multi-byte sequence split across two feeds completes
/// correctly. This is the load-bearing cross-feed behavior:
/// pre-fix, the first feed would emit U+FFFD for the start byte
/// and the second feed would emit U+FFFDs for the continuation
/// bytes; post-fix, the partial bytes wait in `utf8_buf` and
/// flush as one correct char when the trailing byte arrives.
#[test]
fn ansi_utf8_split_across_two_feeds() {
let mut p = AnsiParser::new();
let bytes = "café".as_bytes();
// Split between the start byte (0xC3) and continuation
// (0xA9) of the é.
let split = bytes.len() - 1;
let evs1 = p.feed(&bytes[..split]);
let evs2 = p.feed(&bytes[split..]);
let combined: String = collect_text(&evs1) + &collect_text(&evs2);
assert_eq!(combined, "café");
}
/// A four-byte sequence split across three feeds completes
/// correctly. Exercises the cross-feed buffer holding 2 bytes
/// across one feed and 1 byte across the next.
#[test]
fn ansi_utf8_four_byte_split_across_three_feeds() {
let mut p = AnsiParser::new();
// U+1F600 = 0xF0 0x9F 0x98 0x80
let evs1 = p.feed(&[0xF0]);
let evs2 = p.feed(&[0x9F, 0x98]);
let evs3 = p.feed(&[0x80]);
let combined = collect_text(&evs1) + &collect_text(&evs2) + &collect_text(&evs3);
assert_eq!(combined, "😀");
}
/// Mixed ASCII and multi-byte text decodes correctly.
#[test]
fn ansi_utf8_mixed_ascii_and_multibyte() {
let mut p = AnsiParser::new();
let evs = p.feed("hello, café 日本 😀!".as_bytes());
assert_eq!(collect_text(&evs), "hello, café 日本 😀!");
}
/// A lone continuation byte (no preceding start) emits U+FFFD
/// for that byte only; subsequent ASCII still decodes to ASCII.
#[test]
fn ansi_utf8_lone_continuation_byte_emits_replacement() {
let mut p = AnsiParser::new();
// 0x80 alone is a continuation byte with no start.
// ASCII follows; it should decode normally.
let evs = p.feed(&[b'a', 0x80, b'b']);
assert_eq!(collect_text(&evs), "a\u{FFFD}b");
}
/// An invalid start byte (0xFF) emits U+FFFD; subsequent ASCII
/// decodes normally.
#[test]
fn ansi_utf8_invalid_start_byte_emits_replacement() {
let mut p = AnsiParser::new();
let evs = p.feed(&[b'a', 0xFF, b'b']);
assert_eq!(collect_text(&evs), "a\u{FFFD}b");
}
/// A start byte expecting two continuations followed by ASCII
/// (one continuation, then ASCII) emits U+FFFD for the
/// truncated sequence and decodes the ASCII normally.
#[test]
fn ansi_utf8_truncated_sequence_then_ascii() {
let mut p = AnsiParser::new();
// 0xE6 (start of 3-byte) + 0x97 (continuation) + 'X' (ASCII,
// not a continuation). The sequence is malformed: 0xE6 0x97
// followed by ASCII.
let evs = p.feed(&[0xE6, 0x97, b'X']);
// The 0xE6 0x97 prefix is malformed; std::str::from_utf8
// reports error_len() = 2, so we emit one U+FFFD covering
// both bytes, then ASCII.
let text = collect_text(&evs);
assert!(
text.contains('\u{FFFD}') && text.ends_with('X'),
"expected U+FFFD then 'X', got {text:?}"
);
}
/// A multi-byte sequence interrupted by a control byte (CSI
/// start) flushes pending bytes as U+FFFD, then resumes
/// processing the control byte normally.
#[test]
fn ansi_utf8_partial_interrupted_by_csi_emits_replacement() {
let mut p = AnsiParser::new();
// 0xC3 (start of 2-byte) then ESC [ 31 m (red SGR).
// The 0xC3 is interrupted by the ESC; should emit U+FFFD
// for the partial, then process the SGR.
let evs = p.feed(&[b'a', 0xC3, 0x1B, b'[', b'3', b'1', b'm', b'b']);
let text = collect_text(&evs);
assert!(
text.starts_with("a\u{FFFD}") && text.ends_with('b'),
"expected a + U+FFFD + b, got {text:?}"
);
// The SGR should still have produced a SetStyle event.
let styles = collect_styles(&evs);
assert_eq!(styles.len(), 1, "expected one SetStyle from the SGR");
assert_eq!(styles[0].fg, Color::Indexed(1));
}
/// Pending UTF-8 prefix that exceeds the 8-byte defensive cap
/// (pathological producer that never finishes a sequence)
/// flushes as U+FFFD and recovers. Ensures the buffer can't
/// grow unbounded.
#[test]
fn ansi_utf8_pathological_pending_caps_at_8_bytes() {
let mut p = AnsiParser::new();
// Feed 8 start bytes in a row. Each is a "start of 2-byte"
// marker; the next one arriving where a continuation is
// expected is malformed. The first one accumulates; each
// subsequent one emits U+FFFD for the malformed prefix.
// After 8 bytes accumulated without completing, the cap
// forces a flush.
let evs = p.feed(&[0xC3; 9]);
let text = collect_text(&evs);
// Every byte should have been replaced; no panic, no
// unbounded growth.
assert!(
text.chars().all(|c| c == '\u{FFFD}'),
"all 9 bytes should be replaced; got {text:?}"
);
}
/// `reset()` clears any pending UTF-8 prefix.
#[test]
fn ansi_utf8_reset_clears_pending_buffer() {
let mut p = AnsiParser::new();
let _ = p.feed(&[0xC3]); // partial é prefix pending
p.reset();
// Subsequent valid input must decode cleanly --- the stale
// prefix should not contaminate it.
let evs = p.feed("hello".as_bytes());
assert_eq!(collect_text(&evs), "hello");
}
/// SGR 9 (strikethrough) is a no-op for the running style --- /// SGR 9 (strikethrough) is a no-op for the running style ---
/// the cell `Style` has no strikethrough field, so the running /// the cell `Style` has no strikethrough field, so the running
/// style must be *unchanged* after SGR 9. A future regression /// style must be *unchanged* after SGR 9. A future regression

View File

@ -1220,6 +1220,27 @@ fn classify_ssh_exit(
mod tests { mod tests {
use super::*; use super::*;
/// Try to bind a `UnixListener` at `path`. On `PermissionDenied`
/// (e.g., a sandboxed CI environment that disallows `AF_UNIX`
/// socket creation), prints a skip notice and returns `None`;
/// the calling test should early-return so the suite reports
/// `0 failed` rather than a misleading panic. Mirror of the
/// helper in `daemon_attach.rs`'s test module.
fn bind_or_skip(path: &std::path::Path) -> Option<UnixListener> {
match UnixListener::bind(path) {
Ok(l) => Some(l),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!(
"test skipped: UnixListener::bind {} → PermissionDenied \
(sandboxed environment).",
path.display()
);
None
}
Err(e) => panic!("UnixListener::bind {} failed: {e}", path.display()),
}
}
#[test] #[test]
fn format_uptime_shapes() { fn format_uptime_shapes() {
assert_eq!(format_uptime(5), "5s"); assert_eq!(format_uptime(5), "5s");
@ -1474,7 +1495,9 @@ mod tests {
fn version_mismatch_errors_at_construction_site() { fn version_mismatch_errors_at_construction_site() {
let tmp = tempfile::tempdir().expect("tempdir"); let tmp = tempfile::tempdir().expect("tempdir");
let socket_path = tmp.path().join("test.sock"); let socket_path = tmp.path().join("test.sock");
let listener = UnixListener::bind(&socket_path).expect("UnixListener::bind"); let Some(listener) = bind_or_skip(&socket_path) else {
return;
};
// Fake daemon: accept one connection, write a Hello with a // Fake daemon: accept one connection, write a Hello with a
// bogus protocol version, exit. The accept blocks until // bogus protocol version, exit. The accept blocks until

View File

@ -29,7 +29,7 @@
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use crate::rope::{Edit, Position, Range, Rope, RopeError}; use crate::rope::{Edit, Position, Range, Rope, RopeError};
use crate::view::View; use crate::view::{InterceptContext, View};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Identifiers // Identifiers
@ -75,6 +75,37 @@ impl ViewId {
} }
} }
/// Opaque, per-buffer identifier for a position mark.
///
/// Marks are owned by a [`Buffer`] and move through edits according to
/// their gravity. They are intentionally not process-global: a
/// `MarkId(0)` from one buffer has no meaning in another buffer.
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub struct MarkId(u64);
impl MarkId {
/// Inspect the raw value. Useful for logging and FFI.
#[must_use]
pub const fn raw(self) -> u64 {
self.0
}
}
/// Which side of an insertion/replacement a mark sticks to.
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum MarkGravity {
/// Stay before bytes inserted exactly at the mark.
Left,
/// Move after bytes inserted exactly at the mark.
Right,
}
#[derive(Copy, Clone, Debug)]
struct Mark {
pos: Position,
gravity: MarkGravity,
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Edit operations // Edit operations
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -156,10 +187,24 @@ pub struct Buffer {
views: Vec<(ViewId, Box<dyn View>)>, views: Vec<(ViewId, Box<dyn View>)>,
/// Per-buffer counter for [`ViewId`] allocation. /// Per-buffer counter for [`ViewId`] allocation.
next_view_id: u64, next_view_id: u64,
/// Buffer-relative marks. Kept as a small vector because current
/// consumers create a handful per buffer; if this grows into
/// thousands, this can become an indexed table without changing
/// the public API.
marks: Vec<(MarkId, Mark)>,
/// Per-buffer counter for [`MarkId`] allocation.
next_mark_id: u64,
/// Undo stack. Most recent entry on top. /// Undo stack. Most recent entry on top.
undo: Vec<UndoEntry>, undo: Vec<UndoEntry>,
/// Redo stack. Cleared by any forward edit. /// Redo stack. Cleared by any forward edit.
redo: Vec<UndoEntry>, redo: Vec<UndoEntry>,
/// True while an edit is in flight on this buffer (T M7.4).
/// Set by [`Buffer::begin_edit`], cleared by [`Buffer::end_edit`].
/// A re-entrant `apply_edit` / `apply_edit_skip_intercepts` while
/// the flag is set returns [`BufferError::ConcurrentEdit`] rather
/// than mutating the rope mid-intercept; cross-buffer re-entry
/// is unaffected.
editing_in_progress: bool,
} }
impl Buffer { impl Buffer {
@ -193,8 +238,11 @@ impl Buffer {
revision: 0, revision: 0,
views: Vec::new(), views: Vec::new(),
next_view_id: 0, next_view_id: 0,
marks: Vec::new(),
next_mark_id: 0,
undo: Vec::new(), undo: Vec::new(),
redo: Vec::new(), redo: Vec::new(),
editing_in_progress: false,
} }
} }
@ -290,6 +338,126 @@ impl Buffer {
Some(self.views.remove(idx).1) Some(self.views.remove(idx).1)
} }
/// Create a mark at byte position `pos`.
///
/// The position must be inside the current buffer (`pos <= len`).
/// The returned ID is scoped to this buffer.
pub fn create_mark(
&mut self,
pos: Position,
gravity: MarkGravity,
) -> Result<MarkId, BufferError> {
if pos > self.len() {
return Err(BufferError::Rope(RopeError::OutOfBounds {
pos,
len: self.len(),
}));
}
let id = MarkId(self.next_mark_id);
self.next_mark_id += 1;
self.marks.push((id, Mark { pos, gravity }));
Ok(id)
}
/// Current byte position of a mark, or `None` if it has been removed.
#[must_use]
pub fn mark_pos(&self, id: MarkId) -> Option<Position> {
self.marks
.iter()
.find_map(|(mark_id, mark)| (*mark_id == id).then_some(mark.pos))
}
/// Move an existing mark to `pos`.
///
/// Returns `Ok(false)` for an unknown mark ID. Out-of-bounds
/// positions are errors and leave the mark unchanged.
pub fn set_mark(&mut self, id: MarkId, pos: Position) -> Result<bool, BufferError> {
if pos > self.len() {
return Err(BufferError::Rope(RopeError::OutOfBounds {
pos,
len: self.len(),
}));
}
let Some((_, mark)) = self.marks.iter_mut().find(|(mark_id, _)| *mark_id == id) else {
return Ok(false);
};
mark.pos = pos;
Ok(true)
}
/// Remove a mark. Returns `true` if the mark existed.
pub fn remove_mark(&mut self, id: MarkId) -> bool {
let Some(idx) = self.marks.iter().position(|(mark_id, _)| *mark_id == id) else {
return false;
};
self.marks.remove(idx);
true
}
/// Take all attached views out of the buffer, returning ownership
/// to the caller (T M7.4).
///
/// Pair with [`Buffer::restore_views`]. While the views are taken
/// out, the buffer's view list is empty: `attach_view` calls
/// during this window land in the empty list and will be
/// preserved by `restore_views`.
///
/// Used by the Lua bindings to run the intercept chain with the
/// registry borrow released, so an intercept body may safely
/// re-enter the buffer API on any buffer (including this one,
/// modulo the `editing_in_progress` gate).
pub fn take_views(&mut self) -> Vec<(ViewId, Box<dyn View>)> {
std::mem::take(&mut self.views)
}
/// Restore previously-taken views.
///
/// Views attached during the take/restore window are preserved
/// and ordered after the restored set. Use case: a Lua intercept
/// body on buffer A calls `pmacs.buffer.add_intercept(A, ...)` to
/// install another intercept; the new view should sit after the
/// existing chain so the existing chain still runs first on
/// future edits.
pub fn restore_views(&mut self, mut original: Vec<(ViewId, Box<dyn View>)>) {
let new_additions = std::mem::take(&mut self.views);
original.extend(new_additions);
self.views = original;
}
/// Mark the buffer as mid-edit (T M7.4). Pairs with [`Buffer::end_edit`].
///
/// Returns [`BufferError::ConcurrentEdit`] if a previous
/// `begin_edit` is unmatched. The Lua bindings call this at the
/// start of the three-phase edit flow so that a re-entrant Lua
/// call into the same buffer's `apply_edit` /
/// `apply_edit_skip_intercepts` surfaces a typed error rather
/// than silently corrupting state.
pub fn begin_edit(&mut self) -> Result<(), BufferError> {
if self.editing_in_progress {
return Err(BufferError::ConcurrentEdit {
id: self.id,
name: self.name.clone(),
});
}
self.editing_in_progress = true;
Ok(())
}
/// Clear the mid-edit flag set by [`Buffer::begin_edit`].
/// Idempotent. Lua bindings call this at the end of the edit
/// flow, before the final `apply_edit_skip_intercepts`.
pub fn end_edit(&mut self) {
self.editing_in_progress = false;
}
/// Whether the buffer is currently mid-edit (T M7.4).
/// Useful for diagnostic tooling; the in-process flow's
/// re-entrancy check happens inside `apply_edit` itself.
#[must_use]
pub fn editing_in_progress(&self) -> bool {
self.editing_in_progress
}
/// Apply an edit. /// Apply an edit.
/// ///
/// Walks the intercept-edit chain in attach order; applies the /// Walks the intercept-edit chain in attach order; applies the
@ -300,8 +468,24 @@ impl Buffer {
/// On error the buffer is left in its pre-edit state and the undo /// On error the buffer is left in its pre-edit state and the undo
/// stack is unchanged. /// stack is unchanged.
/// ///
/// # Re-entrancy (T M7.4)
///
/// In-process Rust callers run intercepts under the same `&mut Buffer`
/// borrow that owns the apply --- no re-entry path exists, so the
/// `editing_in_progress` flag is not set by this method (it is set
/// only by [`Buffer::begin_edit`], which the Lua bindings use to gate
/// same-buffer re-entry). A caller that does `b.apply_edit(...)`
/// while another `apply_edit` is on the stack for the same `b`
/// would already fail at `&mut` aliasing in safe Rust.
///
/// Threading: main thread only. /// Threading: main thread only.
pub fn apply_edit(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> { pub fn apply_edit(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> {
if self.editing_in_progress {
return Err(BufferError::ConcurrentEdit {
id: self.id,
name: self.name.clone(),
});
}
// Take views out so the loop body can borrow `&self` while iterating. // Take views out so the loop body can borrow `&self` while iterating.
// The buffer is left view-less only for the duration of this call; // The buffer is left view-less only for the duration of this call;
// panics during it would leave an empty view list (acceptable: views // panics during it would leave an empty view list (acceptable: views
@ -313,6 +497,29 @@ impl Buffer {
result result
} }
/// Apply an edit, skipping the intercept chain.
///
/// Used by the Lua bindings (T M7.4) after they have run intercepts
/// out-of-band with the registry borrow released. Behaves like
/// [`Buffer::apply_edit`] from "rope edit" onward: rope mutation,
/// undo bookkeeping, modified flag, revision bump, and `on_edit`
/// broadcast all happen here.
///
/// In-process Rust callers should use [`Buffer::apply_edit`]
/// instead --- this primitive exists for the case where the
/// caller has already evaluated the intercept chain and has the
/// final [`EditOp`] in hand.
#[allow(
clippy::needless_pass_by_value,
reason = "by-value mirrors apply_edit's signature; the Lua bindings build a fresh EditOp per call"
)]
pub fn apply_edit_skip_intercepts(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> {
let mut views = std::mem::take(&mut self.views);
let result = self.run_rope_edit_and_broadcast(&mut views, &op);
self.views = views;
result
}
fn apply_edit_inner( fn apply_edit_inner(
&mut self, &mut self,
views: &mut [(ViewId, Box<dyn View>)], views: &mut [(ViewId, Box<dyn View>)],
@ -320,12 +527,22 @@ impl Buffer {
) -> Result<Edit, BufferError> { ) -> Result<Edit, BufferError> {
// Stage 1: intercept chain. // Stage 1: intercept chain.
let mut current = op; let mut current = op;
let ctx = InterceptContext::snapshot(self);
for (_, view) in views.iter_mut() { for (_, view) in views.iter_mut() {
current = view.intercept_edit(self, current)?; current = view.intercept_edit(&ctx, current)?;
} }
// Stages 2-4: rope edit + state update + broadcast.
self.run_rope_edit_and_broadcast(views, &current)
}
fn run_rope_edit_and_broadcast(
&mut self,
views: &mut [(ViewId, Box<dyn View>)],
current: &EditOp<'_>,
) -> Result<Edit, BufferError> {
// Stage 2: rope edit. // Stage 2: rope edit.
let edit = match &current { let edit = match current {
EditOp::Insert { pos, bytes } => self.rope.insert(*pos, bytes)?, EditOp::Insert { pos, bytes } => self.rope.insert(*pos, bytes)?,
EditOp::Delete { range } => self.rope.delete(range.start, range.end)?, EditOp::Delete { range } => self.rope.delete(range.start, range.end)?,
EditOp::Replace { range, bytes } => self.rope.replace(range.start, range.end, bytes)?, EditOp::Replace { range, bytes } => self.rope.replace(range.start, range.end, bytes)?,
@ -350,6 +567,7 @@ impl Buffer {
let pre_range = edit.range; let pre_range = edit.range;
let inserted_len = edit.inserted_len; let inserted_len = edit.inserted_len;
let old_rope = std::mem::replace(&mut self.rope, edit.new_rope.clone()); let old_rope = std::mem::replace(&mut self.rope, edit.new_rope.clone());
self.adjust_marks_for_edit(pre_range, inserted_len);
self.undo.push(UndoEntry { self.undo.push(UndoEntry {
rope: old_rope, rope: old_rope,
edit: EditDescription { edit: EditDescription {
@ -391,6 +609,7 @@ impl Buffer {
let new_rope = entry.rope.clone(); let new_rope = entry.rope.clone();
let old_rope = std::mem::replace(&mut self.rope, new_rope.clone()); let old_rope = std::mem::replace(&mut self.rope, new_rope.clone());
self.adjust_marks_for_edit(inverse_pre_range, inverse_inserted_len);
self.redo.push(UndoEntry { self.redo.push(UndoEntry {
rope: old_rope, rope: old_rope,
edit: EditDescription { edit: EditDescription {
@ -428,6 +647,7 @@ impl Buffer {
let new_rope = entry.rope.clone(); let new_rope = entry.rope.clone();
let old_rope = std::mem::replace(&mut self.rope, new_rope.clone()); let old_rope = std::mem::replace(&mut self.rope, new_rope.clone());
self.adjust_marks_for_edit(inverse_pre_range, inverse_inserted_len);
self.undo.push(UndoEntry { self.undo.push(UndoEntry {
rope: old_rope, rope: old_rope,
edit: EditDescription { edit: EditDescription {
@ -458,6 +678,43 @@ impl Buffer {
self.views = views; self.views = views;
result result
} }
fn adjust_marks_for_edit(&mut self, range: Range, inserted_len: u64) {
let start = range.start;
let end = range.end;
let old_len = range.len();
let new_end = start.saturating_add(inserted_len);
for (_, mark) in &mut self.marks {
let pos = mark.pos;
mark.pos = if pos < start {
pos
} else if pos > end {
pos - old_len + inserted_len
} else if pos == start {
if old_len == 0 && mark.gravity == MarkGravity::Right {
new_end
} else if old_len == 0 {
start
} else {
match mark.gravity {
MarkGravity::Left => start,
MarkGravity::Right => new_end,
}
}
} else if pos < end {
match mark.gravity {
MarkGravity::Left => start,
MarkGravity::Right => new_end,
}
} else {
match mark.gravity {
MarkGravity::Left => start,
MarkGravity::Right => new_end,
}
};
}
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -486,6 +743,27 @@ pub enum BufferError {
/// Human-readable reason. Surfaced verbatim to the user. /// Human-readable reason. Surfaced verbatim to the user.
reason: String, reason: String,
}, },
/// A re-entrant edit was attempted on a buffer that is already
/// mid-edit (T M7.4). The most common path: a Lua intercept body
/// running on buffer A called `A:insert(...)` or similar.
/// Cross-buffer re-entry (`A`'s intercept editing `B`) is allowed
/// and does not surface this error.
///
/// The message names a workaround per the project convention.
#[error(
"buffer `{name}` (id {id:?}) is already being edited; \
re-entrant edits on the same buffer are not supported. \
To compose with the current edit, return a transformed table \
from this intercept; to schedule a follow-up edit, register an \
on-edit hook that runs after the current edit completes, or \
edit a different buffer."
)]
ConcurrentEdit {
/// The buffer's identifier.
id: BufferId,
/// The buffer's name, for diagnostics.
name: String,
},
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -525,13 +803,12 @@ mod tests {
impl View for RecorderView { impl View for RecorderView {
fn intercept_edit<'a>( fn intercept_edit<'a>(
&mut self, &mut self,
buf: &Buffer, ctx: &crate::view::InterceptContext,
op: EditOp<'a>, op: EditOp<'a>,
) -> Result<EditOp<'a>, BufferError> { ) -> Result<EditOp<'a>, BufferError> {
self.events self.events.lock().unwrap().push(RecorderEvent::Intercept {
.lock() pre_len: ctx.buf_len,
.unwrap() });
.push(RecorderEvent::Intercept { pre_len: buf.len() });
Ok(op) Ok(op)
} }
fn on_edit(&mut self, buf: &Buffer, edit: &Edit) -> Result<(), BufferError> { fn on_edit(&mut self, buf: &Buffer, edit: &Edit) -> Result<(), BufferError> {
@ -550,7 +827,7 @@ mod tests {
impl View for ReverseInsertView { impl View for ReverseInsertView {
fn intercept_edit<'a>( fn intercept_edit<'a>(
&mut self, &mut self,
_buf: &Buffer, _ctx: &crate::view::InterceptContext,
op: EditOp<'a>, op: EditOp<'a>,
) -> Result<EditOp<'a>, BufferError> { ) -> Result<EditOp<'a>, BufferError> {
// Cannot return EditOp with owned bytes given the lifetime // Cannot return EditOp with owned bytes given the lifetime
@ -621,6 +898,58 @@ mod tests {
assert_eq!(collect(&b), b"abc"); assert_eq!(collect(&b), b"abc");
} }
#[test]
fn marks_apply_insertion_gravity() {
let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abcd");
let left = b.create_mark(2, MarkGravity::Left).unwrap();
let right = b.create_mark(2, MarkGravity::Right).unwrap();
b.apply_edit(EditOp::Insert {
pos: 2,
bytes: b"XX",
})
.unwrap();
assert_eq!(b.mark_pos(left), Some(2));
assert_eq!(b.mark_pos(right), Some(4));
}
#[test]
fn marks_shift_and_clamp_through_delete() {
let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abcdef");
let before = b.create_mark(1, MarkGravity::Right).unwrap();
let inside = b.create_mark(3, MarkGravity::Left).unwrap();
let after = b.create_mark(5, MarkGravity::Right).unwrap();
b.apply_edit(EditOp::Delete {
range: Range::new(2, 4),
})
.unwrap();
assert_eq!(b.mark_pos(before), Some(1));
assert_eq!(b.mark_pos(inside), Some(2));
assert_eq!(b.mark_pos(after), Some(3));
}
#[test]
fn marks_follow_undo_and_redo() {
let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abcd");
let mark = b.create_mark(3, MarkGravity::Right).unwrap();
b.apply_edit(EditOp::Insert {
pos: 1,
bytes: b"XX",
})
.unwrap();
assert_eq!(b.mark_pos(mark), Some(5));
b.undo().unwrap();
assert_eq!(b.mark_pos(mark), Some(3));
b.redo().unwrap();
assert_eq!(b.mark_pos(mark), Some(5));
}
#[test] #[test]
fn intercept_runs_before_on_edit_and_before_rope_mutation() { fn intercept_runs_before_on_edit_and_before_rope_mutation() {
let mut b = Buffer::from_bytes(BufferId::next(), "test", b"hi"); let mut b = Buffer::from_bytes(BufferId::next(), "test", b"hi");

View File

@ -35,6 +35,24 @@ pub enum RegistryError {
/// the Lua boundary (R52). /// the Lua boundary (R52).
id: BufferId, id: BufferId,
}, },
/// Removal was requested for a buffer that is currently mid-edit
/// (T M7.4). The most common path: a Lua intercept body on buffer
/// `A` called `pmacs.buffer.remove(A)`. Mirrors
/// [`crate::buffer::BufferError::ConcurrentEdit`] in spirit; the
/// message names the workaround per project convention.
#[error(
"buffer `{name}` (id {id:?}) is already being edited; \
it cannot be removed while an intercept is running on it. \
To remove this buffer, return from the intercept first \
(the registry borrow will release at that point), or remove \
a different buffer."
)]
ConcurrentEdit {
/// The buffer's identifier.
id: BufferId,
/// The buffer's name, for diagnostics.
name: String,
},
} }
/// Owns [`Buffer`]s behind their [`BufferId`]s. /// Owns [`Buffer`]s behind their [`BufferId`]s.
@ -100,7 +118,23 @@ impl BufferRegistry {
/// Remove and return the buffer behind `id`. Subsequent lookups of /// Remove and return the buffer behind `id`. Subsequent lookups of
/// `id` produce [`RegistryError::Missing`]. /// `id` produce [`RegistryError::Missing`].
///
/// Refuses to remove a buffer that is currently mid-edit (T M7.4):
/// an intercept body running on buffer `A` cannot drop `A` out
/// from under itself. Returns
/// [`RegistryError::ConcurrentEdit`] in that case, leaving the
/// buffer in place.
pub fn remove(&mut self, id: BufferId) -> Result<Buffer, RegistryError> { pub fn remove(&mut self, id: BufferId) -> Result<Buffer, RegistryError> {
// Peek without taking ownership: if the buffer is mid-edit we
// surface a typed error and leave the registry untouched.
if let Some(buf) = self.buffers.get(&id) {
if buf.editing_in_progress() {
return Err(RegistryError::ConcurrentEdit {
id,
name: buf.name().to_string(),
});
}
}
let buf = self let buf = self
.buffers .buffers
.remove(&id) .remove(&id)
@ -178,8 +212,12 @@ mod tests {
let r = BufferRegistry::new(); let r = BufferRegistry::new();
let stale = BufferId::next(); let stale = BufferId::next();
let err = r.get(stale).err().expect("expected stale-handle error"); let err = r.get(stale).err().expect("expected stale-handle error");
let RegistryError::Missing { id } = err; match err {
assert_eq!(id, stale); RegistryError::Missing { id } => assert_eq!(id, stale),
other @ RegistryError::ConcurrentEdit { .. } => {
panic!("expected Missing, got {other:?}")
}
}
} }
#[test] #[test]

View File

@ -399,6 +399,31 @@ mod tests {
use std::sync::mpsc; use std::sync::mpsc;
use std::time::Duration; use std::time::Duration;
/// Try to bind a `UnixListener` at `path`. On `PermissionDenied`
/// (e.g., a sandboxed CI environment that disallows `AF_UNIX`
/// socket creation), prints a skip notice to stderr and returns
/// `None`; the calling test should then early-return so the
/// suite reports `0 failed` rather than a misleading panic.
///
/// Any other `io::Error` is still a hard failure: the test
/// should not silently skip on (e.g.) `EADDRINUSE` --- that's a
/// real bug in the test setup.
fn bind_or_skip(path: &Path) -> Option<UnixListener> {
match UnixListener::bind(path) {
Ok(listener) => Some(listener),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!(
"test skipped: UnixListener::bind {} → PermissionDenied \
(sandboxed environment). To run this test, give the \
test process permission to create AF_UNIX sockets.",
path.display()
);
None
}
Err(e) => panic!("UnixListener::bind {} failed: {e}", path.display()),
}
}
/// End-to-end byte echo through the bridge. /// End-to-end byte echo through the bridge.
/// ///
/// Setup: /// Setup:
@ -415,7 +440,9 @@ mod tests {
fn bridge_round_trips_bytes_through_daemon() { fn bridge_round_trips_bytes_through_daemon() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let socket_path = tmp.path().join("test.sock"); let socket_path = tmp.path().join("test.sock");
let listener = UnixListener::bind(&socket_path).unwrap(); let Some(listener) = bind_or_skip(&socket_path) else {
return;
};
// Echo daemon: read everything, write it back, exit on EOF. // Echo daemon: read everything, write it back, exit on EOF.
let daemon = thread::spawn(move || { let daemon = thread::spawn(move || {
@ -581,7 +608,9 @@ mod tests {
fn ensure_running_returns_immediately_when_daemon_already_listening() { fn ensure_running_returns_immediately_when_daemon_already_listening() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let socket_path = tmp.path().join("test.sock"); let socket_path = tmp.path().join("test.sock");
let _listener = UnixListener::bind(&socket_path).unwrap(); let Some(_listener) = bind_or_skip(&socket_path) else {
return;
};
let spawner_called = Arc::new(AtomicBool::new(false)); let spawner_called = Arc::new(AtomicBool::new(false));
let sc = spawner_called.clone(); let sc = spawner_called.clone();
@ -607,6 +636,18 @@ mod tests {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let socket_path = tmp.path().join("test.sock"); let socket_path = tmp.path().join("test.sock");
// Probe-bind once up front. The actual bind happens inside a
// child thread (and we cannot return from the test from
// there), so checking PermissionDenied here lets us skip
// before scheduling the worker.
match bind_or_skip(&socket_path) {
Some(listener) => drop(listener),
None => return,
}
// Some kernels keep the inode visible after drop. Make sure
// the path is gone so the spawner's bind doesn't EADDRINUSE.
let _ = std::fs::remove_file(&socket_path);
// Spawner: defer binding by 100ms in a worker thread, then // Spawner: defer binding by 100ms in a worker thread, then
// hold the listener long enough for `ensure_running` to see // hold the listener long enough for `ensure_running` to see
// it. The spawner returns Ok as soon as the worker is // it. The spawner returns Ok as soon as the worker is

View File

@ -495,7 +495,10 @@ impl EditorState {
// command which mutates the core). // command which mutates the core).
let mut args = mlua::MultiValue::new(); let mut args = mlua::MultiValue::new();
args.push_back(mlua::Value::String( args.push_back(mlua::Value::String(
self.lua_host.lua().create_string(&contents).unwrap(), self.lua_host
.lua()
.create_string(&contents)
.expect("Lua VM out of memory while building minibuffer callback args"),
)); ));
if let Err(e) = on_accept.call::<mlua::MultiValue>(args) { if let Err(e) = on_accept.call::<mlua::MultiValue>(args) {
self.core.borrow_mut().status = format!( self.core.borrow_mut().status = format!(
@ -618,7 +621,10 @@ impl EditorState {
core.windows[&win_id].text_view.display_to_pos(buf, target) core.windows[&win_id].text_view.display_to_pos(buf, target)
}; };
if let Some(p) = pos { if let Some(p) = pos {
let aw = core.windows.get_mut(&win_id).unwrap(); let aw = core
.windows
.get_mut(&win_id)
.expect("invariant: win_id passed in must be a live window in core.windows");
aw.cursor = p; aw.cursor = p;
aw.goal_col = None; aw.goal_col = None;
} }
@ -674,7 +680,10 @@ impl EditorState {
.or_else(|| aw.text_view.line_offset(target_row_usize)) .or_else(|| aw.text_view.line_offset(target_row_usize))
}) })
}; };
let aw = core.windows.get_mut(&win_id).unwrap(); let aw = core
.windows
.get_mut(&win_id)
.expect("invariant: win_id passed in must be a live window in core.windows");
aw.view_top = new_top; aw.view_top = new_top;
if let Some(p) = new_cursor { if let Some(p) = new_cursor {
aw.cursor = p; aw.cursor = p;
@ -915,7 +924,10 @@ pub fn paint_frame(
let reg = registry.borrow(); let reg = registry.borrow();
let buf_id = core.active_buffer_id(); let buf_id = core.active_buffer_id();
if let Ok(buf) = reg.get(buf_id) { if let Ok(buf) = reg.get(buf_id) {
let aw = core.windows.get_mut(&active).unwrap(); let aw = core
.windows
.get_mut(&active)
.expect("invariant: core.active is always a live window in core.windows");
let cursor_row = aw let cursor_row = aw
.text_view .text_view
.pos_to_display(buf, aw.cursor) .pos_to_display(buf, aw.cursor)

View File

@ -662,6 +662,59 @@ impl EditorCore {
} }
} }
/// Delete from the cursor backward to the start of the previous
/// word. The CUA-style `Ctrl+Backspace`. No-op at start-of-buffer.
/// Mirrors [`Self::backspace`] but the deleted range is the gap
/// between the cursor and where [`Self::move_word_left`] would
/// land.
pub fn delete_word_backward(&mut self) {
self.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
if cursor == 0 {
return;
}
let new = {
let id = self.active_buffer_id();
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
backward_word(buffer, cursor)
};
if new == cursor {
return;
}
let range = Range::new(new, cursor);
if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
self.status = format!("delete failed: {e}");
return;
}
self.active_window_mut().cursor = new;
}
/// Delete from the cursor forward to the end of the next word. The
/// CUA-style `Ctrl+Delete`. No-op at end-of-buffer. Mirrors
/// [`Self::delete_forward`] over the gap from the cursor to where
/// [`Self::move_word_right`] would land.
pub fn delete_word_forward(&mut self) {
self.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
let id = self.active_buffer_id();
let new = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
if cursor >= buffer.len() {
return;
}
forward_word(buffer, cursor)
};
if new == cursor {
return;
}
let range = Range::new(cursor, new);
if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
self.status = format!("delete failed: {e}");
}
}
/// Undo the most recent edit on the active buffer; clamp the /// Undo the most recent edit on the active buffer; clamp the
/// active window's cursor to the new length and notify all /// active window's cursor to the new length and notify all
/// windows on this buffer. /// windows on this buffer.
@ -1210,6 +1263,47 @@ mod tests {
assert_eq!(s.active_buffer_len(), 3); assert_eq!(s.active_buffer_len(), 3);
} }
#[test]
fn delete_word_backward_removes_previous_word_to_cursor() {
// Cursor sits at end-of-buffer; deletes back through "world".
let mut s = from_bytes(b"hello world");
s.active_window_mut().cursor = 11;
s.delete_word_backward();
// `backward_word` lands at the start of the word ("world"
// begins at byte 6), so we delete bytes 6..11.
assert_eq!(s.cursor(), 6);
assert_eq!(s.active_buffer_len(), 6);
}
#[test]
fn delete_word_backward_at_start_of_buffer_is_noop() {
let mut s = from_bytes(b"hello");
s.active_window_mut().cursor = 0;
s.delete_word_backward();
assert_eq!(s.cursor(), 0);
assert_eq!(s.active_buffer_len(), 5);
}
#[test]
fn delete_word_forward_removes_next_word_from_cursor() {
let mut s = from_bytes(b"hello world");
s.active_window_mut().cursor = 0;
s.delete_word_forward();
// `forward_word` lands at the end of the first word (byte 5);
// delete bytes 0..5. Cursor stays where it was.
assert_eq!(s.cursor(), 0);
assert_eq!(s.active_buffer_len(), 6);
}
#[test]
fn delete_word_forward_at_end_of_buffer_is_noop() {
let mut s = from_bytes(b"hello");
s.active_window_mut().cursor = 5;
s.delete_word_forward();
assert_eq!(s.cursor(), 5);
assert_eq!(s.active_buffer_len(), 5);
}
#[test] #[test]
fn multibyte_navigation() { fn multibyte_navigation() {
let mut s = from_bytes("héllo".as_bytes()); let mut s = from_bytes("héllo".as_bytes());

View File

@ -182,8 +182,20 @@ impl Frontend {
// literal `/` with CONTROL instead of the byte-roulette legacy // literal `/` with CONTROL instead of the byte-roulette legacy
// protocols produce. Terminals that don't ignore the CSI; we // protocols produce. Terminals that don't ignore the CSI; we
// push the flag anyway so the Pop on teardown is balanced. // push the flag anyway so the Pop on teardown is balanced.
let kitty_flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES //
| KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES; // We deliberately do NOT push `REPORT_ALL_KEYS_AS_ESCAPE_CODES`.
// That flag tells the terminal to send every key (including
// printable letters) as a CSI sequence carrying the unshifted
// base key plus modifier bits, e.g. `Shift+a` arrives as
// `Char('a') + SHIFT` rather than `Char('A')`. Pmacs has no
// keyboard-layout knowledge to translate `9 + SHIFT` into `(`
// on a US layout (or `É` on a French layout, etc.); the
// terminal does. Letting the terminal apply layout-aware shift
// translation is correct; receiving the post-shift character
// is what every typing-driven path (self-insert, minibuffer,
// search) expects. `DISAMBIGUATE_ESCAPE_CODES` alone still
// gives us the C-i/Tab and C-m/Enter disambiguation we want.
let kitty_flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES;
if queue!(me.out, PushKeyboardEnhancementFlags(kitty_flags)).is_ok() { if queue!(me.out, PushKeyboardEnhancementFlags(kitty_flags)).is_ok() {
me.keyboard_enhancement = true; me.keyboard_enhancement = true;
} }

View File

@ -60,18 +60,32 @@ impl Chord {
/// Canonicalization rules: /// Canonicalization rules:
/// * `KeyCode::Char('A')` with `SHIFT` --- the SHIFT bit is /// * `KeyCode::Char('A')` with `SHIFT` --- the SHIFT bit is
/// stripped; the uppercase letter already implies it. /// stripped; the uppercase letter already implies it.
/// * `KeyCode::Char('a')` with `SHIFT` --- promoted to
/// `KeyCode::Char('A')` and SHIFT stripped. Some terminals (or
/// kitty-protocol modes the user might enable separately) report
/// shifted ASCII letters as the unshifted code with SHIFT set;
/// normalizing here means `S-a` and `A` hash identically and
/// self-insert produces `A`. Non-letter shifted keys like
/// `Shift+9` rely on terminal-side layout translation (frontend
/// does not push `REPORT_ALL_KEYS_AS_ESCAPE_CODES`); pmacs has
/// no layout knowledge to map `9 + SHIFT` to `(`.
/// * `KeyCode::Char(c)` for any `c` whose lowercase is itself /// * `KeyCode::Char(c)` for any `c` whose lowercase is itself
/// (`/`, `1`, ...) leaves modifiers as-is. /// (`/`, `1`, ...) leaves modifiers as-is.
#[must_use] #[must_use]
pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self { pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
let mods = match code { let (code, modifiers) = match code {
KeyCode::Char(ch) if ch.is_ascii_uppercase() => modifiers - KeyModifiers::SHIFT, KeyCode::Char(ch) if ch.is_ascii_uppercase() => (code, modifiers - KeyModifiers::SHIFT),
_ => modifiers, KeyCode::Char(ch)
if ch.is_ascii_lowercase() && modifiers.contains(KeyModifiers::SHIFT) =>
{
(
KeyCode::Char(ch.to_ascii_uppercase()),
modifiers - KeyModifiers::SHIFT,
)
}
_ => (code, modifiers),
}; };
Self { Self { code, modifiers }
code,
modifiers: mods,
}
} }
/// Build a plain unmodified chord. /// Build a plain unmodified chord.
@ -433,6 +447,38 @@ mod tests {
assert_eq!(with_shift, bare); assert_eq!(with_shift, bare);
} }
#[test]
fn lowercase_letter_with_shift_promotes_to_uppercase() {
// Some terminals (or kitty-protocol modes a user may enable
// separately) report shifted ASCII letters as the unshifted
// code with SHIFT set: `Shift+a` arrives as `Char('a')+SHIFT`,
// not `Char('A')`. The canonicalization promotes to the
// uppercase form so self-insert produces 'A' and bindings
// for `S-a` hash identically to bindings for `A`.
let promoted = Chord::new(KeyCode::Char('a'), KeyModifiers::SHIFT);
let bare = Chord::plain(KeyCode::Char('A'));
assert_eq!(promoted, bare);
}
#[test]
fn shift_a_equals_capital_a_through_parser() {
// Parser emits the same chord whether you write `S-a` or `A`.
assert_eq!(parse_chord("S-a").unwrap(), parse_chord("A").unwrap());
}
#[test]
fn non_letter_shifted_chars_keep_modifiers() {
// pmacs has no layout knowledge: a chord built from
// `Char('9')+SHIFT` cannot be promoted to `Char('(')`. We
// leave it as-is and rely on the terminal to deliver the
// post-shift character (`(`) in normal operation. This test
// pins the no-touch behavior so the canonicalization doesn't
// creep into territory that would need a layout map.
let chord = Chord::new(KeyCode::Char('9'), KeyModifiers::SHIFT);
assert_eq!(chord.code, KeyCode::Char('9'));
assert!(chord.modifiers.contains(KeyModifiers::SHIFT));
}
#[test] #[test]
fn display_round_trips_canonical_form() { fn display_round_trips_canonical_form() {
let cases = [ let cases = [

View File

@ -62,6 +62,7 @@ pub mod lua_bindings;
pub mod message_bus; pub mod message_bus;
pub mod minibuffer; pub mod minibuffer;
pub mod overlay; pub mod overlay;
pub mod packages;
pub mod process; pub mod process;
pub mod project; pub mod project;
pub mod project_index; pub mod project_index;

View File

@ -1088,6 +1088,15 @@ impl LspManager {
ProcessEventKind::Stderr(bytes) => { ProcessEventKind::Stderr(bytes) => {
self.push_event(sid, ev.at, LspEventKind::Stderr(bytes)); self.push_event(sid, ev.at, LspEventKind::Stderr(bytes));
} }
ProcessEventKind::Ansi(_) => {
self.push_event(
sid,
ev.at,
LspEventKind::ProtocolError {
message: "supervisor emitted ANSI events for pipe-mode LSP process".into(),
},
);
}
ProcessEventKind::Exited { code } => { ProcessEventKind::Exited { code } => {
self.on_exit(sid, ev.at, format!("exit code {code}"), code == 0); self.on_exit(sid, ev.at, format!("exit code {code}"), code == 0);
} }

View File

@ -33,8 +33,9 @@ pub const ERRORS_BUFFER_NAME: &str = "*errors*";
use crate::command::CommandRegistry; use crate::command::CommandRegistry;
use crate::keymap_stack::KeymapStack; use crate::keymap_stack::KeymapStack;
use crate::lua_bindings::{ use crate::lua_bindings::{
self, CurrentAttachmentSlot, InitCompleteFlag, LocalInstanceInfo, RequestedAttach, self, CurrentAttachmentSlot, InitCompleteFlag, LocalInstanceInfo, PackageInstallOverride,
SharedCommandRegistry, SharedCore, SharedHookRegistry, SharedKeymapStack, SharedRegistry, RequestedAttach, SharedCommandRegistry, SharedCore, SharedHookRegistry, SharedKeymapStack,
SharedRegistry,
}; };
use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity}; use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity};
@ -274,6 +275,17 @@ impl LuaHost {
/// `source` is an optional label (file path, chunk name) used in /// `source` is an optional label (file path, chunk name) used in
/// diagnostics; Lua reports it back in stack traces. /// diagnostics; Lua reports it back in stack traces.
pub fn eval(&mut self, source: Option<&str>, chunk: &str) -> mlua::Result<Value> { pub fn eval(&mut self, source: Option<&str>, chunk: &str) -> mlua::Result<Value> {
// Push the source label into the per-Lua app-data slot so
// bindings that need the chunk's location (e.g.,
// `pmacs.packages.install_project`'s relative-path
// resolution) can read it back. This is the only way to
// recover chunk source from a Rust callback in pmacs, since
// the Lua state is built without the `debug` library
// (`forbid(unsafe_code)` rules out `Lua::unsafe_new`).
self.lua
.set_app_data(crate::lua_bindings::CurrentEvalSource(
source.map(str::to_owned),
));
let mut loader = self.lua.load(chunk); let mut loader = self.lua.load(chunk);
if let Some(name) = source { if let Some(name) = source {
loader = loader.set_name(name); loader = loader.set_name(name);
@ -438,6 +450,17 @@ impl LuaHost {
.and_then(|s| s.get()) .and_then(|s| s.get())
} }
/// Install a [`PackageInstallOverride`] so subsequent
/// `pmacs.packages.install{...}` calls redirect their cache and
/// install roots away from `$XDG_*` defaults.
///
/// Production code does not call this. Tests use it because the
/// project's `forbid(unsafe_code)` rules out `std::env::set_var`
/// (which has been `unsafe` since Rust 2024).
pub fn set_package_install_override(&self, override_: PackageInstallOverride) {
self.lua.set_app_data(override_);
}
/// Override the instance name reported by `pmacs.instance.identity()` /// Override the instance name reported by `pmacs.instance.identity()`
/// (M5.6f). /// (M5.6f).
/// ///

File diff suppressed because it is too large Load Diff

View File

@ -41,8 +41,13 @@
//! mapping out keeps overlays cheap and stateless. A future //! mapping out keeps overlays cheap and stateless. A future
//! buffer-coord overlay would compose on top of these the same way. //! buffer-coord overlay would compose on top of these the same way.
use std::sync::{Arc, Mutex};
use unicode_width::UnicodeWidthChar;
use crate::buffer::Buffer; use crate::buffer::Buffer;
use crate::cell::{Cell, CellCoord, CellGrid, Style}; use crate::cell::{Cell, CellCoord, CellGrid, Style};
use crate::rope::Edit;
use crate::view::{View, Viewport}; use crate::view::{View, Viewport};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -152,6 +157,196 @@ pub fn merge_styles(base: Style, overlay: Style) -> Style {
} }
} }
// ---------------------------------------------------------------------------
// BufferStyleOverlay
// ---------------------------------------------------------------------------
/// A style annotation expressed in buffer byte coordinates.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct BufferStyleSpan {
/// First byte covered by the style.
pub start: u64,
/// Byte one past the styled range.
pub end: u64,
/// Style to merge over the base text.
pub style: Style,
}
/// Shared span store used by Lua handles and render overlays.
pub type SharedBufferStyleSpans = Arc<Mutex<Vec<BufferStyleSpan>>>;
/// View that renders buffer-byte style annotations.
///
/// Unlike [`StyleSpanOverlay`], this overlay stores byte ranges rather
/// than viewport cell ranges. That is the right shape for stream
/// consumers such as the REPL: ANSI SGR applies to bytes as they land
/// in the rope, and render maps the surviving ranges into visible cells.
#[derive(Clone, Debug)]
pub struct BufferStyleOverlay {
spans: SharedBufferStyleSpans,
}
impl BufferStyleOverlay {
/// Construct an overlay backed by `spans`.
#[must_use]
pub fn new(spans: SharedBufferStyleSpans) -> Self {
Self { spans }
}
}
impl View for BufferStyleOverlay {
fn on_edit(&mut self, _buf: &Buffer, edit: &Edit) -> Result<(), crate::buffer::BufferError> {
let old_start = edit.range.start;
let old_end = edit.range.end;
let old_len = old_end - old_start;
let new_len = edit.inserted_len;
let mut spans = self.spans.lock().expect("style spans mutex poisoned");
let mut adjusted = Vec::with_capacity(spans.len());
for mut span in spans.drain(..) {
if span.end <= old_start {
adjusted.push(span);
} else if span.start >= old_end {
span.start = shift_pos(span.start, old_end, old_len, new_len);
span.end = shift_pos(span.end, old_end, old_len, new_len);
adjusted.push(span);
}
// Overlapping spans are dropped. REPL style spans are append-only
// and scrollback truncation deletes whole old blocks, so a
// conservative drop is simpler and avoids half-styled fragments.
}
*spans = adjusted;
Ok(())
}
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
let spans = self
.spans
.lock()
.expect("style spans mutex poisoned")
.clone();
if spans.is_empty() {
return;
}
let line_offsets = compute_line_offsets(buf);
if line_offsets.is_empty() {
return;
}
let start_line = line_at_offset(&line_offsets, viewport.buffer_start);
for span in spans {
render_buffer_style_span(buf, &line_offsets, start_line, viewport, cells, span);
}
}
}
fn shift_pos(pos: u64, old_end: u64, old_len: u64, new_len: u64) -> u64 {
if new_len >= old_len {
pos + (new_len - old_len)
} else {
pos.saturating_sub(old_end)
.saturating_add(old_end - (old_len - new_len))
}
}
fn compute_line_offsets(buf: &Buffer) -> Vec<u64> {
let mut offsets = vec![0];
let rope = buf.snapshot_rope();
let mut pos = 0;
let len = rope.len();
for chunk in rope.chunks(0, len) {
for (i, b) in chunk.iter().enumerate() {
if *b == b'\n' {
offsets.push(pos + i as u64 + 1);
}
}
pos += chunk.len() as u64;
}
offsets
}
fn line_at_offset(line_offsets: &[u64], offset: u64) -> usize {
match line_offsets.binary_search(&offset) {
Ok(i) => i,
Err(i) => i.saturating_sub(1),
}
}
fn line_end(buf: &Buffer, line_offsets: &[u64], line: usize) -> u64 {
let start = line_offsets[line];
let raw_end = line_offsets.get(line + 1).copied().unwrap_or(buf.len());
if line + 1 < line_offsets.len() && raw_end > start {
raw_end - 1
} else {
raw_end
}
}
fn display_col_for_range(buf: &Buffer, start: u64, end: u64) -> u32 {
if end <= start {
return 0;
}
let mut bytes = vec![0u8; (end - start) as usize];
buf.snapshot_rope().slice(start, end, &mut bytes);
while !bytes.is_empty() && std::str::from_utf8(&bytes).is_err() {
bytes.pop();
}
let Ok(s) = std::str::from_utf8(&bytes) else {
return 0;
};
let mut col = 0;
for ch in s.chars() {
let width = if ch == '\t' {
8 - (col % 8)
} else {
UnicodeWidthChar::width(ch).unwrap_or(0) as u32
};
col += width;
}
col
}
fn render_buffer_style_span(
buf: &Buffer,
line_offsets: &[u64],
start_line: usize,
viewport: Viewport,
cells: &mut CellGrid<'_>,
span: BufferStyleSpan,
) {
if span.start >= span.end {
return;
}
let first_line = line_at_offset(line_offsets, span.start);
let last_line = line_at_offset(line_offsets, span.end.saturating_sub(1));
for line in first_line..=last_line {
if line < start_line {
continue;
}
let row_offset = (line - start_line) as u32;
if row_offset >= viewport.cell_size.rows {
break;
}
let line_start = line_offsets[line];
let line_end = line_end(buf, line_offsets, line);
let style_start = span.start.max(line_start).min(line_end);
let style_end = span.end.min(line_end);
if style_start >= style_end {
continue;
}
let start_col = display_col_for_range(buf, line_start, style_start);
let end_col = display_col_for_range(buf, line_start, style_end);
let start_col = start_col.min(viewport.cell_size.cols);
let end_col = end_col.min(viewport.cell_size.cols);
for col in start_col..end_col {
let coord = CellCoord::new(
viewport.cell_origin.row + row_offset,
viewport.cell_origin.col + col,
);
let cell = cells.at(coord);
cell.style = merge_styles(cell.style, span.style);
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// VirtualCellOverlay // VirtualCellOverlay
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

443
src/packages/address.rs Normal file
View File

@ -0,0 +1,443 @@
// packages/address.rs --- Address parsing for v1.0 package addresses.
//! Address parsing (T M7.2, spec §sec:packages-future).
//!
//! v1.0 ships three 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.
//! - `git:<URL>` --- 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://`,
//! `git://`), SSH shorthand (`git@host:path`), local paths. Validation
//! that the URL actually resolves happens at clone time, not parse
//! time --- delegating the URL-form question to git's existing
//! documentation rather than maintaining our own parser.
//! - Raw URLs starting with `https://` or `git://` --- accepted directly
//! without a prefix. The natural form `https://example.com/repo.git`
//! parses without forcing the user to type a redundant `https:` or
//! `git:` prefix.
//!
//! ## 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.
//!
//! ## Authentication
//!
//! v1.0 delegates authentication to the user's git configuration: if
//! the system has a credential helper for HTTPS or an SSH agent for
//! SSH URLs, private repos work transparently. The address parser
//! does not handle credentials; it only produces the URL string that
//! `git clone` will eventually receive.
use thiserror::Error;
// ---------------------------------------------------------------------------
// Address
// ---------------------------------------------------------------------------
/// 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)]
pub enum Address {
/// `github:owner/repo` sugar.
Github {
/// Repository owner (user or organization).
owner: String,
/// Repository name. Tolerates an optional `.git` suffix at parse
/// time but stores the bare name.
repo: String,
},
/// Any clone-cloneable URL or shorthand. Stored as-is; passed to
/// `git clone` verbatim.
Url(String),
}
impl Address {
/// Parse an address string per the v1.0 syntax.
pub fn parse(s: &str) -> Result<Self, AddressError> {
if s.is_empty() {
return Err(AddressError::Empty);
}
// Deferred forge aliases must be detected before generic prefix
// handling so the error message points at the trim decision.
for alias in DEFERRED_ALIASES {
if s.starts_with(alias) {
return Err(AddressError::DeferredAlias {
alias: (*alias).to_string(),
input: s.to_string(),
});
}
}
// 1. Raw URLs without a prefix --- accept directly. This branch
// must come before the `git:` prefix handler: `git://x`
// starts with `git:` and would otherwise be miscaptured as
// a `git:` prefix with body `//x`.
if s.starts_with("https://") || s.starts_with("git://") {
return Ok(Address::Url(s.to_string()));
}
// 2. github:owner/repo (with optional .git suffix).
if let Some(rest) = s.strip_prefix("github:") {
return parse_github(rest, s);
}
// 3. git:<anything> --- 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
// reachable happens at fetch time, not at parse time.
if let Some(rest) = s.strip_prefix("git:") {
if rest.is_empty() {
return Err(AddressError::EmptyGitTarget {
input: s.to_string(),
});
}
return Ok(Address::Url(rest.to_string()));
}
// 4. https:<rest> --- redundant verbose form, kept for symmetry
// with git:URL. The natural `https://...` form is already
// handled by branch 1; this branch covers the user who
// writes `https:https://...` by reflex. Anything else after
// the `https:` prefix that isn't a recognizable HTTPS body
// is rejected.
if let Some(rest) = s.strip_prefix("https:") {
if let Some(inner) = rest.strip_prefix("https://") {
let _ = inner;
return Ok(Address::Url(rest.to_string()));
}
return Err(AddressError::MalformedHttps {
input: s.to_string(),
});
}
Err(AddressError::UnknownScheme {
input: s.to_string(),
})
}
/// The clone URL this address resolves to. Pass to `git clone`.
#[must_use]
pub fn to_git_url(&self) -> String {
match self {
Self::Github { owner, repo } => {
format!("https://github.com/{owner}/{repo}.git")
}
Self::Url(u) => u.clone(),
}
}
}
const DEFERRED_ALIASES: &[&str] = &["gitlab:", "codeberg:", "forgejo:"];
fn parse_github(rest: &str, original: &str) -> Result<Address, AddressError> {
// Tolerate trailing `.git` --- users will type it by habit.
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(),
});
}
// 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(),
});
}
}
Ok(Address::Github {
owner: owner.to_string(),
repo: repo.to_string(),
})
}
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Errors produced by [`Address::parse`].
///
/// Every variant names the offending input. Forge-alias rejections
/// also point at the `git:URL` fallback so the user knows what to
/// type instead.
#[derive(Debug, Error, Eq, PartialEq)]
pub enum AddressError {
/// Input was empty.
#[error("empty package address")]
Empty,
/// `github:owner/repo` form was malformed (missing slash, extra
/// segment, invalid characters).
#[error("invalid github address `{input}`: expected `github:owner/repo`")]
InvalidGithub {
/// The offending input.
input: String,
},
/// `git:` prefix was followed by an empty body.
#[error("empty git target in `{input}`: expected `git:<URL>`")]
EmptyGitTarget {
/// The offending input.
input: String,
},
/// `https:` prefix did not introduce a recognizable HTTPS URL.
#[error("malformed https address `{input}`: expected `https://...`")]
MalformedHttps {
/// 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.
#[error(
"address scheme `{alias}` is deferred for v1.0; \
use `git:<full-URL>` instead (e.g. `git:https://gitlab.com/owner/repo.git`). \
Offending input: `{input}`"
)]
DeferredAlias {
/// The deferred alias prefix (e.g. `"gitlab:"`).
alias: String,
/// The full offending input.
input: String,
},
/// Address did not match any v1.0 scheme.
#[error(
"unknown address scheme in `{input}`; \
expected `github:owner/repo`, `git:<URL>`, `https://...`, or `git://...`"
)]
UnknownScheme {
/// The offending input.
input: String,
},
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- github sugar --------------------------------------------------------
#[test]
fn github_simple_form_parses() {
let a = Address::parse("github:rust-lang/rust").unwrap();
assert_eq!(
a,
Address::Github {
owner: "rust-lang".into(),
repo: "rust".into(),
}
);
}
#[test]
fn github_dot_git_suffix_tolerated() {
let a = Address::parse("github:owner/repo.git").unwrap();
assert_eq!(
a,
Address::Github {
owner: "owner".into(),
repo: "repo".into(),
}
);
}
#[test]
fn github_to_git_url_canonicalizes() {
let a = Address::parse("github:foo/bar").unwrap();
assert_eq!(a.to_git_url(), "https://github.com/foo/bar.git");
}
#[test]
fn github_to_git_url_canonicalizes_after_dot_git_strip() {
let a = Address::parse("github:foo/bar.git").unwrap();
// The `.git` survives in the canonical URL even though the
// parsed `repo` is `bar`.
assert_eq!(a.to_git_url(), "https://github.com/foo/bar.git");
}
#[test]
fn github_rejects_missing_repo() {
let err = Address::parse("github:owner").unwrap_err();
assert!(matches!(err, AddressError::InvalidGithub { .. }));
}
#[test]
fn github_rejects_extra_segment() {
let err = Address::parse("github:owner/repo/extra").unwrap_err();
assert!(matches!(err, AddressError::InvalidGithub { .. }));
}
#[test]
fn github_rejects_empty_owner() {
let err = Address::parse("github:/repo").unwrap_err();
assert!(matches!(err, AddressError::InvalidGithub { .. }));
}
#[test]
fn github_rejects_empty_repo() {
let err = Address::parse("github:owner/").unwrap_err();
assert!(matches!(err, AddressError::InvalidGithub { .. }));
}
#[test]
fn github_rejects_invalid_characters() {
let err = Address::parse("github:owner/repo with spaces").unwrap_err();
assert!(matches!(err, AddressError::InvalidGithub { .. }));
}
// -- git: prefix --------------------------------------------------------
#[test]
fn git_prefix_with_https_url() {
let a = Address::parse("git:https://example.com/owner/repo.git").unwrap();
assert_eq!(a, Address::Url("https://example.com/owner/repo.git".into()));
assert_eq!(a.to_git_url(), "https://example.com/owner/repo.git");
}
#[test]
fn git_prefix_with_ssh_url() {
let a = Address::parse("git:ssh://git@example.com/owner/repo.git").unwrap();
assert_eq!(
a,
Address::Url("ssh://git@example.com/owner/repo.git".into())
);
}
#[test]
fn git_prefix_with_ssh_shorthand() {
// SSH shorthand isn't a URL but `git clone` accepts it. We pass
// it through verbatim.
let a = Address::parse("git:git@github.com:owner/repo.git").unwrap();
assert_eq!(a, Address::Url("git@github.com:owner/repo.git".into()));
}
#[test]
fn git_prefix_with_file_url() {
let a = Address::parse("git:file:///tmp/test-repo").unwrap();
assert_eq!(a, Address::Url("file:///tmp/test-repo".into()));
}
#[test]
fn git_prefix_empty_is_rejected() {
let err = Address::parse("git:").unwrap_err();
assert!(matches!(err, AddressError::EmptyGitTarget { .. }));
}
// -- raw URL forms ------------------------------------------------------
#[test]
fn raw_https_url_accepted() {
let a = Address::parse("https://example.com/owner/repo.git").unwrap();
assert_eq!(a, Address::Url("https://example.com/owner/repo.git".into()));
}
#[test]
fn raw_git_protocol_url_accepted() {
let a = Address::parse("git://example.com/owner/repo").unwrap();
assert_eq!(a, Address::Url("git://example.com/owner/repo".into()));
}
#[test]
fn https_verbose_redundant_form_accepted() {
let a = Address::parse("https:https://example.com/owner/repo").unwrap();
// Verbose form: the inner `https://...` is what we keep.
assert_eq!(a, Address::Url("https://example.com/owner/repo".into()));
}
#[test]
fn https_prefix_without_url_body_rejected() {
let err = Address::parse("https:example.com/repo").unwrap_err();
assert!(matches!(err, AddressError::MalformedHttps { .. }));
}
// -- Forge aliases rejected with helpful pointer ------------------------
#[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}"
);
}
#[test]
fn codeberg_alias_rejected_with_pointer_to_git_fallback() {
let err = Address::parse("codeberg:owner/repo").unwrap_err();
let msg = err.to_string();
assert!(matches!(err, AddressError::DeferredAlias { .. }));
assert!(msg.contains("codeberg:"));
assert!(msg.contains("git:"));
}
#[test]
fn forgejo_alias_rejected_with_pointer_to_git_fallback() {
let err = Address::parse("forgejo:host/owner/repo").unwrap_err();
let msg = err.to_string();
assert!(matches!(err, AddressError::DeferredAlias { .. }));
assert!(msg.contains("forgejo:"));
assert!(msg.contains("git:"));
}
// -- Catch-alls ---------------------------------------------------------
#[test]
fn empty_input_rejected() {
let err = Address::parse("").unwrap_err();
assert!(matches!(err, AddressError::Empty));
}
#[test]
fn unknown_scheme_rejected_with_help() {
let err = Address::parse("ftp://example.com/repo").unwrap_err();
let msg = err.to_string();
assert!(matches!(err, AddressError::UnknownScheme { .. }));
assert!(
msg.contains("github:"),
"error should list known schemes: {msg}"
);
assert!(msg.contains("git:"));
}
#[test]
fn http_unsupported_falls_to_unknown_scheme() {
// Plain http:// is not in v1.0's set --- users should use https.
let err = Address::parse("http://example.com/repo").unwrap_err();
assert!(matches!(err, AddressError::UnknownScheme { .. }));
}
#[test]
fn bare_word_rejected() {
let err = Address::parse("just-a-word").unwrap_err();
assert!(matches!(err, AddressError::UnknownScheme { .. }));
}
}

1071
src/packages/fetcher.rs Normal file

File diff suppressed because it is too large Load Diff

1104
src/packages/installer.rs Normal file

File diff suppressed because it is too large Load Diff

603
src/packages/manifest.rs Normal file
View File

@ -0,0 +1,603 @@
// packages/manifest.rs --- pmacs.toml schema, parser, and validator.
//! Manifest schema and parser (T M7.1, spec §sec:packages-future).
//!
//! A package is a versioned, addressable unit of Lua code with declared
//! metadata, dependencies, and a single entry point. Every package
//! ships a [`pmacs.toml`] at its root, deserialized into a
//! [`PackageManifest`] at install time (load time only re-stats it).
//!
//! v1.0 fields:
//! - `name` ([`PackageName`]) --- lowercase, hyphen-separated, with
//! an optional `user/pkg-name` namespace.
//! - `version` ([`semver::Version`]) --- semantic version, parsed and
//! rejected at parse time, not at install time.
//! - `summary` ([`String`]) --- one-line description.
//! - `pmacs_required` ([`semver::VersionReq`]) --- the pmacs version
//! range this package supports.
//! - `dependencies` (`Vec<`[`DependencySpec`]`>`) --- packages this
//! one needs, by address plus version constraint.
//! - `conflicts` (`Vec<`[`DependencySpec`]`>`) --- packages this one
//! refuses to coexist with (e.g., two REPL implementations).
//! - `entry` ([`PathBuf`]) --- the Lua module file `require` returns.
//! - `exports` (`Vec<String>`) --- public Lua module names; other
//! code must use only these.
//!
//! `dependencies` and `conflicts` default to empty if omitted; every
//! other field is required. Unknown fields are accepted (forward
//! compatibility): a v1.0 binary reading a v1.1 manifest with a new
//! optional field still loads it.
//!
//! ## TOML shape
//!
//! ```toml
//! name = "pmacs-magit"
//! version = "1.2.3"
//! summary = "Git porcelain for pmacs."
//! pmacs_required = ">= 1.0.0, < 2.0.0"
//! entry = "init.lua"
//! exports = ["magit", "magit.commit"]
//!
//! [[dependencies]]
//! address = "github:user/pmacs-async-utils"
//! version = "^0.4.0"
//!
//! [[conflicts]]
//! address = "github:other/pmacs-vc"
//! version = "*"
//! ```
use std::path::PathBuf;
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use thiserror::Error;
// ---------------------------------------------------------------------------
// PackageName
// ---------------------------------------------------------------------------
/// Validated package name. Lowercase letters, digits, and hyphens; an
/// optional `namespace/name` form for forge-style ownership prefixes.
///
/// 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)]
pub struct PackageName(String);
impl PackageName {
/// Validate and wrap a candidate name.
///
/// Rules: each segment (the part before `/` and the part after)
/// must start with a lowercase ASCII letter and contain only
/// `[a-z0-9-]`. At most one `/` separates a namespace from a name.
pub fn new(s: impl Into<String>) -> Result<Self, ManifestError> {
let s = s.into();
let (head, tail) = match s.split_once('/') {
None => (s.as_str(), ""),
Some((h, t)) => (h, t),
};
if s.matches('/').count() > 1 {
return Err(ManifestError::InvalidName {
value: s.clone(),
reason: "at most one `/` separator".into(),
});
}
Self::validate_segment(head, &s)?;
if !tail.is_empty() {
Self::validate_segment(tail, &s)?;
} else if s.contains('/') {
return Err(ManifestError::InvalidName {
value: s.clone(),
reason: "namespace separator with empty tail".into(),
});
}
Ok(Self(s))
}
fn validate_segment(seg: &str, full: &str) -> Result<(), ManifestError> {
if seg.is_empty() {
return Err(ManifestError::InvalidName {
value: full.to_string(),
reason: "empty segment".into(),
});
}
let bytes = seg.as_bytes();
if !bytes[0].is_ascii_lowercase() {
return Err(ManifestError::InvalidName {
value: full.to_string(),
reason: format!("segment `{seg}` must start with a-z"),
});
}
for &b in bytes {
if !(b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') {
return Err(ManifestError::InvalidName {
value: full.to_string(),
reason: format!("segment `{seg}` may only contain a-z, 0-9, and `-`"),
});
}
}
Ok(())
}
/// Borrow the inner string.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for PackageName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::new(s).map_err(serde::de::Error::custom)
}
}
// ---------------------------------------------------------------------------
// DependencySpec
// ---------------------------------------------------------------------------
/// A package address plus the version constraint that gates resolution.
///
/// The `address` is whatever syntax the package author wrote in their
/// manifest (`github:user/repo`, `git:URL`, etc.); validation that the
/// address parses lives in T M7.2's address fetcher, not here. T M7.1
/// stores the raw string verbatim so the M7.2 parser can deliver
/// scheme-localized errors at install time.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct DependencySpec {
/// Package address (e.g., `"github:user/pmacs-magit"`).
pub address: String,
/// Version constraint (e.g., `"^1.0.0"`).
pub version: VersionReq,
}
// ---------------------------------------------------------------------------
// PackageManifest
// ---------------------------------------------------------------------------
/// In-memory representation of a parsed `pmacs.toml`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PackageManifest {
/// Unique identifier per [`PackageName`] rules.
pub name: PackageName,
/// Package version (semver).
pub version: Version,
/// One-line description.
pub summary: String,
/// Compatible pmacs version range.
pub pmacs_required: VersionReq,
/// Dependencies. Empty if omitted from TOML.
#[serde(default)]
pub dependencies: Vec<DependencySpec>,
/// Conflicts. Empty if omitted from TOML.
#[serde(default)]
pub conflicts: Vec<DependencySpec>,
/// Lua module path the package's `require` returns. Relative to
/// the package root.
pub entry: PathBuf,
/// Public Lua module names exported to other packages.
pub exports: Vec<String>,
}
impl PackageManifest {
/// Parse a TOML manifest from a string.
///
/// Validation happens during deserialization: missing required
/// fields produce errors naming the field; invalid semver in
/// `version` or `pmacs_required` is rejected at parse time.
pub fn from_toml(s: &str) -> Result<Self, ManifestError> {
toml::from_str(s).map_err(ManifestError::from)
}
/// Serialize to canonical TOML form.
pub fn to_toml(&self) -> Result<String, ManifestError> {
toml::to_string(self).map_err(ManifestError::from)
}
}
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Errors produced by manifest parsing, validation, and serialization.
#[derive(Debug, Error)]
pub enum ManifestError {
/// A package name failed [`PackageName::new`]'s rules.
#[error("invalid package name `{value}`: {reason}")]
InvalidName {
/// The offending string.
value: String,
/// Human-readable explanation (which segment, which character).
reason: String,
},
/// TOML deserialization or per-field validation failed. The inner
/// error includes a span (line, column) plus the field name from
/// serde for missing-field errors and the semver crate's own
/// message for invalid `version` / `pmacs_required`.
#[error("manifest parse error: {0}")]
Parse(#[from] toml::de::Error),
/// TOML serialization failed (e.g., a non-UTF-8 path).
#[error("manifest serialize error: {0}")]
Serialize(#[from] toml::ser::Error),
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn sample_manifest() -> PackageManifest {
PackageManifest {
name: PackageName::new("pmacs-magit").unwrap(),
version: Version::new(1, 2, 3),
summary: "Git porcelain for pmacs.".into(),
pmacs_required: VersionReq::parse(">=1.0.0, <2.0.0").unwrap(),
dependencies: vec![DependencySpec {
address: "github:user/pmacs-async-utils".into(),
version: VersionReq::parse("^0.4.0").unwrap(),
}],
conflicts: vec![DependencySpec {
address: "github:other/pmacs-vc".into(),
version: VersionReq::parse("*").unwrap(),
}],
entry: PathBuf::from("init.lua"),
exports: vec!["magit".into(), "magit.commit".into()],
}
}
// -- Round-trip ----------------------------------------------------------
#[test]
fn from_toml_round_trips_a_valid_manifest() {
let m = sample_manifest();
let s = m.to_toml().unwrap();
let parsed = PackageManifest::from_toml(&s).unwrap();
assert_eq!(parsed, m);
}
#[test]
fn from_toml_accepts_minimal_manifest_with_defaults() {
let s = r#"
name = "minimal"
version = "0.1.0"
summary = "minimal package"
pmacs_required = ">=1.0.0"
entry = "init.lua"
exports = []
"#;
let m = PackageManifest::from_toml(s).unwrap();
assert_eq!(m.name.as_str(), "minimal");
assert!(m.dependencies.is_empty());
assert!(m.conflicts.is_empty());
}
// -- Missing required fields name the field -----------------------------
#[test]
fn missing_name_field_error_names_name() {
let s = r#"
version = "0.1.0"
summary = "x"
pmacs_required = ">=1.0.0"
entry = "init.lua"
exports = []
"#;
let err = PackageManifest::from_toml(s).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("name"),
"error should name the missing field, got: {msg}"
);
}
#[test]
fn missing_version_field_error_names_version() {
let s = r#"
name = "ok"
summary = "x"
pmacs_required = ">=1.0.0"
entry = "init.lua"
exports = []
"#;
let err = PackageManifest::from_toml(s).unwrap_err();
assert!(err.to_string().contains("version"));
}
#[test]
fn missing_summary_field_error_names_summary() {
let s = r#"
name = "ok"
version = "0.1.0"
pmacs_required = ">=1.0.0"
entry = "init.lua"
exports = []
"#;
let err = PackageManifest::from_toml(s).unwrap_err();
assert!(err.to_string().contains("summary"));
}
#[test]
fn missing_pmacs_required_field_error_names_pmacs_required() {
let s = r#"
name = "ok"
version = "0.1.0"
summary = "x"
entry = "init.lua"
exports = []
"#;
let err = PackageManifest::from_toml(s).unwrap_err();
assert!(err.to_string().contains("pmacs_required"));
}
#[test]
fn missing_entry_field_error_names_entry() {
let s = r#"
name = "ok"
version = "0.1.0"
summary = "x"
pmacs_required = ">=1.0.0"
exports = []
"#;
let err = PackageManifest::from_toml(s).unwrap_err();
assert!(err.to_string().contains("entry"));
}
#[test]
fn missing_exports_field_error_names_exports() {
let s = r#"
name = "ok"
version = "0.1.0"
summary = "x"
pmacs_required = ">=1.0.0"
entry = "init.lua"
"#;
let err = PackageManifest::from_toml(s).unwrap_err();
assert!(err.to_string().contains("exports"));
}
// -- Invalid semver rejected at parse time ------------------------------
#[test]
fn invalid_version_string_rejected_at_parse_time() {
let s = r#"
name = "ok"
version = "not-a-semver"
summary = "x"
pmacs_required = ">=1.0.0"
entry = "init.lua"
exports = []
"#;
let err = PackageManifest::from_toml(s).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("version") || msg.contains("not-a-semver"),
"expected version parse error, got: {msg}"
);
}
#[test]
fn invalid_pmacs_required_rejected_at_parse_time() {
let s = r#"
name = "ok"
version = "0.1.0"
summary = "x"
pmacs_required = "garbage-version-req"
entry = "init.lua"
exports = []
"#;
let err = PackageManifest::from_toml(s).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("pmacs_required") || msg.contains("garbage-version-req"),
"expected pmacs_required parse error, got: {msg}"
);
}
#[test]
fn invalid_dependency_version_rejected_at_parse_time() {
let s = r#"
name = "ok"
version = "0.1.0"
summary = "x"
pmacs_required = ">=1.0.0"
entry = "init.lua"
exports = []
[[dependencies]]
address = "github:foo/bar"
version = "not-a-req"
"#;
let err = PackageManifest::from_toml(s).unwrap_err();
assert!(err.to_string().contains("dependencies") || err.to_string().contains("not-a-req"));
}
// -- PackageName validator coverage -------------------------------------
#[test]
fn package_name_accepts_simple_lowercase() {
assert!(PackageName::new("pmacs-magit").is_ok());
assert!(PackageName::new("a").is_ok());
assert!(PackageName::new("a1").is_ok());
assert!(PackageName::new("foo-bar-baz").is_ok());
}
#[test]
fn package_name_accepts_namespace_form() {
assert!(PackageName::new("user/pkg").is_ok());
assert!(PackageName::new("user-name/pkg-name").is_ok());
}
#[test]
fn package_name_rejects_uppercase() {
let err = PackageName::new("Pmacs-magit").unwrap_err();
assert!(matches!(err, ManifestError::InvalidName { .. }));
}
#[test]
fn package_name_rejects_leading_digit() {
assert!(PackageName::new("1pkg").is_err());
}
#[test]
fn package_name_rejects_leading_hyphen() {
assert!(PackageName::new("-pkg").is_err());
}
#[test]
fn package_name_rejects_underscore() {
assert!(PackageName::new("pkg_name").is_err());
}
#[test]
fn package_name_rejects_empty() {
assert!(PackageName::new("").is_err());
}
#[test]
fn package_name_rejects_double_namespace() {
assert!(PackageName::new("a/b/c").is_err());
}
#[test]
fn package_name_rejects_trailing_slash() {
assert!(PackageName::new("user/").is_err());
}
#[test]
fn package_name_rejects_leading_slash() {
assert!(PackageName::new("/pkg").is_err());
}
#[test]
fn package_name_deserializes_with_validation() {
let s = r#"
name = "BAD-CAPS"
version = "0.1.0"
summary = "x"
pmacs_required = ">=1.0.0"
entry = "init.lua"
exports = []
"#;
let err = PackageManifest::from_toml(s).unwrap_err();
assert!(err.to_string().contains("BAD-CAPS") || err.to_string().contains("name"));
}
// -- Optional dependencies / conflicts respected ------------------------
#[test]
fn dependencies_default_to_empty_when_omitted() {
let s = r#"
name = "ok"
version = "0.1.0"
summary = "x"
pmacs_required = ">=1.0.0"
entry = "init.lua"
exports = ["main"]
"#;
let m = PackageManifest::from_toml(s).unwrap();
assert!(m.dependencies.is_empty());
assert!(m.conflicts.is_empty());
}
// -- Property test: arbitrary valid manifest round-trips ----------------
fn name_segment_strategy() -> impl Strategy<Value = String> {
(
prop::char::range('a', 'z'),
prop::collection::vec(
prop_oneof![
prop::char::range('a', 'z'),
prop::char::range('0', '9'),
Just('-'),
],
0..8,
),
)
.prop_map(|(head, rest)| {
let mut s = String::new();
s.push(head);
s.extend(rest);
s
})
}
fn package_name_strategy() -> impl Strategy<Value = PackageName> {
prop_oneof![
name_segment_strategy().prop_filter_map("must validate", |s| PackageName::new(s).ok()),
(name_segment_strategy(), name_segment_strategy())
.prop_filter_map("namespaced must validate", |(a, b)| {
PackageName::new(format!("{a}/{b}")).ok()
}),
]
}
fn version_strategy() -> impl Strategy<Value = Version> {
(0u8..16, 0u8..16, 0u8..16).prop_map(|(a, b, c)| Version::new(a.into(), b.into(), c.into()))
}
fn version_req_strategy() -> impl Strategy<Value = VersionReq> {
// Stick to forms semver round-trips losslessly: an exact pin
// formatted as `=major.minor.patch`. Caret/tilde/range-style
// requirements have canonicalization quirks (e.g., `*` <->
// `>=0.0.0`) that round-trip but compare unequal; the property
// test asserts equality, so we stay in the always-equal subset.
version_strategy().prop_map(|v| VersionReq::parse(&format!("={v}")).unwrap())
}
fn dep_spec_strategy() -> impl Strategy<Value = DependencySpec> {
(
prop::string::string_regex("[a-z][a-z0-9-]{0,15}").unwrap(),
version_req_strategy(),
)
.prop_map(|(addr, ver)| DependencySpec {
address: format!("github:user/{addr}"),
version: ver,
})
}
fn manifest_strategy() -> impl Strategy<Value = PackageManifest> {
(
package_name_strategy(),
version_strategy(),
// Summary: printable ASCII without quote/backslash hazards.
prop::string::string_regex("[a-zA-Z0-9 .,!?]{0,40}").unwrap(),
version_req_strategy(),
prop::collection::vec(dep_spec_strategy(), 0..3),
prop::collection::vec(dep_spec_strategy(), 0..3),
prop::string::string_regex("[a-z][a-z0-9_/.]{0,16}\\.lua").unwrap(),
prop::collection::vec(
prop::string::string_regex("[a-z][a-z0-9_.]{0,16}").unwrap(),
0..4,
),
)
.prop_map(
|(name, version, summary, req, deps, conf, entry, exports)| PackageManifest {
name,
version,
summary,
pmacs_required: req,
dependencies: deps,
conflicts: conf,
entry: PathBuf::from(entry),
exports,
},
)
}
proptest! {
#[test]
fn arbitrary_valid_manifest_survives_round_trip(m in manifest_strategy()) {
let s = m.to_toml().expect("serialize");
let parsed = PackageManifest::from_toml(&s).expect("parse round-tripped output");
prop_assert_eq!(parsed, m);
}
}
}

22
src/packages/mod.rs Normal file
View File

@ -0,0 +1,22 @@
// packages/mod.rs --- Package model: manifests, addresses, resolver, lockfile.
//! Package model (spec §sec:packages-future, M7).
//!
//! v0.1 ships zero package machinery; loose Lua files in a config
//! directory are loaded directly. M7 adds the manifest format, address
//! parsing, the dependency resolver, the lockfile, and the loader that
//! wires all of it into `require`.
//!
//! This module assembles the M7 building blocks. T M7.1 lands the
//! manifest schema and parser ([`manifest`]); subsequent M7 tasks add
//! their own submodules under this same parent.
pub mod address;
pub mod fetcher;
pub mod installer;
pub mod manifest;
pub use address::{Address, AddressError};
pub use fetcher::{FetchError, Fetcher, RefSpec};
pub use installer::{InstallError, InstallScope, InstallSpec, InstalledPackage, Installer};
pub use manifest::{DependencySpec, ManifestError, PackageManifest, PackageName};

View File

@ -60,6 +60,8 @@ use crossbeam::channel::{self, Receiver, Sender};
use nix::sys::signal::Signal; use nix::sys::signal::Signal;
use nix::unistd::Pid; use nix::unistd::Pid;
use crate::ansi::{AnsiEvent, AnsiParser};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Identity and configuration // Identity and configuration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -194,6 +196,10 @@ pub struct ProcessSpec {
pub mode: ProcessMode, pub mode: ProcessMode,
/// What to do on termination. /// What to do on termination.
pub restart: RestartPolicy, pub restart: RestartPolicy,
/// Parse PTY output on a worker and emit structured ANSI events
/// instead of raw stdout bytes. Opt-in so LSP and other byte-stream
/// consumers keep their existing stdout/stderr contract.
pub ansi_events: bool,
} }
impl ProcessSpec { impl ProcessSpec {
@ -209,6 +215,7 @@ impl ProcessSpec {
env: Vec::new(), env: Vec::new(),
mode: ProcessMode::Pipes, mode: ProcessMode::Pipes,
restart: RestartPolicy::Never, restart: RestartPolicy::Never,
ansi_events: false,
} }
} }
} }
@ -314,6 +321,10 @@ pub enum ProcessEventKind {
/// never emit `Stderr` (the pty merges output streams); they /// never emit `Stderr` (the pty merges output streams); they
/// emit only `Stdout`. /// emit only `Stdout`.
Stderr(Vec<u8>), Stderr(Vec<u8>),
/// Structured ANSI events decoded from a PTY byte stream on the
/// parser worker. Only emitted when [`ProcessSpec::ansi_events`]
/// is true.
Ansi(Vec<AnsiEvent>),
/// Child exited cleanly. /// Child exited cleanly.
Exited { Exited {
/// Exit code. /// Exit code.
@ -362,12 +373,24 @@ pub const PTY_READ_CEILING_BYTES: usize = 1 << 20;
/// chunks and a 1 MiB ceiling, this is 128 slots. /// chunks and a 1 MiB ceiling, this is 128 slots.
const BYTE_CHUNK_CHANNEL_CAP: usize = PTY_READ_CEILING_BYTES / BYTE_CHUNK_SIZE; const BYTE_CHUNK_CHANNEL_CAP: usize = PTY_READ_CEILING_BYTES / BYTE_CHUNK_SIZE;
/// In-flight structured-event ceiling between ANSI parser worker and
/// main-thread supervisor drain. The spec names this as 256 KiB; with
/// 8 KiB read chunks this is 32 parser batches in flight.
pub const ANSI_EVENT_CEILING_BYTES: usize = 256 * 1024;
const ANSI_EVENT_CHANNEL_CAP: usize = ANSI_EVENT_CEILING_BYTES / BYTE_CHUNK_SIZE;
/// How long a reader thread waits in a bounded `send` before polling /// How long a reader thread waits in a bounded `send` before polling
/// its cancel flag. 50 ms is long enough that healthy steady-state /// its cancel flag. 50 ms is long enough that healthy steady-state
/// flow doesn't burn cycles re-checking, short enough that a /// flow doesn't burn cycles re-checking, short enough that a
/// shutting-down supervisor sees readers exit promptly. /// shutting-down supervisor sees readers exit promptly.
const READER_SEND_POLL_INTERVAL: Duration = Duration::from_millis(50); const READER_SEND_POLL_INTERVAL: Duration = Duration::from_millis(50);
/// Bounded grace window used when a child has exited but its reader /
/// parser worker may still have already-read bytes in flight. This is
/// not process termination grace; it is only the final output flush
/// before the runtime handles are dropped.
const EXIT_OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Supervisor // Supervisor
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -410,11 +433,10 @@ struct RuntimeHandles {
/// [`RuntimeHandles`] so a generation's worker threads don't /// [`RuntimeHandles`] so a generation's worker threads don't
/// outlive the supervisor. /// outlive the supervisor.
readers: Vec<JoinHandle<()>>, readers: Vec<JoinHandle<()>>,
/// Bounded byte channel from reader threads to the supervisor. /// Bounded output channel drained by the supervisor. Raw processes
/// Sized at [`BYTE_CHUNK_CHANNEL_CAP`] slots so in-flight bytes /// expose bytes directly; ANSI-enabled PTY processes expose parser
/// are capped at [`PTY_READ_CEILING_BYTES`]. T M6.2 / spec /// batches from the worker stage.
/// §sec:repl-streaming. output_rx: RuntimeOutputRx,
byte_rx: Receiver<ByteChunk>,
/// Cancel flag observed by reader threads when their bounded /// Cancel flag observed by reader threads when their bounded
/// `send` blocks. Set on generation end / supervisor drop so a /// `send` blocks. Set on generation end / supervisor drop so a
/// reader stuck in `send` (consumer fell behind) wakes promptly /// reader stuck in `send` (consumer fell behind) wakes promptly
@ -441,6 +463,13 @@ impl Drop for RuntimeHandles {
/// `Stdout`). Lives on the per-generation bounded byte channel. /// `Stdout`). Lives on the per-generation bounded byte channel.
type ByteChunk = (ReaderKind, Vec<u8>); type ByteChunk = (ReaderKind, Vec<u8>);
type AnsiBatch = Vec<AnsiEvent>;
enum RuntimeOutputRx {
Bytes(Receiver<ByteChunk>),
Ansi(Receiver<AnsiBatch>),
}
/// Discriminated wrapper over a pipe-mode `std::process::Child` and /// Discriminated wrapper over a pipe-mode `std::process::Child` and
/// a pty-mode portable-pty pair. The variants share `try_wait` / /// a pty-mode portable-pty pair. The variants share `try_wait` /
/// pid retrieval through a thin enum match. /// pid retrieval through a thin enum match.
@ -758,38 +787,25 @@ impl ProcessSupervisor {
/// most one `Stdout` and one `Stderr` event into pending. Called /// most one `Stdout` and one `Stderr` event into pending. Called
/// from `tick()`. No-op if the process has no live runtime. /// from `tick()`. No-op if the process has no live runtime.
fn drain_byte_channel(&mut self, id: ProcessId) { fn drain_byte_channel(&mut self, id: ProcessId) {
let Some(proc) = self.processes.get(&id) else { let drained = {
return; let Some(proc) = self.processes.get(&id) else {
}; return;
let Some(rt) = proc.runtime.as_ref() else { };
return; let Some(rt) = proc.runtime.as_ref() else {
}; return;
let mut stdout_buf: Vec<u8> = Vec::new(); };
let mut stderr_buf: Vec<u8> = Vec::new(); match &rt.output_rx {
while let Ok((kind, mut bytes)) = rt.byte_rx.try_recv() { RuntimeOutputRx::Bytes(byte_rx) => drain_raw_output(byte_rx),
match kind { RuntimeOutputRx::Ansi(ansi_rx) => drain_ansi_output(ansi_rx),
ReaderKind::Stdout => stdout_buf.append(&mut bytes),
ReaderKind::Stderr => stderr_buf.append(&mut bytes),
} }
} };
if stdout_buf.is_empty() && stderr_buf.is_empty() { if drained.is_empty() {
return; return;
} }
let now = Instant::now(); let now = Instant::now();
let queue = self.pending.entry(id).or_default(); let queue = self.pending.entry(id).or_default();
if !stdout_buf.is_empty() { for kind in drained {
queue.push(ProcessEvent { queue.push(ProcessEvent { id, kind, at: now });
id,
kind: ProcessEventKind::Stdout(stdout_buf),
at: now,
});
}
if !stderr_buf.is_empty() {
queue.push(ProcessEvent {
id,
kind: ProcessEventKind::Stderr(stderr_buf),
at: now,
});
} }
} }
@ -811,12 +827,14 @@ impl ProcessSupervisor {
Ok(None) => {} Ok(None) => {}
Ok(Some(TermStatus::Exited(code))) => { Ok(Some(TermStatus::Exited(code))) => {
let now = Instant::now(); let now = Instant::now();
let final_output = final_drain_runtime(runtime);
proc.state = ProcessState::Terminated(Termination::Exited { proc.state = ProcessState::Terminated(Termination::Exited {
code, code,
started, started,
ended: now, ended: now,
}); });
proc.runtime = None; proc.runtime = None;
append_process_events(&mut self.pending, id, final_output, now);
self.pending.entry(id).or_default().push(ProcessEvent { self.pending.entry(id).or_default().push(ProcessEvent {
id, id,
kind: ProcessEventKind::Exited { code }, kind: ProcessEventKind::Exited { code },
@ -825,12 +843,14 @@ impl ProcessSupervisor {
} }
Ok(Some(TermStatus::Signaled(signal))) => { Ok(Some(TermStatus::Signaled(signal))) => {
let now = Instant::now(); let now = Instant::now();
let final_output = final_drain_runtime(runtime);
proc.state = ProcessState::Terminated(Termination::Signaled { proc.state = ProcessState::Terminated(Termination::Signaled {
signal: signal.clone(), signal: signal.clone(),
started, started,
ended: now, ended: now,
}); });
proc.runtime = None; proc.runtime = None;
append_process_events(&mut self.pending, id, final_output, now);
self.pending.entry(id).or_default().push(ProcessEvent { self.pending.entry(id).or_default().push(ProcessEvent {
id, id,
kind: ProcessEventKind::Signaled { signal }, kind: ProcessEventKind::Signaled { signal },
@ -839,11 +859,13 @@ impl ProcessSupervisor {
} }
Err(e) => { Err(e) => {
let now = Instant::now(); let now = Instant::now();
let final_output = final_drain_runtime(runtime);
proc.state = ProcessState::Terminated(Termination::Crashed { proc.state = ProcessState::Terminated(Termination::Crashed {
error: e.clone(), error: e.clone(),
ended: now, ended: now,
}); });
proc.runtime = None; proc.runtime = None;
append_process_events(&mut self.pending, id, final_output, now);
self.pending.entry(id).or_default().push(ProcessEvent { self.pending.entry(id).or_default().push(ProcessEvent {
id, id,
kind: ProcessEventKind::Crashed { error: e }, kind: ProcessEventKind::Crashed { error: e },
@ -1037,6 +1059,9 @@ impl Drop for ProcessSupervisor {
/// `events_tx`; reader threads emit only byte chunks onto the /// `events_tx`; reader threads emit only byte chunks onto the
/// per-generation bounded byte channel. T M6.2. /// per-generation bounded byte channel. T M6.2.
fn build_runtime(spec: &ProcessSpec, id: ProcessId) -> Result<RuntimeHandles, String> { fn build_runtime(spec: &ProcessSpec, id: ProcessId) -> Result<RuntimeHandles, String> {
if spec.ansi_events && matches!(spec.mode, ProcessMode::Pipes) {
return Err("process spawn: ansi=true requires pty mode; pipe-mode consumers receive raw stdout/stderr bytes".to_owned());
}
match spec.mode { match spec.mode {
ProcessMode::Pipes => build_pipes_runtime(spec, id), ProcessMode::Pipes => build_pipes_runtime(spec, id),
ProcessMode::Pty { rows, cols, mode } => build_pty_runtime(spec, id, rows, cols, mode), ProcessMode::Pty { rows, cols, mode } => build_pty_runtime(spec, id, rows, cols, mode),
@ -1089,7 +1114,7 @@ fn build_pipes_runtime(spec: &ProcessSpec, _id: ProcessId) -> Result<RuntimeHand
stdin, stdin,
pid, pid,
readers, readers,
byte_rx, output_rx: RuntimeOutputRx::Bytes(byte_rx),
cancel, cancel,
}) })
} }
@ -1154,12 +1179,19 @@ fn build_pty_runtime(
.map_err(|e| format!("pty reader: {e}"))?; .map_err(|e| format!("pty reader: {e}"))?;
let (byte_tx, byte_rx) = channel::bounded::<ByteChunk>(BYTE_CHUNK_CHANNEL_CAP); let (byte_tx, byte_rx) = channel::bounded::<ByteChunk>(BYTE_CHUNK_CHANNEL_CAP);
let cancel = Arc::new(AtomicBool::new(false)); let cancel = Arc::new(AtomicBool::new(false));
let readers = vec![spawn_reader( let mut readers = vec![spawn_reader(
byte_tx, byte_tx,
Arc::clone(&cancel), Arc::clone(&cancel),
reader, reader,
ReaderKind::Stdout, ReaderKind::Stdout,
)]; )];
let output_rx = if spec.ansi_events {
let (ansi_tx, ansi_rx) = channel::bounded::<AnsiBatch>(ANSI_EVENT_CHANNEL_CAP);
readers.push(spawn_ansi_parser(byte_rx, ansi_tx, Arc::clone(&cancel)));
RuntimeOutputRx::Ansi(ansi_rx)
} else {
RuntimeOutputRx::Bytes(byte_rx)
};
Ok(RuntimeHandles { Ok(RuntimeHandles {
child: ChildHandle::Pty { child: ChildHandle::Pty {
child: Arc::new(Mutex::new(into_send_sync_child(child))), child: Arc::new(Mutex::new(into_send_sync_child(child))),
@ -1168,7 +1200,7 @@ fn build_pty_runtime(
stdin: Some(writer), stdin: Some(writer),
pid, pid,
readers, readers,
byte_rx, output_rx,
cancel, cancel,
}) })
} }
@ -1327,6 +1359,122 @@ fn spawn_reader<R: Read + Send + 'static>(
}) })
} }
fn drain_raw_output(byte_rx: &Receiver<ByteChunk>) -> Vec<ProcessEventKind> {
let mut stdout_buf: Vec<u8> = Vec::new();
let mut stderr_buf: Vec<u8> = Vec::new();
while let Ok((kind, mut bytes)) = byte_rx.try_recv() {
match kind {
ReaderKind::Stdout => stdout_buf.append(&mut bytes),
ReaderKind::Stderr => stderr_buf.append(&mut bytes),
}
}
let mut out = Vec::with_capacity(2);
if !stdout_buf.is_empty() {
out.push(ProcessEventKind::Stdout(stdout_buf));
}
if !stderr_buf.is_empty() {
out.push(ProcessEventKind::Stderr(stderr_buf));
}
out
}
fn drain_ansi_output(ansi_rx: &Receiver<AnsiBatch>) -> Vec<ProcessEventKind> {
let mut events: Vec<AnsiEvent> = Vec::new();
while let Ok(mut batch) = ansi_rx.try_recv() {
events.append(&mut batch);
}
if events.is_empty() {
Vec::new()
} else {
vec![ProcessEventKind::Ansi(events)]
}
}
fn drain_runtime_output(rt: &RuntimeHandles) -> Vec<ProcessEventKind> {
match &rt.output_rx {
RuntimeOutputRx::Bytes(byte_rx) => drain_raw_output(byte_rx),
RuntimeOutputRx::Ansi(ansi_rx) => drain_ansi_output(ansi_rx),
}
}
fn final_drain_runtime(rt: &RuntimeHandles) -> Vec<ProcessEventKind> {
let deadline = Instant::now() + EXIT_OUTPUT_DRAIN_TIMEOUT;
let mut out = Vec::new();
loop {
let drained = drain_runtime_output(rt);
let drained_any = !drained.is_empty();
out.extend(drained);
if rt.readers.iter().all(std::thread::JoinHandle::is_finished) && !drained_any {
return out;
}
if Instant::now() >= deadline {
return out;
}
std::thread::sleep(Duration::from_millis(1));
}
}
fn append_process_events(
pending: &mut HashMap<ProcessId, Vec<ProcessEvent>>,
id: ProcessId,
kinds: Vec<ProcessEventKind>,
at: Instant,
) {
if kinds.is_empty() {
return;
}
let queue = pending.entry(id).or_default();
for kind in kinds {
queue.push(ProcessEvent { id, kind, at });
}
}
/// Spawn the ANSI parser worker for an ANSI-enabled PTY generation.
///
/// The reader thread remains responsible for the 1 MiB PTY-read ceiling.
/// This stage consumes those chunks, maintains parser state across chunk
/// boundaries, and forwards structured events through a second bounded
/// channel whose capacity represents the spec's 256 KiB parser→main
/// ceiling.
fn spawn_ansi_parser(
byte_rx: Receiver<ByteChunk>,
ansi_tx: Sender<AnsiBatch>,
cancel: Arc<AtomicBool>,
) -> JoinHandle<()> {
std::thread::spawn(move || {
let mut parser = AnsiParser::new();
loop {
if cancel.load(Ordering::Relaxed) {
return;
}
let (kind, bytes) = match byte_rx.recv_timeout(READER_SEND_POLL_INTERVAL) {
Ok(chunk) => chunk,
Err(crossbeam::channel::RecvTimeoutError::Timeout) => continue,
Err(crossbeam::channel::RecvTimeoutError::Disconnected) => return,
};
if !matches!(kind, ReaderKind::Stdout) {
continue;
}
let mut events = parser.feed(&bytes);
if events.is_empty() {
continue;
}
loop {
match ansi_tx.send_timeout(events, READER_SEND_POLL_INTERVAL) {
Ok(()) => break,
Err(crossbeam::channel::SendTimeoutError::Timeout(rejected)) => {
if cancel.load(Ordering::Relaxed) {
return;
}
events = rejected;
}
Err(crossbeam::channel::SendTimeoutError::Disconnected(_)) => return,
}
}
}
})
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1688,7 +1836,10 @@ mod tests {
sup.processes sup.processes
.get(&id) .get(&id)
.and_then(|p| p.runtime.as_ref()) .and_then(|p| p.runtime.as_ref())
.map_or(0, |rt| rt.byte_rx.len()) .map_or(0, |rt| match &rt.output_rx {
RuntimeOutputRx::Bytes(rx) => rx.len(),
RuntimeOutputRx::Ansi(_) => 0,
})
} }
/// T M6.2 acceptance bullet 1: the per-generation byte channel /// T M6.2 acceptance bullet 1: the per-generation byte channel
@ -1831,6 +1982,69 @@ mod tests {
); );
} }
#[test]
fn m6_2_ansi_enabled_pty_emits_structured_events() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("ansi-stream", "/bin/sh");
spec.args = vec!["-c".into(), "printf '\\033[31mhi\\033[0m\\n'".into()];
spec.mode = ProcessMode::Pty {
rows: 24,
cols: 80,
mode: TerminalMode::Canonical,
};
spec.ansi_events = true;
let id = sup.spawn(spec).expect("spawn ansi pty");
let evs = drain_until(&mut sup, id, Duration::from_secs(5), has_exited);
assert!(
evs.iter()
.all(|e| !matches!(e.kind, ProcessEventKind::Stdout(_))),
"ansi-enabled PTY should not surface raw stdout events: {evs:?}"
);
let mut saw_red = false;
let mut saw_text = false;
for ev in evs {
if let ProcessEventKind::Ansi(events) = ev.kind {
for event in events {
match event {
AnsiEvent::SetStyle(style)
if style.fg == crate::cell::Color::Indexed(1) =>
{
saw_red = true;
}
AnsiEvent::Text(text) if text.contains("hi") => {
saw_text = true;
}
_ => {}
}
}
}
}
assert!(saw_red, "expected structured red SetStyle event");
assert!(saw_text, "expected structured text event");
}
#[test]
fn m6_2_ansi_parser_worker_exits_when_reader_channel_closes() {
let (byte_tx, byte_rx) = channel::bounded::<ByteChunk>(1);
let (ansi_tx, _ansi_rx) = channel::bounded::<AnsiBatch>(1);
let cancel = Arc::new(AtomicBool::new(false));
let handle = spawn_ansi_parser(byte_rx, ansi_tx, Arc::clone(&cancel));
drop(byte_tx);
let deadline = Instant::now() + Duration::from_millis(500);
while Instant::now() < deadline && !handle.is_finished() {
std::thread::sleep(Duration::from_millis(5));
}
if !handle.is_finished() {
cancel.store(true, Ordering::Relaxed);
}
assert!(
handle.is_finished(),
"ANSI parser worker should exit once the byte reader channel closes"
);
handle.join().expect("parser worker join");
}
/// T M6.2 acceptance bullet 2: stream cancellation propagates to /// T M6.2 acceptance bullet 2: stream cancellation propagates to
/// the source. A long-lived producer with the consumer paused /// the source. A long-lived producer with the consumer paused
/// (so the reader is blocked in `send`) still terminates /// (so the reader is blocked in `send`) still terminates

View File

@ -201,6 +201,28 @@ pub fn default_markers() -> Vec<ProjectMarker> {
/// search begins at its parent. /// search begins at its parent.
#[must_use] #[must_use]
pub fn detect_project(start: &Path, markers: &[ProjectMarker]) -> Option<(PathBuf, ProjectKind)> { pub fn detect_project(start: &Path, markers: &[ProjectMarker]) -> Option<(PathBuf, ProjectKind)> {
walk_for_marker(start, markers, None)
}
/// Like [`detect_project`], but halts the upward walk after examining
/// `stop_root`. Used by tests so a stray marker in a temp-dir's
/// ancestor (e.g. a developer's `/tmp/.git`) can't leak into a
/// fixture that lives below it. The walk still examines `stop_root`
/// itself; only its parent and beyond are skipped.
#[must_use]
pub fn detect_project_within(
start: &Path,
markers: &[ProjectMarker],
stop_root: &Path,
) -> Option<(PathBuf, ProjectKind)> {
walk_for_marker(start, markers, Some(stop_root))
}
fn walk_for_marker(
start: &Path,
markers: &[ProjectMarker],
stop_root: Option<&Path>,
) -> Option<(PathBuf, ProjectKind)> {
let start_dir: &Path = if start.is_file() { let start_dir: &Path = if start.is_file() {
start.parent().unwrap_or(start) start.parent().unwrap_or(start)
} else { } else {
@ -210,6 +232,11 @@ pub fn detect_project(start: &Path, markers: &[ProjectMarker]) -> Option<(PathBu
if let Some(kind) = match_marker(ancestor, markers) { if let Some(kind) = match_marker(ancestor, markers) {
return Some((ancestor.to_path_buf(), kind)); return Some((ancestor.to_path_buf(), kind));
} }
if let Some(stop) = stop_root {
if ancestor == stop {
break;
}
}
} }
None None
} }
@ -259,6 +286,16 @@ pub struct Workspace {
by_root: HashMap<PathBuf, ProjectId>, by_root: HashMap<PathBuf, ProjectId>,
active: Option<ProjectId>, active: Option<ProjectId>,
markers: Vec<ProjectMarker>, markers: Vec<ProjectMarker>,
/// Optional clamp on [`Self::detect`]'s upward marker walk.
/// When `None` (the default), detection walks ancestors all the
/// way to the filesystem root (matching `git rev-parse
/// --show-toplevel` semantics). When `Some(boundary)`, the walk
/// halts after examining `boundary`; ancestors above `boundary`
/// are not consulted.
///
/// Stored canonicalized so the symlinked-workspace case behaves
/// predictably (see [`Self::set_search_boundary`]).
search_boundary: Option<PathBuf>,
} }
impl Default for Workspace { impl Default for Workspace {
@ -268,7 +305,8 @@ impl Default for Workspace {
} }
impl Workspace { impl Workspace {
/// Empty workspace with the [`default_markers`] rule set. /// Empty workspace with the [`default_markers`] rule set and no
/// search boundary (detection walks to filesystem root).
#[must_use] #[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@ -276,6 +314,7 @@ impl Workspace {
by_root: HashMap::new(), by_root: HashMap::new(),
active: None, active: None,
markers: default_markers(), markers: default_markers(),
search_boundary: None,
} }
} }
@ -291,11 +330,54 @@ impl Workspace {
&self.markers &self.markers
} }
/// Detect the project root for `file_path`. Convenience wrapper /// Set (or clear) the upward-walk boundary for [`Self::detect`].
/// around [`detect_project`] using the workspace's marker rules. ///
/// `Some(path)` clamps detection so it never examines ancestors
/// above `path`. `None` restores the default walk-to-filesystem-root
/// behavior. Use case: a user whose code lives under `~/code` can
/// set `search_boundary = "~/code"` in `init.lua` so a stray
/// marker higher up the tree (e.g. `/tmp/.git`, an orphaned `.git`
/// in `~`) cannot capture unrelated files.
///
/// # Symlink handling
///
/// The boundary is canonicalized at set time, and start paths are
/// canonicalized at detect time, so a search starting from a
/// symlinked path that resolves under the boundary still respects
/// the boundary. If `path` does not exist on disk we store it
/// as-is; later detection then compares against the literal value
/// (which matches the upstream behavior of
/// [`canonicalize_or_passthrough`]).
///
/// # Inclusivity
///
/// The boundary is *inclusive*: a marker located at the boundary
/// path itself is found; only strict ancestors of the boundary
/// are skipped. Set the boundary to the directory that *contains*
/// your projects, not to one level above.
pub fn set_search_boundary(&mut self, path: Option<PathBuf>) {
self.search_boundary = path.map(|p| canonicalize_or_passthrough(&p));
}
/// Read-only view of the configured search boundary.
#[must_use]
pub fn search_boundary(&self) -> Option<&Path> {
self.search_boundary.as_deref()
}
/// Detect the project root for `file_path`, honoring the
/// workspace's [`Self::set_search_boundary`] clamp if any.
///
/// `file_path` is canonicalized before the walk if possible so
/// that boundary comparison works correctly under symlinks (e.g.,
/// `/tmp/sandbox/foo` symlinked to `/home/user/code/foo`).
#[must_use] #[must_use]
pub fn detect(&self, file_path: &Path) -> Option<(PathBuf, ProjectKind)> { pub fn detect(&self, file_path: &Path) -> Option<(PathBuf, ProjectKind)> {
detect_project(file_path, &self.markers) let canonical = canonicalize_or_passthrough(file_path);
match self.search_boundary.as_deref() {
Some(boundary) => detect_project_within(&canonical, &self.markers, boundary),
None => detect_project(&canonical, &self.markers),
}
} }
/// Open a project at `root`. Idempotent: if a project for the /// Open a project at `root`. Idempotent: if a project for the
@ -508,10 +590,15 @@ mod tests {
#[test] #[test]
fn detect_returns_none_with_no_markers() { fn detect_returns_none_with_no_markers() {
// Bound the walk at the tempdir so a marker in a real
// ancestor (a developer's `/tmp/.git`, a CI runner's repo
// root above the test fixture, etc.) can't masquerade as a
// hit. Production callers walk to the filesystem root; the
// bound is a test-only correctness aid.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let f = dir.path().join("a/b.rs"); let f = dir.path().join("a/b.rs");
touch(&f); touch(&f);
assert!(detect_project(&f, &default_markers()).is_none()); assert!(detect_project_within(&f, &default_markers(), dir.path()).is_none());
} }
#[test] #[test]
@ -611,7 +698,133 @@ mod tests {
kind: ProjectKind::Git, kind: ProjectKind::Git,
is_directory: true, is_directory: true,
}]); }]);
// No git → no detection. // No git → no detection. Bound the walk at the tempdir so
// that a `.git` in any real ancestor cannot satisfy the
// (now sole) git marker; we are testing that `set_markers`
// chose the marker set, not what lives above the test.
assert!(detect_project_within(&f, ws.markers(), root).is_none());
}
// ----------------------------------------------------------------------
// Reviewer-flagged item 2: search-boundary clamp on detection.
// ----------------------------------------------------------------------
#[test]
fn search_boundary_default_is_none() {
let ws = Workspace::new();
assert!(ws.search_boundary().is_none());
}
#[test]
fn search_boundary_clamps_walk_above_boundary() {
// Stage a fake "outer marker" above the boundary (the reviewer's
// /tmp/.git case). Without the boundary, `detect` would walk up
// to the outer marker. With it, the walk halts at the
// boundary directory and returns None.
let outer = tempfile::tempdir().expect("outer");
// The outer dir gets a git marker.
mkdir(&outer.path().join(".git"));
// The "boundary" directory is a child of outer; the file
// lives below the boundary.
let boundary = outer.path().join("workspace");
let f = boundary.join("src/main.rs");
touch(&f);
let mut ws = Workspace::new();
// Without the boundary, detect walks up and finds the outer
// marker.
assert!(
ws.detect(&f).is_some(),
"sanity: outer .git is detectable without the boundary"
);
ws.set_search_boundary(Some(boundary.clone()));
// With the boundary set to the workspace dir, the outer marker
// is above the boundary and thus invisible.
assert!(
ws.detect(&f).is_none(),
"with boundary at {boundary:?}, the outer marker must be excluded"
);
}
#[test]
fn search_boundary_is_inclusive_examines_boundary_itself() {
// The boundary semantics: the boundary path *itself* is
// examined for markers; only strict ancestors are skipped.
// Documented inclusivity: "set boundary to the directory
// containing your projects, not one level above."
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
touch(&root.join("Cargo.toml"));
let f = root.join("src/lib.rs");
touch(&f);
let mut ws = Workspace::new();
ws.set_search_boundary(Some(root.to_path_buf()));
let detected = ws.detect(&f).expect("marker at the boundary must match");
// Path equality after canonicalization (macOS /tmp → /private/tmp).
assert_eq!(
detected.0.canonicalize().expect("canon found"),
root.canonicalize().expect("canon root")
);
}
#[test]
fn search_boundary_clearable_back_to_none() {
let outer = tempfile::tempdir().expect("outer");
mkdir(&outer.path().join(".git"));
let boundary = outer.path().join("workspace");
let f = boundary.join("src/main.rs");
touch(&f);
let mut ws = Workspace::new();
ws.set_search_boundary(Some(boundary.clone()));
assert!(ws.detect(&f).is_none()); assert!(ws.detect(&f).is_none());
ws.set_search_boundary(None);
assert!(
ws.detect(&f).is_some(),
"clearing the boundary must restore the unbounded walk"
);
}
#[test]
fn search_boundary_resolves_under_symlinked_start() {
// The symlinked-workspace case: corporate /home mounts and
// user-organized symlink farms put the file path under a
// symlink that resolves into the boundary. The walk
// canonicalizes both the start path and the boundary, so the
// boundary applies after symlink resolution.
//
// Skip on platforms / sandboxes that disallow symlink
// creation. Symlinks under tempfile dirs are normally allowed,
// but a paranoid sandbox may reject EPERM.
let real_dir = tempfile::tempdir().expect("real");
let real = real_dir.path().to_path_buf();
touch(&real.join("Cargo.toml"));
let real_file = real.join("src/lib.rs");
touch(&real_file);
let link_dir = tempfile::tempdir().expect("link");
let link = link_dir.path().join("via-link");
if let Err(e) = std::os::unix::fs::symlink(&real, &link) {
eprintln!("test skipped: symlink {link:?}{real:?} failed: {e}");
return;
}
let linked_file = link.join("src/lib.rs");
let mut ws = Workspace::new();
ws.set_search_boundary(Some(real.clone()));
// Walking via the symlinked path: after canonicalization both
// the start and the boundary live under `real`. The marker at
// `real/Cargo.toml` is at the boundary and is examined.
let (found, kind) = ws
.detect(&linked_file)
.expect("symlinked walk must still find the marker at the boundary");
assert_eq!(kind, ProjectKind::Rust);
assert_eq!(
found.canonicalize().expect("canon"),
real.canonicalize().expect("canon real")
);
} }
} }

View File

@ -38,10 +38,62 @@
//! when overlays touch only the cells they declare (see //! when overlays touch only the cells they declare (see
//! `composition_overhead_under_ten_percent` in `editor.rs`). //! `composition_overhead_under_ten_percent` in `editor.rs`).
use crate::buffer::{Buffer, BufferError, EditOp}; use crate::buffer::{Buffer, BufferError, BufferId, EditOp};
use crate::cell::{CellCoord, CellGrid, CellSize}; use crate::cell::{CellCoord, CellGrid, CellSize};
use crate::rope::{Edit, Position}; use crate::rope::{Edit, Position};
// ---------------------------------------------------------------------------
// InterceptContext (T M7.4)
// ---------------------------------------------------------------------------
/// Snapshot of the buffer's identity and shape, passed to
/// [`View::intercept_edit`] in lieu of a `&Buffer` reference.
///
/// # Why a snapshot, not a `&Buffer`
///
/// Pre-M7.4, `intercept_edit` received `&Buffer`. The Lua-bindings
/// layer held the registry's `RefCell::borrow_mut` for the full
/// duration of the call, so an intercept body that re-entered
/// `pmacs.buffer.X` synchronously hit a recursive-borrow error
/// (`BindingError::Reentrant`); the M6.10 audit fix surfaced this as
/// a typed error rather than a panic but did not enable the re-entry.
///
/// M7.4 splits the edit flow into three phases. Phase 2 runs the
/// intercept chain with the registry borrow released, so an intercept
/// body may call back into `pmacs.buffer.X` on any buffer. The cost:
/// the intercept can no longer hold a `&Buffer` (the buffer might be
/// mutated by the re-entrant call). Instead, we hand it an
/// `InterceptContext` snapshot taken at the start of the edit; the
/// fields cover every read the intercept needs (`buf_id` for routing,
/// `buf_len` for clamping positions, `buf_name` for diagnostic
/// messages, `revision` for "did this snapshot drift?" checks).
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InterceptContext {
/// The buffer's identifier. Stable across the edit.
pub buf_id: BufferId,
/// Buffer length in bytes at the moment the snapshot was taken.
pub buf_len: u64,
/// Buffer name at the moment the snapshot was taken.
pub buf_name: String,
/// Buffer revision at the moment the snapshot was taken. Useful
/// for re-entrant cross-buffer edits to detect whether the parent
/// buffer was mutated during their lifetime.
pub revision: u64,
}
impl InterceptContext {
/// Build a context snapshot from a buffer reference.
#[must_use]
pub fn snapshot(buf: &Buffer) -> Self {
Self {
buf_id: buf.id(),
buf_len: buf.len(),
buf_name: buf.name().to_string(),
revision: buf.revision(),
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Coordinates // Coordinates
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -115,10 +167,17 @@ pub trait View {
/// view that translates filename edits into rename operations). /// view that translates filename edits into rename operations).
/// Returning an error rejects the edit. /// Returning an error rejects the edit.
/// ///
/// The view receives a [`InterceptContext`] snapshot of the
/// buffer's identity and shape at the moment the edit began
/// (T M7.4). Intercept bodies needing live state on another
/// buffer can re-enter `pmacs.buffer.X` (or the in-process
/// equivalent); the registry borrow is released for the duration
/// of this call.
///
/// Default: pass through. /// Default: pass through.
fn intercept_edit<'a>( fn intercept_edit<'a>(
&mut self, &mut self,
_buf: &Buffer, _ctx: &InterceptContext,
op: EditOp<'a>, op: EditOp<'a>,
) -> Result<EditOp<'a>, BufferError> { ) -> Result<EditOp<'a>, BufferError> {
Ok(op) Ok(op)

View File

@ -3283,3 +3283,88 @@ fn m4_12_default_bundle_after_load_robust_to_missing_server() {
.unwrap(); .unwrap();
let _ = saw_error; let _ = saw_error;
} }
// ---------------------------------------------------------------------------
// Reviewer-flagged item 2: pmacs.project.set_search_boundary
// ---------------------------------------------------------------------------
//
// End-to-end acceptance for the search-boundary clamp on the Lua
// surface. The Rust-side semantics are unit-tested in
// `src/project.rs::tests::search_boundary_*`; these tests cover the
// Lua function-call surface and the round-trip through the
// `pmacs.project.detect` binding.
#[test]
fn project_set_search_boundary_clamps_lua_detect_call() {
use pmacs::editor::EditorState;
// Stage an outer .git that detection would normally find, plus
// a workspace dir under it with a file but no marker.
let outer = tempfile::tempdir().expect("outer");
std::fs::create_dir_all(outer.path().join(".git")).expect("outer .git");
let workspace = outer.path().join("workspace");
std::fs::create_dir_all(workspace.join("src")).expect("workspace/src");
let f = workspace.join("src/main.rs");
std::fs::write(&f, b"").expect("touch file");
let state = EditorState::new();
let lua = state.lua_host.lua();
let workspace_str = workspace.display().to_string();
let f_str = f.display().to_string();
let (without_boundary, with_boundary): (Option<String>, Option<String>) = lua
.load(format!(
"
-- Without a boundary, detect walks up to the outer .git.
local before = pmacs.project.detect('{f_str}')
-- Clamp the walk to the workspace dir; the outer marker is now invisible.
pmacs.project.set_search_boundary('{workspace_str}')
local after = pmacs.project.detect('{f_str}')
return before and before.kind or nil, after and after.kind or nil
"
))
.eval()
.expect("detect sequence");
assert_eq!(
without_boundary.as_deref(),
Some("git"),
"without the boundary, the outer .git is detected"
);
assert!(
with_boundary.is_none(),
"with the boundary clamping at the workspace dir, the outer marker is invisible: {with_boundary:?}"
);
}
#[test]
fn project_search_boundary_round_trips_via_lua() {
use pmacs::editor::EditorState;
let dir = tempfile::tempdir().expect("dir");
let dir_str = dir.path().display().to_string();
let state = EditorState::new();
let lua = state.lua_host.lua();
let (initial, after_set, after_clear): (Option<String>, Option<String>, Option<String>) = lua
.load(format!(
"
local before = pmacs.project.search_boundary()
pmacs.project.set_search_boundary('{dir_str}')
local after = pmacs.project.search_boundary()
pmacs.project.set_search_boundary(nil)
local cleared = pmacs.project.search_boundary()
return before, after, cleared
"
))
.eval()
.expect("round trip");
assert!(initial.is_none(), "default boundary is nil");
assert!(
after_set.is_some(),
"set_search_boundary(path) must surface as a non-nil read"
);
assert!(
after_clear.is_none(),
"set_search_boundary(nil) must clear back to nil"
);
}

View File

@ -142,6 +142,54 @@ fn m6_4_set_prompt_replaces_prompt_region() {
"#); "#);
} }
#[test]
fn m6_4_region_boundaries_are_mark_backed_across_input_edits() {
run(r#"
local h = pmacs.repl.create({ name = "*test*" })
h:append_output("history\n")
h:set_prompt("$ ")
local buf = h:buffer_id()
local prompt_before = h:prompt_end()
-- User edits at the input boundary must not drag the prompt
-- boundary forward. This is the failure mode byte-offset
-- mirrors hid before real marks existed.
buf:insert(prompt_before, "abc")
assert(h:prompt_end() == prompt_before,
"prompt_end moved across input insert")
assert(h:input_text() == "abc", "input text after insert")
h:append_output("more\n")
assert(h:input_text() == "abc",
"input preserved after output before prompt")
local history = buf:slice(0, h:history_end())
assert(history == "history\nmore\n",
"history after append: " .. history)
"#);
}
#[test]
fn m6_4_osc_133_prompt_markers_route_text_to_prompt_region() {
run(r#"
local h = pmacs.repl.create({ name = "*test*" })
h:append_output("\27]133;A\7$ \27]133;B\7")
local buf = h:buffer_id()
assert(h:history_end() == 0,
"prompt marker text must not enter history")
assert(buf:slice(h:history_end(), h:prompt_end()) == "$ ",
"prompt region should hold shell prompt")
buf:insert(h:prompt_end(), "typed")
h:append_output("out\n")
assert(buf:slice(0, h:history_end()) == "out\n",
"later output should enter history")
assert(buf:slice(h:history_end(), h:prompt_end()) == "$ ",
"prompt remains prompt after history output")
assert(h:input_text() == "typed", "input preserved")
"#);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Acceptance bullet 3: read-only enforcement // Acceptance bullet 3: read-only enforcement
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -288,6 +336,38 @@ fn m6_4_ansi_styled_output_routes_to_history_only() {
.. content .. "'") .. content .. "'")
assert(not content:find("\27"), assert(not content:find("\27"),
"rope must not contain ESC bytes") "rope must not contain ESC bytes")
local spans = h:style_spans()
assert(#spans == 1, "expected one red style span, got " .. #spans)
assert(spans[1].start == 0 and spans[1]["end"] == 5,
"red span should cover hello, got [" .. spans[1].start ..
"," .. spans[1]["end"] .. ")")
assert(spans[1].style.fg == 1,
"red span should carry palette fg=1")
"#);
}
#[test]
fn m6_4_line_level_ansi_updates_history_in_place() {
run(r#"
local h = pmacs.repl.create({ name = "*test*" })
h:append_output("progress 10%")
h:append_output("\rprogress 20%\27[K")
local content = h:buffer_id():slice(0, h:buffer_id():len())
assert(content == "progress 20%",
"CR overwrite + erase-to-EOL should update in place: '" ..
content .. "'")
h:append_output("\r\27[2Kdone\n")
content = h:buffer_id():slice(0, h:buffer_id():len())
assert(content == "done\n",
"erase-line should clear current line before rewrite: '" ..
content .. "'")
h:append_output("abc\bZ")
content = h:buffer_id():slice(0, h:buffer_id():len())
assert(content == "done\nabZ",
"backspace should rewind within current line: '" ..
content .. "'")
"#); "#);
} }

597
tests/m7_3_acceptance.rs Normal file
View File

@ -0,0 +1,597 @@
// m7_3_acceptance.rs --- Acceptance suite for T M7.3 (`pmacs.packages.*`).
//! End-to-end acceptance tests for T M7.3 (`pmacs.packages.install`,
//! `pmacs.packages.install_project`).
//!
//! The three acceptance bullets from the task definition:
//!
//! 1. **User-config install of a sample package succeeds and the
//! package's entry module is requireable.** Tested here as
//! [`user_install_makes_entry_requireable`]. The test stages a
//! bare git repo containing a `pmacs.toml` + `init.lua`, redirects
//! the install machinery away from the developer's real
//! `$XDG_*` paths via [`PackageInstallOverride`], calls
//! `pmacs.packages.install`, and verifies that
//! `require("samplepkg")` returns the entry module's table.
//! 2. **Project install for a sample package isolates from
//! user-config.** Tested as
//! [`project_install_isolates_from_user_install`]. A user install
//! and a project install of the same upstream land in different
//! on-disk roots; both are listed in `pmacs.packages.installed()`.
//! 3. **Both variants are documented at the public Lua API surface
//! with `EmmyLua`-style annotations.** Verified by the static
//! [`emmylua_doc_file_exists`] check, which reads
//! `builtin/api/packages.lua` and confirms it contains `EmmyLua`
//! annotations for `install` and `install_project`.
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;
use pmacs::lua::LuaHost;
use pmacs::lua_bindings::PackageInstallOverride;
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Sample-package fixture
// ---------------------------------------------------------------------------
/// Build a sample-package bare repo whose entry is at `entry_path`
/// (relative to the package root) with the supplied Lua body.
/// Returns `(tempdir, bare_path)` --- the tempdir owns both the work
/// tree and the bare clone.
fn make_package_with_entry(
name: &str,
entry_path: &str,
entry_body: &str,
) -> (TempDir, PathBuf) {
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"),
]);
let manifest = format!(
"name = \"{name}\"\n\
version = \"1.0.0\"\n\
summary = \"acceptance fixture\"\n\
pmacs_required = \">= 0.1.0\"\n\
entry = \"{entry_path}\"\n\
exports = [\"{name}\"]\n"
);
std::fs::write(work.join("pmacs.toml"), manifest).expect("write pmacs.toml");
let entry_full = work.join(entry_path);
if let Some(parent) = entry_full.parent() {
std::fs::create_dir_all(parent).expect("mkdir entry parent");
}
std::fs::write(&entry_full, entry_body).expect("write entry");
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"),
]);
run_git(&[
OsStr::new("clone"),
OsStr::new("--bare"),
work.as_os_str(),
bare.as_os_str(),
]);
(td, bare)
}
/// Build a sample-package bare repo with a `pmacs.toml` + `init.lua`,
/// tagged `v1.0.0`. Returns `(tempdir, bare_path)`.
fn make_sample_package(name: &str) -> (TempDir, PathBuf) {
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"),
]);
let manifest = format!(
"name = \"{name}\"\n\
version = \"1.0.0\"\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");
std::fs::write(
work.join("init.lua"),
format!("return {{ name = '{name}', version = '1.0.0' }}\n"),
)
.expect("write init.lua");
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"),
]);
run_git(&[
OsStr::new("clone"),
OsStr::new("--bare"),
work.as_os_str(),
bare.as_os_str(),
]);
(td, bare)
}
fn run_git(args: &[&OsStr]) {
let mut cmd = Command::new("git");
for a in args {
cmd.arg(a);
}
cmd.env("GIT_TERMINAL_PROMPT", "0");
cmd.env("LC_ALL", "C");
let out = cmd.output().expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn file_url(p: &Path) -> String {
format!("file://{}", p.display())
}
/// Build a [`LuaHost`] with the package-install machinery redirected
/// to per-test tempdirs. Returns the host plus the paths so the test
/// can introspect what landed where.
fn host_with_overrides() -> (LuaHost, TempDir, TempDir) {
let cache = tempfile::tempdir().expect("cache tempdir");
let user_root = tempfile::tempdir().expect("user-root tempdir");
let host = LuaHost::new().expect("LuaHost::new");
host.set_package_install_override(
PackageInstallOverride::new()
.with_cache_dir(cache.path().to_path_buf())
.with_user_install_root(user_root.path().to_path_buf()),
);
(host, cache, user_root)
}
// ---------------------------------------------------------------------------
// Acceptance bullet 1: user-config install + entry requireable.
// ---------------------------------------------------------------------------
#[test]
fn user_install_makes_entry_requireable() {
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}",
version = "^1.0.0",
}}
assert(installed.name == "samplepkg",
"expected name == samplepkg, got " .. tostring(installed.name))
assert(installed.version == "1.0.0",
"expected version 1.0.0, got " .. tostring(installed.version))
assert(installed.scope == "user",
"expected scope user, got " .. tostring(installed.scope))
-- The acceptance bullet: the entry module is requireable.
local mod = require("samplepkg")
assert(mod.name == "samplepkg",
"require returned wrong table: " .. tostring(mod.name))
assert(mod.version == "1.0.0",
"require returned wrong version: " .. tostring(mod.version))
-- The roster reflects the install.
local list = pmacs.packages.installed()
assert(#list == 1, "expected 1 installed package, got " .. tostring(#list))
assert(list[1].name == "samplepkg")
"#,
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("install + require failed: {e}");
});
assert!(
host.errors().is_empty(),
"errors after install: {:?}",
host.errors()
);
}
// ---------------------------------------------------------------------------
// Acceptance bullet 2: project install isolates from user install.
// ---------------------------------------------------------------------------
#[test]
fn project_install_isolates_from_user_install() {
let (_pkg_td, bare) = make_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, user_root) = host_with_overrides();
// The project root is its own tempdir, distinct from the user root.
let project = tempfile::tempdir().expect("project tempdir");
let project_root = project.path().display().to_string();
let user_root_str = user_root.path().display().to_string();
let script = format!(
r#"
local user = pmacs.packages.install {{
"git:{url}",
version = "^1.0.0",
}}
local proj = pmacs.packages.install_project {{
"git:{url}",
version = "^1.0.0",
project_root = "{project_root}",
}}
assert(user.scope == "user", "user scope")
assert(proj.scope == "project", "project scope")
assert(user.install_path ~= proj.install_path,
"install paths must differ: " .. user.install_path .. " vs " .. proj.install_path)
assert(string.find(proj.install_path, "{project_root}", 1, true) ~= nil,
"project path should be under project_root: " .. proj.install_path)
assert(string.find(user.install_path, "{user_root_str}", 1, true) ~= nil,
"user path should be under user_root: " .. user.install_path)
-- Both records present.
local list = pmacs.packages.installed()
assert(#list == 2, "expected 2, got " .. tostring(#list))
"#,
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("install isolation failed: {e}");
});
assert!(
host.errors().is_empty(),
"errors after dual install: {:?}",
host.errors()
);
}
// ---------------------------------------------------------------------------
// Acceptance bullet 3: EmmyLua annotations exist.
// ---------------------------------------------------------------------------
#[test]
fn emmylua_doc_file_exists() {
// The acceptance bullet says "documented at the public Lua API
// surface with `EmmyLua`-style annotations". This test pins that
// documentation file so it stays in lockstep with the binding.
let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("builtin/api/packages.lua");
let s = std::fs::read_to_string(&p)
.unwrap_or_else(|e| panic!("missing EmmyLua doc file at {}: {e}", p.display()));
assert!(
s.contains("@param"),
"EmmyLua doc file must contain @param annotations"
);
assert!(s.contains("install"), "doc file must document `install`");
assert!(
s.contains("install_project"),
"doc file must document `install_project`"
);
}
// ---------------------------------------------------------------------------
// Init-time-only gate.
// ---------------------------------------------------------------------------
#[test]
fn install_after_init_complete_errors_with_workaround() {
let mut host = LuaHost::new().expect("LuaHost::new");
host.set_init_complete();
let err = host
.eval(
Some("test"),
r#"pmacs.packages.install { "github:user/repo", version = "*" }"#,
)
.expect_err("post-init install must error");
let msg = err.to_string();
assert!(
msg.contains("pmacs.packages.install"),
"error must name the op: {msg}"
);
assert!(
msg.contains("init.lua"),
"error must name the right phase: {msg}"
);
}
// ---------------------------------------------------------------------------
// Spec-shape parsing.
// ---------------------------------------------------------------------------
#[test]
fn shorthand_string_form_is_accepted() {
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}@^1.0.0")
assert(installed.name == "samplepkg")
"#,
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("shorthand form failed: {e}");
});
}
#[test]
fn install_spec_missing_address_errors() {
let mut host = LuaHost::new().expect("LuaHost::new");
let err = host
.eval(Some("test"), r#"pmacs.packages.install { version = "*" }"#)
.expect_err("missing address must error");
let msg = err.to_string();
assert!(
msg.contains("address"),
"error should name the missing field: {msg}"
);
}
// ---------------------------------------------------------------------------
// Reviewer-flagged item 10: install_project requires explicit project_root.
// ---------------------------------------------------------------------------
#[test]
fn install_project_without_project_root_errors_with_workaround() {
// Pre-v0.1 the missing field silently fell back to
// `std::env::current_dir()`. That was a footgun: CWD-at-startup
// is whatever shell directory the user happened to invoke pmacs
// from, almost never a meaningful project root. The fallback is
// gone; the binding now requires an explicit field and the error
// message names two concrete patterns for filling it in
// (env-var lookup, init.lua-relative path).
let mut host = LuaHost::new().expect("LuaHost::new");
let err = host
.eval(
Some("test"),
r#"pmacs.packages.install_project { "git:does-not-matter", version = "^1.0" }"#,
)
.expect_err("missing project_root must error");
let msg = err.to_string();
assert!(
msg.contains("project_root"),
"error must name the missing field: {msg}"
);
assert!(
msg.contains("install_project"),
"error must name the op: {msg}"
);
// Two concrete patterns the user can apply without hunting for
// documentation. Both are mentioned in the error text so a CI
// log line stands on its own.
assert!(
msg.contains("PMACS_PROJECT") || msg.contains("os.getenv"),
"error should hint at env-var pattern: {msg}"
);
assert!(
msg.contains("init.lua"),
"error should hint at init.lua-relative pattern: {msg}"
);
}
#[test]
fn install_project_relative_path_resolves_against_init_lua_dir() {
// A relative `project_root` value resolves against the
// directory of the loading chunk (the convention for
// file-loaded `init.lua`). The chunk source label
// `@<absolute-path>` is the standard hook --- `LuaHost::eval`
// sets it via `set_name`, and `debug.getinfo("S").source`
// reads it back.
//
// Setup: write a transient `init.lua` under tempdir-A, set
// `project_root = "subproj"` from inside it (a relative path),
// and assert the install lands at `tempdir-A/subproj/.pmacs/...`.
let (_pkg_td, bare) = make_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let init_dir = tempfile::tempdir().expect("init dir");
let init_path = init_dir.path().join("init.lua");
let init_label = format!("@{}", init_path.display());
let script = format!(
r#"
local p = pmacs.packages.install_project {{
"git:{url}",
version = "^1.0.0",
project_root = "subproj",
}}
return p.install_path
"#,
);
let value = host
.eval(Some(&init_label), &script)
.unwrap_or_else(|e| panic!("relative project_root install failed: {e}"));
let install_path = match value {
mlua::Value::String(s) => s.to_str().expect("string utf8").to_string(),
other => panic!("expected install_path string, got {other:?}"),
};
let expected_prefix = init_dir.path().join("subproj");
assert!(
install_path.starts_with(&expected_prefix.display().to_string()),
"expected install under {expected_prefix:?}, got {install_path:?}"
);
}
// ---------------------------------------------------------------------------
// Reviewer-flagged item 7: custom entry paths must be requireable.
// ---------------------------------------------------------------------------
//
// A package whose manifest declares `entry = "main.lua"` (or any
// non-`init.lua` path) cannot be loaded via the standard
// `?.lua;?/init.lua` `package.path` pattern alone --- the path
// search misses the entry file. The custom searcher in
// `lua_bindings::register_package_searcher` closes the gap by
// mapping `require("<basename>")` directly to the manifest's
// declared entry path.
#[test]
fn package_with_main_lua_entry_is_requireable() {
// Entry file at `main.lua` (not the conventional `init.lua`).
// The path-based searcher misses it; the custom searcher must
// route `require("samplepkg")` to `<install>/main.lua`.
let (_pkg_td, bare) = make_package_with_entry(
"samplepkg",
"main.lua",
"return { name = 'samplepkg', via = 'main.lua' }\n",
);
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}",
version = "^1.0.0",
}}
assert(installed.entry:sub(-#"main.lua") == "main.lua",
"manifest entry must be main.lua, got " .. installed.entry)
local mod = require("samplepkg")
assert(mod.name == "samplepkg",
"custom searcher should have loaded main.lua, got: " .. tostring(mod.name))
assert(mod.via == "main.lua",
"module body must be the contents of main.lua, got via=" .. tostring(mod.via))
"#
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("require(samplepkg) with entry=main.lua failed: {e}");
});
}
#[test]
fn package_with_nested_entry_path_is_requireable() {
// Entry at `lib/foo.lua` --- a package layout where the
// user-facing module lives a couple of levels deep. The custom
// searcher must read it from the manifest and resolve
// `require("samplepkg")` to the nested file.
let (_pkg_td, bare) = make_package_with_entry(
"samplepkg",
"lib/foo.lua",
"return { name = 'samplepkg', via = 'lib/foo.lua' }\n",
);
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}",
version = "^1.0.0",
}}
assert(installed.entry:sub(-#"lib/foo.lua") == "lib/foo.lua",
"manifest entry must be lib/foo.lua, got " .. installed.entry)
local mod = require("samplepkg")
assert(mod.via == "lib/foo.lua",
"module body must be the contents of lib/foo.lua, got via=" .. tostring(mod.via))
"#
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("require(samplepkg) with nested entry failed: {e}");
});
}
#[test]
fn searcher_misses_for_unknown_name_with_pmacs_specific_message() {
// No installed package matches `unrelatedpkg`. The custom
// searcher returns its "no installed pmacs package named ..."
// string, which Lua appends to the standard require-failure
// chain. The point: a `require` that fails with an obvious
// typo produces an error message that names the pmacs-side
// searcher's contribution to the search, so a user can see
// why the install they thought they did did not satisfy this
// require.
let mut host = LuaHost::new().expect("LuaHost::new");
let err = host
.eval(
Some("test"),
r#"
local mod = require("unrelatedpkg")
return mod
"#,
)
.expect_err("require for unknown name must error");
let msg = err.to_string();
assert!(
msg.contains("unrelatedpkg"),
"error must echo the require name: {msg}"
);
assert!(
msg.contains("no installed pmacs package"),
"error must mention the pmacs searcher's contribution: {msg}"
);
}