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:
parent
174e36fce3
commit
24ca906294
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,517 @@
|
|||
//! Typed-edit consumer chain acceptance (Arc 8 Stage 4a,
|
||||
//! docs/lean4-mode-framing.md Q#LN10, criteria 46a–46e).
|
||||
//!
|
||||
//! 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)"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue