fix(compile): PR #113 round 1 — coordinates, recovery, rules, types, EOF

Finding-by-finding (framing revision 7; every fix bite-verified via
scripts/bite against the pre-fix tree):

1. Stored coordinates must be finite integers, and both cursor walks
   are movement-bounded — they clamp at EOF, and the column walk
   clamps at the target row's EOL instead of marching onto later
   rows. An astronomical %d+ capture can no longer hang the editor.
2. The grep panel gains the same immediate buffer.after-edit
   recovery trigger as the compile slots: M-x buffer.undo after a
   COMPLETED search is marked synchronously.
3. The rustc arrow rule uses the framing's ([^:]+) spelling — paths
   with spaces capture whole.
4. All pattern captures are collected (index 4+ reads the real
   capture, not nil-as-column-0); capture indexes must be positive
   integers; a rule naming a column its match didn't produce rejects
   the match.
5. emit_text_raw is module-local — a user global could shadow the
   helper the terminal-event path depends on, and its error consumed
   the terminal event before pump cleanup/forget ran.
6. stdin/group spec fields reject wrong Lua types as hard errors;
   group is matched as a raw Value because mlua's bool conversion
   applies Lua truthiness ("true" would silently coerce).
7. resync also nils the public line_start_byte — total pre-marker
   anchor invalidation includes the byte anchor.
8. The inherited cwd resolves through
   pmacs.instance.identity().working_directory; the header always
   names a real path and relative error files get an explicit base.
