feat(typed-edit): the typed-edit consumer chain (Arc 8 Stage 4a)

`pmacs.editor.take_typed_edit()` is one-shot and per-frontend (Q#AP9):
the first `buffer.after-edit` callback to call it clears the slot, and
every later callback in the same fan-out sees nil. That was survivable
only because auto-pairing was the sole consumer — never a property
anyone chose. A second independent caller would get nil or steal the
record from pairing depending on hook registration order, and
registration order is not a contract.

This makes it one. `builtin/runtime/typed_edit.lua` owns the single
after-edit subscriber that reads the record, and offers that one read to
consumers registered through `pmacs.typed_edit.add_consumer{ name,
priority, fn }`: lowest priority first, ties by registration order, and
the first consumer to return truthy claims the edit and stops the chain.
`pair.lua` becomes that chain's only consumer, at priority 100.

No Lean content. Stage 4b's abbreviation expander is what needs the
ordering guarantee (64 of its 1,855 keys contain a `lean4` pair-set
character, so pairing running first corrupts them), but the chain is
substrate every language runs through, which is why it ships alone —
framing Q#LN10, and §4's rule that no PR in this arc mixes a
cross-cutting substrate change with Lean feature content.

Three design points worth review attention:

- Consumers are called even when the record is nil. "This fan-out
  carried no typed edit" is information a consumer acts on: it is how
  pairing's test seam observes a non-event, and how Stage 4b will
  abandon a pending abbreviation an unrelated edit invalidated. Three
  existing auto-pairing tests fail if the chain skips consumers on nil.
- The chain pcalls each consumer. `buffer.after-edit` is
  all-must-succeed, so a throwing consumer would otherwise fail the
  fan-out for every other subscriber, including lsp.lua's didChange
  flush. Behavior-preserving for pairing, which already never throws.
- Ordered insertion, not `table.sort`, which is not stable in Lua —
  "ties by registration order" is a stated contract, not a coincidence.

`tests/auto_pair_acceptance.rs` is UNCHANGED — zero lines — and its 45
tests pass. That is criterion 46 and the whole no-behavior-change claim;
a suite edited to accommodate the refactor would prove nothing.

`tests/typed_edit_chain_acceptance.rs` adds 9 tests for criteria
46a-46e. Every one is bite-verified by mutation: appending instead of
ordered insert (5 fail), `>=` for the tiebreak (1), re-taking per
consumer (4), ignoring the claim (1), dropping the pcall (1), skipping
nil fan-outs (1 here plus 3 in the untouched auto-pair suite), and
loading the chain after lsp.lua (the Q#AP7 flush test fails, alongside
the two existing pairing ones).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B
This commit is contained in:
Levi Neuwirth 2026-07-26 13:00:03 -04:00
parent 174e36fce3
commit 24ca906294
4 changed files with 699 additions and 30 deletions

View File

@ -4,20 +4,30 @@
-- next char is already `)` steps over it instead of doubling it. The
-- carrier is a `buffer.after-edit` reaction (Q#AP1): the opener stays
-- a genuine single-codepoint self-insert — the classification
-- signature help depends on — and this hook inserts (or swallows) the
-- closer as a second edit. Provenance is the exact one-shot typed-edit
-- record (`pmacs.editor.take_typed_edit()`, Q#AP9), not buffer-text
-- inference: pastes, programmatic edits, manual hook runs, and a stale
-- `this_command` have no record and never pair, and a transformed,
-- relocated, or context-switching source self-insert fails closed.
-- signature help depends on — and this reaction inserts (or swallows)
-- the closer as a second edit. Provenance is the exact one-shot
-- typed-edit record (`pmacs.editor.take_typed_edit()`, Q#AP9), not
-- buffer-text inference: pastes, programmatic edits, manual hook runs,
-- and a stale `this_command` have no record and never pair, and a
-- transformed, relocated, or context-switching source self-insert fails
-- closed.
--
-- This chunk loads BEFORE lsp.lua (Q#AP7): registration order is hook
-- execution order, and lsp.lua's after-edit callback synchronously
-- flushes didChange on the signature-trigger path — the closer must
-- already be in the buffer when that callback runs. Everything under
-- `pmacs.lsp` is therefore looked up lazily at callback time.
-- Since Arc 8 Stage 4a (Q#LN10) pairing no longer subscribes to
-- `buffer.after-edit` itself. It registers on the typed-edit chain
-- (`builtin/runtime/typed_edit.lua`), which owns the single subscriber
-- and the single one-shot read. Everything above still holds — the
-- record is the same record — but the chain, not this file, decides
-- who sees it and in what order.
--
-- Framing: docs/auto-pairing-framing.md.
-- This chunk loads AFTER typed_edit.lua (it registers into it) and
-- BEFORE lsp.lua (Q#AP7): registration order is hook execution order,
-- and lsp.lua's after-edit callback synchronously flushes didChange on
-- the signature-trigger path — the closer must already be in the
-- buffer when that callback runs. Everything under `pmacs.lsp` is
-- therefore looked up lazily at callback time.
--
-- Framing: docs/auto-pairing-framing.md; Stage 4a in
-- docs/lean4-mode-framing.md Q#LN10.
pmacs.pair = pmacs.pair or {}
@ -40,7 +50,7 @@ local ed = pmacs.editor
-- Per-buffer on/off switch (Q#CR8's flagship adopter). Read against the
-- SOURCE buffer of the typed edit, never the currently active one — see
-- the hook body below, which resolves it the same way `set_for` resolves
-- the consumer body below, which resolves it the same way `set_for` resolves
-- the buffer's pair set (round 2, finding 2): `rec.buffer`, not
-- `pmacs.window.buffer()`.
pmacs.config.define {
@ -214,28 +224,36 @@ end
-- Acceptance tests flip `_capture_records` on; each fan-out then
-- publishes the record it observed (or nil) to `_last_record`, which
-- is how tests read the exact codepoint / effective triple and prove
-- one-shot-ness (this callback registers first and consumes it).
-- one-shot-ness (the chain takes the record before any other
-- `buffer.after-edit` subscriber can, and hands it here).
pmacs.pair._capture_records = false
pmacs.hook.add("buffer.after-edit", function()
-- The typed-edit consumer (Arc 8 Stage 4a, Q#LN10). `rec` is the one
-- record `typed_edit.lua` read for this fan-out — possibly nil, which
-- is why the capture seam below is updated before the nil guard.
-- Returns whether pairing CLAIMED the keystroke: true once it has
-- committed to reacting (a skip-over or a closer insert, landed or
-- intercept-rejected), false on every decline. Pairing is last of the
-- builtin consumers, so nothing currently observes that value; it is
-- stated correctly so it stays correct when something does.
local function on_typed_edit(rec)
-- One-shot provenance (Q#AP9). Absence — paste, programmatic edit,
-- manual hook run, rejected insert, a post-insert mutation by the
-- command, stale `this_command` — is a silent non-event; only a
-- live record for a pair-set character that then fails a gate
-- reports.
local rec = ed.take_typed_edit and ed.take_typed_edit()
if pmacs.pair._capture_records then pmacs.pair._last_record = rec end
if not rec then return end
if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end
if not rec then return false end
if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return false end
-- The master switch, per-buffer (Q#CR4): the SOURCE buffer of the
-- typed edit, resolved buffer-local -> global -> default(true). A
-- second buffer of the same language is untouched by a buffer-local
-- override here (acceptance 29).
if not pmacs.config.get("editing.auto-pair", rec.buffer) then return end
if not pmacs.config.get("editing.auto-pair", rec.buffer) then return false end
local buf = pmacs.window.buffer()
if not buf then return end
if not buf then return false end
-- Relevance first (PR #110 round 1, finding 2): pairing has no
-- interest in characters outside the set, so a transformed or
@ -247,14 +265,14 @@ pmacs.hook.add("buffer.after-edit", function()
-- Rust.
local ch = rec.char
local openers, closers = maps_for(set_for(rec.buffer))
if not (openers[ch] or closers[ch]) then return end
if not (openers[ch] or closers[ch]) then return false end
-- Fail closed on a transformed source self-insert (Q#AP3): the
-- intercept's positional result stands as produced; pairing on top
-- of a relocated or expanded opener would compound it.
if not rec.clean then
ed.set_status("auto-pair skipped: source self-insert transformed")
return
return false
end
-- Fail closed when the source edit's context is no longer current:
-- an intercept switched window/buffer, or something moved the
@ -268,14 +286,14 @@ pmacs.hook.add("buffer.after-edit", function()
or pmacs.window.current() ~= rec.window
or ed.cursor() ~= rec.post_cursor then
ed.set_status("auto-pair skipped: source context changed")
return
return false
end
-- Region guard (Q#AP3/Q#AP6): on the dispatch route type-over has
-- already consumed and cleared the region. A region surviving the
-- edit means the TUI's selection-blind optimistic gate let a custom
-- pair char through (named deferral) — reacting would pile a closer
-- onto an unconsumed region.
if ed.region() ~= nil then return end
if ed.region() ~= nil then return false end
local cursor = rec.post_cursor
@ -294,19 +312,19 @@ pmacs.hook.add("buffer.after-edit", function()
if not ok then
-- The duplicate stays (e.g. `())`); report, no retry.
ed.set_status("auto-pair skip rejected by buffer intercept")
return
return true
end
if estart ~= cursor or estop ~= cursor + #ch or einserted ~= 0 then
ed.set_status("auto-pair skip altered by buffer intercept")
repair_cursor(win0, buf, cursor, estart, estop, einserted)
end
return
return true
end
end
local closer = openers[ch]
if not closer then return end
if not should_pair(buf, cursor, closers) then return end
if not closer then return false end
if not should_pair(buf, cursor, closers) then return false end
local win0 = pmacs.window.current()
local ok, estart, estop, einserted = pcall(function()
@ -315,7 +333,7 @@ pmacs.hook.add("buffer.after-edit", function()
if not ok then
-- Nothing landed; the opener stands alone.
ed.set_status("auto-pair closer rejected by buffer intercept")
return
return true
end
if estart ~= cursor or estop ~= cursor or einserted ~= #closer then
ed.set_status("auto-pair closer altered by buffer intercept")
@ -324,4 +342,11 @@ pmacs.hook.add("buffer.after-edit", function()
-- Clean path: no cursor motion — the insert landed at the cursor
-- and Lua mutators move no cursors, so it already sits between the
-- pair; the daemon's per-tick CursorByte re-grounds both frontends.
end)
return true
end
pmacs.typed_edit.add_consumer {
name = "auto-pair",
priority = 100,
fn = on_typed_edit,
}

View File

@ -0,0 +1,112 @@
-- typed_edit.lua --- the typed-character consumer chain (Arc 8 Stage 4a).
--
-- `pmacs.editor.take_typed_edit()` is ONE-SHOT and per-frontend (Q#AP9):
-- the first `buffer.after-edit` callback to call it clears the slot, and
-- every later callback in the same fan-out --- including a nested manual
-- `pmacs.hook.run` --- sees nil. That was survivable only because
-- auto-pairing was the sole consumer, which was never a property anyone
-- chose. A second independent caller gets nil or steals the record from
-- pairing depending on hook registration order, and registration order
-- is not a contract.
--
-- This module makes it one. It owns the single `buffer.after-edit`
-- subscriber that reads the record, and offers that one read to
-- consumers registered through `pmacs.typed_edit.add_consumer`:
--
-- pmacs.typed_edit.add_consumer {
-- name = "auto-pair", -- for error reporting; must be unique-ish
-- priority = 100, -- LOWEST runs FIRST
-- fn = function(rec) ... return claimed end,
-- }
--
-- A consumer returns whether it CLAIMED the edit; the first that claims
-- stops the chain. "Claimed" means the chain stops, not that an edit was
-- made --- Stage 4b's abbreviation expander claims every keystroke that
-- extends a pending abbreviation precisely so that auto-pairing does not
-- also react to it (Q#LN22).
--
-- Priority is an explicit number rather than load-order-implied, because
-- the ordering is load-bearing (Q#LN22: 64 Lean abbreviation keys
-- contain a character in the `lean4` pair set, and pairing running first
-- corrupts them) and a reader must be able to check it without
-- reconstructing `src/editor.rs`'s include list.
--
-- ORDERING CONTRACT: this chunk loads BEFORE pair.lua, which registers
-- into it, and therefore before lsp.lua. That preserves Q#AP7 --- see
-- pair.lua's header and the load site in `src/editor.rs`.
--
-- Framing: docs/lean4-mode-framing.md Q#LN10.
pmacs.typed_edit = pmacs.typed_edit or {}
-- Consumers in run order: lowest `priority` first, registration order
-- breaking ties. Maintained by ordered INSERTION rather than
-- `table.sort`, which is not stable in Lua --- equal priorities would
-- otherwise resolve arbitrarily, and "ties broken by registration
-- order" is part of the stated contract, not an incidental property.
local consumers = {}
-- Register a typed-edit consumer. Argument errors throw: registration
-- happens at chunk-load or config-load time, where a throw is a visible
-- startup failure rather than a silently missing feature. Nothing in
-- the after-edit path throws --- see the fan-out below.
function pmacs.typed_edit.add_consumer(spec)
if type(spec) ~= "table" then
error("pmacs.typed_edit.add_consumer: spec must be a table", 2)
end
local name, priority, fn = spec.name, spec.priority, spec.fn
if type(name) ~= "string" or name == "" then
error("pmacs.typed_edit.add_consumer: name must be a non-empty string", 2)
end
if type(priority) ~= "number" then
error("pmacs.typed_edit.add_consumer: " .. name ..
": priority must be a number", 2)
end
if type(fn) ~= "function" then
error("pmacs.typed_edit.add_consumer: " .. name ..
": fn must be a function", 2)
end
-- STRICTLY-greater comparison, so a new consumer lands AFTER every
-- already-registered consumer of equal priority. That is exactly the
-- registration-order tiebreak; `>=` here would silently reverse it.
local at = #consumers + 1
for i, c in ipairs(consumers) do
if c.priority > priority then
at = i
break
end
end
table.insert(consumers, at, { name = name, priority = priority, fn = fn })
end
pmacs.hook.add("buffer.after-edit", function()
local ed = pmacs.editor
-- ONE read for the whole fan-out (Q#AP9). The record may be nil ---
-- paste, programmatic mutation, manual hook run, a replicated CRDT
-- op, a stale `this_command` --- and consumers are called ANYWAY,
-- with nil. That is deliberate: "this fan-out carried no typed edit"
-- is information a consumer acts on. Auto-pairing's test seam
-- observes the non-event through it, and Stage 4b abandons a pending
-- abbreviation that an unrelated edit invalidated. Skipping the
-- fan-out on nil would leave both reading stale state.
local rec = ed.take_typed_edit and ed.take_typed_edit()
for _, c in ipairs(consumers) do
-- `buffer.after-edit` is all-must-succeed (builtin/hooks/default.lua):
-- a throwing consumer would fail the fan-out for every OTHER
-- subscriber, including lsp.lua's didChange flush. Contain it,
-- report it, and keep going --- a broken consumer must not be able
-- to stop the editor from telling the language server what changed.
-- This matches pair.lua's existing never-throw-from-after-edit
-- discipline; it does not weaken the hook's contract for anyone
-- else, because the chain itself still never fails.
local ok, claimed = pcall(c.fn, rec)
if not ok then
ed.set_status("typed-edit consumer '" .. c.name .. "' failed: " ..
tostring(claimed))
elseif claimed then
return
end
end
end)

View File

@ -415,6 +415,18 @@ impl EditorState {
include_str!("../builtin/runtime/listview.lua"),
)
.expect("load listview builtin chunk");
// The typed-edit consumer chain (Arc 8 Stage 4a, Q#LN10) —
// ORDERING CONTRACT: typed_edit.lua must load BEFORE pair.lua,
// which registers a consumer into it, and therefore before
// lsp.lua. It owns the single `buffer.after-edit` subscriber
// that reads the one-shot typed-edit record, so its
// registration position is what preserves Q#AP7 below.
lua_host
.eval(
Some("@pmacs/builtin/runtime/typed_edit.lua"),
include_str!("../builtin/runtime/typed_edit.lua"),
)
.expect("load typed_edit builtin chunk");
// Auto-pairing (Arc 2, Q#AP7) — ORDERING CONTRACT: pair.lua
// must load BEFORE lsp.lua. Hook callbacks run in registration
// order, and lsp.lua's `buffer.after-edit` callback flushes
@ -424,6 +436,9 @@ impl EditorState {
// the closer stays unsynchronized until the next edit (hook
// edits don't re-fire the hook). pair.lua's `pmacs.lsp.*`
// lookups are lazy and nil-guarded for the same reason.
// Since Stage 4a the closer is inserted from the chain's
// subscriber rather than pair.lua's own, which is registered
// one chunk earlier — strictly safer for this contract.
lua_host
.eval(
Some("@pmacs/builtin/runtime/pair.lua"),

View File

@ -0,0 +1,517 @@
//! Typed-edit consumer chain acceptance (Arc 8 Stage 4a,
//! docs/lean4-mode-framing.md Q#LN10, criteria 46a46e).
//!
//! The chain owns the single `buffer.after-edit` subscriber that reads
//! the one-shot typed-edit record (Q#AP9) and offers it to consumers in
//! priority order. These tests pin the chain's OWN behavior — take-once,
//! priority ordering, claim-stops-chain, throw containment, and the
//! Q#AP7 flush ordering it inherited from `pair.lua`.
//!
//! They deliberately do not re-test auto-pairing: criterion 46 requires
//! `tests/auto_pair_acceptance.rs` to pass byte-identical, and that
//! suite is the no-behavior-change pin. Pairing appears here only as
//! the chain's last consumer, which is how 46c observes that a claim
//! really stopped the chain.
//!
//! Dispatch-driven throughout: `dispatch_key` is the producer that arms
//! the record for a grid frontend.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::lua_bindings::StateDir;
use pmacs::protocol::FrontendId;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
fn fresh_state_dir() -> PathBuf {
static SEQ: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"pmacs-typededit-{}-{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn type_str(s: &mut EditorState, text: &str) {
for ch in text.chars() {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
fn buffer_text(s: &EditorState) -> String {
let b: mlua::String = eval(
s,
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
);
String::from_utf8_lossy(&b.as_bytes()).into_owned()
}
fn status(s: &EditorState) -> String {
s.core.borrow().status.clone()
}
/// Fresh scratch-buffer editor, cursor at 0. Scratch pairing uses the
/// `default` set, so `(` pairs — which is what 46c reads.
fn editor_with(body: &str) -> EditorState {
let s = EditorState::new();
if !body.is_empty() {
exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})"));
}
exec(&s, "pmacs.editor.goto_byte(0)");
s
}
// ---------------------------------------------------------------------------
// 46a — one read for the whole fan-out
// ---------------------------------------------------------------------------
#[test]
fn chain_reads_the_record_once_and_hands_the_same_one_to_every_consumer() {
let mut s = editor_with("");
exec(
&s,
r#"
_G.seen = {}
local function spy(tag)
return function(rec)
-- Each consumer independently attempts its own take. Under
-- the pre-chain design this is exactly what a second
-- consumer would have done, and exactly what would have
-- returned nil (or stolen the record from pairing).
local own = pmacs.editor.take_typed_edit()
_G.seen[#_G.seen + 1] = {
tag = tag,
char = rec and rec.char,
post_cursor = rec and rec.post_cursor,
clean = rec and rec.clean,
own_take_was_nil = (own == nil),
}
return false
end
end
pmacs.typed_edit.add_consumer { name = "spy-a", priority = 1, fn = spy("a") }
pmacs.typed_edit.add_consumer { name = "spy-b", priority = 2, fn = spy("b") }
"#,
);
type_str(&mut s, "x");
let (n, a_char, b_char, a_pc, b_pc, a_clean, b_clean, a_nil, b_nil): (
i64,
String,
String,
i64,
i64,
bool,
bool,
bool,
bool,
) = eval(
&s,
"
local a, b = _G.seen[1], _G.seen[2]
return #_G.seen, a.char, b.char, a.post_cursor, b.post_cursor,
a.clean, b.clean, a.own_take_was_nil, b.own_take_was_nil
",
);
assert_eq!(n, 2, "both consumers ran for one typed character");
// The same record, not two reads of a slot that only one could win.
assert_eq!(a_char, "x");
assert_eq!(b_char, "x", "the second consumer sees the record too");
assert_eq!((a_pc, b_pc), (1, 1), "identical post_cursor");
assert!(a_clean && b_clean, "identical clean verdict");
// ...and the chain, not the consumers, did the taking.
assert!(
a_nil && b_nil,
"a consumer's own take_typed_edit() observes nil — the chain \
already consumed the one-shot slot (Q#AP9)"
);
}
#[test]
fn consumers_run_when_the_fan_out_carries_no_record() {
// The chain calls consumers with nil rather than skipping them.
// Three tests in the auto-pairing suite depend on this (they assert
// `_last_record == nil` after a record-less fan-out), so it is a
// load-bearing decision and not an implementation detail.
let s = editor_with("");
exec(
&s,
r#"
_G.calls, _G.nil_calls = 0, 0
pmacs.typed_edit.add_consumer {
name = "nil-spy", priority = 1,
fn = function(rec)
_G.calls = _G.calls + 1
if rec == nil then _G.nil_calls = _G.nil_calls + 1 end
return false
end,
}
"#,
);
// A manual fan-out arms no record.
exec(&s, "pmacs.hook.run(\"buffer.after-edit\")");
let (calls, nil_calls): (i64, i64) = eval(&s, "return _G.calls, _G.nil_calls");
assert_eq!(calls, 1, "the consumer ran");
assert_eq!(nil_calls, 1, "and was handed nil, not skipped");
}
// ---------------------------------------------------------------------------
// 46b — priority order, not registration order
// ---------------------------------------------------------------------------
#[test]
fn consumers_run_in_priority_order_not_registration_order() {
let mut s = editor_with("");
// Registered HIGH priority first. If the chain honored registration
// order (or `include_str!` order, which is the same failure dressed
// differently), the observed order would be the registration order.
exec(
&s,
r#"
_G.order = {}
local function mark(tag)
return function() _G.order[#_G.order + 1] = tag; return false end
end
pmacs.typed_edit.add_consumer { name = "late", priority = 30, fn = mark("late") }
pmacs.typed_edit.add_consumer { name = "early", priority = 10, fn = mark("early") }
pmacs.typed_edit.add_consumer { name = "mid", priority = 20, fn = mark("mid") }
"#,
);
type_str(&mut s, "x");
let order: String = eval(&s, "return table.concat(_G.order, ',')");
assert_eq!(
order, "early,mid,late",
"lowest priority runs first, regardless of when it registered"
);
}
#[test]
fn equal_priorities_break_by_registration_order() {
// The stated tiebreak. Lua's `table.sort` is not stable, so this
// bites an implementation that sorts instead of inserting in place.
let mut s = editor_with("");
exec(
&s,
r#"
_G.order = {}
local function mark(tag)
return function() _G.order[#_G.order + 1] = tag; return false end
end
pmacs.typed_edit.add_consumer { name = "first", priority = 5, fn = mark("first") }
pmacs.typed_edit.add_consumer { name = "second", priority = 5, fn = mark("second") }
pmacs.typed_edit.add_consumer { name = "third", priority = 5, fn = mark("third") }
"#,
);
type_str(&mut s, "x");
let order: String = eval(&s, "return table.concat(_G.order, ',')");
assert_eq!(order, "first,second,third");
}
// ---------------------------------------------------------------------------
// 46c — a claim stops the chain
// ---------------------------------------------------------------------------
#[test]
fn a_claiming_consumer_stops_the_chain() {
let mut s = editor_with("");
exec(
&s,
r#"
_G.later_ran = false
pmacs.typed_edit.add_consumer {
name = "claimer", priority = 1, fn = function() return true end,
}
pmacs.typed_edit.add_consumer {
name = "later", priority = 2,
fn = function() _G.later_ran = true; return false end,
}
"#,
);
type_str(&mut s, "(");
let later_ran: bool = eval(&s, "return _G.later_ran");
assert!(!later_ran, "a later consumer must not run after a claim");
// Pairing is the chain's last consumer at priority 100, so the
// claim is observable in the buffer: no closer was inserted. This
// is the assertion that makes the criterion about behavior rather
// than about a bookkeeping flag.
assert_eq!(
buffer_text(&s),
"(",
"auto-pairing never ran, so the opener stands alone"
);
}
#[test]
fn a_non_claiming_consumer_does_not_stop_the_chain() {
let mut s = editor_with("");
exec(
&s,
r#"
_G.later_ran = false
pmacs.typed_edit.add_consumer {
name = "passer", priority = 1, fn = function() return false end,
}
pmacs.typed_edit.add_consumer {
name = "later", priority = 2,
fn = function() _G.later_ran = true; return false end,
}
"#,
);
type_str(&mut s, "(");
let later_ran: bool = eval(&s, "return _G.later_ran");
assert!(later_ran, "a declining consumer passes the edit along");
assert_eq!(
buffer_text(&s),
"()",
"and pairing, still last in the chain, reacted normally"
);
}
// ---------------------------------------------------------------------------
// 46d — a throwing consumer is contained
// ---------------------------------------------------------------------------
#[test]
fn a_throwing_consumer_is_contained_reported_and_does_not_stop_the_chain() {
let mut s = editor_with("");
exec(
&s,
r#"
_G.later_ran = false
pmacs.typed_edit.add_consumer {
name = "boom", priority = 1,
fn = function() error("consumer exploded") end,
}
pmacs.typed_edit.add_consumer {
name = "later", priority = 2,
fn = function() _G.later_ran = true; return false end,
}
"#,
);
// `buffer.after-edit` is all-must-succeed: an uncontained throw
// would fail the fan-out for every other subscriber, including
// lsp.lua's didChange flush.
type_str(&mut s, "(");
let later_ran: bool = eval(&s, "return _G.later_ran");
assert!(later_ran, "a throwing consumer must not stop the chain");
assert_eq!(
buffer_text(&s),
"()",
"and pairing still ran — the fan-out survived the throw"
);
let st = status(&s);
assert!(
st.contains("boom") && st.contains("consumer exploded"),
"the failure is reported by consumer name and message, got {st:?}"
);
}
#[test]
fn add_consumer_rejects_malformed_registrations() {
let s = editor_with("");
for (src, want) in [
(
"pmacs.typed_edit.add_consumer(\"nope\")",
"spec must be a table",
),
(
"pmacs.typed_edit.add_consumer{ priority = 1, fn = function() end }",
"name must be a non-empty string",
),
(
"pmacs.typed_edit.add_consumer{ name = \"n\", fn = function() end }",
"priority must be a number",
),
(
"pmacs.typed_edit.add_consumer{ name = \"n\", priority = 1 }",
"fn must be a function",
),
] {
let err = s
.lua_host
.lua()
.load(src.to_string())
.exec()
.expect_err("malformed registration must throw");
let msg = err.to_string();
assert!(
msg.contains(want),
"expected {want:?} in the error for {src:?}, got {msg:?}"
);
}
}
// ---------------------------------------------------------------------------
// 46e — the Q#AP7 flush ordering the chain inherited
// ---------------------------------------------------------------------------
fn fake_lsp_path() -> String {
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
}
fn pump_lua_flag(state: &mut EditorState, flag: &str, secs: u64) -> bool {
let deadline = Instant::now() + Duration::from_secs(secs);
loop {
state.tick_processes();
state.tick_lsp();
state.tick_async();
let done: bool = state
.lua_host
.lua()
.load(format!("return ({flag}) == true"))
.eval()
.unwrap_or(false);
if done {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(10));
}
}
/// The `text` of every `textDocument/didChange` line in the sink, in
/// arrival order.
fn did_change_texts(sink: &std::path::Path) -> Vec<String> {
let Ok(raw) = std::fs::read_to_string(sink) else {
return Vec::new();
};
raw.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|v| v.get("method").and_then(|m| m.as_str()) == Some("textDocument/didChange"))
.filter_map(|v| v.get("text").and_then(|t| t.as_str()).map(str::to_owned))
.collect()
}
#[test]
fn a_chain_consumers_edit_reaches_the_first_did_change() {
// Q#AP7 generalized from pairing to the chain: lsp.lua's after-edit
// callback flushes didChange SYNCHRONOUSLY on the signature-trigger
// path, so every reaction to a typed character must already be in
// the buffer when it runs. The auto-pairing suite pins this for
// pairing; this pins it for the chain itself, which is what now
// owns the registration position.
//
// Falsified by loading typed_edit.lua after lsp.lua in
// `src/editor.rs`: the consumer's text would then arrive in the
// SECOND didChange, or not at all.
let dir = fresh_state_dir();
let sink = dir.join("changes.jsonl");
let sink_disp = sink.display().to_string();
let fake = fake_lsp_path();
let mut s = EditorState::new();
s.lua_host.lua().remove_app_data::<StateDir>();
s.lua_host.lua().set_app_data(StateDir(dir.clone()));
exec(&s, "pmacs.lsp.config = {}");
exec(
&s,
&format!(
"pmacs.lsp.config.rust = {{
command = '{fake}',
env = {{
PMACS_FAKE_LSP_MODE = 'sighelp',
PMACS_FAKE_LSP_CHANGE_SINK = '{sink_disp}',
}},
}}"
),
);
// A consumer that appends a marker of its own, ahead of pairing.
// It declines the claim so pairing still runs — the assertion is
// about ordering against the flush, not about claiming.
exec(
&s,
r#"
pmacs.typed_edit.add_consumer {
name = "marker", priority = 1,
fn = function(rec)
if not rec then return false end
if rec.char ~= "(" then return false end
local buf = pmacs.window.buffer()
buf:insert(buf:len(), "Z")
return false
end,
}
"#,
);
let f = dir.join("a.rs");
std::fs::write(&f, "\n").unwrap();
let fd = f.display().to_string();
exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
let initialized = "(function() \
for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then return true end \
end \
return false \
end)()";
assert!(pump_lua_flag(&mut s, initialized, 5), "fake server init");
type_str(&mut s, "(");
assert_eq!(
buffer_text(&s),
"()\nZ",
"both the chain consumer's marker and pairing's closer landed"
);
let deadline = Instant::now() + Duration::from_secs(5);
let changes = loop {
s.tick_processes();
s.tick_lsp();
s.tick_async();
let c = did_change_texts(&sink);
if !c.is_empty() {
break c;
}
assert!(
Instant::now() < deadline,
"no didChange reached the fake server"
);
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(
changes[0], "()\nZ",
"the FIRST didChange carries BOTH reactions — the chain ran \
before lsp.lua's synchronous flush (Q#AP7)"
);
}