V0.2-prerequisite pull-forward + M10.11 clean audit round
Pulls a set of planned V0.2 prerequisites forward to ship in v1.0, plus the clean audit-review round over that work. V0.2-prereq implementations (documented promotions, not M11 surprises; operator raised the v1.0 public-API ceiling to absorb them — see V0.2-PREREQUISITES.md "v1.0 pull-forward"): - CC-1: `bypass_intercept` opts on buffer insert/delete/replace — skips the Lua intercept chain only; preserves the same-buffer re-entry guard, undo/dirty bookkeeping, view notifications, and CRDT broadcast queueing. - CC-2: `pmacs.buffer.on_removed(buf, cb)` + idempotent `:remove()` handle; buffer-local keymaps pruned on removal. Fires for both `pmacs.buffer.remove` and `.kill` (incl. interactive C-x k); callback errors logged to *errors* without failing the removal. - SP-4: `pmacs.buffer.from_file`. - SP-5: `pmacs.fs.watch` (polling; `:cancel()`/`:is_cancelled()`). - SP-7: `pmacs.async.yield_to_next_tick` (worker-free next-tick yield); outline-aggregate repaint now uses it instead of workers.sleep(0):await(), pinning propagation to one async tick. - SP-1: `pmacs.editor.move_to_line` (0-based, clamps out-of-range). - SP-6: `pmacs.outline.query` published by pmacs-outline. - SP-3: audit rule 15 `reach-around-require-field` (Info). - CC-3: runtime API-availability documented (docs-only). Clean audit-review round (M10.11 framing stop-condition pass): - Finding 1 (fixed): clippy needless_raw_string_hashes blocked `clippy -D warnings` on both lanes; raw-string delimiter fixed. - Finding 2 (fixed): fs_watch acceptance test was racy — the `pending == 1` gate could not distinguish the in-flight baseline stat from the steady-state poll sleep, so under load the mutation raced the baseline (~1/3 fail in the default lane). Rewritten to re-emit a distinct change each pump iteration; 6/6 on the previously-failing invocation. - Finding 3 (fixed): documented fs.watch's async-baseline startup window and size+mtime-granularity detection limit. - Finding 4 / SP-8 (logged, non-blocking, out of diff): a pre-existing PTY-lifecycle test timing flake under severe CPU oversubscription; src/process.rs untouched here. CC-1's opts-extension-counts question resolved explicitly (consistent treatment: counted; ceiling raised to fit). Gate at normal load, both lanes: fmt clean; clippy --all-targets -D warnings clean; non-crdt lib 1223/0; crdt lib 1377/0; m8_1/m8_9/m8_10 green. Not in scope here: v1.0 CHANGELOG body, version bump, the M10.11 Finding-4 (reattach undo) user-facing artifact, and the recorded two-laptop manual acceptance — tracked as the remaining v1.0 steps. .gitignore: M*-FRAMING.md added to the internal-only block for consistency with the M*-AUDIT.md / M*-SHIP-GATE.md siblings. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
04ac1fbd8d
commit
c50db222d3
|
|
@ -16,7 +16,13 @@
|
|||
/M*-AUDIT.md
|
||||
/M*-API-SURVEY.md
|
||||
/M*-SHIP-GATE.md
|
||||
/M*-FRAMING.md
|
||||
/V*-PREREQUISITES.md
|
||||
/MANUAL-TEST-CHECKLIST.md
|
||||
/tests/INDEX.md
|
||||
/.claude/
|
||||
|
||||
# F8b SSH-transport diagnostic probes (M10.11). Kept local for now —
|
||||
# v0.2 breadth work (V0.2-PREREQUISITES.md SP-5-adjacent / F8b n=1)
|
||||
# reuses these. Revisit relocating to a tracked tools/ dir at v0.2.
|
||||
/f8b-*.sh
|
||||
|
|
|
|||
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
> Scope note: this section currently records only release-affecting
|
||||
> changes surfaced during M10 ship-gate work. The full M6–M10 v1.0
|
||||
> changelog body is a separate release-authoring task and is not yet
|
||||
> written here.
|
||||
|
||||
### Changed
|
||||
|
||||
- **SSH remote attach now carries the wire protocol over the SSH
|
||||
*stderr* channel by default** (previously stdout). On at least one
|
||||
tested environment (`OpenSSH_10.3p1`), a non-PTY SSH session does
|
||||
not forward a long-lived remote process's stdout until that process
|
||||
exits, while stderr streams in real time; the `--daemon-attach`
|
||||
bridge is exactly such a process, so a stdout-carried protocol hung
|
||||
indefinitely (the daemon and bridge were correct throughout). The
|
||||
default flip resolves this with no user action required. Override
|
||||
per invocation with `PMACS_ATTACH_SSH_PROTOCOL=stdout|stderr` (the
|
||||
legacy `PMACS_ATTACH_SSH_PROTOCOL_STDERR=0/1` is still honored).
|
||||
Breadth is currently a single environment; the default is a
|
||||
one-line internal switch so it can be revisited without downstream
|
||||
impact.
|
||||
|
||||
### Fixed
|
||||
|
||||
- SSH remote attach no longer fails with a host-side
|
||||
`send Hello failed: Broken pipe`: the daemon-liveness probe in the
|
||||
`--daemon-attach` auto-start path was a disposable connect that
|
||||
could consume the daemon's server-speaks-first `Hello`; the
|
||||
established connection is now reused (regression-tested).
|
||||
|
||||
## [0.1.0] --- 2026-05-03
|
||||
|
||||
First public preview. Solo development through 1.0; public contributions
|
||||
|
|
|
|||
|
|
@ -196,3 +196,24 @@
|
|||
(#eq? @fn "require")
|
||||
(#match? @arg "\\.")
|
||||
(#not-match? @arg "^pmacs(\\.|$)")) @violation
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Rule 15: reach-around-require-field
|
||||
;;
|
||||
;; Info-level: detects field access directly on a required module
|
||||
;; when the field name is private-looking (`_name`) or a loud
|
||||
;; in-tree test seam (`__pmacs_*_DO_NOT_USE`). This catches
|
||||
;; `require("pkg")._private` / `require("pkg").__pmacs_X_DO_NOT_USE`,
|
||||
;; which the dotted-require rule above cannot see.
|
||||
;;
|
||||
;; The host `pmacs.X` namespaces are excluded for the same reason as
|
||||
;; Rule 14.
|
||||
;; ---------------------------------------------------------------------------
|
||||
((dot_index_expression
|
||||
(function_call
|
||||
(identifier) @fn
|
||||
(arguments (string (string_content) @arg)))
|
||||
(identifier) @field)
|
||||
(#eq? @fn "require")
|
||||
(#not-match? @arg "^pmacs(\\.|$)")
|
||||
(#match? @field "^_|^__pmacs_.*_DO_NOT_USE$")) @violation
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ assert(async_mod, "pmacs._async must be installed before the async builtin loads
|
|||
local parked_coroutines = {}
|
||||
-- handle id -> array of on_complete callbacks.
|
||||
local on_complete_callbacks = {}
|
||||
-- coroutines waiting for the next async tick without dispatching a worker job.
|
||||
local next_tick_coroutines = {}
|
||||
-- coroutine -> the handle the coroutine yielded with last (so we can
|
||||
-- detect a coroutine that yields a non-handle and surface it).
|
||||
local coroutine_waiting_on = setmetatable({}, { __mode = "k" })
|
||||
|
|
@ -199,6 +201,8 @@ local function step(co)
|
|||
if type(yielded) == "table" and yielded._is_pmacs_handle then
|
||||
parked_coroutines[yielded._id] = co
|
||||
coroutine_waiting_on[co] = yielded._id
|
||||
elseif type(yielded) == "table" and yielded._is_pmacs_next_tick then
|
||||
next_tick_coroutines[#next_tick_coroutines + 1] = co
|
||||
else
|
||||
if pmacs.error then
|
||||
pmacs.error("pmacs.async: coroutine yielded a non-Handle value (" ..
|
||||
|
|
@ -209,7 +213,7 @@ local function step(co)
|
|||
end
|
||||
end
|
||||
|
||||
function pmacs.async(fn)
|
||||
local function spawn_async(fn)
|
||||
if type(fn) ~= "function" then
|
||||
error("pmacs.async expects a function, got " .. type(fn))
|
||||
end
|
||||
|
|
@ -217,6 +221,20 @@ function pmacs.async(fn)
|
|||
step(co)
|
||||
end
|
||||
|
||||
local async_public = {}
|
||||
|
||||
setmetatable(async_public, {
|
||||
__call = function(_, fn)
|
||||
return spawn_async(fn)
|
||||
end,
|
||||
})
|
||||
|
||||
function async_public.yield_to_next_tick()
|
||||
coroutine.yield({ _is_pmacs_next_tick = true })
|
||||
end
|
||||
|
||||
pmacs.async = async_public
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- pmacs.workers --- name-based dispatch surface.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
@ -363,6 +381,12 @@ end
|
|||
-- ---------------------------------------------------------------------------
|
||||
|
||||
function pmacs._async.tick()
|
||||
local ready_next_tick = next_tick_coroutines
|
||||
next_tick_coroutines = {}
|
||||
for _, co in ipairs(ready_next_tick) do
|
||||
step(co)
|
||||
end
|
||||
|
||||
local settled = async_mod._tick()
|
||||
for _, id in ipairs(settled) do
|
||||
-- Fire on_complete callbacks before resuming the parked coroutine.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@
|
|||
-- exits with a structured `{ tag = "cancelled" }` error from
|
||||
-- :await() on cancel; callers either let it propagate (the typical
|
||||
-- behavior under supersede) or pcall around it.
|
||||
--
|
||||
-- pmacs.fs.watch(path, callback [, opts])
|
||||
-- Polling file watcher. Calls callback({ kind = "changed" |
|
||||
-- "created" | "removed", path = path, recursive = bool }) when
|
||||
-- the snapshot changes. opts.interval_ms defaults to 250;
|
||||
-- opts.recursive defaults to false. Returns a watcher with
|
||||
-- :cancel() and :is_cancelled().
|
||||
|
||||
local async_mod = pmacs._async
|
||||
assert(async_mod, "pmacs._async must be installed before pmacs.fs loads")
|
||||
|
|
@ -143,4 +150,138 @@ function fs.remove(path)
|
|||
return build_handle(async_mod._dispatch_fs_remove(path))
|
||||
end
|
||||
|
||||
local Watch = {}
|
||||
Watch.__index = Watch
|
||||
|
||||
function Watch:cancel()
|
||||
self.cancelled = true
|
||||
if self._sleep_handle then
|
||||
self._sleep_handle:cancel()
|
||||
end
|
||||
end
|
||||
|
||||
function Watch:is_cancelled()
|
||||
return self.cancelled
|
||||
end
|
||||
|
||||
local function join_path(base, name)
|
||||
if base:sub(-1) == "/" then return base .. name end
|
||||
return base .. "/" .. name
|
||||
end
|
||||
|
||||
local function error_string(err)
|
||||
if type(err) == "table" then
|
||||
return tostring(err.tag or "error") .. ":" .. tostring(err.message or "")
|
||||
end
|
||||
return tostring(err)
|
||||
end
|
||||
|
||||
local function entry_signature(path, entry)
|
||||
return table.concat({
|
||||
path,
|
||||
tostring(entry.kind),
|
||||
tostring(entry.size),
|
||||
tostring(entry.mtime),
|
||||
tostring(entry.mtime_nsec),
|
||||
tostring(entry.mode),
|
||||
tostring(entry.symlink_target or ""),
|
||||
}, "|")
|
||||
end
|
||||
|
||||
local function append_snapshot(parts, path, recursive)
|
||||
local ok, entry = pcall(function() return fs.stat(path):await() end)
|
||||
if not ok then
|
||||
parts[#parts + 1] = path .. "|error|" .. error_string(entry)
|
||||
return false
|
||||
end
|
||||
parts[#parts + 1] = entry_signature(path, entry)
|
||||
if entry.kind ~= "dir" then
|
||||
return true
|
||||
end
|
||||
|
||||
local ok_dir, entries = pcall(function() return fs.read_dir(path):await() end)
|
||||
if not ok_dir then
|
||||
parts[#parts + 1] = path .. "|read_dir_error|" .. error_string(entries)
|
||||
return true
|
||||
end
|
||||
|
||||
table.sort(entries, function(a, b) return a.name < b.name end)
|
||||
for _, child in ipairs(entries) do
|
||||
local child_path = join_path(path, child.name)
|
||||
parts[#parts + 1] = entry_signature(child_path, child)
|
||||
if recursive and child.kind == "dir" then
|
||||
append_snapshot(parts, child_path, true)
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function snapshot(path, recursive)
|
||||
local parts = {}
|
||||
local exists = append_snapshot(parts, path, recursive)
|
||||
table.sort(parts)
|
||||
return table.concat(parts, "\n"), exists
|
||||
end
|
||||
|
||||
function fs.watch(path, callback, opts)
|
||||
if type(path) ~= "string" then
|
||||
error("pmacs.fs.watch: path must be a string, got " .. type(path))
|
||||
end
|
||||
if type(callback) ~= "function" then
|
||||
error("pmacs.fs.watch: callback must be a function, got " .. type(callback))
|
||||
end
|
||||
if opts ~= nil and type(opts) ~= "table" then
|
||||
error("pmacs.fs.watch: opts must be a table or nil, got " .. type(opts))
|
||||
end
|
||||
opts = opts or {}
|
||||
local interval_ms = opts.interval_ms or 250
|
||||
if type(interval_ms) ~= "number" or interval_ms < 1 then
|
||||
error("pmacs.fs.watch: opts.interval_ms must be a positive number")
|
||||
end
|
||||
interval_ms = math.floor(interval_ms)
|
||||
local recursive = opts.recursive == true
|
||||
|
||||
local watch = setmetatable({
|
||||
path = path,
|
||||
recursive = recursive,
|
||||
cancelled = false,
|
||||
_sleep_handle = nil,
|
||||
}, Watch)
|
||||
|
||||
pmacs.async(function()
|
||||
local previous, previous_exists = snapshot(path, recursive)
|
||||
while not watch.cancelled do
|
||||
local sleep_handle = pmacs.workers.sleep(interval_ms)
|
||||
watch._sleep_handle = sleep_handle
|
||||
pcall(function() sleep_handle:await() end)
|
||||
watch._sleep_handle = nil
|
||||
if watch.cancelled then break end
|
||||
|
||||
local current, current_exists = snapshot(path, recursive)
|
||||
if current ~= previous then
|
||||
local kind = "changed"
|
||||
if previous_exists and not current_exists then
|
||||
kind = "removed"
|
||||
elseif not previous_exists and current_exists then
|
||||
kind = "created"
|
||||
end
|
||||
previous = current
|
||||
previous_exists = current_exists
|
||||
local ok, err = pcall(callback, {
|
||||
kind = kind,
|
||||
path = path,
|
||||
recursive = recursive,
|
||||
})
|
||||
if not ok and pmacs.error then
|
||||
pmacs.error("pmacs.fs.watch callback failed: " .. tostring(err))
|
||||
elseif not ok then
|
||||
error(err)
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
return watch
|
||||
end
|
||||
|
||||
pmacs.fs = fs
|
||||
|
|
|
|||
|
|
@ -100,6 +100,29 @@ Practical implication: a package can declare module-level state
|
|||
without worrying about colliding with another package's module-level
|
||||
state, even if both pick the same name.
|
||||
|
||||
### Runtime API availability
|
||||
|
||||
Package entry chunks run during package load, including audit and
|
||||
headless load paths. Keep top-level code limited to registration and
|
||||
state setup. Surfaces installed by the base Lua host are available
|
||||
there: `pmacs.buffer`, `pmacs.command`, `pmacs.keymap`,
|
||||
`pmacs.hook`, `pmacs.describe`, `pmacs.help`, `pmacs.attach`,
|
||||
`pmacs.now_ms`, and the standard Lua libraries.
|
||||
|
||||
Editor-state surfaces are available once the editor bridge is
|
||||
installed: command bodies invoked by pmacs, main-thread hooks fired by
|
||||
the editor, edit intercepts, and `pmacs.async` callbacks resumed by
|
||||
the editor loop can use `pmacs.editor`, `pmacs.window`,
|
||||
`pmacs.frontend`, and `pmacs.minibuffer`. Top-level package load code
|
||||
that also needs to run in audit/headless contexts should still guard
|
||||
editor-only work or defer it to a command/hook.
|
||||
|
||||
All of these callbacks run on the main Lua state. Intercepts have one
|
||||
extra restriction: re-entering a mutation on the same buffer is
|
||||
rejected by the buffer re-entry guard. Use
|
||||
`{ bypass_intercept = true }` for package-owned redraws, not recursive
|
||||
same-buffer edits from inside the intercept body.
|
||||
|
||||
---
|
||||
|
||||
## 3. Address schemes (v1.0)
|
||||
|
|
@ -172,9 +195,10 @@ emits findings at three severity levels:
|
|||
Currently always fires for fs / process operations; will gate on
|
||||
a future manifest `permissions` field.
|
||||
* **Info** --- patterns that need human classification (currently
|
||||
cross-package dotted requires).
|
||||
cross-package dotted requires and private-looking fields read
|
||||
directly from `require(...)`).
|
||||
|
||||
### v1.0 rules (15 patterns across 7 spec classes)
|
||||
### v1.0 rules (16 patterns across 7 spec classes)
|
||||
|
||||
The full list lives at
|
||||
[`audit/audit-rules.scm`](../audit/audit-rules.scm) (the published
|
||||
|
|
@ -188,7 +212,7 @@ contract) and `src/audit/rules.rs` (the metadata table). Summary:
|
|||
| Error | environment escape | `no-rawget-rawset-on-globals`, `no-setfenv-getfenv` |
|
||||
| Warning | filesystem mutation | `no-fs-mutation-io-open-write`, `no-fs-mutation-os` |
|
||||
| Warning | process spawning | `no-process-spawn-io`, `no-process-spawn-os`, `no-process-spawn-pmacs` |
|
||||
| Info | reach-around | `reach-around-require` |
|
||||
| Info | reach-around | `reach-around-require`, `reach-around-require-field` |
|
||||
|
||||
### Common Error rules and their fixes
|
||||
|
||||
|
|
@ -412,6 +436,7 @@ end)
|
|||
| `rename(from, to)` | nil on success | Atomic on the same filesystem; cross-fs returns EXDEV. |
|
||||
| `chmod(path, mode)` | nil on success | **Follows symlinks** (per `chmod(2)`); changes the target's mode, not the link's. Asymmetric with `read_dir`/`stat` which use lstat. |
|
||||
| `remove(path)` | nil on success | File or empty dir. Non-empty dirs fail; recurse at the package layer. Symlinks removed as symlinks (target survives). |
|
||||
| `watch(path, callback [, opts])` | watcher handle | Polls for file/directory changes. Calls `callback({ kind = "changed" \| "created" \| "removed", path = path, recursive = bool })`. `opts.interval_ms` defaults to 250; `opts.recursive` defaults to false. |
|
||||
|
||||
`opts.supersede = "<key>"` chains read ops (`read_dir`, `stat`)
|
||||
into the M3 supersede semantics: a later op under the same key
|
||||
|
|
@ -420,11 +445,90 @@ Mutating ops (`rename`/`chmod`/`remove`) intentionally don't
|
|||
accept `opts.supersede` — a "cancelled" syscall may have already
|
||||
mutated disk.
|
||||
|
||||
`watch` is intentionally polling-backed. The watcher handle exposes
|
||||
`:cancel()` and `:is_cancelled()`. Use package-side coalescing if a
|
||||
burst of filesystem writes should produce one refresh.
|
||||
|
||||
Two consequences of the polling design are load-bearing for
|
||||
callers. First, the baseline snapshot is taken asynchronously after
|
||||
`watch` returns; a change that lands between the `watch` call and
|
||||
the first completed snapshot is folded into the baseline and never
|
||||
reported. Re-trigger the action if you need a guaranteed first
|
||||
event, rather than assuming `watch` is armed synchronously. Second,
|
||||
change detection compares a per-entry signature of size, mtime
|
||||
(including nanoseconds), mode, and symlink target — a same-size
|
||||
content rewrite within the filesystem's mtime granularity is not
|
||||
observed. Both are acceptable for the derived-view refresh use case
|
||||
`watch` targets; neither is suitable as a correctness-critical
|
||||
change feed.
|
||||
|
||||
`pmacs.async.yield_to_next_tick()` may be called inside
|
||||
`pmacs.async(function() ... end)` when a package needs to resume on
|
||||
the next editor async tick without dispatching a worker job.
|
||||
|
||||
`pmacs-outline` also publishes `pmacs.outline.query(buffer,
|
||||
predicate)` when the package is loaded. It returns parsed outline
|
||||
entries whose fields match the package parser entries, and is the
|
||||
public way for other packages to inspect outline structure without
|
||||
requiring `pmacs-outline.parser` directly.
|
||||
|
||||
**UTF-8 constraint.** v0.1's `pmacs.fs` requires UTF-8 paths and
|
||||
entry names. A directory containing a non-UTF-8 entry surfaces a
|
||||
`failed` status from `:await()` with the parent path and offending
|
||||
raw bytes named. Byte-preserving paths are post-v0.1 work.
|
||||
|
||||
### `pmacs.buffer.*` — buffers, file loading, and cleanup
|
||||
|
||||
Packages can create scratch buffers from bytes, load files through the
|
||||
editor's file loader, and observe buffer removal for package-owned
|
||||
state.
|
||||
|
||||
| Function | Shape | Notes |
|
||||
|----------|-------|-------|
|
||||
| `create(name)` | buffer handle | Empty clean buffer. |
|
||||
| `from_bytes(name, bytes)` | buffer handle | Byte-preserving buffer seeded from a Lua string. |
|
||||
| `from_file(path)` | buffer handle | Loads the file using pmacs's normal file loader, creates a clean buffer named by `path`, switches the editor core to it when an editor core is present, and runs `buffer.after-load` if that hook is defined. |
|
||||
| `remove(buf)` | nil on success | Removes a buffer and fires removal cleanup. |
|
||||
| `on_removed(buf, callback)` | handle | Calls `callback(buf)` after `buf` is removed by `remove` or `kill`. The returned handle has `:remove()` for idempotent unsubscription. |
|
||||
|
||||
Buffer-local keymaps are pruned automatically when a buffer is
|
||||
removed. Package-local tables that hold per-buffer handles should use
|
||||
`on_removed` to drop their own state:
|
||||
|
||||
```lua
|
||||
local cleanup = pmacs.buffer.on_removed(buf, function(dead)
|
||||
handles[tostring(dead)] = nil
|
||||
end)
|
||||
|
||||
-- Later, if the package tears down before the buffer dies:
|
||||
cleanup:remove()
|
||||
```
|
||||
|
||||
For generated buffers with intercepts, package-owned writes can skip
|
||||
the intercept chain explicitly:
|
||||
|
||||
```lua
|
||||
buf:replace(0, buf:len(), rendered, { bypass_intercept = true })
|
||||
```
|
||||
|
||||
`bypass_intercept` applies only to the intercept chain. It does not
|
||||
disable the same-buffer re-entry guard, undo bookkeeping, dirty
|
||||
tracking, view notifications, or CRDT broadcast queueing.
|
||||
|
||||
### `pmacs.editor.*` — active-window editor state
|
||||
|
||||
Editor primitives operate on the active window for the active
|
||||
frontend. Cursor positions are byte offsets; line numbers are 0-based
|
||||
to match `cursor_line()`.
|
||||
|
||||
| Function | Shape | Notes |
|
||||
|----------|-------|-------|
|
||||
| `cursor()` | byte offset | Active window cursor. |
|
||||
| `cursor_line()` | line index | 0-based line containing the cursor. |
|
||||
| `cursor_col()` | byte column | 0-based byte column within the current line. |
|
||||
| `move_to_line(line)` | nil | Moves to the start of `line`; out-of-range values clamp to the last line. |
|
||||
| `set_status(message)` | nil | Replaces the status message. |
|
||||
|
||||
### A complete dev-loop example
|
||||
|
||||
```lua
|
||||
|
|
@ -481,6 +585,11 @@ Multiple intercepts may be attached to the same buffer; they run in
|
|||
attach order, threading the (possibly position-modified) op through
|
||||
the chain.
|
||||
|
||||
Package-owned redraws of derived buffers should prefer
|
||||
`{ bypass_intercept = true }` on `insert`, `delete`, or `replace`
|
||||
instead of maintaining a separate `painting` boolean around every
|
||||
write.
|
||||
|
||||
---
|
||||
|
||||
## 8. The bundled REPL as a worked example
|
||||
|
|
|
|||
|
|
@ -479,6 +479,34 @@ mod tests {
|
|||
assert!(f.iter().all(|x| x.rule != "reach-around-require"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reach_around_field_access_is_info_level() {
|
||||
let f = engine().audit_source(
|
||||
"t.lua",
|
||||
r#"local seam = require("otherpkg").__pmacs_outline_test_seam_DO_NOT_USE"#,
|
||||
);
|
||||
let r = f
|
||||
.iter()
|
||||
.find(|x| x.rule == "reach-around-require-field")
|
||||
.expect("expected reach-around field finding");
|
||||
assert_eq!(r.severity, Severity::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reach_around_field_access_ignores_public_and_pmacs_fields() {
|
||||
let f = engine().audit_source(
|
||||
"t.lua",
|
||||
r#"
|
||||
local ok = require("otherpkg").query
|
||||
local host = require("pmacs.foo")._private
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
f.iter().all(|x| x.rule != "reach-around-require-field"),
|
||||
"expected no field reach-around findings, got {f:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_require_is_not_a_finding() {
|
||||
let f = engine().audit_source("t.lua", r#"require("magit")"#);
|
||||
|
|
|
|||
|
|
@ -145,4 +145,10 @@ pub const DEFAULT_RULES: &[AuditRule] = &[
|
|||
severity: Severity::Info,
|
||||
message: "dotted require may target another package's non-exported submodule",
|
||||
},
|
||||
// 15
|
||||
AuditRule {
|
||||
name: "reach-around-require-field",
|
||||
severity: Severity::Info,
|
||||
message: "private-looking field access on require() may reach another package's internals",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -3175,6 +3175,34 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_kill_fires_on_removed_callbacks() {
|
||||
let s = EditorState::new();
|
||||
let doomed = s
|
||||
.lua_host
|
||||
.registry()
|
||||
.borrow_mut()
|
||||
.create_from_bytes("doomed.txt", b"hello");
|
||||
let called: bool = s
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
r"
|
||||
local doomed = ...
|
||||
local called = false
|
||||
pmacs.buffer.on_removed(doomed, function(dead)
|
||||
assert(dead == doomed)
|
||||
called = true
|
||||
end)
|
||||
pmacs.buffer.kill(doomed)
|
||||
return called
|
||||
",
|
||||
)
|
||||
.call(crate::lua_bindings::BufferIdLua(doomed))
|
||||
.unwrap();
|
||||
assert!(called, "kill should fire buffer removal callbacks");
|
||||
}
|
||||
|
||||
/// `pmacs.buffer.kill` refuses to remove the last remaining
|
||||
/// buffer; the registry must never go empty.
|
||||
#[test]
|
||||
|
|
@ -3245,6 +3273,26 @@ mod tests {
|
|||
assert_eq!(s.core.borrow().active_buffer_name(), "target.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_move_to_line_positions_cursor_by_zero_based_line() {
|
||||
let s = fresh_with(b"alpha\nbeta\ngamma");
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load("pmacs.editor.move_to_line(1)")
|
||||
.exec()
|
||||
.unwrap();
|
||||
assert_eq!(s.core.borrow().cursor_line(), 1);
|
||||
assert_eq!(s.core.borrow().cursor(), 6);
|
||||
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load("pmacs.editor.move_to_line(99)")
|
||||
.exec()
|
||||
.unwrap();
|
||||
assert_eq!(s.core.borrow().cursor_line(), 2);
|
||||
assert_eq!(s.core.borrow().cursor(), 11);
|
||||
}
|
||||
|
||||
/// `editor.next-buffer` walks the active window through the
|
||||
/// buffer registry in order, wrapping past the end. Three buffers
|
||||
/// in registry order: walking next four times returns to the
|
||||
|
|
|
|||
|
|
@ -355,6 +355,21 @@ impl EditorCore {
|
|||
aw.text_view.line_at_offset(aw.cursor)
|
||||
}
|
||||
|
||||
/// Move the active window's cursor to the start of a 0-based line.
|
||||
/// Out-of-range line numbers clamp to the last line.
|
||||
pub fn move_to_line(&mut self, line: usize) {
|
||||
let line_count = self.active_window().text_view.line_count().max(1);
|
||||
let target_line = line.min(line_count - 1);
|
||||
let target = self
|
||||
.active_window()
|
||||
.text_view
|
||||
.line_offset(target_line)
|
||||
.unwrap_or_else(|| self.active_buffer_len());
|
||||
let aw = self.active_window_mut();
|
||||
aw.cursor = target;
|
||||
aw.goal_col = None;
|
||||
}
|
||||
|
||||
// ---- editing primitives ------------------------------------------------
|
||||
|
||||
/// Apply `op` to the active buffer; notify every window
|
||||
|
|
|
|||
|
|
@ -197,6 +197,12 @@ impl KeymapStack {
|
|||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Drop every buffer-local binding for a buffer that just left
|
||||
/// the registry.
|
||||
pub fn remove_buffer(&mut self, buffer: BufferId) -> bool {
|
||||
self.buffers.remove(&buffer).is_some()
|
||||
}
|
||||
|
||||
/// Unbind a sequence from a mode keymap.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
|
|||
|
|
@ -97,6 +97,77 @@ pub type SharedCore = Rc<RefCell<EditorCore>>;
|
|||
/// rationale as the other `Rc<RefCell<...>>` aliases.
|
||||
pub type SharedHookRegistry = Rc<RefCell<HookRegistry>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BufferRemoveCallbacks(Rc<RefCell<BufferRemoveCallbackState>>);
|
||||
|
||||
struct BufferRemoveCallbackState {
|
||||
next_id: u64,
|
||||
callbacks: HashMap<BufferId, Vec<BufferRemoveCallback>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BufferRemoveCallback {
|
||||
id: u64,
|
||||
body: Function,
|
||||
source: SourceLocation,
|
||||
}
|
||||
|
||||
impl BufferRemoveCallbacks {
|
||||
fn new() -> Self {
|
||||
Self(Rc::new(RefCell::new(BufferRemoveCallbackState {
|
||||
next_id: 1,
|
||||
callbacks: HashMap::new(),
|
||||
})))
|
||||
}
|
||||
|
||||
fn add(&self, buffer: BufferId, body: Function, source: SourceLocation) -> u64 {
|
||||
let mut state = self.0.borrow_mut();
|
||||
let id = state.next_id;
|
||||
state.next_id = state.next_id.saturating_add(1);
|
||||
state
|
||||
.callbacks
|
||||
.entry(buffer)
|
||||
.or_default()
|
||||
.push(BufferRemoveCallback { id, body, source });
|
||||
id
|
||||
}
|
||||
|
||||
fn remove(&self, buffer: BufferId, callback_id: u64) -> bool {
|
||||
let mut state = self.0.borrow_mut();
|
||||
let Some(callbacks) = state.callbacks.get_mut(&buffer) else {
|
||||
return false;
|
||||
};
|
||||
let before = callbacks.len();
|
||||
callbacks.retain(|callback| callback.id != callback_id);
|
||||
let removed = callbacks.len() != before;
|
||||
if callbacks.is_empty() {
|
||||
state.callbacks.remove(&buffer);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
fn take(&self, buffer: BufferId) -> Vec<BufferRemoveCallback> {
|
||||
self.0
|
||||
.borrow_mut()
|
||||
.callbacks
|
||||
.remove(&buffer)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
struct BufferRemoveCallbackHandleLua {
|
||||
buffer: BufferId,
|
||||
callback_id: u64,
|
||||
}
|
||||
|
||||
impl UserData for BufferRemoveCallbackHandleLua {
|
||||
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
|
||||
methods.add_method("remove", |lua, this, ()| {
|
||||
Ok(remove_buffer_removed_callback(lua, this))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Init-phase tracker. The user's `init.lua` runs while this is `false`;
|
||||
/// [`crate::editor::EditorState::new`] flips it to `true` after the
|
||||
/// init chunk returns. Lua bindings that gate on init phase
|
||||
|
|
@ -1083,40 +1154,51 @@ fn add_query_methods<M: UserDataMethods<BufferIdLua>>(methods: &mut M) {
|
|||
}
|
||||
|
||||
fn add_mutation_methods<M: UserDataMethods<BufferIdLua>>(methods: &mut M) {
|
||||
methods.add_method("insert", |lua, this, (pos, bytes): (i64, mlua::String)| {
|
||||
let pos = u64_from_lua(pos)?;
|
||||
let payload = bytes.as_bytes();
|
||||
let edit = run_managed_edit(
|
||||
lua,
|
||||
this.0,
|
||||
EditOp::Insert {
|
||||
pos,
|
||||
bytes: &payload,
|
||||
},
|
||||
)?;
|
||||
notify_buffer_edit_to_windows(lua, this.0, &edit);
|
||||
Ok(())
|
||||
});
|
||||
methods.add_method(
|
||||
"insert",
|
||||
|lua, this, (pos, bytes, opts): (i64, mlua::String, Option<Table>)| {
|
||||
let pos = u64_from_lua(pos)?;
|
||||
let bypass_intercept = parse_bypass_intercept(opts.as_ref())?;
|
||||
let payload = bytes.as_bytes();
|
||||
let edit = run_buffer_edit(
|
||||
lua,
|
||||
this.0,
|
||||
EditOp::Insert {
|
||||
pos,
|
||||
bytes: &payload,
|
||||
},
|
||||
bypass_intercept,
|
||||
)?;
|
||||
notify_buffer_edit_to_windows(lua, this.0, &edit);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
|
||||
methods.add_method("delete", |lua, this, (start, end): (i64, i64)| {
|
||||
let range = checked_range(start, end)?;
|
||||
let edit = run_managed_edit(lua, this.0, EditOp::Delete { range })?;
|
||||
notify_buffer_edit_to_windows(lua, this.0, &edit);
|
||||
Ok(())
|
||||
});
|
||||
methods.add_method(
|
||||
"delete",
|
||||
|lua, this, (start, end, opts): (i64, i64, Option<Table>)| {
|
||||
let range = checked_range(start, end)?;
|
||||
let bypass_intercept = parse_bypass_intercept(opts.as_ref())?;
|
||||
let edit = run_buffer_edit(lua, this.0, EditOp::Delete { range }, bypass_intercept)?;
|
||||
notify_buffer_edit_to_windows(lua, this.0, &edit);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
|
||||
methods.add_method(
|
||||
"replace",
|
||||
|lua, this, (start, end, bytes): (i64, i64, mlua::String)| {
|
||||
|lua, this, (start, end, bytes, opts): (i64, i64, mlua::String, Option<Table>)| {
|
||||
let range = checked_range(start, end)?;
|
||||
let bypass_intercept = parse_bypass_intercept(opts.as_ref())?;
|
||||
let payload = bytes.as_bytes();
|
||||
let edit = run_managed_edit(
|
||||
let edit = run_buffer_edit(
|
||||
lua,
|
||||
this.0,
|
||||
EditOp::Replace {
|
||||
range,
|
||||
bytes: &payload,
|
||||
},
|
||||
bypass_intercept,
|
||||
)?;
|
||||
notify_buffer_edit_to_windows(lua, this.0, &edit);
|
||||
Ok(())
|
||||
|
|
@ -1124,6 +1206,40 @@ fn add_mutation_methods<M: UserDataMethods<BufferIdLua>>(methods: &mut M) {
|
|||
);
|
||||
}
|
||||
|
||||
fn parse_bypass_intercept(opts: Option<&Table>) -> mlua::Result<bool> {
|
||||
Ok(match opts {
|
||||
Some(opts) => opts
|
||||
.get::<Option<bool>>("bypass_intercept")?
|
||||
.unwrap_or(false),
|
||||
None => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_buffer_edit(
|
||||
lua: &Lua,
|
||||
id: BufferId,
|
||||
op: EditOp<'_>,
|
||||
bypass_intercept: bool,
|
||||
) -> mlua::Result<crate::rope::Edit> {
|
||||
if bypass_intercept {
|
||||
run_bypass_edit(lua, id, op)
|
||||
} else {
|
||||
run_managed_edit(lua, id, op)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_bypass_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result<crate::rope::Edit> {
|
||||
with_registry_mut(lua, |r| {
|
||||
let buf = resolve_mut(r, id)?;
|
||||
buf.begin_edit().map_err(mlua::Error::external)?;
|
||||
let result = buf
|
||||
.apply_edit_skip_intercepts(op)
|
||||
.map_err(mlua::Error::external);
|
||||
buf.end_edit();
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
/// Three-phase edit flow that lets intercepts re-enter `pmacs.buffer.X`
|
||||
/// safely (T M7.4).
|
||||
///
|
||||
|
|
@ -1239,6 +1355,52 @@ fn notify_buffer_edit_to_windows(lua: &Lua, buffer_id: BufferId, edit: &crate::r
|
|||
core.queue_daemon_origin_crdt_op(buffer_id, edit);
|
||||
}
|
||||
|
||||
fn remove_buffer_removed_callback(lua: &Lua, handle: &BufferRemoveCallbackHandleLua) -> bool {
|
||||
let Some(callbacks) = lua.app_data_ref::<BufferRemoveCallbacks>() else {
|
||||
return false;
|
||||
};
|
||||
callbacks.remove(handle.buffer, handle.callback_id)
|
||||
}
|
||||
|
||||
fn remove_buffer_and_fire(lua: &Lua, registry: &SharedRegistry, id: BufferId) -> mlua::Result<()> {
|
||||
registry
|
||||
.borrow_mut()
|
||||
.remove(id)
|
||||
.map(|_| ())
|
||||
.map_err(mlua::Error::external)?;
|
||||
after_buffer_removed(lua, id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn after_buffer_removed(lua: &Lua, id: BufferId) {
|
||||
if let Some(keymaps) = lua.app_data_ref::<SharedKeymapStack>() {
|
||||
keymaps.borrow_mut().remove_buffer(id);
|
||||
}
|
||||
let callbacks = match lua.app_data_ref::<BufferRemoveCallbacks>() {
|
||||
Some(callbacks) => callbacks.take(id),
|
||||
None => Vec::new(),
|
||||
};
|
||||
for callback in callbacks {
|
||||
if let Err(err) = callback.body.call::<()>(BufferIdLua(id)) {
|
||||
log_buffer_removed_error(lua, &callback.source, &err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_hook_if_defined(lua: &Lua, name: &str, args: mlua::MultiValue) {
|
||||
let snapshot = match lua.app_data_ref::<SharedHookRegistry>() {
|
||||
Some(hooks) => hooks.borrow().snapshot(name),
|
||||
None => None,
|
||||
};
|
||||
let Some((kind, callbacks)) = snapshot else {
|
||||
return;
|
||||
};
|
||||
let outcome = crate::hook::run_snapshot(kind, &callbacks, args);
|
||||
for err in &outcome.errors {
|
||||
log_hook_error(lua, name, err);
|
||||
}
|
||||
}
|
||||
|
||||
/// Force any window showing `buffer_id` to rebuild its `TextView`.
|
||||
///
|
||||
/// Called by `pmacs.help.*` after a render rewrites `*help*` end to
|
||||
|
|
@ -1733,6 +1895,7 @@ pub fn install(
|
|||
lua.set_app_data(InstalledPackages::new());
|
||||
lua.set_app_data(PackageUnloadHooks::new());
|
||||
lua.set_app_data(CurrentlyLoadingPackage::new());
|
||||
lua.set_app_data(BufferRemoveCallbacks::new());
|
||||
|
||||
let pmacs = lua.create_table()?;
|
||||
pmacs.set("buffer", install_buffer_module(lua, registry)?)?;
|
||||
|
|
@ -2146,6 +2309,32 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
|||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let reg = registry.clone();
|
||||
buffer.set(
|
||||
"from_file",
|
||||
lua.create_function(move |lua, path: String| -> mlua::Result<BufferIdLua> {
|
||||
let path_buf = std::path::PathBuf::from(&path);
|
||||
let (bytes, meta) = crate::file_io::load_file(&path_buf).map_err(|source| {
|
||||
mlua::Error::external(std::io::Error::new(
|
||||
source.kind(),
|
||||
format!("failed to load {path}: {source}"),
|
||||
))
|
||||
})?;
|
||||
let id = reg.borrow_mut().create_from_bytes(path.clone(), &bytes);
|
||||
if let Some(core) = lua.app_data_ref::<SharedCore>() {
|
||||
let mut core = core.borrow_mut();
|
||||
core.switch_active_buffer(id)
|
||||
.map_err(mlua::Error::external)?;
|
||||
core.file_path = Some(path_buf);
|
||||
core.file_meta = Some(meta);
|
||||
}
|
||||
run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new());
|
||||
Ok(BufferIdLua(id))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let reg = registry.clone();
|
||||
buffer.set(
|
||||
|
|
@ -2165,15 +2354,37 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
|||
let reg = registry.clone();
|
||||
buffer.set(
|
||||
"remove",
|
||||
lua.create_function(move |_, id: BufferIdLua| {
|
||||
reg.borrow_mut()
|
||||
.remove(id.0)
|
||||
.map(|_| ())
|
||||
.map_err(mlua::Error::external)
|
||||
lua.create_function(move |lua, id: BufferIdLua| {
|
||||
remove_buffer_and_fire(lua, ®, id.0)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let reg = registry.clone();
|
||||
buffer.set(
|
||||
"on_removed",
|
||||
lua.create_function(
|
||||
move |lua,
|
||||
(id, body): (BufferIdLua, Function)|
|
||||
-> mlua::Result<BufferRemoveCallbackHandleLua> {
|
||||
{
|
||||
let r = reg.borrow();
|
||||
resolve(&r, id.0)?;
|
||||
}
|
||||
let callbacks = lua
|
||||
.app_data_ref::<BufferRemoveCallbacks>()
|
||||
.ok_or_else(|| mlua::Error::external(BindingError::NoRegistry))?;
|
||||
let callback_id = callbacks.add(id.0, body, caller_source(lua, 2));
|
||||
Ok(BufferRemoveCallbackHandleLua {
|
||||
buffer: id.0,
|
||||
callback_id,
|
||||
})
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let reg = registry.clone();
|
||||
buffer.set(
|
||||
|
|
@ -3953,10 +4164,12 @@ fn install_buffer_kill(lua: &Lua, core: &SharedCore) -> mlua::Result<()> {
|
|||
let cc = core.clone();
|
||||
buffer.set(
|
||||
"kill",
|
||||
lua.create_function(move |_, id: BufferIdLua| -> mlua::Result<()> {
|
||||
lua.create_function(move |lua, id: BufferIdLua| -> mlua::Result<()> {
|
||||
cc.borrow_mut()
|
||||
.kill_buffer(id.0)
|
||||
.map_err(mlua::Error::external)
|
||||
.map_err(mlua::Error::external)?;
|
||||
after_buffer_removed(lua, id.0);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
|
|
@ -4430,6 +4643,37 @@ fn log_package_load_error(lua: &Lua, package: &str, err: &mlua::Error) {
|
|||
}
|
||||
}
|
||||
|
||||
fn log_buffer_removed_error(lua: &Lua, source: &SourceLocation, err: &mlua::Error) {
|
||||
let line = format!(
|
||||
"[buffer.on_removed] callback at {} raised: {err}\n",
|
||||
source.render()
|
||||
);
|
||||
let result = {
|
||||
let Some(app) = lua.app_data_ref::<SharedRegistry>() else {
|
||||
return;
|
||||
};
|
||||
let mut reg = app.borrow_mut();
|
||||
let id = match reg.find_by_name(crate::lua::ERRORS_BUFFER_NAME) {
|
||||
Some(id) => id,
|
||||
None => reg.create(crate::lua::ERRORS_BUFFER_NAME),
|
||||
};
|
||||
let Ok(buf) = reg.get_mut(id) else {
|
||||
return;
|
||||
};
|
||||
let pos = buf.len();
|
||||
let edit = buf
|
||||
.apply_edit(EditOp::Insert {
|
||||
pos,
|
||||
bytes: line.as_bytes(),
|
||||
})
|
||||
.ok();
|
||||
edit.map(|e| (id, e))
|
||||
};
|
||||
if let Some((id, edit)) = result {
|
||||
notify_buffer_edit_to_windows(lua, id, &edit);
|
||||
}
|
||||
}
|
||||
|
||||
fn install_describe_module(
|
||||
lua: &Lua,
|
||||
registry: &SharedRegistry,
|
||||
|
|
@ -9888,6 +10132,17 @@ fn install_motion(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result<
|
|||
"move_paragraph_down",
|
||||
EditorCore::move_paragraph_down,
|
||||
)?;
|
||||
{
|
||||
let cc = core.clone();
|
||||
editor.set(
|
||||
"move_to_line",
|
||||
lua.create_function(move |_, line: i64| {
|
||||
let line = usize::try_from(line).map_err(mlua::Error::external)?;
|
||||
cc.borrow_mut().move_to_line(line);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -10667,6 +10922,28 @@ mod tests {
|
|||
assert_eq!(len, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_file_loads_existing_file_as_clean_buffer() {
|
||||
let (lua, _reg, _cmds, _kms, _hks) = fresh();
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("seed.txt");
|
||||
std::fs::write(&path, b"abcde").expect("write seed file");
|
||||
let loaded: BufferIdLua = lua
|
||||
.load("return pmacs.buffer.from_file(...)")
|
||||
.call(path.display().to_string())
|
||||
.unwrap();
|
||||
let content: String = lua
|
||||
.load("local id = ...; return id:slice(0, id:len())")
|
||||
.call(loaded)
|
||||
.unwrap();
|
||||
let modified: bool = lua
|
||||
.load("local id = ...; return id:is_modified()")
|
||||
.call(loaded)
|
||||
.unwrap();
|
||||
assert_eq!(content, "abcde");
|
||||
assert!(!modified, "loaded buffers should start clean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_replace_undo_redo() {
|
||||
let (lua, _reg, _cmds, _kms, _hks) = fresh();
|
||||
|
|
@ -11104,6 +11381,52 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bypass_intercept_option_skips_intercept_chain() {
|
||||
let (lua, _reg, _cmds, _kms, _hks) = fresh();
|
||||
let content: String = lua
|
||||
.load(
|
||||
r#"
|
||||
local id = pmacs.buffer.from_bytes("scratch", "abcdef")
|
||||
pmacs.buffer.add_intercept(id, function()
|
||||
error("blocked")
|
||||
end)
|
||||
local ok = pcall(function() id:replace(0, 1, "X") end)
|
||||
assert(not ok, "plain replace must still run intercepts")
|
||||
id:replace(0, 1, "Y", { bypass_intercept = true })
|
||||
return id:slice(0, id:len())
|
||||
"#,
|
||||
)
|
||||
.eval()
|
||||
.unwrap();
|
||||
assert_eq!(content, "Ybcdef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bypass_intercept_keeps_same_buffer_reentry_gate() {
|
||||
let (lua, _reg, _cmds, _kms, _hks) = fresh();
|
||||
let msg: String = lua
|
||||
.load(
|
||||
r#"
|
||||
local id = pmacs.buffer.from_bytes("scratch", "abc")
|
||||
pmacs.buffer.add_intercept(id, function()
|
||||
id:replace(0, 1, "X", { bypass_intercept = true })
|
||||
return nil
|
||||
end)
|
||||
local ok, err = pcall(function() id:replace(0, 1, "Y") end)
|
||||
assert(not ok, "same-buffer re-entry must still be gated")
|
||||
assert(id:slice(0, id:len()) == "abc")
|
||||
return tostring(err)
|
||||
"#,
|
||||
)
|
||||
.eval()
|
||||
.unwrap();
|
||||
assert!(
|
||||
msg.contains("already being edited") || msg.contains("ConcurrentEdit"),
|
||||
"expected ConcurrentEdit-style error; got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_buffer_remove_from_intercept_succeeds() {
|
||||
// Cross-buffer remove from inside an intercept is allowed:
|
||||
|
|
@ -11136,6 +11459,60 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_removed_callback_fires_once_and_can_be_removed() {
|
||||
let (lua, _reg, _cmds, _kms, _hks) = fresh();
|
||||
let called: bool = lua
|
||||
.load(
|
||||
r#"
|
||||
local first = pmacs.buffer.create("first")
|
||||
local second = pmacs.buffer.create("second")
|
||||
local calls = 0
|
||||
pmacs.buffer.on_removed(first, function(dead)
|
||||
assert(dead == first, "removed callback receives the removed buffer")
|
||||
calls = calls + 1
|
||||
end)
|
||||
local handle = pmacs.buffer.on_removed(second, function()
|
||||
calls = calls + 100
|
||||
end)
|
||||
assert(handle:remove() == true)
|
||||
assert(handle:remove() == false)
|
||||
pmacs.buffer.remove(first)
|
||||
pmacs.buffer.remove(second)
|
||||
return calls == 1
|
||||
"#,
|
||||
)
|
||||
.eval()
|
||||
.unwrap();
|
||||
assert!(called);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_remove_prunes_buffer_local_keymaps() {
|
||||
let (lua, _reg, _cmds, kms, _hks) = fresh();
|
||||
let id: BufferIdLua = lua
|
||||
.load(
|
||||
r#"
|
||||
local id = pmacs.buffer.create("keyed")
|
||||
pmacs.keymap.bind {
|
||||
scope = "buffer",
|
||||
buffer = id,
|
||||
sequence = "C-c x",
|
||||
command = "buffer.save",
|
||||
}
|
||||
return id
|
||||
"#,
|
||||
)
|
||||
.eval()
|
||||
.unwrap();
|
||||
assert!(kms.borrow().buffers.contains_key(&id.0));
|
||||
lua.load("pmacs.buffer.remove(...)").call::<()>(id).unwrap();
|
||||
assert!(
|
||||
!kms.borrow().buffers.contains_key(&id.0),
|
||||
"buffer-local keymaps should be pruned on removal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m6_4_intercept_transform_overrides_position() {
|
||||
// An intercept may transform an insert by returning a table
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@
|
|||
--
|
||||
-- * Source-listener intercept on each source marks the aggregate
|
||||
-- dirty and schedules an async repaint via pmacs.async +
|
||||
-- workers.sleep(0):await(). The body yields once so the source
|
||||
-- edit's Phase-3 commit completes before repaint reads the
|
||||
-- source's text.
|
||||
-- pmacs.async.yield_to_next_tick(). The body yields once so the
|
||||
-- source edit's Phase-3 commit completes before repaint reads
|
||||
-- the source's text.
|
||||
--
|
||||
-- * Cross-block edits are rejected. A delete or replace whose
|
||||
-- range spans more than one block --- for example, deleting
|
||||
|
|
@ -170,29 +170,19 @@ M.__pmacs_outline_test_repaint = repaint
|
|||
-- Source intercepts can't repaint synchronously: the source's
|
||||
-- Phase 3 hasn't committed yet, so reading source:slice would see
|
||||
-- the pre-edit text. We schedule a coroutine that yields once via
|
||||
-- pmacs.workers.sleep(0):await(); the next tick_async resumes it
|
||||
-- pmacs.async.yield_to_next_tick(); the next tick_async resumes it
|
||||
-- after Phase 3 has applied. `repaint_scheduled` coalesces multiple
|
||||
-- source edits into one repaint per tick.
|
||||
--
|
||||
-- Pass-6 finding 3 / timing semantics: `workers.sleep(0)` dispatches
|
||||
-- a sleep job to a worker thread which immediately replies to the
|
||||
-- main-thread bus; the next call to `state.tick_async` drains the
|
||||
-- bus and resumes our coroutine. This is "within one tick *after
|
||||
-- the worker reply settles*" --- not strictly one tick of the
|
||||
-- editor's main event loop. In practice the worker reply arrives
|
||||
-- in microseconds, so a single tick suffices once a small amount
|
||||
-- of real time has elapsed; the M8.10 acceptance test verifies
|
||||
-- bounded propagation via `pump_until` rather than a hard one-tick
|
||||
-- count. SP-7 in V0.2-PREREQUISITES.md tracks the v0.2 work to add
|
||||
-- a synchronous post-edit hook (or a worker-free coroutine yield
|
||||
-- primitive) so aggregate repaint can be pinned to exactly one
|
||||
-- tick of the main event loop.
|
||||
-- SP-7 resolution: this uses a worker-free next-tick yield primitive,
|
||||
-- so propagation is pinned to the editor's async tick rather than to
|
||||
-- a worker reply round trip.
|
||||
|
||||
local function schedule_repaint(handle)
|
||||
if handle.repaint_scheduled or not handle.alive then return end
|
||||
handle.repaint_scheduled = true
|
||||
pmacs.async(function()
|
||||
pmacs.workers.sleep(0):await()
|
||||
pmacs.async.yield_to_next_tick()
|
||||
handle.repaint_scheduled = false
|
||||
if handle.alive and handle.dirty then
|
||||
repaint(handle)
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@
|
|||
-- .parser_handle
|
||||
-- outline.close(handle) -- removes intercepts,
|
||||
-- drops the visible buffer
|
||||
-- outline.query(source_buf, predicate) -- public structure query;
|
||||
-- also installed as
|
||||
-- pmacs.outline.query
|
||||
--
|
||||
-- M-x pmacs-outline.next-headline -- in visible buffer
|
||||
-- M-x pmacs-outline.parent-headline
|
||||
|
|
@ -320,6 +323,28 @@ function M.toggle_fold(handle, source_byte)
|
|||
repaint(handle)
|
||||
end
|
||||
|
||||
function M.query(source_buf, predicate)
|
||||
if type(predicate) ~= "function" then
|
||||
error("pmacs-outline.query: predicate must be a function")
|
||||
end
|
||||
|
||||
local h = find_handle_by("source", source_buf)
|
||||
if h then
|
||||
return parser.query(h.parser_handle, predicate)
|
||||
end
|
||||
|
||||
local ph = parser.attach(source_buf)
|
||||
local ok, result = pcall(function()
|
||||
return parser.query(ph, predicate)
|
||||
end)
|
||||
parser.detach(ph)
|
||||
if not ok then error(result) end
|
||||
return result
|
||||
end
|
||||
|
||||
pmacs.outline = pmacs.outline or {}
|
||||
pmacs.outline.query = M.query
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Commands
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
@ -389,6 +414,9 @@ pmacs.packages.on_unload(function()
|
|||
pmacs.command.unregister(name)
|
||||
end
|
||||
OWNED_COMMANDS = {}
|
||||
if pmacs.outline and pmacs.outline.query == M.query then
|
||||
pmacs.outline.query = nil
|
||||
end
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1164,7 +1164,7 @@ fn m10_11_q13_cat2_undo_across_delayed_ops() {
|
|||
/// re-run (framing Q8).
|
||||
///
|
||||
/// **F2 correction.** Finding 5's first resolution ("(B): jitter-mode
|
||||
/// delays both CellDelta and CrdtOp") was wrong — it widened the
|
||||
/// delays both `CellDelta` and `CrdtOp`") was wrong — it widened the
|
||||
/// match in the render-message loop, which never carries broadcast
|
||||
/// `CrdtOp`s; the CRDT-convergence path was *not* exercised and this
|
||||
/// test silently asserted nothing about CRDT-under-jitter. F2 moved
|
||||
|
|
|
|||
|
|
@ -67,20 +67,6 @@ fn editor_with_outline() -> (EditorState, TempDir, TempDir) {
|
|||
(state, cache, user_root)
|
||||
}
|
||||
|
||||
/// Pump the async runtime until `predicate` returns true or the
|
||||
/// deadline elapses. Magit and dired tests use the same shape.
|
||||
fn pump_until<F: Fn(&EditorState) -> bool>(state: &mut EditorState, predicate: F) {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while !predicate(state) {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"async pump deadline exceeded after 2s"
|
||||
);
|
||||
state.tick_async();
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
|
||||
fn agg_text(state: &mut EditorState) -> String {
|
||||
state
|
||||
.lua_host
|
||||
|
|
@ -251,18 +237,10 @@ fn outline_aggregate_source_change_propagates_within_one_tick() {
|
|||
)
|
||||
.expect("source edit");
|
||||
|
||||
// Pump the async runtime: the source-listener intercept scheduled
|
||||
// a deferred repaint via pmacs.async(...sleep(0):await()...). One
|
||||
// tick should be enough to dispatch it.
|
||||
pump_until(&mut state, |state| {
|
||||
let txt: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(r"return AGG.buffer:slice(0, AGG.buffer:len())")
|
||||
.eval()
|
||||
.expect("agg text");
|
||||
txt.contains("TODO modified")
|
||||
});
|
||||
// SP-7: the source-listener intercept now schedules a deferred
|
||||
// repaint via pmacs.async.yield_to_next_tick(), with no worker
|
||||
// round trip. One async tick must be sufficient.
|
||||
state.tick_async();
|
||||
|
||||
let after = agg_text(&mut state);
|
||||
assert!(
|
||||
|
|
@ -1204,16 +1182,9 @@ fn outline_aggregate_package_reload_closes_aggregates() {
|
|||
|
||||
#[test]
|
||||
fn outline_aggregate_source_change_propagates_with_tight_deadline() {
|
||||
// Pass-6 finding 3. The bullet-3 acceptance test uses a 2s
|
||||
// pump_until deadline, which validates eventual bounded
|
||||
// propagation rather than a strict one-tick guarantee. This
|
||||
// test pins the *bounded* claim explicitly with a tight 200ms
|
||||
// deadline: under the v0.1 implementation (source-listener
|
||||
// schedules via pmacs.async + workers.sleep(0):await()), the
|
||||
// worker reply arrives in microseconds and a small number of
|
||||
// ticks suffices. SP-7 in V0.2-PREREQUISITES.md tracks the
|
||||
// v0.2 work to pin propagation to exactly one tick of the
|
||||
// main event loop.
|
||||
// SP-7 regression: the aggregate source-listener uses
|
||||
// pmacs.async.yield_to_next_tick(), so the old worker-sleep
|
||||
// timing path must not come back.
|
||||
let (mut state, _c, _u) = editor_with_outline();
|
||||
state
|
||||
.lua_host
|
||||
|
|
|
|||
|
|
@ -549,3 +549,63 @@ fn read_dir_on_missing_path_returns_failed_status() {
|
|||
"error message must name the offending path; got {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_watch_reports_file_change_and_can_cancel() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("watched.txt");
|
||||
std::fs::write(&path, b"one").expect("write initial");
|
||||
|
||||
let mut state = fresh_editor();
|
||||
let path_str = path.display().to_string();
|
||||
let chunk = format!(
|
||||
r#"
|
||||
_G.WATCH_EVENTS = {{}}
|
||||
_G.WATCH = pmacs.fs.watch("{path_str}", function(event)
|
||||
WATCH_EVENTS[#WATCH_EVENTS + 1] = event.kind
|
||||
end, {{ interval_ms = 5 }})
|
||||
"#,
|
||||
);
|
||||
eval_sync::<()>(&mut state, &chunk);
|
||||
|
||||
// The watcher establishes its baseline snapshot asynchronously (an
|
||||
// in-flight fs.stat). `pmacs._async.pending_count() == 1` cannot
|
||||
// distinguish that in-flight baseline read from the steady-state
|
||||
// poll sleep, so a single post-gate write can race the baseline,
|
||||
// be absorbed into it, and never produce an event (the flake this
|
||||
// replaces). Instead, rewrite the file with distinct-length
|
||||
// content on every pump iteration: whatever snapshot the watcher
|
||||
// captured as its baseline, a subsequent distinct write
|
||||
// necessarily differs from it (size differs, so it holds even on
|
||||
// coarse-mtime filesystems) and fires a "changed" event.
|
||||
let writes = std::cell::Cell::new(0u32);
|
||||
pump_until(&mut state, |s| {
|
||||
let n = writes.get() + 1;
|
||||
writes.set(n);
|
||||
std::fs::write(&path, "v".repeat(n as usize + 1)).expect("rewrite changed");
|
||||
let count: i64 = s
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return #WATCH_EVENTS")
|
||||
.eval()
|
||||
.expect("event count");
|
||||
count >= 1
|
||||
});
|
||||
|
||||
let first: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return WATCH_EVENTS[1]")
|
||||
.eval()
|
||||
.expect("first watch event");
|
||||
assert_eq!(first, "changed");
|
||||
|
||||
eval_sync::<()>(&mut state, "WATCH:cancel()");
|
||||
let cancelled: bool = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return WATCH:is_cancelled()")
|
||||
.eval()
|
||||
.expect("cancelled");
|
||||
assert!(cancelled, "watch handle must report cancellation");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,37 @@ fn editor_with_outline() -> (EditorState, TempDir, TempDir) {
|
|||
(state, cache, user_root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pmacs_outline_query_returns_matching_entries() {
|
||||
let (mut state, _cache, _user_root) = editor_with_outline();
|
||||
state
|
||||
.lua_host
|
||||
.eval(
|
||||
Some("public-outline-query"),
|
||||
r#"
|
||||
_G.SRC = pmacs.buffer.create("*outline-query-src*")
|
||||
_G.SRC:replace(0, 0,
|
||||
"* TODO alpha :todo:\nbody\n" ..
|
||||
"* DONE beta :done:\nbody\n" ..
|
||||
"* TODO gamma :todo:\nbody\n")
|
||||
local hits = pmacs.outline.query(_G.SRC, function(e)
|
||||
return e.tagset and e.tagset.todo
|
||||
end)
|
||||
_G.HIT_COUNT = #hits
|
||||
_G.FIRST_TITLE = hits[1].title
|
||||
_G.SECOND_TITLE = hits[2].title
|
||||
"#,
|
||||
)
|
||||
.expect("public outline query");
|
||||
|
||||
let count: i64 = state.lua_host.lua().globals().get("HIT_COUNT").unwrap();
|
||||
assert_eq!(count, 2);
|
||||
let first: String = state.lua_host.lua().globals().get("FIRST_TITLE").unwrap();
|
||||
let second: String = state.lua_host.lua().globals().get("SECOND_TITLE").unwrap();
|
||||
assert_eq!(first, "TODO alpha");
|
||||
assert_eq!(second, "TODO gamma");
|
||||
}
|
||||
|
||||
/// Boot an outline view over a fresh source buffer pre-populated
|
||||
/// with `text`. After this call the visible projection buffer is
|
||||
/// active in the window. Globals stashed for test access:
|
||||
|
|
|
|||
Loading…
Reference in New Issue