Fix PTY final-output drain race
This commit is contained in:
parent
4da4b09d5d
commit
c8d0d67615
|
|
@ -563,11 +563,13 @@ dependencies = [
|
||||||
"postcard",
|
"postcard",
|
||||||
"proptest",
|
"proptest",
|
||||||
"rmp-serde",
|
"rmp-serde",
|
||||||
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"signal-hook",
|
"signal-hook",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"toml",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-lua",
|
"tree-sitter-lua",
|
||||||
"tree-sitter-rust",
|
"tree-sitter-rust",
|
||||||
|
|
@ -847,6 +849,10 @@ name = "semver"
|
||||||
version = "1.0.28"
|
version = "1.0.28"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
|
|
@ -892,6 +898,15 @@ dependencies = [
|
||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serial2"
|
name = "serial2"
|
||||||
version = "0.2.36"
|
version = "0.2.36"
|
||||||
|
|
@ -1047,6 +1062,47 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_write",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tree-sitter"
|
name = "tree-sitter"
|
||||||
version = "0.26.8"
|
version = "0.26.8"
|
||||||
|
|
@ -1300,6 +1356,15 @@ version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,14 @@ portable-pty = "0.9"
|
||||||
# (spec §5.2). `preserve_order` is off --- LSP doesn't require it
|
# (spec §5.2). `preserve_order` is off --- LSP doesn't require it
|
||||||
# and the default `BTreeMap` keeps allocation low.
|
# and the default `BTreeMap` keeps allocation low.
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
# T M7.1 package manifest format (spec §sec:packages-future): TOML at
|
||||||
|
# the package's root (`pmacs.toml`). Read at install time, not at every
|
||||||
|
# load.
|
||||||
|
toml = "0.8"
|
||||||
|
# T M7.1 semantic versioning for `version` and `pmacs_required` fields.
|
||||||
|
# `serde` feature gives us localized parse errors during deserialization
|
||||||
|
# (rejected at parse time, not at install time).
|
||||||
|
semver = { version = "1", features = ["serde"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
|
|
||||||
47
README.md
47
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
|
and scrollback navigation/search) are `#[ignore]`'d during normal
|
||||||
test runs and exercised in CI under dedicated jobs.
|
test runs and exercised in CI under dedicated jobs.
|
||||||
|
|
||||||
|
## Runtime requirements
|
||||||
|
|
||||||
|
The pmacs binary depends on a small set of POSIX command-line tools
|
||||||
|
at runtime. The dependency exists because the project enforces
|
||||||
|
`#![forbid(unsafe_code)]` everywhere, including in tests; calls that
|
||||||
|
would otherwise need `unsafe` (PTY raw-mode setup, signal name
|
||||||
|
translation) are routed through trampolines that exec these tools.
|
||||||
|
|
||||||
|
- **`/bin/sh`** (POSIX shell). Used for the PTY raw-mode trampoline:
|
||||||
|
`/bin/sh -c 'stty raw -echo </dev/tty 2>/dev/null; exec "$@"' --`
|
||||||
|
configures the controlling TTY's line discipline before exec'ing
|
||||||
|
the actual subprocess. Required by the REPL package and any other
|
||||||
|
caller that spawns a process in raw PTY mode.
|
||||||
|
- **`stty`** (coreutils). The line-discipline configurator invoked
|
||||||
|
by the trampoline above.
|
||||||
|
- **`coreutils`** more broadly. The M6 process-supervisor tests
|
||||||
|
spawn `cat`, `yes`, and `which`; absent these the test suite (not
|
||||||
|
the editor itself) degrades. `which` is also used by the M6.5
|
||||||
|
shell-locator helper to find `bash` / `zsh` / `fish` for
|
||||||
|
per-shell integration tests. The M7.2 fetcher's timeout test
|
||||||
|
uses `sleep`.
|
||||||
|
- **`git`** (added in M7.2). Required for any package operation:
|
||||||
|
the package fetcher shells out to `git` to clone, fetch, and
|
||||||
|
resolve refs, with a deterministic environment
|
||||||
|
(`GIT_TERMINAL_PROMPT=0`, `GIT_CONFIG_NOSYSTEM=1`, `LC_ALL=C`,
|
||||||
|
inherited `GIT_*` variables stripped). Authentication for
|
||||||
|
private repositories rides the user's existing git configuration
|
||||||
|
(credential helpers, SSH agent), so packagers do not need a
|
||||||
|
separate auth story. Pre-M7 builds without package operations
|
||||||
|
do not need git.
|
||||||
|
- **`tar`** (added in M7.3). Required for `pmacs.packages.install`:
|
||||||
|
the installer materializes a snapshot via `git archive --format=tar`
|
||||||
|
piped into `tar -x -C <dest>`, which keeps the on-disk install
|
||||||
|
directory self-contained (no `.git` linkage back to the bare
|
||||||
|
cache, no working-tree state). GNU tar and bsdtar both work.
|
||||||
|
Pre-M7 builds and any path that doesn't call
|
||||||
|
`pmacs.packages.install{...}` do not need tar.
|
||||||
|
|
||||||
|
Distribution packagers should ensure these are runtime dependencies
|
||||||
|
of the pmacs package. On a typical Linux distribution, busybox or
|
||||||
|
GNU coreutils plus a shell of any kind satisfies the requirement; on
|
||||||
|
macOS the system shell and `/usr/bin/stty` are both standard.
|
||||||
|
|
||||||
|
The Lua VM (LuaJIT or Lua 5.4) is statically vendored via `mlua`'s
|
||||||
|
`vendored` feature, so there is no external Lua dependency at
|
||||||
|
runtime.
|
||||||
|
|
||||||
## What v0.1 ships with
|
## What v0.1 ships with
|
||||||
|
|
||||||
- **Editor core.** Persistent rope with O(log N) edits and snapshots;
|
- **Editor core.** Persistent rope with O(log N) edits and snapshots;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
--- @meta pmacs.packages
|
||||||
|
---
|
||||||
|
--- EmmyLua-style annotations for the `pmacs.packages.*` Lua surface
|
||||||
|
--- (T M7.3, spec §sec:packages-future). This file is a documentation
|
||||||
|
--- artifact: editor tooling (lua-language-server, EmmyLua) consumes it
|
||||||
|
--- to power completion and hover; the runtime implementation lives in
|
||||||
|
--- Rust (`src/lua_bindings.rs::install_packages_module`).
|
||||||
|
---
|
||||||
|
--- The runtime never `require`s this file. It is shipped under
|
||||||
|
--- `builtin/api/` so packagers know to include it alongside the
|
||||||
|
--- binary; users who want IDE completion add this directory to their
|
||||||
|
--- workspace's lua-language-server `Lua.workspace.library`.
|
||||||
|
|
||||||
|
--- A package install spec.
|
||||||
|
---
|
||||||
|
--- Two accepted shapes:
|
||||||
|
---
|
||||||
|
--- - Table with positional address at `[1]`: `{ "github:owner/repo", version = "^1.0.0" }`.
|
||||||
|
--- The `version` field defaults to `"*"` (any tag) when omitted. The
|
||||||
|
--- `install_project` variant additionally **requires** `project_root = "..."`
|
||||||
|
--- (no default; see the field doc below).
|
||||||
|
--- - Shorthand string `"github:owner/repo@^1.0.0"`. The separator is the **last** `@` in the
|
||||||
|
--- string, so addresses containing an `@` (SSH shorthand `git@host:path`) parse correctly.
|
||||||
|
--- The shorthand form is not accepted by `install_project` (no place to put `project_root`).
|
||||||
|
---
|
||||||
|
--- @class PackageInstallSpec
|
||||||
|
--- @field [1] string Positional address (e.g., `"github:owner/repo"`).
|
||||||
|
--- @field address string|nil Alternative to the positional `[1]`.
|
||||||
|
--- @field version string|nil Semver constraint (e.g., `"^1.0.0"`, `"=1.2.3"`, `"*"`). Defaults to `"*"`.
|
||||||
|
--- @field project_root string|nil `install_project` only: REQUIRED project root. Absolute paths used as-is. Relative paths resolve against the directory of the loading `init.lua` (not against CWD). Common patterns: `os.getenv("PMACS_PROJECT")`, or a literal subdirectory like `"."` for "alongside this init.lua".
|
||||||
|
|
||||||
|
--- A successful-install record returned by `install` and listed by `installed`.
|
||||||
|
---
|
||||||
|
--- @class InstalledPackage
|
||||||
|
--- @field name string Package name from `pmacs.toml` (e.g., `"samplepkg"` or `"user/samplepkg"`).
|
||||||
|
--- @field version string Semver of the resolved tag (canonical numeric form, e.g., `"1.2.3"`).
|
||||||
|
--- @field tag string The tag that was matched (e.g., `"v1.2.3"`).
|
||||||
|
--- @field commit string 40-character commit SHA of the installed snapshot.
|
||||||
|
--- @field install_path string Absolute on-disk install directory.
|
||||||
|
--- @field entry string Absolute path to the package's `entry` Lua module.
|
||||||
|
--- @field scope "user"|"project" Which scope the package was installed under.
|
||||||
|
--- @field summary string One-line description from the manifest.
|
||||||
|
|
||||||
|
local pmacs = pmacs or {}
|
||||||
|
pmacs.packages = pmacs.packages or {}
|
||||||
|
|
||||||
|
--- Install a package to the user-config root (`$XDG_DATA_HOME/pmacs/packages/`).
|
||||||
|
---
|
||||||
|
--- Synchronous: clones / fetches the address, picks the highest semver tag
|
||||||
|
--- matching `version`, materializes the snapshot via `git archive | tar -x`,
|
||||||
|
--- and makes the package's entry module requireable as
|
||||||
|
--- `require(<package-name-basename>)`.
|
||||||
|
---
|
||||||
|
--- Resolution path: standard layouts (`<basename>.lua`,
|
||||||
|
--- `<basename>/init.lua`) are found via `package.path`. Non-standard
|
||||||
|
--- entries (e.g. `entry = "main.lua"` or `entry = "lib/foo.lua"`) are
|
||||||
|
--- found by a custom searcher pmacs registers in `package.searchers`
|
||||||
|
--- (Lua 5.4) / `package.loaders` (LuaJIT, Lua 5.1), which consults
|
||||||
|
--- the install roster and returns the manifest's exact entry path.
|
||||||
|
---
|
||||||
|
--- **Init-time-only.** Calling outside `init.lua` raises an error pointing
|
||||||
|
--- at the workaround (restart pmacs after editing `init.lua`). Mid-session
|
||||||
|
--- install is not supported in v0.1; M7.6 adds `pmacs.packages.update(...)`
|
||||||
|
--- for in-place version changes.
|
||||||
|
---
|
||||||
|
--- @param spec PackageInstallSpec|string Spec table, or shorthand string `"address@constraint"`.
|
||||||
|
--- @return InstalledPackage
|
||||||
|
--- @throws "init-only" if called after init has finished.
|
||||||
|
--- @throws "no matching version" if no tag satisfies `version`.
|
||||||
|
--- @throws "already installed" if a different commit occupies the install path.
|
||||||
|
function pmacs.packages.install(spec) end
|
||||||
|
|
||||||
|
--- Install a package to a project-scoped root (`<project_root>/.pmacs/packages/`).
|
||||||
|
---
|
||||||
|
--- Identical to `install` except for the on-disk root. **Requires** an
|
||||||
|
--- explicit `project_root` field in the spec table. Absolute paths are
|
||||||
|
--- used as-is; relative paths resolve against the directory of the
|
||||||
|
--- loading `init.lua` (not against the process CWD, which is rarely a
|
||||||
|
--- meaningful project root). The shorthand string form is not accepted.
|
||||||
|
---
|
||||||
|
--- Project installs override user installs of the same package basename
|
||||||
|
--- in `package.path` (project entries are prepended).
|
||||||
|
---
|
||||||
|
--- **Init-time-only.** See `install` for the gate.
|
||||||
|
---
|
||||||
|
--- @param spec PackageInstallSpec
|
||||||
|
--- @return InstalledPackage
|
||||||
|
--- @throws "init-only" if called after init has finished.
|
||||||
|
--- @throws "no matching version" if no tag satisfies `version`.
|
||||||
|
--- @throws "already installed" if a different commit occupies the install path.
|
||||||
|
--- @throws "missing project_root" if the spec table omits the `project_root` field. The error message names two patterns for filling it in: `os.getenv("PMACS_PROJECT")` for an env-var-driven setup, or a path relative to the loading `init.lua`'s directory.
|
||||||
|
function pmacs.packages.install_project(spec) end
|
||||||
|
|
||||||
|
--- Snapshot the in-memory roster of packages installed during this init pass.
|
||||||
|
---
|
||||||
|
--- Each entry is the same shape as `install`'s return value. The list is
|
||||||
|
--- ordered by install order (first call first).
|
||||||
|
---
|
||||||
|
--- @return InstalledPackage[]
|
||||||
|
function pmacs.packages.installed() end
|
||||||
|
|
||||||
|
--- Re-resolve and update an installed package to the latest commit
|
||||||
|
--- matching its constraint.
|
||||||
|
---
|
||||||
|
--- **Implemented in M7.6** (lockfile + resolver). v0.1 / current builds
|
||||||
|
--- raise an error pointing at the workaround: re-run `pmacs.packages.install`
|
||||||
|
--- with the new constraint to upgrade in place.
|
||||||
|
---
|
||||||
|
--- @param name string|nil Package name to update; omit to update all.
|
||||||
|
--- @throws "unsupported" until M7.6 ships.
|
||||||
|
function pmacs.packages.update(name) end
|
||||||
|
|
||||||
|
return pmacs.packages
|
||||||
|
|
@ -44,6 +44,47 @@ cmd { name = "buffer.delete-backward", description = "Delete the codepoint befor
|
||||||
fn = function() ed.backspace() end }
|
fn = function() ed.backspace() end }
|
||||||
cmd { name = "buffer.delete-forward", description = "Delete the codepoint at the cursor.",
|
cmd { name = "buffer.delete-forward", description = "Delete the codepoint at the cursor.",
|
||||||
fn = function() ed.delete_forward() end }
|
fn = function() ed.delete_forward() end }
|
||||||
|
cmd { name = "buffer.delete-word-backward",
|
||||||
|
description = "Delete from the cursor back to the start of the previous word.",
|
||||||
|
fn = function() ed.delete_word_backward() end }
|
||||||
|
cmd { name = "buffer.delete-word-forward",
|
||||||
|
description = "Delete from the cursor forward to the end of the next word.",
|
||||||
|
fn = function() ed.delete_word_forward() end }
|
||||||
|
|
||||||
|
-- Selection-extending motion (CUA-style Shift+motion). Each select-*
|
||||||
|
-- command anchors at the current cursor (if no region is already
|
||||||
|
-- active) and then performs the underlying motion. Plain motion
|
||||||
|
-- commands are unchanged: they preserve existing selections.
|
||||||
|
local function ensure_anchor()
|
||||||
|
if ed.region() == nil then
|
||||||
|
ed.begin_selection(ed.cursor())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
cmd { name = "cursor.select-left",
|
||||||
|
description = "Extend selection by one codepoint left.",
|
||||||
|
fn = function() ensure_anchor(); ed.move_left() end }
|
||||||
|
cmd { name = "cursor.select-right",
|
||||||
|
description = "Extend selection by one codepoint right.",
|
||||||
|
fn = function() ensure_anchor(); ed.move_right() end }
|
||||||
|
cmd { name = "cursor.select-up",
|
||||||
|
description = "Extend selection upward by one line.",
|
||||||
|
fn = function() ensure_anchor(); ed.move_up() end }
|
||||||
|
cmd { name = "cursor.select-down",
|
||||||
|
description = "Extend selection downward by one line.",
|
||||||
|
fn = function() ensure_anchor(); ed.move_down() end }
|
||||||
|
cmd { name = "cursor.select-word-left",
|
||||||
|
description = "Extend selection by one word left.",
|
||||||
|
fn = function() ensure_anchor(); ed.move_word_left() end }
|
||||||
|
cmd { name = "cursor.select-word-right",
|
||||||
|
description = "Extend selection by one word right.",
|
||||||
|
fn = function() ensure_anchor(); ed.move_word_right() end }
|
||||||
|
cmd { name = "cursor.select-line-start",
|
||||||
|
description = "Extend selection to start of line.",
|
||||||
|
fn = function() ensure_anchor(); ed.move_line_start() end }
|
||||||
|
cmd { name = "cursor.select-line-end",
|
||||||
|
description = "Extend selection to end of line.",
|
||||||
|
fn = function() ensure_anchor(); ed.move_line_end() end }
|
||||||
cmd { name = "buffer.newline", description = "Insert a newline at the cursor.",
|
cmd { name = "buffer.newline", description = "Insert a newline at the cursor.",
|
||||||
fn = function() ed.insert_char(10) end }
|
fn = function() ed.insert_char(10) end }
|
||||||
cmd { name = "buffer.tab", description = "Insert a tab at the cursor.",
|
cmd { name = "buffer.tab", description = "Insert a tab at the cursor.",
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,40 @@ bind("C-d", "buffer.delete-forward")
|
||||||
bind("RET", "buffer.newline")
|
bind("RET", "buffer.newline")
|
||||||
bind("TAB", "buffer.tab")
|
bind("TAB", "buffer.tab")
|
||||||
|
|
||||||
|
-- CUA-style word-level deletion (the same shortcuts users expect from
|
||||||
|
-- IDEs, browsers, terminals on Linux/Windows). C-BS deletes back to
|
||||||
|
-- the start of the previous word; C-DEL deletes forward through the
|
||||||
|
-- next word. Emacs's classic M-BS and M-d remain bound below.
|
||||||
|
--
|
||||||
|
-- Why we also bind C-h: most terminals (anything not implementing the
|
||||||
|
-- kitty keyboard protocol) cannot disambiguate Ctrl+Backspace from
|
||||||
|
-- Ctrl+H — both legacy paths produce byte 0x08, which crossterm
|
||||||
|
-- surfaces as `Char('h') + CONTROL`. Binding C-h to the same command
|
||||||
|
-- makes the shortcut work on legacy terminals too. C-h was free
|
||||||
|
-- (pmacs does not use it as a help prefix); users wanting Emacs's
|
||||||
|
-- help-prefix can override.
|
||||||
|
bind("C-BS", "buffer.delete-word-backward")
|
||||||
|
bind("C-h", "buffer.delete-word-backward")
|
||||||
|
bind("C-DEL", "buffer.delete-word-forward")
|
||||||
|
bind("M-BS", "buffer.delete-word-backward")
|
||||||
|
bind("M-d", "buffer.delete-word-forward")
|
||||||
|
|
||||||
|
-- CUA-style Shift+motion selection. Each Shift+arrow extends a
|
||||||
|
-- selection from the cursor (anchoring at the current position if no
|
||||||
|
-- region is yet active). Ctrl+Shift+Left/Right extend by whole words;
|
||||||
|
-- Shift+Home/End extend to line edges. Plain motion (without Shift)
|
||||||
|
-- is unchanged --- it preserves any existing selection rather than
|
||||||
|
-- dropping it (Emacs-flavored default; users who want strict-CUA
|
||||||
|
-- "drop-on-plain-motion" can rebind their motion commands).
|
||||||
|
bind("S-<left>", "cursor.select-left")
|
||||||
|
bind("S-<right>", "cursor.select-right")
|
||||||
|
bind("S-<up>", "cursor.select-up")
|
||||||
|
bind("S-<down>", "cursor.select-down")
|
||||||
|
bind("S-<home>", "cursor.select-line-start")
|
||||||
|
bind("S-<end>", "cursor.select-line-end")
|
||||||
|
bind("C-S-<left>", "cursor.select-word-left")
|
||||||
|
bind("C-S-<right>", "cursor.select-word-right")
|
||||||
|
|
||||||
-- Undo / redo ----------------------------------------------------------------
|
-- Undo / redo ----------------------------------------------------------------
|
||||||
--
|
--
|
||||||
-- Multiple undo bindings exist because terminals translate Ctrl+/
|
-- Multiple undo bindings exist because terminals translate Ctrl+/
|
||||||
|
|
|
||||||
|
|
@ -4,26 +4,15 @@
|
||||||
-- intercept that enforces read-only / truncate-to-input policy.
|
-- intercept that enforces read-only / truncate-to-input policy.
|
||||||
-- Spec: §sec:repl-view.
|
-- Spec: §sec:repl-view.
|
||||||
--
|
--
|
||||||
-- # Region tracking: byte offsets, not (yet) marks
|
-- # Region tracking: marks
|
||||||
--
|
--
|
||||||
-- The spec says "regions are tracked as marks (M2 primitive)". M2's
|
-- The history/prompt boundaries are backed by core buffer marks.
|
||||||
-- mark primitive doesn't yet exist; M6.4 tracks region boundaries as
|
-- `_history_end` and `_prompt_end` remain as compatibility mirrors
|
||||||
-- byte offsets stored on the handle (`_history_end`, `_prompt_end`).
|
-- for tests and package introspection, but the authoritative positions
|
||||||
-- This works correctly because:
|
-- are `_history_end_mark` and `_prompt_end_mark`. This matters for
|
||||||
--
|
-- process prompts: user edits in the input region must not accidentally
|
||||||
-- * intercept_edit runs *before* the rope mutates. The package
|
-- move the prompt boundary, while package output inserted before the
|
||||||
-- either pre-decides positions (its own write paths) or vetoes
|
-- prompt must move both boundaries with the rope.
|
||||||
-- (in intercept). It does not need to react to a post-mutation
|
|
||||||
-- event to keep the offsets consistent.
|
|
||||||
-- * User-driven edits in the input region (pos >= prompt_end) do
|
|
||||||
-- not move history_end or prompt_end. So the handle's offsets
|
|
||||||
-- only need updates from inside the package's own write paths
|
|
||||||
-- (append_output, set_prompt, submit) --- never from user edits.
|
|
||||||
--
|
|
||||||
-- This is sufficient for M6.4. When real marks land (M2 future
|
|
||||||
-- work), the indirection becomes a one-line replacement: the offsets
|
|
||||||
-- get backed by mark handles instead of plain integers, and the
|
|
||||||
-- existing call sites work unchanged.
|
|
||||||
--
|
--
|
||||||
-- # Self-write bypass
|
-- # Self-write bypass
|
||||||
--
|
--
|
||||||
|
|
@ -130,6 +119,8 @@ local function new_handle(buffer_id)
|
||||||
return setmetatable({
|
return setmetatable({
|
||||||
_buf = buffer_id,
|
_buf = buffer_id,
|
||||||
_parser = pmacs.ansi.parser(),
|
_parser = pmacs.ansi.parser(),
|
||||||
|
_history_end_mark = pmacs.buffer.mark_create(buffer_id, 0, { gravity = "left" }),
|
||||||
|
_prompt_end_mark = pmacs.buffer.mark_create(buffer_id, 0, { gravity = "left" }),
|
||||||
_history_end = 0,
|
_history_end = 0,
|
||||||
_prompt_end = 0,
|
_prompt_end = 0,
|
||||||
-- Latest SetStyle observed. M6.4 doesn't render this anywhere
|
-- Latest SetStyle observed. M6.4 doesn't render this anywhere
|
||||||
|
|
@ -139,6 +130,9 @@ local function new_handle(buffer_id)
|
||||||
_current_style = nil,
|
_current_style = nil,
|
||||||
_alt_screen = false,
|
_alt_screen = false,
|
||||||
_title = nil,
|
_title = nil,
|
||||||
|
_output_pos = 0,
|
||||||
|
_capture = "history",
|
||||||
|
_style_overlay = nil,
|
||||||
_self_write = false,
|
_self_write = false,
|
||||||
_intercept_handle = nil,
|
_intercept_handle = nil,
|
||||||
-- Scrollback block index (M6.7). The first block is degenerate
|
-- Scrollback block index (M6.7). The first block is degenerate
|
||||||
|
|
@ -154,6 +148,33 @@ local function new_handle(buffer_id)
|
||||||
}, Handle)
|
}, Handle)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function sync_marks(h)
|
||||||
|
h._history_end = h._history_end_mark:get()
|
||||||
|
h._prompt_end = h._prompt_end_mark:get()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function history_end(h)
|
||||||
|
local pos = h._history_end_mark:get()
|
||||||
|
h._history_end = pos
|
||||||
|
return pos
|
||||||
|
end
|
||||||
|
|
||||||
|
local function prompt_end(h)
|
||||||
|
local pos = h._prompt_end_mark:get()
|
||||||
|
h._prompt_end = pos
|
||||||
|
return pos
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_history_end(h, pos)
|
||||||
|
h._history_end_mark:set(pos)
|
||||||
|
h._history_end = pos
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_prompt_end(h, pos)
|
||||||
|
h._prompt_end_mark:set(pos)
|
||||||
|
h._prompt_end = pos
|
||||||
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
-- Construction / teardown
|
-- Construction / teardown
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
|
|
@ -166,6 +187,9 @@ function repl.create(opts)
|
||||||
h._intercept_handle = pmacs.buffer.add_intercept(buf, function(op)
|
h._intercept_handle = pmacs.buffer.add_intercept(buf, function(op)
|
||||||
return repl._intercept(h, op)
|
return repl._intercept(h, op)
|
||||||
end)
|
end)
|
||||||
|
if pmacs.buffer.add_style_overlay then
|
||||||
|
h._style_overlay = pmacs.buffer.add_style_overlay(buf)
|
||||||
|
end
|
||||||
return h
|
return h
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -199,6 +223,24 @@ local function basename(s)
|
||||||
return (s:gsub("^.*/", ""))
|
return (s:gsub("^.*/", ""))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function copy_env(env)
|
||||||
|
local out = {}
|
||||||
|
if env then
|
||||||
|
for k, v in pairs(env) do out[k] = v end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shell_prompt_marker_env(argv, base_env)
|
||||||
|
local shell = basename(argv[1])
|
||||||
|
if shell ~= "bash" and shell ~= "zsh" then
|
||||||
|
return base_env
|
||||||
|
end
|
||||||
|
local env = copy_env(base_env)
|
||||||
|
env.PS1 = "\27]133;A\7$ \27]133;B\7"
|
||||||
|
return env
|
||||||
|
end
|
||||||
|
|
||||||
function repl.spawn(opts)
|
function repl.spawn(opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local argv = validate_argv(opts.argv)
|
local argv = validate_argv(opts.argv)
|
||||||
|
|
@ -220,9 +262,14 @@ function repl.spawn(opts)
|
||||||
command = argv[1],
|
command = argv[1],
|
||||||
args = args,
|
args = args,
|
||||||
pty = { rows = rows, cols = cols, mode = "raw" },
|
pty = { rows = rows, cols = cols, mode = "raw" },
|
||||||
|
ansi = true,
|
||||||
}
|
}
|
||||||
if opts.cwd then spec.cwd = opts.cwd end
|
if opts.cwd then spec.cwd = opts.cwd end
|
||||||
if opts.env then spec.env = opts.env end
|
local env = opts.env
|
||||||
|
if opts.prompt_markers ~= false then
|
||||||
|
env = shell_prompt_marker_env(argv, env)
|
||||||
|
end
|
||||||
|
if env then spec.env = env end
|
||||||
|
|
||||||
local proc_id = pmacs.process.spawn(spec)
|
local proc_id = pmacs.process.spawn(spec)
|
||||||
h._proc_id = proc_id
|
h._proc_id = proc_id
|
||||||
|
|
@ -236,6 +283,9 @@ function repl.spawn(opts)
|
||||||
if pmacs.window and pmacs.window.switch_buffer then
|
if pmacs.window and pmacs.window.switch_buffer then
|
||||||
pcall(pmacs.window.switch_buffer, h._buf)
|
pcall(pmacs.window.switch_buffer, h._buf)
|
||||||
end
|
end
|
||||||
|
if pmacs.buffer.attach_style_overlay and h._style_overlay then
|
||||||
|
pcall(pmacs.buffer.attach_style_overlay, h._buf, h._style_overlay)
|
||||||
|
end
|
||||||
|
|
||||||
-- Buffer-scoped bindings. RET submits the input region to the
|
-- Buffer-scoped bindings. RET submits the input region to the
|
||||||
-- process; C-c sends SIGINT; C-d closes stdin (when input empty)
|
-- process; C-c sends SIGINT; C-d closes stdin (when input empty)
|
||||||
|
|
@ -295,11 +345,11 @@ function Handle:buffer_id()
|
||||||
end
|
end
|
||||||
|
|
||||||
function Handle:history_end()
|
function Handle:history_end()
|
||||||
return self._history_end
|
return history_end(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Handle:prompt_end()
|
function Handle:prompt_end()
|
||||||
return self._prompt_end
|
return prompt_end(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Handle:title()
|
function Handle:title()
|
||||||
|
|
@ -307,13 +357,18 @@ function Handle:title()
|
||||||
end
|
end
|
||||||
|
|
||||||
function Handle:input_text()
|
function Handle:input_text()
|
||||||
return self._buf:slice(self._prompt_end, self._buf:len())
|
return self._buf:slice(prompt_end(self), self._buf:len())
|
||||||
end
|
end
|
||||||
|
|
||||||
function Handle:alt_screen_active()
|
function Handle:alt_screen_active()
|
||||||
return self._alt_screen
|
return self._alt_screen
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Handle:style_spans()
|
||||||
|
if not self._style_overlay then return {} end
|
||||||
|
return self._style_overlay:spans()
|
||||||
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
-- Package-driven writes
|
-- Package-driven writes
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
|
|
@ -325,11 +380,18 @@ end
|
||||||
-- suppression at the parser level (so Text events between markers
|
-- suppression at the parser level (so Text events between markers
|
||||||
-- never reach us).
|
-- never reach us).
|
||||||
function Handle:append_output(bytes)
|
function Handle:append_output(bytes)
|
||||||
local events = self._parser:feed(bytes)
|
self:append_events(self._parser:feed(bytes))
|
||||||
|
end
|
||||||
|
|
||||||
|
function Handle:append_events(events)
|
||||||
for _, ev in ipairs(events) do
|
for _, ev in ipairs(events) do
|
||||||
local kind = ev.kind
|
local kind = ev.kind
|
||||||
if kind == "text" then
|
if kind == "text" then
|
||||||
self:_emit_history(ev.text)
|
if self._capture == "prompt" then
|
||||||
|
self:_emit_prompt(ev.text)
|
||||||
|
else
|
||||||
|
self:_emit_history(ev.text)
|
||||||
|
end
|
||||||
elseif kind == "set_style" then
|
elseif kind == "set_style" then
|
||||||
self._current_style = ev.style
|
self._current_style = ev.style
|
||||||
elseif kind == "alt_screen_enter" then
|
elseif kind == "alt_screen_enter" then
|
||||||
|
|
@ -338,12 +400,28 @@ function Handle:append_output(bytes)
|
||||||
self._alt_screen = false
|
self._alt_screen = false
|
||||||
elseif kind == "set_title" then
|
elseif kind == "set_title" then
|
||||||
self._title = ev.title
|
self._title = ev.title
|
||||||
-- carriage_return, backspace, erase_to_eol, erase_line,
|
elseif kind == "prompt_start" then
|
||||||
-- bracketed_paste_*: parsed-and-acknowledged in M6.4. Their
|
self:_begin_prompt_capture()
|
||||||
-- semantic effects (CR rewinds input cursor, erase rewrites
|
elseif kind == "prompt_end" then
|
||||||
-- in-place output, etc.) are M6.5+ refinements where they
|
self:_end_prompt_capture()
|
||||||
-- meet a real shell. M6.4's "synthetic stream" tests don't
|
elseif kind == "command_start" or kind == "output_start" then
|
||||||
-- exercise them.
|
self:_begin_command_output()
|
||||||
|
elseif kind == "carriage_return" then
|
||||||
|
self._output_pos = self:_current_line_start()
|
||||||
|
elseif kind == "backspace" then
|
||||||
|
local line_start = self:_current_line_start()
|
||||||
|
if self._output_pos > line_start then
|
||||||
|
self._output_pos = self._output_pos - 1
|
||||||
|
end
|
||||||
|
elseif kind == "erase_to_eol" then
|
||||||
|
self:_delete_history_range(self._output_pos, self:_current_line_end())
|
||||||
|
elseif kind == "erase_line" then
|
||||||
|
local line_start = self:_current_line_start()
|
||||||
|
local line_end = self:_current_line_end()
|
||||||
|
self:_delete_history_range(line_start, line_end)
|
||||||
|
self._output_pos = line_start
|
||||||
|
-- bracketed_paste_* markers are delimiters only; process-emitted
|
||||||
|
-- contents are ordinary text events between them.
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -352,10 +430,13 @@ end
|
||||||
-- untouched; the input region is preserved (it sits past prompt_end).
|
-- untouched; the input region is preserved (it sits past prompt_end).
|
||||||
function Handle:set_prompt(text)
|
function Handle:set_prompt(text)
|
||||||
text = text or ""
|
text = text or ""
|
||||||
|
local h_end = history_end(self)
|
||||||
|
local p_end = prompt_end(self)
|
||||||
with_self_write(self, function()
|
with_self_write(self, function()
|
||||||
self._buf:replace(self._history_end, self._prompt_end, text)
|
self._buf:replace(h_end, p_end, text)
|
||||||
end)
|
end)
|
||||||
self._prompt_end = self._history_end + #text
|
set_prompt_end(self, history_end(self) + #text)
|
||||||
|
sync_marks(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Pop the input region's text. Returns the popped string. Does NOT
|
-- Pop the input region's text. Returns the popped string. Does NOT
|
||||||
|
|
@ -368,12 +449,14 @@ end
|
||||||
-- start_byte invariant.
|
-- start_byte invariant.
|
||||||
function Handle:submit()
|
function Handle:submit()
|
||||||
local text = self:input_text()
|
local text = self:input_text()
|
||||||
|
local p_end = prompt_end(self)
|
||||||
with_self_write(self, function()
|
with_self_write(self, function()
|
||||||
self._buf:delete(self._prompt_end, self._buf:len())
|
self._buf:delete(p_end, self._buf:len())
|
||||||
end)
|
end)
|
||||||
local last = self._blocks[#self._blocks]
|
local last = self._blocks[#self._blocks]
|
||||||
if self._history_end > last.start_byte then
|
local h_end = history_end(self)
|
||||||
self._blocks[#self._blocks + 1] = { start_byte = self._history_end }
|
if h_end > last.start_byte then
|
||||||
|
self._blocks[#self._blocks + 1] = { start_byte = h_end }
|
||||||
end
|
end
|
||||||
return text
|
return text
|
||||||
end
|
end
|
||||||
|
|
@ -384,18 +467,132 @@ end
|
||||||
|
|
||||||
function Handle:_emit_history(text)
|
function Handle:_emit_history(text)
|
||||||
if #text == 0 then return end
|
if #text == 0 then return end
|
||||||
|
local h_end = history_end(self)
|
||||||
|
local pos = self._output_pos or h_end
|
||||||
|
if pos > h_end then pos = h_end end
|
||||||
|
local overwrite_len = math.min(#text, h_end - pos)
|
||||||
|
local insert_len = #text - overwrite_len
|
||||||
with_self_write(self, function()
|
with_self_write(self, function()
|
||||||
self._buf:insert(self._history_end, text)
|
if overwrite_len > 0 then
|
||||||
|
self._buf:replace(pos, pos + overwrite_len, text:sub(1, overwrite_len))
|
||||||
|
end
|
||||||
|
if insert_len > 0 then
|
||||||
|
self._buf:insert(pos + overwrite_len, text:sub(overwrite_len + 1))
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
local n = #text
|
if insert_len > 0 then
|
||||||
self._history_end = self._history_end + n
|
self:_adjust_blocks_after_edit(pos + overwrite_len, 0, insert_len)
|
||||||
self._prompt_end = self._prompt_end + n
|
end
|
||||||
|
sync_marks(self)
|
||||||
|
set_history_end(self, h_end + insert_len)
|
||||||
|
if prompt_end(self) < history_end(self) then
|
||||||
|
set_prompt_end(self, history_end(self))
|
||||||
|
end
|
||||||
|
self._output_pos = pos + #text
|
||||||
|
self:_add_style_span(pos, pos + #text)
|
||||||
-- M6.7: mark the handle for the next tick's truncation check.
|
-- M6.7: mark the handle for the next tick's truncation check.
|
||||||
-- Per-byte work beyond this assignment regresses the M6.6 100 MB/s
|
-- Per-byte work beyond this assignment regresses the M6.6 100 MB/s
|
||||||
-- ingest gate; line counting is deferred to _maybe_truncate.
|
-- ingest gate; line counting is deferred to _maybe_truncate.
|
||||||
self._dirty_since_last_tick = true
|
self._dirty_since_last_tick = true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Handle:_begin_prompt_capture()
|
||||||
|
self._capture = "prompt"
|
||||||
|
self:set_prompt("")
|
||||||
|
end
|
||||||
|
|
||||||
|
function Handle:_emit_prompt(text)
|
||||||
|
if #text == 0 then return end
|
||||||
|
local p_end = prompt_end(self)
|
||||||
|
with_self_write(self, function()
|
||||||
|
self._buf:insert(p_end, text)
|
||||||
|
end)
|
||||||
|
set_prompt_end(self, p_end + #text)
|
||||||
|
self:_add_style_span(p_end, p_end + #text)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Handle:_end_prompt_capture()
|
||||||
|
self._capture = "history"
|
||||||
|
self._output_pos = history_end(self)
|
||||||
|
sync_marks(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Handle:_begin_command_output()
|
||||||
|
self._capture = "history"
|
||||||
|
self:set_prompt("")
|
||||||
|
self._output_pos = history_end(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function style_is_default(style)
|
||||||
|
if not style then return true end
|
||||||
|
return style.fg == "default"
|
||||||
|
and style.bg == "default"
|
||||||
|
and not style.bold
|
||||||
|
and not style.italic
|
||||||
|
and style.underline == "none"
|
||||||
|
and not style.reverse
|
||||||
|
end
|
||||||
|
|
||||||
|
function Handle:_add_style_span(start_pos, end_pos)
|
||||||
|
if not self._style_overlay then return end
|
||||||
|
if start_pos >= end_pos then return end
|
||||||
|
if style_is_default(self._current_style) then return end
|
||||||
|
self._style_overlay:add(start_pos, end_pos, self._current_style)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Handle:_adjust_blocks_after_edit(start_pos, old_len, new_len)
|
||||||
|
local delta = new_len - old_len
|
||||||
|
if delta == 0 then return end
|
||||||
|
for i = 1, #self._blocks do
|
||||||
|
local b = self._blocks[i]
|
||||||
|
if b.start_byte > start_pos then
|
||||||
|
b.start_byte = b.start_byte + delta
|
||||||
|
if b.start_byte < start_pos then b.start_byte = start_pos end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Handle:_current_line_start()
|
||||||
|
local h_end = history_end(self)
|
||||||
|
local pos = self._output_pos or h_end
|
||||||
|
if pos > h_end then pos = h_end end
|
||||||
|
local prefix = self._buf:slice(0, pos)
|
||||||
|
local start = 0
|
||||||
|
local search = 1
|
||||||
|
while true do
|
||||||
|
local idx = prefix:find("\n", search, true)
|
||||||
|
if not idx then return start end
|
||||||
|
start = idx
|
||||||
|
search = idx + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Handle:_current_line_end()
|
||||||
|
local h_end = history_end(self)
|
||||||
|
local pos = self._output_pos or h_end
|
||||||
|
if pos > h_end then pos = h_end end
|
||||||
|
local suffix = self._buf:slice(pos, h_end)
|
||||||
|
local idx = suffix:find("\n", 1, true)
|
||||||
|
if idx then return pos + idx - 1 end
|
||||||
|
return h_end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Handle:_delete_history_range(start_pos, end_pos)
|
||||||
|
if end_pos <= start_pos then return end
|
||||||
|
with_self_write(self, function()
|
||||||
|
self._buf:delete(start_pos, end_pos)
|
||||||
|
end)
|
||||||
|
local removed = end_pos - start_pos
|
||||||
|
sync_marks(self)
|
||||||
|
if self._output_pos > end_pos then
|
||||||
|
self._output_pos = self._output_pos - removed
|
||||||
|
elseif self._output_pos > start_pos then
|
||||||
|
self._output_pos = start_pos
|
||||||
|
end
|
||||||
|
self:_adjust_blocks_after_edit(start_pos, removed, 0)
|
||||||
|
self._dirty_since_last_tick = true
|
||||||
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
-- Scrollback truncation (M6.7)
|
-- Scrollback truncation (M6.7)
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
|
|
@ -420,7 +617,7 @@ end
|
||||||
-- (16 MiB / 10000 lines), and skipped entirely by the byte-only
|
-- (16 MiB / 10000 lines), and skipped entirely by the byte-only
|
||||||
-- shortcut in within_limits.
|
-- shortcut in within_limits.
|
||||||
local function history_lines(h)
|
local function history_lines(h)
|
||||||
return count_newlines(h._buf:slice(0, h._history_end))
|
return count_newlines(h._buf:slice(0, history_end(h)))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Both invariants in one predicate, with a fast path that avoids the
|
-- Both invariants in one predicate, with a fast path that avoids the
|
||||||
|
|
@ -431,8 +628,9 @@ end
|
||||||
-- pay for the line scan.
|
-- pay for the line scan.
|
||||||
local function within_limits(h)
|
local function within_limits(h)
|
||||||
local cfg = repl.config
|
local cfg = repl.config
|
||||||
if h._history_end > cfg.scrollback_bytes then return false end
|
local h_end = history_end(h)
|
||||||
if h._history_end <= cfg.scrollback_lines then return true end
|
if h_end > cfg.scrollback_bytes then return false end
|
||||||
|
if h_end <= cfg.scrollback_lines then return true end
|
||||||
return history_lines(h) <= cfg.scrollback_lines
|
return history_lines(h) <= cfg.scrollback_lines
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -449,8 +647,8 @@ local function drop_oldest_block(h)
|
||||||
with_self_write(h, function()
|
with_self_write(h, function()
|
||||||
h._buf:delete(first.start_byte, second.start_byte)
|
h._buf:delete(first.start_byte, second.start_byte)
|
||||||
end)
|
end)
|
||||||
h._history_end = h._history_end - removed_bytes
|
sync_marks(h)
|
||||||
h._prompt_end = h._prompt_end - removed_bytes
|
h._output_pos = math.max(0, (h._output_pos or history_end(h)) - removed_bytes)
|
||||||
table.remove(h._blocks, 1)
|
table.remove(h._blocks, 1)
|
||||||
for i = 1, #h._blocks do
|
for i = 1, #h._blocks do
|
||||||
h._blocks[i].start_byte = h._blocks[i].start_byte - removed_bytes
|
h._blocks[i].start_byte = h._blocks[i].start_byte - removed_bytes
|
||||||
|
|
@ -489,7 +687,7 @@ function repl._intercept(h, op)
|
||||||
if h._self_write then
|
if h._self_write then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local prompt_end = h._prompt_end
|
local prompt_end = prompt_end(h)
|
||||||
if op.kind == "insert" then
|
if op.kind == "insert" then
|
||||||
if op.pos < prompt_end then
|
if op.pos < prompt_end then
|
||||||
error("REPL: history/prompt region is read-only (insert at "
|
error("REPL: history/prompt region is read-only (insert at "
|
||||||
|
|
@ -547,6 +745,8 @@ local function drain_handle(h)
|
||||||
-- they do (regression / pipe-mode use), routing them through
|
-- they do (regression / pipe-mode use), routing them through
|
||||||
-- append_output preserves user output rather than dropping it.
|
-- append_output preserves user output rather than dropping it.
|
||||||
h:append_output(ev.bytes)
|
h:append_output(ev.bytes)
|
||||||
|
elseif kind == "ansi" then
|
||||||
|
h:append_events(ev.events)
|
||||||
elseif kind == "exited" or kind == "signaled" or kind == "crashed" then
|
elseif kind == "exited" or kind == "signaled" or kind == "crashed" then
|
||||||
h:_on_exit(ev)
|
h:_on_exit(ev)
|
||||||
end
|
end
|
||||||
|
|
@ -672,9 +872,9 @@ pmacs.command.define {
|
||||||
-- delete-char-forward when the input region is non-empty so users
|
-- delete-char-forward when the input region is non-empty so users
|
||||||
-- never see C-d as broken. Empty case writes \x04 (EOT); raw-mode
|
-- never see C-d as broken. Empty case writes \x04 (EOT); raw-mode
|
||||||
-- shells with a line editor interpret that as end-of-input. Non-empty
|
-- shells with a line editor interpret that as end-of-input. Non-empty
|
||||||
-- case delegates to the existing pmacs.editor.delete_forward primitive
|
-- case deletes through the REPL buffer at the cursor when it is inside
|
||||||
-- (which the M6.4 intercept policy guards: deletes inside the input
|
-- the input region, falling back to the input start if the editor
|
||||||
-- region pass through, deletes into prompt/history are rejected).
|
-- cursor is stale/outside the region.
|
||||||
pmacs.command.define {
|
pmacs.command.define {
|
||||||
name = "pmacs.repl.send-eof-current",
|
name = "pmacs.repl.send-eof-current",
|
||||||
description = "Close stdin on empty input region; delete-char-forward otherwise.",
|
description = "Close stdin on empty input region; delete-char-forward otherwise.",
|
||||||
|
|
@ -685,7 +885,11 @@ pmacs.command.define {
|
||||||
if h:input_text() == "" then
|
if h:input_text() == "" then
|
||||||
pmacs.process.write_stdin(h._proc_id, "\x04")
|
pmacs.process.write_stdin(h._proc_id, "\x04")
|
||||||
else
|
else
|
||||||
pmacs.editor.delete_forward()
|
local start = h:prompt_end()
|
||||||
|
local len = h._buf:len()
|
||||||
|
local pos = pmacs.editor.cursor()
|
||||||
|
if pos < start or pos >= len then pos = start end
|
||||||
|
if pos < len then h._buf:delete(pos, pos + 1) end
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
# Package installation
|
||||||
|
|
||||||
|
pmacs's package surface lives at `pmacs.packages.*` in Lua. Two
|
||||||
|
install variants ship in v0.1: `install` (user-config scope) and
|
||||||
|
`install_project` (project scope). Both run synchronously during
|
||||||
|
init; mid-session install is not supported.
|
||||||
|
|
||||||
|
## `pmacs.packages.install { ... }` — user-config scope
|
||||||
|
|
||||||
|
Installs to `$XDG_DATA_HOME/pmacs/packages/<basename>/` (or
|
||||||
|
`$HOME/.local/share/pmacs/packages/...` if `XDG_DATA_HOME` is unset).
|
||||||
|
The package's entry module is wired into Lua's require resolution so
|
||||||
|
`require("<basename>")` returns the module's table.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
pmacs.packages.install {
|
||||||
|
"github:owner/repo",
|
||||||
|
version = "^1.0.0",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The shorthand string form is also accepted:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
pmacs.packages.install "github:owner/repo@^1.0.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
## `pmacs.packages.install_project { ... }` — project scope
|
||||||
|
|
||||||
|
Installs to `<project_root>/.pmacs/packages/<basename>/`. Project
|
||||||
|
installs are prepended to `package.path` so they take precedence
|
||||||
|
over user-config installs of the same basename.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
pmacs.packages.install_project {
|
||||||
|
"github:owner/repo",
|
||||||
|
version = "^1.0.0",
|
||||||
|
project_root = "/abs/path/to/project",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `project_root` is required
|
||||||
|
|
||||||
|
`install_project` requires an explicit `project_root`. There is **no
|
||||||
|
fallback to the process CWD**: at init time CWD is whatever shell
|
||||||
|
directory the user happened to invoke pmacs from, which is almost
|
||||||
|
never a meaningful project root.
|
||||||
|
|
||||||
|
If you omit the field you get a typed error that names two concrete
|
||||||
|
patterns for filling it in. Pick whichever fits:
|
||||||
|
|
||||||
|
#### Pattern A: an environment variable (CI, scripts, multiple machines)
|
||||||
|
|
||||||
|
```lua
|
||||||
|
pmacs.packages.install_project {
|
||||||
|
"github:owner/repo",
|
||||||
|
version = "^1.0.0",
|
||||||
|
project_root = os.getenv("PMACS_PROJECT"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The user (or the CI runner) sets `PMACS_PROJECT=/path/to/project`
|
||||||
|
before invoking `pmacs`. The path is stable across invocations
|
||||||
|
regardless of where the shell happened to be.
|
||||||
|
|
||||||
|
#### Pattern B: a path relative to the loading `init.lua`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
pmacs.packages.install_project {
|
||||||
|
"github:owner/repo",
|
||||||
|
version = "^1.0.0",
|
||||||
|
project_root = ".",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Relative paths in `project_root` resolve against the directory
|
||||||
|
**containing the loading `init.lua`**, *not* against CWD. So
|
||||||
|
`project_root = "."` means "alongside this init.lua";
|
||||||
|
`project_root = "subdir"` means a subdirectory of the init.lua's
|
||||||
|
directory.
|
||||||
|
|
||||||
|
This works because pmacs's loader stamps each chunk with a `@<path>`
|
||||||
|
source label (the standard Lua convention for file-loaded chunks);
|
||||||
|
the install binding reads that label back from a per-eval app-data
|
||||||
|
slot to recover the chunk's directory.
|
||||||
|
|
||||||
|
Edge case: when running pmacs's package API from a Lua chunk that
|
||||||
|
was *not* loaded from a file (e.g., via `pmacs --eval ...`, or from
|
||||||
|
the M-x command-line evaluator), there is no source label. Relative
|
||||||
|
`project_root` values then fall through to "as-is," matching the
|
||||||
|
pre-v0.1 CWD interpretation. This is intentionally ad-hoc: the only
|
||||||
|
flow that matters in v0.1 is init.lua, and string-loaded chunks
|
||||||
|
that need an exact path can use Pattern A or pass an absolute path
|
||||||
|
literally.
|
||||||
|
|
||||||
|
### Forward planning: project-local `init.lua`
|
||||||
|
|
||||||
|
When project-local `init.lua` lands (post-v0.1), the project loader
|
||||||
|
will set a "current project root" before evaluating the project's
|
||||||
|
init.lua, and `install_project` from inside that init.lua will pick
|
||||||
|
up the project root automatically — no `project_root` field needed.
|
||||||
|
The user-global init.lua path will continue to require an explicit
|
||||||
|
field, since it has no implicit project context.
|
||||||
|
|
||||||
|
The change will be relaxation, not breakage: code that explicitly
|
||||||
|
passes `project_root` keeps working unchanged.
|
||||||
|
|
||||||
|
## `pmacs.packages.installed()`
|
||||||
|
|
||||||
|
Returns an array of records describing every package installed
|
||||||
|
during the current init pass. Each record has the same shape as
|
||||||
|
`install`'s return value (`name`, `version`, `commit`,
|
||||||
|
`install_path`, `entry`, `scope`, `summary`).
|
||||||
|
|
||||||
|
## `pmacs.packages.update(...)`
|
||||||
|
|
||||||
|
Stubbed for v0.1. M7.6 implements re-resolution and lockfile
|
||||||
|
regeneration. Until then, re-running `install` with a new constraint
|
||||||
|
upgrades in place.
|
||||||
|
|
||||||
|
## Errors and how they're shaped
|
||||||
|
|
||||||
|
Every install error message names the operation, the input that
|
||||||
|
caused the failure, and (where applicable) the workaround. Examples:
|
||||||
|
|
||||||
|
- `pmacs.packages.install_project requires an explicit project_root field. Pass project_root = "/path/to/your/project" (often os.getenv("PMACS_PROJECT") or a path relative to the directory containing your init.lua).`
|
||||||
|
- `package at tag v2.0.0 of github:owner/repo requires pmacs ">= 2.0.0", but this pmacs is "0.1.0". Upgrade pmacs, or pin a package version compatible with "0.1.0".`
|
||||||
|
- `no tag for github:owner/repo satisfies "^99.0.0". Available tags: ["v1.0.0", "v1.1.0"]`
|
||||||
|
|
||||||
|
The convention is that the error stands on its own — read in a CI
|
||||||
|
log or stack trace, the user can see what to do without context.
|
||||||
|
|
@ -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.
|
||||||
419
src/ansi.rs
419
src/ansi.rs
|
|
@ -77,6 +77,14 @@ pub enum AnsiEvent {
|
||||||
/// Set the window title (OSC 0 / OSC 2 with terminator).
|
/// Set the window title (OSC 0 / OSC 2 with terminator).
|
||||||
/// Exposed as a per-buffer attribute by the M6.4 REPL view.
|
/// Exposed as a per-buffer attribute by the M6.4 REPL view.
|
||||||
SetTitle(String),
|
SetTitle(String),
|
||||||
|
/// `OSC 133;A`: shell prompt begins.
|
||||||
|
PromptStart,
|
||||||
|
/// `OSC 133;B`: shell prompt ends.
|
||||||
|
PromptEnd,
|
||||||
|
/// `OSC 133;C`: command input begins.
|
||||||
|
CommandStart,
|
||||||
|
/// `OSC 133;D`: command output begins / command finished marker.
|
||||||
|
OutputStart,
|
||||||
/// `CSI 200 ~`: a process-emitted bracketed-paste begin marker.
|
/// `CSI 200 ~`: a process-emitted bracketed-paste begin marker.
|
||||||
BracketedPasteBegin,
|
BracketedPasteBegin,
|
||||||
/// `CSI 201 ~`: a process-emitted bracketed-paste end marker.
|
/// `CSI 201 ~`: a process-emitted bracketed-paste end marker.
|
||||||
|
|
@ -286,8 +294,20 @@ pub struct AnsiParser {
|
||||||
ignore_byte_count: usize,
|
ignore_byte_count: usize,
|
||||||
/// In-progress text run accumulator. Flushed as a single
|
/// In-progress text run accumulator. Flushed as a single
|
||||||
/// [`AnsiEvent::Text`] when we leave Ground for any non-Ground
|
/// [`AnsiEvent::Text`] when we leave Ground for any non-Ground
|
||||||
/// state.
|
/// state. Holds only complete (valid UTF-8) characters; partial
|
||||||
|
/// multi-byte sequences live in `utf8_buf` until they complete.
|
||||||
text_run: String,
|
text_run: String,
|
||||||
|
/// Pending UTF-8 bytes that haven't yet decoded to a complete
|
||||||
|
/// scalar. Cross-feed buffer: a multi-byte sequence split across
|
||||||
|
/// `feed()` calls accumulates here until the trailing byte
|
||||||
|
/// arrives (or until a non-continuation byte invalidates the
|
||||||
|
/// sequence, at which point we emit U+FFFD for the malformed
|
||||||
|
/// bytes and continue). The buffer holds at most a few bytes
|
||||||
|
/// (the longest valid UTF-8 sequence is 4 bytes; we cap at 8
|
||||||
|
/// defensively). This replaces the M6.3-stage-1 shortcut where
|
||||||
|
/// every non-ASCII byte was emitted as U+FFFD regardless of
|
||||||
|
/// whether it was actually malformed.
|
||||||
|
utf8_buf: Vec<u8>,
|
||||||
/// Suppress `Text` and `SetStyle` events while alternate-screen
|
/// Suppress `Text` and `SetStyle` events while alternate-screen
|
||||||
/// is active. Spec §sec:ansi-scope: parser advances state
|
/// is active. Spec §sec:ansi-scope: parser advances state
|
||||||
/// normally but emits no payload.
|
/// normally but emits no payload.
|
||||||
|
|
@ -321,6 +341,7 @@ impl AnsiParser {
|
||||||
current_style: Style::default(),
|
current_style: Style::default(),
|
||||||
ignore_byte_count: 0,
|
ignore_byte_count: 0,
|
||||||
text_run: String::new(),
|
text_run: String::new(),
|
||||||
|
utf8_buf: Vec::new(),
|
||||||
alt_screen_active: false,
|
alt_screen_active: false,
|
||||||
csi: CsiCollector::default(),
|
csi: CsiCollector::default(),
|
||||||
osc_body: Vec::new(),
|
osc_body: Vec::new(),
|
||||||
|
|
@ -336,6 +357,7 @@ impl AnsiParser {
|
||||||
self.state = State::Ground;
|
self.state = State::Ground;
|
||||||
self.ignore_byte_count = 0;
|
self.ignore_byte_count = 0;
|
||||||
self.text_run.clear();
|
self.text_run.clear();
|
||||||
|
self.utf8_buf.clear();
|
||||||
self.csi.reset();
|
self.csi.reset();
|
||||||
self.osc_body.clear();
|
self.osc_body.clear();
|
||||||
self.escape_intermediates.clear();
|
self.escape_intermediates.clear();
|
||||||
|
|
@ -354,7 +376,20 @@ impl AnsiParser {
|
||||||
// resolve and the bytes belong to it). Since we only build
|
// resolve and the bytes belong to it). Since we only build
|
||||||
// text_run while in Ground, this is safe to flush
|
// text_run while in Ground, this is safe to flush
|
||||||
// unconditionally as a final step.
|
// unconditionally as a final step.
|
||||||
self.flush_text_run(&mut events);
|
//
|
||||||
|
// We do NOT flush `utf8_buf` here: an incomplete multi-byte
|
||||||
|
// sequence at the feed boundary is exactly the case the
|
||||||
|
// cross-feed buffer was added to handle --- the trailing
|
||||||
|
// bytes are expected to arrive in the next feed. The state
|
||||||
|
// transition path (flush_text_run) does emit U+FFFD for
|
||||||
|
// pending bytes because a non-text byte genuinely
|
||||||
|
// interrupts the sequence; feed-boundary doesn't.
|
||||||
|
if !self.text_run.is_empty() && !self.alt_screen_active {
|
||||||
|
let run = std::mem::take(&mut self.text_run);
|
||||||
|
events.push(AnsiEvent::Text(run));
|
||||||
|
} else {
|
||||||
|
self.text_run.clear();
|
||||||
|
}
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -425,6 +460,11 @@ impl AnsiParser {
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
fn flush_text_run(&mut self, events: &mut Vec<AnsiEvent>) {
|
fn flush_text_run(&mut self, events: &mut Vec<AnsiEvent>) {
|
||||||
|
// Any pending UTF-8 prefix at this point is interrupted by
|
||||||
|
// a non-text byte (control byte, CSI start, etc.); the
|
||||||
|
// sequence can't continue across that boundary. Emit U+FFFD
|
||||||
|
// for the incomplete bytes before committing the run.
|
||||||
|
self.flush_pending_utf8_as_replacement();
|
||||||
if self.text_run.is_empty() {
|
if self.text_run.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -498,58 +538,125 @@ impl AnsiParser {
|
||||||
// line break in the rope; HT as a literal tab. Other
|
// line break in the rope; HT as a literal tab. Other
|
||||||
// C0 controls (0x00..=0x06, 0x0E..=0x1F) and DEL
|
// C0 controls (0x00..=0x06, 0x0E..=0x1F) and DEL
|
||||||
// (0x7F) are dropped silently.
|
// (0x7F) are dropped silently.
|
||||||
0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E => {
|
//
|
||||||
self.text_run.push(b as char);
|
// 0x80..=0xFF: UTF-8 lead or continuation byte. Goes
|
||||||
|
// through `push_text_byte`'s stateful decoder so
|
||||||
|
// multi-byte sequences across feeds are buffered until
|
||||||
|
// complete.
|
||||||
|
//
|
||||||
|
// All text bytes route through `push_text_byte` (not
|
||||||
|
// just non-ASCII): an ASCII byte arriving while a
|
||||||
|
// partial UTF-8 sequence is pending invalidates that
|
||||||
|
// sequence (the partial prefix's expected continuation
|
||||||
|
// didn't arrive), and `push_text_byte` is the only
|
||||||
|
// place that knows to flush the partial as `U+FFFD`.
|
||||||
|
// The fast path inside `push_text_byte` keeps the
|
||||||
|
// pure-ASCII case allocation-free.
|
||||||
|
0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => {
|
||||||
|
self.push_text_byte(b);
|
||||||
}
|
}
|
||||||
0x00..=0x1F | 0x7F => {}
|
0x00..=0x1F | 0x7F => {}
|
||||||
// 0x80..=0xFF: UTF-8 continuation. Push raw bytes; we
|
}
|
||||||
// commit on text-run flush. The push-as-char approach
|
}
|
||||||
// is wrong for multi-byte UTF-8, so we route through a
|
|
||||||
// byte-level buffer that gets decoded on flush.
|
/// Append a single text byte, handling multi-byte UTF-8
|
||||||
0x80..=0xFF => {
|
/// correctly across feed boundaries.
|
||||||
// Append as a raw byte. We store text_run as a
|
///
|
||||||
// String, so push UTF-8 bytes via a fallback path:
|
/// ASCII bytes (0x00..=0x7F) take a fast path directly into
|
||||||
// accumulate into a Vec<u8> first if we hit non-ASCII.
|
/// `text_run` when no partial sequence is pending. Non-ASCII
|
||||||
// For simplicity, push the byte and let
|
/// bytes (0x80..=0xFF) and any byte arriving while a partial
|
||||||
// String::push_byte handle it via a helper.
|
/// sequence is pending go through `utf8_buf`, which is then
|
||||||
self.push_text_byte(b);
|
/// greedily decoded by `try_decode_utf8_buf`. Complete scalars
|
||||||
|
/// flush to `text_run`; an incomplete trailing sequence stays
|
||||||
|
/// in `utf8_buf` for the next byte (whether next-feed or
|
||||||
|
/// later in the same feed).
|
||||||
|
///
|
||||||
|
/// Malformed sequences (lone continuation, invalid start byte,
|
||||||
|
/// non-continuation arriving where one was expected, overlong
|
||||||
|
/// encoding) emit `U+FFFD` for the offending bytes only and
|
||||||
|
/// resume decoding the rest --- they do not corrupt subsequent
|
||||||
|
/// valid UTF-8.
|
||||||
|
fn push_text_byte(&mut self, b: u8) {
|
||||||
|
if self.utf8_buf.is_empty() && b < 0x80 {
|
||||||
|
self.text_run.push(b as char);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.utf8_buf.push(b);
|
||||||
|
self.try_decode_utf8_buf();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Greedy UTF-8 decoder over `utf8_buf`. Pushes complete chars
|
||||||
|
/// to `text_run`; emits `U+FFFD` for malformed bytes; leaves
|
||||||
|
/// the trailing incomplete prefix in `utf8_buf` for the next
|
||||||
|
/// byte to complete.
|
||||||
|
///
|
||||||
|
/// Caps the buffer at 8 bytes defensively: a valid UTF-8
|
||||||
|
/// sequence is at most 4 bytes, so any trailing run longer
|
||||||
|
/// than that is malformed (we'd have hit either a complete
|
||||||
|
/// scalar or a `from_utf8` error before reaching 8). The cap
|
||||||
|
/// bounds the pathological-input memory cost.
|
||||||
|
fn try_decode_utf8_buf(&mut self) {
|
||||||
|
loop {
|
||||||
|
if self.utf8_buf.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match std::str::from_utf8(&self.utf8_buf) {
|
||||||
|
Ok(s) => {
|
||||||
|
// Whole buffer is valid UTF-8: flush all of it.
|
||||||
|
self.text_run.push_str(s);
|
||||||
|
self.utf8_buf.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let valid = e.valid_up_to();
|
||||||
|
if valid > 0 {
|
||||||
|
// Push the valid prefix.
|
||||||
|
let prefix = std::str::from_utf8(&self.utf8_buf[..valid])
|
||||||
|
.expect("invariant: valid_up_to bytes are valid UTF-8");
|
||||||
|
self.text_run.push_str(prefix);
|
||||||
|
self.utf8_buf.drain(..valid);
|
||||||
|
}
|
||||||
|
match e.error_len() {
|
||||||
|
None => {
|
||||||
|
// Trailing incomplete sequence; wait for
|
||||||
|
// more bytes (next push_text_byte or next
|
||||||
|
// feed). The 8-byte defensive cap below
|
||||||
|
// guards against a pathological producer
|
||||||
|
// that never finishes a sequence.
|
||||||
|
if self.utf8_buf.len() >= 8 {
|
||||||
|
self.text_run.push('\u{FFFD}');
|
||||||
|
self.utf8_buf.clear();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(n) => {
|
||||||
|
// n bytes after the valid prefix are an
|
||||||
|
// invalid sequence: emit U+FFFD for them
|
||||||
|
// and continue with whatever follows.
|
||||||
|
self.text_run.push('\u{FFFD}');
|
||||||
|
self.utf8_buf.drain(..n);
|
||||||
|
// Loop to retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a single byte to the text run, handling UTF-8
|
/// Drain any pending UTF-8 prefix as `U+FFFD`. Called from
|
||||||
/// continuation correctly. ASCII bytes go directly; non-ASCII
|
/// `flush_text_run` when the text run is being committed
|
||||||
/// bytes accumulate in a pending UTF-8 buffer that flushes
|
/// because we're transitioning out of Ground (a control byte,
|
||||||
/// once a complete scalar arrives or recovers as U+FFFD on a
|
/// a CSI start, etc.). At that boundary, an unfinished
|
||||||
/// malformed sequence.
|
/// multi-byte sequence is genuinely interrupted --- it can't
|
||||||
fn push_text_byte(&mut self, b: u8) {
|
/// continue across the non-text bytes --- so we emit the
|
||||||
// For correctness across multi-byte UTF-8 split across feeds
|
/// replacement character and clear.
|
||||||
// we'd need a stateful UTF-8 decoder. For v0.1 / M6.3, we
|
///
|
||||||
// append the byte as-is by reinterpreting the String's
|
/// Not called at end-of-feed: a sequence interrupted by feed
|
||||||
// backing buffer: since we always hit this path after an
|
/// boundary may legitimately resume in the next feed.
|
||||||
// ASCII run, and the only non-ASCII source is text, the
|
fn flush_pending_utf8_as_replacement(&mut self) {
|
||||||
// simple safe approach is to accumulate raw bytes in a
|
if !self.utf8_buf.is_empty() {
|
||||||
// separate Vec until flush time and then convert. That's a
|
|
||||||
// bigger refactor; for stage 1 we use String::from_utf8_lossy
|
|
||||||
// at flush time. Implementation bridge: stash raw bytes in
|
|
||||||
// text_run via unsafe? No --- forbid(unsafe_code). Instead,
|
|
||||||
// route through a small helper that writes the byte as a
|
|
||||||
// single-byte char iff it's ASCII, else uses a
|
|
||||||
// String-extending fallback. For non-ASCII we append a
|
|
||||||
// valid char that we'll fix up at flush time.
|
|
||||||
//
|
|
||||||
// Stage 1 simplification: convert the byte to its ASCII
|
|
||||||
// approximation. Stage 2 will introduce a proper UTF-8
|
|
||||||
// continuation decoder.
|
|
||||||
//
|
|
||||||
// TODO(M6.3 stage 2): proper UTF-8 across feed boundaries.
|
|
||||||
if b.is_ascii() {
|
|
||||||
self.text_run.push(b as char);
|
|
||||||
} else {
|
|
||||||
// Fall back: render as Unicode replacement character.
|
|
||||||
// This is wrong for proper UTF-8 input but never panics
|
|
||||||
// and never corrupts state. Stage 2 fix.
|
|
||||||
self.text_run.push('\u{FFFD}');
|
self.text_run.push('\u{FFFD}');
|
||||||
|
self.utf8_buf.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -896,10 +1003,22 @@ impl AnsiParser {
|
||||||
let num: Option<u32> = std::str::from_utf8(num_part)
|
let num: Option<u32> = std::str::from_utf8(num_part)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse().ok());
|
.and_then(|s| s.parse().ok());
|
||||||
// Only OSC 0 (set icon name + window title) and OSC 2 (set
|
if matches!(num, Some(133)) && !self.alt_screen_active {
|
||||||
// window title) produce a SetTitle event. Other OSC numbers
|
match text_part.first().copied() {
|
||||||
// are parsed and discarded per spec §sec:ansi-scope, with
|
Some(b'A') => events.push(AnsiEvent::PromptStart),
|
||||||
// the critical guarantee that state alignment is preserved.
|
Some(b'B') => events.push(AnsiEvent::PromptEnd),
|
||||||
|
Some(b'C') => events.push(AnsiEvent::CommandStart),
|
||||||
|
Some(b'D') => events.push(AnsiEvent::OutputStart),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only OSC 0 (set icon name + window title), OSC 2 (set
|
||||||
|
// window title), and the OSC 133 shell integration markers
|
||||||
|
// above produce events. Other OSC numbers are parsed and
|
||||||
|
// discarded per spec §sec:ansi-scope, with the critical
|
||||||
|
// guarantee that state alignment is preserved.
|
||||||
if matches!(num, Some(0 | 2)) && !self.alt_screen_active {
|
if matches!(num, Some(0 | 2)) && !self.alt_screen_active {
|
||||||
let title = String::from_utf8_lossy(text_part).into_owned();
|
let title = String::from_utf8_lossy(text_part).into_owned();
|
||||||
events.push(AnsiEvent::SetTitle(title));
|
events.push(AnsiEvent::SetTitle(title));
|
||||||
|
|
@ -1256,6 +1375,33 @@ mod tests {
|
||||||
assert_eq!(collect_text(&evs), "hello");
|
assert_eq!(collect_text(&evs), "hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m6_3_osc_133_prompt_markers_are_structured_events() {
|
||||||
|
let mut p = AnsiParser::new();
|
||||||
|
let evs = p.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07\x1b]133;C\x07\x1b]133;D;0\x07");
|
||||||
|
let kinds: Vec<&str> = evs
|
||||||
|
.iter()
|
||||||
|
.map(|ev| match ev {
|
||||||
|
AnsiEvent::PromptStart => "prompt_start",
|
||||||
|
AnsiEvent::Text(s) if s == "$ " => "text",
|
||||||
|
AnsiEvent::PromptEnd => "prompt_end",
|
||||||
|
AnsiEvent::CommandStart => "command_start",
|
||||||
|
AnsiEvent::OutputStart => "output_start",
|
||||||
|
other => panic!("unexpected event: {other:?}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
kinds,
|
||||||
|
vec![
|
||||||
|
"prompt_start",
|
||||||
|
"text",
|
||||||
|
"prompt_end",
|
||||||
|
"command_start",
|
||||||
|
"output_start"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Acceptance bullet 4: alternate-screen suppression
|
// Acceptance bullet 4: alternate-screen suppression
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
@ -1383,6 +1529,177 @@ mod tests {
|
||||||
// Strikethrough advances state without corrupting running style
|
// Strikethrough advances state without corrupting running style
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---- UTF-8 cross-feed handling (post-M6.10 audit fix) -----------------
|
||||||
|
|
||||||
|
/// Multi-byte UTF-8 in a single feed decodes to the correct
|
||||||
|
/// scalar, not U+FFFD. Catches the M6.3-stage-1 shortcut that
|
||||||
|
/// emitted U+FFFD for every non-ASCII byte regardless of whether
|
||||||
|
/// it was actually malformed.
|
||||||
|
#[test]
|
||||||
|
fn ansi_utf8_multibyte_in_single_feed_decodes_correctly() {
|
||||||
|
let mut p = AnsiParser::new();
|
||||||
|
// "café" --- the é is U+00E9, encoded as 0xC3 0xA9 in UTF-8.
|
||||||
|
let evs = p.feed("café".as_bytes());
|
||||||
|
assert_eq!(collect_text(&evs), "café");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three-byte UTF-8 (e.g., CJK) decodes correctly. Same gate as
|
||||||
|
/// above but exercises the 1110xxxx start byte + two
|
||||||
|
/// continuations path.
|
||||||
|
#[test]
|
||||||
|
fn ansi_utf8_three_byte_sequence_decodes_correctly() {
|
||||||
|
let mut p = AnsiParser::new();
|
||||||
|
let evs = p.feed("日本語".as_bytes());
|
||||||
|
assert_eq!(collect_text(&evs), "日本語");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Four-byte UTF-8 (e.g., emoji past U+FFFF) decodes correctly.
|
||||||
|
/// Exercises the 11110xxx start byte + three continuations path.
|
||||||
|
#[test]
|
||||||
|
fn ansi_utf8_four_byte_sequence_decodes_correctly() {
|
||||||
|
let mut p = AnsiParser::new();
|
||||||
|
// U+1F600 = 😀 = 0xF0 0x9F 0x98 0x80
|
||||||
|
let evs = p.feed("😀".as_bytes());
|
||||||
|
assert_eq!(collect_text(&evs), "😀");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A multi-byte sequence split across two feeds completes
|
||||||
|
/// correctly. This is the load-bearing cross-feed behavior:
|
||||||
|
/// pre-fix, the first feed would emit U+FFFD for the start byte
|
||||||
|
/// and the second feed would emit U+FFFDs for the continuation
|
||||||
|
/// bytes; post-fix, the partial bytes wait in `utf8_buf` and
|
||||||
|
/// flush as one correct char when the trailing byte arrives.
|
||||||
|
#[test]
|
||||||
|
fn ansi_utf8_split_across_two_feeds() {
|
||||||
|
let mut p = AnsiParser::new();
|
||||||
|
let bytes = "café".as_bytes();
|
||||||
|
// Split between the start byte (0xC3) and continuation
|
||||||
|
// (0xA9) of the é.
|
||||||
|
let split = bytes.len() - 1;
|
||||||
|
let evs1 = p.feed(&bytes[..split]);
|
||||||
|
let evs2 = p.feed(&bytes[split..]);
|
||||||
|
let combined: String = collect_text(&evs1) + &collect_text(&evs2);
|
||||||
|
assert_eq!(combined, "café");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A four-byte sequence split across three feeds completes
|
||||||
|
/// correctly. Exercises the cross-feed buffer holding 2 bytes
|
||||||
|
/// across one feed and 1 byte across the next.
|
||||||
|
#[test]
|
||||||
|
fn ansi_utf8_four_byte_split_across_three_feeds() {
|
||||||
|
let mut p = AnsiParser::new();
|
||||||
|
// U+1F600 = 0xF0 0x9F 0x98 0x80
|
||||||
|
let evs1 = p.feed(&[0xF0]);
|
||||||
|
let evs2 = p.feed(&[0x9F, 0x98]);
|
||||||
|
let evs3 = p.feed(&[0x80]);
|
||||||
|
let combined = collect_text(&evs1) + &collect_text(&evs2) + &collect_text(&evs3);
|
||||||
|
assert_eq!(combined, "😀");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mixed ASCII and multi-byte text decodes correctly.
|
||||||
|
#[test]
|
||||||
|
fn ansi_utf8_mixed_ascii_and_multibyte() {
|
||||||
|
let mut p = AnsiParser::new();
|
||||||
|
let evs = p.feed("hello, café 日本 😀!".as_bytes());
|
||||||
|
assert_eq!(collect_text(&evs), "hello, café 日本 😀!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lone continuation byte (no preceding start) emits U+FFFD
|
||||||
|
/// for that byte only; subsequent ASCII still decodes to ASCII.
|
||||||
|
#[test]
|
||||||
|
fn ansi_utf8_lone_continuation_byte_emits_replacement() {
|
||||||
|
let mut p = AnsiParser::new();
|
||||||
|
// 0x80 alone is a continuation byte with no start.
|
||||||
|
// ASCII follows; it should decode normally.
|
||||||
|
let evs = p.feed(&[b'a', 0x80, b'b']);
|
||||||
|
assert_eq!(collect_text(&evs), "a\u{FFFD}b");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An invalid start byte (0xFF) emits U+FFFD; subsequent ASCII
|
||||||
|
/// decodes normally.
|
||||||
|
#[test]
|
||||||
|
fn ansi_utf8_invalid_start_byte_emits_replacement() {
|
||||||
|
let mut p = AnsiParser::new();
|
||||||
|
let evs = p.feed(&[b'a', 0xFF, b'b']);
|
||||||
|
assert_eq!(collect_text(&evs), "a\u{FFFD}b");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A start byte expecting two continuations followed by ASCII
|
||||||
|
/// (one continuation, then ASCII) emits U+FFFD for the
|
||||||
|
/// truncated sequence and decodes the ASCII normally.
|
||||||
|
#[test]
|
||||||
|
fn ansi_utf8_truncated_sequence_then_ascii() {
|
||||||
|
let mut p = AnsiParser::new();
|
||||||
|
// 0xE6 (start of 3-byte) + 0x97 (continuation) + 'X' (ASCII,
|
||||||
|
// not a continuation). The sequence is malformed: 0xE6 0x97
|
||||||
|
// followed by ASCII.
|
||||||
|
let evs = p.feed(&[0xE6, 0x97, b'X']);
|
||||||
|
// The 0xE6 0x97 prefix is malformed; std::str::from_utf8
|
||||||
|
// reports error_len() = 2, so we emit one U+FFFD covering
|
||||||
|
// both bytes, then ASCII.
|
||||||
|
let text = collect_text(&evs);
|
||||||
|
assert!(
|
||||||
|
text.contains('\u{FFFD}') && text.ends_with('X'),
|
||||||
|
"expected U+FFFD then 'X', got {text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A multi-byte sequence interrupted by a control byte (CSI
|
||||||
|
/// start) flushes pending bytes as U+FFFD, then resumes
|
||||||
|
/// processing the control byte normally.
|
||||||
|
#[test]
|
||||||
|
fn ansi_utf8_partial_interrupted_by_csi_emits_replacement() {
|
||||||
|
let mut p = AnsiParser::new();
|
||||||
|
// 0xC3 (start of 2-byte) then ESC [ 31 m (red SGR).
|
||||||
|
// The 0xC3 is interrupted by the ESC; should emit U+FFFD
|
||||||
|
// for the partial, then process the SGR.
|
||||||
|
let evs = p.feed(&[b'a', 0xC3, 0x1B, b'[', b'3', b'1', b'm', b'b']);
|
||||||
|
let text = collect_text(&evs);
|
||||||
|
assert!(
|
||||||
|
text.starts_with("a\u{FFFD}") && text.ends_with('b'),
|
||||||
|
"expected a + U+FFFD + b, got {text:?}"
|
||||||
|
);
|
||||||
|
// The SGR should still have produced a SetStyle event.
|
||||||
|
let styles = collect_styles(&evs);
|
||||||
|
assert_eq!(styles.len(), 1, "expected one SetStyle from the SGR");
|
||||||
|
assert_eq!(styles[0].fg, Color::Indexed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pending UTF-8 prefix that exceeds the 8-byte defensive cap
|
||||||
|
/// (pathological producer that never finishes a sequence)
|
||||||
|
/// flushes as U+FFFD and recovers. Ensures the buffer can't
|
||||||
|
/// grow unbounded.
|
||||||
|
#[test]
|
||||||
|
fn ansi_utf8_pathological_pending_caps_at_8_bytes() {
|
||||||
|
let mut p = AnsiParser::new();
|
||||||
|
// Feed 8 start bytes in a row. Each is a "start of 2-byte"
|
||||||
|
// marker; the next one arriving where a continuation is
|
||||||
|
// expected is malformed. The first one accumulates; each
|
||||||
|
// subsequent one emits U+FFFD for the malformed prefix.
|
||||||
|
// After 8 bytes accumulated without completing, the cap
|
||||||
|
// forces a flush.
|
||||||
|
let evs = p.feed(&[0xC3; 9]);
|
||||||
|
let text = collect_text(&evs);
|
||||||
|
// Every byte should have been replaced; no panic, no
|
||||||
|
// unbounded growth.
|
||||||
|
assert!(
|
||||||
|
text.chars().all(|c| c == '\u{FFFD}'),
|
||||||
|
"all 9 bytes should be replaced; got {text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `reset()` clears any pending UTF-8 prefix.
|
||||||
|
#[test]
|
||||||
|
fn ansi_utf8_reset_clears_pending_buffer() {
|
||||||
|
let mut p = AnsiParser::new();
|
||||||
|
let _ = p.feed(&[0xC3]); // partial é prefix pending
|
||||||
|
p.reset();
|
||||||
|
// Subsequent valid input must decode cleanly --- the stale
|
||||||
|
// prefix should not contaminate it.
|
||||||
|
let evs = p.feed("hello".as_bytes());
|
||||||
|
assert_eq!(collect_text(&evs), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
/// SGR 9 (strikethrough) is a no-op for the running style ---
|
/// SGR 9 (strikethrough) is a no-op for the running style ---
|
||||||
/// the cell `Style` has no strikethrough field, so the running
|
/// the cell `Style` has no strikethrough field, so the running
|
||||||
/// style must be *unchanged* after SGR 9. A future regression
|
/// style must be *unchanged* after SGR 9. A future regression
|
||||||
|
|
|
||||||
|
|
@ -1220,6 +1220,27 @@ fn classify_ssh_exit(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Try to bind a `UnixListener` at `path`. On `PermissionDenied`
|
||||||
|
/// (e.g., a sandboxed CI environment that disallows `AF_UNIX`
|
||||||
|
/// socket creation), prints a skip notice and returns `None`;
|
||||||
|
/// the calling test should early-return so the suite reports
|
||||||
|
/// `0 failed` rather than a misleading panic. Mirror of the
|
||||||
|
/// helper in `daemon_attach.rs`'s test module.
|
||||||
|
fn bind_or_skip(path: &std::path::Path) -> Option<UnixListener> {
|
||||||
|
match UnixListener::bind(path) {
|
||||||
|
Ok(l) => Some(l),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||||
|
eprintln!(
|
||||||
|
"test skipped: UnixListener::bind {} → PermissionDenied \
|
||||||
|
(sandboxed environment).",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => panic!("UnixListener::bind {} failed: {e}", path.display()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_uptime_shapes() {
|
fn format_uptime_shapes() {
|
||||||
assert_eq!(format_uptime(5), "5s");
|
assert_eq!(format_uptime(5), "5s");
|
||||||
|
|
@ -1474,7 +1495,9 @@ mod tests {
|
||||||
fn version_mismatch_errors_at_construction_site() {
|
fn version_mismatch_errors_at_construction_site() {
|
||||||
let tmp = tempfile::tempdir().expect("tempdir");
|
let tmp = tempfile::tempdir().expect("tempdir");
|
||||||
let socket_path = tmp.path().join("test.sock");
|
let socket_path = tmp.path().join("test.sock");
|
||||||
let listener = UnixListener::bind(&socket_path).expect("UnixListener::bind");
|
let Some(listener) = bind_or_skip(&socket_path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
// Fake daemon: accept one connection, write a Hello with a
|
// Fake daemon: accept one connection, write a Hello with a
|
||||||
// bogus protocol version, exit. The accept blocks until
|
// bogus protocol version, exit. The accept blocks until
|
||||||
|
|
|
||||||
347
src/buffer.rs
347
src/buffer.rs
|
|
@ -29,7 +29,7 @@
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use crate::rope::{Edit, Position, Range, Rope, RopeError};
|
use crate::rope::{Edit, Position, Range, Rope, RopeError};
|
||||||
use crate::view::View;
|
use crate::view::{InterceptContext, View};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Identifiers
|
// Identifiers
|
||||||
|
|
@ -75,6 +75,37 @@ impl ViewId {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opaque, per-buffer identifier for a position mark.
|
||||||
|
///
|
||||||
|
/// Marks are owned by a [`Buffer`] and move through edits according to
|
||||||
|
/// their gravity. They are intentionally not process-global: a
|
||||||
|
/// `MarkId(0)` from one buffer has no meaning in another buffer.
|
||||||
|
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
|
||||||
|
pub struct MarkId(u64);
|
||||||
|
|
||||||
|
impl MarkId {
|
||||||
|
/// Inspect the raw value. Useful for logging and FFI.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn raw(self) -> u64 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which side of an insertion/replacement a mark sticks to.
|
||||||
|
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
||||||
|
pub enum MarkGravity {
|
||||||
|
/// Stay before bytes inserted exactly at the mark.
|
||||||
|
Left,
|
||||||
|
/// Move after bytes inserted exactly at the mark.
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
struct Mark {
|
||||||
|
pos: Position,
|
||||||
|
gravity: MarkGravity,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Edit operations
|
// Edit operations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -156,10 +187,24 @@ pub struct Buffer {
|
||||||
views: Vec<(ViewId, Box<dyn View>)>,
|
views: Vec<(ViewId, Box<dyn View>)>,
|
||||||
/// Per-buffer counter for [`ViewId`] allocation.
|
/// Per-buffer counter for [`ViewId`] allocation.
|
||||||
next_view_id: u64,
|
next_view_id: u64,
|
||||||
|
/// Buffer-relative marks. Kept as a small vector because current
|
||||||
|
/// consumers create a handful per buffer; if this grows into
|
||||||
|
/// thousands, this can become an indexed table without changing
|
||||||
|
/// the public API.
|
||||||
|
marks: Vec<(MarkId, Mark)>,
|
||||||
|
/// Per-buffer counter for [`MarkId`] allocation.
|
||||||
|
next_mark_id: u64,
|
||||||
/// Undo stack. Most recent entry on top.
|
/// Undo stack. Most recent entry on top.
|
||||||
undo: Vec<UndoEntry>,
|
undo: Vec<UndoEntry>,
|
||||||
/// Redo stack. Cleared by any forward edit.
|
/// Redo stack. Cleared by any forward edit.
|
||||||
redo: Vec<UndoEntry>,
|
redo: Vec<UndoEntry>,
|
||||||
|
/// True while an edit is in flight on this buffer (T M7.4).
|
||||||
|
/// Set by [`Buffer::begin_edit`], cleared by [`Buffer::end_edit`].
|
||||||
|
/// A re-entrant `apply_edit` / `apply_edit_skip_intercepts` while
|
||||||
|
/// the flag is set returns [`BufferError::ConcurrentEdit`] rather
|
||||||
|
/// than mutating the rope mid-intercept; cross-buffer re-entry
|
||||||
|
/// is unaffected.
|
||||||
|
editing_in_progress: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Buffer {
|
impl Buffer {
|
||||||
|
|
@ -193,8 +238,11 @@ impl Buffer {
|
||||||
revision: 0,
|
revision: 0,
|
||||||
views: Vec::new(),
|
views: Vec::new(),
|
||||||
next_view_id: 0,
|
next_view_id: 0,
|
||||||
|
marks: Vec::new(),
|
||||||
|
next_mark_id: 0,
|
||||||
undo: Vec::new(),
|
undo: Vec::new(),
|
||||||
redo: Vec::new(),
|
redo: Vec::new(),
|
||||||
|
editing_in_progress: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,6 +338,126 @@ impl Buffer {
|
||||||
Some(self.views.remove(idx).1)
|
Some(self.views.remove(idx).1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a mark at byte position `pos`.
|
||||||
|
///
|
||||||
|
/// The position must be inside the current buffer (`pos <= len`).
|
||||||
|
/// The returned ID is scoped to this buffer.
|
||||||
|
pub fn create_mark(
|
||||||
|
&mut self,
|
||||||
|
pos: Position,
|
||||||
|
gravity: MarkGravity,
|
||||||
|
) -> Result<MarkId, BufferError> {
|
||||||
|
if pos > self.len() {
|
||||||
|
return Err(BufferError::Rope(RopeError::OutOfBounds {
|
||||||
|
pos,
|
||||||
|
len: self.len(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let id = MarkId(self.next_mark_id);
|
||||||
|
self.next_mark_id += 1;
|
||||||
|
self.marks.push((id, Mark { pos, gravity }));
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current byte position of a mark, or `None` if it has been removed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn mark_pos(&self, id: MarkId) -> Option<Position> {
|
||||||
|
self.marks
|
||||||
|
.iter()
|
||||||
|
.find_map(|(mark_id, mark)| (*mark_id == id).then_some(mark.pos))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move an existing mark to `pos`.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(false)` for an unknown mark ID. Out-of-bounds
|
||||||
|
/// positions are errors and leave the mark unchanged.
|
||||||
|
pub fn set_mark(&mut self, id: MarkId, pos: Position) -> Result<bool, BufferError> {
|
||||||
|
if pos > self.len() {
|
||||||
|
return Err(BufferError::Rope(RopeError::OutOfBounds {
|
||||||
|
pos,
|
||||||
|
len: self.len(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let Some((_, mark)) = self.marks.iter_mut().find(|(mark_id, _)| *mark_id == id) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
mark.pos = pos;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a mark. Returns `true` if the mark existed.
|
||||||
|
pub fn remove_mark(&mut self, id: MarkId) -> bool {
|
||||||
|
let Some(idx) = self.marks.iter().position(|(mark_id, _)| *mark_id == id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.marks.remove(idx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take all attached views out of the buffer, returning ownership
|
||||||
|
/// to the caller (T M7.4).
|
||||||
|
///
|
||||||
|
/// Pair with [`Buffer::restore_views`]. While the views are taken
|
||||||
|
/// out, the buffer's view list is empty: `attach_view` calls
|
||||||
|
/// during this window land in the empty list and will be
|
||||||
|
/// preserved by `restore_views`.
|
||||||
|
///
|
||||||
|
/// Used by the Lua bindings to run the intercept chain with the
|
||||||
|
/// registry borrow released, so an intercept body may safely
|
||||||
|
/// re-enter the buffer API on any buffer (including this one,
|
||||||
|
/// modulo the `editing_in_progress` gate).
|
||||||
|
pub fn take_views(&mut self) -> Vec<(ViewId, Box<dyn View>)> {
|
||||||
|
std::mem::take(&mut self.views)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore previously-taken views.
|
||||||
|
///
|
||||||
|
/// Views attached during the take/restore window are preserved
|
||||||
|
/// and ordered after the restored set. Use case: a Lua intercept
|
||||||
|
/// body on buffer A calls `pmacs.buffer.add_intercept(A, ...)` to
|
||||||
|
/// install another intercept; the new view should sit after the
|
||||||
|
/// existing chain so the existing chain still runs first on
|
||||||
|
/// future edits.
|
||||||
|
pub fn restore_views(&mut self, mut original: Vec<(ViewId, Box<dyn View>)>) {
|
||||||
|
let new_additions = std::mem::take(&mut self.views);
|
||||||
|
original.extend(new_additions);
|
||||||
|
self.views = original;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the buffer as mid-edit (T M7.4). Pairs with [`Buffer::end_edit`].
|
||||||
|
///
|
||||||
|
/// Returns [`BufferError::ConcurrentEdit`] if a previous
|
||||||
|
/// `begin_edit` is unmatched. The Lua bindings call this at the
|
||||||
|
/// start of the three-phase edit flow so that a re-entrant Lua
|
||||||
|
/// call into the same buffer's `apply_edit` /
|
||||||
|
/// `apply_edit_skip_intercepts` surfaces a typed error rather
|
||||||
|
/// than silently corrupting state.
|
||||||
|
pub fn begin_edit(&mut self) -> Result<(), BufferError> {
|
||||||
|
if self.editing_in_progress {
|
||||||
|
return Err(BufferError::ConcurrentEdit {
|
||||||
|
id: self.id,
|
||||||
|
name: self.name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.editing_in_progress = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the mid-edit flag set by [`Buffer::begin_edit`].
|
||||||
|
/// Idempotent. Lua bindings call this at the end of the edit
|
||||||
|
/// flow, before the final `apply_edit_skip_intercepts`.
|
||||||
|
pub fn end_edit(&mut self) {
|
||||||
|
self.editing_in_progress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the buffer is currently mid-edit (T M7.4).
|
||||||
|
/// Useful for diagnostic tooling; the in-process flow's
|
||||||
|
/// re-entrancy check happens inside `apply_edit` itself.
|
||||||
|
#[must_use]
|
||||||
|
pub fn editing_in_progress(&self) -> bool {
|
||||||
|
self.editing_in_progress
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply an edit.
|
/// Apply an edit.
|
||||||
///
|
///
|
||||||
/// Walks the intercept-edit chain in attach order; applies the
|
/// Walks the intercept-edit chain in attach order; applies the
|
||||||
|
|
@ -300,8 +468,24 @@ impl Buffer {
|
||||||
/// On error the buffer is left in its pre-edit state and the undo
|
/// On error the buffer is left in its pre-edit state and the undo
|
||||||
/// stack is unchanged.
|
/// stack is unchanged.
|
||||||
///
|
///
|
||||||
|
/// # Re-entrancy (T M7.4)
|
||||||
|
///
|
||||||
|
/// In-process Rust callers run intercepts under the same `&mut Buffer`
|
||||||
|
/// borrow that owns the apply --- no re-entry path exists, so the
|
||||||
|
/// `editing_in_progress` flag is not set by this method (it is set
|
||||||
|
/// only by [`Buffer::begin_edit`], which the Lua bindings use to gate
|
||||||
|
/// same-buffer re-entry). A caller that does `b.apply_edit(...)`
|
||||||
|
/// while another `apply_edit` is on the stack for the same `b`
|
||||||
|
/// would already fail at `&mut` aliasing in safe Rust.
|
||||||
|
///
|
||||||
/// Threading: main thread only.
|
/// Threading: main thread only.
|
||||||
pub fn apply_edit(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> {
|
pub fn apply_edit(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> {
|
||||||
|
if self.editing_in_progress {
|
||||||
|
return Err(BufferError::ConcurrentEdit {
|
||||||
|
id: self.id,
|
||||||
|
name: self.name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
// Take views out so the loop body can borrow `&self` while iterating.
|
// Take views out so the loop body can borrow `&self` while iterating.
|
||||||
// The buffer is left view-less only for the duration of this call;
|
// The buffer is left view-less only for the duration of this call;
|
||||||
// panics during it would leave an empty view list (acceptable: views
|
// panics during it would leave an empty view list (acceptable: views
|
||||||
|
|
@ -313,6 +497,29 @@ impl Buffer {
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply an edit, skipping the intercept chain.
|
||||||
|
///
|
||||||
|
/// Used by the Lua bindings (T M7.4) after they have run intercepts
|
||||||
|
/// out-of-band with the registry borrow released. Behaves like
|
||||||
|
/// [`Buffer::apply_edit`] from "rope edit" onward: rope mutation,
|
||||||
|
/// undo bookkeeping, modified flag, revision bump, and `on_edit`
|
||||||
|
/// broadcast all happen here.
|
||||||
|
///
|
||||||
|
/// In-process Rust callers should use [`Buffer::apply_edit`]
|
||||||
|
/// instead --- this primitive exists for the case where the
|
||||||
|
/// caller has already evaluated the intercept chain and has the
|
||||||
|
/// final [`EditOp`] in hand.
|
||||||
|
#[allow(
|
||||||
|
clippy::needless_pass_by_value,
|
||||||
|
reason = "by-value mirrors apply_edit's signature; the Lua bindings build a fresh EditOp per call"
|
||||||
|
)]
|
||||||
|
pub fn apply_edit_skip_intercepts(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> {
|
||||||
|
let mut views = std::mem::take(&mut self.views);
|
||||||
|
let result = self.run_rope_edit_and_broadcast(&mut views, &op);
|
||||||
|
self.views = views;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_edit_inner(
|
fn apply_edit_inner(
|
||||||
&mut self,
|
&mut self,
|
||||||
views: &mut [(ViewId, Box<dyn View>)],
|
views: &mut [(ViewId, Box<dyn View>)],
|
||||||
|
|
@ -320,12 +527,22 @@ impl Buffer {
|
||||||
) -> Result<Edit, BufferError> {
|
) -> Result<Edit, BufferError> {
|
||||||
// Stage 1: intercept chain.
|
// Stage 1: intercept chain.
|
||||||
let mut current = op;
|
let mut current = op;
|
||||||
|
let ctx = InterceptContext::snapshot(self);
|
||||||
for (_, view) in views.iter_mut() {
|
for (_, view) in views.iter_mut() {
|
||||||
current = view.intercept_edit(self, current)?;
|
current = view.intercept_edit(&ctx, current)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stages 2-4: rope edit + state update + broadcast.
|
||||||
|
self.run_rope_edit_and_broadcast(views, ¤t)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_rope_edit_and_broadcast(
|
||||||
|
&mut self,
|
||||||
|
views: &mut [(ViewId, Box<dyn View>)],
|
||||||
|
current: &EditOp<'_>,
|
||||||
|
) -> Result<Edit, BufferError> {
|
||||||
// Stage 2: rope edit.
|
// Stage 2: rope edit.
|
||||||
let edit = match ¤t {
|
let edit = match current {
|
||||||
EditOp::Insert { pos, bytes } => self.rope.insert(*pos, bytes)?,
|
EditOp::Insert { pos, bytes } => self.rope.insert(*pos, bytes)?,
|
||||||
EditOp::Delete { range } => self.rope.delete(range.start, range.end)?,
|
EditOp::Delete { range } => self.rope.delete(range.start, range.end)?,
|
||||||
EditOp::Replace { range, bytes } => self.rope.replace(range.start, range.end, bytes)?,
|
EditOp::Replace { range, bytes } => self.rope.replace(range.start, range.end, bytes)?,
|
||||||
|
|
@ -350,6 +567,7 @@ impl Buffer {
|
||||||
let pre_range = edit.range;
|
let pre_range = edit.range;
|
||||||
let inserted_len = edit.inserted_len;
|
let inserted_len = edit.inserted_len;
|
||||||
let old_rope = std::mem::replace(&mut self.rope, edit.new_rope.clone());
|
let old_rope = std::mem::replace(&mut self.rope, edit.new_rope.clone());
|
||||||
|
self.adjust_marks_for_edit(pre_range, inserted_len);
|
||||||
self.undo.push(UndoEntry {
|
self.undo.push(UndoEntry {
|
||||||
rope: old_rope,
|
rope: old_rope,
|
||||||
edit: EditDescription {
|
edit: EditDescription {
|
||||||
|
|
@ -391,6 +609,7 @@ impl Buffer {
|
||||||
|
|
||||||
let new_rope = entry.rope.clone();
|
let new_rope = entry.rope.clone();
|
||||||
let old_rope = std::mem::replace(&mut self.rope, new_rope.clone());
|
let old_rope = std::mem::replace(&mut self.rope, new_rope.clone());
|
||||||
|
self.adjust_marks_for_edit(inverse_pre_range, inverse_inserted_len);
|
||||||
self.redo.push(UndoEntry {
|
self.redo.push(UndoEntry {
|
||||||
rope: old_rope,
|
rope: old_rope,
|
||||||
edit: EditDescription {
|
edit: EditDescription {
|
||||||
|
|
@ -428,6 +647,7 @@ impl Buffer {
|
||||||
|
|
||||||
let new_rope = entry.rope.clone();
|
let new_rope = entry.rope.clone();
|
||||||
let old_rope = std::mem::replace(&mut self.rope, new_rope.clone());
|
let old_rope = std::mem::replace(&mut self.rope, new_rope.clone());
|
||||||
|
self.adjust_marks_for_edit(inverse_pre_range, inverse_inserted_len);
|
||||||
self.undo.push(UndoEntry {
|
self.undo.push(UndoEntry {
|
||||||
rope: old_rope,
|
rope: old_rope,
|
||||||
edit: EditDescription {
|
edit: EditDescription {
|
||||||
|
|
@ -458,6 +678,43 @@ impl Buffer {
|
||||||
self.views = views;
|
self.views = views;
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn adjust_marks_for_edit(&mut self, range: Range, inserted_len: u64) {
|
||||||
|
let start = range.start;
|
||||||
|
let end = range.end;
|
||||||
|
let old_len = range.len();
|
||||||
|
let new_end = start.saturating_add(inserted_len);
|
||||||
|
|
||||||
|
for (_, mark) in &mut self.marks {
|
||||||
|
let pos = mark.pos;
|
||||||
|
mark.pos = if pos < start {
|
||||||
|
pos
|
||||||
|
} else if pos > end {
|
||||||
|
pos - old_len + inserted_len
|
||||||
|
} else if pos == start {
|
||||||
|
if old_len == 0 && mark.gravity == MarkGravity::Right {
|
||||||
|
new_end
|
||||||
|
} else if old_len == 0 {
|
||||||
|
start
|
||||||
|
} else {
|
||||||
|
match mark.gravity {
|
||||||
|
MarkGravity::Left => start,
|
||||||
|
MarkGravity::Right => new_end,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if pos < end {
|
||||||
|
match mark.gravity {
|
||||||
|
MarkGravity::Left => start,
|
||||||
|
MarkGravity::Right => new_end,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match mark.gravity {
|
||||||
|
MarkGravity::Left => start,
|
||||||
|
MarkGravity::Right => new_end,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -486,6 +743,27 @@ pub enum BufferError {
|
||||||
/// Human-readable reason. Surfaced verbatim to the user.
|
/// Human-readable reason. Surfaced verbatim to the user.
|
||||||
reason: String,
|
reason: String,
|
||||||
},
|
},
|
||||||
|
/// A re-entrant edit was attempted on a buffer that is already
|
||||||
|
/// mid-edit (T M7.4). The most common path: a Lua intercept body
|
||||||
|
/// running on buffer A called `A:insert(...)` or similar.
|
||||||
|
/// Cross-buffer re-entry (`A`'s intercept editing `B`) is allowed
|
||||||
|
/// and does not surface this error.
|
||||||
|
///
|
||||||
|
/// The message names a workaround per the project convention.
|
||||||
|
#[error(
|
||||||
|
"buffer `{name}` (id {id:?}) is already being edited; \
|
||||||
|
re-entrant edits on the same buffer are not supported. \
|
||||||
|
To compose with the current edit, return a transformed table \
|
||||||
|
from this intercept; to schedule a follow-up edit, register an \
|
||||||
|
on-edit hook that runs after the current edit completes, or \
|
||||||
|
edit a different buffer."
|
||||||
|
)]
|
||||||
|
ConcurrentEdit {
|
||||||
|
/// The buffer's identifier.
|
||||||
|
id: BufferId,
|
||||||
|
/// The buffer's name, for diagnostics.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -525,13 +803,12 @@ mod tests {
|
||||||
impl View for RecorderView {
|
impl View for RecorderView {
|
||||||
fn intercept_edit<'a>(
|
fn intercept_edit<'a>(
|
||||||
&mut self,
|
&mut self,
|
||||||
buf: &Buffer,
|
ctx: &crate::view::InterceptContext,
|
||||||
op: EditOp<'a>,
|
op: EditOp<'a>,
|
||||||
) -> Result<EditOp<'a>, BufferError> {
|
) -> Result<EditOp<'a>, BufferError> {
|
||||||
self.events
|
self.events.lock().unwrap().push(RecorderEvent::Intercept {
|
||||||
.lock()
|
pre_len: ctx.buf_len,
|
||||||
.unwrap()
|
});
|
||||||
.push(RecorderEvent::Intercept { pre_len: buf.len() });
|
|
||||||
Ok(op)
|
Ok(op)
|
||||||
}
|
}
|
||||||
fn on_edit(&mut self, buf: &Buffer, edit: &Edit) -> Result<(), BufferError> {
|
fn on_edit(&mut self, buf: &Buffer, edit: &Edit) -> Result<(), BufferError> {
|
||||||
|
|
@ -550,7 +827,7 @@ mod tests {
|
||||||
impl View for ReverseInsertView {
|
impl View for ReverseInsertView {
|
||||||
fn intercept_edit<'a>(
|
fn intercept_edit<'a>(
|
||||||
&mut self,
|
&mut self,
|
||||||
_buf: &Buffer,
|
_ctx: &crate::view::InterceptContext,
|
||||||
op: EditOp<'a>,
|
op: EditOp<'a>,
|
||||||
) -> Result<EditOp<'a>, BufferError> {
|
) -> Result<EditOp<'a>, BufferError> {
|
||||||
// Cannot return EditOp with owned bytes given the lifetime
|
// Cannot return EditOp with owned bytes given the lifetime
|
||||||
|
|
@ -621,6 +898,58 @@ mod tests {
|
||||||
assert_eq!(collect(&b), b"abc");
|
assert_eq!(collect(&b), b"abc");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marks_apply_insertion_gravity() {
|
||||||
|
let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abcd");
|
||||||
|
let left = b.create_mark(2, MarkGravity::Left).unwrap();
|
||||||
|
let right = b.create_mark(2, MarkGravity::Right).unwrap();
|
||||||
|
|
||||||
|
b.apply_edit(EditOp::Insert {
|
||||||
|
pos: 2,
|
||||||
|
bytes: b"XX",
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(b.mark_pos(left), Some(2));
|
||||||
|
assert_eq!(b.mark_pos(right), Some(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marks_shift_and_clamp_through_delete() {
|
||||||
|
let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abcdef");
|
||||||
|
let before = b.create_mark(1, MarkGravity::Right).unwrap();
|
||||||
|
let inside = b.create_mark(3, MarkGravity::Left).unwrap();
|
||||||
|
let after = b.create_mark(5, MarkGravity::Right).unwrap();
|
||||||
|
|
||||||
|
b.apply_edit(EditOp::Delete {
|
||||||
|
range: Range::new(2, 4),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(b.mark_pos(before), Some(1));
|
||||||
|
assert_eq!(b.mark_pos(inside), Some(2));
|
||||||
|
assert_eq!(b.mark_pos(after), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marks_follow_undo_and_redo() {
|
||||||
|
let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abcd");
|
||||||
|
let mark = b.create_mark(3, MarkGravity::Right).unwrap();
|
||||||
|
|
||||||
|
b.apply_edit(EditOp::Insert {
|
||||||
|
pos: 1,
|
||||||
|
bytes: b"XX",
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(b.mark_pos(mark), Some(5));
|
||||||
|
|
||||||
|
b.undo().unwrap();
|
||||||
|
assert_eq!(b.mark_pos(mark), Some(3));
|
||||||
|
|
||||||
|
b.redo().unwrap();
|
||||||
|
assert_eq!(b.mark_pos(mark), Some(5));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn intercept_runs_before_on_edit_and_before_rope_mutation() {
|
fn intercept_runs_before_on_edit_and_before_rope_mutation() {
|
||||||
let mut b = Buffer::from_bytes(BufferId::next(), "test", b"hi");
|
let mut b = Buffer::from_bytes(BufferId::next(), "test", b"hi");
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,24 @@ pub enum RegistryError {
|
||||||
/// the Lua boundary (R52).
|
/// the Lua boundary (R52).
|
||||||
id: BufferId,
|
id: BufferId,
|
||||||
},
|
},
|
||||||
|
/// Removal was requested for a buffer that is currently mid-edit
|
||||||
|
/// (T M7.4). The most common path: a Lua intercept body on buffer
|
||||||
|
/// `A` called `pmacs.buffer.remove(A)`. Mirrors
|
||||||
|
/// [`crate::buffer::BufferError::ConcurrentEdit`] in spirit; the
|
||||||
|
/// message names the workaround per project convention.
|
||||||
|
#[error(
|
||||||
|
"buffer `{name}` (id {id:?}) is already being edited; \
|
||||||
|
it cannot be removed while an intercept is running on it. \
|
||||||
|
To remove this buffer, return from the intercept first \
|
||||||
|
(the registry borrow will release at that point), or remove \
|
||||||
|
a different buffer."
|
||||||
|
)]
|
||||||
|
ConcurrentEdit {
|
||||||
|
/// The buffer's identifier.
|
||||||
|
id: BufferId,
|
||||||
|
/// The buffer's name, for diagnostics.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns [`Buffer`]s behind their [`BufferId`]s.
|
/// Owns [`Buffer`]s behind their [`BufferId`]s.
|
||||||
|
|
@ -100,7 +118,23 @@ impl BufferRegistry {
|
||||||
|
|
||||||
/// Remove and return the buffer behind `id`. Subsequent lookups of
|
/// Remove and return the buffer behind `id`. Subsequent lookups of
|
||||||
/// `id` produce [`RegistryError::Missing`].
|
/// `id` produce [`RegistryError::Missing`].
|
||||||
|
///
|
||||||
|
/// Refuses to remove a buffer that is currently mid-edit (T M7.4):
|
||||||
|
/// an intercept body running on buffer `A` cannot drop `A` out
|
||||||
|
/// from under itself. Returns
|
||||||
|
/// [`RegistryError::ConcurrentEdit`] in that case, leaving the
|
||||||
|
/// buffer in place.
|
||||||
pub fn remove(&mut self, id: BufferId) -> Result<Buffer, RegistryError> {
|
pub fn remove(&mut self, id: BufferId) -> Result<Buffer, RegistryError> {
|
||||||
|
// Peek without taking ownership: if the buffer is mid-edit we
|
||||||
|
// surface a typed error and leave the registry untouched.
|
||||||
|
if let Some(buf) = self.buffers.get(&id) {
|
||||||
|
if buf.editing_in_progress() {
|
||||||
|
return Err(RegistryError::ConcurrentEdit {
|
||||||
|
id,
|
||||||
|
name: buf.name().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
let buf = self
|
let buf = self
|
||||||
.buffers
|
.buffers
|
||||||
.remove(&id)
|
.remove(&id)
|
||||||
|
|
@ -178,8 +212,12 @@ mod tests {
|
||||||
let r = BufferRegistry::new();
|
let r = BufferRegistry::new();
|
||||||
let stale = BufferId::next();
|
let stale = BufferId::next();
|
||||||
let err = r.get(stale).err().expect("expected stale-handle error");
|
let err = r.get(stale).err().expect("expected stale-handle error");
|
||||||
let RegistryError::Missing { id } = err;
|
match err {
|
||||||
assert_eq!(id, stale);
|
RegistryError::Missing { id } => assert_eq!(id, stale),
|
||||||
|
other @ RegistryError::ConcurrentEdit { .. } => {
|
||||||
|
panic!("expected Missing, got {other:?}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -399,6 +399,31 @@ mod tests {
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Try to bind a `UnixListener` at `path`. On `PermissionDenied`
|
||||||
|
/// (e.g., a sandboxed CI environment that disallows `AF_UNIX`
|
||||||
|
/// socket creation), prints a skip notice to stderr and returns
|
||||||
|
/// `None`; the calling test should then early-return so the
|
||||||
|
/// suite reports `0 failed` rather than a misleading panic.
|
||||||
|
///
|
||||||
|
/// Any other `io::Error` is still a hard failure: the test
|
||||||
|
/// should not silently skip on (e.g.) `EADDRINUSE` --- that's a
|
||||||
|
/// real bug in the test setup.
|
||||||
|
fn bind_or_skip(path: &Path) -> Option<UnixListener> {
|
||||||
|
match UnixListener::bind(path) {
|
||||||
|
Ok(listener) => Some(listener),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||||
|
eprintln!(
|
||||||
|
"test skipped: UnixListener::bind {} → PermissionDenied \
|
||||||
|
(sandboxed environment). To run this test, give the \
|
||||||
|
test process permission to create AF_UNIX sockets.",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => panic!("UnixListener::bind {} failed: {e}", path.display()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// End-to-end byte echo through the bridge.
|
/// End-to-end byte echo through the bridge.
|
||||||
///
|
///
|
||||||
/// Setup:
|
/// Setup:
|
||||||
|
|
@ -415,7 +440,9 @@ mod tests {
|
||||||
fn bridge_round_trips_bytes_through_daemon() {
|
fn bridge_round_trips_bytes_through_daemon() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let socket_path = tmp.path().join("test.sock");
|
let socket_path = tmp.path().join("test.sock");
|
||||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
let Some(listener) = bind_or_skip(&socket_path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
// Echo daemon: read everything, write it back, exit on EOF.
|
// Echo daemon: read everything, write it back, exit on EOF.
|
||||||
let daemon = thread::spawn(move || {
|
let daemon = thread::spawn(move || {
|
||||||
|
|
@ -581,7 +608,9 @@ mod tests {
|
||||||
fn ensure_running_returns_immediately_when_daemon_already_listening() {
|
fn ensure_running_returns_immediately_when_daemon_already_listening() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let socket_path = tmp.path().join("test.sock");
|
let socket_path = tmp.path().join("test.sock");
|
||||||
let _listener = UnixListener::bind(&socket_path).unwrap();
|
let Some(_listener) = bind_or_skip(&socket_path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let spawner_called = Arc::new(AtomicBool::new(false));
|
let spawner_called = Arc::new(AtomicBool::new(false));
|
||||||
let sc = spawner_called.clone();
|
let sc = spawner_called.clone();
|
||||||
|
|
@ -607,6 +636,18 @@ mod tests {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let socket_path = tmp.path().join("test.sock");
|
let socket_path = tmp.path().join("test.sock");
|
||||||
|
|
||||||
|
// Probe-bind once up front. The actual bind happens inside a
|
||||||
|
// child thread (and we cannot return from the test from
|
||||||
|
// there), so checking PermissionDenied here lets us skip
|
||||||
|
// before scheduling the worker.
|
||||||
|
match bind_or_skip(&socket_path) {
|
||||||
|
Some(listener) => drop(listener),
|
||||||
|
None => return,
|
||||||
|
}
|
||||||
|
// Some kernels keep the inode visible after drop. Make sure
|
||||||
|
// the path is gone so the spawner's bind doesn't EADDRINUSE.
|
||||||
|
let _ = std::fs::remove_file(&socket_path);
|
||||||
|
|
||||||
// Spawner: defer binding by 100ms in a worker thread, then
|
// Spawner: defer binding by 100ms in a worker thread, then
|
||||||
// hold the listener long enough for `ensure_running` to see
|
// hold the listener long enough for `ensure_running` to see
|
||||||
// it. The spawner returns Ok as soon as the worker is
|
// it. The spawner returns Ok as soon as the worker is
|
||||||
|
|
|
||||||
|
|
@ -495,7 +495,10 @@ impl EditorState {
|
||||||
// command which mutates the core).
|
// command which mutates the core).
|
||||||
let mut args = mlua::MultiValue::new();
|
let mut args = mlua::MultiValue::new();
|
||||||
args.push_back(mlua::Value::String(
|
args.push_back(mlua::Value::String(
|
||||||
self.lua_host.lua().create_string(&contents).unwrap(),
|
self.lua_host
|
||||||
|
.lua()
|
||||||
|
.create_string(&contents)
|
||||||
|
.expect("Lua VM out of memory while building minibuffer callback args"),
|
||||||
));
|
));
|
||||||
if let Err(e) = on_accept.call::<mlua::MultiValue>(args) {
|
if let Err(e) = on_accept.call::<mlua::MultiValue>(args) {
|
||||||
self.core.borrow_mut().status = format!(
|
self.core.borrow_mut().status = format!(
|
||||||
|
|
@ -618,7 +621,10 @@ impl EditorState {
|
||||||
core.windows[&win_id].text_view.display_to_pos(buf, target)
|
core.windows[&win_id].text_view.display_to_pos(buf, target)
|
||||||
};
|
};
|
||||||
if let Some(p) = pos {
|
if let Some(p) = pos {
|
||||||
let aw = core.windows.get_mut(&win_id).unwrap();
|
let aw = core
|
||||||
|
.windows
|
||||||
|
.get_mut(&win_id)
|
||||||
|
.expect("invariant: win_id passed in must be a live window in core.windows");
|
||||||
aw.cursor = p;
|
aw.cursor = p;
|
||||||
aw.goal_col = None;
|
aw.goal_col = None;
|
||||||
}
|
}
|
||||||
|
|
@ -674,7 +680,10 @@ impl EditorState {
|
||||||
.or_else(|| aw.text_view.line_offset(target_row_usize))
|
.or_else(|| aw.text_view.line_offset(target_row_usize))
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
let aw = core.windows.get_mut(&win_id).unwrap();
|
let aw = core
|
||||||
|
.windows
|
||||||
|
.get_mut(&win_id)
|
||||||
|
.expect("invariant: win_id passed in must be a live window in core.windows");
|
||||||
aw.view_top = new_top;
|
aw.view_top = new_top;
|
||||||
if let Some(p) = new_cursor {
|
if let Some(p) = new_cursor {
|
||||||
aw.cursor = p;
|
aw.cursor = p;
|
||||||
|
|
@ -915,7 +924,10 @@ pub fn paint_frame(
|
||||||
let reg = registry.borrow();
|
let reg = registry.borrow();
|
||||||
let buf_id = core.active_buffer_id();
|
let buf_id = core.active_buffer_id();
|
||||||
if let Ok(buf) = reg.get(buf_id) {
|
if let Ok(buf) = reg.get(buf_id) {
|
||||||
let aw = core.windows.get_mut(&active).unwrap();
|
let aw = core
|
||||||
|
.windows
|
||||||
|
.get_mut(&active)
|
||||||
|
.expect("invariant: core.active is always a live window in core.windows");
|
||||||
let cursor_row = aw
|
let cursor_row = aw
|
||||||
.text_view
|
.text_view
|
||||||
.pos_to_display(buf, aw.cursor)
|
.pos_to_display(buf, aw.cursor)
|
||||||
|
|
|
||||||
|
|
@ -662,6 +662,59 @@ impl EditorCore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete from the cursor backward to the start of the previous
|
||||||
|
/// word. The CUA-style `Ctrl+Backspace`. No-op at start-of-buffer.
|
||||||
|
/// Mirrors [`Self::backspace`] but the deleted range is the gap
|
||||||
|
/// between the cursor and where [`Self::move_word_left`] would
|
||||||
|
/// land.
|
||||||
|
pub fn delete_word_backward(&mut self) {
|
||||||
|
self.active_window_mut().goal_col = None;
|
||||||
|
let cursor = self.active_window().cursor;
|
||||||
|
if cursor == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let new = {
|
||||||
|
let id = self.active_buffer_id();
|
||||||
|
let reg = self.registry.borrow();
|
||||||
|
let Ok(buffer) = reg.get(id) else { return };
|
||||||
|
backward_word(buffer, cursor)
|
||||||
|
};
|
||||||
|
if new == cursor {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let range = Range::new(new, cursor);
|
||||||
|
if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
|
||||||
|
self.status = format!("delete failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.active_window_mut().cursor = new;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete from the cursor forward to the end of the next word. The
|
||||||
|
/// CUA-style `Ctrl+Delete`. No-op at end-of-buffer. Mirrors
|
||||||
|
/// [`Self::delete_forward`] over the gap from the cursor to where
|
||||||
|
/// [`Self::move_word_right`] would land.
|
||||||
|
pub fn delete_word_forward(&mut self) {
|
||||||
|
self.active_window_mut().goal_col = None;
|
||||||
|
let cursor = self.active_window().cursor;
|
||||||
|
let id = self.active_buffer_id();
|
||||||
|
let new = {
|
||||||
|
let reg = self.registry.borrow();
|
||||||
|
let Ok(buffer) = reg.get(id) else { return };
|
||||||
|
if cursor >= buffer.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
forward_word(buffer, cursor)
|
||||||
|
};
|
||||||
|
if new == cursor {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let range = Range::new(cursor, new);
|
||||||
|
if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
|
||||||
|
self.status = format!("delete failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Undo the most recent edit on the active buffer; clamp the
|
/// Undo the most recent edit on the active buffer; clamp the
|
||||||
/// active window's cursor to the new length and notify all
|
/// active window's cursor to the new length and notify all
|
||||||
/// windows on this buffer.
|
/// windows on this buffer.
|
||||||
|
|
@ -1210,6 +1263,47 @@ mod tests {
|
||||||
assert_eq!(s.active_buffer_len(), 3);
|
assert_eq!(s.active_buffer_len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_word_backward_removes_previous_word_to_cursor() {
|
||||||
|
// Cursor sits at end-of-buffer; deletes back through "world".
|
||||||
|
let mut s = from_bytes(b"hello world");
|
||||||
|
s.active_window_mut().cursor = 11;
|
||||||
|
s.delete_word_backward();
|
||||||
|
// `backward_word` lands at the start of the word ("world"
|
||||||
|
// begins at byte 6), so we delete bytes 6..11.
|
||||||
|
assert_eq!(s.cursor(), 6);
|
||||||
|
assert_eq!(s.active_buffer_len(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_word_backward_at_start_of_buffer_is_noop() {
|
||||||
|
let mut s = from_bytes(b"hello");
|
||||||
|
s.active_window_mut().cursor = 0;
|
||||||
|
s.delete_word_backward();
|
||||||
|
assert_eq!(s.cursor(), 0);
|
||||||
|
assert_eq!(s.active_buffer_len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_word_forward_removes_next_word_from_cursor() {
|
||||||
|
let mut s = from_bytes(b"hello world");
|
||||||
|
s.active_window_mut().cursor = 0;
|
||||||
|
s.delete_word_forward();
|
||||||
|
// `forward_word` lands at the end of the first word (byte 5);
|
||||||
|
// delete bytes 0..5. Cursor stays where it was.
|
||||||
|
assert_eq!(s.cursor(), 0);
|
||||||
|
assert_eq!(s.active_buffer_len(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_word_forward_at_end_of_buffer_is_noop() {
|
||||||
|
let mut s = from_bytes(b"hello");
|
||||||
|
s.active_window_mut().cursor = 5;
|
||||||
|
s.delete_word_forward();
|
||||||
|
assert_eq!(s.cursor(), 5);
|
||||||
|
assert_eq!(s.active_buffer_len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multibyte_navigation() {
|
fn multibyte_navigation() {
|
||||||
let mut s = from_bytes("héllo".as_bytes());
|
let mut s = from_bytes("héllo".as_bytes());
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,20 @@ impl Frontend {
|
||||||
// literal `/` with CONTROL instead of the byte-roulette legacy
|
// literal `/` with CONTROL instead of the byte-roulette legacy
|
||||||
// protocols produce. Terminals that don't ignore the CSI; we
|
// protocols produce. Terminals that don't ignore the CSI; we
|
||||||
// push the flag anyway so the Pop on teardown is balanced.
|
// push the flag anyway so the Pop on teardown is balanced.
|
||||||
let kitty_flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
|
//
|
||||||
| KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
|
// We deliberately do NOT push `REPORT_ALL_KEYS_AS_ESCAPE_CODES`.
|
||||||
|
// That flag tells the terminal to send every key (including
|
||||||
|
// printable letters) as a CSI sequence carrying the unshifted
|
||||||
|
// base key plus modifier bits, e.g. `Shift+a` arrives as
|
||||||
|
// `Char('a') + SHIFT` rather than `Char('A')`. Pmacs has no
|
||||||
|
// keyboard-layout knowledge to translate `9 + SHIFT` into `(`
|
||||||
|
// on a US layout (or `É` on a French layout, etc.); the
|
||||||
|
// terminal does. Letting the terminal apply layout-aware shift
|
||||||
|
// translation is correct; receiving the post-shift character
|
||||||
|
// is what every typing-driven path (self-insert, minibuffer,
|
||||||
|
// search) expects. `DISAMBIGUATE_ESCAPE_CODES` alone still
|
||||||
|
// gives us the C-i/Tab and C-m/Enter disambiguation we want.
|
||||||
|
let kitty_flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES;
|
||||||
if queue!(me.out, PushKeyboardEnhancementFlags(kitty_flags)).is_ok() {
|
if queue!(me.out, PushKeyboardEnhancementFlags(kitty_flags)).is_ok() {
|
||||||
me.keyboard_enhancement = true;
|
me.keyboard_enhancement = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
src/key.rs
60
src/key.rs
|
|
@ -60,18 +60,32 @@ impl Chord {
|
||||||
/// Canonicalization rules:
|
/// Canonicalization rules:
|
||||||
/// * `KeyCode::Char('A')` with `SHIFT` --- the SHIFT bit is
|
/// * `KeyCode::Char('A')` with `SHIFT` --- the SHIFT bit is
|
||||||
/// stripped; the uppercase letter already implies it.
|
/// stripped; the uppercase letter already implies it.
|
||||||
|
/// * `KeyCode::Char('a')` with `SHIFT` --- promoted to
|
||||||
|
/// `KeyCode::Char('A')` and SHIFT stripped. Some terminals (or
|
||||||
|
/// kitty-protocol modes the user might enable separately) report
|
||||||
|
/// shifted ASCII letters as the unshifted code with SHIFT set;
|
||||||
|
/// normalizing here means `S-a` and `A` hash identically and
|
||||||
|
/// self-insert produces `A`. Non-letter shifted keys like
|
||||||
|
/// `Shift+9` rely on terminal-side layout translation (frontend
|
||||||
|
/// does not push `REPORT_ALL_KEYS_AS_ESCAPE_CODES`); pmacs has
|
||||||
|
/// no layout knowledge to map `9 + SHIFT` to `(`.
|
||||||
/// * `KeyCode::Char(c)` for any `c` whose lowercase is itself
|
/// * `KeyCode::Char(c)` for any `c` whose lowercase is itself
|
||||||
/// (`/`, `1`, ...) leaves modifiers as-is.
|
/// (`/`, `1`, ...) leaves modifiers as-is.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
|
pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
|
||||||
let mods = match code {
|
let (code, modifiers) = match code {
|
||||||
KeyCode::Char(ch) if ch.is_ascii_uppercase() => modifiers - KeyModifiers::SHIFT,
|
KeyCode::Char(ch) if ch.is_ascii_uppercase() => (code, modifiers - KeyModifiers::SHIFT),
|
||||||
_ => modifiers,
|
KeyCode::Char(ch)
|
||||||
|
if ch.is_ascii_lowercase() && modifiers.contains(KeyModifiers::SHIFT) =>
|
||||||
|
{
|
||||||
|
(
|
||||||
|
KeyCode::Char(ch.to_ascii_uppercase()),
|
||||||
|
modifiers - KeyModifiers::SHIFT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => (code, modifiers),
|
||||||
};
|
};
|
||||||
Self {
|
Self { code, modifiers }
|
||||||
code,
|
|
||||||
modifiers: mods,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a plain unmodified chord.
|
/// Build a plain unmodified chord.
|
||||||
|
|
@ -433,6 +447,38 @@ mod tests {
|
||||||
assert_eq!(with_shift, bare);
|
assert_eq!(with_shift, bare);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lowercase_letter_with_shift_promotes_to_uppercase() {
|
||||||
|
// Some terminals (or kitty-protocol modes a user may enable
|
||||||
|
// separately) report shifted ASCII letters as the unshifted
|
||||||
|
// code with SHIFT set: `Shift+a` arrives as `Char('a')+SHIFT`,
|
||||||
|
// not `Char('A')`. The canonicalization promotes to the
|
||||||
|
// uppercase form so self-insert produces 'A' and bindings
|
||||||
|
// for `S-a` hash identically to bindings for `A`.
|
||||||
|
let promoted = Chord::new(KeyCode::Char('a'), KeyModifiers::SHIFT);
|
||||||
|
let bare = Chord::plain(KeyCode::Char('A'));
|
||||||
|
assert_eq!(promoted, bare);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_a_equals_capital_a_through_parser() {
|
||||||
|
// Parser emits the same chord whether you write `S-a` or `A`.
|
||||||
|
assert_eq!(parse_chord("S-a").unwrap(), parse_chord("A").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_letter_shifted_chars_keep_modifiers() {
|
||||||
|
// pmacs has no layout knowledge: a chord built from
|
||||||
|
// `Char('9')+SHIFT` cannot be promoted to `Char('(')`. We
|
||||||
|
// leave it as-is and rely on the terminal to deliver the
|
||||||
|
// post-shift character (`(`) in normal operation. This test
|
||||||
|
// pins the no-touch behavior so the canonicalization doesn't
|
||||||
|
// creep into territory that would need a layout map.
|
||||||
|
let chord = Chord::new(KeyCode::Char('9'), KeyModifiers::SHIFT);
|
||||||
|
assert_eq!(chord.code, KeyCode::Char('9'));
|
||||||
|
assert!(chord.modifiers.contains(KeyModifiers::SHIFT));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_round_trips_canonical_form() {
|
fn display_round_trips_canonical_form() {
|
||||||
let cases = [
|
let cases = [
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ pub mod lua_bindings;
|
||||||
pub mod message_bus;
|
pub mod message_bus;
|
||||||
pub mod minibuffer;
|
pub mod minibuffer;
|
||||||
pub mod overlay;
|
pub mod overlay;
|
||||||
|
pub mod packages;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod project_index;
|
pub mod project_index;
|
||||||
|
|
|
||||||
|
|
@ -1088,6 +1088,15 @@ impl LspManager {
|
||||||
ProcessEventKind::Stderr(bytes) => {
|
ProcessEventKind::Stderr(bytes) => {
|
||||||
self.push_event(sid, ev.at, LspEventKind::Stderr(bytes));
|
self.push_event(sid, ev.at, LspEventKind::Stderr(bytes));
|
||||||
}
|
}
|
||||||
|
ProcessEventKind::Ansi(_) => {
|
||||||
|
self.push_event(
|
||||||
|
sid,
|
||||||
|
ev.at,
|
||||||
|
LspEventKind::ProtocolError {
|
||||||
|
message: "supervisor emitted ANSI events for pipe-mode LSP process".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
ProcessEventKind::Exited { code } => {
|
ProcessEventKind::Exited { code } => {
|
||||||
self.on_exit(sid, ev.at, format!("exit code {code}"), code == 0);
|
self.on_exit(sid, ev.at, format!("exit code {code}"), code == 0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
src/lua.rs
27
src/lua.rs
|
|
@ -33,8 +33,9 @@ pub const ERRORS_BUFFER_NAME: &str = "*errors*";
|
||||||
use crate::command::CommandRegistry;
|
use crate::command::CommandRegistry;
|
||||||
use crate::keymap_stack::KeymapStack;
|
use crate::keymap_stack::KeymapStack;
|
||||||
use crate::lua_bindings::{
|
use crate::lua_bindings::{
|
||||||
self, CurrentAttachmentSlot, InitCompleteFlag, LocalInstanceInfo, RequestedAttach,
|
self, CurrentAttachmentSlot, InitCompleteFlag, LocalInstanceInfo, PackageInstallOverride,
|
||||||
SharedCommandRegistry, SharedCore, SharedHookRegistry, SharedKeymapStack, SharedRegistry,
|
RequestedAttach, SharedCommandRegistry, SharedCore, SharedHookRegistry, SharedKeymapStack,
|
||||||
|
SharedRegistry,
|
||||||
};
|
};
|
||||||
use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity};
|
use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity};
|
||||||
|
|
||||||
|
|
@ -274,6 +275,17 @@ impl LuaHost {
|
||||||
/// `source` is an optional label (file path, chunk name) used in
|
/// `source` is an optional label (file path, chunk name) used in
|
||||||
/// diagnostics; Lua reports it back in stack traces.
|
/// diagnostics; Lua reports it back in stack traces.
|
||||||
pub fn eval(&mut self, source: Option<&str>, chunk: &str) -> mlua::Result<Value> {
|
pub fn eval(&mut self, source: Option<&str>, chunk: &str) -> mlua::Result<Value> {
|
||||||
|
// Push the source label into the per-Lua app-data slot so
|
||||||
|
// bindings that need the chunk's location (e.g.,
|
||||||
|
// `pmacs.packages.install_project`'s relative-path
|
||||||
|
// resolution) can read it back. This is the only way to
|
||||||
|
// recover chunk source from a Rust callback in pmacs, since
|
||||||
|
// the Lua state is built without the `debug` library
|
||||||
|
// (`forbid(unsafe_code)` rules out `Lua::unsafe_new`).
|
||||||
|
self.lua
|
||||||
|
.set_app_data(crate::lua_bindings::CurrentEvalSource(
|
||||||
|
source.map(str::to_owned),
|
||||||
|
));
|
||||||
let mut loader = self.lua.load(chunk);
|
let mut loader = self.lua.load(chunk);
|
||||||
if let Some(name) = source {
|
if let Some(name) = source {
|
||||||
loader = loader.set_name(name);
|
loader = loader.set_name(name);
|
||||||
|
|
@ -438,6 +450,17 @@ impl LuaHost {
|
||||||
.and_then(|s| s.get())
|
.and_then(|s| s.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Install a [`PackageInstallOverride`] so subsequent
|
||||||
|
/// `pmacs.packages.install{...}` calls redirect their cache and
|
||||||
|
/// install roots away from `$XDG_*` defaults.
|
||||||
|
///
|
||||||
|
/// Production code does not call this. Tests use it because the
|
||||||
|
/// project's `forbid(unsafe_code)` rules out `std::env::set_var`
|
||||||
|
/// (which has been `unsafe` since Rust 2024).
|
||||||
|
pub fn set_package_install_override(&self, override_: PackageInstallOverride) {
|
||||||
|
self.lua.set_app_data(override_);
|
||||||
|
}
|
||||||
|
|
||||||
/// Override the instance name reported by `pmacs.instance.identity()`
|
/// Override the instance name reported by `pmacs.instance.identity()`
|
||||||
/// (M5.6f).
|
/// (M5.6f).
|
||||||
///
|
///
|
||||||
|
|
|
||||||
1238
src/lua_bindings.rs
1238
src/lua_bindings.rs
File diff suppressed because it is too large
Load Diff
195
src/overlay.rs
195
src/overlay.rs
|
|
@ -41,8 +41,13 @@
|
||||||
//! mapping out keeps overlays cheap and stateless. A future
|
//! mapping out keeps overlays cheap and stateless. A future
|
||||||
//! buffer-coord overlay would compose on top of these the same way.
|
//! buffer-coord overlay would compose on top of these the same way.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
use crate::buffer::Buffer;
|
use crate::buffer::Buffer;
|
||||||
use crate::cell::{Cell, CellCoord, CellGrid, Style};
|
use crate::cell::{Cell, CellCoord, CellGrid, Style};
|
||||||
|
use crate::rope::Edit;
|
||||||
use crate::view::{View, Viewport};
|
use crate::view::{View, Viewport};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -152,6 +157,196 @@ pub fn merge_styles(base: Style, overlay: Style) -> Style {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BufferStyleOverlay
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A style annotation expressed in buffer byte coordinates.
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct BufferStyleSpan {
|
||||||
|
/// First byte covered by the style.
|
||||||
|
pub start: u64,
|
||||||
|
/// Byte one past the styled range.
|
||||||
|
pub end: u64,
|
||||||
|
/// Style to merge over the base text.
|
||||||
|
pub style: Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared span store used by Lua handles and render overlays.
|
||||||
|
pub type SharedBufferStyleSpans = Arc<Mutex<Vec<BufferStyleSpan>>>;
|
||||||
|
|
||||||
|
/// View that renders buffer-byte style annotations.
|
||||||
|
///
|
||||||
|
/// Unlike [`StyleSpanOverlay`], this overlay stores byte ranges rather
|
||||||
|
/// than viewport cell ranges. That is the right shape for stream
|
||||||
|
/// consumers such as the REPL: ANSI SGR applies to bytes as they land
|
||||||
|
/// in the rope, and render maps the surviving ranges into visible cells.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct BufferStyleOverlay {
|
||||||
|
spans: SharedBufferStyleSpans,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BufferStyleOverlay {
|
||||||
|
/// Construct an overlay backed by `spans`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(spans: SharedBufferStyleSpans) -> Self {
|
||||||
|
Self { spans }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl View for BufferStyleOverlay {
|
||||||
|
fn on_edit(&mut self, _buf: &Buffer, edit: &Edit) -> Result<(), crate::buffer::BufferError> {
|
||||||
|
let old_start = edit.range.start;
|
||||||
|
let old_end = edit.range.end;
|
||||||
|
let old_len = old_end - old_start;
|
||||||
|
let new_len = edit.inserted_len;
|
||||||
|
let mut spans = self.spans.lock().expect("style spans mutex poisoned");
|
||||||
|
let mut adjusted = Vec::with_capacity(spans.len());
|
||||||
|
for mut span in spans.drain(..) {
|
||||||
|
if span.end <= old_start {
|
||||||
|
adjusted.push(span);
|
||||||
|
} else if span.start >= old_end {
|
||||||
|
span.start = shift_pos(span.start, old_end, old_len, new_len);
|
||||||
|
span.end = shift_pos(span.end, old_end, old_len, new_len);
|
||||||
|
adjusted.push(span);
|
||||||
|
}
|
||||||
|
// Overlapping spans are dropped. REPL style spans are append-only
|
||||||
|
// and scrollback truncation deletes whole old blocks, so a
|
||||||
|
// conservative drop is simpler and avoids half-styled fragments.
|
||||||
|
}
|
||||||
|
*spans = adjusted;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||||
|
let spans = self
|
||||||
|
.spans
|
||||||
|
.lock()
|
||||||
|
.expect("style spans mutex poisoned")
|
||||||
|
.clone();
|
||||||
|
if spans.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let line_offsets = compute_line_offsets(buf);
|
||||||
|
if line_offsets.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let start_line = line_at_offset(&line_offsets, viewport.buffer_start);
|
||||||
|
for span in spans {
|
||||||
|
render_buffer_style_span(buf, &line_offsets, start_line, viewport, cells, span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shift_pos(pos: u64, old_end: u64, old_len: u64, new_len: u64) -> u64 {
|
||||||
|
if new_len >= old_len {
|
||||||
|
pos + (new_len - old_len)
|
||||||
|
} else {
|
||||||
|
pos.saturating_sub(old_end)
|
||||||
|
.saturating_add(old_end - (old_len - new_len))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_line_offsets(buf: &Buffer) -> Vec<u64> {
|
||||||
|
let mut offsets = vec![0];
|
||||||
|
let rope = buf.snapshot_rope();
|
||||||
|
let mut pos = 0;
|
||||||
|
let len = rope.len();
|
||||||
|
for chunk in rope.chunks(0, len) {
|
||||||
|
for (i, b) in chunk.iter().enumerate() {
|
||||||
|
if *b == b'\n' {
|
||||||
|
offsets.push(pos + i as u64 + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos += chunk.len() as u64;
|
||||||
|
}
|
||||||
|
offsets
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line_at_offset(line_offsets: &[u64], offset: u64) -> usize {
|
||||||
|
match line_offsets.binary_search(&offset) {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(i) => i.saturating_sub(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line_end(buf: &Buffer, line_offsets: &[u64], line: usize) -> u64 {
|
||||||
|
let start = line_offsets[line];
|
||||||
|
let raw_end = line_offsets.get(line + 1).copied().unwrap_or(buf.len());
|
||||||
|
if line + 1 < line_offsets.len() && raw_end > start {
|
||||||
|
raw_end - 1
|
||||||
|
} else {
|
||||||
|
raw_end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_col_for_range(buf: &Buffer, start: u64, end: u64) -> u32 {
|
||||||
|
if end <= start {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut bytes = vec![0u8; (end - start) as usize];
|
||||||
|
buf.snapshot_rope().slice(start, end, &mut bytes);
|
||||||
|
while !bytes.is_empty() && std::str::from_utf8(&bytes).is_err() {
|
||||||
|
bytes.pop();
|
||||||
|
}
|
||||||
|
let Ok(s) = std::str::from_utf8(&bytes) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let mut col = 0;
|
||||||
|
for ch in s.chars() {
|
||||||
|
let width = if ch == '\t' {
|
||||||
|
8 - (col % 8)
|
||||||
|
} else {
|
||||||
|
UnicodeWidthChar::width(ch).unwrap_or(0) as u32
|
||||||
|
};
|
||||||
|
col += width;
|
||||||
|
}
|
||||||
|
col
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_buffer_style_span(
|
||||||
|
buf: &Buffer,
|
||||||
|
line_offsets: &[u64],
|
||||||
|
start_line: usize,
|
||||||
|
viewport: Viewport,
|
||||||
|
cells: &mut CellGrid<'_>,
|
||||||
|
span: BufferStyleSpan,
|
||||||
|
) {
|
||||||
|
if span.start >= span.end {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let first_line = line_at_offset(line_offsets, span.start);
|
||||||
|
let last_line = line_at_offset(line_offsets, span.end.saturating_sub(1));
|
||||||
|
for line in first_line..=last_line {
|
||||||
|
if line < start_line {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let row_offset = (line - start_line) as u32;
|
||||||
|
if row_offset >= viewport.cell_size.rows {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let line_start = line_offsets[line];
|
||||||
|
let line_end = line_end(buf, line_offsets, line);
|
||||||
|
let style_start = span.start.max(line_start).min(line_end);
|
||||||
|
let style_end = span.end.min(line_end);
|
||||||
|
if style_start >= style_end {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let start_col = display_col_for_range(buf, line_start, style_start);
|
||||||
|
let end_col = display_col_for_range(buf, line_start, style_end);
|
||||||
|
let start_col = start_col.min(viewport.cell_size.cols);
|
||||||
|
let end_col = end_col.min(viewport.cell_size.cols);
|
||||||
|
for col in start_col..end_col {
|
||||||
|
let coord = CellCoord::new(
|
||||||
|
viewport.cell_origin.row + row_offset,
|
||||||
|
viewport.cell_origin.col + col,
|
||||||
|
);
|
||||||
|
let cell = cells.at(coord);
|
||||||
|
cell.style = merge_styles(cell.style, span.style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// VirtualCellOverlay
|
// VirtualCellOverlay
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,443 @@
|
||||||
|
// packages/address.rs --- Address parsing for v1.0 package addresses.
|
||||||
|
|
||||||
|
//! Address parsing (T M7.2, spec §sec:packages-future).
|
||||||
|
//!
|
||||||
|
//! v1.0 ships three address forms:
|
||||||
|
//!
|
||||||
|
//! - `github:owner/repo` --- sugar that expands to
|
||||||
|
//! `https://github.com/owner/repo.git`. The `.git` suffix is
|
||||||
|
//! tolerated; `github:owner/repo.git` is accepted and treated as
|
||||||
|
//! equivalent.
|
||||||
|
//! - `git:<URL>` --- the prefix is stripped and whatever remains is
|
||||||
|
//! passed to `git clone` as-is. This intentionally accepts anything
|
||||||
|
//! `git clone` accepts: full URLs (`https://`, `ssh://`, `file://`,
|
||||||
|
//! `git://`), SSH shorthand (`git@host:path`), local paths. Validation
|
||||||
|
//! that the URL actually resolves happens at clone time, not parse
|
||||||
|
//! time --- delegating the URL-form question to git's existing
|
||||||
|
//! documentation rather than maintaining our own parser.
|
||||||
|
//! - Raw URLs starting with `https://` or `git://` --- accepted directly
|
||||||
|
//! without a prefix. The natural form `https://example.com/repo.git`
|
||||||
|
//! parses without forcing the user to type a redundant `https:` or
|
||||||
|
//! `git:` prefix.
|
||||||
|
//!
|
||||||
|
//! ## Forge aliases (deferred)
|
||||||
|
//!
|
||||||
|
//! `gitlab:`, `codeberg:`, and `forgejo:` were considered for v1.0 and
|
||||||
|
//! deferred to a post-v1.0 patch release driven by user demand (see
|
||||||
|
//! T M7.2 box in `pmacs-tasks.tex`). Inputs starting with these
|
||||||
|
//! prefixes return [`AddressError::DeferredAlias`], whose message names
|
||||||
|
//! the alias and points at the `git:URL` fallback.
|
||||||
|
//!
|
||||||
|
//! ## Authentication
|
||||||
|
//!
|
||||||
|
//! v1.0 delegates authentication to the user's git configuration: if
|
||||||
|
//! the system has a credential helper for HTTPS or an SSH agent for
|
||||||
|
//! SSH URLs, private repos work transparently. The address parser
|
||||||
|
//! does not handle credentials; it only produces the URL string that
|
||||||
|
//! `git clone` will eventually receive.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Address
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A parsed package address.
|
||||||
|
///
|
||||||
|
/// Two variants in v1.0: a special-cased GitHub form (because it's the
|
||||||
|
/// most common) and an opaque URL form (everything else).
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||||
|
pub enum Address {
|
||||||
|
/// `github:owner/repo` sugar.
|
||||||
|
Github {
|
||||||
|
/// Repository owner (user or organization).
|
||||||
|
owner: String,
|
||||||
|
/// Repository name. Tolerates an optional `.git` suffix at parse
|
||||||
|
/// time but stores the bare name.
|
||||||
|
repo: String,
|
||||||
|
},
|
||||||
|
/// Any clone-cloneable URL or shorthand. Stored as-is; passed to
|
||||||
|
/// `git clone` verbatim.
|
||||||
|
Url(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Address {
|
||||||
|
/// Parse an address string per the v1.0 syntax.
|
||||||
|
pub fn parse(s: &str) -> Result<Self, AddressError> {
|
||||||
|
if s.is_empty() {
|
||||||
|
return Err(AddressError::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deferred forge aliases must be detected before generic prefix
|
||||||
|
// handling so the error message points at the trim decision.
|
||||||
|
for alias in DEFERRED_ALIASES {
|
||||||
|
if s.starts_with(alias) {
|
||||||
|
return Err(AddressError::DeferredAlias {
|
||||||
|
alias: (*alias).to_string(),
|
||||||
|
input: s.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Raw URLs without a prefix --- accept directly. This branch
|
||||||
|
// must come before the `git:` prefix handler: `git://x`
|
||||||
|
// starts with `git:` and would otherwise be miscaptured as
|
||||||
|
// a `git:` prefix with body `//x`.
|
||||||
|
if s.starts_with("https://") || s.starts_with("git://") {
|
||||||
|
return Ok(Address::Url(s.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. github:owner/repo (with optional .git suffix).
|
||||||
|
if let Some(rest) = s.strip_prefix("github:") {
|
||||||
|
return parse_github(rest, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. git:<anything> --- pass-through. Whatever follows is fed to
|
||||||
|
// `git clone` as-is. Accepts SSH shorthand, file URLs, and
|
||||||
|
// arbitrary clone targets. Validation that the target is
|
||||||
|
// reachable happens at fetch time, not at parse time.
|
||||||
|
if let Some(rest) = s.strip_prefix("git:") {
|
||||||
|
if rest.is_empty() {
|
||||||
|
return Err(AddressError::EmptyGitTarget {
|
||||||
|
input: s.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Ok(Address::Url(rest.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. https:<rest> --- redundant verbose form, kept for symmetry
|
||||||
|
// with git:URL. The natural `https://...` form is already
|
||||||
|
// handled by branch 1; this branch covers the user who
|
||||||
|
// writes `https:https://...` by reflex. Anything else after
|
||||||
|
// the `https:` prefix that isn't a recognizable HTTPS body
|
||||||
|
// is rejected.
|
||||||
|
if let Some(rest) = s.strip_prefix("https:") {
|
||||||
|
if let Some(inner) = rest.strip_prefix("https://") {
|
||||||
|
let _ = inner;
|
||||||
|
return Ok(Address::Url(rest.to_string()));
|
||||||
|
}
|
||||||
|
return Err(AddressError::MalformedHttps {
|
||||||
|
input: s.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AddressError::UnknownScheme {
|
||||||
|
input: s.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The clone URL this address resolves to. Pass to `git clone`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn to_git_url(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Github { owner, repo } => {
|
||||||
|
format!("https://github.com/{owner}/{repo}.git")
|
||||||
|
}
|
||||||
|
Self::Url(u) => u.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFERRED_ALIASES: &[&str] = &["gitlab:", "codeberg:", "forgejo:"];
|
||||||
|
|
||||||
|
fn parse_github(rest: &str, original: &str) -> Result<Address, AddressError> {
|
||||||
|
// Tolerate trailing `.git` --- users will type it by habit.
|
||||||
|
let body = rest.strip_suffix(".git").unwrap_or(rest);
|
||||||
|
let mut parts = body.split('/');
|
||||||
|
let owner = parts.next().unwrap_or("");
|
||||||
|
let repo = parts.next().unwrap_or("");
|
||||||
|
if owner.is_empty() || repo.is_empty() || parts.next().is_some() {
|
||||||
|
return Err(AddressError::InvalidGithub {
|
||||||
|
input: original.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Conservative character validation: GitHub itself allows a wider
|
||||||
|
// set, but accepting only `[A-Za-z0-9_.-]` covers every realistic
|
||||||
|
// case and rejects obvious typos (slashes inside segments, etc.)
|
||||||
|
// without spec churn. Wider sets can be admitted later if a real
|
||||||
|
// package surfaces a rejection.
|
||||||
|
for seg in [owner, repo] {
|
||||||
|
if !seg
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
|
||||||
|
{
|
||||||
|
return Err(AddressError::InvalidGithub {
|
||||||
|
input: original.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Address::Github {
|
||||||
|
owner: owner.to_string(),
|
||||||
|
repo: repo.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Errors produced by [`Address::parse`].
|
||||||
|
///
|
||||||
|
/// Every variant names the offending input. Forge-alias rejections
|
||||||
|
/// also point at the `git:URL` fallback so the user knows what to
|
||||||
|
/// type instead.
|
||||||
|
#[derive(Debug, Error, Eq, PartialEq)]
|
||||||
|
pub enum AddressError {
|
||||||
|
/// Input was empty.
|
||||||
|
#[error("empty package address")]
|
||||||
|
Empty,
|
||||||
|
/// `github:owner/repo` form was malformed (missing slash, extra
|
||||||
|
/// segment, invalid characters).
|
||||||
|
#[error("invalid github address `{input}`: expected `github:owner/repo`")]
|
||||||
|
InvalidGithub {
|
||||||
|
/// The offending input.
|
||||||
|
input: String,
|
||||||
|
},
|
||||||
|
/// `git:` prefix was followed by an empty body.
|
||||||
|
#[error("empty git target in `{input}`: expected `git:<URL>`")]
|
||||||
|
EmptyGitTarget {
|
||||||
|
/// The offending input.
|
||||||
|
input: String,
|
||||||
|
},
|
||||||
|
/// `https:` prefix did not introduce a recognizable HTTPS URL.
|
||||||
|
#[error("malformed https address `{input}`: expected `https://...`")]
|
||||||
|
MalformedHttps {
|
||||||
|
/// The offending input.
|
||||||
|
input: String,
|
||||||
|
},
|
||||||
|
/// Address used a forge-alias prefix that v1.0 deferred (gitlab:,
|
||||||
|
/// codeberg:, forgejo:). The message points at the `git:URL`
|
||||||
|
/// fallback so the user knows what to type instead.
|
||||||
|
#[error(
|
||||||
|
"address scheme `{alias}` is deferred for v1.0; \
|
||||||
|
use `git:<full-URL>` instead (e.g. `git:https://gitlab.com/owner/repo.git`). \
|
||||||
|
Offending input: `{input}`"
|
||||||
|
)]
|
||||||
|
DeferredAlias {
|
||||||
|
/// The deferred alias prefix (e.g. `"gitlab:"`).
|
||||||
|
alias: String,
|
||||||
|
/// The full offending input.
|
||||||
|
input: String,
|
||||||
|
},
|
||||||
|
/// Address did not match any v1.0 scheme.
|
||||||
|
#[error(
|
||||||
|
"unknown address scheme in `{input}`; \
|
||||||
|
expected `github:owner/repo`, `git:<URL>`, `https://...`, or `git://...`"
|
||||||
|
)]
|
||||||
|
UnknownScheme {
|
||||||
|
/// The offending input.
|
||||||
|
input: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// -- github sugar --------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_simple_form_parses() {
|
||||||
|
let a = Address::parse("github:rust-lang/rust").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
a,
|
||||||
|
Address::Github {
|
||||||
|
owner: "rust-lang".into(),
|
||||||
|
repo: "rust".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_dot_git_suffix_tolerated() {
|
||||||
|
let a = Address::parse("github:owner/repo.git").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
a,
|
||||||
|
Address::Github {
|
||||||
|
owner: "owner".into(),
|
||||||
|
repo: "repo".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_to_git_url_canonicalizes() {
|
||||||
|
let a = Address::parse("github:foo/bar").unwrap();
|
||||||
|
assert_eq!(a.to_git_url(), "https://github.com/foo/bar.git");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_to_git_url_canonicalizes_after_dot_git_strip() {
|
||||||
|
let a = Address::parse("github:foo/bar.git").unwrap();
|
||||||
|
// The `.git` survives in the canonical URL even though the
|
||||||
|
// parsed `repo` is `bar`.
|
||||||
|
assert_eq!(a.to_git_url(), "https://github.com/foo/bar.git");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_rejects_missing_repo() {
|
||||||
|
let err = Address::parse("github:owner").unwrap_err();
|
||||||
|
assert!(matches!(err, AddressError::InvalidGithub { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_rejects_extra_segment() {
|
||||||
|
let err = Address::parse("github:owner/repo/extra").unwrap_err();
|
||||||
|
assert!(matches!(err, AddressError::InvalidGithub { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_rejects_empty_owner() {
|
||||||
|
let err = Address::parse("github:/repo").unwrap_err();
|
||||||
|
assert!(matches!(err, AddressError::InvalidGithub { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_rejects_empty_repo() {
|
||||||
|
let err = Address::parse("github:owner/").unwrap_err();
|
||||||
|
assert!(matches!(err, AddressError::InvalidGithub { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_rejects_invalid_characters() {
|
||||||
|
let err = Address::parse("github:owner/repo with spaces").unwrap_err();
|
||||||
|
assert!(matches!(err, AddressError::InvalidGithub { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- git: prefix --------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_prefix_with_https_url() {
|
||||||
|
let a = Address::parse("git:https://example.com/owner/repo.git").unwrap();
|
||||||
|
assert_eq!(a, Address::Url("https://example.com/owner/repo.git".into()));
|
||||||
|
assert_eq!(a.to_git_url(), "https://example.com/owner/repo.git");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_prefix_with_ssh_url() {
|
||||||
|
let a = Address::parse("git:ssh://git@example.com/owner/repo.git").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
a,
|
||||||
|
Address::Url("ssh://git@example.com/owner/repo.git".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_prefix_with_ssh_shorthand() {
|
||||||
|
// SSH shorthand isn't a URL but `git clone` accepts it. We pass
|
||||||
|
// it through verbatim.
|
||||||
|
let a = Address::parse("git:git@github.com:owner/repo.git").unwrap();
|
||||||
|
assert_eq!(a, Address::Url("git@github.com:owner/repo.git".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_prefix_with_file_url() {
|
||||||
|
let a = Address::parse("git:file:///tmp/test-repo").unwrap();
|
||||||
|
assert_eq!(a, Address::Url("file:///tmp/test-repo".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_prefix_empty_is_rejected() {
|
||||||
|
let err = Address::parse("git:").unwrap_err();
|
||||||
|
assert!(matches!(err, AddressError::EmptyGitTarget { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- raw URL forms ------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_https_url_accepted() {
|
||||||
|
let a = Address::parse("https://example.com/owner/repo.git").unwrap();
|
||||||
|
assert_eq!(a, Address::Url("https://example.com/owner/repo.git".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_git_protocol_url_accepted() {
|
||||||
|
let a = Address::parse("git://example.com/owner/repo").unwrap();
|
||||||
|
assert_eq!(a, Address::Url("git://example.com/owner/repo".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn https_verbose_redundant_form_accepted() {
|
||||||
|
let a = Address::parse("https:https://example.com/owner/repo").unwrap();
|
||||||
|
// Verbose form: the inner `https://...` is what we keep.
|
||||||
|
assert_eq!(a, Address::Url("https://example.com/owner/repo".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn https_prefix_without_url_body_rejected() {
|
||||||
|
let err = Address::parse("https:example.com/repo").unwrap_err();
|
||||||
|
assert!(matches!(err, AddressError::MalformedHttps { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Forge aliases rejected with helpful pointer ------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitlab_alias_rejected_with_pointer_to_git_fallback() {
|
||||||
|
let err = Address::parse("gitlab:owner/repo").unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(matches!(err, AddressError::DeferredAlias { .. }));
|
||||||
|
assert!(
|
||||||
|
msg.contains("gitlab:"),
|
||||||
|
"error should name the alias: {msg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
msg.contains("git:"),
|
||||||
|
"error should point at fallback: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codeberg_alias_rejected_with_pointer_to_git_fallback() {
|
||||||
|
let err = Address::parse("codeberg:owner/repo").unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(matches!(err, AddressError::DeferredAlias { .. }));
|
||||||
|
assert!(msg.contains("codeberg:"));
|
||||||
|
assert!(msg.contains("git:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forgejo_alias_rejected_with_pointer_to_git_fallback() {
|
||||||
|
let err = Address::parse("forgejo:host/owner/repo").unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(matches!(err, AddressError::DeferredAlias { .. }));
|
||||||
|
assert!(msg.contains("forgejo:"));
|
||||||
|
assert!(msg.contains("git:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Catch-alls ---------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_input_rejected() {
|
||||||
|
let err = Address::parse("").unwrap_err();
|
||||||
|
assert!(matches!(err, AddressError::Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_scheme_rejected_with_help() {
|
||||||
|
let err = Address::parse("ftp://example.com/repo").unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(matches!(err, AddressError::UnknownScheme { .. }));
|
||||||
|
assert!(
|
||||||
|
msg.contains("github:"),
|
||||||
|
"error should list known schemes: {msg}"
|
||||||
|
);
|
||||||
|
assert!(msg.contains("git:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_unsupported_falls_to_unknown_scheme() {
|
||||||
|
// Plain http:// is not in v1.0's set --- users should use https.
|
||||||
|
let err = Address::parse("http://example.com/repo").unwrap_err();
|
||||||
|
assert!(matches!(err, AddressError::UnknownScheme { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bare_word_rejected() {
|
||||||
|
let err = Address::parse("just-a-word").unwrap_err();
|
||||||
|
assert!(matches!(err, AddressError::UnknownScheme { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,603 @@
|
||||||
|
// packages/manifest.rs --- pmacs.toml schema, parser, and validator.
|
||||||
|
|
||||||
|
//! Manifest schema and parser (T M7.1, spec §sec:packages-future).
|
||||||
|
//!
|
||||||
|
//! A package is a versioned, addressable unit of Lua code with declared
|
||||||
|
//! metadata, dependencies, and a single entry point. Every package
|
||||||
|
//! ships a [`pmacs.toml`] at its root, deserialized into a
|
||||||
|
//! [`PackageManifest`] at install time (load time only re-stats it).
|
||||||
|
//!
|
||||||
|
//! v1.0 fields:
|
||||||
|
//! - `name` ([`PackageName`]) --- lowercase, hyphen-separated, with
|
||||||
|
//! an optional `user/pkg-name` namespace.
|
||||||
|
//! - `version` ([`semver::Version`]) --- semantic version, parsed and
|
||||||
|
//! rejected at parse time, not at install time.
|
||||||
|
//! - `summary` ([`String`]) --- one-line description.
|
||||||
|
//! - `pmacs_required` ([`semver::VersionReq`]) --- the pmacs version
|
||||||
|
//! range this package supports.
|
||||||
|
//! - `dependencies` (`Vec<`[`DependencySpec`]`>`) --- packages this
|
||||||
|
//! one needs, by address plus version constraint.
|
||||||
|
//! - `conflicts` (`Vec<`[`DependencySpec`]`>`) --- packages this one
|
||||||
|
//! refuses to coexist with (e.g., two REPL implementations).
|
||||||
|
//! - `entry` ([`PathBuf`]) --- the Lua module file `require` returns.
|
||||||
|
//! - `exports` (`Vec<String>`) --- public Lua module names; other
|
||||||
|
//! code must use only these.
|
||||||
|
//!
|
||||||
|
//! `dependencies` and `conflicts` default to empty if omitted; every
|
||||||
|
//! other field is required. Unknown fields are accepted (forward
|
||||||
|
//! compatibility): a v1.0 binary reading a v1.1 manifest with a new
|
||||||
|
//! optional field still loads it.
|
||||||
|
//!
|
||||||
|
//! ## TOML shape
|
||||||
|
//!
|
||||||
|
//! ```toml
|
||||||
|
//! name = "pmacs-magit"
|
||||||
|
//! version = "1.2.3"
|
||||||
|
//! summary = "Git porcelain for pmacs."
|
||||||
|
//! pmacs_required = ">= 1.0.0, < 2.0.0"
|
||||||
|
//! entry = "init.lua"
|
||||||
|
//! exports = ["magit", "magit.commit"]
|
||||||
|
//!
|
||||||
|
//! [[dependencies]]
|
||||||
|
//! address = "github:user/pmacs-async-utils"
|
||||||
|
//! version = "^0.4.0"
|
||||||
|
//!
|
||||||
|
//! [[conflicts]]
|
||||||
|
//! address = "github:other/pmacs-vc"
|
||||||
|
//! version = "*"
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use semver::{Version, VersionReq};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PackageName
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Validated package name. Lowercase letters, digits, and hyphens; an
|
||||||
|
/// optional `namespace/name` form for forge-style ownership prefixes.
|
||||||
|
///
|
||||||
|
/// Construct via [`PackageName::new`]; deserialization runs the same
|
||||||
|
/// validator and surfaces a parse-time error on invalid names.
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize)]
|
||||||
|
pub struct PackageName(String);
|
||||||
|
|
||||||
|
impl PackageName {
|
||||||
|
/// Validate and wrap a candidate name.
|
||||||
|
///
|
||||||
|
/// Rules: each segment (the part before `/` and the part after)
|
||||||
|
/// must start with a lowercase ASCII letter and contain only
|
||||||
|
/// `[a-z0-9-]`. At most one `/` separates a namespace from a name.
|
||||||
|
pub fn new(s: impl Into<String>) -> Result<Self, ManifestError> {
|
||||||
|
let s = s.into();
|
||||||
|
let (head, tail) = match s.split_once('/') {
|
||||||
|
None => (s.as_str(), ""),
|
||||||
|
Some((h, t)) => (h, t),
|
||||||
|
};
|
||||||
|
if s.matches('/').count() > 1 {
|
||||||
|
return Err(ManifestError::InvalidName {
|
||||||
|
value: s.clone(),
|
||||||
|
reason: "at most one `/` separator".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Self::validate_segment(head, &s)?;
|
||||||
|
if !tail.is_empty() {
|
||||||
|
Self::validate_segment(tail, &s)?;
|
||||||
|
} else if s.contains('/') {
|
||||||
|
return Err(ManifestError::InvalidName {
|
||||||
|
value: s.clone(),
|
||||||
|
reason: "namespace separator with empty tail".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_segment(seg: &str, full: &str) -> Result<(), ManifestError> {
|
||||||
|
if seg.is_empty() {
|
||||||
|
return Err(ManifestError::InvalidName {
|
||||||
|
value: full.to_string(),
|
||||||
|
reason: "empty segment".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let bytes = seg.as_bytes();
|
||||||
|
if !bytes[0].is_ascii_lowercase() {
|
||||||
|
return Err(ManifestError::InvalidName {
|
||||||
|
value: full.to_string(),
|
||||||
|
reason: format!("segment `{seg}` must start with a-z"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for &b in bytes {
|
||||||
|
if !(b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') {
|
||||||
|
return Err(ManifestError::InvalidName {
|
||||||
|
value: full.to_string(),
|
||||||
|
reason: format!("segment `{seg}` may only contain a-z, 0-9, and `-`"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrow the inner string.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for PackageName {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Self::new(s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DependencySpec
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A package address plus the version constraint that gates resolution.
|
||||||
|
///
|
||||||
|
/// The `address` is whatever syntax the package author wrote in their
|
||||||
|
/// manifest (`github:user/repo`, `git:URL`, etc.); validation that the
|
||||||
|
/// address parses lives in T M7.2's address fetcher, not here. T M7.1
|
||||||
|
/// stores the raw string verbatim so the M7.2 parser can deliver
|
||||||
|
/// scheme-localized errors at install time.
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct DependencySpec {
|
||||||
|
/// Package address (e.g., `"github:user/pmacs-magit"`).
|
||||||
|
pub address: String,
|
||||||
|
/// Version constraint (e.g., `"^1.0.0"`).
|
||||||
|
pub version: VersionReq,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PackageManifest
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// In-memory representation of a parsed `pmacs.toml`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct PackageManifest {
|
||||||
|
/// Unique identifier per [`PackageName`] rules.
|
||||||
|
pub name: PackageName,
|
||||||
|
/// Package version (semver).
|
||||||
|
pub version: Version,
|
||||||
|
/// One-line description.
|
||||||
|
pub summary: String,
|
||||||
|
/// Compatible pmacs version range.
|
||||||
|
pub pmacs_required: VersionReq,
|
||||||
|
/// Dependencies. Empty if omitted from TOML.
|
||||||
|
#[serde(default)]
|
||||||
|
pub dependencies: Vec<DependencySpec>,
|
||||||
|
/// Conflicts. Empty if omitted from TOML.
|
||||||
|
#[serde(default)]
|
||||||
|
pub conflicts: Vec<DependencySpec>,
|
||||||
|
/// Lua module path the package's `require` returns. Relative to
|
||||||
|
/// the package root.
|
||||||
|
pub entry: PathBuf,
|
||||||
|
/// Public Lua module names exported to other packages.
|
||||||
|
pub exports: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PackageManifest {
|
||||||
|
/// Parse a TOML manifest from a string.
|
||||||
|
///
|
||||||
|
/// Validation happens during deserialization: missing required
|
||||||
|
/// fields produce errors naming the field; invalid semver in
|
||||||
|
/// `version` or `pmacs_required` is rejected at parse time.
|
||||||
|
pub fn from_toml(s: &str) -> Result<Self, ManifestError> {
|
||||||
|
toml::from_str(s).map_err(ManifestError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize to canonical TOML form.
|
||||||
|
pub fn to_toml(&self) -> Result<String, ManifestError> {
|
||||||
|
toml::to_string(self).map_err(ManifestError::from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Errors produced by manifest parsing, validation, and serialization.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ManifestError {
|
||||||
|
/// A package name failed [`PackageName::new`]'s rules.
|
||||||
|
#[error("invalid package name `{value}`: {reason}")]
|
||||||
|
InvalidName {
|
||||||
|
/// The offending string.
|
||||||
|
value: String,
|
||||||
|
/// Human-readable explanation (which segment, which character).
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// TOML deserialization or per-field validation failed. The inner
|
||||||
|
/// error includes a span (line, column) plus the field name from
|
||||||
|
/// serde for missing-field errors and the semver crate's own
|
||||||
|
/// message for invalid `version` / `pmacs_required`.
|
||||||
|
#[error("manifest parse error: {0}")]
|
||||||
|
Parse(#[from] toml::de::Error),
|
||||||
|
/// TOML serialization failed (e.g., a non-UTF-8 path).
|
||||||
|
#[error("manifest serialize error: {0}")]
|
||||||
|
Serialize(#[from] toml::ser::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
fn sample_manifest() -> PackageManifest {
|
||||||
|
PackageManifest {
|
||||||
|
name: PackageName::new("pmacs-magit").unwrap(),
|
||||||
|
version: Version::new(1, 2, 3),
|
||||||
|
summary: "Git porcelain for pmacs.".into(),
|
||||||
|
pmacs_required: VersionReq::parse(">=1.0.0, <2.0.0").unwrap(),
|
||||||
|
dependencies: vec![DependencySpec {
|
||||||
|
address: "github:user/pmacs-async-utils".into(),
|
||||||
|
version: VersionReq::parse("^0.4.0").unwrap(),
|
||||||
|
}],
|
||||||
|
conflicts: vec![DependencySpec {
|
||||||
|
address: "github:other/pmacs-vc".into(),
|
||||||
|
version: VersionReq::parse("*").unwrap(),
|
||||||
|
}],
|
||||||
|
entry: PathBuf::from("init.lua"),
|
||||||
|
exports: vec!["magit".into(), "magit.commit".into()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Round-trip ----------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_toml_round_trips_a_valid_manifest() {
|
||||||
|
let m = sample_manifest();
|
||||||
|
let s = m.to_toml().unwrap();
|
||||||
|
let parsed = PackageManifest::from_toml(&s).unwrap();
|
||||||
|
assert_eq!(parsed, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_toml_accepts_minimal_manifest_with_defaults() {
|
||||||
|
let s = r#"
|
||||||
|
name = "minimal"
|
||||||
|
version = "0.1.0"
|
||||||
|
summary = "minimal package"
|
||||||
|
pmacs_required = ">=1.0.0"
|
||||||
|
entry = "init.lua"
|
||||||
|
exports = []
|
||||||
|
"#;
|
||||||
|
let m = PackageManifest::from_toml(s).unwrap();
|
||||||
|
assert_eq!(m.name.as_str(), "minimal");
|
||||||
|
assert!(m.dependencies.is_empty());
|
||||||
|
assert!(m.conflicts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Missing required fields name the field -----------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_name_field_error_names_name() {
|
||||||
|
let s = r#"
|
||||||
|
version = "0.1.0"
|
||||||
|
summary = "x"
|
||||||
|
pmacs_required = ">=1.0.0"
|
||||||
|
entry = "init.lua"
|
||||||
|
exports = []
|
||||||
|
"#;
|
||||||
|
let err = PackageManifest::from_toml(s).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("name"),
|
||||||
|
"error should name the missing field, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_version_field_error_names_version() {
|
||||||
|
let s = r#"
|
||||||
|
name = "ok"
|
||||||
|
summary = "x"
|
||||||
|
pmacs_required = ">=1.0.0"
|
||||||
|
entry = "init.lua"
|
||||||
|
exports = []
|
||||||
|
"#;
|
||||||
|
let err = PackageManifest::from_toml(s).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_summary_field_error_names_summary() {
|
||||||
|
let s = r#"
|
||||||
|
name = "ok"
|
||||||
|
version = "0.1.0"
|
||||||
|
pmacs_required = ">=1.0.0"
|
||||||
|
entry = "init.lua"
|
||||||
|
exports = []
|
||||||
|
"#;
|
||||||
|
let err = PackageManifest::from_toml(s).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("summary"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_pmacs_required_field_error_names_pmacs_required() {
|
||||||
|
let s = r#"
|
||||||
|
name = "ok"
|
||||||
|
version = "0.1.0"
|
||||||
|
summary = "x"
|
||||||
|
entry = "init.lua"
|
||||||
|
exports = []
|
||||||
|
"#;
|
||||||
|
let err = PackageManifest::from_toml(s).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("pmacs_required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_entry_field_error_names_entry() {
|
||||||
|
let s = r#"
|
||||||
|
name = "ok"
|
||||||
|
version = "0.1.0"
|
||||||
|
summary = "x"
|
||||||
|
pmacs_required = ">=1.0.0"
|
||||||
|
exports = []
|
||||||
|
"#;
|
||||||
|
let err = PackageManifest::from_toml(s).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("entry"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_exports_field_error_names_exports() {
|
||||||
|
let s = r#"
|
||||||
|
name = "ok"
|
||||||
|
version = "0.1.0"
|
||||||
|
summary = "x"
|
||||||
|
pmacs_required = ">=1.0.0"
|
||||||
|
entry = "init.lua"
|
||||||
|
"#;
|
||||||
|
let err = PackageManifest::from_toml(s).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("exports"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Invalid semver rejected at parse time ------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_version_string_rejected_at_parse_time() {
|
||||||
|
let s = r#"
|
||||||
|
name = "ok"
|
||||||
|
version = "not-a-semver"
|
||||||
|
summary = "x"
|
||||||
|
pmacs_required = ">=1.0.0"
|
||||||
|
entry = "init.lua"
|
||||||
|
exports = []
|
||||||
|
"#;
|
||||||
|
let err = PackageManifest::from_toml(s).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("version") || msg.contains("not-a-semver"),
|
||||||
|
"expected version parse error, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_pmacs_required_rejected_at_parse_time() {
|
||||||
|
let s = r#"
|
||||||
|
name = "ok"
|
||||||
|
version = "0.1.0"
|
||||||
|
summary = "x"
|
||||||
|
pmacs_required = "garbage-version-req"
|
||||||
|
entry = "init.lua"
|
||||||
|
exports = []
|
||||||
|
"#;
|
||||||
|
let err = PackageManifest::from_toml(s).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("pmacs_required") || msg.contains("garbage-version-req"),
|
||||||
|
"expected pmacs_required parse error, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_dependency_version_rejected_at_parse_time() {
|
||||||
|
let s = r#"
|
||||||
|
name = "ok"
|
||||||
|
version = "0.1.0"
|
||||||
|
summary = "x"
|
||||||
|
pmacs_required = ">=1.0.0"
|
||||||
|
entry = "init.lua"
|
||||||
|
exports = []
|
||||||
|
[[dependencies]]
|
||||||
|
address = "github:foo/bar"
|
||||||
|
version = "not-a-req"
|
||||||
|
"#;
|
||||||
|
let err = PackageManifest::from_toml(s).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("dependencies") || err.to_string().contains("not-a-req"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- PackageName validator coverage -------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_name_accepts_simple_lowercase() {
|
||||||
|
assert!(PackageName::new("pmacs-magit").is_ok());
|
||||||
|
assert!(PackageName::new("a").is_ok());
|
||||||
|
assert!(PackageName::new("a1").is_ok());
|
||||||
|
assert!(PackageName::new("foo-bar-baz").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_name_accepts_namespace_form() {
|
||||||
|
assert!(PackageName::new("user/pkg").is_ok());
|
||||||
|
assert!(PackageName::new("user-name/pkg-name").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_name_rejects_uppercase() {
|
||||||
|
let err = PackageName::new("Pmacs-magit").unwrap_err();
|
||||||
|
assert!(matches!(err, ManifestError::InvalidName { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_name_rejects_leading_digit() {
|
||||||
|
assert!(PackageName::new("1pkg").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_name_rejects_leading_hyphen() {
|
||||||
|
assert!(PackageName::new("-pkg").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_name_rejects_underscore() {
|
||||||
|
assert!(PackageName::new("pkg_name").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_name_rejects_empty() {
|
||||||
|
assert!(PackageName::new("").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_name_rejects_double_namespace() {
|
||||||
|
assert!(PackageName::new("a/b/c").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_name_rejects_trailing_slash() {
|
||||||
|
assert!(PackageName::new("user/").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_name_rejects_leading_slash() {
|
||||||
|
assert!(PackageName::new("/pkg").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_name_deserializes_with_validation() {
|
||||||
|
let s = r#"
|
||||||
|
name = "BAD-CAPS"
|
||||||
|
version = "0.1.0"
|
||||||
|
summary = "x"
|
||||||
|
pmacs_required = ">=1.0.0"
|
||||||
|
entry = "init.lua"
|
||||||
|
exports = []
|
||||||
|
"#;
|
||||||
|
let err = PackageManifest::from_toml(s).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("BAD-CAPS") || err.to_string().contains("name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Optional dependencies / conflicts respected ------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dependencies_default_to_empty_when_omitted() {
|
||||||
|
let s = r#"
|
||||||
|
name = "ok"
|
||||||
|
version = "0.1.0"
|
||||||
|
summary = "x"
|
||||||
|
pmacs_required = ">=1.0.0"
|
||||||
|
entry = "init.lua"
|
||||||
|
exports = ["main"]
|
||||||
|
"#;
|
||||||
|
let m = PackageManifest::from_toml(s).unwrap();
|
||||||
|
assert!(m.dependencies.is_empty());
|
||||||
|
assert!(m.conflicts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Property test: arbitrary valid manifest round-trips ----------------
|
||||||
|
|
||||||
|
fn name_segment_strategy() -> impl Strategy<Value = String> {
|
||||||
|
(
|
||||||
|
prop::char::range('a', 'z'),
|
||||||
|
prop::collection::vec(
|
||||||
|
prop_oneof![
|
||||||
|
prop::char::range('a', 'z'),
|
||||||
|
prop::char::range('0', '9'),
|
||||||
|
Just('-'),
|
||||||
|
],
|
||||||
|
0..8,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.prop_map(|(head, rest)| {
|
||||||
|
let mut s = String::new();
|
||||||
|
s.push(head);
|
||||||
|
s.extend(rest);
|
||||||
|
s
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn package_name_strategy() -> impl Strategy<Value = PackageName> {
|
||||||
|
prop_oneof![
|
||||||
|
name_segment_strategy().prop_filter_map("must validate", |s| PackageName::new(s).ok()),
|
||||||
|
(name_segment_strategy(), name_segment_strategy())
|
||||||
|
.prop_filter_map("namespaced must validate", |(a, b)| {
|
||||||
|
PackageName::new(format!("{a}/{b}")).ok()
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_strategy() -> impl Strategy<Value = Version> {
|
||||||
|
(0u8..16, 0u8..16, 0u8..16).prop_map(|(a, b, c)| Version::new(a.into(), b.into(), c.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_req_strategy() -> impl Strategy<Value = VersionReq> {
|
||||||
|
// Stick to forms semver round-trips losslessly: an exact pin
|
||||||
|
// formatted as `=major.minor.patch`. Caret/tilde/range-style
|
||||||
|
// requirements have canonicalization quirks (e.g., `*` <->
|
||||||
|
// `>=0.0.0`) that round-trip but compare unequal; the property
|
||||||
|
// test asserts equality, so we stay in the always-equal subset.
|
||||||
|
version_strategy().prop_map(|v| VersionReq::parse(&format!("={v}")).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dep_spec_strategy() -> impl Strategy<Value = DependencySpec> {
|
||||||
|
(
|
||||||
|
prop::string::string_regex("[a-z][a-z0-9-]{0,15}").unwrap(),
|
||||||
|
version_req_strategy(),
|
||||||
|
)
|
||||||
|
.prop_map(|(addr, ver)| DependencySpec {
|
||||||
|
address: format!("github:user/{addr}"),
|
||||||
|
version: ver,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_strategy() -> impl Strategy<Value = PackageManifest> {
|
||||||
|
(
|
||||||
|
package_name_strategy(),
|
||||||
|
version_strategy(),
|
||||||
|
// Summary: printable ASCII without quote/backslash hazards.
|
||||||
|
prop::string::string_regex("[a-zA-Z0-9 .,!?]{0,40}").unwrap(),
|
||||||
|
version_req_strategy(),
|
||||||
|
prop::collection::vec(dep_spec_strategy(), 0..3),
|
||||||
|
prop::collection::vec(dep_spec_strategy(), 0..3),
|
||||||
|
prop::string::string_regex("[a-z][a-z0-9_/.]{0,16}\\.lua").unwrap(),
|
||||||
|
prop::collection::vec(
|
||||||
|
prop::string::string_regex("[a-z][a-z0-9_.]{0,16}").unwrap(),
|
||||||
|
0..4,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.prop_map(
|
||||||
|
|(name, version, summary, req, deps, conf, entry, exports)| PackageManifest {
|
||||||
|
name,
|
||||||
|
version,
|
||||||
|
summary,
|
||||||
|
pmacs_required: req,
|
||||||
|
dependencies: deps,
|
||||||
|
conflicts: conf,
|
||||||
|
entry: PathBuf::from(entry),
|
||||||
|
exports,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn arbitrary_valid_manifest_survives_round_trip(m in manifest_strategy()) {
|
||||||
|
let s = m.to_toml().expect("serialize");
|
||||||
|
let parsed = PackageManifest::from_toml(&s).expect("parse round-tripped output");
|
||||||
|
prop_assert_eq!(parsed, m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
// packages/mod.rs --- Package model: manifests, addresses, resolver, lockfile.
|
||||||
|
|
||||||
|
//! Package model (spec §sec:packages-future, M7).
|
||||||
|
//!
|
||||||
|
//! v0.1 ships zero package machinery; loose Lua files in a config
|
||||||
|
//! directory are loaded directly. M7 adds the manifest format, address
|
||||||
|
//! parsing, the dependency resolver, the lockfile, and the loader that
|
||||||
|
//! wires all of it into `require`.
|
||||||
|
//!
|
||||||
|
//! This module assembles the M7 building blocks. T M7.1 lands the
|
||||||
|
//! manifest schema and parser ([`manifest`]); subsequent M7 tasks add
|
||||||
|
//! their own submodules under this same parent.
|
||||||
|
|
||||||
|
pub mod address;
|
||||||
|
pub mod fetcher;
|
||||||
|
pub mod installer;
|
||||||
|
pub mod manifest;
|
||||||
|
|
||||||
|
pub use address::{Address, AddressError};
|
||||||
|
pub use fetcher::{FetchError, Fetcher, RefSpec};
|
||||||
|
pub use installer::{InstallError, InstallScope, InstallSpec, InstalledPackage, Installer};
|
||||||
|
pub use manifest::{DependencySpec, ManifestError, PackageManifest, PackageName};
|
||||||
286
src/process.rs
286
src/process.rs
|
|
@ -60,6 +60,8 @@ use crossbeam::channel::{self, Receiver, Sender};
|
||||||
use nix::sys::signal::Signal;
|
use nix::sys::signal::Signal;
|
||||||
use nix::unistd::Pid;
|
use nix::unistd::Pid;
|
||||||
|
|
||||||
|
use crate::ansi::{AnsiEvent, AnsiParser};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Identity and configuration
|
// Identity and configuration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -194,6 +196,10 @@ pub struct ProcessSpec {
|
||||||
pub mode: ProcessMode,
|
pub mode: ProcessMode,
|
||||||
/// What to do on termination.
|
/// What to do on termination.
|
||||||
pub restart: RestartPolicy,
|
pub restart: RestartPolicy,
|
||||||
|
/// Parse PTY output on a worker and emit structured ANSI events
|
||||||
|
/// instead of raw stdout bytes. Opt-in so LSP and other byte-stream
|
||||||
|
/// consumers keep their existing stdout/stderr contract.
|
||||||
|
pub ansi_events: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProcessSpec {
|
impl ProcessSpec {
|
||||||
|
|
@ -209,6 +215,7 @@ impl ProcessSpec {
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
mode: ProcessMode::Pipes,
|
mode: ProcessMode::Pipes,
|
||||||
restart: RestartPolicy::Never,
|
restart: RestartPolicy::Never,
|
||||||
|
ansi_events: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -314,6 +321,10 @@ pub enum ProcessEventKind {
|
||||||
/// never emit `Stderr` (the pty merges output streams); they
|
/// never emit `Stderr` (the pty merges output streams); they
|
||||||
/// emit only `Stdout`.
|
/// emit only `Stdout`.
|
||||||
Stderr(Vec<u8>),
|
Stderr(Vec<u8>),
|
||||||
|
/// Structured ANSI events decoded from a PTY byte stream on the
|
||||||
|
/// parser worker. Only emitted when [`ProcessSpec::ansi_events`]
|
||||||
|
/// is true.
|
||||||
|
Ansi(Vec<AnsiEvent>),
|
||||||
/// Child exited cleanly.
|
/// Child exited cleanly.
|
||||||
Exited {
|
Exited {
|
||||||
/// Exit code.
|
/// Exit code.
|
||||||
|
|
@ -362,12 +373,24 @@ pub const PTY_READ_CEILING_BYTES: usize = 1 << 20;
|
||||||
/// chunks and a 1 MiB ceiling, this is 128 slots.
|
/// chunks and a 1 MiB ceiling, this is 128 slots.
|
||||||
const BYTE_CHUNK_CHANNEL_CAP: usize = PTY_READ_CEILING_BYTES / BYTE_CHUNK_SIZE;
|
const BYTE_CHUNK_CHANNEL_CAP: usize = PTY_READ_CEILING_BYTES / BYTE_CHUNK_SIZE;
|
||||||
|
|
||||||
|
/// In-flight structured-event ceiling between ANSI parser worker and
|
||||||
|
/// main-thread supervisor drain. The spec names this as 256 KiB; with
|
||||||
|
/// 8 KiB read chunks this is 32 parser batches in flight.
|
||||||
|
pub const ANSI_EVENT_CEILING_BYTES: usize = 256 * 1024;
|
||||||
|
const ANSI_EVENT_CHANNEL_CAP: usize = ANSI_EVENT_CEILING_BYTES / BYTE_CHUNK_SIZE;
|
||||||
|
|
||||||
/// How long a reader thread waits in a bounded `send` before polling
|
/// How long a reader thread waits in a bounded `send` before polling
|
||||||
/// its cancel flag. 50 ms is long enough that healthy steady-state
|
/// its cancel flag. 50 ms is long enough that healthy steady-state
|
||||||
/// flow doesn't burn cycles re-checking, short enough that a
|
/// flow doesn't burn cycles re-checking, short enough that a
|
||||||
/// shutting-down supervisor sees readers exit promptly.
|
/// shutting-down supervisor sees readers exit promptly.
|
||||||
const READER_SEND_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
const READER_SEND_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
|
/// Bounded grace window used when a child has exited but its reader /
|
||||||
|
/// parser worker may still have already-read bytes in flight. This is
|
||||||
|
/// not process termination grace; it is only the final output flush
|
||||||
|
/// before the runtime handles are dropped.
|
||||||
|
const EXIT_OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Supervisor
|
// Supervisor
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -410,11 +433,10 @@ struct RuntimeHandles {
|
||||||
/// [`RuntimeHandles`] so a generation's worker threads don't
|
/// [`RuntimeHandles`] so a generation's worker threads don't
|
||||||
/// outlive the supervisor.
|
/// outlive the supervisor.
|
||||||
readers: Vec<JoinHandle<()>>,
|
readers: Vec<JoinHandle<()>>,
|
||||||
/// Bounded byte channel from reader threads to the supervisor.
|
/// Bounded output channel drained by the supervisor. Raw processes
|
||||||
/// Sized at [`BYTE_CHUNK_CHANNEL_CAP`] slots so in-flight bytes
|
/// expose bytes directly; ANSI-enabled PTY processes expose parser
|
||||||
/// are capped at [`PTY_READ_CEILING_BYTES`]. T M6.2 / spec
|
/// batches from the worker stage.
|
||||||
/// §sec:repl-streaming.
|
output_rx: RuntimeOutputRx,
|
||||||
byte_rx: Receiver<ByteChunk>,
|
|
||||||
/// Cancel flag observed by reader threads when their bounded
|
/// Cancel flag observed by reader threads when their bounded
|
||||||
/// `send` blocks. Set on generation end / supervisor drop so a
|
/// `send` blocks. Set on generation end / supervisor drop so a
|
||||||
/// reader stuck in `send` (consumer fell behind) wakes promptly
|
/// reader stuck in `send` (consumer fell behind) wakes promptly
|
||||||
|
|
@ -441,6 +463,13 @@ impl Drop for RuntimeHandles {
|
||||||
/// `Stdout`). Lives on the per-generation bounded byte channel.
|
/// `Stdout`). Lives on the per-generation bounded byte channel.
|
||||||
type ByteChunk = (ReaderKind, Vec<u8>);
|
type ByteChunk = (ReaderKind, Vec<u8>);
|
||||||
|
|
||||||
|
type AnsiBatch = Vec<AnsiEvent>;
|
||||||
|
|
||||||
|
enum RuntimeOutputRx {
|
||||||
|
Bytes(Receiver<ByteChunk>),
|
||||||
|
Ansi(Receiver<AnsiBatch>),
|
||||||
|
}
|
||||||
|
|
||||||
/// Discriminated wrapper over a pipe-mode `std::process::Child` and
|
/// Discriminated wrapper over a pipe-mode `std::process::Child` and
|
||||||
/// a pty-mode portable-pty pair. The variants share `try_wait` /
|
/// a pty-mode portable-pty pair. The variants share `try_wait` /
|
||||||
/// pid retrieval through a thin enum match.
|
/// pid retrieval through a thin enum match.
|
||||||
|
|
@ -758,38 +787,25 @@ impl ProcessSupervisor {
|
||||||
/// most one `Stdout` and one `Stderr` event into pending. Called
|
/// most one `Stdout` and one `Stderr` event into pending. Called
|
||||||
/// from `tick()`. No-op if the process has no live runtime.
|
/// from `tick()`. No-op if the process has no live runtime.
|
||||||
fn drain_byte_channel(&mut self, id: ProcessId) {
|
fn drain_byte_channel(&mut self, id: ProcessId) {
|
||||||
let Some(proc) = self.processes.get(&id) else {
|
let drained = {
|
||||||
return;
|
let Some(proc) = self.processes.get(&id) else {
|
||||||
};
|
return;
|
||||||
let Some(rt) = proc.runtime.as_ref() else {
|
};
|
||||||
return;
|
let Some(rt) = proc.runtime.as_ref() else {
|
||||||
};
|
return;
|
||||||
let mut stdout_buf: Vec<u8> = Vec::new();
|
};
|
||||||
let mut stderr_buf: Vec<u8> = Vec::new();
|
match &rt.output_rx {
|
||||||
while let Ok((kind, mut bytes)) = rt.byte_rx.try_recv() {
|
RuntimeOutputRx::Bytes(byte_rx) => drain_raw_output(byte_rx),
|
||||||
match kind {
|
RuntimeOutputRx::Ansi(ansi_rx) => drain_ansi_output(ansi_rx),
|
||||||
ReaderKind::Stdout => stdout_buf.append(&mut bytes),
|
|
||||||
ReaderKind::Stderr => stderr_buf.append(&mut bytes),
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
if stdout_buf.is_empty() && stderr_buf.is_empty() {
|
if drained.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let queue = self.pending.entry(id).or_default();
|
let queue = self.pending.entry(id).or_default();
|
||||||
if !stdout_buf.is_empty() {
|
for kind in drained {
|
||||||
queue.push(ProcessEvent {
|
queue.push(ProcessEvent { id, kind, at: now });
|
||||||
id,
|
|
||||||
kind: ProcessEventKind::Stdout(stdout_buf),
|
|
||||||
at: now,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if !stderr_buf.is_empty() {
|
|
||||||
queue.push(ProcessEvent {
|
|
||||||
id,
|
|
||||||
kind: ProcessEventKind::Stderr(stderr_buf),
|
|
||||||
at: now,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -811,12 +827,14 @@ impl ProcessSupervisor {
|
||||||
Ok(None) => {}
|
Ok(None) => {}
|
||||||
Ok(Some(TermStatus::Exited(code))) => {
|
Ok(Some(TermStatus::Exited(code))) => {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
let final_output = final_drain_runtime(runtime);
|
||||||
proc.state = ProcessState::Terminated(Termination::Exited {
|
proc.state = ProcessState::Terminated(Termination::Exited {
|
||||||
code,
|
code,
|
||||||
started,
|
started,
|
||||||
ended: now,
|
ended: now,
|
||||||
});
|
});
|
||||||
proc.runtime = None;
|
proc.runtime = None;
|
||||||
|
append_process_events(&mut self.pending, id, final_output, now);
|
||||||
self.pending.entry(id).or_default().push(ProcessEvent {
|
self.pending.entry(id).or_default().push(ProcessEvent {
|
||||||
id,
|
id,
|
||||||
kind: ProcessEventKind::Exited { code },
|
kind: ProcessEventKind::Exited { code },
|
||||||
|
|
@ -825,12 +843,14 @@ impl ProcessSupervisor {
|
||||||
}
|
}
|
||||||
Ok(Some(TermStatus::Signaled(signal))) => {
|
Ok(Some(TermStatus::Signaled(signal))) => {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
let final_output = final_drain_runtime(runtime);
|
||||||
proc.state = ProcessState::Terminated(Termination::Signaled {
|
proc.state = ProcessState::Terminated(Termination::Signaled {
|
||||||
signal: signal.clone(),
|
signal: signal.clone(),
|
||||||
started,
|
started,
|
||||||
ended: now,
|
ended: now,
|
||||||
});
|
});
|
||||||
proc.runtime = None;
|
proc.runtime = None;
|
||||||
|
append_process_events(&mut self.pending, id, final_output, now);
|
||||||
self.pending.entry(id).or_default().push(ProcessEvent {
|
self.pending.entry(id).or_default().push(ProcessEvent {
|
||||||
id,
|
id,
|
||||||
kind: ProcessEventKind::Signaled { signal },
|
kind: ProcessEventKind::Signaled { signal },
|
||||||
|
|
@ -839,11 +859,13 @@ impl ProcessSupervisor {
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
let final_output = final_drain_runtime(runtime);
|
||||||
proc.state = ProcessState::Terminated(Termination::Crashed {
|
proc.state = ProcessState::Terminated(Termination::Crashed {
|
||||||
error: e.clone(),
|
error: e.clone(),
|
||||||
ended: now,
|
ended: now,
|
||||||
});
|
});
|
||||||
proc.runtime = None;
|
proc.runtime = None;
|
||||||
|
append_process_events(&mut self.pending, id, final_output, now);
|
||||||
self.pending.entry(id).or_default().push(ProcessEvent {
|
self.pending.entry(id).or_default().push(ProcessEvent {
|
||||||
id,
|
id,
|
||||||
kind: ProcessEventKind::Crashed { error: e },
|
kind: ProcessEventKind::Crashed { error: e },
|
||||||
|
|
@ -1037,6 +1059,9 @@ impl Drop for ProcessSupervisor {
|
||||||
/// `events_tx`; reader threads emit only byte chunks onto the
|
/// `events_tx`; reader threads emit only byte chunks onto the
|
||||||
/// per-generation bounded byte channel. T M6.2.
|
/// per-generation bounded byte channel. T M6.2.
|
||||||
fn build_runtime(spec: &ProcessSpec, id: ProcessId) -> Result<RuntimeHandles, String> {
|
fn build_runtime(spec: &ProcessSpec, id: ProcessId) -> Result<RuntimeHandles, String> {
|
||||||
|
if spec.ansi_events && matches!(spec.mode, ProcessMode::Pipes) {
|
||||||
|
return Err("process spawn: ansi=true requires pty mode; pipe-mode consumers receive raw stdout/stderr bytes".to_owned());
|
||||||
|
}
|
||||||
match spec.mode {
|
match spec.mode {
|
||||||
ProcessMode::Pipes => build_pipes_runtime(spec, id),
|
ProcessMode::Pipes => build_pipes_runtime(spec, id),
|
||||||
ProcessMode::Pty { rows, cols, mode } => build_pty_runtime(spec, id, rows, cols, mode),
|
ProcessMode::Pty { rows, cols, mode } => build_pty_runtime(spec, id, rows, cols, mode),
|
||||||
|
|
@ -1089,7 +1114,7 @@ fn build_pipes_runtime(spec: &ProcessSpec, _id: ProcessId) -> Result<RuntimeHand
|
||||||
stdin,
|
stdin,
|
||||||
pid,
|
pid,
|
||||||
readers,
|
readers,
|
||||||
byte_rx,
|
output_rx: RuntimeOutputRx::Bytes(byte_rx),
|
||||||
cancel,
|
cancel,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1154,12 +1179,19 @@ fn build_pty_runtime(
|
||||||
.map_err(|e| format!("pty reader: {e}"))?;
|
.map_err(|e| format!("pty reader: {e}"))?;
|
||||||
let (byte_tx, byte_rx) = channel::bounded::<ByteChunk>(BYTE_CHUNK_CHANNEL_CAP);
|
let (byte_tx, byte_rx) = channel::bounded::<ByteChunk>(BYTE_CHUNK_CHANNEL_CAP);
|
||||||
let cancel = Arc::new(AtomicBool::new(false));
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
let readers = vec![spawn_reader(
|
let mut readers = vec![spawn_reader(
|
||||||
byte_tx,
|
byte_tx,
|
||||||
Arc::clone(&cancel),
|
Arc::clone(&cancel),
|
||||||
reader,
|
reader,
|
||||||
ReaderKind::Stdout,
|
ReaderKind::Stdout,
|
||||||
)];
|
)];
|
||||||
|
let output_rx = if spec.ansi_events {
|
||||||
|
let (ansi_tx, ansi_rx) = channel::bounded::<AnsiBatch>(ANSI_EVENT_CHANNEL_CAP);
|
||||||
|
readers.push(spawn_ansi_parser(byte_rx, ansi_tx, Arc::clone(&cancel)));
|
||||||
|
RuntimeOutputRx::Ansi(ansi_rx)
|
||||||
|
} else {
|
||||||
|
RuntimeOutputRx::Bytes(byte_rx)
|
||||||
|
};
|
||||||
Ok(RuntimeHandles {
|
Ok(RuntimeHandles {
|
||||||
child: ChildHandle::Pty {
|
child: ChildHandle::Pty {
|
||||||
child: Arc::new(Mutex::new(into_send_sync_child(child))),
|
child: Arc::new(Mutex::new(into_send_sync_child(child))),
|
||||||
|
|
@ -1168,7 +1200,7 @@ fn build_pty_runtime(
|
||||||
stdin: Some(writer),
|
stdin: Some(writer),
|
||||||
pid,
|
pid,
|
||||||
readers,
|
readers,
|
||||||
byte_rx,
|
output_rx,
|
||||||
cancel,
|
cancel,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1327,6 +1359,122 @@ fn spawn_reader<R: Read + Send + 'static>(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn drain_raw_output(byte_rx: &Receiver<ByteChunk>) -> Vec<ProcessEventKind> {
|
||||||
|
let mut stdout_buf: Vec<u8> = Vec::new();
|
||||||
|
let mut stderr_buf: Vec<u8> = Vec::new();
|
||||||
|
while let Ok((kind, mut bytes)) = byte_rx.try_recv() {
|
||||||
|
match kind {
|
||||||
|
ReaderKind::Stdout => stdout_buf.append(&mut bytes),
|
||||||
|
ReaderKind::Stderr => stderr_buf.append(&mut bytes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(2);
|
||||||
|
if !stdout_buf.is_empty() {
|
||||||
|
out.push(ProcessEventKind::Stdout(stdout_buf));
|
||||||
|
}
|
||||||
|
if !stderr_buf.is_empty() {
|
||||||
|
out.push(ProcessEventKind::Stderr(stderr_buf));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_ansi_output(ansi_rx: &Receiver<AnsiBatch>) -> Vec<ProcessEventKind> {
|
||||||
|
let mut events: Vec<AnsiEvent> = Vec::new();
|
||||||
|
while let Ok(mut batch) = ansi_rx.try_recv() {
|
||||||
|
events.append(&mut batch);
|
||||||
|
}
|
||||||
|
if events.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![ProcessEventKind::Ansi(events)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_runtime_output(rt: &RuntimeHandles) -> Vec<ProcessEventKind> {
|
||||||
|
match &rt.output_rx {
|
||||||
|
RuntimeOutputRx::Bytes(byte_rx) => drain_raw_output(byte_rx),
|
||||||
|
RuntimeOutputRx::Ansi(ansi_rx) => drain_ansi_output(ansi_rx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn final_drain_runtime(rt: &RuntimeHandles) -> Vec<ProcessEventKind> {
|
||||||
|
let deadline = Instant::now() + EXIT_OUTPUT_DRAIN_TIMEOUT;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
loop {
|
||||||
|
let drained = drain_runtime_output(rt);
|
||||||
|
let drained_any = !drained.is_empty();
|
||||||
|
out.extend(drained);
|
||||||
|
if rt.readers.iter().all(std::thread::JoinHandle::is_finished) && !drained_any {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_process_events(
|
||||||
|
pending: &mut HashMap<ProcessId, Vec<ProcessEvent>>,
|
||||||
|
id: ProcessId,
|
||||||
|
kinds: Vec<ProcessEventKind>,
|
||||||
|
at: Instant,
|
||||||
|
) {
|
||||||
|
if kinds.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let queue = pending.entry(id).or_default();
|
||||||
|
for kind in kinds {
|
||||||
|
queue.push(ProcessEvent { id, kind, at });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the ANSI parser worker for an ANSI-enabled PTY generation.
|
||||||
|
///
|
||||||
|
/// The reader thread remains responsible for the 1 MiB PTY-read ceiling.
|
||||||
|
/// This stage consumes those chunks, maintains parser state across chunk
|
||||||
|
/// boundaries, and forwards structured events through a second bounded
|
||||||
|
/// channel whose capacity represents the spec's 256 KiB parser→main
|
||||||
|
/// ceiling.
|
||||||
|
fn spawn_ansi_parser(
|
||||||
|
byte_rx: Receiver<ByteChunk>,
|
||||||
|
ansi_tx: Sender<AnsiBatch>,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
) -> JoinHandle<()> {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut parser = AnsiParser::new();
|
||||||
|
loop {
|
||||||
|
if cancel.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (kind, bytes) = match byte_rx.recv_timeout(READER_SEND_POLL_INTERVAL) {
|
||||||
|
Ok(chunk) => chunk,
|
||||||
|
Err(crossbeam::channel::RecvTimeoutError::Timeout) => continue,
|
||||||
|
Err(crossbeam::channel::RecvTimeoutError::Disconnected) => return,
|
||||||
|
};
|
||||||
|
if !matches!(kind, ReaderKind::Stdout) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut events = parser.feed(&bytes);
|
||||||
|
if events.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
match ansi_tx.send_timeout(events, READER_SEND_POLL_INTERVAL) {
|
||||||
|
Ok(()) => break,
|
||||||
|
Err(crossbeam::channel::SendTimeoutError::Timeout(rejected)) => {
|
||||||
|
if cancel.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events = rejected;
|
||||||
|
}
|
||||||
|
Err(crossbeam::channel::SendTimeoutError::Disconnected(_)) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -1688,7 +1836,10 @@ mod tests {
|
||||||
sup.processes
|
sup.processes
|
||||||
.get(&id)
|
.get(&id)
|
||||||
.and_then(|p| p.runtime.as_ref())
|
.and_then(|p| p.runtime.as_ref())
|
||||||
.map_or(0, |rt| rt.byte_rx.len())
|
.map_or(0, |rt| match &rt.output_rx {
|
||||||
|
RuntimeOutputRx::Bytes(rx) => rx.len(),
|
||||||
|
RuntimeOutputRx::Ansi(_) => 0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// T M6.2 acceptance bullet 1: the per-generation byte channel
|
/// T M6.2 acceptance bullet 1: the per-generation byte channel
|
||||||
|
|
@ -1831,6 +1982,69 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m6_2_ansi_enabled_pty_emits_structured_events() {
|
||||||
|
let mut sup = ProcessSupervisor::new();
|
||||||
|
let mut spec = ProcessSpec::new("ansi-stream", "/bin/sh");
|
||||||
|
spec.args = vec!["-c".into(), "printf '\\033[31mhi\\033[0m\\n'".into()];
|
||||||
|
spec.mode = ProcessMode::Pty {
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
mode: TerminalMode::Canonical,
|
||||||
|
};
|
||||||
|
spec.ansi_events = true;
|
||||||
|
let id = sup.spawn(spec).expect("spawn ansi pty");
|
||||||
|
let evs = drain_until(&mut sup, id, Duration::from_secs(5), has_exited);
|
||||||
|
assert!(
|
||||||
|
evs.iter()
|
||||||
|
.all(|e| !matches!(e.kind, ProcessEventKind::Stdout(_))),
|
||||||
|
"ansi-enabled PTY should not surface raw stdout events: {evs:?}"
|
||||||
|
);
|
||||||
|
let mut saw_red = false;
|
||||||
|
let mut saw_text = false;
|
||||||
|
for ev in evs {
|
||||||
|
if let ProcessEventKind::Ansi(events) = ev.kind {
|
||||||
|
for event in events {
|
||||||
|
match event {
|
||||||
|
AnsiEvent::SetStyle(style)
|
||||||
|
if style.fg == crate::cell::Color::Indexed(1) =>
|
||||||
|
{
|
||||||
|
saw_red = true;
|
||||||
|
}
|
||||||
|
AnsiEvent::Text(text) if text.contains("hi") => {
|
||||||
|
saw_text = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(saw_red, "expected structured red SetStyle event");
|
||||||
|
assert!(saw_text, "expected structured text event");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m6_2_ansi_parser_worker_exits_when_reader_channel_closes() {
|
||||||
|
let (byte_tx, byte_rx) = channel::bounded::<ByteChunk>(1);
|
||||||
|
let (ansi_tx, _ansi_rx) = channel::bounded::<AnsiBatch>(1);
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
let handle = spawn_ansi_parser(byte_rx, ansi_tx, Arc::clone(&cancel));
|
||||||
|
drop(byte_tx);
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(500);
|
||||||
|
while Instant::now() < deadline && !handle.is_finished() {
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
if !handle.is_finished() {
|
||||||
|
cancel.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
handle.is_finished(),
|
||||||
|
"ANSI parser worker should exit once the byte reader channel closes"
|
||||||
|
);
|
||||||
|
handle.join().expect("parser worker join");
|
||||||
|
}
|
||||||
|
|
||||||
/// T M6.2 acceptance bullet 2: stream cancellation propagates to
|
/// T M6.2 acceptance bullet 2: stream cancellation propagates to
|
||||||
/// the source. A long-lived producer with the consumer paused
|
/// the source. A long-lived producer with the consumer paused
|
||||||
/// (so the reader is blocked in `send`) still terminates
|
/// (so the reader is blocked in `send`) still terminates
|
||||||
|
|
|
||||||
225
src/project.rs
225
src/project.rs
|
|
@ -201,6 +201,28 @@ pub fn default_markers() -> Vec<ProjectMarker> {
|
||||||
/// search begins at its parent.
|
/// search begins at its parent.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn detect_project(start: &Path, markers: &[ProjectMarker]) -> Option<(PathBuf, ProjectKind)> {
|
pub fn detect_project(start: &Path, markers: &[ProjectMarker]) -> Option<(PathBuf, ProjectKind)> {
|
||||||
|
walk_for_marker(start, markers, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`detect_project`], but halts the upward walk after examining
|
||||||
|
/// `stop_root`. Used by tests so a stray marker in a temp-dir's
|
||||||
|
/// ancestor (e.g. a developer's `/tmp/.git`) can't leak into a
|
||||||
|
/// fixture that lives below it. The walk still examines `stop_root`
|
||||||
|
/// itself; only its parent and beyond are skipped.
|
||||||
|
#[must_use]
|
||||||
|
pub fn detect_project_within(
|
||||||
|
start: &Path,
|
||||||
|
markers: &[ProjectMarker],
|
||||||
|
stop_root: &Path,
|
||||||
|
) -> Option<(PathBuf, ProjectKind)> {
|
||||||
|
walk_for_marker(start, markers, Some(stop_root))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_for_marker(
|
||||||
|
start: &Path,
|
||||||
|
markers: &[ProjectMarker],
|
||||||
|
stop_root: Option<&Path>,
|
||||||
|
) -> Option<(PathBuf, ProjectKind)> {
|
||||||
let start_dir: &Path = if start.is_file() {
|
let start_dir: &Path = if start.is_file() {
|
||||||
start.parent().unwrap_or(start)
|
start.parent().unwrap_or(start)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -210,6 +232,11 @@ pub fn detect_project(start: &Path, markers: &[ProjectMarker]) -> Option<(PathBu
|
||||||
if let Some(kind) = match_marker(ancestor, markers) {
|
if let Some(kind) = match_marker(ancestor, markers) {
|
||||||
return Some((ancestor.to_path_buf(), kind));
|
return Some((ancestor.to_path_buf(), kind));
|
||||||
}
|
}
|
||||||
|
if let Some(stop) = stop_root {
|
||||||
|
if ancestor == stop {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
@ -259,6 +286,16 @@ pub struct Workspace {
|
||||||
by_root: HashMap<PathBuf, ProjectId>,
|
by_root: HashMap<PathBuf, ProjectId>,
|
||||||
active: Option<ProjectId>,
|
active: Option<ProjectId>,
|
||||||
markers: Vec<ProjectMarker>,
|
markers: Vec<ProjectMarker>,
|
||||||
|
/// Optional clamp on [`Self::detect`]'s upward marker walk.
|
||||||
|
/// When `None` (the default), detection walks ancestors all the
|
||||||
|
/// way to the filesystem root (matching `git rev-parse
|
||||||
|
/// --show-toplevel` semantics). When `Some(boundary)`, the walk
|
||||||
|
/// halts after examining `boundary`; ancestors above `boundary`
|
||||||
|
/// are not consulted.
|
||||||
|
///
|
||||||
|
/// Stored canonicalized so the symlinked-workspace case behaves
|
||||||
|
/// predictably (see [`Self::set_search_boundary`]).
|
||||||
|
search_boundary: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Workspace {
|
impl Default for Workspace {
|
||||||
|
|
@ -268,7 +305,8 @@ impl Default for Workspace {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Workspace {
|
impl Workspace {
|
||||||
/// Empty workspace with the [`default_markers`] rule set.
|
/// Empty workspace with the [`default_markers`] rule set and no
|
||||||
|
/// search boundary (detection walks to filesystem root).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -276,6 +314,7 @@ impl Workspace {
|
||||||
by_root: HashMap::new(),
|
by_root: HashMap::new(),
|
||||||
active: None,
|
active: None,
|
||||||
markers: default_markers(),
|
markers: default_markers(),
|
||||||
|
search_boundary: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -291,11 +330,54 @@ impl Workspace {
|
||||||
&self.markers
|
&self.markers
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect the project root for `file_path`. Convenience wrapper
|
/// Set (or clear) the upward-walk boundary for [`Self::detect`].
|
||||||
/// around [`detect_project`] using the workspace's marker rules.
|
///
|
||||||
|
/// `Some(path)` clamps detection so it never examines ancestors
|
||||||
|
/// above `path`. `None` restores the default walk-to-filesystem-root
|
||||||
|
/// behavior. Use case: a user whose code lives under `~/code` can
|
||||||
|
/// set `search_boundary = "~/code"` in `init.lua` so a stray
|
||||||
|
/// marker higher up the tree (e.g. `/tmp/.git`, an orphaned `.git`
|
||||||
|
/// in `~`) cannot capture unrelated files.
|
||||||
|
///
|
||||||
|
/// # Symlink handling
|
||||||
|
///
|
||||||
|
/// The boundary is canonicalized at set time, and start paths are
|
||||||
|
/// canonicalized at detect time, so a search starting from a
|
||||||
|
/// symlinked path that resolves under the boundary still respects
|
||||||
|
/// the boundary. If `path` does not exist on disk we store it
|
||||||
|
/// as-is; later detection then compares against the literal value
|
||||||
|
/// (which matches the upstream behavior of
|
||||||
|
/// [`canonicalize_or_passthrough`]).
|
||||||
|
///
|
||||||
|
/// # Inclusivity
|
||||||
|
///
|
||||||
|
/// The boundary is *inclusive*: a marker located at the boundary
|
||||||
|
/// path itself is found; only strict ancestors of the boundary
|
||||||
|
/// are skipped. Set the boundary to the directory that *contains*
|
||||||
|
/// your projects, not to one level above.
|
||||||
|
pub fn set_search_boundary(&mut self, path: Option<PathBuf>) {
|
||||||
|
self.search_boundary = path.map(|p| canonicalize_or_passthrough(&p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only view of the configured search boundary.
|
||||||
|
#[must_use]
|
||||||
|
pub fn search_boundary(&self) -> Option<&Path> {
|
||||||
|
self.search_boundary.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the project root for `file_path`, honoring the
|
||||||
|
/// workspace's [`Self::set_search_boundary`] clamp if any.
|
||||||
|
///
|
||||||
|
/// `file_path` is canonicalized before the walk if possible so
|
||||||
|
/// that boundary comparison works correctly under symlinks (e.g.,
|
||||||
|
/// `/tmp/sandbox/foo` symlinked to `/home/user/code/foo`).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn detect(&self, file_path: &Path) -> Option<(PathBuf, ProjectKind)> {
|
pub fn detect(&self, file_path: &Path) -> Option<(PathBuf, ProjectKind)> {
|
||||||
detect_project(file_path, &self.markers)
|
let canonical = canonicalize_or_passthrough(file_path);
|
||||||
|
match self.search_boundary.as_deref() {
|
||||||
|
Some(boundary) => detect_project_within(&canonical, &self.markers, boundary),
|
||||||
|
None => detect_project(&canonical, &self.markers),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a project at `root`. Idempotent: if a project for the
|
/// Open a project at `root`. Idempotent: if a project for the
|
||||||
|
|
@ -508,10 +590,15 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_returns_none_with_no_markers() {
|
fn detect_returns_none_with_no_markers() {
|
||||||
|
// Bound the walk at the tempdir so a marker in a real
|
||||||
|
// ancestor (a developer's `/tmp/.git`, a CI runner's repo
|
||||||
|
// root above the test fixture, etc.) can't masquerade as a
|
||||||
|
// hit. Production callers walk to the filesystem root; the
|
||||||
|
// bound is a test-only correctness aid.
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let f = dir.path().join("a/b.rs");
|
let f = dir.path().join("a/b.rs");
|
||||||
touch(&f);
|
touch(&f);
|
||||||
assert!(detect_project(&f, &default_markers()).is_none());
|
assert!(detect_project_within(&f, &default_markers(), dir.path()).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -611,7 +698,133 @@ mod tests {
|
||||||
kind: ProjectKind::Git,
|
kind: ProjectKind::Git,
|
||||||
is_directory: true,
|
is_directory: true,
|
||||||
}]);
|
}]);
|
||||||
// No git → no detection.
|
// No git → no detection. Bound the walk at the tempdir so
|
||||||
|
// that a `.git` in any real ancestor cannot satisfy the
|
||||||
|
// (now sole) git marker; we are testing that `set_markers`
|
||||||
|
// chose the marker set, not what lives above the test.
|
||||||
|
assert!(detect_project_within(&f, ws.markers(), root).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Reviewer-flagged item 2: search-boundary clamp on detection.
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_boundary_default_is_none() {
|
||||||
|
let ws = Workspace::new();
|
||||||
|
assert!(ws.search_boundary().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_boundary_clamps_walk_above_boundary() {
|
||||||
|
// Stage a fake "outer marker" above the boundary (the reviewer's
|
||||||
|
// /tmp/.git case). Without the boundary, `detect` would walk up
|
||||||
|
// to the outer marker. With it, the walk halts at the
|
||||||
|
// boundary directory and returns None.
|
||||||
|
let outer = tempfile::tempdir().expect("outer");
|
||||||
|
// The outer dir gets a git marker.
|
||||||
|
mkdir(&outer.path().join(".git"));
|
||||||
|
// The "boundary" directory is a child of outer; the file
|
||||||
|
// lives below the boundary.
|
||||||
|
let boundary = outer.path().join("workspace");
|
||||||
|
let f = boundary.join("src/main.rs");
|
||||||
|
touch(&f);
|
||||||
|
|
||||||
|
let mut ws = Workspace::new();
|
||||||
|
// Without the boundary, detect walks up and finds the outer
|
||||||
|
// marker.
|
||||||
|
assert!(
|
||||||
|
ws.detect(&f).is_some(),
|
||||||
|
"sanity: outer .git is detectable without the boundary"
|
||||||
|
);
|
||||||
|
|
||||||
|
ws.set_search_boundary(Some(boundary.clone()));
|
||||||
|
// With the boundary set to the workspace dir, the outer marker
|
||||||
|
// is above the boundary and thus invisible.
|
||||||
|
assert!(
|
||||||
|
ws.detect(&f).is_none(),
|
||||||
|
"with boundary at {boundary:?}, the outer marker must be excluded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_boundary_is_inclusive_examines_boundary_itself() {
|
||||||
|
// The boundary semantics: the boundary path *itself* is
|
||||||
|
// examined for markers; only strict ancestors are skipped.
|
||||||
|
// Documented inclusivity: "set boundary to the directory
|
||||||
|
// containing your projects, not one level above."
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let root = dir.path();
|
||||||
|
touch(&root.join("Cargo.toml"));
|
||||||
|
let f = root.join("src/lib.rs");
|
||||||
|
touch(&f);
|
||||||
|
|
||||||
|
let mut ws = Workspace::new();
|
||||||
|
ws.set_search_boundary(Some(root.to_path_buf()));
|
||||||
|
let detected = ws.detect(&f).expect("marker at the boundary must match");
|
||||||
|
// Path equality after canonicalization (macOS /tmp → /private/tmp).
|
||||||
|
assert_eq!(
|
||||||
|
detected.0.canonicalize().expect("canon found"),
|
||||||
|
root.canonicalize().expect("canon root")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_boundary_clearable_back_to_none() {
|
||||||
|
let outer = tempfile::tempdir().expect("outer");
|
||||||
|
mkdir(&outer.path().join(".git"));
|
||||||
|
let boundary = outer.path().join("workspace");
|
||||||
|
let f = boundary.join("src/main.rs");
|
||||||
|
touch(&f);
|
||||||
|
|
||||||
|
let mut ws = Workspace::new();
|
||||||
|
ws.set_search_boundary(Some(boundary.clone()));
|
||||||
assert!(ws.detect(&f).is_none());
|
assert!(ws.detect(&f).is_none());
|
||||||
|
ws.set_search_boundary(None);
|
||||||
|
assert!(
|
||||||
|
ws.detect(&f).is_some(),
|
||||||
|
"clearing the boundary must restore the unbounded walk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_boundary_resolves_under_symlinked_start() {
|
||||||
|
// The symlinked-workspace case: corporate /home mounts and
|
||||||
|
// user-organized symlink farms put the file path under a
|
||||||
|
// symlink that resolves into the boundary. The walk
|
||||||
|
// canonicalizes both the start path and the boundary, so the
|
||||||
|
// boundary applies after symlink resolution.
|
||||||
|
//
|
||||||
|
// Skip on platforms / sandboxes that disallow symlink
|
||||||
|
// creation. Symlinks under tempfile dirs are normally allowed,
|
||||||
|
// but a paranoid sandbox may reject EPERM.
|
||||||
|
let real_dir = tempfile::tempdir().expect("real");
|
||||||
|
let real = real_dir.path().to_path_buf();
|
||||||
|
touch(&real.join("Cargo.toml"));
|
||||||
|
let real_file = real.join("src/lib.rs");
|
||||||
|
touch(&real_file);
|
||||||
|
|
||||||
|
let link_dir = tempfile::tempdir().expect("link");
|
||||||
|
let link = link_dir.path().join("via-link");
|
||||||
|
if let Err(e) = std::os::unix::fs::symlink(&real, &link) {
|
||||||
|
eprintln!("test skipped: symlink {link:?} → {real:?} failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let linked_file = link.join("src/lib.rs");
|
||||||
|
|
||||||
|
let mut ws = Workspace::new();
|
||||||
|
ws.set_search_boundary(Some(real.clone()));
|
||||||
|
// Walking via the symlinked path: after canonicalization both
|
||||||
|
// the start and the boundary live under `real`. The marker at
|
||||||
|
// `real/Cargo.toml` is at the boundary and is examined.
|
||||||
|
let (found, kind) = ws
|
||||||
|
.detect(&linked_file)
|
||||||
|
.expect("symlinked walk must still find the marker at the boundary");
|
||||||
|
assert_eq!(kind, ProjectKind::Rust);
|
||||||
|
assert_eq!(
|
||||||
|
found.canonicalize().expect("canon"),
|
||||||
|
real.canonicalize().expect("canon real")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
src/view.rs
63
src/view.rs
|
|
@ -38,10 +38,62 @@
|
||||||
//! when overlays touch only the cells they declare (see
|
//! when overlays touch only the cells they declare (see
|
||||||
//! `composition_overhead_under_ten_percent` in `editor.rs`).
|
//! `composition_overhead_under_ten_percent` in `editor.rs`).
|
||||||
|
|
||||||
use crate::buffer::{Buffer, BufferError, EditOp};
|
use crate::buffer::{Buffer, BufferError, BufferId, EditOp};
|
||||||
use crate::cell::{CellCoord, CellGrid, CellSize};
|
use crate::cell::{CellCoord, CellGrid, CellSize};
|
||||||
use crate::rope::{Edit, Position};
|
use crate::rope::{Edit, Position};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// InterceptContext (T M7.4)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Snapshot of the buffer's identity and shape, passed to
|
||||||
|
/// [`View::intercept_edit`] in lieu of a `&Buffer` reference.
|
||||||
|
///
|
||||||
|
/// # Why a snapshot, not a `&Buffer`
|
||||||
|
///
|
||||||
|
/// Pre-M7.4, `intercept_edit` received `&Buffer`. The Lua-bindings
|
||||||
|
/// layer held the registry's `RefCell::borrow_mut` for the full
|
||||||
|
/// duration of the call, so an intercept body that re-entered
|
||||||
|
/// `pmacs.buffer.X` synchronously hit a recursive-borrow error
|
||||||
|
/// (`BindingError::Reentrant`); the M6.10 audit fix surfaced this as
|
||||||
|
/// a typed error rather than a panic but did not enable the re-entry.
|
||||||
|
///
|
||||||
|
/// M7.4 splits the edit flow into three phases. Phase 2 runs the
|
||||||
|
/// intercept chain with the registry borrow released, so an intercept
|
||||||
|
/// body may call back into `pmacs.buffer.X` on any buffer. The cost:
|
||||||
|
/// the intercept can no longer hold a `&Buffer` (the buffer might be
|
||||||
|
/// mutated by the re-entrant call). Instead, we hand it an
|
||||||
|
/// `InterceptContext` snapshot taken at the start of the edit; the
|
||||||
|
/// fields cover every read the intercept needs (`buf_id` for routing,
|
||||||
|
/// `buf_len` for clamping positions, `buf_name` for diagnostic
|
||||||
|
/// messages, `revision` for "did this snapshot drift?" checks).
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct InterceptContext {
|
||||||
|
/// The buffer's identifier. Stable across the edit.
|
||||||
|
pub buf_id: BufferId,
|
||||||
|
/// Buffer length in bytes at the moment the snapshot was taken.
|
||||||
|
pub buf_len: u64,
|
||||||
|
/// Buffer name at the moment the snapshot was taken.
|
||||||
|
pub buf_name: String,
|
||||||
|
/// Buffer revision at the moment the snapshot was taken. Useful
|
||||||
|
/// for re-entrant cross-buffer edits to detect whether the parent
|
||||||
|
/// buffer was mutated during their lifetime.
|
||||||
|
pub revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InterceptContext {
|
||||||
|
/// Build a context snapshot from a buffer reference.
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot(buf: &Buffer) -> Self {
|
||||||
|
Self {
|
||||||
|
buf_id: buf.id(),
|
||||||
|
buf_len: buf.len(),
|
||||||
|
buf_name: buf.name().to_string(),
|
||||||
|
revision: buf.revision(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Coordinates
|
// Coordinates
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -115,10 +167,17 @@ pub trait View {
|
||||||
/// view that translates filename edits into rename operations).
|
/// view that translates filename edits into rename operations).
|
||||||
/// Returning an error rejects the edit.
|
/// Returning an error rejects the edit.
|
||||||
///
|
///
|
||||||
|
/// The view receives a [`InterceptContext`] snapshot of the
|
||||||
|
/// buffer's identity and shape at the moment the edit began
|
||||||
|
/// (T M7.4). Intercept bodies needing live state on another
|
||||||
|
/// buffer can re-enter `pmacs.buffer.X` (or the in-process
|
||||||
|
/// equivalent); the registry borrow is released for the duration
|
||||||
|
/// of this call.
|
||||||
|
///
|
||||||
/// Default: pass through.
|
/// Default: pass through.
|
||||||
fn intercept_edit<'a>(
|
fn intercept_edit<'a>(
|
||||||
&mut self,
|
&mut self,
|
||||||
_buf: &Buffer,
|
_ctx: &InterceptContext,
|
||||||
op: EditOp<'a>,
|
op: EditOp<'a>,
|
||||||
) -> Result<EditOp<'a>, BufferError> {
|
) -> Result<EditOp<'a>, BufferError> {
|
||||||
Ok(op)
|
Ok(op)
|
||||||
|
|
|
||||||
|
|
@ -3283,3 +3283,88 @@ fn m4_12_default_bundle_after_load_robust_to_missing_server() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let _ = saw_error;
|
let _ = saw_error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reviewer-flagged item 2: pmacs.project.set_search_boundary
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// End-to-end acceptance for the search-boundary clamp on the Lua
|
||||||
|
// surface. The Rust-side semantics are unit-tested in
|
||||||
|
// `src/project.rs::tests::search_boundary_*`; these tests cover the
|
||||||
|
// Lua function-call surface and the round-trip through the
|
||||||
|
// `pmacs.project.detect` binding.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_set_search_boundary_clamps_lua_detect_call() {
|
||||||
|
use pmacs::editor::EditorState;
|
||||||
|
|
||||||
|
// Stage an outer .git that detection would normally find, plus
|
||||||
|
// a workspace dir under it with a file but no marker.
|
||||||
|
let outer = tempfile::tempdir().expect("outer");
|
||||||
|
std::fs::create_dir_all(outer.path().join(".git")).expect("outer .git");
|
||||||
|
let workspace = outer.path().join("workspace");
|
||||||
|
std::fs::create_dir_all(workspace.join("src")).expect("workspace/src");
|
||||||
|
let f = workspace.join("src/main.rs");
|
||||||
|
std::fs::write(&f, b"").expect("touch file");
|
||||||
|
|
||||||
|
let state = EditorState::new();
|
||||||
|
let lua = state.lua_host.lua();
|
||||||
|
let workspace_str = workspace.display().to_string();
|
||||||
|
let f_str = f.display().to_string();
|
||||||
|
|
||||||
|
let (without_boundary, with_boundary): (Option<String>, Option<String>) = lua
|
||||||
|
.load(format!(
|
||||||
|
"
|
||||||
|
-- Without a boundary, detect walks up to the outer .git.
|
||||||
|
local before = pmacs.project.detect('{f_str}')
|
||||||
|
-- Clamp the walk to the workspace dir; the outer marker is now invisible.
|
||||||
|
pmacs.project.set_search_boundary('{workspace_str}')
|
||||||
|
local after = pmacs.project.detect('{f_str}')
|
||||||
|
return before and before.kind or nil, after and after.kind or nil
|
||||||
|
"
|
||||||
|
))
|
||||||
|
.eval()
|
||||||
|
.expect("detect sequence");
|
||||||
|
assert_eq!(
|
||||||
|
without_boundary.as_deref(),
|
||||||
|
Some("git"),
|
||||||
|
"without the boundary, the outer .git is detected"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
with_boundary.is_none(),
|
||||||
|
"with the boundary clamping at the workspace dir, the outer marker is invisible: {with_boundary:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_search_boundary_round_trips_via_lua() {
|
||||||
|
use pmacs::editor::EditorState;
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("dir");
|
||||||
|
let dir_str = dir.path().display().to_string();
|
||||||
|
let state = EditorState::new();
|
||||||
|
let lua = state.lua_host.lua();
|
||||||
|
|
||||||
|
let (initial, after_set, after_clear): (Option<String>, Option<String>, Option<String>) = lua
|
||||||
|
.load(format!(
|
||||||
|
"
|
||||||
|
local before = pmacs.project.search_boundary()
|
||||||
|
pmacs.project.set_search_boundary('{dir_str}')
|
||||||
|
local after = pmacs.project.search_boundary()
|
||||||
|
pmacs.project.set_search_boundary(nil)
|
||||||
|
local cleared = pmacs.project.search_boundary()
|
||||||
|
return before, after, cleared
|
||||||
|
"
|
||||||
|
))
|
||||||
|
.eval()
|
||||||
|
.expect("round trip");
|
||||||
|
assert!(initial.is_none(), "default boundary is nil");
|
||||||
|
assert!(
|
||||||
|
after_set.is_some(),
|
||||||
|
"set_search_boundary(path) must surface as a non-nil read"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
after_clear.is_none(),
|
||||||
|
"set_search_boundary(nil) must clear back to nil"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,54 @@ fn m6_4_set_prompt_replaces_prompt_region() {
|
||||||
"#);
|
"#);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m6_4_region_boundaries_are_mark_backed_across_input_edits() {
|
||||||
|
run(r#"
|
||||||
|
local h = pmacs.repl.create({ name = "*test*" })
|
||||||
|
h:append_output("history\n")
|
||||||
|
h:set_prompt("$ ")
|
||||||
|
local buf = h:buffer_id()
|
||||||
|
local prompt_before = h:prompt_end()
|
||||||
|
|
||||||
|
-- User edits at the input boundary must not drag the prompt
|
||||||
|
-- boundary forward. This is the failure mode byte-offset
|
||||||
|
-- mirrors hid before real marks existed.
|
||||||
|
buf:insert(prompt_before, "abc")
|
||||||
|
assert(h:prompt_end() == prompt_before,
|
||||||
|
"prompt_end moved across input insert")
|
||||||
|
assert(h:input_text() == "abc", "input text after insert")
|
||||||
|
|
||||||
|
h:append_output("more\n")
|
||||||
|
assert(h:input_text() == "abc",
|
||||||
|
"input preserved after output before prompt")
|
||||||
|
local history = buf:slice(0, h:history_end())
|
||||||
|
assert(history == "history\nmore\n",
|
||||||
|
"history after append: " .. history)
|
||||||
|
"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m6_4_osc_133_prompt_markers_route_text_to_prompt_region() {
|
||||||
|
run(r#"
|
||||||
|
local h = pmacs.repl.create({ name = "*test*" })
|
||||||
|
h:append_output("\27]133;A\7$ \27]133;B\7")
|
||||||
|
local buf = h:buffer_id()
|
||||||
|
|
||||||
|
assert(h:history_end() == 0,
|
||||||
|
"prompt marker text must not enter history")
|
||||||
|
assert(buf:slice(h:history_end(), h:prompt_end()) == "$ ",
|
||||||
|
"prompt region should hold shell prompt")
|
||||||
|
|
||||||
|
buf:insert(h:prompt_end(), "typed")
|
||||||
|
h:append_output("out\n")
|
||||||
|
assert(buf:slice(0, h:history_end()) == "out\n",
|
||||||
|
"later output should enter history")
|
||||||
|
assert(buf:slice(h:history_end(), h:prompt_end()) == "$ ",
|
||||||
|
"prompt remains prompt after history output")
|
||||||
|
assert(h:input_text() == "typed", "input preserved")
|
||||||
|
"#);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Acceptance bullet 3: read-only enforcement
|
// Acceptance bullet 3: read-only enforcement
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -288,6 +336,38 @@ fn m6_4_ansi_styled_output_routes_to_history_only() {
|
||||||
.. content .. "'")
|
.. content .. "'")
|
||||||
assert(not content:find("\27"),
|
assert(not content:find("\27"),
|
||||||
"rope must not contain ESC bytes")
|
"rope must not contain ESC bytes")
|
||||||
|
local spans = h:style_spans()
|
||||||
|
assert(#spans == 1, "expected one red style span, got " .. #spans)
|
||||||
|
assert(spans[1].start == 0 and spans[1]["end"] == 5,
|
||||||
|
"red span should cover hello, got [" .. spans[1].start ..
|
||||||
|
"," .. spans[1]["end"] .. ")")
|
||||||
|
assert(spans[1].style.fg == 1,
|
||||||
|
"red span should carry palette fg=1")
|
||||||
|
"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m6_4_line_level_ansi_updates_history_in_place() {
|
||||||
|
run(r#"
|
||||||
|
local h = pmacs.repl.create({ name = "*test*" })
|
||||||
|
h:append_output("progress 10%")
|
||||||
|
h:append_output("\rprogress 20%\27[K")
|
||||||
|
local content = h:buffer_id():slice(0, h:buffer_id():len())
|
||||||
|
assert(content == "progress 20%",
|
||||||
|
"CR overwrite + erase-to-EOL should update in place: '" ..
|
||||||
|
content .. "'")
|
||||||
|
|
||||||
|
h:append_output("\r\27[2Kdone\n")
|
||||||
|
content = h:buffer_id():slice(0, h:buffer_id():len())
|
||||||
|
assert(content == "done\n",
|
||||||
|
"erase-line should clear current line before rewrite: '" ..
|
||||||
|
content .. "'")
|
||||||
|
|
||||||
|
h:append_output("abc\bZ")
|
||||||
|
content = h:buffer_id():slice(0, h:buffer_id():len())
|
||||||
|
assert(content == "done\nabZ",
|
||||||
|
"backspace should rewind within current line: '" ..
|
||||||
|
content .. "'")
|
||||||
"#);
|
"#);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,597 @@
|
||||||
|
// m7_3_acceptance.rs --- Acceptance suite for T M7.3 (`pmacs.packages.*`).
|
||||||
|
|
||||||
|
//! End-to-end acceptance tests for T M7.3 (`pmacs.packages.install`,
|
||||||
|
//! `pmacs.packages.install_project`).
|
||||||
|
//!
|
||||||
|
//! The three acceptance bullets from the task definition:
|
||||||
|
//!
|
||||||
|
//! 1. **User-config install of a sample package succeeds and the
|
||||||
|
//! package's entry module is requireable.** Tested here as
|
||||||
|
//! [`user_install_makes_entry_requireable`]. The test stages a
|
||||||
|
//! bare git repo containing a `pmacs.toml` + `init.lua`, redirects
|
||||||
|
//! the install machinery away from the developer's real
|
||||||
|
//! `$XDG_*` paths via [`PackageInstallOverride`], calls
|
||||||
|
//! `pmacs.packages.install`, and verifies that
|
||||||
|
//! `require("samplepkg")` returns the entry module's table.
|
||||||
|
//! 2. **Project install for a sample package isolates from
|
||||||
|
//! user-config.** Tested as
|
||||||
|
//! [`project_install_isolates_from_user_install`]. A user install
|
||||||
|
//! and a project install of the same upstream land in different
|
||||||
|
//! on-disk roots; both are listed in `pmacs.packages.installed()`.
|
||||||
|
//! 3. **Both variants are documented at the public Lua API surface
|
||||||
|
//! with `EmmyLua`-style annotations.** Verified by the static
|
||||||
|
//! [`emmylua_doc_file_exists`] check, which reads
|
||||||
|
//! `builtin/api/packages.lua` and confirms it contains `EmmyLua`
|
||||||
|
//! annotations for `install` and `install_project`.
|
||||||
|
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use pmacs::lua::LuaHost;
|
||||||
|
use pmacs::lua_bindings::PackageInstallOverride;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sample-package fixture
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Build a sample-package bare repo whose entry is at `entry_path`
|
||||||
|
/// (relative to the package root) with the supplied Lua body.
|
||||||
|
/// Returns `(tempdir, bare_path)` --- the tempdir owns both the work
|
||||||
|
/// tree and the bare clone.
|
||||||
|
fn make_package_with_entry(
|
||||||
|
name: &str,
|
||||||
|
entry_path: &str,
|
||||||
|
entry_body: &str,
|
||||||
|
) -> (TempDir, PathBuf) {
|
||||||
|
let td = tempfile::tempdir().expect("tempdir");
|
||||||
|
let work = td.path().join("work");
|
||||||
|
let bare = td.path().join("upstream.git");
|
||||||
|
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("init"),
|
||||||
|
OsStr::new("--initial-branch=main"),
|
||||||
|
work.as_os_str(),
|
||||||
|
]);
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("-C"),
|
||||||
|
work.as_os_str(),
|
||||||
|
OsStr::new("config"),
|
||||||
|
OsStr::new("user.email"),
|
||||||
|
OsStr::new("test@example.com"),
|
||||||
|
]);
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("-C"),
|
||||||
|
work.as_os_str(),
|
||||||
|
OsStr::new("config"),
|
||||||
|
OsStr::new("user.name"),
|
||||||
|
OsStr::new("Tester"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let manifest = format!(
|
||||||
|
"name = \"{name}\"\n\
|
||||||
|
version = \"1.0.0\"\n\
|
||||||
|
summary = \"acceptance fixture\"\n\
|
||||||
|
pmacs_required = \">= 0.1.0\"\n\
|
||||||
|
entry = \"{entry_path}\"\n\
|
||||||
|
exports = [\"{name}\"]\n"
|
||||||
|
);
|
||||||
|
std::fs::write(work.join("pmacs.toml"), manifest).expect("write pmacs.toml");
|
||||||
|
let entry_full = work.join(entry_path);
|
||||||
|
if let Some(parent) = entry_full.parent() {
|
||||||
|
std::fs::create_dir_all(parent).expect("mkdir entry parent");
|
||||||
|
}
|
||||||
|
std::fs::write(&entry_full, entry_body).expect("write entry");
|
||||||
|
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("-C"),
|
||||||
|
work.as_os_str(),
|
||||||
|
OsStr::new("add"),
|
||||||
|
OsStr::new("."),
|
||||||
|
]);
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("-C"),
|
||||||
|
work.as_os_str(),
|
||||||
|
OsStr::new("commit"),
|
||||||
|
OsStr::new("-m"),
|
||||||
|
OsStr::new("v1.0.0"),
|
||||||
|
]);
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("-C"),
|
||||||
|
work.as_os_str(),
|
||||||
|
OsStr::new("tag"),
|
||||||
|
OsStr::new("v1.0.0"),
|
||||||
|
]);
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("clone"),
|
||||||
|
OsStr::new("--bare"),
|
||||||
|
work.as_os_str(),
|
||||||
|
bare.as_os_str(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
(td, bare)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a sample-package bare repo with a `pmacs.toml` + `init.lua`,
|
||||||
|
/// tagged `v1.0.0`. Returns `(tempdir, bare_path)`.
|
||||||
|
fn make_sample_package(name: &str) -> (TempDir, PathBuf) {
|
||||||
|
let td = tempfile::tempdir().expect("tempdir");
|
||||||
|
let work = td.path().join("work");
|
||||||
|
let bare = td.path().join("upstream.git");
|
||||||
|
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("init"),
|
||||||
|
OsStr::new("--initial-branch=main"),
|
||||||
|
work.as_os_str(),
|
||||||
|
]);
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("-C"),
|
||||||
|
work.as_os_str(),
|
||||||
|
OsStr::new("config"),
|
||||||
|
OsStr::new("user.email"),
|
||||||
|
OsStr::new("test@example.com"),
|
||||||
|
]);
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("-C"),
|
||||||
|
work.as_os_str(),
|
||||||
|
OsStr::new("config"),
|
||||||
|
OsStr::new("user.name"),
|
||||||
|
OsStr::new("Tester"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let manifest = format!(
|
||||||
|
"name = \"{name}\"\n\
|
||||||
|
version = \"1.0.0\"\n\
|
||||||
|
summary = \"acceptance fixture\"\n\
|
||||||
|
pmacs_required = \">= 0.1.0\"\n\
|
||||||
|
entry = \"init.lua\"\n\
|
||||||
|
exports = [\"{name}\"]\n"
|
||||||
|
);
|
||||||
|
std::fs::write(work.join("pmacs.toml"), manifest).expect("write pmacs.toml");
|
||||||
|
std::fs::write(
|
||||||
|
work.join("init.lua"),
|
||||||
|
format!("return {{ name = '{name}', version = '1.0.0' }}\n"),
|
||||||
|
)
|
||||||
|
.expect("write init.lua");
|
||||||
|
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("-C"),
|
||||||
|
work.as_os_str(),
|
||||||
|
OsStr::new("add"),
|
||||||
|
OsStr::new("."),
|
||||||
|
]);
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("-C"),
|
||||||
|
work.as_os_str(),
|
||||||
|
OsStr::new("commit"),
|
||||||
|
OsStr::new("-m"),
|
||||||
|
OsStr::new("v1.0.0"),
|
||||||
|
]);
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("-C"),
|
||||||
|
work.as_os_str(),
|
||||||
|
OsStr::new("tag"),
|
||||||
|
OsStr::new("v1.0.0"),
|
||||||
|
]);
|
||||||
|
run_git(&[
|
||||||
|
OsStr::new("clone"),
|
||||||
|
OsStr::new("--bare"),
|
||||||
|
work.as_os_str(),
|
||||||
|
bare.as_os_str(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
(td, bare)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_git(args: &[&OsStr]) {
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
for a in args {
|
||||||
|
cmd.arg(a);
|
||||||
|
}
|
||||||
|
cmd.env("GIT_TERMINAL_PROMPT", "0");
|
||||||
|
cmd.env("LC_ALL", "C");
|
||||||
|
let out = cmd.output().expect("git spawn");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"git {args:?} failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_url(p: &Path) -> String {
|
||||||
|
format!("file://{}", p.display())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a [`LuaHost`] with the package-install machinery redirected
|
||||||
|
/// to per-test tempdirs. Returns the host plus the paths so the test
|
||||||
|
/// can introspect what landed where.
|
||||||
|
fn host_with_overrides() -> (LuaHost, TempDir, TempDir) {
|
||||||
|
let cache = tempfile::tempdir().expect("cache tempdir");
|
||||||
|
let user_root = tempfile::tempdir().expect("user-root tempdir");
|
||||||
|
let host = LuaHost::new().expect("LuaHost::new");
|
||||||
|
host.set_package_install_override(
|
||||||
|
PackageInstallOverride::new()
|
||||||
|
.with_cache_dir(cache.path().to_path_buf())
|
||||||
|
.with_user_install_root(user_root.path().to_path_buf()),
|
||||||
|
);
|
||||||
|
(host, cache, user_root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acceptance bullet 1: user-config install + entry requireable.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_install_makes_entry_requireable() {
|
||||||
|
let (_pkg_td, bare) = make_sample_package("samplepkg");
|
||||||
|
let url = file_url(&bare);
|
||||||
|
let (mut host, _cache, _user_root) = host_with_overrides();
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
local installed = pmacs.packages.install {{
|
||||||
|
"git:{url}",
|
||||||
|
version = "^1.0.0",
|
||||||
|
}}
|
||||||
|
assert(installed.name == "samplepkg",
|
||||||
|
"expected name == samplepkg, got " .. tostring(installed.name))
|
||||||
|
assert(installed.version == "1.0.0",
|
||||||
|
"expected version 1.0.0, got " .. tostring(installed.version))
|
||||||
|
assert(installed.scope == "user",
|
||||||
|
"expected scope user, got " .. tostring(installed.scope))
|
||||||
|
|
||||||
|
-- The acceptance bullet: the entry module is requireable.
|
||||||
|
local mod = require("samplepkg")
|
||||||
|
assert(mod.name == "samplepkg",
|
||||||
|
"require returned wrong table: " .. tostring(mod.name))
|
||||||
|
assert(mod.version == "1.0.0",
|
||||||
|
"require returned wrong version: " .. tostring(mod.version))
|
||||||
|
|
||||||
|
-- The roster reflects the install.
|
||||||
|
local list = pmacs.packages.installed()
|
||||||
|
assert(#list == 1, "expected 1 installed package, got " .. tostring(#list))
|
||||||
|
assert(list[1].name == "samplepkg")
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
host.eval(Some("test"), &script).unwrap_or_else(|e| {
|
||||||
|
panic!("install + require failed: {e}");
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
host.errors().is_empty(),
|
||||||
|
"errors after install: {:?}",
|
||||||
|
host.errors()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acceptance bullet 2: project install isolates from user install.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_install_isolates_from_user_install() {
|
||||||
|
let (_pkg_td, bare) = make_sample_package("samplepkg");
|
||||||
|
let url = file_url(&bare);
|
||||||
|
let (mut host, _cache, user_root) = host_with_overrides();
|
||||||
|
|
||||||
|
// The project root is its own tempdir, distinct from the user root.
|
||||||
|
let project = tempfile::tempdir().expect("project tempdir");
|
||||||
|
let project_root = project.path().display().to_string();
|
||||||
|
let user_root_str = user_root.path().display().to_string();
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
local user = pmacs.packages.install {{
|
||||||
|
"git:{url}",
|
||||||
|
version = "^1.0.0",
|
||||||
|
}}
|
||||||
|
local proj = pmacs.packages.install_project {{
|
||||||
|
"git:{url}",
|
||||||
|
version = "^1.0.0",
|
||||||
|
project_root = "{project_root}",
|
||||||
|
}}
|
||||||
|
|
||||||
|
assert(user.scope == "user", "user scope")
|
||||||
|
assert(proj.scope == "project", "project scope")
|
||||||
|
assert(user.install_path ~= proj.install_path,
|
||||||
|
"install paths must differ: " .. user.install_path .. " vs " .. proj.install_path)
|
||||||
|
assert(string.find(proj.install_path, "{project_root}", 1, true) ~= nil,
|
||||||
|
"project path should be under project_root: " .. proj.install_path)
|
||||||
|
assert(string.find(user.install_path, "{user_root_str}", 1, true) ~= nil,
|
||||||
|
"user path should be under user_root: " .. user.install_path)
|
||||||
|
|
||||||
|
-- Both records present.
|
||||||
|
local list = pmacs.packages.installed()
|
||||||
|
assert(#list == 2, "expected 2, got " .. tostring(#list))
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
host.eval(Some("test"), &script).unwrap_or_else(|e| {
|
||||||
|
panic!("install isolation failed: {e}");
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
host.errors().is_empty(),
|
||||||
|
"errors after dual install: {:?}",
|
||||||
|
host.errors()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acceptance bullet 3: EmmyLua annotations exist.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emmylua_doc_file_exists() {
|
||||||
|
// The acceptance bullet says "documented at the public Lua API
|
||||||
|
// surface with `EmmyLua`-style annotations". This test pins that
|
||||||
|
// documentation file so it stays in lockstep with the binding.
|
||||||
|
let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("builtin/api/packages.lua");
|
||||||
|
let s = std::fs::read_to_string(&p)
|
||||||
|
.unwrap_or_else(|e| panic!("missing EmmyLua doc file at {}: {e}", p.display()));
|
||||||
|
assert!(
|
||||||
|
s.contains("@param"),
|
||||||
|
"EmmyLua doc file must contain @param annotations"
|
||||||
|
);
|
||||||
|
assert!(s.contains("install"), "doc file must document `install`");
|
||||||
|
assert!(
|
||||||
|
s.contains("install_project"),
|
||||||
|
"doc file must document `install_project`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Init-time-only gate.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_after_init_complete_errors_with_workaround() {
|
||||||
|
let mut host = LuaHost::new().expect("LuaHost::new");
|
||||||
|
host.set_init_complete();
|
||||||
|
|
||||||
|
let err = host
|
||||||
|
.eval(
|
||||||
|
Some("test"),
|
||||||
|
r#"pmacs.packages.install { "github:user/repo", version = "*" }"#,
|
||||||
|
)
|
||||||
|
.expect_err("post-init install must error");
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("pmacs.packages.install"),
|
||||||
|
"error must name the op: {msg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
msg.contains("init.lua"),
|
||||||
|
"error must name the right phase: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Spec-shape parsing.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shorthand_string_form_is_accepted() {
|
||||||
|
let (_pkg_td, bare) = make_sample_package("samplepkg");
|
||||||
|
let url = file_url(&bare);
|
||||||
|
let (mut host, _cache, _user_root) = host_with_overrides();
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
local installed = pmacs.packages.install("git:{url}@^1.0.0")
|
||||||
|
assert(installed.name == "samplepkg")
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
host.eval(Some("test"), &script).unwrap_or_else(|e| {
|
||||||
|
panic!("shorthand form failed: {e}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_spec_missing_address_errors() {
|
||||||
|
let mut host = LuaHost::new().expect("LuaHost::new");
|
||||||
|
let err = host
|
||||||
|
.eval(Some("test"), r#"pmacs.packages.install { version = "*" }"#)
|
||||||
|
.expect_err("missing address must error");
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("address"),
|
||||||
|
"error should name the missing field: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reviewer-flagged item 10: install_project requires explicit project_root.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_project_without_project_root_errors_with_workaround() {
|
||||||
|
// Pre-v0.1 the missing field silently fell back to
|
||||||
|
// `std::env::current_dir()`. That was a footgun: CWD-at-startup
|
||||||
|
// is whatever shell directory the user happened to invoke pmacs
|
||||||
|
// from, almost never a meaningful project root. The fallback is
|
||||||
|
// gone; the binding now requires an explicit field and the error
|
||||||
|
// message names two concrete patterns for filling it in
|
||||||
|
// (env-var lookup, init.lua-relative path).
|
||||||
|
let mut host = LuaHost::new().expect("LuaHost::new");
|
||||||
|
let err = host
|
||||||
|
.eval(
|
||||||
|
Some("test"),
|
||||||
|
r#"pmacs.packages.install_project { "git:does-not-matter", version = "^1.0" }"#,
|
||||||
|
)
|
||||||
|
.expect_err("missing project_root must error");
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("project_root"),
|
||||||
|
"error must name the missing field: {msg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
msg.contains("install_project"),
|
||||||
|
"error must name the op: {msg}"
|
||||||
|
);
|
||||||
|
// Two concrete patterns the user can apply without hunting for
|
||||||
|
// documentation. Both are mentioned in the error text so a CI
|
||||||
|
// log line stands on its own.
|
||||||
|
assert!(
|
||||||
|
msg.contains("PMACS_PROJECT") || msg.contains("os.getenv"),
|
||||||
|
"error should hint at env-var pattern: {msg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
msg.contains("init.lua"),
|
||||||
|
"error should hint at init.lua-relative pattern: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_project_relative_path_resolves_against_init_lua_dir() {
|
||||||
|
// A relative `project_root` value resolves against the
|
||||||
|
// directory of the loading chunk (the convention for
|
||||||
|
// file-loaded `init.lua`). The chunk source label
|
||||||
|
// `@<absolute-path>` is the standard hook --- `LuaHost::eval`
|
||||||
|
// sets it via `set_name`, and `debug.getinfo("S").source`
|
||||||
|
// reads it back.
|
||||||
|
//
|
||||||
|
// Setup: write a transient `init.lua` under tempdir-A, set
|
||||||
|
// `project_root = "subproj"` from inside it (a relative path),
|
||||||
|
// and assert the install lands at `tempdir-A/subproj/.pmacs/...`.
|
||||||
|
let (_pkg_td, bare) = make_sample_package("samplepkg");
|
||||||
|
let url = file_url(&bare);
|
||||||
|
let (mut host, _cache, _user_root) = host_with_overrides();
|
||||||
|
|
||||||
|
let init_dir = tempfile::tempdir().expect("init dir");
|
||||||
|
let init_path = init_dir.path().join("init.lua");
|
||||||
|
let init_label = format!("@{}", init_path.display());
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
local p = pmacs.packages.install_project {{
|
||||||
|
"git:{url}",
|
||||||
|
version = "^1.0.0",
|
||||||
|
project_root = "subproj",
|
||||||
|
}}
|
||||||
|
return p.install_path
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let value = host
|
||||||
|
.eval(Some(&init_label), &script)
|
||||||
|
.unwrap_or_else(|e| panic!("relative project_root install failed: {e}"));
|
||||||
|
let install_path = match value {
|
||||||
|
mlua::Value::String(s) => s.to_str().expect("string utf8").to_string(),
|
||||||
|
other => panic!("expected install_path string, got {other:?}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let expected_prefix = init_dir.path().join("subproj");
|
||||||
|
assert!(
|
||||||
|
install_path.starts_with(&expected_prefix.display().to_string()),
|
||||||
|
"expected install under {expected_prefix:?}, got {install_path:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reviewer-flagged item 7: custom entry paths must be requireable.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// A package whose manifest declares `entry = "main.lua"` (or any
|
||||||
|
// non-`init.lua` path) cannot be loaded via the standard
|
||||||
|
// `?.lua;?/init.lua` `package.path` pattern alone --- the path
|
||||||
|
// search misses the entry file. The custom searcher in
|
||||||
|
// `lua_bindings::register_package_searcher` closes the gap by
|
||||||
|
// mapping `require("<basename>")` directly to the manifest's
|
||||||
|
// declared entry path.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_with_main_lua_entry_is_requireable() {
|
||||||
|
// Entry file at `main.lua` (not the conventional `init.lua`).
|
||||||
|
// The path-based searcher misses it; the custom searcher must
|
||||||
|
// route `require("samplepkg")` to `<install>/main.lua`.
|
||||||
|
let (_pkg_td, bare) = make_package_with_entry(
|
||||||
|
"samplepkg",
|
||||||
|
"main.lua",
|
||||||
|
"return { name = 'samplepkg', via = 'main.lua' }\n",
|
||||||
|
);
|
||||||
|
let url = file_url(&bare);
|
||||||
|
let (mut host, _cache, _user_root) = host_with_overrides();
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
local installed = pmacs.packages.install {{
|
||||||
|
"git:{url}",
|
||||||
|
version = "^1.0.0",
|
||||||
|
}}
|
||||||
|
assert(installed.entry:sub(-#"main.lua") == "main.lua",
|
||||||
|
"manifest entry must be main.lua, got " .. installed.entry)
|
||||||
|
local mod = require("samplepkg")
|
||||||
|
assert(mod.name == "samplepkg",
|
||||||
|
"custom searcher should have loaded main.lua, got: " .. tostring(mod.name))
|
||||||
|
assert(mod.via == "main.lua",
|
||||||
|
"module body must be the contents of main.lua, got via=" .. tostring(mod.via))
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
host.eval(Some("test"), &script).unwrap_or_else(|e| {
|
||||||
|
panic!("require(samplepkg) with entry=main.lua failed: {e}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_with_nested_entry_path_is_requireable() {
|
||||||
|
// Entry at `lib/foo.lua` --- a package layout where the
|
||||||
|
// user-facing module lives a couple of levels deep. The custom
|
||||||
|
// searcher must read it from the manifest and resolve
|
||||||
|
// `require("samplepkg")` to the nested file.
|
||||||
|
let (_pkg_td, bare) = make_package_with_entry(
|
||||||
|
"samplepkg",
|
||||||
|
"lib/foo.lua",
|
||||||
|
"return { name = 'samplepkg', via = 'lib/foo.lua' }\n",
|
||||||
|
);
|
||||||
|
let url = file_url(&bare);
|
||||||
|
let (mut host, _cache, _user_root) = host_with_overrides();
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
local installed = pmacs.packages.install {{
|
||||||
|
"git:{url}",
|
||||||
|
version = "^1.0.0",
|
||||||
|
}}
|
||||||
|
assert(installed.entry:sub(-#"lib/foo.lua") == "lib/foo.lua",
|
||||||
|
"manifest entry must be lib/foo.lua, got " .. installed.entry)
|
||||||
|
local mod = require("samplepkg")
|
||||||
|
assert(mod.via == "lib/foo.lua",
|
||||||
|
"module body must be the contents of lib/foo.lua, got via=" .. tostring(mod.via))
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
host.eval(Some("test"), &script).unwrap_or_else(|e| {
|
||||||
|
panic!("require(samplepkg) with nested entry failed: {e}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn searcher_misses_for_unknown_name_with_pmacs_specific_message() {
|
||||||
|
// No installed package matches `unrelatedpkg`. The custom
|
||||||
|
// searcher returns its "no installed pmacs package named ..."
|
||||||
|
// string, which Lua appends to the standard require-failure
|
||||||
|
// chain. The point: a `require` that fails with an obvious
|
||||||
|
// typo produces an error message that names the pmacs-side
|
||||||
|
// searcher's contribution to the search, so a user can see
|
||||||
|
// why the install they thought they did did not satisfy this
|
||||||
|
// require.
|
||||||
|
let mut host = LuaHost::new().expect("LuaHost::new");
|
||||||
|
let err = host
|
||||||
|
.eval(
|
||||||
|
Some("test"),
|
||||||
|
r#"
|
||||||
|
local mod = require("unrelatedpkg")
|
||||||
|
return mod
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect_err("require for unknown name must error");
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("unrelatedpkg"),
|
||||||
|
"error must echo the require name: {msg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
msg.contains("no installed pmacs package"),
|
||||||
|
"error must mention the pmacs searcher's contribution: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue