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:
Levi Neuwirth 2026-05-18 10:31:31 -04:00
parent 04ac1fbd8d
commit c50db222d3
18 changed files with 980 additions and 88 deletions

6
.gitignore vendored
View File

@ -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

View File

@ -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 M6M10 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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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")"#);

View File

@ -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",
},
];

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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, &reg, 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

View File

@ -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)

View File

@ -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)
-- ---------------------------------------------------------------------------

View File

@ -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

View File

@ -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

View File

@ -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");
}

View File

@ -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: