Merge pull request #111 from levineuwirth/editops
feat(edit): editing-conveniences pack (editops)
This commit is contained in:
commit
f0a05c53af
|
|
@ -0,0 +1,893 @@
|
|||
-- editops.lua --- the editing-conveniences pack.
|
||||
--
|
||||
-- goto-line, case ops, transpose, zap-to-char (a kill-chain member;
|
||||
-- killring owns the ring surface), line move/duplicate/join, region
|
||||
-- sort/reverse/dedupe, and delete-trailing-whitespace with an opt-in
|
||||
-- trim-on-save hook. Framing: docs/editing-conveniences-framing.md
|
||||
-- (Q#EC1..Q#EC10).
|
||||
--
|
||||
-- House disciplines this file leans on:
|
||||
-- * Q#EC2: every text change is ONE pcall'd mutator (one undo
|
||||
-- step), snapshot-guarded (intercepts may switch window/buffer),
|
||||
-- exact-effective-triple checked, with right-gravity cursor
|
||||
-- translation on transformed edits and an UNCONDITIONAL
|
||||
-- selection clear after any landed edit (a dormant zero-length
|
||||
-- anchor re-activates on cursor motion).
|
||||
-- * Locale hygiene: explicit byte ranges and byte comparators
|
||||
-- only — string.upper/lower, the default string `<`, and the
|
||||
-- %l/%u/%w/%s/%d pattern classes are ctype/strcoll-backed on the
|
||||
-- Lua 5.4 backend and shift under os.setlocale.
|
||||
-- * Word class: ASCII [A-Za-z0-9_] (the word_at_cursor precedent;
|
||||
-- narrower than the Unicode motion class — named limitation).
|
||||
|
||||
pmacs.editops = pmacs.editops or {}
|
||||
|
||||
local ed = pmacs.editor
|
||||
|
||||
local CHUNK = 4096
|
||||
|
||||
-- ---- byte classes (explicit ranges only) ---------------------------
|
||||
|
||||
local function is_word_byte(b)
|
||||
return (b >= 48 and b <= 57) -- 0-9
|
||||
or (b >= 65 and b <= 90) -- A-Z
|
||||
or (b >= 97 and b <= 122) -- a-z
|
||||
or b == 95 -- _
|
||||
end
|
||||
|
||||
local function not_word_byte(b)
|
||||
return not is_word_byte(b)
|
||||
end
|
||||
|
||||
local function is_nl_byte(b)
|
||||
return b == 10
|
||||
end
|
||||
|
||||
local function is_ws_byte(b)
|
||||
return b == 32 or b == 9
|
||||
end
|
||||
|
||||
-- ---- chunked scans (giant-line safety; the kill_line precedent) ----
|
||||
|
||||
-- First index >= from (< len) whose byte satisfies pred, or nil.
|
||||
local function scan_forward(buf, from, len, pred)
|
||||
local p = from
|
||||
while p < len do
|
||||
local stop = math.min(p + CHUNK, len)
|
||||
local chunk = buf:slice(p, stop)
|
||||
for i = 1, #chunk do
|
||||
if pred(chunk:byte(i)) then return p + i - 1 end
|
||||
end
|
||||
p = stop
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- First index < from (>= 0) whose byte satisfies pred, scanning
|
||||
-- toward 0, or nil.
|
||||
local function scan_back(buf, from, pred)
|
||||
local p = from
|
||||
while p > 0 do
|
||||
local start = math.max(p - CHUNK, 0)
|
||||
local chunk = buf:slice(start, p)
|
||||
for i = #chunk, 1, -1 do
|
||||
if pred(chunk:byte(i)) then return start + i - 1 end
|
||||
end
|
||||
p = start
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- First occurrence of `needle` (plain bytes) at index >= from, or
|
||||
-- nil. Windows overlap by #needle - 1 so a match spanning a chunk
|
||||
-- boundary is seen whole by the next window.
|
||||
local function find_forward(buf, from, len, needle)
|
||||
local n = #needle
|
||||
local p = from
|
||||
while p < len do
|
||||
local stop = math.min(p + CHUNK, len)
|
||||
local wstop = math.min(stop + n - 1, len)
|
||||
local chunk = buf:slice(p, wstop)
|
||||
local i = chunk:find(needle, 1, true)
|
||||
if i then return p + i - 1 end
|
||||
p = stop
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- bol/eol of the line containing `pos` (eol = index of its newline,
|
||||
-- or len for a final bare line; a cursor sitting ON the newline
|
||||
-- belongs to the line it terminates).
|
||||
local function line_bounds(buf, pos, len)
|
||||
local bol = (scan_back(buf, pos, is_nl_byte) or -1) + 1
|
||||
local eol = scan_forward(buf, pos, len, is_nl_byte) or len
|
||||
return bol, eol
|
||||
end
|
||||
|
||||
-- ---- UTF-8 (codepoint-exact commands fail closed) ------------------
|
||||
|
||||
local function is_cont_byte(b)
|
||||
return b >= 0x80 and b <= 0xBF
|
||||
end
|
||||
|
||||
-- Byte-length of the VALID UTF-8 scalar starting at index `i` of
|
||||
-- `s`, or nil. Enforces the full second-byte constraint table —
|
||||
-- overlong encodings (C0/C1, E0 80..9F, F0 80..8F), surrogates
|
||||
-- (ED A0..BF), and > U+10FFFF (F4 90..BF, F5..FF) all fail, not
|
||||
-- just bad lead/continuation ranges. Every consumer of "one
|
||||
-- codepoint" routes through here so malformed bytes fail closed.
|
||||
local function scalar_len(s, i)
|
||||
local b1 = s:byte(i)
|
||||
if not b1 then return nil end
|
||||
if b1 < 0x80 then return 1 end
|
||||
local b2 = s:byte(i + 1)
|
||||
if not b2 or not is_cont_byte(b2) then return nil end
|
||||
if b1 >= 0xC2 and b1 <= 0xDF then
|
||||
return 2
|
||||
end
|
||||
if b1 >= 0xE0 and b1 <= 0xEF then
|
||||
if b1 == 0xE0 and b2 < 0xA0 then return nil end
|
||||
if b1 == 0xED and b2 > 0x9F then return nil end
|
||||
local b3 = s:byte(i + 2)
|
||||
if not b3 or not is_cont_byte(b3) then return nil end
|
||||
return 3
|
||||
end
|
||||
if b1 >= 0xF0 and b1 <= 0xF4 then
|
||||
if b1 == 0xF0 and b2 < 0x90 then return nil end
|
||||
if b1 == 0xF4 and b2 > 0x8F then return nil end
|
||||
local b3 = s:byte(i + 2)
|
||||
if not b3 or not is_cont_byte(b3) then return nil end
|
||||
local b4 = s:byte(i + 3)
|
||||
if not b4 or not is_cont_byte(b4) then return nil end
|
||||
return 4
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function single_codepoint(s)
|
||||
return #s > 0 and scalar_len(s, 1) == #s
|
||||
end
|
||||
|
||||
-- Start of the codepoint ending just before `pos`. Returns nil at
|
||||
-- BOB; nil, "malformed" when the preceding bytes are not one valid
|
||||
-- UTF-8 scalar (fail closed — goto_byte guarantees no boundary
|
||||
-- alignment, and a length-consistent overlong/surrogate span must
|
||||
-- not be treated as a character).
|
||||
local function prev_cp_start(buf, pos)
|
||||
if pos <= 0 then return nil end
|
||||
local q = pos - 1
|
||||
local steps = 0
|
||||
while q > 0 and steps < 3 do
|
||||
local b = buf:slice(q, q + 1):byte(1)
|
||||
if not is_cont_byte(b) then break end
|
||||
q = q - 1
|
||||
steps = steps + 1
|
||||
end
|
||||
local span = buf:slice(q, pos)
|
||||
if scalar_len(span, 1) ~= pos - q then return nil, "malformed" end
|
||||
return q
|
||||
end
|
||||
|
||||
-- ---- ASCII case maps (Q#EC4; never string.upper/lower) -------------
|
||||
|
||||
local function ascii_upper(s)
|
||||
return (s:gsub("[a-z]", function(c)
|
||||
return string.char(c:byte() - 32)
|
||||
end))
|
||||
end
|
||||
|
||||
local function ascii_lower(s)
|
||||
return (s:gsub("[A-Z]", function(c)
|
||||
return string.char(c:byte() + 32)
|
||||
end))
|
||||
end
|
||||
|
||||
-- Per-word capitalize across the span (Emacs capitalize-region
|
||||
-- parity, verified against Emacs 30.2: "hello WORLD" -> "Hello
|
||||
-- World", "9abc" -> "9abc", "a9bc" -> "A9bc"): each word's first
|
||||
-- byte is upcased when it is a letter, every other letter is
|
||||
-- downcased. Words per the pack's ASCII class — `_` and digits are
|
||||
-- word constituents here, so "foo_bar" gives "Foo_bar" where
|
||||
-- Emacs's symbol-syntax `_` gives "Foo_Bar" (named deviation,
|
||||
-- Q#EC4).
|
||||
local function ascii_capitalize(s)
|
||||
local out = {}
|
||||
local in_word = false
|
||||
for i = 1, #s do
|
||||
local b = s:byte(i)
|
||||
local w = is_word_byte(b)
|
||||
if w and not in_word then
|
||||
if b >= 97 and b <= 122 then b = b - 32 end
|
||||
elseif w then
|
||||
if b >= 65 and b <= 90 then b = b + 32 end
|
||||
end
|
||||
in_word = w
|
||||
out[#out + 1] = string.char(b)
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- ---- Q#EC2 shared discipline ---------------------------------------
|
||||
|
||||
-- Right-gravity translation of `pos` through an effective edit (the
|
||||
-- indent.lua formula; estop is the PRE-edit end of the range).
|
||||
local function translate(pos, estart, estop, einserted)
|
||||
if pos < estart then return pos end
|
||||
if pos > estop then return pos - (estop - estart) + einserted end
|
||||
return estart + einserted
|
||||
end
|
||||
|
||||
-- One guarded replace: snapshot -> mutate -> exact verify -> context
|
||||
-- guard -> cursor (command target on clean, translate+clamp on
|
||||
-- transformed) -> unconditional selection clear. Returns "clean" |
|
||||
-- "rejected" | "transformed" | "context".
|
||||
local function guarded_replace(name, buf, rstart, rstop, text, clean_target)
|
||||
local win0 = pmacs.window.current()
|
||||
local cursor0 = ed.cursor()
|
||||
local ok, estart, estop, einserted = pcall(function()
|
||||
return buf:replace(rstart, rstop, text)
|
||||
end)
|
||||
if not ok then
|
||||
ed.set_status(name .. " rejected by buffer intercept")
|
||||
return "rejected"
|
||||
end
|
||||
if pmacs.window.current() ~= win0 or pmacs.window.buffer() ~= buf then
|
||||
ed.set_status(name .. ": context changed during edit")
|
||||
return "context"
|
||||
end
|
||||
if estart == rstart and estop == rstop and einserted == #text then
|
||||
ed.goto_byte(clean_target)
|
||||
ed.clear_selection()
|
||||
return "clean"
|
||||
end
|
||||
ed.set_status(name .. " altered by buffer intercept")
|
||||
ed.goto_byte(translate(cursor0, estart, estop, einserted))
|
||||
ed.clear_selection()
|
||||
return "transformed"
|
||||
end
|
||||
|
||||
-- ---- goto-line (Q#EC3) ---------------------------------------------
|
||||
|
||||
pmacs.command.define {
|
||||
name = "cursor.goto-line",
|
||||
description = "Go to a line by number (1-based; clamps to the buffer).",
|
||||
fn = function()
|
||||
local origin_fid = pmacs.frontend.id()
|
||||
pmacs.minibuffer.read {
|
||||
prompt = "Goto line: ",
|
||||
history = "goto-line",
|
||||
on_accept = function(input)
|
||||
-- Milder origin guard (Q#EC6 tail): a prompt completed by
|
||||
-- another frontend must not move THAT frontend's cursor.
|
||||
if pmacs.frontend.id() ~= origin_fid then
|
||||
ed.set_status("goto-line: prompt origin changed; ignored")
|
||||
return
|
||||
end
|
||||
local cap = tostring(input or ""):match("^[ \t]*([0-9]+)[ \t]*$")
|
||||
if not cap then
|
||||
ed.set_status("goto-line: enter a line number")
|
||||
return
|
||||
end
|
||||
-- Validate and bound BEFORE any state change: "0" clamps to
|
||||
-- line 1 (Emacs); the cap keeps huge decimals inside what the
|
||||
-- binding's integer conversion accepts (tonumber overflows to
|
||||
-- float; math.min returns the integer cap).
|
||||
local n = math.max(1, math.min(tonumber(cap), 0x80000000))
|
||||
ed.push_jump()
|
||||
ed.move_to_line(n - 1)
|
||||
end,
|
||||
}
|
||||
end,
|
||||
}
|
||||
|
||||
-- ---- case ops (Q#EC4) ----------------------------------------------
|
||||
|
||||
local function case_command(name, label, xform)
|
||||
pmacs.command.define {
|
||||
name = name,
|
||||
description = label .. " the region, or the word from the cursor.",
|
||||
fn = function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
ed.set_status(name .. ": no buffer")
|
||||
return
|
||||
end
|
||||
local len = buf:len()
|
||||
local region = ed.region()
|
||||
local rstart, rstop
|
||||
if region and region["end"] > region.start then
|
||||
rstart, rstop = region.start, region["end"]
|
||||
else
|
||||
-- First word byte at-or-after the cursor, through that
|
||||
-- word's end (Emacs mid-word remainder semantics).
|
||||
local ws = scan_forward(buf, ed.cursor(), len, is_word_byte)
|
||||
if not ws then
|
||||
ed.set_status(name .. ": no word after the cursor")
|
||||
return
|
||||
end
|
||||
rstart = ws
|
||||
rstop = scan_forward(buf, ws + 1, len, not_word_byte) or len
|
||||
end
|
||||
local text = buf:slice(rstart, rstop)
|
||||
local new = xform(text)
|
||||
if new == text then
|
||||
-- Identity: no edit (no undo step, no CRDT op) — but the
|
||||
-- cursor still travels and the anchor rule still applies.
|
||||
ed.goto_byte(rstop)
|
||||
ed.clear_selection()
|
||||
return
|
||||
end
|
||||
guarded_replace(name, buf, rstart, rstop, new, rstop)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
case_command("edit.upcase", "Upcase", ascii_upper)
|
||||
case_command("edit.downcase", "Downcase", ascii_lower)
|
||||
case_command("edit.capitalize", "Capitalize", ascii_capitalize)
|
||||
|
||||
-- ---- transpose (Q#EC5) ---------------------------------------------
|
||||
|
||||
pmacs.command.define {
|
||||
name = "edit.transpose-chars",
|
||||
description = "Swap the characters around the cursor (C-t).",
|
||||
fn = function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
ed.set_status("transpose-chars: no buffer")
|
||||
return
|
||||
end
|
||||
local len = buf:len()
|
||||
local cursor = ed.cursor()
|
||||
local at_b = nil
|
||||
if cursor < len then
|
||||
at_b = buf:slice(cursor, cursor + 1):byte(1)
|
||||
if is_cont_byte(at_b) then
|
||||
ed.set_status("transpose-chars: cursor is inside a multi-byte character")
|
||||
return
|
||||
end
|
||||
end
|
||||
if cursor >= len or at_b == 10 then
|
||||
-- EOL/EOF: swap the two codepoints BEFORE the cursor (Emacs
|
||||
-- special case); cursor stays put.
|
||||
local s2, why2 = prev_cp_start(buf, cursor)
|
||||
if not s2 then
|
||||
ed.set_status(why2 == "malformed"
|
||||
and "transpose-chars: malformed UTF-8 before the cursor"
|
||||
or "transpose-chars: not enough characters")
|
||||
return
|
||||
end
|
||||
local s1, why1 = prev_cp_start(buf, s2)
|
||||
if not s1 then
|
||||
ed.set_status(why1 == "malformed"
|
||||
and "transpose-chars: malformed UTF-8 before the cursor"
|
||||
or "transpose-chars: not enough characters")
|
||||
return
|
||||
end
|
||||
local cp1 = buf:slice(s1, s2)
|
||||
local cp2 = buf:slice(s2, cursor)
|
||||
guarded_replace("transpose-chars", buf, s1, cursor, cp2 .. cp1, cursor)
|
||||
else
|
||||
-- Swap the codepoints before and at the cursor; cursor ends
|
||||
-- after both (Emacs drag-forward).
|
||||
local s1, why = prev_cp_start(buf, cursor)
|
||||
if not s1 then
|
||||
ed.set_status(why == "malformed"
|
||||
and "transpose-chars: malformed UTF-8 before the cursor"
|
||||
or "transpose-chars: not enough characters")
|
||||
return
|
||||
end
|
||||
-- Validate the WHOLE scalar at the cursor, trailing bytes
|
||||
-- included — a valid lead followed by non-continuation bytes
|
||||
-- must fail closed, not ride along as "one character".
|
||||
local head = buf:slice(cursor, math.min(cursor + 4, len))
|
||||
local n2 = scalar_len(head, 1)
|
||||
if not n2 then
|
||||
ed.set_status("transpose-chars: malformed UTF-8 at the cursor")
|
||||
return
|
||||
end
|
||||
local cp1 = buf:slice(s1, cursor)
|
||||
local cp2 = head:sub(1, n2)
|
||||
guarded_replace("transpose-chars", buf, s1, cursor + n2, cp2 .. cp1,
|
||||
cursor + n2)
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "edit.transpose-words",
|
||||
description = "Swap the words around the cursor (M-t).",
|
||||
fn = function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
ed.set_status("transpose-words: no buffer")
|
||||
return
|
||||
end
|
||||
local len = buf:len()
|
||||
local cursor = ed.cursor()
|
||||
-- W1 = the word containing the last word byte at-or-before
|
||||
-- cursor-1 (covers strictly-inside, at-word-start, and separator
|
||||
-- positions — the Emacs 30.2 table); fallback: the first word
|
||||
-- at-or-after the cursor (BOB / leading separators).
|
||||
local s1, e1
|
||||
local j = cursor > 0 and scan_back(buf, cursor, is_word_byte) or nil
|
||||
if j then
|
||||
s1 = (scan_back(buf, j, not_word_byte) or -1) + 1
|
||||
e1 = scan_forward(buf, j + 1, len, not_word_byte) or len
|
||||
else
|
||||
local ws = scan_forward(buf, cursor, len, is_word_byte)
|
||||
if not ws then
|
||||
ed.set_status("transpose-words: no words to transpose")
|
||||
return
|
||||
end
|
||||
s1 = ws
|
||||
e1 = scan_forward(buf, ws + 1, len, not_word_byte) or len
|
||||
end
|
||||
-- W2 = the first word strictly after W1. Missing -> status, no
|
||||
-- edit, NO cursor motion (Emacs errors and moves point; the
|
||||
-- point motion is a wart we don't copy).
|
||||
local s2 = scan_forward(buf, e1, len, is_word_byte)
|
||||
if not s2 then
|
||||
ed.set_status("transpose-words: no following word to transpose")
|
||||
return
|
||||
end
|
||||
local e2 = scan_forward(buf, s2 + 1, len, not_word_byte) or len
|
||||
local w1 = buf:slice(s1, e1)
|
||||
local sep = buf:slice(e1, s2)
|
||||
local w2 = buf:slice(s2, e2)
|
||||
-- Cursor: end of the replaced span — after W1 in its NEW position.
|
||||
guarded_replace("transpose-words", buf, s1, e2, w2 .. sep .. w1, e2)
|
||||
end,
|
||||
}
|
||||
|
||||
-- ---- zap (Q#EC6; ring surface owned by killring) -------------------
|
||||
|
||||
local function zap_command(cmd_name, label, prompt, up_to)
|
||||
pmacs.command.define {
|
||||
name = cmd_name,
|
||||
description = label,
|
||||
fn = function()
|
||||
local origin_fid = pmacs.frontend.id()
|
||||
pmacs.killring.arm_kill_prompt()
|
||||
pmacs.minibuffer.read {
|
||||
prompt = prompt,
|
||||
history = "zap-char",
|
||||
on_accept = function(input)
|
||||
-- Origin guard: the session is global, boundaries are
|
||||
-- per-frontend, and pointer input breaks the boundary
|
||||
-- without closing the prompt. Both checks or no kill.
|
||||
if pmacs.frontend.id() ~= origin_fid
|
||||
or ed.this_command() ~= cmd_name then
|
||||
pmacs.killring.break_chain(origin_fid)
|
||||
ed.set_status("zap: prompt origin changed; aborted")
|
||||
return
|
||||
end
|
||||
input = tostring(input or "")
|
||||
if not single_codepoint(input) then
|
||||
pmacs.killring.break_chain(origin_fid)
|
||||
ed.set_status("zap: type a single character")
|
||||
return
|
||||
end
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
pmacs.killring.break_chain(origin_fid)
|
||||
ed.set_status("zap: no buffer")
|
||||
return
|
||||
end
|
||||
local len = buf:len()
|
||||
local cursor = ed.cursor()
|
||||
local p = find_forward(buf, cursor, len, input)
|
||||
if not p then
|
||||
pmacs.killring.break_chain(origin_fid)
|
||||
ed.set_status("zap: no '" .. input .. "' after the cursor")
|
||||
return
|
||||
end
|
||||
local stop = up_to and p or (p + #input)
|
||||
if stop <= cursor then
|
||||
pmacs.killring.break_chain(origin_fid)
|
||||
ed.set_status("zap: already at '" .. input .. "'")
|
||||
return
|
||||
end
|
||||
-- Q#EC2 snapshot around the killring-owned delete.
|
||||
local win0 = pmacs.window.current()
|
||||
local cursor0 = cursor
|
||||
-- A consumed marker means the armed state is no longer
|
||||
-- trustworthy (public Lua touched it mid-prompt): fail
|
||||
-- closed, no kill.
|
||||
if not pmacs.killring.commit_kill_prompt() then
|
||||
pmacs.killring.break_chain(origin_fid)
|
||||
ed.set_status("zap: prompt state consumed; aborted")
|
||||
return
|
||||
end
|
||||
local ok, kind, estart, estop, eins =
|
||||
pmacs.killring.kill_range(cursor, stop)
|
||||
if pmacs.window.current() ~= win0
|
||||
or pmacs.window.buffer() ~= buf then
|
||||
ed.set_status("zap: context changed during edit")
|
||||
return
|
||||
end
|
||||
if ok then
|
||||
-- Clean: the delete starts at the cursor; re-seat
|
||||
-- explicitly and apply the unconditional anchor clear.
|
||||
ed.goto_byte(cursor0)
|
||||
ed.clear_selection()
|
||||
elseif kind == "transformed" then
|
||||
ed.goto_byte(translate(cursor0, estart, estop, eins))
|
||||
ed.clear_selection()
|
||||
end
|
||||
-- rejected: nothing landed; kill_range reported and broke
|
||||
-- the chain; no fix-up.
|
||||
end,
|
||||
on_cancel = function()
|
||||
-- Targeted: whichever frontend cancels, the ORIGIN's chain
|
||||
-- must break (its this_command is still the zap).
|
||||
pmacs.killring.break_chain(origin_fid)
|
||||
end,
|
||||
}
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
zap_command("edit.zap-to-char", "Kill through the next occurrence of a character.",
|
||||
"Zap to char: ", false)
|
||||
zap_command("edit.zap-up-to-char", "Kill up to (excluding) the next occurrence of a character.",
|
||||
"Zap up to char: ", true)
|
||||
|
||||
-- ---- line ops (Q#EC7; plain byte moves, never indentation) ---------
|
||||
|
||||
pmacs.command.define {
|
||||
name = "edit.move-line-down",
|
||||
description = "Swap the cursor line with the line below.",
|
||||
fn = function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
ed.set_status("move-line-down: no buffer")
|
||||
return
|
||||
end
|
||||
local len = buf:len()
|
||||
local cursor = ed.cursor()
|
||||
local bol, eol = line_bounds(buf, cursor, len)
|
||||
if eol >= len then
|
||||
ed.set_status("move-line-down: already at the last line")
|
||||
return
|
||||
end
|
||||
local nbol = eol + 1
|
||||
local neol = scan_forward(buf, nbol, len, is_nl_byte) or len
|
||||
local cur = buf:slice(bol, eol)
|
||||
local nxt = buf:slice(nbol, neol)
|
||||
local col = math.min(cursor - bol, #cur)
|
||||
guarded_replace("move-line-down", buf, bol, neol, nxt .. "\n" .. cur,
|
||||
bol + #nxt + 1 + col)
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "edit.move-line-up",
|
||||
description = "Swap the cursor line with the line above.",
|
||||
fn = function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
ed.set_status("move-line-up: no buffer")
|
||||
return
|
||||
end
|
||||
local len = buf:len()
|
||||
local cursor = ed.cursor()
|
||||
local bol, eol = line_bounds(buf, cursor, len)
|
||||
if bol == 0 then
|
||||
ed.set_status("move-line-up: already at the first line")
|
||||
return
|
||||
end
|
||||
local peol = bol - 1
|
||||
local pbol = (scan_back(buf, peol, is_nl_byte) or -1) + 1
|
||||
local cur = buf:slice(bol, eol)
|
||||
local prev = buf:slice(pbol, peol)
|
||||
local col = math.min(cursor - bol, #cur)
|
||||
guarded_replace("move-line-up", buf, pbol, eol, cur .. "\n" .. prev,
|
||||
pbol + col)
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "edit.duplicate-line",
|
||||
description = "Insert a copy of the cursor line below it.",
|
||||
fn = function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
ed.set_status("duplicate-line: no buffer")
|
||||
return
|
||||
end
|
||||
local len = buf:len()
|
||||
local cursor = ed.cursor()
|
||||
local bol, eol = line_bounds(buf, cursor, len)
|
||||
local line = buf:slice(bol, eol)
|
||||
local col = math.min(cursor - bol, #line)
|
||||
-- Insert "\n"..line AT the line's end: works uniformly for a
|
||||
-- middle line and a final line without a trailing newline.
|
||||
guarded_replace("duplicate-line", buf, eol, eol, "\n" .. line,
|
||||
eol + 1 + col)
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "edit.join-line",
|
||||
description = "Join the cursor line onto the previous line (M-^).",
|
||||
fn = function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
ed.set_status("join-line: no buffer")
|
||||
return
|
||||
end
|
||||
local len = buf:len()
|
||||
local cursor = ed.cursor()
|
||||
local bol, eol = line_bounds(buf, cursor, len)
|
||||
if bol == 0 then
|
||||
ed.set_status("join-line: already at the first line")
|
||||
return
|
||||
end
|
||||
local peol = bol - 1
|
||||
local pbol = (scan_back(buf, peol, is_nl_byte) or -1) + 1
|
||||
-- Junction: prev line's trailing whitespace + the newline + the
|
||||
-- current line's leading whitespace, replaced by one space — or
|
||||
-- nothing when either side of the junction is empty.
|
||||
local tws = peol
|
||||
while tws > pbol do
|
||||
local b = buf:slice(tws - 1, tws):byte(1)
|
||||
if is_ws_byte(b) then tws = tws - 1 else break end
|
||||
end
|
||||
local lwe = bol
|
||||
while lwe < eol do
|
||||
local b = buf:slice(lwe, lwe + 1):byte(1)
|
||||
if is_ws_byte(b) then lwe = lwe + 1 else break end
|
||||
end
|
||||
local prev_empty = tws == pbol
|
||||
local cur_empty = lwe == eol
|
||||
local sep = (prev_empty or cur_empty) and "" or " "
|
||||
guarded_replace("join-line", buf, tws, lwe, sep, tws)
|
||||
end,
|
||||
}
|
||||
|
||||
-- ---- region line ops (Q#EC8) ---------------------------------------
|
||||
|
||||
-- Byte-wise line order: never the default string `<` (strcoll).
|
||||
local function byte_lt(a, b)
|
||||
local la, lb = #a, #b
|
||||
local n = la < lb and la or lb
|
||||
for i = 1, n do
|
||||
local ba, bb = a:byte(i), b:byte(i)
|
||||
if ba ~= bb then return ba < bb end
|
||||
end
|
||||
return la < lb
|
||||
end
|
||||
|
||||
local function region_lines_command(name, label, transform)
|
||||
pmacs.command.define {
|
||||
name = name,
|
||||
description = label,
|
||||
fn = function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
ed.set_status(name .. ": no buffer")
|
||||
return
|
||||
end
|
||||
local region = ed.region()
|
||||
if not region or region["end"] <= region.start then
|
||||
ed.set_status(name .. ": no active region (select the lines first)")
|
||||
return
|
||||
end
|
||||
local len = buf:len()
|
||||
-- Whole-line expansion: BOL of the start line through EOL of
|
||||
-- the line containing region.end - 1 (a region ending exactly
|
||||
-- at a BOL excludes that line), newline included when present.
|
||||
local lstart = (scan_back(buf, region.start, is_nl_byte) or -1) + 1
|
||||
local eol = scan_forward(buf, region["end"] - 1, len, is_nl_byte)
|
||||
local lend = eol and (eol + 1) or len
|
||||
local text = buf:slice(lstart, lend)
|
||||
local had_nl = text:sub(-1) == "\n"
|
||||
local body = had_nl and text:sub(1, -2) or text
|
||||
local lines = {}
|
||||
local pos = 1
|
||||
while true do
|
||||
local nl = body:find("\n", pos, true)
|
||||
if nl then
|
||||
lines[#lines + 1] = body:sub(pos, nl - 1)
|
||||
pos = nl + 1
|
||||
else
|
||||
lines[#lines + 1] = body:sub(pos)
|
||||
break
|
||||
end
|
||||
end
|
||||
local newlines, info = transform(lines)
|
||||
if info then ed.set_status(info) end
|
||||
local newbody = table.concat(newlines, "\n") .. (had_nl and "\n" or "")
|
||||
if newbody == text then
|
||||
-- Identity: no edit, no undo step; anchor rule still applies.
|
||||
ed.goto_byte(lstart)
|
||||
ed.clear_selection()
|
||||
return
|
||||
end
|
||||
guarded_replace(name, buf, lstart, lend, newbody, lstart)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
region_lines_command("edit.sort-lines", "Sort the selected lines (byte order).",
|
||||
function(lines)
|
||||
local out = {}
|
||||
for i, l in ipairs(lines) do out[i] = l end
|
||||
table.sort(out, byte_lt)
|
||||
return out
|
||||
end)
|
||||
|
||||
region_lines_command("edit.reverse-lines", "Reverse the order of the selected lines.",
|
||||
function(lines)
|
||||
local out = {}
|
||||
for i = #lines, 1, -1 do out[#out + 1] = lines[i] end
|
||||
return out
|
||||
end)
|
||||
|
||||
region_lines_command("edit.delete-duplicate-lines",
|
||||
"Delete duplicate lines in the region, keeping first occurrences.",
|
||||
function(lines)
|
||||
local seen, out = {}, {}
|
||||
for _, l in ipairs(lines) do
|
||||
if not seen[l] then
|
||||
seen[l] = true
|
||||
out[#out + 1] = l
|
||||
end
|
||||
end
|
||||
return out, string.format("delete-duplicate-lines: %d removed",
|
||||
#lines - #out)
|
||||
end)
|
||||
|
||||
-- ---- delete-trailing-whitespace (Q#EC9) ----------------------------
|
||||
|
||||
-- Trailing ' '/'\t' runs, one {start, stop, line} per affected line,
|
||||
-- ascending. Applied bottom-up so earlier deletes never shift later
|
||||
-- targets.
|
||||
local function collect_trailing(buf, len)
|
||||
local runs = {}
|
||||
local line_no = 1
|
||||
local p = 0
|
||||
while true do
|
||||
local eol = scan_forward(buf, p, len, is_nl_byte) or len
|
||||
local s = eol
|
||||
while s > p do
|
||||
local b = buf:slice(s - 1, s):byte(1)
|
||||
if is_ws_byte(b) then s = s - 1 else break end
|
||||
end
|
||||
if s < eol then
|
||||
runs[#runs + 1] = { start = s, stop = eol, line = line_no }
|
||||
end
|
||||
if eol >= len then break end
|
||||
p = eol + 1
|
||||
line_no = line_no + 1
|
||||
end
|
||||
return runs
|
||||
end
|
||||
|
||||
local function trim_active(name)
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
ed.set_status(name .. ": no buffer")
|
||||
return
|
||||
end
|
||||
local len = buf:len()
|
||||
local runs = collect_trailing(buf, len)
|
||||
if #runs == 0 then
|
||||
ed.set_status(name .. ": nothing to trim")
|
||||
return
|
||||
end
|
||||
local win0 = pmacs.window.current()
|
||||
local cursor0 = ed.cursor()
|
||||
local applied = {}
|
||||
local failed = nil
|
||||
local context_tripped = false
|
||||
for i = #runs, 1, -1 do
|
||||
local r = runs[i]
|
||||
local ok, estart, estop, eins = pcall(function()
|
||||
return buf:delete(r.start, r.stop)
|
||||
end)
|
||||
if not ok then
|
||||
-- Rejected: nothing landed for this line; stop the sweep.
|
||||
failed = { line = r.line, what = "rejected" }
|
||||
break
|
||||
end
|
||||
applied[#applied + 1] = { estart, estop, eins }
|
||||
-- Context guard after EVERY delete: a clean delete's intercept
|
||||
-- can switch window/buffer, and the sweep must not keep deleting
|
||||
-- through the saved handle behind the new context's back.
|
||||
if pmacs.window.current() ~= win0 or pmacs.window.buffer() ~= buf then
|
||||
context_tripped = true
|
||||
break
|
||||
end
|
||||
if estart ~= r.start or estop ~= r.stop or eins ~= 0 then
|
||||
-- Transformed: the intercept's edit stands (it is in `applied`
|
||||
-- for translation); stop the sweep.
|
||||
failed = { line = r.line, what = "altered" }
|
||||
break
|
||||
end
|
||||
end
|
||||
if context_tripped then
|
||||
ed.set_status(name .. ": context changed during edit")
|
||||
return
|
||||
end
|
||||
if failed then
|
||||
ed.set_status(string.format("%s: line %d %s by buffer intercept",
|
||||
name, failed.line, failed.what))
|
||||
else
|
||||
ed.set_status(string.format("%s: trimmed %d line%s", name, #applied,
|
||||
#applied == 1 and "" or "s"))
|
||||
end
|
||||
if #applied > 0 then
|
||||
-- Translate through every landed effective edit, in application
|
||||
-- order; goto_byte clamps. Unconditional anchor clear (step 7).
|
||||
local c = cursor0
|
||||
for _, t in ipairs(applied) do
|
||||
c = translate(c, t[1], t[2], t[3])
|
||||
end
|
||||
ed.goto_byte(c)
|
||||
ed.clear_selection()
|
||||
end
|
||||
end
|
||||
|
||||
pmacs.command.define {
|
||||
name = "edit.delete-trailing-whitespace",
|
||||
description = "Delete trailing spaces and tabs from every line.",
|
||||
fn = function()
|
||||
trim_active("delete-trailing-whitespace")
|
||||
end,
|
||||
}
|
||||
|
||||
-- Opt-in trim-on-save. Getter when nil (the killring.max shape);
|
||||
-- default OFF — rewriting bytes on save is a policy, not a default.
|
||||
local trim_enabled = false
|
||||
function pmacs.editops.trim_on_save(on)
|
||||
if on == nil then return trim_enabled end
|
||||
trim_enabled = (on ~= false)
|
||||
return trim_enabled
|
||||
end
|
||||
|
||||
-- Registered at load time (gated inside) so it runs BEFORE
|
||||
-- saveplace's cursor-record in the before-save fan-out: editops.lua
|
||||
-- loads before saveplace.lua (the loader ordering contract, Q#EC9).
|
||||
pmacs.hook.add("buffer.before-save", function()
|
||||
-- Outer pcall: a raised error in a short-circuit hook vetoes the
|
||||
-- save; trim failure must never block a save. But it must not be
|
||||
-- silently discarded either — an unexpected error is a bug, and
|
||||
-- swallowing it hides the bug. Report on both channels the
|
||||
-- autosave sweep uses: the status line (visible when the save
|
||||
-- itself fails or is vetoed; a successful save overwrites it with
|
||||
-- "saved ...") and the *errors* buffer via pmacs.error (durable
|
||||
-- either way). Both reports are pcall'd — a broken reporting
|
||||
-- channel must not resurrect the veto.
|
||||
local ok, err = pcall(function()
|
||||
if trim_enabled then
|
||||
trim_active("delete-trailing-whitespace (on save)")
|
||||
end
|
||||
end)
|
||||
if not ok then
|
||||
local msg = "delete-trailing-whitespace (on save) failed: "
|
||||
.. tostring(err)
|
||||
pcall(ed.set_status, msg)
|
||||
if pmacs.error then pcall(pmacs.error, msg) end
|
||||
end
|
||||
-- nil return either way: never a veto.
|
||||
end)
|
||||
|
||||
-- ---- bindings (Q#EC1: all verified free across builtin bind sites) --
|
||||
|
||||
local function bind(seq, command)
|
||||
pmacs.keymap.bind { scope = "global", sequence = seq, command = command }
|
||||
end
|
||||
|
||||
bind("M-g g", "cursor.goto-line")
|
||||
bind("M-g M-g", "cursor.goto-line")
|
||||
bind("M-u", "edit.upcase")
|
||||
bind("M-l", "edit.downcase")
|
||||
bind("M-c", "edit.capitalize")
|
||||
bind("C-t", "edit.transpose-chars")
|
||||
bind("M-t", "edit.transpose-words")
|
||||
bind("M-z", "edit.zap-to-char")
|
||||
bind("M-<up>", "edit.move-line-up")
|
||||
bind("M-<down>", "edit.move-line-down")
|
||||
bind("M-^", "edit.join-line")
|
||||
|
|
@ -34,8 +34,24 @@ local max_entries = DEFAULT_MAX
|
|||
local last_kill_id = {} -- fid -> ring-entry id of that frontend's last kill
|
||||
local sessions = {} -- fid -> { buffer, start, stop, entry_id, text }
|
||||
|
||||
-- Commands whose success may extend a kill chain (Q#KR4).
|
||||
local KILL_CHAIN = { ["edit.kill-line"] = true, ["edit.cut"] = true }
|
||||
-- Commands whose success may extend a kill chain (Q#KR4; the zap
|
||||
-- pair joined via the editing-conveniences framing Q#EC6 — their
|
||||
-- kills run inside a minibuffer accept, where the boundary state is
|
||||
-- the invoking dispatch's, preserved by the minibuffer key shadow).
|
||||
local KILL_CHAIN = {
|
||||
["edit.kill-line"] = true,
|
||||
["edit.cut"] = true,
|
||||
["edit.zap-to-char"] = true,
|
||||
["edit.zap-up-to-char"] = true,
|
||||
}
|
||||
|
||||
-- Q#EC6 pending-prompt marker: fid -> true while a kill-producing
|
||||
-- minibuffer prompt is armed but not yet committed. Minibuffer::begin
|
||||
-- replaces a live session WITHOUT running its on_cancel, so a
|
||||
-- silently-discarded zap prompt leaves no callback to break the
|
||||
-- chain; the marker lives here, where every kill can see it, and an
|
||||
-- ordinary kill that meets it uncommitted refuses to append.
|
||||
local pending_kill_prompt = {} -- fid -> true
|
||||
|
||||
local function trim()
|
||||
while #ring > max_entries do
|
||||
|
|
@ -63,9 +79,13 @@ function pmacs.killring.list()
|
|||
return out
|
||||
end
|
||||
|
||||
-- Test/debug seam (Q#KR11 lifecycle assertions).
|
||||
-- Test/debug seam (Q#KR11 lifecycle assertions; Q#EC6 marker).
|
||||
function pmacs.killring._debug_state(fid)
|
||||
return { session = sessions[fid], last_kill_id = last_kill_id[fid] }
|
||||
return {
|
||||
session = sessions[fid],
|
||||
last_kill_id = last_kill_id[fid],
|
||||
pending_kill_prompt = pending_kill_prompt[fid],
|
||||
}
|
||||
end
|
||||
|
||||
-- Push `text` as a fresh entry (duplicate-of-head collapses, keeping
|
||||
|
|
@ -79,9 +99,13 @@ local function push_entry(text)
|
|||
end
|
||||
|
||||
-- A kill-family command failed or was a no-op: it must not leave a
|
||||
-- live chain for the next kill to append to (Q#KR4).
|
||||
-- live chain for the next kill to append to (Q#KR4). Also drops any
|
||||
-- armed-but-uncommitted prompt marker (Q#EC6): every failure path
|
||||
-- routes through here, and clearing both state components together
|
||||
-- is what makes break_chain sufficient.
|
||||
local function fail_kill(fid)
|
||||
last_kill_id[fid] = nil
|
||||
pending_kill_prompt[fid] = nil
|
||||
end
|
||||
|
||||
-- Chain-aware kill (Q#KR4): append to the head iff the previous
|
||||
|
|
@ -90,6 +114,17 @@ end
|
|||
-- not ours — append would corrupt their entry). Mirrors the head to
|
||||
-- the acting frontend's OS clipboard either way.
|
||||
local function kill_push(fid, text)
|
||||
-- Q#EC6 fail-safe: an uncommitted prompt marker means an armed
|
||||
-- kill prompt never resolved (silent session replacement bypasses
|
||||
-- on_cancel). Refuse to append no matter what last_command and the
|
||||
-- id say — force a fresh entry and clear the marker.
|
||||
if pending_kill_prompt[fid] then
|
||||
pending_kill_prompt[fid] = nil
|
||||
local head = push_entry(text)
|
||||
last_kill_id[fid] = head.id
|
||||
ed.clipboard_set(head.text)
|
||||
return head
|
||||
end
|
||||
local chained = KILL_CHAIN[ed.last_command() or ""]
|
||||
and last_kill_id[fid] ~= nil
|
||||
and ring[1] ~= nil
|
||||
|
|
@ -335,11 +370,100 @@ function pmacs.killring.yank_pop()
|
|||
return true
|
||||
end
|
||||
|
||||
-- ---- editing-conveniences exports (Q#EC6) --------------------------
|
||||
-- The chain-aware surface zap needs from its minibuffer accept: a
|
||||
-- range kill with killring's exact-effective-edit discipline, a
|
||||
-- targeted chain break (the frontend whose chain must break is the
|
||||
-- INVOKING one, which need not be the acting one), and the
|
||||
-- pending-prompt marker lifecycle.
|
||||
|
||||
-- Kill [start, stop) of the ACTIVE buffer into the ring, chain-aware.
|
||||
-- Returns true on a clean kill; false, "rejected" when an intercept
|
||||
-- threw (nothing landed); false, "transformed", estart, estop,
|
||||
-- einserted when the effective edit deviated (the intercept's result
|
||||
-- stands — the caller owns any cursor repair). Both failure paths
|
||||
-- break the acting frontend's chain and report status. Invalid
|
||||
-- arguments error BEFORE any ring or buffer mutation: misuse of a
|
||||
-- programmatic API, not a user outcome.
|
||||
function pmacs.killring.kill_range(start, stop)
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
error("pmacs.killring.kill_range: no active buffer")
|
||||
end
|
||||
local function ok_int(n)
|
||||
return type(n) == "number" and n == n and n ~= math.huge
|
||||
and n == math.floor(n) and n >= 0
|
||||
end
|
||||
if not (ok_int(start) and ok_int(stop))
|
||||
or start >= stop or stop > buf:len() then
|
||||
error("pmacs.killring.kill_range: expected integers "
|
||||
.. "0 <= start < stop <= buffer length")
|
||||
end
|
||||
local fid = pmacs.frontend.id()
|
||||
local text = buf:slice(start, stop)
|
||||
local ok, estart, estop, einserted = pcall(function()
|
||||
return buf:delete(start, stop)
|
||||
end)
|
||||
if not ok then
|
||||
fail_kill(fid)
|
||||
ed.set_status("kill rejected by buffer intercept")
|
||||
return false, "rejected"
|
||||
end
|
||||
if estart ~= start or estop ~= stop or einserted ~= 0 then
|
||||
fail_kill(fid)
|
||||
ed.set_status("kill altered by buffer intercept; ring not updated")
|
||||
return false, "transformed", estart, estop, einserted
|
||||
end
|
||||
kill_push(fid, text)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Public chain break. Targets `fid` when given (the origin guard
|
||||
-- passes the INVOKING frontend, which may differ from the acting
|
||||
-- one), else the acting frontend.
|
||||
function pmacs.killring.break_chain(fid)
|
||||
if fid == nil then
|
||||
fid = pmacs.frontend.id()
|
||||
elseif type(fid) ~= "number" or fid ~= fid or fid == math.huge
|
||||
or fid ~= math.floor(fid) or fid < 0 then
|
||||
error("pmacs.killring.break_chain: fid must be a nonnegative integer")
|
||||
end
|
||||
fail_kill(fid)
|
||||
end
|
||||
|
||||
-- Arm the acting frontend's pending-prompt marker (zap, at invoke
|
||||
-- time, before minibuffer.read). Does NOT touch last_kill_id —
|
||||
-- backward chaining (C-k then a completed zap appends) needs the id
|
||||
-- alive. An ALREADY-set marker means the previous armed prompt was
|
||||
-- silently discarded without resolution (replaced session, no
|
||||
-- on_cancel): break that chain first, or a second zap would commit
|
||||
-- the stale marker away and falsely append to the pre-abandonment
|
||||
-- kill.
|
||||
function pmacs.killring.arm_kill_prompt()
|
||||
local fid = pmacs.frontend.id()
|
||||
if pending_kill_prompt[fid] then
|
||||
last_kill_id[fid] = nil
|
||||
end
|
||||
pending_kill_prompt[fid] = true
|
||||
end
|
||||
|
||||
-- Clear the acting frontend's marker, reporting whether one was
|
||||
-- still armed. Callers kill only on true: a false return means some
|
||||
-- other Lua consumed the marker while the prompt was open, and the
|
||||
-- armed state is no longer trustworthy (fail closed).
|
||||
function pmacs.killring.commit_kill_prompt()
|
||||
local fid = pmacs.frontend.id()
|
||||
local was_armed = pending_kill_prompt[fid] ~= nil
|
||||
pending_kill_prompt[fid] = nil
|
||||
return was_armed
|
||||
end
|
||||
|
||||
-- Q#KR11: a detached frontend's chain/session state must not outlive
|
||||
-- it (ids are monotonic; these tables would grow forever).
|
||||
pmacs.hook.add("frontend.detached", function(fid)
|
||||
sessions[fid] = nil
|
||||
last_kill_id[fid] = nil
|
||||
pending_kill_prompt[fid] = nil
|
||||
end)
|
||||
|
||||
pmacs.command.define {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,32 @@
|
|||
# Agent handoff — cross-machine continuity
|
||||
|
||||
**Last updated: 2026-07-12, on the laptop, by the auto-pairing
|
||||
session (post-merge sync).** This file is the bridge between
|
||||
development machines. If you are an agent reading this on a fresh
|
||||
clone: this document plus the `docs/*-framing.md` files ARE your
|
||||
memory. Read this fully before taking on work, seed your persistent
|
||||
memory from it, and **update this file (and commit it) whenever
|
||||
project state changes materially** — the next machine reads it the
|
||||
way you just did.
|
||||
**Last updated: 2026-07-12, on the laptop, by the editops session
|
||||
(merging the auto-pairing #110 post-merge sync).** This file is the
|
||||
bridge between development machines. If you are an agent reading
|
||||
this on a fresh clone: this document plus the `docs/*-framing.md`
|
||||
files ARE your memory. Read this fully before taking on work, seed
|
||||
your persistent memory from it, and **update this file (and commit
|
||||
it) whenever project state changes materially** — the next machine
|
||||
reads it the way you just did.
|
||||
|
||||
## 1. Where the project stands (2026-07-12)
|
||||
|
||||
- `main` @ `4174f3e` (auto-pairing #110 merged), protocol **v15**
|
||||
(`SUPPORTED=[6..15]`).
|
||||
- **Editing-conveniences pack (editops) in flight: PR #111** — the
|
||||
Lua parallel lane, branch `editops`. Framing
|
||||
`docs/editing-conveniences-framing.md` at revision 6 (three
|
||||
pre-branch rounds, one adopted post-approval hardening, and PR
|
||||
round 1: full UTF-8 scalar validation, per-word capitalize with
|
||||
the `_`-constituent deviation named, trim-on-save dual-channel
|
||||
error reporting). goto-line, case ops, transpose, zap-to-char
|
||||
(kill-chain member with an origin guard and killring's new
|
||||
pending-prompt marker), line move/duplicate/join, region
|
||||
sort/reverse/dedupe, delete-trailing-whitespace + opt-in
|
||||
trim-on-save. killring.lua gains `kill_range` /
|
||||
`break_chain([fid])` / the marker lifecycle. On the laptop this
|
||||
branch lives in a git worktree at `../pmacs-editops`; fold it
|
||||
back after merge.
|
||||
- **Auto-pairing (#110) landed — Arc 2 is COMPLETE.** Framing
|
||||
`docs/auto-pairing-framing.md` at revision 6 (two pre-branch rounds
|
||||
+ three PR rounds). Shape that shipped: the nine built-in pair
|
||||
|
|
@ -179,7 +193,11 @@ comment padding. Pairing (framing "Deferred"): wrap-region on opener,
|
|||
pair-aware backspace, RET-inside-pair closer-on-own-line,
|
||||
in-string/in-comment inhibit (needs node-at-byte `pmacs.parse`),
|
||||
undo amalgamation (pair = one step), balance-aware quotes,
|
||||
per-buffer toggle (config-registry-blocked).
|
||||
per-buffer toggle (config-registry-blocked). Editops deferrals (full
|
||||
list in its framing): recenter (blocked on viewport facts — the GPU
|
||||
never consumes daemon `view_top`), Unicode case/word classes,
|
||||
region-spanning move/duplicate, locale collation for sort-lines,
|
||||
ensure-final-newline on save.
|
||||
Substrate: buffer-aware edit epoch (after-edit currently compares the
|
||||
ACTIVE buffer only), wire provenance for CRDT self-insert
|
||||
classification, Lua intercept probe, completion.lua still on the old
|
||||
|
|
|
|||
|
|
@ -0,0 +1,809 @@
|
|||
# Editing conveniences pack — framing (Lua-side, parallel lane)
|
||||
|
||||
The doom/Emacs muscle-memory commands that are pure Lua on settled
|
||||
substrate: goto-line, case ops, transpose, zap-to-char, line
|
||||
move/duplicate/join, region line ops (sort/reverse/dedupe), and
|
||||
delete-trailing-whitespace with an opt-in on-save hook. One runtime
|
||||
chunk, one command family, zero contact with the in-flight lanes
|
||||
(auto-pairing: `pmacs.pair.*` / `pair.lua` / its acceptance file;
|
||||
indent: `indent.lua` / `pmacs.indent.*`; RET).
|
||||
|
||||
Roadmap: `docs/roadmap-2026-07.md` — Arc 2 spirit ("editing table
|
||||
stakes") but deliberately outside Arc 2's remaining scope; runs in
|
||||
parallel to the auto-pairing close-out without sharing files beyond
|
||||
the two named coordination points (Q#EC1).
|
||||
|
||||
Revision 2: R1 findings — zap is now a real kill-chain member (the
|
||||
no-chain premise was false: minibuffer keys never rotate the command
|
||||
boundary, so `on_accept` observes exactly the state chaining needs);
|
||||
the chain-unsafe `killring.push` is replaced by a chain-aware
|
||||
`kill_range` + `break_chain`; Q#EC2 adopts the settled auto-indent
|
||||
context-guard/translate discipline for all fix-up including
|
||||
transformed-edit cursor repair; goto-line validates and bounds
|
||||
before `push_jump`; transpose-words is specified against an
|
||||
empirical Emacs 30.2 boundary table; ASCII conversion and sorting
|
||||
are explicit-byte-range/-comparator (the Lua 5.4 backend is
|
||||
locale-sensitive in `string.upper/lower`, string `<`, and pattern
|
||||
classes); the trim callback gains an outer pcall (a raised error in
|
||||
a short-circuit hook vetoes) and defined partial-sweep semantics.
|
||||
|
||||
Revision 3: R2 findings — the zap chain gains an origin-frontend
|
||||
guard (the minibuffer session is GLOBAL while command boundaries and
|
||||
`last_kill_id` are per-frontend: another frontend can accept or
|
||||
cancel the prompt, and pointer input breaks the boundary while
|
||||
leaving the prompt open — either would misattribute or falsely
|
||||
extend a chain); `break_chain` takes a target frontend; selection
|
||||
clearing after a landed edit is unconditional (a dormant zero-length
|
||||
anchor re-activates on cursor motion — the auto-indent rule);
|
||||
transpose-words' cursor endpoint is named correctly (after W1 in its
|
||||
new position); acceptance drives the minibuffer by dispatching
|
||||
RET/C-g (the Lua lifecycle `accept()` bypasses the after-edit
|
||||
wrapper); the trim sweep checks the context guard after every
|
||||
delete; goto-line's parser uses `tonumber` + `[ \t]*` explicitly.
|
||||
|
||||
Revision 4: R3 finding — `Minibuffer::begin` replaces a live
|
||||
session without running its `on_cancel`, so zap's armed chain state
|
||||
cannot live only in callbacks: killring gains a per-frontend
|
||||
pending-prompt marker (arm at invoke, commit immediately before the
|
||||
clean kill, cleared by `break_chain`/detach, and force-fresh +
|
||||
clear when an ordinary kill meets it uncommitted), plus an arm-time
|
||||
abandoned-marker break so a second zap after a silent replacement
|
||||
cannot append to the pre-abandonment kill. `break_chain(fid)`
|
||||
validates its argument; the zero-length-anchor acceptance case uses
|
||||
a cursor-moving command so it cannot pass vacuously; the `accept()`
|
||||
bypass is described as a path interactive key input never takes.
|
||||
|
||||
Revision 5 (post-approval, R3's optional hardening adopted):
|
||||
`commit_kill_prompt()` reports whether a marker was still armed;
|
||||
zap fails closed — no kill, chain broken — when public Lua consumed
|
||||
the marker while the prompt was open.
|
||||
|
||||
Revision 6 (PR #111 round 1): codepoint recognition is full UTF-8
|
||||
scalar validation (second-byte constraint table; transpose
|
||||
validates the cursor scalar trailing-bytes-included, and a
|
||||
length-consistent overlong/surrogate span behind the cursor fails
|
||||
closed); capitalize is per-word across the span (Emacs
|
||||
capitalize-region parity, empirically verified, with the pack's
|
||||
`_`-is-a-word-constituent deviation named); trim-on-save reports
|
||||
unexpected errors on the status line AND the `*errors*` buffer via
|
||||
`pmacs.error` instead of silently discarding them, still never
|
||||
vetoing.
|
||||
|
||||
## Ground truth (as of `7e127ab`)
|
||||
|
||||
- **The taken-chord registry is wider than `builtin/keymaps/
|
||||
default.lua`.** Runtime chunks bind globally too: killring
|
||||
(`C-k`, `M-y`; `builtin/runtime/killring.lua:357-358`), recentf
|
||||
(`C-x C-r`, `builtin/runtime/recentf.lua:85`), comment (`M-;`,
|
||||
`builtin/runtime/comment.lua:208`), lsp (`C-c` family, `M-.`,
|
||||
`M-,`, `M-?`, `M-g n`, `M-g p`), completion (`C-M-i`). Every
|
||||
chord this pack binds was verified free across ALL builtin bind
|
||||
sites. `M-g` is already a live prefix (`M-g n`/`M-g p`
|
||||
diagnostics), so `M-g g` extends an existing prefix map;
|
||||
multi-chord and shifted-punctuation sequences are established
|
||||
(`C-x C-s`, `M-%`, `M-{`).
|
||||
- **Mutator discipline (substrate invariant).** `buf:insert/delete/
|
||||
replace` return the post-intercept effective `(start, end,
|
||||
inserted_len)`; callers pcall and compare EXACTLY against the
|
||||
request (killring documents why length-delta checks are defeated
|
||||
patterns, `builtin/runtime/killring.lua:202-223`). Lua mutators
|
||||
move no cursors. **The settled fix-up pattern is auto-indent's**
|
||||
(`builtin/runtime/indent.lua:57-124`): snapshot window + buffer +
|
||||
cursor BEFORE the edit (intercepts run with borrows released and
|
||||
may switch context); after the edit, a context guard stops ALL
|
||||
fix-up if the active window or buffer changed; cursor repair
|
||||
right-gravity-translates the pre-edit cursor through the
|
||||
effective triple and `goto_byte` clamps. Skipping transformed-edit
|
||||
repair is not an option: an intercept that expands a replace can
|
||||
shrink the buffer below the old cursor byte. Auto-indent also
|
||||
clears the selection UNCONDITIONALLY after a landed edit
|
||||
(`indent.lua:121-122`) — `ed.region()` hides an anchor equal to
|
||||
the cursor, and the moment a command moves the cursor that
|
||||
dormant zero-length anchor becomes an active selection.
|
||||
- **`buffer.after-edit` coverage.** The dispatch cycle fires it
|
||||
once, post-command, gated on an active-buffer revision change
|
||||
(`src/editor.rs:772-776`). Edits performed inside a minibuffer
|
||||
accept callback are covered separately by
|
||||
`with_after_edit_check` (`src/editor.rs:840-864`) — the dedicated
|
||||
revision wrapper for the accept/menu/paste paths. Zap's edit is
|
||||
observed through that wrapper, not the `M-z` dispatch cycle —
|
||||
but only on the KEY path: the RET dispatch wraps the accept
|
||||
(`src/editor.rs:1170`), while the Lua lifecycle
|
||||
`pmacs.minibuffer.accept()` invokes the callback directly and
|
||||
BYPASSES `with_after_edit_check`
|
||||
(`src/lua_bindings/mod.rs:11464`). Tests that call `accept()`
|
||||
instead of dispatching RET therefore exercise a path interactive
|
||||
key input never takes (public Lua code CAN take it — which is a
|
||||
reason for tests to avoid it, not a claim it is unreachable). Direct Lua mutation outside these paths does
|
||||
not fire the hook; editops has no such path.
|
||||
- **Minibuffer sessions preserve command-boundary state — but the
|
||||
session is global and boundaries are per-frontend.** While a
|
||||
prompt is active every key routes through the minibuffer's
|
||||
hardcoded handler and returns before normal dispatch
|
||||
(`src/editor.rs:693-700`) — no `rotate_command` happens.
|
||||
`rotate_command` runs once per interactive command dispatch
|
||||
(`src/editor_core.rs:2254-2262`), and `last_command` names the
|
||||
predecessor as observed from inside the currently-running command
|
||||
(`src/editor_core.rs:2273-2280`). Consequence, for a command that
|
||||
reads input via `pmacs.minibuffer.read`: inside `on_accept`,
|
||||
`this_command()` is still the invoking command and
|
||||
`last_command()` is still its predecessor; the NEXT command
|
||||
rotates the invoking command into `last_command`. This is exactly
|
||||
the state kill-chaining needs — in both directions. Three
|
||||
hazards, though: the minibuffer session lives on the shared core,
|
||||
not a frontend (`src/minibuffer.rs:60`); every input event
|
||||
updates `active_frontend` BEFORE minibuffer interception
|
||||
(`src/editor.rs:635`), so a different frontend can accept or
|
||||
cancel the prompt and the callback then observes THAT frontend's
|
||||
command history, buffer, and id; and pointer input is not
|
||||
minibuffer-intercepted — a click breaks the boundary
|
||||
(`this_command = nil`, `src/editor.rs:1288`) while leaving
|
||||
`last_command` AND the open prompt intact, so a later accept
|
||||
would still see the pre-invocation kill as `last_command` and
|
||||
falsely append. And a fourth: `Minibuffer::begin` REPLACES an
|
||||
existing session without invoking its `on_cancel`
|
||||
(`src/minibuffer.rs:103`) — any Lua code, async callback, or
|
||||
package calling `pmacs.minibuffer.read` mid-prompt silently
|
||||
discards the session, so no cancel-path cleanup can be relied on
|
||||
to run. Chain-sensitive minibuffer commands must pin their origin
|
||||
frontend, re-verify `this_command` at accept time, and carry
|
||||
their armed state somewhere a later kill can see it even when no
|
||||
callback ever fired (Q#EC6).
|
||||
- **Kill-ring internals.** Appending requires
|
||||
`last_command ∈ KILL_CHAIN` AND the per-frontend `last_kill_id`
|
||||
matching the current head's id (`builtin/runtime/
|
||||
killring.lua:38`, `:92-107`); `fail_kill` clears the id so both
|
||||
conditions can never hold across a failed kill (`:83-85`).
|
||||
`push_entry` collapses a duplicate-of-head while KEEPING the
|
||||
existing id (`:71-79`) — so a naive "fresh push" API that leaves
|
||||
`last_kill_id` untouched is chain-unsafe: kill "x", push "x"
|
||||
(collapses to the same id), and the next `C-k` sees a matching id
|
||||
and appends. There is no public push today; any export must keep
|
||||
the id discipline intact.
|
||||
- **The save pipeline is Lua; short-circuit hooks veto on raised
|
||||
errors.** `buffer.save` runs
|
||||
`pmacs.hook.run("buffer.before-save")` and only then `ed.save()`
|
||||
(`builtin/commands/default.lua:222-234`) — a before-save mutation
|
||||
lands in the written bytes. A callback returning `nil` never
|
||||
vetoes (`builtin/runtime/saveplace.lua:80`), but a RAISED error
|
||||
in a short-circuit hook vetoes immediately (`src/hook.rs:299`) —
|
||||
which is why saveplace pcall-wraps its whole callback
|
||||
(`saveplace.lua:81-91`). Callbacks run in registration order
|
||||
(`src/hook.rs:240`).
|
||||
- **The Lua 5.4 backend is locale-sensitive where rev 1 assumed
|
||||
ASCII.** `string.upper/lower` call C `toupper`/`tolower` bytewise
|
||||
(vendored lua-5.4.7, `lstrlib.c:124`) — after `os.setlocale`,
|
||||
non-ASCII bytes can change and UTF-8 can be corrupted. String
|
||||
`<`/`table.sort`'s default comparator use `strcoll`
|
||||
(`lvm.c:370`) — not guaranteed byte-lexicographic. Pattern
|
||||
classes (`%l`, `%u`, `%w`) are ctype-backed and equally
|
||||
locale-sensitive. Only explicit byte ranges (`[a-z]`,
|
||||
`[A-Za-z0-9_]`, `[0-9]`) and explicit comparators are portable
|
||||
across locales and backends.
|
||||
- **Word classes already diverge in-core.** Word *motion* is
|
||||
Unicode alphanumeric + `_` (`src/editor_core.rs:2721-2724`);
|
||||
`word_at_cursor` is deliberately ASCII alnum + `_`
|
||||
(`src/editor_core.rs:2110-2132`). This pack's word/case ops use
|
||||
the ASCII class — the `word_at_cursor` precedent, narrower than
|
||||
motion (named limitation, Q#EC4).
|
||||
- **Cursor-byte bindings clamp to length, not to codepoint
|
||||
boundaries.** `goto_byte` clamps to `buf:len()` only; nothing
|
||||
guarantees the cursor sits on a UTF-8 boundary when a command
|
||||
starts. Codepoint-exact commands must fail closed on a
|
||||
continuation byte at the cursor (Q#EC5). `move_to_line` is
|
||||
0-based and clamps out-of-range (`src/lua_bindings/mod.rs:10761`)
|
||||
— but the Lua→integer conversion at the binding boundary errors
|
||||
on huge or negative numbers before any clamping runs, so inputs
|
||||
must be bounded Lua-side (Q#EC3).
|
||||
- **Emacs transpose-words boundary behavior (empirical).** GNU
|
||||
Emacs 30.2, `-Q --batch`, buffer `"one two three"`, 2026-07-11:
|
||||
|
||||
| point (1-based) | position | result | final point |
|
||||
|---|---|---|---|
|
||||
| 1 | BOB, start of "one" | `two one three` | 8 |
|
||||
| 2 | inside "one" | `two one three` | 8 |
|
||||
| 4 | separator after "one" | `two one three` | 8 |
|
||||
| 5 | exactly at start of "two" | `two one three` | 8 |
|
||||
| 6 | inside "two" | `one three two` | 14 |
|
||||
| 8 | separator after "two" | `one three two` | 14 |
|
||||
| 9 | exactly at start of "three" | `one three two` | 14 |
|
||||
| 11 | inside "three" (final word) | ERROR, buffer unchanged, point moved to 9 | 9 |
|
||||
| 14 | EOB | ERROR, buffer unchanged, point moved to 9 | 9 |
|
||||
|
||||
Cursor exactly AT a word's start pairs the PREVIOUS word with
|
||||
that word; strictly inside a word pairs that word with the NEXT;
|
||||
a final word with no successor is an error (with point motion —
|
||||
a wart we do not copy, Q#EC5).
|
||||
- **Recenter is not honestly buildable today.** `view_top` /
|
||||
`set_view_top` are daemon-window line indices
|
||||
(`src/lua_bindings/mod.rs:11015-11031`) which the TUI renders,
|
||||
but the GPU's scroll is frontend-local and caret-driven (scroll
|
||||
framing Q#S1/S2) and never consumes daemon `view_top`; no Lua API
|
||||
exposes viewport height, so "center" is not computable. Recenter
|
||||
is cut, not shipped TUI-only (Q#EC10).
|
||||
- **`push_jump`/`jump_back` exist** (`src/lua_bindings/
|
||||
mod.rs:10770-10786`) and `M-,` already unwinds the jump stack.
|
||||
- **Chunked scanning is the giant-line-safe idiom** — kill_line's
|
||||
4096-byte newline scan (`builtin/runtime/killring.lua:183-194`).
|
||||
- **Runtime chunks load from an ordered `include_str!` list** in
|
||||
`src/editor.rs` (async → fs → syntax → mcp → listview → lsp →
|
||||
completion → saveplace → recentf → …). Command-body references
|
||||
resolve at invoke time, so load position matters only for
|
||||
load-time registrations — here, exactly one: the trim before-save
|
||||
callback (Q#EC9).
|
||||
- **Handoff §6 owns adjacent deferrals this pack must not claim.**
|
||||
Word kills into the ring (`M-d`/`M-BS` rework), `C-SPC` set-mark,
|
||||
and undo amalgamation stay in their lanes; editops touches none
|
||||
of the delete-word commands.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Q#EC1 — Shape: one chunk, one namespace, two coordination points
|
||||
|
||||
`builtin/runtime/editops.lua`, namespace `pmacs.editops.*` (config +
|
||||
implementation), commands in the existing `edit.*` family plus
|
||||
`cursor.goto-line`. All bindings are made inside editops.lua (the
|
||||
killring/recentf pattern) — `builtin/keymaps/default.lua` is not
|
||||
touched, keeping the "default keymap is stable" contract with the
|
||||
auto-pairing lane.
|
||||
|
||||
File touch set: new `builtin/runtime/editops.lua`, new
|
||||
`tests/editops_acceptance.rs`, a loader entry in `src/editor.rs`
|
||||
(between completion.lua and saveplace.lua — the only load-order
|
||||
requirement, Q#EC9), and ~40 lines in `builtin/runtime/killring.lua`
|
||||
(Q#EC6: `kill_range`, `break_chain`, the pending-prompt marker). The two coordination points with the auto-pairing branch:
|
||||
both add an editor.rs loader entry (different positions — pair.lua
|
||||
goes before lsp.lua; trivial merge), and neither touches the other's
|
||||
files otherwise.
|
||||
|
||||
Ten commands bound via eleven sequences (each verified free across
|
||||
every builtin bind site):
|
||||
|
||||
| Chord | Command |
|
||||
|---|---|
|
||||
| `M-g g`, `M-g M-g` | `cursor.goto-line` |
|
||||
| `M-u` / `M-l` / `M-c` | `edit.upcase` / `edit.downcase` / `edit.capitalize` |
|
||||
| `C-t` / `M-t` | `edit.transpose-chars` / `edit.transpose-words` |
|
||||
| `M-z` | `edit.zap-to-char` |
|
||||
| `M-<up>` / `M-<down>` | `edit.move-line-up` / `edit.move-line-down` |
|
||||
| `M-^` | `edit.join-line` |
|
||||
|
||||
M-x-only (no chords): `edit.zap-up-to-char`, `edit.duplicate-line`,
|
||||
`edit.sort-lines`, `edit.reverse-lines`,
|
||||
`edit.delete-duplicate-lines`, `edit.delete-trailing-whitespace`.
|
||||
|
||||
### Q#EC2 — Mutator discipline: the auto-indent guard, one replace per command
|
||||
|
||||
Every text-changing command is expressed as a SINGLE `buf:replace`
|
||||
(or `buf:delete`) spanning the affected region wherever possible —
|
||||
transpose, case, line move, join, sort/reverse/dedupe are each one
|
||||
edit, hence one undo unit. The one coarser-grained command is named:
|
||||
trim (one delete per trimmed line, Q#EC9).
|
||||
|
||||
The shared fix-up discipline is auto-indent's
|
||||
(`builtin/runtime/indent.lua:57-124`), applied uniformly:
|
||||
|
||||
1. **Snapshot** `pmacs.window.current()`, the buffer handle, and
|
||||
`ed.cursor()` before the mutator.
|
||||
2. **Edit** via one pcall'd mutator; capture the effective triple.
|
||||
3. **Rejected** (intercept threw): nothing landed; status names the
|
||||
command + "rejected by buffer intercept"; no fix-up, no state
|
||||
updates (ring untouched, selection left alone).
|
||||
4. **Context guard**: if the active window or buffer changed, stop
|
||||
ALL fix-up — no `goto_byte`, no `clear_selection` against the
|
||||
switched context; report "context changed during edit".
|
||||
5. **Clean** (triple equals request): `goto_byte` to the
|
||||
command-defined cursor target.
|
||||
6. **Transformed** (triple deviates): the intercept's result stands
|
||||
(accepted post-hoc semantics); status reports "altered by buffer
|
||||
intercept"; the ORIGINAL cursor is right-gravity-translated
|
||||
through the effective triple and `goto_byte` clamps (the
|
||||
command-defined target is meaningless against a relocated edit,
|
||||
but leaving the cursor unrepaired can strand it past
|
||||
`buf:len()`). Follow-up state updates that assert the requested
|
||||
edit happened (ring push) are skipped, matching killring.
|
||||
7. **After ANY landed edit** (clean or transformed), under the same
|
||||
guard: `clear_selection()` UNCONDITIONALLY — not just when a
|
||||
nonempty region existed. `ed.region()` hides a zero-length
|
||||
anchor at the cursor, and the command's own cursor motion would
|
||||
re-activate it as a visible selection (the auto-indent rule,
|
||||
`indent.lua:121-122`).
|
||||
|
||||
### Q#EC3 — goto-line: validate and bound BEFORE any state changes
|
||||
|
||||
`cursor.goto-line` reads via `pmacs.minibuffer.read` (prompt
|
||||
"Goto line: ", history bucket `goto-line`, `source = "none"`).
|
||||
`on_accept`, in order:
|
||||
|
||||
1. Parse `^[ \t]*([0-9]+)[ \t]*$` — explicit ranges throughout
|
||||
(not `%d`, not `%s`; both are ctype-backed and the parsing
|
||||
contract is locale-independent). No match → status *"goto-line:
|
||||
enter a line number"*; nothing mutated — `push_jump` has NOT
|
||||
run.
|
||||
2. `n = tonumber(capture)`, explicitly, then bound:
|
||||
`n = math.max(1, math.min(n, 2^31))`. `"0"` clamps to line 1
|
||||
(Emacs behavior); the upper bound keeps the value inside what
|
||||
the binding's integer conversion accepts — huge decimal input
|
||||
must clamp to the last line, not error.
|
||||
3. Only now `push_jump()`, then `move_to_line(n - 1)` (0-based;
|
||||
clamps out-of-range to the last line).
|
||||
|
||||
`M-,` returns to the origin via the existing jump stack. All state
|
||||
is read at accept time — nothing captured at invoke time.
|
||||
|
||||
### Q#EC4 — Case ops: DWIM span, explicit-byte-range conversion
|
||||
|
||||
`edit.upcase` / `edit.downcase` / `edit.capitalize` (Emacs
|
||||
`*-dwim`): with an active region, transform the region and clear the
|
||||
selection (stale byte range; CUA/killring precedent — deviation from
|
||||
Emacs's kept region, named). Without one, transform from the first
|
||||
word character at-or-after the cursor through that word's end
|
||||
(Emacs's mid-word remainder semantics), cursor to the span end.
|
||||
No word forward → status, no edit.
|
||||
|
||||
Word class: ASCII `[A-Za-z0-9_]` via explicit byte ranges (the
|
||||
`word_at_cursor` precedent). Conversion: explicit `[a-z]`/`[A-Z]`
|
||||
range gsub with a byte map — NOT `string.upper/lower` and NOT
|
||||
`%l`/`%u` classes, all of which are locale-backed on the Lua 5.4
|
||||
backend (ground truth); this also keeps Lua 5.4 and LuaJIT
|
||||
identical. Non-ASCII bytes pass through untouched — pinned in
|
||||
acceptance (an `é` in the span is byte-identical after the op).
|
||||
|
||||
Capitalize is PER-WORD across the span — Emacs capitalize-region
|
||||
parity (PR #111 R1 finding 2; empirical, Emacs 30.2 `-Q --batch`:
|
||||
`"hello WORLD"` → `"Hello World"`, `"9abc a9bc"` → `"9abc A9bc"`):
|
||||
each word's first byte is upcased when it is a letter, every other
|
||||
letter downcased; a digit-led word keeps its letters lowercase. One
|
||||
named deviation remains: `_` is a word constituent in this pack's
|
||||
class (the `word_at_cursor` precedent) but symbol-syntax in Emacs,
|
||||
so `foo_bar` capitalizes as `Foo_bar` here versus Emacs's
|
||||
`Foo_Bar`.
|
||||
|
||||
### Q#EC5 — Transpose: codepoint-aware chars, Emacs-verified word boundaries
|
||||
|
||||
`edit.transpose-chars` (C-t): swap the codepoints before and at the
|
||||
cursor, cursor ends after both (Emacs drag-forward). At EOL (next
|
||||
char is `\n` or EOF) with ≥2 preceding codepoints: swap the two
|
||||
before the cursor (Emacs special case). Fewer than two reachable
|
||||
codepoints → status, no edit. Codepoint recognition is FULL scalar
|
||||
validation, not lead/continuation range checks (PR #111 R1 finding
|
||||
1): a shared validator enforces the UTF-8 second-byte constraint
|
||||
table — overlongs (`C0`/`C1`, `E0 80..9F`, `F0 80..8F`), surrogates
|
||||
(`ED A0..BF`), and beyond-`U+10FFFF` (`F4 90..BF`, `F5..FF`) all
|
||||
fail — and the scalar AT the cursor is validated trailing bytes
|
||||
included (a valid lead followed by non-continuation bytes must not
|
||||
ride along as "one character"). Failures fail closed: a
|
||||
continuation byte at the cursor, a malformed scalar at the cursor,
|
||||
and a length-consistent-but-invalid span behind it each report and
|
||||
leave the buffer untouched — `goto_byte` does not guarantee
|
||||
boundary alignment, and buffers are byte-clean, so malformed input
|
||||
is reachable. (Zap's single-codepoint input check uses the same
|
||||
validator as defense-in-depth; minibuffer contents arrive as
|
||||
Rust-side UTF-8 — `set_contents` is `String`-typed — so the
|
||||
buffer-facing checks are the load-bearing ones.) Newlines
|
||||
participate (transpose across lines works). One replace spanning
|
||||
exactly the two codepoints.
|
||||
|
||||
`edit.transpose-words` (M-t), specified against the Emacs 30.2
|
||||
table in Ground truth:
|
||||
|
||||
- **W1** = the word containing the cursor, if the cursor lies
|
||||
STRICTLY after that word's start; otherwise the nearest word
|
||||
entirely before the cursor; if none exists (BOB / leading
|
||||
separators), the first word at-or-after the cursor. A cursor
|
||||
exactly at a word's start therefore pairs the PREVIOUS word with
|
||||
it — the point-5/point-9 rows.
|
||||
- **W2** = the first word strictly after W1's end. No W2 → status,
|
||||
no edit, **no cursor motion** (Emacs errors AND moves point; the
|
||||
point motion is a wart we don't copy — named deviation).
|
||||
- Swap W1 and W2's spans in one replace, separator bytes between
|
||||
them preserved verbatim; cursor ends at the replaced span's end —
|
||||
immediately after W1 in its NEW position (post-swap, W1 sits
|
||||
last; matches the observed final points 8 and 14).
|
||||
|
||||
Word class ASCII (Q#EC4). Named simplification: W1/W2 are always
|
||||
exact word spans — Emacs's `transpose-subr` can drag leading
|
||||
separators into the region at BOB edges; we never transpose
|
||||
separator bytes.
|
||||
|
||||
### Q#EC6 — Zap: a real kill-chain member via a chain-aware killring export
|
||||
|
||||
Rev 1's no-chain design rested on a false premise. Ground truth:
|
||||
minibuffer keys never rotate the boundary, so inside `on_accept`
|
||||
`this_command()` is `edit.zap-to-char` and `last_command()` is
|
||||
M-z's predecessor — and the next command rotates zap into
|
||||
`last_command`. That is exactly the state real chaining needs, in
|
||||
both directions. So zap chains like Emacs:
|
||||
|
||||
- `KILL_CHAIN` gains `edit.zap-to-char` and `edit.zap-up-to-char`:
|
||||
a zap right after `C-k` appends to that kill's entry; a `C-k`
|
||||
right after a zap appends to zap's entry; consecutive zaps
|
||||
append.
|
||||
- New killring exports (replacing rev 1's chain-unsafe `push`,
|
||||
whose duplicate-of-head collapse plus untouched `last_kill_id`
|
||||
would let a later `C-k` append across a foreign push):
|
||||
- `pmacs.killring.kill_range(start, stop)` — operates on the
|
||||
active buffer (the `cut` shape). Validates before ANY mutation:
|
||||
integers, `0 <= start < stop <= buf:len()`, else it errors (a
|
||||
programmer-facing API misuse, not a status). Slices the text
|
||||
first, then one pcall'd exact-checked `buf:delete`. Clean →
|
||||
`kill_push` (chain-aware append-or-push; updates
|
||||
`last_kill_id`, mirrors the acting frontend's clipboard),
|
||||
returns `true`. Rejected → killring-standard status +
|
||||
`fail_kill`, returns `false, "rejected"`. Transformed → the
|
||||
edit stands, status + `fail_kill`, returns
|
||||
`false, "transformed", estart, estop, einserted` so the caller
|
||||
can run its Q#EC2 guarded cursor repair.
|
||||
- `pmacs.killring.break_chain([fid])` — public `fail_kill`,
|
||||
targeting `fid` when given (validated as a nonnegative integer
|
||||
before indexing per-frontend state), else the acting frontend.
|
||||
The target parameter is required by the origin guard below: the
|
||||
frontend whose chain must break is the INVOKING one, which need
|
||||
not be the frontend whose input triggered the callback.
|
||||
Clearing BOTH the chain id and the pending-prompt marker
|
||||
(below) is sufficient to break a chain: appending requires the
|
||||
id match AND the `KILL_CHAIN` predecessor together, and the
|
||||
marker fail-safes the path where no callback ever ran.
|
||||
- `pmacs.killring.arm_kill_prompt()` /
|
||||
`pmacs.killring.commit_kill_prompt()` — the pending-prompt
|
||||
marker (below).
|
||||
|
||||
**Pending-prompt marker (the R3 blocker).** `Minibuffer::begin`
|
||||
replaces a live session WITHOUT running its `on_cancel` (ground
|
||||
truth) — so zap's cancel-path `break_chain` cannot be relied on to
|
||||
run: C-k, M-z, a package's `pmacs.minibuffer.read` silently
|
||||
replacing the prompt, the replacement closing, then C-k would
|
||||
rotate `edit.zap-to-char` into `last_command` with the old id still
|
||||
matching, and append as though the zap had happened. The armed
|
||||
state must therefore live where every kill can see it, not in a
|
||||
callback that may never fire. Killring gains per-frontend
|
||||
`pending_kill_prompt[fid]`:
|
||||
|
||||
- **Arm** (`arm_kill_prompt()`, called by zap at invoke time,
|
||||
before `minibuffer.read`): sets the marker for the acting
|
||||
frontend. It does NOT touch `last_kill_id` — backward chaining
|
||||
(`C-k` then a completed zap appends) needs the id alive. If the
|
||||
marker is ALREADY set, the previous armed prompt was silently
|
||||
discarded without resolution: `fail_kill` first, then arm —
|
||||
otherwise a second `M-z` after a silent replacement would commit
|
||||
the stale marker away and falsely append to the pre-abandonment
|
||||
kill (a residue the marker scheme alone would mask).
|
||||
- **Commit** (`commit_kill_prompt()`): clears the marker and
|
||||
RETURNS whether one was armed (post-approval hardening, adopted
|
||||
from R3's optional note). Zap calls it immediately BEFORE
|
||||
`kill_range` on the clean-input path — before, not after, or
|
||||
`kill_push` would see the marker and force-fresh, killing
|
||||
backward chaining — and treats a `false` return as fail-closed:
|
||||
some public Lua consumed the marker while the prompt was open,
|
||||
so the armed state is no longer trustworthy — status +
|
||||
`break_chain(origin_fid)`, no kill.
|
||||
- **`break_chain([fid])`** clears the marker along with
|
||||
`last_kill_id` — every failure path already routes through it.
|
||||
- **Ordinary `kill_push` encountering an uncommitted marker** for
|
||||
the acting frontend forces a FRESH entry and clears the marker —
|
||||
this is the fail-safe that catches the silent-replacement case:
|
||||
the abandoned zap left its marker, and the next `C-k` refuses to
|
||||
append no matter what `last_command` and the id say.
|
||||
- **`frontend.detached`** clears the marker with the existing
|
||||
per-frontend state (`killring.lua:340-343`).
|
||||
|
||||
**Origin guard (the R2 blocker).** The minibuffer session is global
|
||||
while command boundaries and `last_kill_id` are per-frontend, and
|
||||
pointer input breaks the boundary without closing the prompt
|
||||
(ground truth). So zap captures `origin_fid = pmacs.frontend.id()`
|
||||
when it OPENS the prompt, and `on_accept` proceeds only when BOTH
|
||||
hold:
|
||||
|
||||
- `pmacs.frontend.id() == origin_fid` — the completing frontend is
|
||||
the invoking one (a different frontend's accept would run the
|
||||
kill against ITS buffer, history, and chain state); and
|
||||
- `ed.this_command()` is still the invoking zap command — pointer
|
||||
input (or any boundary-breaking event) on the origin frontend
|
||||
sets `this_command = nil` while leaving `last_command` as the
|
||||
pre-zap kill, so without this check a later accept would falsely
|
||||
append the zap to that old kill.
|
||||
|
||||
On either failure: abort — no scan, no edit — with status, and
|
||||
`break_chain(origin_fid)` (breaking the ACTING frontend's chain
|
||||
would leave the origin's pre-zap chain alive). `on_cancel` does the
|
||||
same targeted `break_chain(origin_fid)` and clears the captured
|
||||
`origin_fid`, regardless of which frontend cancelled.
|
||||
|
||||
`edit.zap-to-char` (M-z): at invoke time, capture
|
||||
`origin_fid = pmacs.frontend.id()` and `arm_kill_prompt()`, then
|
||||
open the prompt ("Zap to char: "); all buffer state is read at
|
||||
accept time. After the origin guard: input must be exactly one
|
||||
UTF-8 codepoint, else status + `break_chain(origin_fid)`. Chunked
|
||||
forward scan from the cursor; found at `p` → Q#EC2 snapshot,
|
||||
`commit_kill_prompt()` (a `false` return aborts fail-closed:
|
||||
status + `break_chain(origin_fid)`, no kill), then
|
||||
`kill_range(cursor, p + #char)`; on the transformed return,
|
||||
guarded translate-and-clamp repair. Not
|
||||
found → status *"zap: no 'c' after the cursor"* +
|
||||
`break_chain(origin_fid)`. `edit.zap-up-to-char` kills
|
||||
`[cursor, p)`; a match AT the cursor is a zero-length no-op with
|
||||
status + `break_chain(origin_fid)` (Emacs parity on the text, chain
|
||||
broken on the no-op).
|
||||
|
||||
Every non-kill outcome breaks the origin frontend's chain: origin
|
||||
mismatch, disturbed boundary, cancel, invalid input, no match,
|
||||
zero-length, rejection, transformation — and when a silent session
|
||||
replacement lets NONE of those paths run, the uncommitted marker
|
||||
makes the next kill fail safe to a fresh entry. Only a clean kill
|
||||
by the origin frontend, through the commit, extends or starts a
|
||||
chain.
|
||||
|
||||
`cursor.goto-line` adopts the same origin guard for consistency
|
||||
(abort with status on mismatch — no `push_jump`, no motion): it has
|
||||
no chain stakes, but a prompt completed by a different frontend
|
||||
moving THAT frontend's cursor is the same wrong-actor bug in milder
|
||||
form.
|
||||
|
||||
### Q#EC7 — Line ops: plain byte moves, explicitly not indentation
|
||||
|
||||
All single-cursor-line in v1 (region-spanning variants deferred);
|
||||
none of them inserts computed whitespace, calls `pmacs.indent.*`, or
|
||||
reindents after moving — stated to keep this pack out of the indent
|
||||
lane permanently, not just while #109's follow-ups settle.
|
||||
|
||||
- `edit.move-line-up/down`: swap the cursor line with its neighbor
|
||||
via one replace spanning both lines (newline placement handled
|
||||
when the last line lacks a trailing `\n`); cursor keeps its byte
|
||||
column, clamped to the moved line's length, on the line's new
|
||||
location. At the first/last line → status, no edit.
|
||||
- `edit.duplicate-line`: insert a copy of the cursor line below
|
||||
(last line without `\n` → insert `"\n" .. line` at EOL); cursor
|
||||
to the same byte column in the copy.
|
||||
- `edit.join-line` (M-^, Emacs delete-indentation): join the cursor
|
||||
line onto the previous one — one replace of
|
||||
[prev line's trailing-whitespace start, current line's
|
||||
leading-whitespace end) with a single space, or with nothing when
|
||||
either side of the junction is empty (prev line blank or current
|
||||
content empty — avoids `" bar"`). Cursor at the junction. On the
|
||||
first line → status, no edit.
|
||||
|
||||
### Q#EC8 — Region line ops: whole-line expansion, explicit byte comparator
|
||||
|
||||
`edit.sort-lines` / `edit.reverse-lines` /
|
||||
`edit.delete-duplicate-lines` require an active region (else status
|
||||
*"…: no active region (select the lines first)"*). Expansion rule:
|
||||
start → beginning of the line containing `region.start`; end → end
|
||||
of the line containing `region.end - 1`, including its newline when
|
||||
present (a region ending exactly at a BOL excludes that line —
|
||||
Emacs sort-lines). Lines split/rejoined preserving the presence or
|
||||
absence of a final newline.
|
||||
|
||||
Sort uses `table.sort` with an EXPLICIT byte-wise comparator —
|
||||
never the default string `<`, which is `strcoll`-backed and
|
||||
locale-dependent (ground truth). Equal lines are identical, so
|
||||
sort instability is moot. Dedupe keeps the first occurrence, status
|
||||
reports the count removed. One replace; fix-up per Q#EC2 (selection
|
||||
cleared, cursor to the region start, transformed edits translated).
|
||||
|
||||
### Q#EC9 — Trailing whitespace: command always, hook opt-in, veto-proof
|
||||
|
||||
`edit.delete-trailing-whitespace`: chunked line scan; one
|
||||
`buf:delete` per line that has a trailing ` `/`\t` run, applied
|
||||
bottom-up so earlier deletes never shift later targets. Undo grain
|
||||
is one step per trimmed line — named (undo amalgamation is an
|
||||
existing deferral, not this pack's).
|
||||
|
||||
Partial-sweep semantics: the Q#EC2 context guard is checked after
|
||||
EVERY delete, not only at final fix-up — a clean delete's intercept
|
||||
can switch the active window or buffer, and the sweep must stop at
|
||||
that point rather than keep deleting through the saved buffer
|
||||
handle behind the switched-to context's back. The sweep also stops
|
||||
at the first non-clean edit (rejected or transformed), reporting
|
||||
which line failed. Fix-up then reflects EVERY edit that actually
|
||||
landed — the cursor is right-gravity-translated through each
|
||||
applied effective triple (including a transformed one, as returned)
|
||||
and clamped, and the selection is cleared (unconditionally, Q#EC2
|
||||
step 7) if any delete landed — all skipped when the context guard
|
||||
tripped. A clean full sweep translates the cursor the same way
|
||||
(inside a trimmed run → its start).
|
||||
|
||||
On-save: `pmacs.editops.trim_on_save([on])` — getter/setter (the
|
||||
`killring.max` shape), **default off** (silently rewriting bytes on
|
||||
save is a policy, not a default). The before-save callback is
|
||||
registered unconditionally at chunk load and gates on the flag
|
||||
inside, so its registration position is fixed by loader order:
|
||||
editops.lua loads BEFORE saveplace.lua, making trim run before
|
||||
saveplace's cursor-record within the before-save fan-out (recorded
|
||||
places see post-trim text). The ENTIRE callback body is wrapped in
|
||||
pcall with a `nil` return on both paths (the saveplace pattern):
|
||||
returning `nil` never vetoes, but a raised error in a
|
||||
short-circuit hook vetoes immediately (`src/hook.rs:299`). An
|
||||
unexpected error caught by that pcall is NOT silently discarded
|
||||
(PR #111 R1 finding 3) — it reports on both channels the autosave
|
||||
sweep uses: the status line (visible when the save fails or is
|
||||
vetoed; a successful save overwrites it with "saved ...") and the
|
||||
`*errors*` buffer via `pmacs.error` (durable either way; the
|
||||
async/mcp/syntax/autosave convention). Both reports are
|
||||
themselves pcall'd so a broken reporting channel cannot resurrect
|
||||
the veto.
|
||||
|
||||
### Q#EC10 — Cut from the pack: recenter
|
||||
|
||||
`C-l` recenter is not shipped: the GPU never consumes daemon
|
||||
`view_top` (its scroll is caret-driven and frontend-local) and no
|
||||
API exposes viewport height, so "center/top/bottom" is either a lie
|
||||
on one frontend or unimplementable. Deferred behind a
|
||||
viewport-facts / frontend-scroll-control substrate (Arc 8 adjacent),
|
||||
not worked around.
|
||||
|
||||
## Bets
|
||||
|
||||
1. **Free-chord verification against ALL bind sites is sufficient.**
|
||||
The registry-of-taken-chords contract with the auto-pairing lane
|
||||
is about not colliding and not rebinding — new bindings on
|
||||
verified-free chords are in-bounds.
|
||||
2. **ASCII word/case semantics are acceptable v1** — they match
|
||||
`word_at_cursor`'s existing posture, and with explicit byte
|
||||
ranges non-ASCII text is passed through untouched in every
|
||||
locale, never corrupted.
|
||||
3. **One-replace-per-command undo grain is what users expect** from
|
||||
transpose/move/sort — and it falls out of the mutator discipline
|
||||
rather than needing grouping substrate.
|
||||
4. **Minibuffer boundary preservation is stable substrate, not
|
||||
accident** — the shadow's early return and `rotate_command`'s
|
||||
contract are documented behavior with the M-x path already
|
||||
depending on them. What is NOT assumed is who completes the
|
||||
prompt or that the boundary survives until accept: the origin
|
||||
guard re-verifies both instead of trusting them, and the
|
||||
acceptance suite pins the preserved-state observation and the
|
||||
guard's failure modes directly.
|
||||
|
||||
## Deferred (named)
|
||||
|
||||
- Recenter + any frontend scroll control (needs viewport facts on
|
||||
the wire; Arc 8 adjacent).
|
||||
- Unicode-aware case conversion and word classes (would also
|
||||
reconcile the in-core motion vs `word_at_cursor` split).
|
||||
- Locale-aware collation modes for sort-lines (byte order is the
|
||||
contract until then), and numeric sort.
|
||||
- Region-spanning move/duplicate (drag-stuff parity).
|
||||
- Emacs's separator-dragging `transpose-subr` edge at BOB (we
|
||||
always transpose exact word spans).
|
||||
- Ensure-final-newline on save (separate policy from trim).
|
||||
- fixup-whitespace refinements for join (punctuation-aware spacing).
|
||||
- Chords for the M-x-only commands if usage earns them.
|
||||
|
||||
## Acceptance
|
||||
|
||||
`tests/editops_acceptance.rs`, dispatch-driven where a binding
|
||||
exists (per the established discipline: `pmacs.command.invoke`
|
||||
bypasses dispatch, so bound-key cases must go through key dispatch
|
||||
or a dead binding passes vacuously). Minibuffer-driven commands may
|
||||
seed input with `set_contents()`, but MUST complete the session by
|
||||
DISPATCHING RET (and C-g for cancel cases) — the Lua lifecycle
|
||||
`accept()` invokes the callback directly and bypasses
|
||||
`with_after_edit_check` (ground truth), a path interactive key
|
||||
input never takes. Cross-frontend cases ride the same multi-frontend
|
||||
harness the kill-ring suite already uses.
|
||||
|
||||
- **Boundary-state pin** (the Q#EC6 substrate observation, asserted
|
||||
directly): inside zap's `on_accept`, `this_command()` is
|
||||
`edit.zap-to-char` and `last_command()` is the pre-M-z command;
|
||||
after accept, the next command observes `last_command() ==
|
||||
"edit.zap-to-char"`.
|
||||
- goto-line: dispatch `M-g g`, accept "5" → line 5 (1-based), jump
|
||||
pushed (`M-,` returns); `"0"` → line 1, no error; a 25-digit
|
||||
input → last line, no error; `"abc"` → status, no motion, and
|
||||
the jump stack is untouched (nothing pushed before validation).
|
||||
- Case ops: region upcase + selection cleared; mid-word `M-u`
|
||||
transforms cursor→word-end and moves the cursor there; cursor on
|
||||
separators skips forward to the next word; no word forward → no
|
||||
edit; `é` in the span is byte-identical while ASCII neighbors
|
||||
flip — and stays byte-identical regardless of process locale
|
||||
(explicit-range pin); capitalize: region `"hello WORLD"` →
|
||||
`"Hello World"` (per-word, the Emacs parity row), `"9abc a9bc"` →
|
||||
`"9abc A9bc"` (digit-led word keeps letters lowercase), and
|
||||
`"foo_bar baz"` → `"Foo_bar Baz"` (the named `_` deviation,
|
||||
pinned).
|
||||
- Transpose-chars: mid-line swap + cursor advance; EOL two-before
|
||||
swap; BOB/single-char no-op; multi-byte: swapping `é` and `x`
|
||||
yields intact UTF-8 both orders; across-newline swap; **cursor
|
||||
parked on a continuation byte → status, no edit** (fail-closed
|
||||
pin); **malformed-scalar pins**: a valid lead with a
|
||||
non-continuation trailing byte at the cursor (`a\xC3xb`), an
|
||||
overlong span behind the cursor (`\xE0\x80\x80b`), and a
|
||||
beyond-`U+10FFFF` span behind it (`\xF4\x90\x80\x80b`) each →
|
||||
status, buffer byte-identical. Undo restores the original in ONE
|
||||
step (grain pin).
|
||||
- Transpose-words: the full nine-position Emacs table from Ground
|
||||
truth, byte-for-byte including final cursor positions for the
|
||||
seven mutating rows; the two no-successor rows assert NO edit and
|
||||
NO cursor motion (the named deviation); separator bytes between
|
||||
the words preserved verbatim; one-step undo.
|
||||
- Zap chain matrix: `C-k` then `M-z` → one appended entry; `M-z`
|
||||
then `C-k` → one appended entry; `M-z M-z` → one appended entry;
|
||||
each of cancel, invalid (multi-char) input, no-match, and
|
||||
zero-length up-to BREAKS the chain (shape: `C-k`, failed/aborted
|
||||
zap, `C-k` → the two `C-k`s are separate ring entries); killed
|
||||
bytes land on the ring head and the clipboard slot; up-to-char
|
||||
leaves the target; match-at-cursor up-to is a zero-length no-op;
|
||||
**after-edit pin**: a completed zap fires `buffer.after-edit`
|
||||
exactly once (the RET-dispatch wrapper — this is why the suite
|
||||
dispatches RET rather than calling `accept()`).
|
||||
- **Origin-guard matrix** (multi-frontend harness; every case ends
|
||||
with frontend A's next `C-k` producing a FRESH ring entry):
|
||||
frontend A invokes `M-z`, frontend B dispatches the accept → no
|
||||
edit on either frontend, status, A's chain broken; A invokes, B
|
||||
dispatches C-g → no edit, A's chain broken, `origin_fid`
|
||||
cleared; A does `C-k`, `M-z`, then a pointer click on A, then
|
||||
accept → NO append to the pre-zap `C-k` entry (the
|
||||
`this_command` re-check), no edit, A's chain broken. Goto-line's
|
||||
milder origin guard: A invokes `M-g g`, B accepts "5" → no
|
||||
motion on either frontend, nothing on the jump stack.
|
||||
- **Silent-replacement matrix** (the R3 blocker; `on_cancel` never
|
||||
runs in either case): `C-k`, `M-z`, a programmatic
|
||||
`pmacs.minibuffer.read` replacing the zap session, the
|
||||
replacement closed by dispatched RET, then `C-k` → TWO separate
|
||||
ring entries (the uncommitted marker forces the second kill
|
||||
fresh); `C-k`, `M-z`, silent replacement, replacement closed,
|
||||
then a SECOND `M-z` completed cleanly → the zap's kill is a
|
||||
FRESH entry, not an append to the pre-abandonment `C-k` (the
|
||||
arm-time abandoned-marker break). A committed normal zap right
|
||||
after `C-k` still appends (the marker must not tax the healthy
|
||||
path). **Consumed-marker pin** (the adopted hardening): public
|
||||
Lua calls `commit_kill_prompt()` while zap's prompt is open →
|
||||
the accept aborts with status, no edit, chain broken.
|
||||
- `break_chain(fid)`: a non-integer or negative `fid` errors
|
||||
before any per-frontend state is touched.
|
||||
- `kill_range` API: invalid arguments (non-integer, negative,
|
||||
`start >= stop`, `stop > len`) error BEFORE any ring or buffer
|
||||
mutation; a rejected delete → `false, "rejected"`, ring
|
||||
untouched, chain broken; a transformed delete → the transformed
|
||||
edit stands, `false, "transformed", triple`, ring untouched,
|
||||
chain broken, and zap's guarded repair leaves the cursor
|
||||
translated and clamped (never past `buf:len()`).
|
||||
- Line ops: move down/up round-trips; first/last line no-ops;
|
||||
last-line-without-newline move and duplicate both preserve the
|
||||
no-trailing-newline invariant; duplicate places the cursor at the
|
||||
same column in the copy; join collapses the junction to one
|
||||
space, to zero when the previous line is blank; each is one undo
|
||||
step.
|
||||
- Region ops: sort/reverse/dedupe on a region including a
|
||||
region-ends-at-BOL exclusion case and a final-line-without-
|
||||
newline case; **byte-order pin**: `{"b", "A", "a", "B"}` sorts to
|
||||
`{"A", "B", "a", "b"}` regardless of process locale; dedupe count
|
||||
in status; no-region → status, no edit; one undo step each;
|
||||
selection cleared.
|
||||
- Intercept discipline, per Q#EC2: a rejecting intercept on each
|
||||
command class → status, no state change; a transforming intercept
|
||||
→ the intercept's result stands, cursor right-gravity-translated
|
||||
and clamped (pinned with an expanding replace that shrinks the
|
||||
buffer below the old cursor), selection cleared, no ring push; a
|
||||
context-switching intercept → ALL fix-up skipped, the switched-to
|
||||
window/buffer's cursor and selection untouched; **zero-length
|
||||
anchor pin** (Q#EC2 step 7): `begin_selection` at the cursor with
|
||||
no motion, then a clean mid-word `M-u` — a command whose clean
|
||||
target MOVES the cursor, so the case cannot pass vacuously — →
|
||||
no active region afterward (the dormant anchor must not
|
||||
re-activate as a selection spanning the cursor's move to the
|
||||
word end).
|
||||
- Trim: command trims multiple lines; cursor inside a trimmed run
|
||||
lands at the run start; cursor after a trimmed run shifts left
|
||||
correctly; undo grain = one step per trimmed line (pinned,
|
||||
named); partial sweep: a rejecting intercept on one line stops
|
||||
the sweep, reports the line, and cursor translation reflects
|
||||
every landed delete; **mid-sweep context switch**: a CLEAN delete
|
||||
whose intercept switches the active buffer stops the sweep at
|
||||
that delete — later (earlier-line) targets in the original
|
||||
buffer are untouched, and no fix-up lands in the switched-to
|
||||
context; `trim_on_save(true)` + `buffer.save` → file
|
||||
bytes on disk are trimmed, and the saveplace-recorded cursor
|
||||
reflects post-trim offsets (ordering pin); trim disabled
|
||||
(default) → save writes bytes untouched; **veto-immunity pin**: a
|
||||
rejecting intercept during on-save trim → the save still
|
||||
proceeds with a status report; another before-save callback's
|
||||
veto still vetoes (trim's `nil` return masks nothing);
|
||||
**unexpected-error pin**: an error raised inside the on-save trim
|
||||
(beyond the per-edit pcalls) → the save still proceeds AND the
|
||||
failure lands in the `pmacs.error` log (stubbed, the m9_6
|
||||
pattern) — never silently discarded.
|
||||
|
||||
No CRDT-specific suite: every editops edit is a daemon-peer edit on
|
||||
the dispatch or minibuffer-accept path with no optimistic-classifier
|
||||
contact — the same posture as comment-toggle (which ships without
|
||||
one).
|
||||
|
|
@ -314,6 +314,19 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/completion.lua"),
|
||||
)
|
||||
.expect("load completion builtin chunk");
|
||||
// Editing-conveniences pack (Q#EC9 ordering contract): MUST
|
||||
// load before saveplace.lua — editops registers its (gated,
|
||||
// default-off) trim-on-save callback at load time, and hook
|
||||
// callbacks run in registration order, so saveplace's
|
||||
// before-save cursor-record must observe post-trim text. Its
|
||||
// pmacs.killring.* references resolve at invoke time, so
|
||||
// loading before killring.lua is fine.
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/editops.lua"),
|
||||
include_str!("../builtin/runtime/editops.lua"),
|
||||
)
|
||||
.expect("load editops builtin chunk");
|
||||
// Arc 3: persistence builtins (saveplace + recentf). Load after
|
||||
// the LSP/completion runtimes; they subscribe to buffer hooks
|
||||
// and drive `pmacs.state` (inert until the state dir is
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue