feat(generated-buffers): dired and listview adopt the authorized write

Stage 1 of generated-buffer immutability
(docs/generated-buffer-immutability-framing.md, revision 5). Closes the
two families the bug is reachable on WITHOUT `M-x`: `compile.lua` and the
search panel rebind all seven undo chords to a no-op, but `dired.lua` and
`listview.lua` rebind nothing, so a bare `C-/` emptied a listing and a
panel. The cheap half is also the exposed half.

An intercept is not read-only. `Buffer::undo` reaches the rope through
`ensure_writable` and never consults the intercept chain, so the
erroring-intercept-plus-`bypass_intercept`-over-a-writable-rope idiom
guarded the edit path and left the history path open. Rebinding chords
does not close it: `M-x buffer.undo` is dispatchable on every buffer in
the tree.

- `dired.lua`'s `paint` and `listview.lua`'s `render` write through
  `pmacs.buffer.set_generated_contents` — lift the lock, whole-buffer
  replace skipping intercepts, discard history, re-assert the lock, fan
  the `Edit` out. Zero `bypass_intercept` writes remain in either file.
- Both keep their named erroring intercept and `set_round_trip_input`.
  The layering at `terminal.lua:351-366` is unchanged: the rope lock
  protects the daemon copy, round-trip input protects a semantic
  frontend's own mirror, and neither substitutes for the other.
- Q#GB13 — `listview.ensure_panel` stops adopting a same-named foreign
  buffer. Ownership is the `panels` table; a collision disambiguates
  `<2>`..`<99>` and raises at the limit, matching `dired.lua:476-504`.
  This is a prerequisite of the lock, not a follow-up: the arc removes
  the `M-x buffer.undo` that was the only recovery from a clobber.
- Q#GB18 — `panels` becomes a compacting list keyed by identity. It was
  written under the requested name and read back under the actual name,
  which a disambiguated panel breaks: `RET`, `g` and `q` fail closed and
  silently, and `listview.open`'s capture guard fails OPEN, capturing a
  panel as its own `q` target — the chained-panel loop its comment says
  it prevents. Ships in the same commit as the disambiguation by the
  framing's ordering constraint.
- Q#GB6 — `EditorCore::notify_buffer_edit` clamps each window coordinate
  against its own post-edit bound, unconditionally. `cursor` is a byte
  position bounded by `Buffer::len`; `view_top` is a line index bounded
  by `TextView::line_count`, and a replace can grow in bytes while
  collapsing lines, so "the buffer shrank" is not a usable trigger. This
  fixes a shipped defect that reaches terminal copy mode.
- Q#GB16(a) — locking these families disables fold CREATION on them,
  because `document_bytes` is spelled `is_read_only()`. Accepted and
  stated rather than shipped silently; the status string now names the
  read-only lock instead of claiming "not a document buffer".

Acceptance: 10 new criteria in `listview_acceptance` (16 total), 6 in
`dired_acceptance` (31 total), 2 in `terminal_copy_mode_acceptance`.
Every criterion's falsifying mutation was run: 5 bite by revert against
`githubsucks/main`, 9 by a named one-line mutation.

Two framing corrections, both recorded in the tests rather than worked
around silently:

- Stage 1 criterion 5 is unreachable as written. `Buffer::apply_edit`
  (`src/buffer.rs:773`) and `begin_edit` (`:725`) call `ensure_writable`
  as their FIRST statement while the intercept chain runs later inside
  `apply_edit_inner` (`:1072`), so once this arc's lock is installed an
  ordinary edit can never reach the intercept. Restated at the one point
  where the two are distinguishable — the lock lifted — which is the
  state the intercept genuinely still covers.
- Criterion 7 cannot bite at the listview adopter. `listview.refresh`
  and `listview.open` both follow `render` with `window.switch_buffer`,
  which rebuilds the `TextView` from scratch and masks a dropped
  fan-out. `dired.revert` does not, so the dired half carries the bite;
  it fails under the mutation with the reported
  `assertion failed: end <= self.len()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
Levi Neuwirth 2026-07-29 09:50:09 -04:00
parent 300cbc4ef0
commit e1b859fd4f
7 changed files with 1166 additions and 38 deletions

View File

@ -364,11 +364,23 @@ local function render_text(handle)
return table.concat(lines, "\n")
end
-- Dired's own writes are the only ones that reach the buffer: the
-- read-only intercept rejects everything else, and this bypasses it.
-- Dired's own writes are the only ones that reach the buffer, and this
-- is the one authorized door (Q#GB1,
-- docs/generated-buffer-immutability-framing.md).
--
-- `set_generated_contents` lifts the rope's `read_only`, replaces the
-- whole buffer skipping intercepts, discards the resulting history and
-- re-asserts the lock --- all inside one registry borrow, so the buffer
-- is never observably unlocked. The erroring intercept this replaces a
-- bypass write beside is KEPT: it guards the edit path with a named
-- error, but `Buffer::undo` reaches the rope through `ensure_writable`
-- and never consults the intercept chain, so a listing protected by an
-- intercept alone was emptied by a bare `C-/` --- dired rebinds no undo
-- chord --- and by `M-x buffer.undo`, which no rebinding can remove.
-- Only rope-level `read_only` closes that, and only the pairing keeps
-- this repaint working after it.
local function paint(handle)
local text = render_text(handle)
handle.buf:replace(0, handle.buf:len(), text, { bypass_intercept = true })
pmacs.buffer.set_generated_contents(handle.buf, render_text(handle))
end
-- ---------------------------------------------------------------------------

View File

@ -5,11 +5,16 @@
-- wholesale re-render, buffer-local RET/n/p/g/q keymap, a
-- line->item map, previous-buffer capture + `q` restore, and the two
-- disciplines the hand-rolled original lacks --- a read-only
-- intercept (Q#P3; the panel's own renders write with
-- bypass_intercept) and the Q#P6 round-trip-input mark, so a
-- intercept (Q#P3) and the Q#P6 round-trip-input mark, so a
-- semantic frontend's RET dispatches into the visit binding instead
-- of optimistically inserting a newline.
--
-- Generated-buffer immutability (Q#GB1, docs/generated-buffer-immutability-framing.md):
-- a panel's rope is genuinely read-only, and `render` is its owner's one
-- authorized door through the lock. The intercept alone protected the
-- edit path and left the history path open, so `C-/` emptied a panel.
-- Ownership is the `panels` table, never a name match (Q#GB13/Q#GB18).
--
-- Panels are buffers, so both frontends render them with zero
-- protocol change (Q#P2: switch-in-place; the GPU cannot show
-- splits). Framing: docs/lsp-panels-framing.md.
@ -24,9 +29,37 @@
pmacs.listview = pmacs.listview or {}
-- name -> { buffer, prev, header, line_to_item, on_visit, on_refresh }
-- panels: array of
-- { requested_name, buffer, prev, header, line_to_item, on_visit, on_refresh }
--
-- A LIST scanned by identity, not a name-keyed map (Q#GB18). `panels`
-- used to be written under the name the CALLER asked for and read back
-- under the buffer's ACTUAL name; those are the same string only while
-- `ensure_panel` adopts whatever buffer already carries the name. Once
-- ownership disambiguates a collision to `*references*<2>` (Q#GB13), a
-- name-keyed lookup can never find its own record, and every consumer
-- below fails: `RET`, `g` and `q` fail closed and silently, while
-- `open`'s capture guard fails OPEN and captures a panel as its own `q`
-- target --- the chained-panel loop its comment says it prevents.
--
-- Keyed by linear scan over `BufferIdLua.__eq` rather than by table key
-- for the same reason dired's `handles` is (dired.lua:120-140): two
-- BufferIdLua values for the same buffer are distinct userdata, so a
-- `panels[buf]` lookup would miss. `compile.lua`'s `slot_for_buffer`
-- is the third instance of this shape; listview adopts it rather than
-- inventing a fourth.
--
-- Dead panels are compacted out on every scan. A map held at most one
-- entry per name and self-limited; a list does not, so killing and
-- reopening `*references*` ten times would otherwise leave nine dead
-- records for every scan to walk.
local panels = {}
-- How far the `<2>`, `<3>`, ... disambiguation walks before giving up.
-- dired.lua:474's constant, same value, same give-up-rather-than-adopt
-- rule.
local NAME_VARIANT_LIMIT = 99
local function find_buffer_by_name(name)
for _, id in ipairs(pmacs.buffer.list()) do
local ok, d = pcall(pmacs.describe.buffer, id)
@ -35,18 +68,52 @@ local function find_buffer_by_name(name)
return nil
end
local function live_panels()
local live = {}
for _, p in ipairs(panels) do
local ok, valid = pcall(p.buffer.is_valid, p.buffer)
if ok and valid then live[#live + 1] = p end
end
panels = live
return live
end
-- The record for the panel `spec.name` asked for. Stable across
-- disambiguation: a repeated `listview.open{ name = "*references*" }`
-- must reach the same panel even when its buffer is called
-- `*references*<2>`.
local function panel_for_requested_name(name)
for _, p in ipairs(live_panels()) do
if p.requested_name == name then return p end
end
return nil
end
-- The record that owns `buf`, or nil. This is the identity question
-- every command below actually asks.
local function panel_for_buffer(buf)
if buf == nil then return nil end
for _, p in ipairs(live_panels()) do
if p.buffer == buf then return p end
end
return nil
end
-- The panel record whose buffer the active window shows, or nil.
local function panel_for_current_buffer()
local buf = pmacs.window.buffer()
if not buf then return nil end
local ok, d = pcall(pmacs.describe.buffer, buf)
if not (ok and d) then return nil end
return panels[d.name]
local function active_panel()
return panel_for_buffer(pmacs.window.buffer())
end
-- Wholesale re-render: header + one line per row, rebuilding the
-- line->item map (data lines are 1-based; the header is line 0).
-- Panel writes bypass the read-only intercept.
--
-- One `set_generated_contents` (the owner-authorized write) rather than
-- a delete-all + insert-all pair through `bypass_intercept`. The
-- intercept guarded the edit path and left the HISTORY path open, so a
-- bare `C-/` --- listview rebinds no undo chord --- emptied the panel;
-- `M-x buffer.undo` did too, and no rebinding can remove that. The
-- primitive lifts the rope lock, writes, discards the history and
-- re-asserts the lock, all inside one registry borrow.
local function render(p, rows)
local lines = { p.header }
p.line_to_item = {}
@ -54,11 +121,7 @@ local function render(p, rows)
lines[#lines + 1] = row.text
p.line_to_item[#lines - 1] = row.item
end
local body = table.concat(lines, "\n")
local buf = p.buffer
local len = buf:len()
if len > 0 then buf:delete(0, len, { bypass_intercept = true }) end
if #body > 0 then buf:insert(0, body, { bypass_intercept = true }) end
pmacs.buffer.set_generated_contents(p.buffer, table.concat(lines, "\n"))
end
-- Re-seat the cursor on data line `line` (1-based, clamped).
@ -87,19 +150,53 @@ local function bind_local_keymap(buf)
bind("q", "listview.quit")
end
-- Build (or adopt) the persistent panel record for `name`. Handles a
-- user-killed panel buffer by recreating it.
-- Build the persistent panel record for `name`. A user-killed panel
-- buffer is compacted out by `live_panels`, so the next `open` builds a
-- fresh record rather than resurrecting a dead one.
--
-- Q#GB13: found-by-name is NOT adoption. `pmacs.buffer.create` takes any
-- caller-chosen name, so a foreign buffer may already be called
-- `*references*`; this used to adopt it, clobber the user's bytes, and
-- install an erroring intercept whose handle it discarded --- leaving
-- the user's buffer permanently un-editable. Rendering through
-- `set_generated_contents` would additionally lock its rope and clear
-- the history, removing the `M-x buffer.undo` that is currently the only
-- way back. So ownership is "this buffer is in `panels`", a name
-- collision disambiguates `<2>`..`<99>`, and exhausting the limit raises
-- rather than adopting --- the rule terminal.lua:300-305 states and
-- dired.lua:476-504 already implements.
local function ensure_panel(name)
local p = panels[name]
if p and p.buffer:is_valid() then return p end
local buf = find_buffer_by_name(name) or pmacs.buffer.create(name)
p = { buffer = buf, line_to_item = {} }
panels[name] = p
-- Read-only (Q#P3): every non-bypass edit is rejected. The
-- intercept lives as long as the buffer; no teardown (the
-- buffer-list precedent for its keymap).
local p = panel_for_requested_name(name)
if p then return p end
local actual = name
if find_buffer_by_name(actual) then
local unique = nil
for i = 2, NAME_VARIANT_LIMIT do
local candidate = string.format("%s<%d>", name, i)
if find_buffer_by_name(candidate) == nil then
unique = candidate
break
end
end
if unique == nil then
error(string.format("listview: %s is taken and no free variant remains", name))
end
actual = unique
end
local buf = pmacs.buffer.create(actual)
p = { requested_name = name, buffer = buf, line_to_item = {} }
panels[#panels + 1] = p
-- Read-only (Q#P3): every non-bypass edit is rejected, with a NAMED
-- error. Kept beside the rope lock, not replaced by it: the layering
-- at terminal.lua:351-366 --- the rope lock protects the daemon copy,
-- this and the round-trip mark protect a semantic frontend's own
-- mirror, and neither substitutes for the other. The intercept lives
-- as long as the buffer; no teardown (the buffer-list precedent for
-- its keymap).
pmacs.buffer.add_intercept(buf, function()
error(name .. " is read-only")
error(actual .. " is read-only")
end)
-- Q#P6: semantic frontends must round-trip keys while this panel
-- is focused (RET = visit, not an optimistic newline).
@ -119,7 +216,7 @@ function pmacs.listview.open(spec)
-- (chained panels would trap `q` in a loop; restore targets the
-- last real buffer).
local active = pmacs.window.buffer()
if active and not panel_for_current_buffer() then
if active and not panel_for_buffer(active) then
p.prev = active
end
render(p, spec.rows or {})
@ -147,7 +244,7 @@ pmacs.command.define {
name = "listview.visit",
description = "Visit the list-panel item under the cursor.",
fn = function()
local p = panel_for_current_buffer()
local p = active_panel()
if not p then return end
local item = p.line_to_item[pmacs.editor.cursor_line()]
if item ~= nil and p.on_visit then p.on_visit(item) end
@ -158,7 +255,7 @@ pmacs.command.define {
name = "listview.refresh",
description = "Re-run the list panel's data source and re-render.",
fn = function()
local p = panel_for_current_buffer()
local p = active_panel()
if not (p and p.on_refresh) then return end
local saved = pmacs.editor.cursor_line()
local rows = p.on_refresh() or {}
@ -174,7 +271,7 @@ pmacs.command.define {
name = "listview.quit",
description = "Leave the list panel, restoring the previous buffer.",
fn = function()
local p = panel_for_current_buffer()
local p = active_panel()
if not p then return end
-- Bottom-panel arc (Q#BP11b): `q` keeps its name and its
-- user-visible behavior, delegating to `window.quit` only when the

View File

@ -1833,18 +1833,39 @@ impl EditorCore {
/// and translate the live origin — otherwise accepted-match
/// highlights and the session origin survive at pre-edit offsets
/// for every Lua mutator edit and applied CRDT op.
///
/// Q#GB6: each window coordinate is also clamped against **its own**
/// post-edit bound, which [`Self::rebuild_views_for`] already does
/// (`:1853-1857`) and this path did not. A generated refresh that
/// shrinks its buffer otherwise leaves `cursor` past the end of the
/// rope indefinitely — neither paint nor a motion command recovers
/// it, because motion is computed from the stale value. The two
/// coordinates fail on different axes and are therefore clamped
/// separately and **unconditionally**: `cursor` is a byte position
/// bounded by [`Buffer::len`], while `view_top` is a line index
/// bounded by [`TextView::line_count`]. A replace can grow in bytes
/// while collapsing many lines into one, so "the buffer shrank" is
/// not a usable trigger for the second.
pub fn notify_buffer_edit(&mut self, buffer_id: BufferId, edit: &Edit) {
self.search_invalidate_for_edit(buffer_id, edit);
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(buffer_id) else {
return;
};
let len = buffer.len();
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
let _ = win.text_view.on_edit(buffer, edit);
for overlay in &mut win.overlays {
let _ = overlay.on_edit(buffer, edit);
}
if win.cursor > len {
win.cursor = len;
}
let max_top = win.text_view.line_count().saturating_sub(1);
if win.view_top > max_top {
win.view_top = max_top;
}
}
}
}

View File

@ -65,7 +65,13 @@ pub fn install_fold(lua: &Lua, fold_registry: &SharedFoldRegistry) -> mlua::Resu
let id = buf.id();
let requested = range_from_table(&range)?;
let Some(bytes) = document_bytes(lua, id)? else {
set_status(lua, "fold rejected: not a document buffer");
// Q#GB16(a): the guard is spelled `is_read_only()`,
// so this is the message it can actually justify.
// Its author meant "terminal"; generated-buffer
// immutability makes dired listings and listview
// panels read-only too, and "not a document buffer"
// would be a false explanation for those.
set_status(lua, "fold rejected: buffer is read-only");
return Ok(false);
};
if requested.start > bytes.len() as u64
@ -307,6 +313,16 @@ fn range_to_table(lua: &Lua, r: ByteRange) -> mlua::Result<Table> {
/// The buffer's bytes if it is a normal document buffer, or `None` if it is
/// read-only (a terminal identity buffer or other non-document buffer —
/// the Q#FD11 "normal document buffer" guard).
///
/// Q#GB16: the guard's author meant "terminal", and `read_only` is what
/// they had. Generated-buffer immutability widens the flag's population
/// — a dired listing and a listview panel are read-only from their first
/// paint — so fold **creation** is now refused on those families too.
/// That is accepted rather than worked around (option (a)): a generated
/// buffer's contents are replaced wholesale on every refresh, which
/// invalidates any stored range anyway. What is *not* accepted is
/// explaining the refusal with a sentence that is no longer true, hence
/// the status text at the `fold` call site.
fn document_bytes(lua: &Lua, buf: BufferId) -> mlua::Result<Option<Vec<u8>>> {
with_registry(lua, |r| {
let buffer = resolve(r, buf)?;

View File

@ -26,6 +26,7 @@ use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::buffer::BufferId;
use pmacs::cell::{CellGrid, CellSize, Glyph};
use pmacs::editor::EditorState;
use pmacs::editor_core::normalize_buffer_path;
@ -70,6 +71,39 @@ fn type_str(s: &mut EditorState, text: &str) {
}
}
fn alt(s: &mut EditorState, c: char) {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT));
}
/// `M-x <name> RET` through the real minibuffer. `buffer.undo` is
/// reachable this way on every buffer in the tree and no buffer-local
/// rebinding can remove it (generated-buffer immutability, §0).
fn m_x(s: &mut EditorState, name: &str) {
alt(s, 'x');
type_str(s, name);
press(s, KeyCode::Enter);
}
fn active_buffer_id(s: &EditorState) -> BufferId {
s.core.borrow().active_buffer_id()
}
/// Q#GB14: the rope lock has no Lua surface, so every "is it locked"
/// assertion goes through Rust.
fn is_read_only(s: &EditorState, id: BufferId) -> bool {
let core = s.core.borrow();
let reg = core.registry.borrow();
reg.get(id).expect("buffer in registry").is_read_only()
}
fn set_read_only(s: &EditorState, id: BufferId, value: bool) {
let core = s.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.get_mut(id)
.expect("buffer in registry")
.set_read_only(value);
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
@ -958,13 +992,21 @@ fn dired_does_not_adopt_a_foreign_buffer_with_its_name() {
// 5 --- read-only discipline
// ---------------------------------------------------------------------------
/// An ordinary self-insert is rejected by the intercept and leaves the
/// text byte-identical, while dired's own repaint succeeds through
/// `bypass_intercept`. `set_round_trip_input` is pinned through the
/// An ordinary self-insert is rejected and leaves the text
/// byte-identical, while dired's own repaint still succeeds through the
/// owner-authorized write. `set_round_trip_input` is pinned through the
/// **production** seam a semantic frontend reads (`dispatch_idle_for`,
/// published as `DispatchIdle`) rather than by a direct-call assertion:
/// without it, a GPU session would optimistically apply `g` as an
/// insert instead of letting it reach the revert binding.
///
/// **This test is NOT coverage of the generated-buffer adoption**, and
/// the `contains("read-only")` assertion below is the reason to say so:
/// `BufferError::ReadOnly` renders as ``buffer `{name}` (id {id:?}) is
/// read-only`` and the intercept's own message ends in `is read-only`
/// too, so that substring passes on both sides of the change. What the
/// adoption adds here is the explicit `is_read_only` assertion and
/// criterion 6(c); the undo criteria are separate tests below.
#[test]
fn dired_buffer_is_read_only_and_round_trips_input() {
let td = fixture_dir();
@ -988,6 +1030,33 @@ fn dired_buffer_is_read_only_and_round_trips_input() {
"a round-trip buffer must turn optimistic apply OFF"
);
// Generated-buffer immutability Stage 1 criterion 6(c), the positive
// control: `dispatch_idle_for` has SIX ways to return false, so the
// assertion above is satisfied by any of them. Switching the same
// window to a plain buffer must flip the gate back ON --- a stuck
// minibuffer, a pending chord, an open menu or a live search would
// keep it off across the switch, so this failing is the signal that
// the assertion above passed for the wrong reason.
let listing = active_buffer_id(&s);
exec(
&s,
"DIRED_LISTING = pmacs.window.buffer()\n\
pmacs.window.switch_buffer(pmacs.buffer.create('*plain*'))",
);
assert!(
s.dispatch_idle_for(FrontendId::LOCAL),
"and back ON for a plain buffer"
);
exec(&s, "pmacs.window.switch_buffer(DIRED_LISTING)");
// The listing's rope is genuinely locked, not merely intercepted
// (generated-buffer immutability Stage 1). Asserted Rust-side
// because `describe.buffer` carries no `read_only` field.
assert!(
is_read_only(&s, listing),
"the first paint must leave the listing's rope read-only"
);
// `z` is bound nowhere in dired mode, so it reaches self-insert.
type_char(&mut s, 'z');
assert_eq!(
@ -1654,3 +1723,274 @@ fn dired_renders_10k_entries_within_200ms() {
"10K entries must render within 200ms; took {elapsed:?}"
);
}
// ---------------------------------------------------------------------------
// Generated-buffer immutability, Stage 1
// (docs/generated-buffer-immutability-framing.md §6, Stage 1)
// ---------------------------------------------------------------------------
/// Criterion 3 [`main`] --- neither `C-/` nor `M-x buffer.undo` can empty
/// a dired listing.
///
/// Both halves in one test because they are one claim about one buffer,
/// and both are needed: dired rebinds **no** undo chord, so `C-/` is the
/// whole distance from a keystroke to an empty listing, while
/// `M-x buffer.undo` is the half that no rebinding could ever close.
/// The assertion is on the listing's own content --- the header line and
/// a real entry --- not on `!is_empty()`.
///
/// *Bite:* measured on the pre-image --- one undo takes the listing to
/// `""`. `scripts/bite githubsucks/main builtin/runtime/dired.lua`
/// falsifies it: `paint`'s `bypass_intercept` replace pushed a poppable
/// undo entry over a writable rope, and `Buffer::undo` reaches that rope
/// through `ensure_writable` without ever consulting the intercept chain.
#[test]
fn dired_undo_cannot_empty_the_listing() {
let td = fixture_dir();
let mut s = editor();
open_ok(&mut s, td.path(), "nil");
let before = active_text(&s);
let name = active_name(&s);
assert!(
before.contains("a.txt") && before.contains(&canon(td.path())),
"precondition: a real listing, got {before:?}"
);
ctrl(&mut s, '/');
assert_eq!(
active_text(&s),
before,
"C-/ must leave the listing's content intact"
);
m_x(&mut s, "buffer.undo");
assert_eq!(
active_name(&s),
name,
"the minibuffer round trip lands back in the listing"
);
assert_eq!(
active_text(&s),
before,
"M-x buffer.undo must leave the listing's content intact"
);
}
/// Criterion 4 [fix-shape] --- the owner's own repaint still works after
/// the lock, and the listing is still locked afterwards.
///
/// *Bite:* a bare `set_read_only(true)` would pass criterion 3 and fail
/// here, because it refuses the refresh the buffer exists for; the
/// falsifying one-line mutation on the shipped primitive is deleting
/// `self.read_only = false` from `Buffer::set_generated_contents`
/// (`src/buffer.rs:546`), after which `g` raises. Asserted on the content
/// the repaint produced, never on the absence of an error.
#[test]
fn dired_revert_still_repaints_after_the_lock() {
let td = fixture_dir();
let mut s = editor();
open_ok(&mut s, td.path(), "nil");
let listing = active_buffer_id(&s);
assert!(
is_read_only(&s, listing),
"precondition: the first paint locked the rope"
);
assert!(!active_text(&s).contains("c.txt"));
std::fs::write(td.path().join("c.txt"), b"new\n").expect("write c");
type_char(&mut s, 'g');
pump(&mut s);
assert!(
active_text(&s).contains("c.txt"),
"the owner's repaint must land through the lock: {:?}",
active_text(&s)
);
assert!(
is_read_only(&s, listing),
"and leave the listing locked afterwards"
);
}
/// Criterion 5 [fix-shape] --- the named intercept survives adoption and
/// is still what refuses an edit whenever the rope does not.
///
/// **Restated against the framing**, which asked for an ordinary edit
/// "refused by the INTERCEPT, not by the rope" and asserted on the
/// message text. That state is unreachable once the arc's lock is
/// installed: `Buffer::apply_edit` (`src/buffer.rs:773`) and
/// `Buffer::begin_edit` (`:725`) call `ensure_writable()` as their FIRST
/// statement, while the intercept chain runs later inside
/// `apply_edit_inner` (`:1072`), so the rope always answers first. The
/// criterion is therefore driven with the lock lifted --- the state the
/// intercept genuinely still covers, including the window between
/// `pmacs.buffer.create` and the first paint.
///
/// *Bite:* unchanged --- an adopter that drops `add_intercept` and relies
/// on the rope alone passes criteria 3 and 4 and fails here.
#[test]
fn dired_keeps_the_named_intercept_beside_the_rope_lock() {
let td = fixture_dir();
let mut s = editor();
open_ok(&mut s, td.path(), "nil");
let listing = active_buffer_id(&s);
let before = active_text(&s);
// With the lock on, the ROPE answers first, and its message is the
// one with the buffer id in it. Pinned so the restatement above
// cannot rot silently.
type_char(&mut s, 'z');
assert!(
status(&s).contains("(id BufferId("),
"with the lock on the rope refuses first; got {:?}",
status(&s)
);
set_read_only(&s, listing, false);
type_char(&mut s, 'z');
set_read_only(&s, listing, true);
assert_eq!(
active_text(&s),
before,
"the intercept must refuse the edit even with the rope writable"
);
let st = status(&s);
assert!(
st.contains("dired.lua") && st.contains("is read-only"),
"and refuse it by NAME, not with the rope's message; got {st:?}"
);
}
/// Criterion 7 [mutation] --- a repaint reaches the **window**, not just
/// the rope, pinned by painting a shrinking listing.
///
/// A rope write is only half of an edit: the window holds a `TextView`
/// line index that only `on_edit` maintains, so a write that reaches the
/// rope without the fan-out leaves the two disagreeing, and the next
/// paint indexes the new rope with the old offsets. `dired.revert` is
/// the right driver because it paints and does **not** follow the paint
/// with a `window.switch_buffer` --- which rebuilds the `TextView` from
/// scratch and would mask the mutation. (`listview.refresh` and
/// `listview.open` both do switch, so the listview half of this
/// criterion cannot bite; the primitive's own pin is
/// `terminal_copy_mode_acceptance::acc16d`.)
///
/// *Bite:* delete the `notify_buffer_edit_to_windows` call in the
/// `set_generated_contents` binding (`src/lua_bindings/mod.rs:3092`) and
/// the painted frame keeps rows the listing no longer has.
#[test]
fn dired_a_shrinking_repaint_reaches_the_window() {
let td = tempfile::tempdir().expect("tempdir");
for name in ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"] {
std::fs::write(td.path().join(name), b"x").expect("write");
}
let mut s = editor();
open_ok(&mut s, td.path(), "nil");
let rows = painted_rows(&s);
assert!(
rows[5].contains("e.txt"),
"precondition: five entries paint on rows 1-5, got {:?}",
&rows[..7]
);
for name in ["b.txt", "c.txt", "d.txt", "e.txt"] {
std::fs::remove_file(td.path().join(name)).expect("remove");
}
type_char(&mut s, 'g');
pump(&mut s);
let rows = painted_rows(&s);
assert!(
rows[1].contains("a.txt"),
"the one surviving entry paints: {:?}",
&rows[..7]
);
assert_eq!(
rows[2],
"",
"and nothing of the four rows it replaced: {:?}",
&rows[..7]
);
}
/// Criterion 13a [`main`] --- a locked generated buffer is not foldable
/// (Q#GB16, option (a)).
///
/// This is a regression pin **for the intended change**: sweep C found
/// that `document_bytes` (`src/lua_bindings/fold.rs`) is spelled
/// `if buffer.is_read_only() { return Ok(None) }`, so locking dired
/// listings silently disables fold *creation* on them. The decision is to
/// accept that --- a generated buffer's contents are replaced wholesale
/// on every refresh, which invalidates any stored range --- and to state
/// it rather than let it ship silently.
///
/// *Bite:* this **fails on `main`**, where the same call returns `true`.
/// `scripts/bite githubsucks/main builtin/runtime/dired.lua` falsifies
/// it.
#[test]
fn dired_a_locked_listing_is_not_foldable() {
let td = fixture_dir();
let mut s = editor();
open_ok(&mut s, td.path(), "nil");
let text = active_text(&s);
let first_nl = text.find('\n').expect("header line");
let second_nl = text[first_nl + 1..]
.find('\n')
.map(|i| first_nl + 1 + i)
.expect("at least two entry lines");
let folded: bool = eval(
&s,
&format!(
"return pmacs.fold.fold(pmacs.window.buffer(), \
{{ start = {first_nl}, ['end'] = {second_nl} }})"
),
);
assert!(!folded, "a locked generated buffer is not foldable");
let n: i64 = eval(&s, "return #pmacs.fold.folds(pmacs.window.buffer())");
assert_eq!(n, 0, "and no fold is stored");
}
/// Criterion 13b [mutation] --- ...and the refusal says why.
///
/// Separate from 13a because the two halves have different pre-images:
/// on `main` the call *succeeds* and sets no status at all, so this
/// cannot share 13a's `main` pre-image. The shape that ships if 13a is
/// written alone is correct behaviour with a false explanation --- the
/// guard's author meant "terminal", and "not a document buffer" is not
/// true of a dired listing.
///
/// *Bite:* revert `src/lua_bindings/fold.rs`'s status string to
/// `"fold rejected: not a document buffer"`.
#[test]
fn dired_the_fold_refusal_names_the_read_only_lock() {
let td = fixture_dir();
let mut s = editor();
open_ok(&mut s, td.path(), "nil");
let text = active_text(&s);
let first_nl = text.find('\n').expect("header line");
let second_nl = text[first_nl + 1..]
.find('\n')
.map(|i| first_nl + 1 + i)
.expect("at least two entry lines");
let _: bool = eval(
&s,
&format!(
"return pmacs.fold.fold(pmacs.window.buffer(), \
{{ start = {first_nl}, ['end'] = {second_nl} }})"
),
);
let st = status(&s);
assert!(
st.contains("read-only"),
"the refusal must name the lock; got {st:?}"
);
assert!(
!st.contains("not a document buffer"),
"and not the sentence that is no longer true; got {st:?}"
);
}

View File

@ -7,8 +7,15 @@
//! substrate hermetically.
//!
//! Framing: docs/lsp-panels-framing.md.
//!
//! Generated-buffer immutability Stage 1
//! (docs/generated-buffer-immutability-framing.md §6) adds the
//! criteria below `refresh_reruns_the_source_and_reseats`: the undo
//! paths the Q#P3 intercept never guarded, the Q#GB13 ownership rule,
//! and the Q#GB18 identity routing that ownership makes load-bearing.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::buffer::BufferId;
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
@ -25,6 +32,97 @@ fn press(s: &mut EditorState, code: KeyCode) {
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
}
fn ctrl(s: &mut EditorState, c: char) {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(c), KeyModifiers::CONTROL),
);
}
fn alt(s: &mut EditorState, c: char) {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT));
}
fn type_str(s: &mut EditorState, text: &str) {
for ch in text.chars() {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
}
/// `M-x <name> RET` through the **real** minibuffer, not
/// `pmacs.command.invoke`: `buffer.undo` is reachable that way on every
/// buffer in the tree and no buffer-local rebinding can remove it, which
/// is the whole reason the intercept idiom did not close this hole.
fn m_x(s: &mut EditorState, name: &str) {
alt(s, 'x');
type_str(s, name);
press(s, KeyCode::Enter);
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
fn status(s: &EditorState) -> String {
s.core.borrow().status.clone()
}
fn buffer_names(s: &EditorState) -> Vec<String> {
eval(
s,
"local out = {}\n\
for _, id in ipairs(pmacs.buffer.list()) do\n\
out[#out + 1] = pmacs.describe.buffer(id).name\n\
end\n\
return out",
)
}
fn active_name(s: &EditorState) -> String {
eval(
s,
"return pmacs.describe.buffer(pmacs.window.buffer()).name",
)
}
fn active_text(s: &EditorState) -> String {
eval(
s,
"local b = pmacs.window.buffer()\nreturn b:slice(0, b:len())",
)
}
fn id_of(s: &EditorState, name: &str) -> BufferId {
let core = s.core.borrow();
let reg = core.registry.borrow();
reg.find_by_name(name)
.unwrap_or_else(|| panic!("no buffer named {name:?} in {:?}", buffer_names(s)))
}
/// Q#GB14: the rope lock is not observable from Lua --- `describe.buffer`
/// carries `name`, `length`, `modified`, `view_count` and nothing else ---
/// so every "is it locked" assertion goes through Rust.
fn is_read_only(s: &EditorState, id: BufferId) -> bool {
let core = s.core.borrow();
let reg = core.registry.borrow();
reg.get(id).expect("buffer in registry").is_read_only()
}
fn set_read_only(s: &EditorState, id: BufferId, value: bool) {
let core = s.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.get_mut(id)
.expect("buffer in registry")
.set_read_only(value);
}
/// Open a three-row test panel whose visits record into `_G.VISITED`.
fn open_test_panel(s: &mut EditorState) {
s.lua_host
@ -136,3 +234,431 @@ fn refresh_reruns_the_source_and_reseats() {
let (_, _, _, visited) = probe(&s);
assert_eq!(visited.as_deref(), Some("D"), "the refreshed row visits");
}
// ---------------------------------------------------------------------------
// Generated-buffer immutability, Stage 1
// (docs/generated-buffer-immutability-framing.md §6, Stage 1)
// ---------------------------------------------------------------------------
/// The exact bytes `open_test_panel` renders. Asserted by value, not by
/// `is_empty()`: "the panel is not empty" is the assertion shape the
/// framing's §0.1 shows passing with the bug live on other families.
const PANEL_TEXT: &str = "3 items RET visit q quit\nalpha\nbeta\ngamma";
/// Criterion 1 [`main`] --- `C-/` cannot empty a listview panel.
///
/// Driven by a real chord through `dispatch_key`, because listview
/// rebinds **no** undo chord (`grep -n 'C-/\|C-_\|C-x u\|undo'
/// builtin/runtime/listview.lua` is empty), so this is the whole
/// distance from a keystroke to an empty panel.
///
/// *Bite:* measured on the pre-image --- `"H\nrow-one\nrow-two"` -> `""`.
/// `scripts/bite githubsucks/main builtin/runtime/listview.lua` falsifies
/// it: the panel's own render pushes a poppable undo entry, and
/// `Buffer::undo` reaches the rope through `ensure_writable` without ever
/// consulting the intercept chain.
#[test]
fn s1_1_the_undo_chord_cannot_empty_a_listview_panel() {
let mut s = EditorState::new();
open_test_panel(&mut s);
assert_eq!(active_text(&s), PANEL_TEXT, "precondition: rendered");
ctrl(&mut s, '/');
assert_eq!(
active_text(&s),
PANEL_TEXT,
"C-/ must leave the panel's content intact"
);
}
/// Criterion 2 [`main`] --- `M-x buffer.undo` cannot empty a listview
/// panel, driven through the **real** minibuffer.
///
/// Separate from criterion 1 on purpose: a fix that only rebound the
/// chords would pass 1 and fail this. `compile.lua`'s own comment already
/// concedes the point ("command/menu undo stays dispatchable").
///
/// *Bite:* same empty result on the pre-image.
#[test]
fn s1_2_m_x_buffer_undo_cannot_empty_a_listview_panel() {
let mut s = EditorState::new();
open_test_panel(&mut s);
m_x(&mut s, "buffer.undo");
assert_eq!(
active_name(&s),
"*test-panel*",
"the minibuffer round trip must land back in the panel"
);
assert_eq!(
active_text(&s),
PANEL_TEXT,
"M-x buffer.undo must leave the panel's content intact"
);
}
/// Criterion 4 [fix-shape] --- the owner's own refresh still works after
/// the lock, and the buffer is still locked afterwards.
///
/// *Bite:* a naive `set_read_only(true)` at panel creation passes
/// criteria 1-3 and fails here, because it refuses the refresh the panel
/// exists for. That is the failure mode `Buffer::set_generated_contents`
/// exists to prevent, and it is why the **pairing** is the primitive.
/// The assertion is on the content `g` produced, not on the call not
/// raising.
#[test]
fn s1_4_the_owners_refresh_still_works_after_the_lock() {
let mut s = EditorState::new();
open_test_panel(&mut s);
let panel = id_of(&s, "*test-panel*");
assert!(
is_read_only(&s, panel),
"precondition: the first render locked the rope"
);
press(&mut s, KeyCode::Char('g'));
let text = active_text(&s);
assert!(text.contains("delta"), "g must re-render: {text:?}");
assert!(
!text.contains("alpha"),
"and replace the old rows: {text:?}"
);
assert!(
is_read_only(&s, panel),
"and the panel must still be locked afterwards"
);
}
/// Criterion 5 [fix-shape] --- the named intercept survives adoption and
/// is still the thing that refuses an edit whenever the rope does not.
///
/// **Restated against the framing, which specified this criterion in a
/// form the tree cannot reach.** §6 Stage 1 criterion 5 asks for an
/// ordinary edit refused "by the INTERCEPT, not by the rope", asserted on
/// the message text. Once the arc's own lock is installed that state is
/// unreachable: `Buffer::apply_edit` (`src/buffer.rs:773`) and
/// `Buffer::begin_edit` (`:725`) both call `ensure_writable()` as their
/// FIRST statement, while the intercept chain runs later, inside
/// `apply_edit_inner` (`:1072`). Measured here: a self-insert on an
/// adopted panel now reports
/// ``insert failed: buffer `*test-panel*` (id BufferId(n)) is read-only``
/// --- the rope's message --- and can never report the intercept's.
///
/// So the criterion is driven at the one point where the two are
/// distinguishable, which is also the state it is actually protecting:
/// the rope lock lifted. That covers the real window between
/// `pmacs.buffer.create` and the first render, and any future Rust-side
/// lift.
///
/// *Bite:* unchanged from the framing's --- an adopter that deletes the
/// `add_intercept` call and relies on the rope alone passes criteria 1-4
/// and fails here, because with the lock lifted the `z` lands.
#[test]
fn s1_5_an_ordinary_edit_is_refused_by_the_named_intercept_not_only_the_rope() {
let mut s = EditorState::new();
open_test_panel(&mut s);
let panel = id_of(&s, "*test-panel*");
// With the lock ON, the rope answers first and the message is its
// own. Pinned so the restatement above cannot rot silently.
press(&mut s, KeyCode::Char('z'));
assert!(
status(&s).contains("(id BufferId("),
"with the lock on, the ROPE refuses first; got {:?}",
status(&s)
);
set_read_only(&s, panel, false);
press(&mut s, KeyCode::Char('z'));
set_read_only(&s, panel, true);
assert_eq!(
active_text(&s),
PANEL_TEXT,
"the intercept must refuse the edit even with the rope writable"
);
let st = status(&s);
assert!(
st.contains("listview.lua") && st.contains("*test-panel* is read-only"),
"and refuse it by NAME, not with the rope's message; got {st:?}"
);
}
/// Criterion 6 [fix-shape] --- `set_round_trip_input` survives adoption,
/// asserted so that only the round-trip mark can make it pass.
///
/// `dispatch_idle_for` (`src/editor.rs:1126-1155`) returns `false` for
/// **six** independent reasons, so `!dispatch_idle_for(..)` alone is
/// satisfied by any of them. All three halves are required:
///
/// * **(a)** the document-window premise (`!window.is_side()`), so a
/// fixture that later displays the panel in a side window fails loudly
/// rather than passing vacuously --- `tests/dired_acceptance.rs:975`'s
/// shape;
/// * **(b)** the gate itself while the panel is focused;
/// * **(c)** the positive control --- switching the same window to a
/// plain buffer must flip the gate back to `true`. A stuck minibuffer,
/// a pending chord, an open menu or a live search would keep it `false`
/// across the switch, so (c) failing is the signal that (b) passed for
/// the wrong reason.
///
/// *Bite:* delete the `set_round_trip_input` call in `listview.lua` and
/// criteria 1-5 all still pass; only (b) fails. A daemon-side rope
/// refusal does nothing for a replica's own mirror, which is why this is
/// pinned through `dispatch_idle_for` rather than through `read_only`.
#[test]
fn s1_6_round_trip_input_survives_the_adoption() {
let mut s = EditorState::new();
open_test_panel(&mut s);
// (a) the premise.
{
let core = s.core.borrow();
let active = core.active_window_id();
assert!(
!core.windows.get(&active).expect("live window").is_side(),
"fixture premise: the panel is in a document window here"
);
}
// (b) the gate.
assert!(
!s.dispatch_idle_for(FrontendId::LOCAL),
"a round-trip buffer must turn optimistic apply OFF"
);
// (c) the positive control.
exec(
&s,
"pmacs.window.switch_buffer(pmacs.buffer.create('*plain*'))",
);
assert!(
s.dispatch_idle_for(FrontendId::LOCAL),
"and back ON for a plain buffer --- otherwise (b) passed for one \
of the other five reasons"
);
}
/// Criterion 9 [`main`] --- a foreign buffer that happens to share the
/// panel's name is never adopted (Q#GB13).
///
/// Both halves, because the second is what fails if adoption is merely
/// made "safe" by skipping the render: the user's bytes survive **and**
/// an ordinary edit to the user's buffer still lands. Adoption installed
/// an erroring intercept whose handle it discarded, so the pre-image left
/// the clobbered buffer permanently un-editable --- and this arc removes
/// the `M-x buffer.undo` that was the only way back.
///
/// *Bite:* measured on the pre-image --- `"my precious notes"` ->
/// `"H\nr1"`, one buffer where there should be two, and the user's buffer
/// left un-editable. `scripts/bite githubsucks/main
/// builtin/runtime/listview.lua` falsifies it.
#[test]
fn s1_9_a_foreign_buffer_with_the_panels_name_is_never_adopted() {
let mut s = EditorState::new();
exec(
&s,
"FOREIGN = pmacs.buffer.create('*test-panel*')\n\
FOREIGN:insert(0, 'my precious notes')",
);
open_test_panel(&mut s);
let foreign: String = eval(&s, "return FOREIGN:slice(0, FOREIGN:len())");
assert_eq!(
foreign, "my precious notes",
"the user's bytes must survive the panel opening"
);
let landed: String = eval(
&s,
"FOREIGN:insert(0, 'still mine: ')\n\
return FOREIGN:slice(0, FOREIGN:len())",
);
assert_eq!(
landed, "still mine: my precious notes",
"and an ordinary edit to it must still land"
);
assert_eq!(
active_name(&s),
"*test-panel*<2>",
"the panel opens under a disambiguated name"
);
let names = buffer_names(&s);
assert!(
names.iter().any(|n| n == "*test-panel*") && names.iter().any(|n| n == "*test-panel*<2>"),
"two buffers, not one: {names:?}"
);
}
/// Criterion 10 [fix-shape] --- exhausting the disambiguation limit
/// raises rather than falling back to adoption, matching
/// `dired.lua:493-503` and `terminal.lua:309-315`.
///
/// *Bite:* an implementation that adopts once `<99>` is taken passes
/// criterion 9 and fails here --- and it fails in the worst direction,
/// because the buffer it would adopt is by construction one a user
/// created.
#[test]
fn s1_10_the_disambiguation_limit_raises_rather_than_adopting() {
let s = EditorState::new();
exec(
&s,
"MINE = pmacs.buffer.create('*test-panel*')\n\
MINE:insert(0, 'mine')\n\
for i = 2, 99 do pmacs.buffer.create(string.format('*test-panel*<%d>', i)) end",
);
let (ok, err): (bool, String) = eval(
&s,
"local ok, err = pcall(pmacs.listview.open, { name = '*test-panel*', rows = {} })\n\
return ok, tostring(err)",
);
assert!(!ok, "the open must raise, not adopt");
assert!(
err.contains("no free variant remains"),
"and say why; got {err:?}"
);
let mine: String = eval(&s, "return MINE:slice(0, MINE:len())");
assert_eq!(mine, "mine", "and touch nothing");
}
/// Criterion 11 [`main`] --- a **disambiguated** panel still answers
/// `RET`, `g` and `q` (Q#GB18).
///
/// This is the criterion that fails against Q#GB13 landed without
/// Q#GB18: disambiguation alone leaves the old lookup reading
/// `panels["*test-panel*<2>"]` for a record stored under
/// `"*test-panel*"`, so all three commands return early. Every one of
/// them fails **silently**, so the assertion is on the content each
/// command produced, never on "it did not raise".
#[test]
fn s1_11_a_disambiguated_panel_still_answers_ret_g_and_q() {
let mut s = EditorState::new();
exec(
&s,
"FOREIGN = pmacs.buffer.create('*test-panel*')\n\
ORIGIN = pmacs.buffer.create('*origin*')\n\
pmacs.window.switch_buffer(ORIGIN)",
);
open_test_panel(&mut s);
assert_eq!(active_name(&s), "*test-panel*<2>", "premise: disambiguated");
// g --- re-render from the data source.
press(&mut s, KeyCode::Char('g'));
let text = active_text(&s);
assert!(text.contains("delta"), "g must re-render: {text:?}");
// RET --- fire on_visit for the row under the cursor.
press(&mut s, KeyCode::Enter);
let visited: Option<String> = eval(&s, "return _G.VISITED");
assert_eq!(
visited.as_deref(),
Some("D"),
"RET must visit the refreshed row"
);
// q --- restore the buffer the panel was opened from.
press(&mut s, KeyCode::Char('q'));
assert_eq!(
active_name(&s),
"*origin*",
"q must restore the previous buffer"
);
}
/// Criterion 12 [`main`] --- the `q`-target capture is not inverted
/// (Q#GB18), which needs its own criterion because it fails **open**
/// rather than closed.
///
/// `listview.open`'s guard reads "capture the current buffer as the `q`
/// target, but never another panel (chained panels would trap `q` in a
/// loop)". When the lookup cannot recognise a disambiguated panel it
/// returns nil, the guard reads "not a panel", and the panel is captured
/// as the next panel's `q` target --- producing exactly the loop the
/// guard exists to prevent. Criterion 11 passes with that bug live,
/// because each command works in isolation; only the two-panel sequence
/// shows it.
///
/// *Bite:* restore the name-keyed `panels[d.name]` lookup while keeping
/// the disambiguation and `q` lands back in `*test-panel*<2>`.
#[test]
fn s1_12_the_q_target_capture_is_not_inverted_across_two_panels() {
let mut s = EditorState::new();
exec(
&s,
"FOREIGN = pmacs.buffer.create('*test-panel*')\n\
ORIGIN = pmacs.buffer.create('*origin*')\n\
pmacs.window.switch_buffer(ORIGIN)",
);
open_test_panel(&mut s);
assert_eq!(active_name(&s), "*test-panel*<2>", "premise: disambiguated");
exec(
&s,
"pmacs.listview.open { name = '*other-panel*', header = 'O', \
rows = { { text = 'x', item = 'X' } } }",
);
assert_eq!(active_name(&s), "*other-panel*", "premise: second panel");
press(&mut s, KeyCode::Char('q'));
assert_ne!(
active_name(&s),
"*test-panel*<2>",
"q must never return into another panel --- the chained-panel loop"
);
assert_eq!(
active_name(&s),
"*scratch*",
"with no capturable previous buffer, q falls back to *scratch*"
);
}
/// Criterion 14 [structural] --- rides **alongside** 1-13, never instead:
/// a structural comparison of two authorities does not catch a misrouted
/// consumer, which is why 11 and 12 assert through `dispatch_key`.
///
/// Three claims, each keyed to a decision: no `bypass_intercept` write
/// survives in either Stage 1 adopter (§1.1's arithmetic is the
/// reference, so the check is per non-comment line rather than a
/// substring sweep that the explanatory comments would trip);
/// `ensure_panel` contains no find-by-name adoption (Q#GB13); and every
/// `panels[` subscript is an append, so none can be keyed by a name
/// derived from `describe.buffer` (Q#GB18).
#[test]
fn s1_14_no_bypass_write_or_name_keyed_identity_remains() {
const LISTVIEW: &str = include_str!("../builtin/runtime/listview.lua");
const DIRED: &str = include_str!("../builtin/runtime/dired.lua");
for (file, src) in [("listview.lua", LISTVIEW), ("dired.lua", DIRED)] {
let writes: Vec<&str> = src
.lines()
.filter(|l| !l.trim_start().starts_with("--") && l.contains("bypass_intercept"))
.collect();
assert!(
writes.is_empty(),
"{file} must contain no bypass_intercept write; found {writes:?}"
);
assert!(
src.contains("set_generated_contents"),
"{file} must write through the authorized primitive"
);
}
assert!(
!LISTVIEW.contains("find_buffer_by_name(name) or pmacs.buffer.create"),
"ensure_panel must not adopt a same-named foreign buffer"
);
let subscripts: Vec<&str> = LISTVIEW
.lines()
.map(str::trim)
.filter(|line| !line.starts_with("--") && line.contains("panels["))
.filter(|line| !line.starts_with("panels[#panels + 1]"))
.collect();
assert!(
subscripts.is_empty(),
"every `panels[` subscript must be an append; found {subscripts:?}"
);
}

