fix(edit): PR #111 round 1 — scalar-valid UTF-8, per-word capitalize, trim error reporting

Finding 1: codepoint recognition is now full UTF-8 scalar validation
(shared second-byte constraint table: overlongs, surrogates, and
beyond-U+10FFFF all fail), and transpose validates the scalar AT the
cursor trailing-bytes-included — a valid lead with non-continuation
trailing bytes fails closed, as does a length-consistent overlong or
out-of-range span behind the cursor. Zap's single-codepoint check
uses the same validator as defense-in-depth (minibuffer contents
arrive as Rust-side UTF-8; the buffer-facing checks are the
load-bearing ones).

Finding 2: capitalize is per-word across the span — Emacs
capitalize-region parity, verified against Emacs 30.2 ("hello WORLD"
-> "Hello World", "9abc a9bc" -> "9abc A9bc"); the one remaining
deviation is named and pinned: `_` is a word constituent in this
pack's ASCII class, so "foo_bar" -> "Foo_bar" versus Emacs's
"Foo_Bar".

Finding 3: an unexpected error caught by the trim-on-save outer
pcall is no longer discarded — it reports on the status line AND the
*errors* buffer via pmacs.error (the autosave sweep convention),
both pcall'd, still never vetoing the save.

All three fixes bite-verified: the five new/updated acceptance cases
fail against the pre-fix editops.lua (72 total now). Framing at
revision 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vF4gQVozBWi38y1SJiGfQ
This commit is contained in:
Levi Neuwirth 2026-07-12 16:34:18 +01:00
parent f0a07f41c5
commit 87e88da024
3 changed files with 259 additions and 54 deletions

View File

@ -110,28 +110,49 @@ local function is_cont_byte(b)
return b >= 0x80 and b <= 0xBF
end
local function cp_len(lead)
if lead < 0x80 then return 1 end
if lead >= 0xC2 and lead <= 0xDF then return 2 end
if lead >= 0xE0 and lead <= 0xEF then return 3 end
if lead >= 0xF0 and lead <= 0xF4 then return 4 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)
if #s == 0 then return false end
local n = cp_len(s:byte(1))
if not n or n ~= #s then return false end
for i = 2, #s do
if not is_cont_byte(s:byte(i)) then return false end
end
return true
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 codepoint (fail closed — goto_byte guarantees no boundary
-- alignment).
-- 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
@ -142,9 +163,8 @@ local function prev_cp_start(buf, pos)
q = q - 1
steps = steps + 1
end
local lead = buf:slice(q, q + 1):byte(1)
local n = cp_len(lead)
if not n or q + n ~= pos then return nil, "malformed" end
local span = buf:slice(q, pos)
if scalar_len(span, 1) ~= pos - q then return nil, "malformed" end
return q
end
@ -162,25 +182,29 @@ local function ascii_lower(s)
end))
end
-- First word byte upcased when it is a letter; every other letter
-- downcased (single-span semantics, Q#EC4).
-- 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 first = nil
local out = {}
local in_word = false
for i = 1, #s do
if is_word_byte(s:byte(i)) then
first = i
break
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
local lowered = ascii_lower(s)
if not first then return lowered end
local b = lowered:byte(first)
if b >= 97 and b <= 122 then
return lowered:sub(1, first - 1)
.. string.char(b - 32)
.. lowered:sub(first + 1)
end
return lowered
return table.concat(out)
end
-- ---- Q#EC2 shared discipline ---------------------------------------
@ -353,13 +377,17 @@ pmacs.command.define {
or "transpose-chars: not enough characters")
return
end
local n2 = cp_len(at_b)
if not n2 or cursor + n2 > len then
-- 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 = buf:slice(cursor, cursor + n2)
local cp2 = head:sub(1, n2)
guarded_replace("transpose-chars", buf, s1, cursor + n2, cp2 .. cp1,
cursor + n2)
end
@ -824,13 +852,26 @@ end
-- 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 report via status, never block a save.
pcall(function()
-- 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)
-- nil return: never a veto.
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) --

View File