9. New AnsiParser::finish() + parser:finish() (additions #5): a
   truncated multibyte sequence at process EOF surfaces as U+FFFD
   before the exit marker instead of vanishing.
10. The built-in default rules are a private deep copy — in-place
    mutations of the public table no longer survive the "using
    built-in defaults" degradation.

Eleven new tests (r1f1a/b–r1f10); bites: 9 fail against pre-fix
compile.lua, r1f2 against pre-fix default.lua, r1f6 against pre-fix
lua_bindings/mod.rs — all clean assertion failures. Gates: fmt,
clippy workspace all-targets, lib 1522, crdt lib 1696, compile
acceptance 45, crdt acceptance 1, m4 101, GPU 59, workspace sweep
2493/0, git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
Levi Neuwirth 2026-07-13 16:03:09 +01:00
parent a2b12dc9d6
commit d67d30bb64
6 changed files with 551 additions and 63 deletions

View File

@ -773,14 +773,44 @@ local function ensure_search_panel()
return p
end
-- Immediate command-path recovery for the panel (the Q#CM2 trigger
-- the compile slots already have): M-x/menu edits fire
-- buffer.after-edit, and after a COMPLETED search no producer write
-- or navigation may ever come — without this, an M-x buffer.undo
-- left corrupted output unmarked indefinitely (PR #113 round-1
-- finding 2). Hook edits don't re-fire the hook, so the resync
-- marker can be appended from here safely.
pmacs.hook.add("buffer.after-edit", function()
local cur = pmacs.window.buffer()
if cur and search_panel_alive() and cur == search_panel.buf then
search_panel_check_rev()
end
end)
-- Cursor walk via primitives (the lsp.lua visit idiom; 0-based).
-- Movement-bounded like compile.lua's walk: a match pointing past
-- EOF/EOL clamps instead of looping (the file may have changed on
-- disk since the worker scanned it).
local function search_move_cursor_to(line, col)
pmacs.editor.move_line_start()
while pmacs.editor.cursor_line() > 0 do
pmacs.editor.move_up()
end
for _ = 1, line do pmacs.editor.move_down() end
for _ = 1, col do pmacs.editor.move_right() end
for _ = 1, line do
local before = pmacs.editor.cursor_line()
pmacs.editor.move_down()
if pmacs.editor.cursor_line() == before then break end
end
local row = pmacs.editor.cursor_line()
for _ = 1, col do
local before = pmacs.editor.cursor()
pmacs.editor.move_right()
if pmacs.editor.cursor() == before then break end
if pmacs.editor.cursor_line() ~= row then
pmacs.editor.move_left()
break
end
end
end
local function visit_match(idx)

View File

@ -79,8 +79,10 @@ pmacs.command.define {
-- `severity` is an override; nil falls back to keyword sniffing on
-- the matched line. User-extensible from init.lua.
pmacs.compile.rules = {
-- rustc/cargo arrow lines: " --> src/foo.rs:12:4"
{ pattern = "%-%->%s+([^:%s]+):(%d+):(%d+)", file = 1, line = 2, col = 3 },
-- rustc/cargo arrow lines: " --> src/foo.rs:12:4". `[^:]+` (the
-- framing's spelling), NOT `[^:%s]+` — paths may contain spaces
-- (PR #113 round-1 finding 3).
{ pattern = "%-%->%s+([^:]+):(%d+):(%d+)", file = 1, line = 2, col = 3 },
-- gcc/clang/grep-format: "file:line:col:" (also matches most Unix tools)
{ pattern = "([^%s:][^:]*):(%d+):(%d+):", file = 1, line = 2, col = 3 },
-- Python tracebacks: 'File "foo.py", line 12'
@ -89,6 +91,13 @@ pmacs.compile.rules = {
{ pattern = "([^%s:][^:]*):(%d+):", file = 1, line = 2 },
}
-- A capture index must be a positive INTEGER (a fractional index
-- silently reads a neighbouring capture via Lua table coercion —
-- round-1 finding 4).
local function is_capture_index(v)
return type(v) == "number" and v >= 1 and v == math.floor(v)
end
local function rule_is_valid(rule)
if type(rule) ~= "table" then return false end
if type(rule.pattern) ~= "string" then return false end
@ -96,9 +105,9 @@ local function rule_is_valid(rule)
-- pattern is caught (and counted in the status note) here at
-- validation time, not silently at match time.
if not pcall(string.match, "", rule.pattern) then return false end
if type(rule.file) ~= "number" or rule.file < 1 then return false end
if type(rule.line) ~= "number" or rule.line < 1 then return false end
if rule.col ~= nil and (type(rule.col) ~= "number" or rule.col < 1) then return false end
if not is_capture_index(rule.file) then return false end
if not is_capture_index(rule.line) then return false end
if rule.col ~= nil and not is_capture_index(rule.col) then return false end
if rule.severity ~= nil and rule.severity ~= "error" and rule.severity ~= "warning" then
return false
end
@ -109,7 +118,19 @@ end
-- per entry (Q#CM4): a non-table container degrades to the built-in
-- defaults; malformed entries are skipped; one status note per run
-- counts the skips. Never raises — this feeds the per-frame pump.
local BUILTIN_RULES = pmacs.compile.rules
--
-- The defaults are a private deep copy taken at load time: an alias
-- of the public table would keep in-place user mutations live after
-- the "using built-in defaults" degradation (round-1 finding 10).
local BUILTIN_RULES = {}
for i, rule in ipairs(pmacs.compile.rules) do
local copy = {}
for k, v in pairs(rule) do
copy[k] = v
end
BUILTIN_RULES[i] = copy
end
local function validated_rules()
local rules = pmacs.compile.rules
if type(rules) ~= "table" then
@ -267,7 +288,11 @@ end
local function resync(slot)
local buf = slot.buf
for _, e in ipairs(slot.errors) do
-- Both in-buffer anchors: the display row AND the public byte
-- anchor — pre-marker byte offsets are exactly as untrustworthy
-- as rows after an unknown edit (round-1 finding 7).
e.row = nil
e.line_start_byte = nil
end
local len = buf:len()
buf:insert(len, DESYNC_MARKER, { bypass_intercept = true })
@ -389,18 +414,33 @@ local SEVERITY_STYLE = {
warning = { fg = 3 }, -- indexed yellow
}
-- A stored coordinate must be a finite integer ≥ 1: `%d+` happily
-- captures digit runs whose tonumber is astronomically large or
-- math.huge, and an unbounded value would drive the cursor walk
-- loops effectively forever (round-1 finding 1). The cursor walk
-- also clamps independently — belt and braces.
local function valid_coordinate(n)
return n ~= nil and n >= 1 and n < math.huge and n == math.floor(n)
end
local function parse_line(slot, line, abs_start)
if not slot.parse_errors then return end
for _, rule in ipairs(slot.rules) do
local ok, c1, c2, c3 = pcall(string.match, line, rule.pattern)
if ok and c1 then
local caps = { c1, c2, c3 }
-- Capture EVERYTHING the pattern produced: validation accepts
-- any positive integer index, so truncating at three silently
-- misread four-capture rules (round-1 finding 4).
local caps = { pcall(string.match, line, rule.pattern) }
local ok = table.remove(caps, 1)
if ok and caps[1] then
local file = caps[rule.file]
local lnum = tonumber(caps[rule.line])
local cnum = rule.col and tonumber(caps[rule.col]) or nil
-- 1-based contract; below-1 captures fail closed (a raw -1
-- would walk the cursor loops to a silent (0,0) landing).
if type(file) == "string" and lnum and lnum >= 1 and (cnum == nil or cnum >= 1) then
-- 1-based contract; below-1, non-integral, and non-finite
-- captures fail closed. A rule that NAMES a column capture the
-- match didn't produce also fails closed (silently storing
-- column 0 would misreport the location).
local col_ok = (rule.col == nil and cnum == nil) or valid_coordinate(cnum)
if type(file) == "string" and valid_coordinate(lnum) and col_ok then
local severity = rule.severity or sniff_severity(line)
slot.errors[#slot.errors + 1] = {
file = file,
@ -454,6 +494,19 @@ local function project_root_of_active()
return nil
end
-- The daemon's actual working directory: the last-resort cwd when
-- there is no explicit opt and no detectable project. Resolving it
-- (rather than leaving nil and printing "(inherited)") gives the
-- header a real path and relative error files an explicit base
-- (round-1 finding 8).
local function daemon_working_directory()
local ok, id = pcall(pmacs.instance.identity)
if ok and type(id) == "table" and type(id.working_directory) == "string" then
return id.working_directory
end
return nil
end
local function format_exit_marker(label, ev)
if ev.kind == "exited" then
return string.format("\n[%s exited with code %d]\n", label, ev.code or 0)
@ -465,12 +518,32 @@ local function format_exit_marker(label, ev)
return string.format("\n[%s exited]\n", label)
end
-- Terminal event: finalize the pending unterminated line (a final
-- Plain append at end, no overwrite/style tracking — markers and
-- headers. LOCAL by design: a global here would let user config
-- shadow a helper the terminal-event path depends on, and an error
-- thrown from that shadow would consume the terminal event before
-- pump cleanup/forget ran (round-1 finding 5).
local function emit_text_raw(slot, text)
local buf = slot.buf
buf:insert(buf:len(), text, { bypass_intercept = true })
slot.out_pos = buf:len()
if slot.parse_line_start > slot.out_pos then
slot.parse_line_start = slot.out_pos
end
end
-- Terminal event: drain the parser's cross-feed state (an
-- incomplete UTF-8 sequence at process EOF can never complete — the
-- parser's finish() emits its replacement character, round-1
-- finding 9), finalize the pending unterminated line (a final
-- diagnostic emitted without a trailing newline is complete at EOF
-- and must not be dropped — Q#CM4), then the exit marker.
local function finish_run(slot, ev)
if not check_rev(slot) then return end
local buf = slot.buf
if slot.parser then
apply_events(slot, slot.parser:finish())
end
local len = buf:len()
if slot.parse_errors and slot.parse_line_start < len then
parse_line(slot, buf:slice(slot.parse_line_start, len), slot.parse_line_start)
@ -489,17 +562,6 @@ local function finish_run(slot, ev)
end
end
-- Plain append at end, no overwrite/style tracking — markers and
-- headers.
function emit_text_raw(slot, text)
local buf = slot.buf
buf:insert(buf:len(), text, { bypass_intercept = true })
slot.out_pos = buf:len()
if slot.parse_line_start > slot.out_pos then
slot.parse_line_start = slot.out_pos
end
end
local function feed_bytes(slot, bytes)
if not check_rev(slot) then return end
apply_events(slot, slot.parser:feed(bytes))
@ -564,7 +626,7 @@ local function start_run(slot, cmdline, opts)
if cur and not pmacs.compile.is_generated_buffer(cur) then
slot.prev = cur
end
local cwd = opts.cwd or project_root_of_active()
local cwd = opts.cwd or project_root_of_active() or daemon_working_directory()
-- Supersede (Q#CM9): terminate the old group and tombstone its
-- pump entry; its terminal event still drives forget.
@ -587,7 +649,9 @@ local function start_run(slot, cmdline, opts)
local buf = slot.buf
local len = buf:len()
if len > 0 then buf:delete(0, len, { bypass_intercept = true }) end
local header = string.format("$ %s\nDirectory: %s\n\n", cmdline, cwd or "(inherited)")
-- The identity fallback should always resolve; "(unknown)" only
-- survives if the instance API itself failed.
local header = string.format("$ %s\nDirectory: %s\n\n", cmdline, cwd or "(unknown)")
buf:insert(0, header, { bypass_intercept = true })
slot.out_pos = buf:len()
slot.parse_line_start = slot.out_pos
@ -631,14 +695,32 @@ end
-- Cursor walk via primitives so overlay observers see the motion
-- (the lsp.lua visit idiom; 0-based line/col; the col walk shares
-- lsp.lua's inherited per-codepoint residual).
-- lsp.lua's inherited per-codepoint residual). Both walks stop when
-- movement stops moving — a diagnostic pointing past EOF/EOL clamps
-- there instead of looping to its nominal coordinate (round-1
-- finding 1; parse-time validation bounds the values, the clamp
-- bounds the walk regardless).
local function move_active_cursor_to(line, col)
pmacs.editor.move_line_start()
while pmacs.editor.cursor_line() > 0 do
pmacs.editor.move_up()
end
for _ = 1, line do pmacs.editor.move_down() end
for _ = 1, col do pmacs.editor.move_right() end
for _ = 1, line do
local before = pmacs.editor.cursor_line()
pmacs.editor.move_down()
if pmacs.editor.cursor_line() == before then break end -- EOF
end
local row = pmacs.editor.cursor_line()
for _ = 1, col do
local before = pmacs.editor.cursor()
pmacs.editor.move_right()
if pmacs.editor.cursor() == before then break end -- buffer end
if pmacs.editor.cursor_line() ~= row then
-- Ran off the line's end onto the next row: step back to EOL.
pmacs.editor.move_left()
break
end
end
end
local function resolve_error_path(slot, file)

View File

@ -1,7 +1,30 @@
# Compile-mode — framing (Arc 5 stage 1, terminal)
**Revision 6 — 2026-07-13. Status: awaiting approval; no branch, no
implementation.**
**Revision 7 — 2026-07-13. Status: implemented on branch
`compile-mode` (PR #113); revision 7 folds in PR round 1.**
Revision 7 (PR #113 round 1, findings 110): stored coordinates must
be finite integers and both cursor walks are movement-bounded
(clamping at EOF/EOL) — an astronomical `%d+` capture can no longer
hang the editor; the grep panel gains the same immediate
`buffer.after-edit` recovery trigger as the compile slots; the rustc
rule uses the framing's `([^:]+)` spelling (paths with spaces);
rule capture indexes must be positive integers, all pattern captures
are honored (not just the first three), and a rule that names a
column its match didn't produce rejects the match; the marker/header
append helper is module-local (a user global could shadow it and its
error consumed the terminal event before forget); `stdin`/`group`
spec fields reject wrong Lua types instead of silently defaulting
(strict boolean check — mlua truthiness would coerce `"true"`);
resync also nils the public `line_start_byte` (total pre-marker
anchor invalidation includes the byte anchor); the inherited cwd
resolves through `pmacs.instance.identity().working_directory` so
the header always names a real path; a new `parser:finish()`
(additions #5) flushes a truncated UTF-8 sequence at process EOF as
U+FFFD before the exit marker; the built-in default rules are a
private deep copy, immune to in-place mutation of the public table.
Eleven bite tests, each verified failing against the pre-fix tree
via `scripts/bite`.
Revision 6 (responding to review round 5, findings 14, plus the
follow-up blocker audit): group-aware final drain now has an absolute
@ -249,6 +272,11 @@ No protocol change, no frontend change.
immediately (zero-race; strictly better here than exposing
`close_stdin` post-spawn). `write_stdin` on such a process errors
with the existing stdin-not-piped message. Rejected under PTY.
The Lua spec parsing for `stdin` and `group` treats wrong types
as HARD errors (Revision 7) — a silently-defaulted `stdin = true`
or `group = "true"` would undo exactly the guarantees the fields
carry; `group` is matched as a raw Value because mlua's bool
conversion applies Lua truthiness.
2. **`group = true`** (`ProcessSpec`, pipes-only) — a full lifecycle
policy, not just a spawn flag:
- **Spawn**: `process_group(0)` — the child leads a fresh group.
@ -369,6 +397,13 @@ No protocol change, no frontend change.
same-length replaces, so undoing one changes content without
changing length. Revision increments on every edit, undo, and
redo.
5. **`AnsiParser::finish()` + `parser:finish()` (Revision 7)** —
stream-end finalization: the feed-boundary contract deliberately
buffers an incomplete UTF-8 sequence for the next feed, but at
process EOF there is no next feed — `finish()` emits U+FFFD for
the pending prefix (the same posture an interrupting control byte
gets) and flushes the text run. Compile-mode calls it once at the
terminal event, before finalizing the pending line.
## Decisions
@ -457,10 +492,12 @@ invoke time, so commands/runtime load order stays irrelevant for it.
length: the CR-overwrite path emits same-length replaces, so an
undone overwrite changes content while preserving length — a
length guard provably misses it (round-3 finding 3). **Anchor
invalidation is total (Revision 5):** a revision mismatch
invalidation is total (Revisions 5/7):** a revision mismatch
carries no edit range, and a same-length replace can remove or
move newlines while every anchor stays in bounds — so ALL
**pre-marker** in-buffer anchors are dropped on any mismatch.
**pre-marker** in-buffer anchors are dropped on any mismatch,
the public `line_start_byte` included (a pre-marker byte offset
is exactly as untrustworthy as a row).
Immediately after recovery, `n`/`p` report "no more errors" and
RET reports "no error on this line" for the damaged pre-marker
content. The marker establishes a fresh anchor epoch: diagnostic
@ -511,8 +548,10 @@ invoke time, so commands/runtime load order stays irrelevant for it.
`GROUP_TERM_GRACE + 2 * READER_SEND_POLL_INTERVAL`; eliminating it
requires the deferred tick-driven drain.
- `cwd`: explicit opt > `pmacs.project.detect(active buffer
path).root` > the daemon process cwd. The header line prints the
resolved cwd.
path).root` > `pmacs.instance.identity().working_directory`
(Revision 7 — actually *resolved*, so the header always names a
real path and relative error files get an explicit base rather
than an implicit pass-through).
### Q#CM4 — Error parsing: ordered Lua-pattern rules, parsed at newline time
@ -539,26 +578,39 @@ invoke time, so commands/runtime load order stays irrelevant for it.
a named deferral.
- **Coordinate normalization:** rule captures follow compiler
convention — **1-based line and column**; that is the public rule
contract. Captured values **below 1 fail closed** (Revision 4):
the match is discarded — `%d+` accepts `0`, and an unvalidated
`0 - 1 = -1` would walk the cursor loops to a silent (0,0)
landing. Valid stored entries are **0-based** (what
`move_active_cursor_to` consumes): `line - 1`; `col - 1` when
captured, else `0`. The Python rule has no column: `line - 1`,
col `0`. (Grep normalization is in Q#CM7.)
contract. Captured values **below 1, non-integral, or non-finite
fail closed** (Revisions 4/7): the match is discarded — `%d+`
accepts `0` (an unvalidated `0 - 1 = -1` would walk the cursor
loops to a silent (0,0) landing) and also accepts digit runs whose
`tonumber` is `math.huge` (an unbounded loop bound). The cursor
walks are independently **movement-bounded** (Revision 7): they
stop when motion stops moving, clamping at EOF, and the column
walk clamps at the target row's EOL instead of marching onto later
rows. Rule capture **indexes** must be positive integers; all
pattern captures are collected (an index above three reads the
real capture, not nil-as-column-0); a rule that names a column
capture its match didn't produce rejects the match. Valid stored
entries are **0-based** (what `move_active_cursor_to` consumes):
`line - 1`; `col - 1` when captured, else `0`. The Python rule has
no column: `line - 1`, col `0`. (Grep normalization is in Q#CM7.)
- **Parse each line exactly once, when its `\n` lands** — CR/erase
rewrites happen within the current *unterminated* line, so the
content parsed at newline time is the line's final form. **At the
terminal event, the pending unterminated line (if any) is
finalized and parsed once before the exit marker is appended.**
terminal event: first `parser:finish()` drains the cross-feed
state (a truncated multibyte sequence at process EOF surfaces as
U+FFFD instead of vanishing — Revision 7, additions #5), then the
pending unterminated line (if any) is finalized and parsed once
before the exit marker is appended.**
- **Rule-table robustness (fail-closed per entry):** on each run's
first use the table is validated — non-table `pmacs.compile.rules`
degrades to the built-in defaults with one status note (the
pair.lua non-table-sets precedent); malformed entries are skipped
and pattern matching is pcall'd so an invalid Lua pattern skips
that entry too. One status note per run counts the skipped
entries; a later valid rule still matches; the per-frame pump
never raises.
degrades to **a private deep copy of the built-in defaults**
(Revision 7: an alias of the public table would keep in-place user
mutations live after the degradation); malformed entries are
skipped, invalid Lua patterns are caught (and counted) at
validation time via a probe match, and match-time pattern calls
stay pcall'd as belt-and-braces. One status note per run counts
the skipped entries; a later valid rule still matches; the
per-frame pump never raises.
- Each match appends `{ file, line, col, severity,
line_start_byte }` to the run's ordered error list (reset per
run). Relative paths resolve against the run's cwd.
@ -630,6 +682,11 @@ user puts it (an auto-scroll option is deferred).
Checking only process-pump appends is insufficient: a no-hook edit to
`*search-results*` followed by a worker batch would otherwise advance
the expected revision and permanently mask the external edit.
- **The panel has the same immediate `buffer.after-edit` recovery
trigger as the compile slots (Revision 7):** after a COMPLETED
search no producer write or navigation may ever come, so an `M-x
buffer.undo` in the panel must be marked synchronously, not on the
next guarded operation.
- **Root retention across interactive supersedes (Revision 3):** the
panel stores the root each search ran with. Resolution order:
explicit `opts.root` > *if the active buffer is the results panel,

View File

@ -393,6 +393,26 @@ impl AnsiParser {
events
}
/// Stream-end finalization. The feed-boundary contract keeps an
/// incomplete UTF-8 sequence buffered because its trailing bytes
/// are expected in the next feed — but at process EOF there IS no
/// next feed, so the pending prefix can never complete. Emit
/// U+FFFD for it (the same posture `flush_text_run` takes when a
/// control byte interrupts a sequence) and flush the resulting
/// text run. Idempotent once drained. First consumer:
/// compile-mode's terminal-event path (Q#CM4).
pub fn finish(&mut self) -> Vec<AnsiEvent> {
let mut events = Vec::new();
self.flush_pending_utf8_as_replacement();
if !self.text_run.is_empty() && !self.alt_screen_active {
let run = std::mem::take(&mut self.text_run);
events.push(AnsiEvent::Text(run));
} else {
self.text_run.clear();
}
events
}
fn feed_byte(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
// ESC-anywhere rule: ECMA-48 §10.2 ("Cancel"). Aborts any
// in-progress sequence and starts a fresh Escape state. The

View File

@ -3009,6 +3009,19 @@ impl UserData for AnsiParserLua {
Ok(())
});
// Stream-end finalization: flushes cross-feed state (an
// incomplete UTF-8 sequence becomes the replacement
// character). Compile-mode calls this once at the process's
// terminal event (Q#CM4; PR #113 round-1 finding 9).
methods.add_method("finish", |lua, this, ()| {
let events = this.0.borrow_mut().finish();
let out = lua.create_table_with_capacity(events.len(), 0)?;
for (i, ev) in events.iter().enumerate() {
out.set(i + 1, event_to_lua_table(lua, ev)?)?;
}
Ok(out)
});
methods.add_meta_method(mlua::MetaMethod::ToString, |_, _this, ()| {
Ok("AnsiParser".to_string())
});
@ -6988,12 +7001,15 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
// Compile-mode process shape (Q#CM3). Both options are
// pipe-mode-only; the supervisor rejects them at spawn under PTY
// so misconfiguration surfaces as a spawn error, not silence.
let stdin = match table
.get::<Option<String>>("stdin")
.ok()
.flatten()
.as_deref()
{
// Type errors are HARD errors, not silent defaults: `stdin =
// true` quietly becoming a piped stdin (hang) or `group =
// "true"` quietly becoming false (descendant leak) would undo
// exactly the guarantees these fields exist to carry (PR #113
// round-1 finding 6).
let stdin_raw: Option<String> = table.get("stdin").map_err(|_| {
mlua::Error::external("stdin must be the string \"piped\" or \"null\"".to_owned())
})?;
let stdin = match stdin_raw.as_deref() {
None | Some("piped") => crate::process::StdinMode::Piped,
Some("null") => crate::process::StdinMode::Null,
Some(other) => {
@ -7002,11 +7018,19 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
)));
}
};
let group = table
.get::<Option<bool>>("group")
.ok()
.flatten()
.unwrap_or(false);
// Read as a raw Value: mlua's `bool` conversion applies Lua
// truthiness, so `group = "true"` would silently coerce instead
// of erroring.
let group = match table.get::<mlua::Value>("group") {
Ok(mlua::Value::Nil) | Err(_) => false,
Ok(mlua::Value::Boolean(b)) => b,
Ok(other) => {
return Err(mlua::Error::external(format!(
"group must be a boolean; got {}",
other.type_name()
)));
}
};
Ok(ProcessSpec {
label,
command,

View File

@ -1447,3 +1447,278 @@ fn acc33_round_trip_input_is_set_on_generated_buffers() {
"*search-results*"
);
}
// ---------------------------------------------------------------------------
// PR #113 round 1 — bite tests (one per finding; each observed
// failing against the pre-fix tree via scripts/bite)
// ---------------------------------------------------------------------------
#[test]
fn r1f1_non_finite_coordinates_fail_closed() {
// A 400-digit line capture tonumbers to math.huge; pre-fix it
// was stored and any visit walked forever.
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
let digits = "9".repeat(400);
compile_and_finish(
&mut s,
&format!("printf 'h.c:{digits}:1: error: e\\n'"),
dir.path(),
);
assert!(
compile_errors(&s).is_empty(),
"a non-finite line coordinate must be discarded; got {:?}",
compile_errors(&s)
);
}
#[test]
fn r1f1_beyond_eol_column_clamps_to_the_target_row() {
// Column past EOL: pre-fix the walk marched move_right across
// newlines and landed rows away from the diagnostic's line (and
// an astronomical value walked effectively forever).
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("w.c"), "ab\ncd\nef\n").unwrap();
let mut s = editor();
compile_and_finish(&mut s, "printf 'w.c:1:500: error: e\\n'", dir.path());
press(&mut s, KeyCode::Char('n'));
press(&mut s, KeyCode::Enter);
assert!(active_buffer_name(&s).ends_with("w.c"));
let line: i64 = eval(&s, "return pmacs.editor.cursor_line()");
assert_eq!(
line, 0,
"the column walk must clamp at the target row's EOL, not run \
onto later rows"
);
}
#[test]
fn r1f2_grep_command_path_undo_after_completed_search_recovers() {
let dir = tempfile::tempdir().unwrap();
grep_fixture(dir.path());
let mut s = editor();
search(&s, "zqxvbn_needle_77", dir.path());
assert!(pump_until(&mut s, 10_000, search_done), "search completes");
// M-x buffer.undo in the completed panel: no producer write or
// navigation may ever come — recovery must be immediate via the
// buffer.after-edit subscription.
alt(&mut s, 'x');
type_str(&mut s, "buffer.undo");
press(&mut s, KeyCode::Enter);
let text = named_text(&s, "*search-results*");
assert!(
text.contains(DESYNC),
"desync marker must appear immediately in the grep panel; \
buffer:\n{text}"
);
assert!(
errors_buffer(&s).is_empty(),
"clean *errors*: {}",
errors_buffer(&s)
);
}
#[test]
fn r1f3_rustc_arrow_paths_with_spaces_parse() {
let dir = tempfile::tempdir().unwrap();
let script = write_script(
dir.path(),
"space.sh",
"printf ' --> /tmp/my dir/foo.rs:12:4\\n'\n",
);
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
assert_eq!(
compile_errors(&s),
vec![("/tmp/my dir/foo.rs".to_owned(), 11, 3, None)],
"the rustc rule must capture space-containing paths whole \
(pre-fix the two-part fallback recorded arrow junk at col 0)"
);
}
#[test]
fn r1f4_capture_indexes_above_three_and_absent_columns() {
let dir = tempfile::tempdir().unwrap();
// (a) a valid four-capture rule with col = 4 must store the
// fourth capture, not silently column 0.
let mut s = editor();
exec(
&s,
r#"
pmacs.compile.rules = {
{ pattern = "(q%.c):(%d+):(x):(%d+)", file = 1, line = 2, col = 4 },
}
"#,
);
compile_and_finish(&mut s, "printf 'q.c:7:x:9 error\\n'", dir.path());
assert_eq!(
compile_errors(&s),
vec![("q.c".to_owned(), 6, 8, Some("error".to_owned()))],
"capture index 4 must be honored"
);
// (b) fractional indexes are malformed; (c) a rule that names a
// column its match didn't produce rejects the match.
let mut s = editor();
exec(
&s,
r#"
pmacs.compile.rules = {
{ pattern = "(a%.c):(%d+):", file = 1, line = 2, col = 1.5 },
{ pattern = "(r%.c):(%d+):?(%d*)", file = 1, line = 2, col = 3 },
}
"#,
);
compile_run(&s, "printf 'a.c:3: e\\nr.c:5: e\\n'", dir.path());
assert!(
status(&s).contains("skipped 1 malformed"),
"the fractional index is rejected at validation; got: {}",
status(&s)
);
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")));
assert!(
compile_errors(&s).is_empty(),
"an empty column capture under a col-naming rule must reject \
the match, not store column 0; got {:?}",
compile_errors(&s)
);
}
#[test]
fn r1f5_user_global_cannot_shadow_the_marker_helper() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
// A hostile (or merely colliding) user global with the helper's
// old name: pre-fix this replaced the module's function and its
// error consumed the terminal event before forget ran.
exec(&s, "_G.emit_text_raw = function() error('shadowed') end");
compile_and_finish(&mut s, "echo fine", dir.path());
assert!(
compilation_text(&s).contains("[compile exited with code 0]"),
"the exit marker must come from the module's local helper"
);
assert!(
errors_buffer(&s).is_empty(),
"no error spam: {}",
errors_buffer(&s)
);
assert!(
pump_until(&mut s, 3_000, |s| process_count(s) == 0),
"terminal-event cleanup (forget) must have run"
);
}
#[test]
fn r1f6_wrong_spec_types_error_instead_of_defaulting() {
let s = editor();
let (ok, err): (bool, String) = eval(
&s,
r#"
local ok, err = pcall(pmacs.process.spawn,
{ label = "t", command = "/bin/true", stdin = true })
return ok, tostring(err)
"#,
);
assert!(!ok, "stdin = true (boolean) must be a hard error");
assert!(err.contains("stdin must be"), "pointed message; got: {err}");
let (ok, err): (bool, String) = eval(
&s,
r#"
local ok, err = pcall(pmacs.process.spawn,
{ label = "t", command = "/bin/true", group = "true" })
return ok, tostring(err)
"#,
);
assert!(!ok, "group = \"true\" (string) must be a hard error");
assert!(
err.contains("group must be a boolean"),
"pointed message; got: {err}"
);
}
#[test]
fn r1f7_resync_invalidates_the_public_byte_anchor() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
compile_and_finish(&mut s, "printf 'a.c:1:1: error: e\\n'", dir.path());
let has_anchor: bool = eval(
&s,
"return pmacs.compile.errors()[1].line_start_byte ~= nil",
);
assert!(has_anchor, "pre-desync entries carry the byte anchor");
// Trigger the guard through the command path.
alt(&mut s, 'x');
type_str(&mut s, "buffer.undo");
press(&mut s, KeyCode::Enter);
assert!(compilation_text(&s).contains(DESYNC), "marker appended");
let has_anchor: bool = eval(
&s,
"return pmacs.compile.errors()[1].line_start_byte ~= nil",
);
assert!(
!has_anchor,
"total pre-marker anchor invalidation includes the public \
line_start_byte, not just the display row"
);
}
#[test]
fn r1f8_inherited_cwd_resolves_to_the_daemon_working_directory() {
// Pathless scratch buffer, no opts.cwd, no project: the header
// must print the real working directory, and relative error
// paths must resolve against it explicitly.
let mut s = editor();
exec(&s, "pmacs.compile.run('echo hi')");
assert!(pump_until(&mut s, 10_000, |s| compilation_text(s)
.contains("[compile exited")));
let cwd = std::env::current_dir().unwrap().display().to_string();
let text = compilation_text(&s);
assert!(
text.contains(&format!("Directory: {cwd}")),
"the header must name the daemon's actual working directory; \
got:\n{text}"
);
assert!(
!text.contains("(inherited)") && !text.contains("(unknown)"),
"no placeholder when the identity API is available:\n{text}"
);
}
#[test]
fn r1f9_truncated_utf8_at_eof_becomes_the_replacement_character() {
let dir = tempfile::tempdir().unwrap();
// \303 (0xC3) opens a two-byte sequence that never completes:
// the parser's cross-feed buffer holds it, and only the new
// stream-end finish() can flush it as U+FFFD.
let script = write_script(dir.path(), "trunc.sh", "printf 'abc\\303'\n");
let mut s = editor();
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
let text = compilation_text(&s);
assert!(
text.contains("abc\u{FFFD}"),
"the truncated sequence must surface as U+FFFD, not vanish; \
buffer:\n{text}"
);
}
#[test]
fn r1f10_builtin_default_rules_survive_in_place_mutation() {
let dir = tempfile::tempdir().unwrap();
let mut s = editor();
// Corrupt an entry IN PLACE, then degrade the container: the
// "built-in defaults" fallback must be a true copy, not an alias
// of the mutated table.
exec(
&s,
"pmacs.compile.rules[2] = 'junk'; pmacs.compile.rules = 42",
);
compile_and_finish(&mut s, "printf 'foo.c:7:2: warning: w\\n'", dir.path());
assert_eq!(
compile_errors(&s),
vec![("foo.c".to_owned(), 6, 1, Some("warning".to_owned()))],
"the gcc three-part rule from the TRUE defaults must match \
(the aliased pre-fix table lost it and the two-part fallback \
stored column 0)"
);
}