diff --git a/Cargo.lock b/Cargo.lock index 3ce4e6f..22b999f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,11 +563,13 @@ dependencies = [ "postcard", "proptest", "rmp-serde", + "semver", "serde", "serde_json", "signal-hook", "tempfile", "thiserror 2.0.18", + "toml", "tree-sitter", "tree-sitter-lua", "tree-sitter-rust", @@ -847,6 +849,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -892,6 +898,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serial2" version = "0.2.36" @@ -1047,6 +1062,47 @@ dependencies = [ "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]] name = "tree-sitter" version = "0.26.8" @@ -1300,6 +1356,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" diff --git a/Cargo.toml b/Cargo.toml index 20425f5..959fb6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,14 @@ portable-pty = "0.9" # (spec §5.2). `preserve_order` is off --- LSP doesn't require it # and the default `BTreeMap` keeps allocation low. 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] proptest = "1" diff --git a/README.md b/README.md index 9847eb8..59d737f 100644 --- a/README.md +++ b/README.md @@ -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 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/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 `, 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 - **Editor core.** Persistent rope with O(log N) edits and snapshots; diff --git a/builtin/api/packages.lua b/builtin/api/packages.lua new file mode 100644 index 0000000..8d6dac4 --- /dev/null +++ b/builtin/api/packages.lua @@ -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()`. +--- +--- Resolution path: standard layouts (`.lua`, +--- `/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 (`/.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 diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index a3f7fd9..405a590 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -44,6 +44,47 @@ cmd { name = "buffer.delete-backward", description = "Delete the codepoint befor fn = function() ed.backspace() end } cmd { name = "buffer.delete-forward", description = "Delete the codepoint at the cursor.", 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.", fn = function() ed.insert_char(10) end } cmd { name = "buffer.tab", description = "Insert a tab at the cursor.", diff --git a/builtin/keymaps/default.lua b/builtin/keymaps/default.lua index 9446737..e2b01ad 100644 --- a/builtin/keymaps/default.lua +++ b/builtin/keymaps/default.lua @@ -52,6 +52,40 @@ bind("C-d", "buffer.delete-forward") bind("RET", "buffer.newline") 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-", "cursor.select-left") +bind("S-", "cursor.select-right") +bind("S-", "cursor.select-up") +bind("S-", "cursor.select-down") +bind("S-", "cursor.select-line-start") +bind("S-", "cursor.select-line-end") +bind("C-S-", "cursor.select-word-left") +bind("C-S-", "cursor.select-word-right") + -- Undo / redo ---------------------------------------------------------------- -- -- Multiple undo bindings exist because terminals translate Ctrl+/ diff --git a/builtin/runtime/repl.lua b/builtin/runtime/repl.lua index 4ec4cb0..3b336f6 100644 --- a/builtin/runtime/repl.lua +++ b/builtin/runtime/repl.lua @@ -4,26 +4,15 @@ -- intercept that enforces read-only / truncate-to-input policy. -- 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 --- mark primitive doesn't yet exist; M6.4 tracks region boundaries as --- byte offsets stored on the handle (`_history_end`, `_prompt_end`). --- This works correctly because: --- --- * intercept_edit runs *before* the rope mutates. The package --- either pre-decides positions (its own write paths) or vetoes --- (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. +-- The history/prompt boundaries are backed by core buffer marks. +-- `_history_end` and `_prompt_end` remain as compatibility mirrors +-- for tests and package introspection, but the authoritative positions +-- are `_history_end_mark` and `_prompt_end_mark`. This matters for +-- process prompts: user edits in the input region must not accidentally +-- move the prompt boundary, while package output inserted before the +-- prompt must move both boundaries with the rope. -- -- # Self-write bypass -- @@ -130,6 +119,8 @@ local function new_handle(buffer_id) return setmetatable({ _buf = buffer_id, _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, _prompt_end = 0, -- Latest SetStyle observed. M6.4 doesn't render this anywhere @@ -139,6 +130,9 @@ local function new_handle(buffer_id) _current_style = nil, _alt_screen = false, _title = nil, + _output_pos = 0, + _capture = "history", + _style_overlay = nil, _self_write = false, _intercept_handle = nil, -- Scrollback block index (M6.7). The first block is degenerate @@ -154,6 +148,33 @@ local function new_handle(buffer_id) }, Handle) 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 -- --------------------------------------------------------------------- @@ -166,6 +187,9 @@ function repl.create(opts) h._intercept_handle = pmacs.buffer.add_intercept(buf, function(op) return repl._intercept(h, op) end) + if pmacs.buffer.add_style_overlay then + h._style_overlay = pmacs.buffer.add_style_overlay(buf) + end return h end @@ -199,6 +223,24 @@ local function basename(s) return (s:gsub("^.*/", "")) 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) opts = opts or {} local argv = validate_argv(opts.argv) @@ -220,9 +262,14 @@ function repl.spawn(opts) command = argv[1], args = args, pty = { rows = rows, cols = cols, mode = "raw" }, + ansi = true, } 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) h._proc_id = proc_id @@ -236,6 +283,9 @@ function repl.spawn(opts) if pmacs.window and pmacs.window.switch_buffer then pcall(pmacs.window.switch_buffer, h._buf) 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 -- process; C-c sends SIGINT; C-d closes stdin (when input empty) @@ -295,11 +345,11 @@ function Handle:buffer_id() end function Handle:history_end() - return self._history_end + return history_end(self) end function Handle:prompt_end() - return self._prompt_end + return prompt_end(self) end function Handle:title() @@ -307,13 +357,18 @@ function Handle:title() end 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 function Handle:alt_screen_active() return self._alt_screen end +function Handle:style_spans() + if not self._style_overlay then return {} end + return self._style_overlay:spans() +end + -- --------------------------------------------------------------------- -- Package-driven writes -- --------------------------------------------------------------------- @@ -325,11 +380,18 @@ end -- suppression at the parser level (so Text events between markers -- never reach us). 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 local kind = ev.kind 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 self._current_style = ev.style elseif kind == "alt_screen_enter" then @@ -338,12 +400,28 @@ function Handle:append_output(bytes) self._alt_screen = false elseif kind == "set_title" then self._title = ev.title - -- carriage_return, backspace, erase_to_eol, erase_line, - -- bracketed_paste_*: parsed-and-acknowledged in M6.4. Their - -- semantic effects (CR rewinds input cursor, erase rewrites - -- in-place output, etc.) are M6.5+ refinements where they - -- meet a real shell. M6.4's "synthetic stream" tests don't - -- exercise them. + elseif kind == "prompt_start" then + self:_begin_prompt_capture() + elseif kind == "prompt_end" then + self:_end_prompt_capture() + elseif kind == "command_start" or kind == "output_start" then + 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 @@ -352,10 +430,13 @@ end -- untouched; the input region is preserved (it sits past prompt_end). function Handle:set_prompt(text) text = text or "" + local h_end = history_end(self) + local p_end = prompt_end(self) with_self_write(self, function() - self._buf:replace(self._history_end, self._prompt_end, text) + self._buf:replace(h_end, p_end, text) end) - self._prompt_end = self._history_end + #text + set_prompt_end(self, history_end(self) + #text) + sync_marks(self) end -- Pop the input region's text. Returns the popped string. Does NOT @@ -368,12 +449,14 @@ end -- start_byte invariant. function Handle:submit() local text = self:input_text() + local p_end = prompt_end(self) with_self_write(self, function() - self._buf:delete(self._prompt_end, self._buf:len()) + self._buf:delete(p_end, self._buf:len()) end) local last = self._blocks[#self._blocks] - if self._history_end > last.start_byte then - self._blocks[#self._blocks + 1] = { start_byte = self._history_end } + local h_end = history_end(self) + if h_end > last.start_byte then + self._blocks[#self._blocks + 1] = { start_byte = h_end } end return text end @@ -384,18 +467,132 @@ end function Handle:_emit_history(text) 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() - 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) - local n = #text - self._history_end = self._history_end + n - self._prompt_end = self._prompt_end + n + if insert_len > 0 then + self:_adjust_blocks_after_edit(pos + overwrite_len, 0, insert_len) + 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. -- Per-byte work beyond this assignment regresses the M6.6 100 MB/s -- ingest gate; line counting is deferred to _maybe_truncate. self._dirty_since_last_tick = true 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) -- --------------------------------------------------------------------- @@ -420,7 +617,7 @@ end -- (16 MiB / 10000 lines), and skipped entirely by the byte-only -- shortcut in within_limits. 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 -- Both invariants in one predicate, with a fast path that avoids the @@ -431,8 +628,9 @@ end -- pay for the line scan. local function within_limits(h) local cfg = repl.config - if h._history_end > cfg.scrollback_bytes then return false end - if h._history_end <= cfg.scrollback_lines then return true end + local h_end = history_end(h) + 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 end @@ -449,8 +647,8 @@ local function drop_oldest_block(h) with_self_write(h, function() h._buf:delete(first.start_byte, second.start_byte) end) - h._history_end = h._history_end - removed_bytes - h._prompt_end = h._prompt_end - removed_bytes + sync_marks(h) + h._output_pos = math.max(0, (h._output_pos or history_end(h)) - removed_bytes) table.remove(h._blocks, 1) for i = 1, #h._blocks do 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 return nil end - local prompt_end = h._prompt_end + local prompt_end = prompt_end(h) if op.kind == "insert" then if op.pos < prompt_end then 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 -- append_output preserves user output rather than dropping it. h:append_output(ev.bytes) + elseif kind == "ansi" then + h:append_events(ev.events) elseif kind == "exited" or kind == "signaled" or kind == "crashed" then h:_on_exit(ev) end @@ -672,9 +872,9 @@ pmacs.command.define { -- 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 -- shells with a line editor interpret that as end-of-input. Non-empty --- case delegates to the existing pmacs.editor.delete_forward primitive --- (which the M6.4 intercept policy guards: deletes inside the input --- region pass through, deletes into prompt/history are rejected). +-- case deletes through the REPL buffer at the cursor when it is inside +-- the input region, falling back to the input start if the editor +-- cursor is stale/outside the region. pmacs.command.define { name = "pmacs.repl.send-eof-current", description = "Close stdin on empty input region; delete-char-forward otherwise.", @@ -685,7 +885,11 @@ pmacs.command.define { if h:input_text() == "" then pmacs.process.write_stdin(h._proc_id, "\x04") 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, } diff --git a/docs/packages.md b/docs/packages.md new file mode 100644 index 0000000..8df0ec7 --- /dev/null +++ b/docs/packages.md @@ -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//` (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("")` 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 `/.pmacs/packages//`. 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 `@` +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. diff --git a/docs/project.md b/docs/project.md new file mode 100644 index 0000000..5e40240 --- /dev/null +++ b/docs/project.md @@ -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. diff --git a/src/ansi.rs b/src/ansi.rs index 0f5ce2a..8ec4d24 100644 --- a/src/ansi.rs +++ b/src/ansi.rs @@ -77,6 +77,14 @@ pub enum AnsiEvent { /// Set the window title (OSC 0 / OSC 2 with terminator). /// Exposed as a per-buffer attribute by the M6.4 REPL view. 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. BracketedPasteBegin, /// `CSI 201 ~`: a process-emitted bracketed-paste end marker. @@ -286,8 +294,20 @@ pub struct AnsiParser { ignore_byte_count: usize, /// In-progress text run accumulator. Flushed as a single /// [`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, + /// 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, /// Suppress `Text` and `SetStyle` events while alternate-screen /// is active. Spec §sec:ansi-scope: parser advances state /// normally but emits no payload. @@ -321,6 +341,7 @@ impl AnsiParser { current_style: Style::default(), ignore_byte_count: 0, text_run: String::new(), + utf8_buf: Vec::new(), alt_screen_active: false, csi: CsiCollector::default(), osc_body: Vec::new(), @@ -336,6 +357,7 @@ impl AnsiParser { self.state = State::Ground; self.ignore_byte_count = 0; self.text_run.clear(); + self.utf8_buf.clear(); self.csi.reset(); self.osc_body.clear(); self.escape_intermediates.clear(); @@ -354,7 +376,20 @@ impl AnsiParser { // resolve and the bytes belong to it). Since we only build // text_run while in Ground, this is safe to flush // 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 } @@ -425,6 +460,11 @@ impl AnsiParser { // ----------------------------------------------------------------------- fn flush_text_run(&mut self, events: &mut Vec) { + // 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() { return; } @@ -498,58 +538,125 @@ impl AnsiParser { // line break in the rope; HT as a literal tab. Other // C0 controls (0x00..=0x06, 0x0E..=0x1F) and DEL // (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 => {} - // 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. - 0x80..=0xFF => { - // Append as a raw byte. We store text_run as a - // String, so push UTF-8 bytes via a fallback path: - // accumulate into a Vec first if we hit non-ASCII. - // For simplicity, push the byte and let - // String::push_byte handle it via a helper. - self.push_text_byte(b); + } + } + + /// Append a single text byte, handling multi-byte UTF-8 + /// correctly across feed boundaries. + /// + /// ASCII bytes (0x00..=0x7F) take a fast path directly into + /// `text_run` when no partial sequence is pending. Non-ASCII + /// bytes (0x80..=0xFF) and any byte arriving while a partial + /// sequence is pending go through `utf8_buf`, which is then + /// 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 - /// continuation correctly. ASCII bytes go directly; non-ASCII - /// bytes accumulate in a pending UTF-8 buffer that flushes - /// once a complete scalar arrives or recovers as U+FFFD on a - /// malformed sequence. - fn push_text_byte(&mut self, b: u8) { - // For correctness across multi-byte UTF-8 split across feeds - // we'd need a stateful UTF-8 decoder. For v0.1 / M6.3, we - // append the byte as-is by reinterpreting the String's - // backing buffer: since we always hit this path after an - // ASCII run, and the only non-ASCII source is text, the - // simple safe approach is to accumulate raw bytes in a - // 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. + /// Drain any pending UTF-8 prefix as `U+FFFD`. Called from + /// `flush_text_run` when the text run is being committed + /// because we're transitioning out of Ground (a control byte, + /// a CSI start, etc.). At that boundary, an unfinished + /// multi-byte sequence is genuinely interrupted --- it can't + /// continue across the non-text bytes --- so we emit the + /// replacement character and clear. + /// + /// Not called at end-of-feed: a sequence interrupted by feed + /// boundary may legitimately resume in the next feed. + fn flush_pending_utf8_as_replacement(&mut self) { + if !self.utf8_buf.is_empty() { self.text_run.push('\u{FFFD}'); + self.utf8_buf.clear(); } } @@ -896,10 +1003,22 @@ impl AnsiParser { let num: Option = std::str::from_utf8(num_part) .ok() .and_then(|s| s.parse().ok()); - // Only OSC 0 (set icon name + window title) and OSC 2 (set - // window title) produce a SetTitle event. 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(133)) && !self.alt_screen_active { + match text_part.first().copied() { + Some(b'A') => events.push(AnsiEvent::PromptStart), + 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 { let title = String::from_utf8_lossy(text_part).into_owned(); events.push(AnsiEvent::SetTitle(title)); @@ -1256,6 +1375,33 @@ mod tests { 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 // ----------------------------------------------------------------- @@ -1383,6 +1529,177 @@ mod tests { // 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 --- /// the cell `Style` has no strikethrough field, so the running /// style must be *unchanged* after SGR 9. A future regression diff --git a/src/attach.rs b/src/attach.rs index 6f11995..0fd56dc 100644 --- a/src/attach.rs +++ b/src/attach.rs @@ -1220,6 +1220,27 @@ fn classify_ssh_exit( mod tests { 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 { + 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] fn format_uptime_shapes() { assert_eq!(format_uptime(5), "5s"); @@ -1474,7 +1495,9 @@ mod tests { fn version_mismatch_errors_at_construction_site() { let tmp = tempfile::tempdir().expect("tempdir"); 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 // bogus protocol version, exit. The accept blocks until diff --git a/src/buffer.rs b/src/buffer.rs index b8e022e..79b88a6 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -29,7 +29,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use crate::rope::{Edit, Position, Range, Rope, RopeError}; -use crate::view::View; +use crate::view::{InterceptContext, View}; // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- @@ -156,10 +187,24 @@ pub struct Buffer { views: Vec<(ViewId, Box)>, /// Per-buffer counter for [`ViewId`] allocation. 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: Vec, /// Redo stack. Cleared by any forward edit. redo: Vec, + /// 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 { @@ -193,8 +238,11 @@ impl Buffer { revision: 0, views: Vec::new(), next_view_id: 0, + marks: Vec::new(), + next_mark_id: 0, undo: Vec::new(), redo: Vec::new(), + editing_in_progress: false, } } @@ -290,6 +338,126 @@ impl Buffer { 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 { + 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 { + 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 { + 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)> { + 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)>) { + 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. /// /// 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 /// 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. pub fn apply_edit(&mut self, op: EditOp<'_>) -> Result { + 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. // The buffer is left view-less only for the duration of this call; // panics during it would leave an empty view list (acceptable: views @@ -313,6 +497,29 @@ impl Buffer { 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 { + 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( &mut self, views: &mut [(ViewId, Box)], @@ -320,12 +527,22 @@ impl Buffer { ) -> Result { // Stage 1: intercept chain. let mut current = op; + let ctx = InterceptContext::snapshot(self); 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, ¤t) + } + + fn run_rope_edit_and_broadcast( + &mut self, + views: &mut [(ViewId, Box)], + current: &EditOp<'_>, + ) -> Result { // Stage 2: rope edit. - let edit = match ¤t { + let edit = match current { EditOp::Insert { pos, bytes } => self.rope.insert(*pos, bytes)?, EditOp::Delete { range } => self.rope.delete(range.start, range.end)?, EditOp::Replace { range, bytes } => self.rope.replace(range.start, range.end, bytes)?, @@ -350,6 +567,7 @@ impl Buffer { let pre_range = edit.range; let inserted_len = edit.inserted_len; 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 { rope: old_rope, edit: EditDescription { @@ -391,6 +609,7 @@ impl Buffer { let new_rope = entry.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 { rope: old_rope, edit: EditDescription { @@ -428,6 +647,7 @@ impl Buffer { let new_rope = entry.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 { rope: old_rope, edit: EditDescription { @@ -458,6 +678,43 @@ impl Buffer { self.views = views; 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. 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 { fn intercept_edit<'a>( &mut self, - buf: &Buffer, + ctx: &crate::view::InterceptContext, op: EditOp<'a>, ) -> Result, BufferError> { - self.events - .lock() - .unwrap() - .push(RecorderEvent::Intercept { pre_len: buf.len() }); + self.events.lock().unwrap().push(RecorderEvent::Intercept { + pre_len: ctx.buf_len, + }); Ok(op) } fn on_edit(&mut self, buf: &Buffer, edit: &Edit) -> Result<(), BufferError> { @@ -550,7 +827,7 @@ mod tests { impl View for ReverseInsertView { fn intercept_edit<'a>( &mut self, - _buf: &Buffer, + _ctx: &crate::view::InterceptContext, op: EditOp<'a>, ) -> Result, BufferError> { // Cannot return EditOp with owned bytes given the lifetime @@ -621,6 +898,58 @@ mod tests { 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] fn intercept_runs_before_on_edit_and_before_rope_mutation() { let mut b = Buffer::from_bytes(BufferId::next(), "test", b"hi"); diff --git a/src/buffer_registry.rs b/src/buffer_registry.rs index 8523215..b27ac5f 100644 --- a/src/buffer_registry.rs +++ b/src/buffer_registry.rs @@ -35,6 +35,24 @@ pub enum RegistryError { /// the Lua boundary (R52). 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. @@ -100,7 +118,23 @@ impl BufferRegistry { /// Remove and return the buffer behind `id`. Subsequent lookups of /// `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 { + // 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 .buffers .remove(&id) @@ -178,8 +212,12 @@ mod tests { let r = BufferRegistry::new(); let stale = BufferId::next(); let err = r.get(stale).err().expect("expected stale-handle error"); - let RegistryError::Missing { id } = err; - assert_eq!(id, stale); + match err { + RegistryError::Missing { id } => assert_eq!(id, stale), + other @ RegistryError::ConcurrentEdit { .. } => { + panic!("expected Missing, got {other:?}") + } + } } #[test] diff --git a/src/daemon_attach.rs b/src/daemon_attach.rs index 00528b5..f602eb9 100644 --- a/src/daemon_attach.rs +++ b/src/daemon_attach.rs @@ -399,6 +399,31 @@ mod tests { use std::sync::mpsc; 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 { + 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. /// /// Setup: @@ -415,7 +440,9 @@ mod tests { fn bridge_round_trips_bytes_through_daemon() { let tmp = tempfile::tempdir().unwrap(); 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. let daemon = thread::spawn(move || { @@ -581,7 +608,9 @@ mod tests { fn ensure_running_returns_immediately_when_daemon_already_listening() { let tmp = tempfile::tempdir().unwrap(); 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 sc = spawner_called.clone(); @@ -607,6 +636,18 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); 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 // hold the listener long enough for `ensure_running` to see // it. The spawner returns Ok as soon as the worker is diff --git a/src/editor.rs b/src/editor.rs index 8da30e6..f71556e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -495,7 +495,10 @@ impl EditorState { // command which mutates the core). let mut args = mlua::MultiValue::new(); 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::(args) { self.core.borrow_mut().status = format!( @@ -618,7 +621,10 @@ impl EditorState { core.windows[&win_id].text_view.display_to_pos(buf, target) }; 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.goal_col = None; } @@ -674,7 +680,10 @@ impl EditorState { .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; if let Some(p) = new_cursor { aw.cursor = p; @@ -915,7 +924,10 @@ pub fn paint_frame( let reg = registry.borrow(); let buf_id = core.active_buffer_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 .text_view .pos_to_display(buf, aw.cursor) diff --git a/src/editor_core.rs b/src/editor_core.rs index 6bfbe34..3dfed05 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -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 /// active window's cursor to the new length and notify all /// windows on this buffer. @@ -1210,6 +1263,47 @@ mod tests { 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] fn multibyte_navigation() { let mut s = from_bytes("héllo".as_bytes()); diff --git a/src/frontend.rs b/src/frontend.rs index d84627d..7f929b1 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -182,8 +182,20 @@ impl Frontend { // literal `/` with CONTROL instead of the byte-roulette legacy // protocols produce. Terminals that don't ignore the CSI; we // 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() { me.keyboard_enhancement = true; } diff --git a/src/key.rs b/src/key.rs index 8fb2a78..a8bc649 100644 --- a/src/key.rs +++ b/src/key.rs @@ -60,18 +60,32 @@ impl Chord { /// Canonicalization rules: /// * `KeyCode::Char('A')` with `SHIFT` --- the SHIFT bit is /// 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 /// (`/`, `1`, ...) leaves modifiers as-is. #[must_use] pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self { - let mods = match code { - KeyCode::Char(ch) if ch.is_ascii_uppercase() => modifiers - KeyModifiers::SHIFT, - _ => modifiers, + let (code, modifiers) = match code { + KeyCode::Char(ch) if ch.is_ascii_uppercase() => (code, modifiers - KeyModifiers::SHIFT), + KeyCode::Char(ch) + if ch.is_ascii_lowercase() && modifiers.contains(KeyModifiers::SHIFT) => + { + ( + KeyCode::Char(ch.to_ascii_uppercase()), + modifiers - KeyModifiers::SHIFT, + ) + } + _ => (code, modifiers), }; - Self { - code, - modifiers: mods, - } + Self { code, modifiers } } /// Build a plain unmodified chord. @@ -433,6 +447,38 @@ mod tests { 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] fn display_round_trips_canonical_form() { let cases = [ diff --git a/src/lib.rs b/src/lib.rs index 0ebcdec..97098d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,7 @@ pub mod lua_bindings; pub mod message_bus; pub mod minibuffer; pub mod overlay; +pub mod packages; pub mod process; pub mod project; pub mod project_index; diff --git a/src/lsp.rs b/src/lsp.rs index 631dc8c..77fcd58 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -1088,6 +1088,15 @@ impl LspManager { ProcessEventKind::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 } => { self.on_exit(sid, ev.at, format!("exit code {code}"), code == 0); } diff --git a/src/lua.rs b/src/lua.rs index fbf725d..4269923 100644 --- a/src/lua.rs +++ b/src/lua.rs @@ -33,8 +33,9 @@ pub const ERRORS_BUFFER_NAME: &str = "*errors*"; use crate::command::CommandRegistry; use crate::keymap_stack::KeymapStack; use crate::lua_bindings::{ - self, CurrentAttachmentSlot, InitCompleteFlag, LocalInstanceInfo, RequestedAttach, - SharedCommandRegistry, SharedCore, SharedHookRegistry, SharedKeymapStack, SharedRegistry, + self, CurrentAttachmentSlot, InitCompleteFlag, LocalInstanceInfo, PackageInstallOverride, + RequestedAttach, SharedCommandRegistry, SharedCore, SharedHookRegistry, SharedKeymapStack, + SharedRegistry, }; use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity}; @@ -274,6 +275,17 @@ impl LuaHost { /// `source` is an optional label (file path, chunk name) used in /// diagnostics; Lua reports it back in stack traces. pub fn eval(&mut self, source: Option<&str>, chunk: &str) -> mlua::Result { + // 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); if let Some(name) = source { loader = loader.set_name(name); @@ -438,6 +450,17 @@ impl LuaHost { .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()` /// (M5.6f). /// diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index b10e9dd..f7862ec 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -44,12 +44,12 @@ use std::rc::Rc; use mlua::{FromLua, Function, Lua, Table, UserData, UserDataMethods, Value, Variadic}; use thiserror::Error; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use crate::async_runtime::{ AsyncRuntime, GrepMatch, GrepSpec, JobOutcome, JobResult, SharedAsyncRuntime, StreamPayload, }; -use crate::buffer::{BufferId, EditOp}; +use crate::buffer::{BufferId, EditOp, MarkGravity, MarkId}; use crate::buffer_registry::BufferRegistry; use crate::cell::{Color, Style, UnderlineStyle}; use crate::command::{Command, CommandError, CommandRegistry, SourceLocation}; @@ -58,6 +58,9 @@ use crate::highlight::{SyntaxHighlightView, Theme}; use crate::hook::{Hook, HookRegistry}; use crate::key::{display_sequence, parse_sequence}; use crate::keymap_stack::KeymapStack; +use crate::packages::{ + Address, Fetcher, InstallError, InstallScope, InstallSpec, InstalledPackage, Installer, +}; use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity}; use crate::rope::Range; use crate::syntax::{self, ParseTreeBundle, ParseView, ParseViewHandle, SharedSyntaxRegistry}; @@ -279,6 +282,100 @@ impl Default for LocalInstanceInfo { } } +/// In-memory roster of packages installed during the init phase. +/// +/// Populated by `pmacs.packages.install{...}` and `install_project{...}` +/// (T M7.3). Read by `pmacs.packages.installed()` for introspection +/// and by the future M7.6 lockfile writer to enumerate the resolved +/// set. Single-threaded `Rc>` per the boundary's +/// main-thread invariant. +#[derive(Debug, Clone, Default)] +pub struct InstalledPackages(Rc>>); + +impl InstalledPackages { + /// Construct an empty roster. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Record a successful install. Order matches install order; that + /// matters for diagnostics ("which install errored?") more than + /// for resolution. + pub fn record(&self, pkg: InstalledPackage) { + self.0.borrow_mut().push(pkg); + } + + /// Snapshot the current roster for read-only consumers. + #[must_use] + pub fn snapshot(&self) -> Vec { + self.0.borrow().clone() + } +} + +/// Source label of the currently-evaluating chunk, populated by +/// [`crate::lua::LuaHost::eval`] before each evaluation. +/// +/// The label follows Lua's `@` convention for file-loaded +/// chunks (see [`crate::config::load_user_config_at`]); the +/// install-API binding strips the `@` and takes the parent +/// directory to resolve relative `project_root` values in +/// `pmacs.packages.install_project`. Without this slot we'd be +/// unable to recover the chunk source from a Rust callback because +/// pmacs's Lua state intentionally omits the `debug` library +/// (`forbid(unsafe_code)` rules out `Lua::unsafe_new`, and +/// `debug.getinfo` is not available in the safe stdlib subset). +/// +/// Single-slot state, no stack: nested `eval` calls overwrite the +/// outer chunk's source for the duration of the inner call. v0.1 +/// has no nested-eval flow that consults this slot, so the +/// simplification is sound. +#[derive(Debug, Clone, Default)] +pub struct CurrentEvalSource(pub Option); + +/// Override hook for the `pmacs.packages.install{...}` machinery. +/// +/// In production this slot is empty: `install` builds a [`Fetcher`] +/// rooted at `$XDG_CACHE_HOME/pmacs/git/` and an [`InstallScope::User`] +/// rooted at `$XDG_DATA_HOME/pmacs/packages/`. Tests cannot mutate +/// `XDG_CACHE_HOME` / `XDG_DATA_HOME` because `std::env::set_var` is +/// `unsafe` since Rust 2024 and the project forbids unsafe; instead +/// they install a [`PackageInstallOverride`] with explicit paths. +/// +/// Set via [`crate::lua::LuaHost::set_package_install_override`]; read +/// by [`do_install`]. +#[derive(Debug, Clone, Default)] +pub struct PackageInstallOverride { + /// Override the bare-mirror cache dir. Defaults to + /// `$XDG_CACHE_HOME/pmacs/git/` when absent. + pub cache_dir: Option, + /// Override the user-scope install root. Defaults to + /// `$XDG_DATA_HOME/pmacs/packages/` when absent. + pub user_install_root: Option, +} + +impl PackageInstallOverride { + /// Empty override (production default behavior). + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Set the cache-dir override. Builder-style. + #[must_use] + pub fn with_cache_dir(mut self, p: std::path::PathBuf) -> Self { + self.cache_dir = Some(p); + self + } + + /// Set the user-install-root override. Builder-style. + #[must_use] + pub fn with_user_install_root(mut self, p: std::path::PathBuf) -> Self { + self.user_install_root = Some(p); + self + } +} + /// Short-circuit a binding when the init phase has completed. /// /// Lifecycle-affecting Lua APIs (currently just `pmacs.attach`; M5.6d+) @@ -333,6 +430,15 @@ pub enum BindingError { id: BufferId, }, + /// A Lua mark handle refers to a mark that has already been removed. + #[error("stale mark handle: mark {mark:?} no longer exists on buffer {buffer:?}")] + StaleMark { + /// Buffer that originally owned the mark. + buffer: BufferId, + /// Removed mark ID. + mark: MarkId, + }, + /// A position argument was negative; positions are byte offsets and /// must be `>= 0`. #[error("position must be non-negative; got {got}")] @@ -495,6 +601,64 @@ pub enum BindingError { prior: String, }, + /// The Lua state is missing its [`InstalledPackages`] roster. + /// Programming error, not user input. + #[error("Lua app data missing: InstalledPackages roster was not installed on this Lua state")] + NoInstalledPackagesSlot, + + /// `pmacs.packages.install{...}` was passed something that wasn't + /// a string (shorthand) or a table (kwargs). + #[error( + "pmacs.packages.install: spec must be a string \ + (e.g. \"github:user/repo@^1.0.0\") or a table \ + (e.g. {{ \"github:user/repo\", version = \"^1.0.0\" }}); got {got}" + )] + InstallSpecWrongType { + /// The Lua type of the offending value. + got: String, + }, + + /// A `pmacs.packages.install{...}` table form omitted the address + /// (no positional `[1]` and no `address = "..."` kwarg). + #[error( + "pmacs.packages.install: spec table must contain either a \ + positional address at [1] or an `address` field" + )] + InstallSpecMissingAddress, + + /// `install_project` was called without an explicit + /// `project_root` field. The CWD-fallback was removed because at + /// init time CWD is whatever directory the user happened to + /// invoke pmacs from --- almost never a meaningful project + /// root. The message names two concrete patterns for filling in + /// a value, so users hitting this in a CI log or stack trace + /// can fix it without context. + #[error( + "pmacs.packages.install_project requires an explicit \ + `project_root` field. \ + Pass `project_root = \"/path/to/your/project\"` (often \ + `os.getenv(\"PMACS_PROJECT\")` or a path relative to the \ + directory containing your init.lua)." + )] + InstallProjectMissingProjectRoot, + + /// The package install layer surfaced a typed error. The display + /// chain reproduces the inner [`InstallError`]'s message verbatim, + /// so callers see e.g. "no tag for X satisfies ^1.0". + #[error("{0}")] + PackageInstall(#[from] InstallError), + + /// Stub for `pmacs.packages.update(...)` which is implemented in + /// T M7.6. Per the project's "stub posture" convention, we accept + /// the call shape (so v0.1 init.lua's that try it get a clean + /// error) and fail with the milestone target named. + #[error( + "pmacs.packages.update is implemented in M7.6 (lockfile + \ + resolver). v0.1 / current builds: re-run `pmacs.packages.install` \ + with the new constraint to upgrade in place." + )] + PackagesUpdateUnsupported, + /// A Lua intercept returned a table that was missing one of the /// required position/range fields. The intercept contract requires /// the returned table to carry the same fields as the input @@ -528,6 +692,27 @@ pub enum BindingError { /// The kind Lua tried to return. to: String, }, + + /// The buffer registry is already borrowed --- typically because a + /// `pmacs.buffer.X` call was made from inside a buffer intercept + /// callback. Intercepts run while the registry is locked so the + /// edit can be applied atomically; calling back into + /// `pmacs.buffer.X` from the intercept body would deadlock. We + /// detect the recursive borrow attempt and surface a typed error + /// instead of letting `RefCell::borrow_mut` panic. + /// + /// The structural fix (let intercepts re-enter the buffer API + /// safely) is tracked as a deferred audit task; until then, + /// intercept bodies must operate only on the `op` parameter and + /// any state captured in their closure --- not call back through + /// the public surface synchronously. + #[error( + "buffer registry already borrowed (likely a re-entrant call from \ + inside a buffer intercept); intercepts cannot call pmacs.buffer.X \ + synchronously --- defer the work to a hook or callback that runs \ + after the edit completes" + )] + Reentrant, } // --------------------------------------------------------------------------- @@ -610,27 +795,23 @@ fn add_query_methods>(methods: &mut M) { fn add_mutation_methods>(methods: &mut M) { methods.add_method("insert", |lua, this, (pos, bytes): (i64, mlua::String)| { - let edit = with_registry_mut(lua, |r| { - let buf = resolve_mut(r, this.0)?; - let pos = u64_from_lua(pos)?; - let payload = bytes.as_bytes(); - buf.apply_edit(EditOp::Insert { + let pos = u64_from_lua(pos)?; + let payload = bytes.as_bytes(); + let edit = run_managed_edit( + lua, + this.0, + EditOp::Insert { pos, bytes: &payload, - }) - .map_err(mlua::Error::external) - })?; + }, + )?; notify_buffer_edit_to_windows(lua, this.0, &edit); Ok(()) }); methods.add_method("delete", |lua, this, (start, end): (i64, i64)| { - let edit = with_registry_mut(lua, |r| { - let range = checked_range(start, end)?; - resolve_mut(r, this.0)? - .apply_edit(EditOp::Delete { range }) - .map_err(mlua::Error::external) - })?; + let range = checked_range(start, end)?; + let edit = run_managed_edit(lua, this.0, EditOp::Delete { range })?; notify_buffer_edit_to_windows(lua, this.0, &edit); Ok(()) }); @@ -638,22 +819,77 @@ fn add_mutation_methods>(methods: &mut M) { methods.add_method( "replace", |lua, this, (start, end, bytes): (i64, i64, mlua::String)| { - let edit = with_registry_mut(lua, |r| { - let range = checked_range(start, end)?; - let payload = bytes.as_bytes(); - resolve_mut(r, this.0)? - .apply_edit(EditOp::Replace { - range, - bytes: &payload, - }) - .map_err(mlua::Error::external) - })?; + let range = checked_range(start, end)?; + let payload = bytes.as_bytes(); + let edit = run_managed_edit( + lua, + this.0, + EditOp::Replace { + range, + bytes: &payload, + }, + )?; notify_buffer_edit_to_windows(lua, this.0, &edit); Ok(()) }, ); } +/// Three-phase edit flow that lets intercepts re-enter `pmacs.buffer.X` +/// safely (T M7.4). +/// +/// Phase 1: borrow the registry, mark the buffer mid-edit +/// (`begin_edit`), take its views out, snapshot the +/// [`InterceptContext`], drop the borrow. +/// +/// Phase 2: run the intercept chain against the snapshot context, +/// without holding the registry borrow. An intercept body that calls +/// back into `pmacs.buffer.X` on a different buffer succeeds +/// transparently; on the same buffer it hits the `editing_in_progress` +/// gate and returns `BufferError::ConcurrentEdit`. +/// +/// Phase 3: re-borrow, restore the views (preserving any new views +/// added during phase 2), clear the mid-edit flag, and run +/// `apply_edit_skip_intercepts` --- which performs the rope edit, the +/// undo bookkeeping, the revision bump, and the `on_edit` broadcast. +fn run_managed_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result { + // Phase 1: borrow, begin_edit, take views, snapshot context. + let (mut views, ctx) = with_registry_mut(lua, |r| { + let buf = resolve_mut(r, id)?; + buf.begin_edit().map_err(mlua::Error::external)?; + let ctx = crate::view::InterceptContext::snapshot(buf); + let views = buf.take_views(); + Ok((views, ctx)) + })?; + + // Phase 2: run intercepts. Registry borrow is released, so the + // intercept body may re-enter `pmacs.buffer.X`. The bytes + // referenced by `op` are owned by the caller's `mlua::String`, + // which lives across the whole call --- the borrow stays valid. + let intercept_result: Result, crate::buffer::BufferError> = (|| { + let mut current = op; + for (_, view) in &mut views { + current = view.intercept_edit(&ctx, current)?; + } + Ok(current) + })(); + + // Phase 3: re-borrow, restore views, clear mid-edit flag, apply. + // We restore views and clear the flag even on intercept error, + // so the buffer is left in a usable state. + with_registry_mut(lua, |r| { + let buf = resolve_mut(r, id)?; + buf.restore_views(views); + buf.end_edit(); + match intercept_result { + Ok(final_op) => buf + .apply_edit_skip_intercepts(final_op) + .map_err(mlua::Error::external), + Err(e) => Err(mlua::Error::external(e)), + } + }) +} + fn add_history_methods>(methods: &mut M) { methods.add_method("undo", |lua, this, ()| { let edit = with_registry_mut(lua, |r| Ok(resolve_mut(r, this.0)?.undo().ok()))?; @@ -811,7 +1047,7 @@ struct LuaInterceptView { impl crate::view::View for LuaInterceptView { fn intercept_edit<'a>( &mut self, - _buf: &crate::buffer::Buffer, + _ctx: &crate::view::InterceptContext, op: EditOp<'a>, ) -> Result, crate::buffer::BufferError> { let input = build_intercept_input(&self.lua, &op).map_err(|e| { @@ -943,6 +1179,89 @@ pub struct InterceptHandleLua { view: crate::buffer::ViewId, } +#[derive(Clone)] +/// Lua handle for a shared buffer-byte style overlay. +pub struct StyleOverlayHandleLua { + /// Shared style spans rendered by every attached overlay view. + spans: crate::overlay::SharedBufferStyleSpans, +} + +impl FromLua for StyleOverlayHandleLua { + fn from_lua(value: Value, _: &Lua) -> mlua::Result { + match value { + Value::UserData(ud) => Ok(ud.borrow::()?.clone()), + other => Err(mlua::Error::FromLuaConversionError { + from: other.type_name(), + to: "StyleOverlayHandleLua".to_string(), + message: Some( + "expected a style overlay handle (returned by pmacs.buffer.add_style_overlay)" + .to_string(), + ), + }), + } + } +} + +impl UserData for StyleOverlayHandleLua { + fn add_methods>(methods: &mut M) { + methods.add_method( + "add", + |_, this, (start, end, style): (i64, i64, Table)| -> mlua::Result<()> { + let start = u64_from_lua(start)?; + let end = u64_from_lua(end)?; + if start > end { + return Err(mlua::Error::external(BindingError::InvalidRange { + start, + end, + })); + } + if start == end { + return Ok(()); + } + this.spans + .lock() + .expect("style overlay mutex poisoned") + .push(crate::overlay::BufferStyleSpan { + start, + end, + style: lua_to_style(&style)?, + }); + Ok(()) + }, + ); + + methods.add_method("clear", |_, this, ()| { + this.spans + .lock() + .expect("style overlay mutex poisoned") + .clear(); + Ok(()) + }); + + methods.add_method("clear_before", |_, this, pos: i64| -> mlua::Result<()> { + let pos = u64_from_lua(pos)?; + this.spans + .lock() + .expect("style overlay mutex poisoned") + .retain(|span| span.end > pos); + Ok(()) + }); + + methods.add_method("spans", |lua, this, ()| { + let spans = this.spans.lock().expect("style overlay mutex poisoned"); + let out = lua.create_table_with_capacity(spans.len(), 0)?; + for (i, span) in spans.iter().enumerate() { + let row = lua.create_table_with_capacity(0, 3)?; + row.set("start", i64::try_from(span.start).unwrap_or(i64::MAX))?; + row.set("end", i64::try_from(span.end).unwrap_or(i64::MAX))?; + row.set("style", style_to_lua(lua, span.style)?)?; + out.set(i + 1, row)?; + } + Ok(out) + }); + } +} + impl FromLua for InterceptHandleLua { fn from_lua(value: Value, _: &Lua) -> mlua::Result { match value { @@ -970,6 +1289,86 @@ impl UserData for InterceptHandleLua { } } +/// Userdata handle for a buffer-owned mark. +#[derive(Copy, Clone)] +pub struct MarkHandleLua { + buffer: BufferId, + mark: MarkId, +} + +impl FromLua for MarkHandleLua { + fn from_lua(value: Value, _: &Lua) -> mlua::Result { + match value { + Value::UserData(ud) => Ok(*ud.borrow::()?), + other => Err(mlua::Error::FromLuaConversionError { + from: other.type_name(), + to: "MarkHandleLua".to_string(), + message: Some( + "expected a mark handle (returned by pmacs.buffer.mark_create)".to_string(), + ), + }), + } + } +} + +impl UserData for MarkHandleLua { + fn add_methods>(methods: &mut M) { + methods.add_method("get", |lua, this, ()| { + with_registry(lua, |r| { + let buf = resolve(r, this.buffer)?; + let pos = buf.mark_pos(this.mark).ok_or_else(|| { + mlua::Error::external(BindingError::StaleMark { + buffer: this.buffer, + mark: this.mark, + }) + })?; + Ok(i64_clamp(pos)) + }) + }); + + methods.add_method("pos", |lua, this, ()| { + with_registry(lua, |r| { + let buf = resolve(r, this.buffer)?; + let pos = buf.mark_pos(this.mark).ok_or_else(|| { + mlua::Error::external(BindingError::StaleMark { + buffer: this.buffer, + mark: this.mark, + }) + })?; + Ok(i64_clamp(pos)) + }) + }); + + methods.add_method("set", |lua, this, pos: i64| { + let pos = u64_from_lua(pos)?; + with_registry_mut(lua, |r| { + let buf = resolve_mut(r, this.buffer)?; + let ok = buf + .set_mark(this.mark, pos) + .map_err(mlua::Error::external)?; + if !ok { + return Err(mlua::Error::external(BindingError::StaleMark { + buffer: this.buffer, + mark: this.mark, + })); + } + Ok(()) + }) + }); + + methods.add_method("remove", |lua, this, ()| { + with_registry_mut(lua, |r| { + let buf = resolve_mut(r, this.buffer)?; + Ok(buf.remove_mark(this.mark)) + }) + }); + + methods.add_meta_method(mlua::MetaMethod::ToString, |_, this, ()| { + Ok(format!("MarkHandle({:?},{:?})", this.buffer, this.mark)) + }); + } +} + // --------------------------------------------------------------------------- // Module install // --------------------------------------------------------------------------- @@ -1000,6 +1399,7 @@ pub fn install( lua.set_app_data(RequestedAttach::new()); lua.set_app_data(CurrentAttachmentSlot::new()); lua.set_app_data(LocalInstanceInfo::new()); + lua.set_app_data(InstalledPackages::new()); let pmacs = lua.create_table()?; pmacs.set("buffer", install_buffer_module(lua, registry)?)?; @@ -1033,6 +1433,7 @@ pub fn install( )?; pmacs.set("instance", install_instance_module(lua, registry)?)?; pmacs.set("ansi", install_ansi_module(lua)?)?; + pmacs.set("packages", install_packages_module(lua)?)?; lua.globals().set("pmacs", pmacs)?; Ok(()) } @@ -1396,6 +1797,25 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result)| { + let gravity = parse_mark_gravity(opts.as_ref())?; + let pos = u64_from_lua(pos)?; + let mut r = reg.borrow_mut(); + let buf = resolve_mut(&mut r, id.0)?; + let mark = buf + .create_mark(pos, gravity) + .map_err(mlua::Error::external)?; + Ok(MarkHandleLua { buffer: id.0, mark }) + }, + )?, + )?; + } + // M6.4: chained intercept registration. The view chain in // `crate::buffer::Buffer` is the underlying primitive; each // registered Lua function becomes a `LuaInterceptView` attached @@ -1444,9 +1864,69 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result mlua::Result { + let spans = Arc::new(Mutex::new(Vec::new())); + let handle = StyleOverlayHandleLua { + spans: Arc::clone(&spans), + }; + attach_style_overlay_to_visible_windows(lua, id.0, spans); + Ok(handle) + }, + )?, + )?; + } + + { + buffer.set( + "attach_style_overlay", + lua.create_function( + move |lua, (id, handle): (BufferIdLua, StyleOverlayHandleLua)| { + attach_style_overlay_to_visible_windows(lua, id.0, Arc::clone(&handle.spans)); + Ok(()) + }, + )?, + )?; + } + Ok(buffer) } +fn parse_mark_gravity(opts: Option<&Table>) -> mlua::Result { + let Some(opts) = opts else { + return Ok(MarkGravity::Right); + }; + let gravity = opts.get::>("gravity")?; + match gravity.as_deref().unwrap_or("right") { + "left" => Ok(MarkGravity::Left), + "right" => Ok(MarkGravity::Right), + other => Err(mlua::Error::external(format!( + "pmacs.buffer.mark_create: opts.gravity must be \"left\" or \"right\"; got {other:?}" + ))), + } +} + +fn attach_style_overlay_to_visible_windows( + lua: &Lua, + buffer_id: BufferId, + spans: crate::overlay::SharedBufferStyleSpans, +) { + let Some(core) = lua.app_data_ref::() else { + return; + }; + let mut core = core.borrow_mut(); + for win in core.windows.values_mut() { + if win.buffer_id == buffer_id { + win.push_overlay(Box::new(crate::overlay::BufferStyleOverlay::new( + Arc::clone(&spans), + ))); + } + } +} + // --------------------------------------------------------------------------- // pmacs.ansi: M6.4-side exposure of the M6.3 parser // --------------------------------------------------------------------------- @@ -1498,6 +1978,405 @@ fn install_ansi_module(lua: &Lua) -> mlua::Result { Ok(ansi) } +// --------------------------------------------------------------------------- +// pmacs.packages module (T M7.3) +// --------------------------------------------------------------------------- + +/// Build the `pmacs.packages.*` table. +/// +/// Surface: +/// +/// - `pmacs.packages.install(spec)` --- install to user-config root +/// (`$XDG_DATA_HOME/pmacs/packages/`). +/// - `pmacs.packages.install_project(spec)` --- install to the project +/// root (`/.pmacs/packages/`, override with `project_root` in +/// the spec). +/// - `pmacs.packages.installed()` --- snapshot of packages that +/// completed install during the init phase. +/// - `pmacs.packages.update(...)` --- M7.6 stub; currently errors +/// pointing at the workaround (re-running install with a new +/// constraint). +/// +/// Both install variants are init-time-only via [`require_init_phase`]; +/// mid-session calls produce [`BindingError::InitOnlyApi`] naming +/// the equivalent CLI flag (none yet --- restart with an updated +/// init.lua). Each install is synchronous: errors raise back at the +/// call site so the offending init.lua line is named in the traceback. +fn install_packages_module(lua: &Lua) -> mlua::Result
{ + let packages = lua.create_table()?; + + packages.set( + "install", + lua.create_function(|lua, spec: Value| -> mlua::Result
{ + require_init_phase(lua, "pmacs.packages.install")?; + let install_spec = parse_lua_install_spec(&spec)?; + do_install(lua, &install_spec, &InstallScope::User) + })?, + )?; + + packages.set( + "install_project", + lua.create_function(|lua, spec: Value| -> mlua::Result
{ + require_init_phase(lua, "pmacs.packages.install_project")?; + let install_spec = parse_lua_install_spec(&spec)?; + // Allow `project_root = "..."` override in the table form. + let project_root = install_spec_project_root(lua, &spec)?; + do_install(lua, &install_spec, &InstallScope::Project { project_root }) + })?, + )?; + + packages.set( + "installed", + lua.create_function(|lua, ()| -> mlua::Result
{ + let slot = lua + .app_data_ref::() + .ok_or_else(|| mlua::Error::external(BindingError::NoInstalledPackagesSlot))?; + let snapshot = slot.snapshot(); + let t = lua.create_table()?; + for (i, pkg) in snapshot.iter().enumerate() { + t.set(i + 1, installed_package_to_lua(lua, pkg)?)?; + } + Ok(t) + })?, + )?; + + packages.set( + "update", + lua.create_function(|_, _args: Variadic| -> mlua::Result<()> { + Err(mlua::Error::external( + BindingError::PackagesUpdateUnsupported, + )) + })?, + )?; + + register_package_searcher(lua)?; + + Ok(packages) +} + +/// Register a custom searcher in `package.searchers` (Lua 5.4) / +/// `package.loaders` (Lua 5.1, LuaJIT) that consults the +/// [`InstalledPackages`] roster at require time. +/// +/// # Why +/// +/// `prepend_package_path` (the existing mechanism) only handles the +/// standard Lua layout: `/.lua` or +/// `//init.lua`. A package whose manifest +/// declares e.g. `entry = "main.lua"` or `entry = "lib/foo.lua"` +/// has its entry file at a path the standard `?.lua;?/init.lua` +/// pattern does not match, and `require("")` would fail +/// even though the install completed. The custom searcher closes +/// that gap by mapping `require("")` directly to the +/// manifest's declared entry path. +/// +/// # Precedence +/// +/// The searcher is appended to the searchers/loaders list, after +/// the path-based searcher. Standard layouts (`init.lua` etc.) +/// continue to load via the path mechanism; the custom searcher +/// only kicks in when the path search misses. This keeps +/// drop-in-compatible packages on the well-trodden path and avoids +/// a behavior change for anyone using the conventional layout. +/// +/// Within the searcher, the [`InstalledPackages`] roster is iterated +/// in *reverse* so the most recently installed package wins on a +/// basename collision. Combined with `init.lua`'s typical pattern +/// (user install first, then project install), this makes +/// project-scope installs override user-scope installs of the same +/// basename --- mirroring `prepend_package_path`'s "newer +/// installations prepend to package.path" semantics. +/// +/// # 5.1 vs 5.4 names +/// +/// Lua 5.1 / LuaJIT exposes the searcher list as `package.loaders`; +/// Lua 5.2+ renamed it to `package.searchers`. Both are tables of +/// functions with the same callback shape. We probe `searchers` +/// first and fall back to `loaders` so the same code works under +/// both feature flags. +fn register_package_searcher(lua: &Lua) -> mlua::Result<()> { + let package: Table = lua.globals().get("package")?; + let searchers: Table = match package.get::>("searchers")? { + Some(t) => t, + None => package.get::
("loaders")?, + }; + + let searcher = lua.create_function( + |lua, name: String| -> mlua::Result { + let Some(slot) = lua.app_data_ref::() else { + // Slot uninstalled (shouldn't happen under + // production wiring, but a defensive nil keeps + // require working under unusual test setups). + return Ok(mlua::Value::Nil); + }; + let snapshot = slot.snapshot(); + // Most-recent-first: a project-scope install of a + // basename overrides a prior user-scope install. + for pkg in snapshot.iter().rev() { + if pkg.install_basename() != name { + continue; + } + let entry = pkg.entry_path(); + let bytes = match std::fs::read(&entry) { + Ok(b) => b, + Err(e) => { + // Searcher convention: a non-function return + // is treated as "not found, here's why" and + // appended to the require error message. + let s = lua.create_string(&format!( + "\n\tinstalled pmacs package '{name}' \ + entry `{}` could not be read: {e}", + entry.display() + ))?; + return Ok(mlua::Value::String(s)); + } + }; + let chunk_name = format!("@{}", entry.display()); + let func = lua + .load(&bytes) + .set_name(&chunk_name) + .into_function()?; + return Ok(mlua::Value::Function(func)); + } + // No installed package matches. Return a string so Lua + // appends our reason to the aggregate require error. + let s = lua.create_string(&format!( + "\n\tno installed pmacs package named '{name}'" + ))?; + Ok(mlua::Value::String(s)) + }, + )?; + + // Append to the searcher list. Lua tables are 1-indexed; the + // new searcher runs after every existing searcher (preload, + // path-based, etc.), so standard layouts are unaffected. + let len = searchers.raw_len(); + searchers.set(len + 1, searcher)?; + Ok(()) +} + +/// Parse the Lua-side `install(...)` argument into an [`InstallSpec`]. +/// +/// Two accepted forms: +/// +/// - **Shorthand string**: `"github:user/repo@^1.0.0"`. Split on the +/// last `@` (so SSH-style addresses like `git:git@host:path@=1.2.3` +/// parse as expected). +/// - **Table**: `{ "github:user/repo", version = "^1.0.0" }`. The +/// address may also be passed as `address = "..."`. The `version` +/// field defaults to `"*"` if omitted (any tag). +fn parse_lua_install_spec(value: &Value) -> mlua::Result { + match value { + Value::String(s) => { + let s = s.to_string_lossy(); + InstallSpec::parse_shorthand(&s) + .map_err(|e| mlua::Error::external(BindingError::from(e))) + } + Value::Table(t) => { + let address_str: String = match t.get::(1) { + Ok(s) => s, + Err(_) => match t.get::("address") { + Ok(s) => s, + Err(_) => { + return Err(mlua::Error::external( + BindingError::InstallSpecMissingAddress, + )); + } + }, + }; + let version_str: String = t + .get::("version") + .unwrap_or_else(|_| "*".to_string()); + let address = Address::parse(&address_str) + .map_err(|e| mlua::Error::external(BindingError::from(InstallError::Address(e))))?; + let version = semver::VersionReq::parse(&version_str).map_err(|e| { + mlua::Error::external(BindingError::from(InstallError::InvalidVersionReq { + value: version_str, + cause: e.to_string(), + })) + })?; + Ok(InstallSpec { address, version }) + } + other => Err(mlua::Error::external(BindingError::InstallSpecWrongType { + got: other.type_name().to_string(), + })), + } +} + +/// Read the required `project_root = "..."` field from the table form +/// of `install_project`'s spec. +/// +/// Absolute paths are returned as-is. Relative paths are resolved +/// against the directory of the currently-evaluating chunk +/// (typically the user's `init.lua`) --- *not* against +/// `std::env::current_dir()`. The init-script's directory is stable +/// across invocations; CWD is whatever shell directory the user +/// happened to start pmacs from and is rarely the right anchor. +/// +/// Returns [`BindingError::InstallProjectMissingProjectRoot`] when +/// the field is absent or the spec was given as a shorthand string. +/// Pre-v0.1.0 the fallback was `current_dir()`; that surprise was +/// removed because CWD-at-startup is almost never the user's project +/// root in any meaningful sense (see reviewer item 10). +/// +/// # How the chunk directory is recovered +/// +/// pmacs's Lua state is built with `Lua::new()`, which loads the +/// safe stdlib subset and intentionally omits `debug` (the project +/// forbids `unsafe_code`, so `Lua::unsafe_new` is not an option). +/// Without `debug.getinfo` we cannot walk Lua's call stack at +/// runtime. Instead, [`crate::lua::LuaHost::eval`] writes the +/// chunk's source label into a [`CurrentEvalSource`] app-data +/// slot before evaluating; this function reads it. The label +/// follows Lua's `@` convention for file-loaded chunks (see +/// [`crate::config::load_user_config_at`]), so stripping the `@` +/// and taking the parent directory is well-defined. +/// +/// # Forward-planning note +/// +/// When project-local `init.lua` lands (post-v0.1; tracked +/// separately in the milestone plan), this function should consult +/// a thread-local "current project root" set by the project loader +/// before falling through to the missing-field error. Until that +/// machinery exists, `project_root` is unconditionally required; +/// the global init.lua path is the only init.lua path, and there +/// is no implicit "current project" to draw on. +fn install_spec_project_root(lua: &Lua, value: &Value) -> mlua::Result { + let field = match value { + Value::Table(t) => t.get::("project_root").ok(), + _ => None, + }; + let raw = match field { + Some(s) if !s.is_empty() => s, + _ => { + return Err(mlua::Error::external( + BindingError::InstallProjectMissingProjectRoot, + )); + } + }; + let candidate = std::path::PathBuf::from(&raw); + if candidate.is_absolute() { + return Ok(candidate); + } + if let Some(chunk_dir) = current_eval_dir(lua) { + return Ok(chunk_dir.join(&candidate)); + } + // Fallback for evaluations without a file-shaped source label + // (string-loaded test chunks, REPL one-liners, the M-x + // command-line evaluator): the relative path is taken as-is. + // The user's value is non-empty so they explicitly opted in; + // this branch matches the pre-v0.1 CWD interpretation. + Ok(candidate) +} + +/// Read the parent directory of the currently-evaluating chunk's +/// source label, if any. Returns `None` when no source has been +/// pushed (e.g., the call stack came in via a non-`eval` entry +/// point), or when the source label is not in `@` shape. +/// +/// The slot is populated by [`crate::lua::LuaHost::eval`] before +/// it runs the chunk; see the docstring on +/// [`install_spec_project_root`] for why we use this rather than +/// `debug.getinfo`. +fn current_eval_dir(lua: &Lua) -> Option { + let slot = lua.app_data_ref::()?; + let label = slot.0.as_deref()?; + let path_str = label.strip_prefix('@')?; + let path = std::path::PathBuf::from(path_str); + let parent = path.parent()?; + if parent.as_os_str().is_empty() { + return None; + } + Some(parent.to_path_buf()) +} + +/// Run the install end-to-end: build a fetcher rooted at +/// `$XDG_CACHE_HOME/pmacs/git/`, run [`Installer::install`], extend +/// `package.path` so the entry module is requireable, and record the +/// result in the [`InstalledPackages`] roster. +/// +/// A [`PackageInstallOverride`] in app data, if present, redirects the +/// fetcher's cache dir and the user-scope install root. Tests use this +/// instead of mutating `XDG_*` env vars (which would require `unsafe`). +fn do_install(lua: &Lua, spec: &InstallSpec, scope: &InstallScope) -> mlua::Result
{ + let override_data = lua.app_data_ref::(); + let cache_override = override_data.as_ref().and_then(|o| o.cache_dir.clone()); + let user_root_override = override_data + .as_ref() + .and_then(|o| o.user_install_root.clone()); + + let fetcher = match cache_override { + Some(dir) => Fetcher::with_cache_dir(dir), + None => Fetcher::from_xdg() + .map_err(|e| mlua::Error::external(BindingError::from(InstallError::Fetch(e))))?, + }; + let mut installer = Installer::new(fetcher, scope.clone()); + if let (InstallScope::User, Some(root)) = (scope, user_root_override) { + installer = installer.with_install_root_override(root); + } + let installed = installer + .install(spec) + .map_err(|e| mlua::Error::external(BindingError::from(e)))?; + + // Extend package.path so the package's entry module is requireable. + if let Some(parent) = installed.install_path.parent() { + prepend_package_path(lua, parent)?; + } + + // Record in the in-memory roster. + let slot = lua + .app_data_ref::() + .ok_or_else(|| mlua::Error::external(BindingError::NoInstalledPackagesSlot))?; + slot.record(installed.clone()); + + installed_package_to_lua(lua, &installed) +} + +/// Idempotently prepend `/?.lua;/?/init.lua` to +/// `package.path`. The standard Lua require pattern: a package with +/// `entry = "init.lua"` installed at `//init.lua` +/// becomes findable as `require("")`. +fn prepend_package_path(lua: &Lua, root: &std::path::Path) -> mlua::Result<()> { + let package_global = lua.globals().get::
("package")?; + let current_path: String = package_global.get::("path").unwrap_or_default(); + let root_str = root.display().to_string(); + let new_entries = format!("{root_str}/?.lua;{root_str}/?/init.lua"); + if current_path + .split(';') + .any(|seg| seg == format!("{root_str}/?.lua") || seg == format!("{root_str}/?/init.lua")) + { + return Ok(()); + } + let combined = if current_path.is_empty() { + new_entries + } else { + format!("{new_entries};{current_path}") + }; + package_global.set("path", combined)?; + Ok(()) +} + +/// Translate an [`InstalledPackage`] into the Lua-facing record +/// returned by `pmacs.packages.install` and `pmacs.packages.installed`. +fn installed_package_to_lua(lua: &Lua, pkg: &InstalledPackage) -> mlua::Result
{ + let t = lua.create_table()?; + t.set("name", pkg.manifest.name.as_str())?; + t.set("version", pkg.version.to_string())?; + t.set("tag", pkg.tag.as_str())?; + t.set("commit", pkg.commit.as_str())?; + t.set("install_path", pkg.install_path.display().to_string())?; + t.set("entry", pkg.entry_path().display().to_string())?; + t.set( + "scope", + match &pkg.scope { + InstallScope::User => "user", + InstallScope::Project { .. } => "project", + }, + )?; + t.set("summary", pkg.manifest.summary.as_str())?; + Ok(t) +} + /// Translate a single [`crate::ansi::AnsiEvent`] into a Lua table. /// /// The `kind` field is the discriminator; per-variant fields follow @@ -1506,6 +2385,7 @@ fn install_ansi_module(lua: &Lua) -> mlua::Result
{ /// - `text`: `{ kind="text", text= }` /// - `set_style`: `{ kind="set_style", style=