View File

@ -992,3 +992,119 @@ fn copy_mode_refuses_a_non_terminal_buffer() {
"the refusal must say why: {err}"
);
}
/// Generated-buffer immutability Stage 1, criterion 8 [`main`] — Q#GB6's
/// cursor clamp.
///
/// `EditorCore::notify_buffer_edit` — the fan-out every generated write
/// goes through — updated each window's `TextView` and overlays but
/// clamped neither window coordinate; only `rebuild_views_for` did, and
/// its doc comment said so. So a shrinking generated refresh left
/// `win.cursor` past the end of the rope **indefinitely**: paint does not
/// crash, and a motion command does not recover it, because motion is
/// computed from the stale value.
///
/// *Bite:* measured on the pre-image — cursor 29, len 2, and `C-p` leaves
/// it at 29. This ships today for terminal copy mode: refresh a snapshot
/// to a shorter one with the point low in the buffer and this is the
/// state. Falsify by deleting the `win.cursor > len` clamp.
#[test]
fn acc16f_a_shrinking_generated_write_clamps_the_window_cursor() {
let state = EditorState::new();
exec(
&state,
r"
GEN = pmacs.buffer.create('*generated-probe*')
pmacs.buffer.set_generated_contents(GEN, 'alpha\nbeta\ngamma\ndelta\nepsilon\n')
pmacs.window.switch_buffer(GEN)
pmacs.editor.goto_byte(30)
",
);
let (cursor, len): (i64, i64) = eval(
&state,
"return pmacs.editor.cursor(), pmacs.window.buffer():len()",
);
assert_eq!(
(cursor, len),
(30, 31),
"precondition: point low in a 31-byte buffer"
);
exec(&state, r"pmacs.buffer.set_generated_contents(GEN, 'x\n')");
let (cursor, len): (i64, i64) = eval(
&state,
"return pmacs.editor.cursor(), pmacs.window.buffer():len()",
);
assert_eq!(len, 2, "precondition: the buffer shrank");
assert!(
cursor <= len,
"the cursor must be clamped into the new rope; got {cursor} for len {len}"
);
// And motion works from there: `C-p` reaches line 0, which it cannot
// do from a dangling offset.
exec(&state, "pmacs.editor.move_up()");
let cursor: i64 = eval(&state, "return pmacs.editor.cursor()");
assert_eq!(cursor, 0, "C-p must move to the first line");
}
/// Generated-buffer immutability Stage 1, criterion 8b [`main`] — Q#GB6's
/// `view_top` clamp, on a buffer that GREW.
///
/// The two coordinates fail on different axes: `cursor` is a byte
/// position bounded by `Buffer::len`, while `view_top` is a **line
/// index** bounded by `TextView::line_count`. A replace can grow in bytes
/// while collapsing many lines into one, so a clamp gated on "the buffer
/// shrank" passes criterion 8 and fails here — which is why the clamp
/// runs unconditionally, each coordinate against its own bound, exactly
/// as `rebuild_views_for` already does.
///
/// *Bite:* falsify by gating the clamp on a byte-length comparison, or by
/// deleting the `view_top` half. Unlike criterion 8 this case is argued
/// from the types rather than measured on `main`; the assertion below is
/// the measurement.
#[test]
fn acc16g_a_line_collapsing_generated_write_clamps_view_top() {
let state = EditorState::new();
exec(
&state,
r"
GEN = pmacs.buffer.create('*viewtop-probe*')
pmacs.buffer.set_generated_contents(GEN, 'a\nb\nc\nd\ne\nf\n')
pmacs.window.switch_buffer(GEN)
pmacs.editor.set_view_top(5)
",
);
let (top, len): (i64, i64) = eval(
&state,
"return pmacs.editor.view_top(), pmacs.window.buffer():len()",
);
assert_eq!(
(top, len),
(5, 12),
"precondition: scrolled to line 5 of a 12-byte, 7-line buffer"
);
// 20 bytes on ONE line: longer than what it replaces, so any
// "the buffer shrank" trigger is false here.
exec(
&state,
r"pmacs.buffer.set_generated_contents(GEN, '0123456789abcdefghij')",
);
let len: i64 = eval(&state, "return pmacs.window.buffer():len()");
assert_eq!(len, 20, "precondition: the buffer GREW in bytes");
let (top, lines) = {
let core = state.core.borrow();
let active = core.active_window_id();
let win = core.windows.get(&active).expect("active window");
(win.view_top, win.text_view.line_count())
};
assert!(
top < lines,
"view_top must be clamped into the new line count; got {top} of {lines}"
);
assert_eq!(top, 0, "the collapsed buffer has exactly one line");
}