-- 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-", "edit.move-line-up") bind("M-", "edit.move-line-down") bind("M-^", "edit.join-line")