@ -58,6 +58,17 @@ Revision 5 (post-approval, R3's optional hardening adopted):
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/
@ -337,7 +348,16 @@ range gsub with a byte map — NOT `string.upper/lower` and NOT
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 = first word char upcased, rest of span downcased.
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
@ -345,13 +365,24 @@ Capitalize = first word char upcased, rest of span downcased.
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 boundaries by UTF-8
lead-byte scan on a small slice around the cursor; **a continuation
byte at the cursor position fails closed** (status, no edit) —
`goto_byte` does not guarantee boundary alignment, and "never split
a codepoint" must hold against a misaligned start, not assume it
away. Newlines participate (transpose across lines works). One
replace spanning exactly the two codepoints.
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:
@ -581,9 +612,15 @@ 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`) — trim
failure of any kind must report via status and never block the
save.
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
@ -658,12 +695,21 @@ harness the kill-ring suite already uses.
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).
(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). Undo restores the original in ONE step (grain pin).
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
@ -751,7 +797,11 @@ harness the kill-ring suite already uses.
(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).
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

View File

@ -306,11 +306,29 @@ fn capitalize_word_and_region() {
alt(&mut s, 'c');
assert_eq!(
buffer_text(&s),
"Hello world",
"single-span capitalize: first word char up, rest down"
"Hello World",
"per-word capitalize across the region (Emacs capitalize-region)"
);
}
#[test]
fn capitalize_is_per_word_with_the_packs_word_class() {
// Emacs 30.2 parity rows: a digit-led word keeps its letters
// lowercase; a letter after a digit is not a word start.
let mut s = editor_with("9abc a9bc");
exec(&s, "pmacs.editor.begin_selection(0)");
exec(&s, "pmacs.editor.goto_byte(9)");
alt(&mut s, 'c');
assert_eq!(buffer_text(&s), "9abc A9bc");
// The named deviation: `_` is a word constituent in this pack's
// ASCII class (Emacs's symbol-syntax `_` would give "Foo_Bar").
let mut s = editor_with("foo_bar baz");
exec(&s, "pmacs.editor.begin_selection(0)");
exec(&s, "pmacs.editor.goto_byte(11)");
alt(&mut s, 'c');
assert_eq!(buffer_text(&s), "Foo_bar Baz");
}
#[test]
fn case_ops_report_when_no_word_follows() {
let mut s = editor_with("foo ");
@ -406,6 +424,55 @@ fn transpose_chars_fails_closed_on_a_continuation_byte() {
assert!(status(&s).contains("multi-byte"));
}
/// Seed raw (possibly malformed) bytes via Lua \x escapes and compare
/// byte-identically Lua-side (`from_utf8_lossy` would mask differences).
fn seed_raw(s: &EditorState, lua_bytes: &str) {
exec(
s,
&format!("local b = pmacs.window.buffer(); b:replace(0, b:len(), \"{lua_bytes}\")"),
);
exec(s, "pmacs.editor.goto_byte(0)");
}
fn raw_equals(s: &EditorState, lua_bytes: &str) -> bool {
eval(
s,
&format!("local b = pmacs.window.buffer(); return b:slice(0, b:len()) == \"{lua_bytes}\""),
)
}
#[test]
fn transpose_chars_fails_closed_on_invalid_trailing_bytes() {
// A valid lead (C3) followed by a non-continuation byte: the
// scalar at the cursor must be validated whole, not length-only.
let mut s = editor_with("");
seed_raw(&s, "a\\xC3xb");
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 't');
assert!(raw_equals(&s, "a\\xC3xb"), "no edit on a malformed scalar");
assert!(status(&s).contains("malformed UTF-8 at the cursor"));
}
#[test]
fn transpose_chars_fails_closed_on_invalid_scalars_behind_the_cursor() {
// Length-consistent but scalar-invalid spans behind the cursor:
// an overlong three-byte encoding and a beyond-U+10FFFF four-byte
// encoding. Both scan back cleanly (lead + continuations) and
// must still fail closed.
let cases = [("\\xE0\\x80\\x80b", 3i64), ("\\xF4\\x90\\x80\\x80b", 4)];
for (bytes, cursor_pos) in cases {
let mut s = editor_with("");
seed_raw(&s, bytes);
exec(&s, &format!("pmacs.editor.goto_byte({cursor_pos})"));
ctrl(&mut s, 't');
assert!(raw_equals(&s, bytes), "no edit for {bytes}");
assert!(
status(&s).contains("malformed UTF-8 before the cursor"),
"fail-closed status for {bytes}"
);
}
}
// ---------------------------------------------------------------------------
// Transpose-words: the nine-position Emacs 30.2 table (Q#EC5)
// ---------------------------------------------------------------------------
@ -1175,6 +1242,53 @@ fn trim_on_save_failure_never_vetoes_the_save() {
let _ = std::fs::remove_file(&path);
}
#[test]
fn trim_on_save_unexpected_error_reports_and_still_saves() {
let path = temp_path("unexpected.txt");
std::fs::write(&path, "x \n").unwrap();
let mut s = EditorState::new();
exec(&s, "pmacs.editops.trim_on_save(true)");
// Capture the pmacs.error log (the m9_6 stub pattern — the
// `if pmacs.error` branch is a no-op without it).
exec(
&s,
r"
PMACS_ERROR_LOG = {}
pmacs.error = function(msg)
PMACS_ERROR_LOG[#PMACS_ERROR_LOG + 1] = msg
end
",
);
exec(
&s,
&format!(
"pmacs.buffer.from_file({})",
lua_str(path.to_str().unwrap())
),
);
// Force an error INSIDE trim, past the per-edit pcalls: trim's
// context snapshot reads pmacs.window.current(), which nothing
// else on the save path touches.
exec(
&s,
"TRIM_ORIG_WC = pmacs.window.current; pmacs.window.current = function() error('boom') end",
);
ctrl(&mut s, 'x');
ctrl(&mut s, 's');
exec(&s, "pmacs.window.current = TRIM_ORIG_WC");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"x \n",
"the save proceeded (trim aborted before any edit)"
);
let logged: String = eval(&s, "return PMACS_ERROR_LOG[1] or ''");
assert!(
logged.contains("delete-trailing-whitespace (on save) failed:"),
"the unexpected error reached the pmacs.error log, got: {logged}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn another_callbacks_veto_is_not_masked_by_trim() {
let path = temp_path("veto.txt");