Merge pull request #109 from levineuwirth/auto-indent
feat(edit): auto-indent on newline (Arc 2)
This commit is contained in:
commit
7e127ab836
|
|
@ -49,7 +49,7 @@ bind("C-v", "cursor.page-down")
|
|||
bind("BS", "buffer.delete-backward")
|
||||
bind("DEL", "buffer.delete-forward")
|
||||
bind("C-d", "buffer.delete-forward")
|
||||
bind("RET", "buffer.newline")
|
||||
bind("RET", "edit.newline-and-indent")
|
||||
bind("TAB", "buffer.tab")
|
||||
|
||||
-- Incremental search ---------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
-- indent.lua --- auto-indent on newline (Arc 2).
|
||||
--
|
||||
-- RET (`edit.newline-and-indent`) inserts a newline plus the current
|
||||
-- line's leading whitespace, verbatim, clipped at the split point
|
||||
-- (Q#AI3): copying bytes is the only policy that cannot be wrong about
|
||||
-- tabs-vs-spaces, and the clip keeps a split inside the indent from
|
||||
-- double-indenting the carried text. The whole thing is ONE edit — one
|
||||
-- undo step, one CRDT op. With a region it is one `buf:replace` (CUA
|
||||
-- type-over, Q#AI4); the selection is cleared after every successful
|
||||
-- edit, region or not (a zero-length selection would otherwise go live
|
||||
-- the moment the cursor moves off the anchor). `buffer.newline` stays
|
||||
-- bound-free as the plain-newline escape hatch (Q#AI2).
|
||||
--
|
||||
-- Framing: docs/auto-indent-framing.md.
|
||||
|
||||
pmacs.indent = pmacs.indent or {}
|
||||
|
||||
local ed = pmacs.editor
|
||||
|
||||
-- Start of the line containing `pos`: chunked backward scan for the
|
||||
-- last newline strictly before it (comment.lua's scan — there is no
|
||||
-- line-access API on buffers; giant lines stay safe).
|
||||
local function line_start_before(buf, pos)
|
||||
local p = pos
|
||||
while p > 0 do
|
||||
local from = math.max(0, p - 4096)
|
||||
local chunk = buf:slice(from, p)
|
||||
local nl = chunk:match("()\n[^\n]*$")
|
||||
if nl then return from + nl end
|
||||
p = from
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- The indent to carry over a split at `split` (Q#AI3):
|
||||
-- bytes[line_start .. min(first_non_ws, split)]. Forward chunked scan
|
||||
-- from the line start, stopping at the first non-whitespace byte —
|
||||
-- never materializing more of the line than the indent itself plus
|
||||
-- one chunk (Enter at the end of a giant minified line must not copy
|
||||
-- the whole line just to produce an empty indent). `[ \t]` rather
|
||||
-- than `%s` so a CR on a CRLF line never counts as indent.
|
||||
local function indent_before(buf, split)
|
||||
local start = line_start_before(buf, split)
|
||||
local parts = {}
|
||||
local p = start
|
||||
while p < split do
|
||||
local chunk_to = math.min(p + 4096, split)
|
||||
local chunk = buf:slice(p, chunk_to)
|
||||
local ws = chunk:match("^[ \t]*")
|
||||
table.insert(parts, ws)
|
||||
if #ws < #chunk then break end
|
||||
p = chunk_to
|
||||
end
|
||||
return table.concat(parts)
|
||||
end
|
||||
|
||||
-- Right-gravity translation of `pos` through the effective edit
|
||||
-- (Q#AI5; the daemon optimistic-arm shape). `estop` is the PRE-edit
|
||||
-- end of the replaced range; an insert has estart == estop.
|
||||
local function translate(pos, estart, estop, einserted)
|
||||
if pos < estart then return pos end
|
||||
if pos > estop then return pos - (estop - estart) + einserted end
|
||||
return estart + einserted
|
||||
end
|
||||
|
||||
-- edit.newline-and-indent body.
|
||||
function pmacs.indent.newline()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
ed.set_status("no buffer")
|
||||
return false
|
||||
end
|
||||
|
||||
-- Snapshot the context BEFORE the edit (Q#AI5): intercepts run with
|
||||
-- the registry borrow released and may switch window or buffer; the
|
||||
-- fix-up below must never touch whatever is active afterwards.
|
||||
local win0 = pmacs.window.current()
|
||||
local cursor0 = ed.cursor()
|
||||
|
||||
local region = ed.region()
|
||||
local has_region = region ~= nil and region["end"] > region.start
|
||||
local rstart, rstop
|
||||
if has_region then
|
||||
rstart, rstop = region.start, region["end"]
|
||||
else
|
||||
rstart, rstop = cursor0, cursor0
|
||||
end
|
||||
local text = "\n" .. indent_before(buf, rstart)
|
||||
|
||||
-- One edit = one undo step, one CRDT op. Same intercept discipline
|
||||
-- as killring/comment: a rejection reports rather than throws and
|
||||
-- leaves no state behind.
|
||||
local ok, estart, estop, einserted = pcall(function()
|
||||
if has_region then
|
||||
return buf:replace(rstart, rstop, text)
|
||||
end
|
||||
return buf:insert(rstart, text)
|
||||
end)
|
||||
if not ok then
|
||||
ed.set_status("newline-and-indent rejected by buffer intercept")
|
||||
return false
|
||||
end
|
||||
|
||||
-- Context guard (Q#AI5): fix up only the window that made the edit.
|
||||
if pmacs.window.current() ~= win0 or pmacs.window.buffer() ~= buf then
|
||||
ed.set_status("newline-and-indent: context changed during edit")
|
||||
return false
|
||||
end
|
||||
|
||||
-- A deviating effective edit means an intercept rewrote it — the
|
||||
-- interceptor's positional result stands (M6.4: kind and payload
|
||||
-- are immutable). Cursor repair uses ONE formula for the clean and
|
||||
-- transformed paths alike: translate the pre-edit cursor through
|
||||
-- the effective edit, then goto_byte (which clamps). The clean
|
||||
-- insert-at-cursor case lands at estart + einserted — right after
|
||||
-- the carried indent.
|
||||
local deviated = estart ~= rstart or estop ~= rstop or einserted ~= #text
|
||||
if deviated then
|
||||
ed.set_status("newline-and-indent altered by buffer intercept")
|
||||
end
|
||||
ed.goto_byte(translate(cursor0, estart, estop, einserted))
|
||||
ed.clear_selection()
|
||||
return not deviated
|
||||
end
|
||||
|
||||
pmacs.command.define {
|
||||
name = "edit.newline-and-indent",
|
||||
description = "Insert a newline carrying the current line's indentation.",
|
||||
fn = function() pmacs.indent.newline() end,
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# Agent handoff — cross-machine continuity
|
||||
|
||||
**Last updated: 2026-07-10, on the desktop, by the session that shipped
|
||||
PR #107.** This file is the bridge between development machines. If you
|
||||
**Last updated: 2026-07-10, on the laptop, by the auto-indent
|
||||
session.** This file is the bridge between development machines. If you
|
||||
are an agent reading this on a fresh clone: this document plus the
|
||||
`docs/*-framing.md` files ARE your memory. Read this fully before
|
||||
taking on work, seed your persistent memory from it, and **update this
|
||||
|
|
@ -10,20 +10,26 @@ next machine reads it the way you just did.
|
|||
|
||||
## 1. Where the project stands (2026-07-10)
|
||||
|
||||
- `main` @ `2dde4b8`, protocol **v15** (`SUPPORTED=[6..15]`).
|
||||
- **PR #107 OPEN**: comment/uncomment toggle on `M-;` (Arc 2). Awaiting
|
||||
the user's review findings. If it's merged by the time you read this,
|
||||
Arc 2 has only auto-indent and auto-pairing left. Check
|
||||
`gh pr list --state open` first thing.
|
||||
- `main` @ `efa41cb`, protocol **v15** (`SUPPORTED=[6..15]`).
|
||||
- **Auto-indent on newline (Arc 2) in flight on this branch** —
|
||||
framing `docs/auto-indent-framing.md` is at revision 6 (five
|
||||
pre-branch review rounds plus PR #109 round 1). RET now binds
|
||||
`edit.newline-and-indent`; plain Enter is no longer GPU-optimistic
|
||||
(round-trips like the TUI). Rode along: Q#AI8 search invalidation is
|
||||
shared by dispatch, direct notification, undo, and redo (stale
|
||||
step/summary fail closed; live origins translate through edits), and
|
||||
Q#AI9 clears empty selections only after successful core inserts and
|
||||
in the daemon's optimistic CRDT source arm for both frontends. The
|
||||
TUI's missing nonempty-selection optimistic type-over gate and
|
||||
generated-buffer search invalidation remain named deferrals.
|
||||
- Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position:
|
||||
- **Arc 1 (LSP utility surface) COMPLETE** — completion popup
|
||||
(#92/#93), panels/references/outline/hover (#94–#96), plus
|
||||
hardening follow-ups (#102, #105, #106).
|
||||
- **Arc 2 (editing table stakes) NEARLY COMPLETE** — query-replace
|
||||
(#97), kill ring + `M-y` (#103/#105/#106), comment-toggle (#107).
|
||||
**Remaining: auto-indent on newline, then auto-pairing.** These are
|
||||
the agreed next work items, in that order, each as its own small
|
||||
framing + PR.
|
||||
- **Arc 2 (editing table stakes)** — query-replace (#97), kill ring
|
||||
+ `M-y` (#103/#105/#106), comment-toggle (#107), auto-indent (this
|
||||
branch). **Remaining after this merges: auto-pairing**, as its own
|
||||
small framing + PR.
|
||||
- **Arc 3 (persistence) COMPLETE** — saveplace/recentf (#98),
|
||||
desktop-save (#99), autosave/crash-recovery (#100), save-clobber
|
||||
fix (#101).
|
||||
|
|
@ -70,12 +76,15 @@ cargo test --workspace -- --skip basedpyright # full sweep
|
|||
git diff --check
|
||||
```
|
||||
|
||||
Machine-specific caveats that were true on the DESKTOP — re-verify on
|
||||
this machine before trusting them:
|
||||
Machine-specific caveats — re-verify on a machine you haven't used
|
||||
before trusting them:
|
||||
|
||||
- **basedpyright**: the desktop's local binary is broken and HANGS the
|
||||
`m4_5_basedpyright` tests — hence the `--skip`. If this machine has a
|
||||
working basedpyright, the skip may be droppable (verify once).
|
||||
- **basedpyright**: the DESKTOP's local binary is broken and HANGS the
|
||||
`m4_5_basedpyright` tests — hence the `--skip` there. The LAPTOP has
|
||||
a working basedpyright 1.39.9 (verified 2026-07-10: the m4_5 test
|
||||
passes in 0.18s), so the skip is droppable on the laptop.
|
||||
- **GPU on the laptop**: AMD Radeon 780M (RADV) — native Vulkan,
|
||||
`PMACS_REQUIRE_GPU=1` works without lavapipe.
|
||||
- **m8 daemon tests are FLAKY** (timing). A lone m8 failure → rerun
|
||||
before investigating.
|
||||
- **GPU tests** need a Vulkan device. `PMACS_REQUIRE_GPU=1` makes
|
||||
|
|
|
|||
|
|
@ -0,0 +1,518 @@
|
|||
# Auto-indent on newline — framing (Arc 2, editing table stakes)
|
||||
|
||||
Pressing RET in pmacs inserts a bare `\n`; every indented line means
|
||||
re-typing the indentation by hand. This adds the table-stakes behavior:
|
||||
RET carries the current line's indentation onto the new line,
|
||||
language-agnostic, as one undoable edit. Second-to-last Arc 2 item;
|
||||
auto-pairing follows as its own framing + PR.
|
||||
|
||||
Roadmap: `docs/roadmap-2026-07.md` Arc 2 ("auto-indent on newline").
|
||||
Revision 6 (PR #109 round 1): Q#AI8's invalidation is one helper
|
||||
invoked from all four edit paths — dispatch, direct notification,
|
||||
undo, redo — and the empty-anchor clear moved daemon-side, closing
|
||||
the optimistic case for BOTH frontends (the residual was never
|
||||
GPU-only: the TUI mirror tracks no selection state). The acceptance
|
||||
matrix now matches what the suites actually pin. Earlier revisions:
|
||||
success-gated Q#AI9, live-origin translation, M6.4 same-kind
|
||||
intercept contract, modal-context corrections.
|
||||
|
||||
## Ground truth (as of `efa41cb`)
|
||||
|
||||
- **RET → `buffer.newline` → `ed.insert_char_over_region(10)`**
|
||||
(`builtin/keymaps/default.lua:52`,
|
||||
`builtin/commands/default.lua:110`): one `Replace` when a region is
|
||||
active (CUA type-over, one undo step), plain insert otherwise. The
|
||||
no-region path does **not** clear an empty selection, and
|
||||
`Window::region()` returns `None` when anchor == cursor while the
|
||||
selection object persists (`src/window.rs:244-255`). So a
|
||||
zero-length selection (e.g. `S-Left` at BOF) survives the edit, and
|
||||
the moment the edit advances the cursor away from the anchor the
|
||||
region is **already nonempty** — the very next self-insert
|
||||
type-overs the just-inserted text. The same no-region arm serves
|
||||
`buffer.self-insert` and `buffer.tab`, so ordinary typing has the
|
||||
identical bug today (`S-Left` at BOF, `x`, `y` → the `y` type-overs
|
||||
the `x`) — it is not RET-specific. `insert_char` catches the
|
||||
intercept rejection internally — status message, return `()`
|
||||
(`src/editor_core.rs:1681-1692`) — so its caller cannot observe
|
||||
failure; and `insert_char_over_region`'s doc comment already claims
|
||||
"any selection is cleared" while only the region arm does it
|
||||
(`src/editor_core.rs:1698`, `:1717`).
|
||||
- **Runtime modules are individually embedded** — each
|
||||
`builtin/runtime/*.lua` has an explicit `include_str!` + eval entry
|
||||
in `src/editor.rs` (comment.lua's at `src/editor.rs:336`). There is
|
||||
no discovery mechanism; a new module ships with a loader entry or it
|
||||
never loads.
|
||||
- **The two frontends' Enter paths diverge.** The TUI attach client
|
||||
round-trips Enter to the daemon keymap by design —
|
||||
`src/optimistic.rs:90-93` names "indentation, newline-with-indent"
|
||||
as the reason, locked by `classify_enter_and_tab_are_round_trip`.
|
||||
The GPU frontend treats plain Enter as optimistic-eligible
|
||||
(`optimistic_insert_text`, `pmacs-gpu/src/main.rs:1520-1530`) and
|
||||
ships a raw `"\n"` CRDT op that never touches the keymap — justified
|
||||
in its doc comment by "`buffer.newline` reduces to plain
|
||||
`insert_char(10)`", the exact premise this feature invalidates. The
|
||||
GPU already round-trips Enter when a selection is active, the
|
||||
completion popup is open, or the dispatcher is busy/stale. The
|
||||
selection gate reads `DecorationKind::Selection` decorations
|
||||
(`pmacs-gpu/src/main.rs:2054-2060`); an **empty** selection paints
|
||||
no decoration, so it does not gate — GPU optimistic typing proceeds
|
||||
over an empty anchor.
|
||||
- **Consequence of the GPU bypass: RET rebindings are dead in the GPU
|
||||
frontend.** The classic buffer list binds RET buffer-locally to
|
||||
`editor.buffer-list-visit` (`builtin/commands/default.lua:407-411`)
|
||||
through normal dispatch; unlike listview it is not marked for
|
||||
round-trip input, so GPU RET there inserts a raw newline instead of
|
||||
visiting. Any user rebind of RET is bypassed the same way.
|
||||
- **Dispatch supplies an empty active-mode list** — the keymap stack
|
||||
is resolved with `&[]` for modes (`src/editor.rs:710`), so
|
||||
mode-scope bindings do not resolve anywhere today, on any frontend.
|
||||
- **GPU test seams**: `optimistic_insert_text` is a free function
|
||||
with an existing in-crate unit test
|
||||
(`pmacs-gpu/src/main.rs:7058-7104`), and it is the sole gate at the
|
||||
top of `optimistic_crdt_insert` (`:2077-2079`). The
|
||||
eligibility/routing layer above it lives in private `App`/`State`
|
||||
methods (`:2050`, `:2077`) with no constructible unit seam — the
|
||||
winit handler's fall-through to `send_key` is not unit-reachable.
|
||||
`pmacs-gpu` declares **no cargo features**
|
||||
(`pmacs-gpu/Cargo.toml`); `crdt` is a root-package feature only.
|
||||
- **Modal contexts that consume Enter before the keymap**: isearch
|
||||
accept (`src/editor.rs:1831`), context-menu invoke
|
||||
(`src/editor.rs:1934`), completion-popup accept
|
||||
(`src/editor.rs:1984`), minibuffer accept
|
||||
(`src/minibuffer.rs:482`), query-replace prompt
|
||||
(`src/editor.rs:968`). These never reach the global keymap; a RET
|
||||
rebind cannot touch them. Acceptance pins them anyway.
|
||||
- **Search staleness is asymmetric across edit paths, and staleness
|
||||
is only half-honored by consumers.** Accepted isearch matches
|
||||
deliberately stay highlighted "until the next edit marks them
|
||||
stale" (`src/editor_core.rs:861-865`). `apply_active_edit` — the
|
||||
path today's `buffer.newline` takes — honors that via
|
||||
`SearchStore::mark_stale` (`src/editor_core.rs:1184-1193`), and
|
||||
highlight producers suppress stale matches. But `search_step`
|
||||
navigates stored ranges without checking `is_stale`
|
||||
(`src/editor_core.rs:844`), and `search_match_summary` exposes
|
||||
stale counts to the n/m prompt (`src/editor_core.rs:719`).
|
||||
`SearchStore::set` clears staleness (`src/search.rs:100`), so any
|
||||
pattern re-run refreshes. Meanwhile direct buffer edits notify
|
||||
through `notify_buffer_edit` (`src/editor_core.rs:1207`), which
|
||||
refreshes views/overlays only — it never marks search state stale.
|
||||
Its **three** callers: the CRDT-op apply path (`src/daemon.rs:2133`),
|
||||
the general Lua mutator path (`notify_buffer_edit_to_windows`,
|
||||
`src/lua_bindings/mod.rs:1390-1395`), and the LuaHost
|
||||
errors-buffer append (`src/lua.rs:442`). A pre-existing
|
||||
stale-highlight/stale-step bug for all three, which a
|
||||
Lua-implemented RET would inherit on the most common keystroke.
|
||||
- **A live search's origin is a raw byte offset.** `SearchSession`
|
||||
stores `origin: (BufferId, byte)` at `search_begin`
|
||||
(`src/editor_core.rs:740-753`); every recompute focuses from that
|
||||
unchanged offset (`src/editor_core.rs:803-810`), and cancel
|
||||
restores the cursor to it directly (`src/editor_core.rs:866`). No
|
||||
edit path translates it — an insert or delete strictly before the
|
||||
origin skews both the recompute focus and the cancel restore even
|
||||
when the match set is fresh. (`apply_active_edit` marks stale right
|
||||
there yet leaves the origin alone, so the other-frontend dispatch
|
||||
path carries the same pre-existing skew.)
|
||||
- **Direct buffer edits do not reconcile window state** —
|
||||
`notify_buffer_edit` adjusts no cursors and no selections. An
|
||||
intercept that expands a replace past the cursor can leave
|
||||
`cursor > buf:len()` and a dangling selection; nothing downstream
|
||||
clamps. The daemon's optimistic-CRDT arm shows the canonical
|
||||
repair: right-gravity cursor translation through the effective edit
|
||||
(`src/daemon.rs:2103-2130` — `pos < start` → unchanged;
|
||||
`pos > pre-edit end` → `pos - old_len + inserted_len`; within →
|
||||
`start + inserted_len`). Note the mutators' returned triple
|
||||
`(start, end, inserted_len)` has `end` = the **pre-edit**
|
||||
replaced-range end (`src/lua_bindings/mod.rs:1246`); the post-edit
|
||||
end is `start + inserted_len`.
|
||||
- **Intercepts may only move an edit, never change what it does.**
|
||||
M6.4 forbids kind-changing transforms and keeps the payload bytes
|
||||
immutable; same-kind position/range overrides are the entire
|
||||
surface (`src/lua_bindings/mod.rs:1061-1074`, `:1653-1657`).
|
||||
Consequences: an intercepted `insert` can only be **relocated** —
|
||||
the buffer always grows by the payload — while only an intercepted
|
||||
`replace` can **shrink** the buffer, by expanding its replaced
|
||||
range past the payload length.
|
||||
- **Intercepts can switch buffers and windows.** Phase 2 of
|
||||
`run_managed_edit` runs the intercept chain with the registry
|
||||
borrow released (`src/lua_bindings/mod.rs:1287-1324`); an intercept
|
||||
body may legally change the active window or buffer. Any post-edit
|
||||
fix-up that blindly targets "the active window" can corrupt
|
||||
unrelated state. `pmacs.window.current()` exposes the active window
|
||||
id to Lua (`src/lua_bindings/mod.rs:10617-10622`).
|
||||
- **No indent infrastructure exists.** No indent command; TAB inserts
|
||||
a literal `\t` (`buffer.tab`); there is no tabs-vs-spaces or width
|
||||
setting anywhere (config registry is a standing deferral); grammars
|
||||
drive highlighting only — no `indents.scm` in the repo. Hardcoded
|
||||
tab widths exist at **five** divergent sites: `TAB_WIDTH = 8` in
|
||||
four core renderers (`src/text_view.rs:35`, `src/highlight.rs:228`,
|
||||
`src/diag.rs:364`, `src/completion.rs:594`) and the GPU minimap's
|
||||
own leading-whitespace scan at width **4**
|
||||
(`pmacs-gpu/src/main.rs:5510`, `:5529-5531`; frontend-local, not
|
||||
reusable for editing).
|
||||
- **The comment.lua pattern covers the Lua mechanics**: line scans
|
||||
over `buf:slice` (`line_start_before`,
|
||||
`builtin/runtime/comment.lua:47-57` — there is no `buf:line()` API;
|
||||
the scan is the idiom), `ed.cursor()/region()/goto_byte()/
|
||||
clear_selection()`, pcall'd mutators with the exact effective-edit
|
||||
triple check. `ed.goto_byte` clamps to buffer length.
|
||||
- `C-j` is bound nowhere in `builtin/`.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Q#AI1 — Lua feature + four named Rust touches
|
||||
|
||||
`builtin/runtime/indent.lua` (new), following the comment.lua module
|
||||
shape. Rust changes, each deliberate and small:
|
||||
|
||||
1. **Loader entry** in `src/editor.rs` — `include_str!` + eval after
|
||||
the comment.lua entry (`:336`). Without it the module never loads
|
||||
and RET would resolve to an undefined command. (Defining the
|
||||
command in `builtin/commands/default.lua` was the alternative; a
|
||||
feature with helpers gets its own module per the comment.lua
|
||||
precedent.)
|
||||
2. **Search-stale substrate fix** (Q#AI8): mark stale in the shared
|
||||
notify path, make the two stale-blind consumers fail closed, and
|
||||
translate the live origin.
|
||||
3. **GPU eligibility fix**: remove the `ProtocolKey::Enter` arm from
|
||||
`optimistic_insert_text` so plain Enter round-trips through the
|
||||
daemon keymap, as the TUI already does. The arm's own documented
|
||||
justification (byte-identical to what the daemon would do) stops
|
||||
being true the moment RET means newline-and-indent. Tab keeps its
|
||||
arm: `buffer.tab` is still a plain `insert_char(9)`; the doc
|
||||
comment is rewritten Tab-only.
|
||||
4. **Empty-selection core fix** (Q#AI9): the no-region arm of
|
||||
`insert_char_over_region` clears a lingering selection.
|
||||
|
||||
### Q#AI2 — Command + binding
|
||||
|
||||
One new command, `edit.newline-and-indent` (the Emacs name), and the
|
||||
default keymap's RET line changes to it. `buffer.newline` keeps its
|
||||
command definition and plain-newline role — the escape hatch via
|
||||
`M-x` or a rebind — unchanged, with one deliberate exception: its
|
||||
empty-selection behavior improves through the shared primitive
|
||||
(the Q#AI9 fix).
|
||||
No new default chord for plain newline in v1: the natural candidate
|
||||
`C-j` is LF itself — the same terminal-ambiguity class as the C-/
|
||||
undo note in the comment-toggle framing — so it's a named deferral,
|
||||
not a casual bind.
|
||||
|
||||
### Q#AI3 — Indent = verbatim copy, clipped at the split point
|
||||
|
||||
On the line containing the split point:
|
||||
|
||||
indent = bytes[line_start .. min(first_non_ws, split_point)]
|
||||
|
||||
where whitespace is space/tab only. Insert `"\n" .. indent` as ONE
|
||||
edit; cursor lands after the indent.
|
||||
|
||||
- **Verbatim copy** (not width math): with no tabs-vs-spaces or width
|
||||
config in the editor, reproducing the existing bytes is the only
|
||||
policy that cannot be wrong about the file's convention. Tabs,
|
||||
spaces, and mixed runs all round-trip untouched.
|
||||
- **Clip at the split point**: splitting *inside* the leading
|
||||
whitespace would otherwise double-indent the carried text. Clipping
|
||||
preserves the carried text's total indentation exactly —
|
||||
`··|··foo` → `··` / `····foo` (split after 2 of 4 spaces: new line
|
||||
still starts at column 4).
|
||||
- **No language awareness in v1**: no extra indent after `{`, no
|
||||
dedent of `}`. Electric indent needs a per-language width opinion
|
||||
pmacs has no surface for (deferral, below).
|
||||
- Whitespace-only lines copy what's before the split point; the
|
||||
abandoned line keeps its trailing whitespace (cleanup is a named
|
||||
deferral, not an accident).
|
||||
|
||||
### Q#AI4 — Region type-over preserved; selection always cleared
|
||||
|
||||
Region active → one `buf:replace(start, end, "\n" .. indent)`, indent
|
||||
computed on the line containing the region start (clipped at region
|
||||
start); cursor after the indent. Matches `insert_char_over_region`'s
|
||||
single-`Replace` / one-undo-step / one-CRDT-op contract that the
|
||||
GPU's selection round-trip relies on.
|
||||
|
||||
**The selection is cleared after every successful edit, region or
|
||||
not.** A zero-length selection (anchor == cursor) reports no region,
|
||||
so the edit takes the no-region path — but the edit itself advances
|
||||
the cursor off the anchor, making the region nonempty **immediately**:
|
||||
the very next self-insert type-overs the newline (`S-Left` at BOF,
|
||||
RET, `x` → the `x` replaces the newline). Unconditional
|
||||
`clear_selection()` closes that for this command. (Q#AI9 fixes the
|
||||
same lingering-anchor bug in the core command family; this command
|
||||
edits via `buf:insert`/`buf:replace`, not `insert_char_over_region`,
|
||||
so it must clear its own.)
|
||||
|
||||
### Q#AI5 — Edit discipline: comment.lua contract + window-state safety
|
||||
|
||||
Snapshot first, edit second, guarded fix-up third:
|
||||
|
||||
1. **Snapshot** `pmacs.window.current()` and the target buffer id
|
||||
before the mutator runs.
|
||||
2. `pcall`'d mutator; a rejecting intercept → status message, no
|
||||
edit, no throw, no dangling state.
|
||||
3. **Guard**: intercepts run with the registry borrow released and
|
||||
may switch the active window or buffer (ground truth). If the
|
||||
active window or its buffer no longer matches the snapshot, skip
|
||||
all fix-up — report only; touching whatever window is now active
|
||||
would corrupt unrelated state.
|
||||
4. **Cursor repair, one formula for both paths**: translate the
|
||||
pre-edit cursor through the *effective* edit with the established
|
||||
right-gravity shape (`src/daemon.rs:2103-2130`): `pos < start` →
|
||||
unchanged; `pos > pre-edit end` → `pos - old_len + inserted_len`;
|
||||
within the replaced range → `start + inserted_len`. Then
|
||||
`goto_byte` (which clamps). For the clean insert-at-cursor case
|
||||
this lands exactly at `start + inserted_len` — the normal
|
||||
after-the-indent position — so there is no special-cased "clean"
|
||||
placement to drift from the repaired one. Never jump to a fixed
|
||||
edit endpoint: an intercept that relocates the edit elsewhere in
|
||||
the buffer must not teleport the cursor there.
|
||||
5. The effective triple is compared EXACTLY against the request; on
|
||||
deviation → status *"newline-and-indent altered by buffer
|
||||
intercept"*; the interceptor's **positional** result stands —
|
||||
kind and payload are immutable under the M6.4 contract (ground
|
||||
truth). Concretely: the plain path's `buf:insert` can only be
|
||||
relocated, so the buffer always grows and the `"\n" .. indent`
|
||||
payload lands wherever the intercept moved it; only the region
|
||||
path's `buf:replace` can shrink the buffer, via a same-kind range
|
||||
expansion. This is a named deviation from the comment.lua
|
||||
precedent (which skips fix-up entirely): skipping here would
|
||||
bless `cursor > buf:len()` whenever an expanded replace shrinks
|
||||
the buffer past the cursor.
|
||||
6. `clear_selection()` (Q#AI4), under the same context guard.
|
||||
|
||||
### Q#AI6 — Consequences of the GPU change, named
|
||||
|
||||
- Plain Enter in the GPU costs one daemon round-trip before echo —
|
||||
parity with every TUI keypress and with the GPU's own
|
||||
selection/popup/busy Enter today.
|
||||
- **Global and buffer-local RET rebindings become effective in the
|
||||
GPU frontend.** Concretely: buffer-list RET now visits the selected
|
||||
buffer instead of inserting a raw newline into the list (today's
|
||||
behavior — the optimistic path bypasses the buffer-local binding).
|
||||
This is an incidental bug fix, named and tested, not a side effect.
|
||||
**Mode-scope bindings are excluded from this claim**: dispatch
|
||||
resolves the keymap stack with an empty mode list
|
||||
(`src/editor.rs:710`), so they stay unresolved on every frontend
|
||||
until the mode system is wired.
|
||||
- `this_command` after Enter in the GPU becomes
|
||||
`edit.newline-and-indent` (was `buffer.self-insert` via the
|
||||
exact-decode CRDT classification) — now consistent across
|
||||
frontends; kill chains break across a newline in both (already true
|
||||
in TUI).
|
||||
- GPU unit tests locking Enter's optimistic eligibility flip to lock
|
||||
the round-trip.
|
||||
|
||||
### Q#AI7 — Chain/hook plumbing: nothing to build
|
||||
|
||||
Keybound RET rotates the command boundary and `buffer.after-edit`
|
||||
fires from dispatch's revision check; `M-x edit.newline-and-indent`
|
||||
gets both via `invoke_interactive`. Same Arc 2 substrate as
|
||||
comment-toggle; the acceptance suite asserts the hook fires once
|
||||
anyway.
|
||||
|
||||
### Q#AI8 — Search staleness: mark it, honor it, and keep the origin true
|
||||
|
||||
Three parts, all required:
|
||||
|
||||
1. **Mark**: one helper, `search_invalidate_for_edit` (mark stale +
|
||||
translate the origin), invoked from **all four edit paths**:
|
||||
`apply_active_edit` (dispatch), `notify_buffer_edit` (applied CRDT
|
||||
ops `src/daemon.rs:2133`, general Lua mutator edits
|
||||
`src/lua_bindings/mod.rs:1390-1395`, and the errors-buffer append
|
||||
`src/lua.rs:442` — normally a no-op, unless the *errors* buffer
|
||||
itself carries accepted search state, in which case marking it
|
||||
stale is exactly right), and — round-1 finding — `undo` / `redo`,
|
||||
which receive precise `Edit` values but previously invalidated
|
||||
nothing. Generated-buffer rebuilds via `rebuild_views_for` remain
|
||||
a named lower-frequency bypass (deferral).
|
||||
2. **Honor (fail closed)**: `SearchStore::step` returns `None` while
|
||||
stale — C-s / `search.next` stops navigating byte ranges that no
|
||||
longer exist instead of teleporting the cursor to them — and
|
||||
`search_match_summary` reports `(None, 0)` while stale, so the
|
||||
TUI/GPU n/m prompt cannot show counts for suppressed highlights.
|
||||
The consumers then match what the highlight producers already do.
|
||||
A stale **live** search un-sticks on the next pattern keystroke:
|
||||
the re-run calls `SearchStore::set`, which clears staleness
|
||||
(`src/search.rs:100`). Auto-recomputing from the stored query on
|
||||
step (instead of failing closed) is a named deferral — nicer UX,
|
||||
separate change.
|
||||
3. **Translate the live origin**: a small `EditorCore` helper
|
||||
right-gravity-translates `SearchSession::origin` through the
|
||||
effective edit, invoked alongside `mark_stale` in **both**
|
||||
`apply_active_edit` and `notify_buffer_edit` (the formula is the
|
||||
`src/daemon.rs:2103-2130` shape; the dispatch path has the same
|
||||
pre-existing skew, and the two call sites already mirror each
|
||||
other for `mark_stale`). Without it, a fresh recompute focuses
|
||||
from a skewed offset and cancel restores the cursor to the wrong
|
||||
place whenever an external edit lands before the origin.
|
||||
|
||||
Together these close a pre-existing bug family — lingering
|
||||
highlights, stale stepping/counts, and skewed origins after any
|
||||
direct Lua edit or optimistic GPU edit — that a Lua-implemented RET
|
||||
would otherwise put on the most common keystroke in the editor.
|
||||
|
||||
### Q#AI9 — Empty-selection type-over: fix the core arm, name the GPU residual
|
||||
|
||||
The lingering-anchor bug is not RET's alone (ground truth): the
|
||||
no-region arm of `insert_char_over_region` leaves an empty selection
|
||||
armed, so ordinary typing already type-overs its own previous
|
||||
keystroke (`S-Left` at BOF, `x`, `y` → the `y` replaces the `x`).
|
||||
|
||||
**In scope**: `insert_char` returns success — today it catches the
|
||||
intercept rejection and returns `()` (ground truth), so the caller
|
||||
cannot distinguish a landed edit from a rejected one — and the
|
||||
no-region arm clears the lingering selection **only on success**.
|
||||
Clearing unconditionally would mutate state on a *failed*
|
||||
self-insert/Tab/plain-newline, which the rejecting-intercept contract
|
||||
forbids. This fixes `buffer.self-insert`, `buffer.tab`, and the
|
||||
retained `buffer.newline` escape hatch for all daemon-dispatched
|
||||
input, on both frontends' round-trip paths, and makes
|
||||
`insert_char_over_region`'s existing "any selection is cleared" doc
|
||||
claim true.
|
||||
|
||||
**Also in scope (round 1: the residual was never GPU-only)**: the
|
||||
TUI attach mirror tracks buffers and cursors but no selection state
|
||||
(`src/buffer_mirror.rs:113`), and its optimistic gate checks cursor
|
||||
freshness and EOL only (`src/optimistic.rs:250`) — so both
|
||||
frontends' optimistic paths could re-arm the type-over. The fix
|
||||
lives in the daemon's CRDT source arm (`handle_remote_crdt_op`):
|
||||
before applying the source cursor update, a selection whose anchor
|
||||
equals the pre-edit cursor — i.e. one that was EMPTY — is cleared.
|
||||
Nonempty selections stand untouched.
|
||||
|
||||
**Out of scope, named**: the TUI optimistic gate performs no
|
||||
type-over check at all — a NONEMPTY selection ending at EOL
|
||||
optimistically inserts instead of consuming the region (the GPU
|
||||
gates on Selection decorations, which nonempty selections do paint,
|
||||
so it round-trips correctly there). That is a pre-existing TUI gate
|
||||
gap, deferred with the substrate reconciliation work.
|
||||
|
||||
## Bets
|
||||
|
||||
1. **Verbatim copy is the right v1 policy** — no tabs-vs-spaces
|
||||
complaints are possible when we only ever reproduce what's already
|
||||
on the line.
|
||||
2. **GPU Enter round-trip latency is imperceptible** — it matches the
|
||||
TUI's every keypress and the GPU's existing non-optimistic paths.
|
||||
3. **Clip-at-split matches muscle memory** — mid-indent splits
|
||||
preserving total indentation is what vi/Emacs users expect; nobody
|
||||
files "my line double-indented".
|
||||
4. **Fail-closed staleness has no workflow regressions** — a stale
|
||||
step no-op (until the pattern re-runs) surprises nobody, because
|
||||
the highlights it would have navigated are already suppressed;
|
||||
nothing legitimate consumes stale offsets once step and summary
|
||||
are guarded.
|
||||
5. **Clearing a lingering empty selection on the core insert arm
|
||||
breaks nothing** — no workflow depends on an anchor surviving
|
||||
plain typing; mainstream editors deactivate the mark on edit.
|
||||
|
||||
## Deferred (named)
|
||||
|
||||
- Language-aware indent (electric `{`/`}`, tree-sitter `indents.scm`)
|
||||
— blocked on width/style config, which is blocked on the config
|
||||
registry deferral.
|
||||
- TAB as reindent (`indent-for-tab-command`) — TAB stays a literal
|
||||
tab, and stays GPU-optimistic.
|
||||
- Doc-comment continuation on newline — re-deferred from the
|
||||
comment-toggle framing; needs in-comment detection
|
||||
(grammar/comment-span work); its own framing after auto-pairing.
|
||||
- Strip the abandoned line's trailing whitespace on split.
|
||||
- A plain-newline default chord (`C-j` is LF — terminal-ambiguous).
|
||||
- `C-o` open-line-with-indent.
|
||||
- Substrate-level window-state reconciliation for direct buffer edits
|
||||
(cursor clamp / selection repair in the notify path, rather than
|
||||
per-command — including windows other than the acting one), and
|
||||
aligning comment.lua's transformed-intercept fix-up with Q#AI5's
|
||||
translate-and-clamp discipline.
|
||||
- **TUI optimistic type-over gate gap** (Q#AI9 round 1): the TUI
|
||||
attach gate consults no selection state, so a NONEMPTY selection
|
||||
ending at EOL optimistically inserts instead of type-over. (The
|
||||
empty-anchor half was fixed daemon-side in round 1.)
|
||||
- **Generated-buffer rebuilds** (`rebuild_views_for`) bypass search
|
||||
invalidation — a lower-frequency Q#AI8 gap, named not handled.
|
||||
- Auto-recompute of a stale search from the stored query on step
|
||||
(Q#AI8 fails closed instead).
|
||||
- Unifying the five hardcoded tab-width sites (4× core `TAB_WIDTH=8`,
|
||||
GPU minimap `4`) behind a real setting — config-registry work.
|
||||
- Mode-scope keybinding resolution (dispatch passes `&[]` today) —
|
||||
the mode system's wiring, not this feature's.
|
||||
- A constructible unit seam for the GPU routing layer above
|
||||
`optimistic_insert_text` (private `App`/`State` today).
|
||||
|
||||
## Acceptance (`tests/auto_indent_acceptance.rs`, dispatch-driven)
|
||||
|
||||
- RET at EOL of a space-indented line: new line carries the indent,
|
||||
cursor after it; tab-indented and mixed `\t··` lines round-trip
|
||||
verbatim.
|
||||
- Mid-line split: `····foo|bar` → `····foo` / `····bar`, cursor
|
||||
before `bar`.
|
||||
- Split inside leading whitespace: `··|··foo` → `··` / `····foo`
|
||||
(clip rule — no double indent).
|
||||
- Zero-indent line and empty buffer: byte-identical to old
|
||||
`buffer.newline`.
|
||||
- Whitespace-only line: new line copies it; abandoned line keeps it.
|
||||
- Region active: ONE `Replace` — region replaced by `"\n" .. indent`,
|
||||
selection cleared; a single `buffer.undo` restores exactly.
|
||||
- **Zero-length selection, command level**: `S-Left` at BOF, RET,
|
||||
`x` → `x` inserts plainly, the newline survives (selection cleared
|
||||
unconditionally). **Core level (Q#AI9)**: `S-Left` at BOF, `x`,
|
||||
`y` → the `y` does not replace the `x`; likewise via
|
||||
`M-x buffer.newline`. **Failure regression**: empty selection
|
||||
armed, self-insert rejected by an intercept → the anchor REMAINS
|
||||
(no state mutation on a failed edit). The GPU-optimistic variant
|
||||
is the named residual, not claimed here.
|
||||
- One undo step in the plain case too.
|
||||
- Intercept discipline: rejecting intercept → status, buffer
|
||||
unchanged, no throw; transforming intercept → reported, positional
|
||||
result stands. **Relocating transform** (plain path: the insert's
|
||||
`pos` moved — the only transform an insert admits) → payload
|
||||
inserted at the new site, cursor translated, NOT teleported.
|
||||
**Shrinking transform** (region path: the replace's range expanded
|
||||
past the payload, shrinking the buffer below the cursor) → cursor
|
||||
right-gravity-translated and clamped within `buf:len()`, selection
|
||||
cleared — the tests assert validity, not immobility.
|
||||
**Context-switching intercept** (switches window or buffer
|
||||
mid-edit) → fix-up skipped; this proves the *new* context is
|
||||
untouched — the original window's validity after such an intercept
|
||||
belongs to the substrate-reconciliation deferral, and the test does
|
||||
not claim it.
|
||||
- **Search staleness** (Q#AI8; acceptance + lib split, per what each
|
||||
seam can reach):
|
||||
- Acceptance: isearch, accept, RET → post-accept `search_step`
|
||||
no-op; the same via a direct `buf:insert` (notify path).
|
||||
- Lib (editor_core): edit during **active** isearch → step no-op
|
||||
and summary `(None, 0)`, then the next pattern keystroke
|
||||
refreshes and stepping resumes; origin translation through
|
||||
**inserts and deletes on both edit paths** (dispatch and
|
||||
notify), pinning recompute focus and cancel restore; **undo and
|
||||
redo** stale accepted matches and translate the live origin.
|
||||
- Lib (search store): stale `step` fails closed; a fresh `set`
|
||||
un-sticks it.
|
||||
- Lib (daemon, `--features crdt`): the optimistic source arm
|
||||
clears an EMPTY anchor and leaves a NONEMPTY selection alone.
|
||||
- `after-edit` fires exactly once per RET (keybound and `M-x`).
|
||||
- Kill-chain break: `C-k`, RET, `C-k` → two ring entries.
|
||||
- Modal contexts: isearch accept and minibuffer accept pinned here
|
||||
(plus buffer-list RET below). Query-replace and completion-popup RET
|
||||
are pinned by `tests/query_replace_acceptance.rs` and
|
||||
`tests/completion_popup_acceptance.rs`; context-menu RET is pinned by
|
||||
`editor::tests::menu_enter_invokes_command_and_closes`. Those tests
|
||||
run in the gates and are not duplicated here.
|
||||
- Giant minified line (64 KiB, unindented and indented): splits
|
||||
correctly — pins the forward-chunked indent scan's behavior
|
||||
(boundedness is by construction).
|
||||
- Buffer-list RET still visits via dispatch (now also reachable from
|
||||
the GPU frontend).
|
||||
- `this_command()` after RET is `edit.newline-and-indent`.
|
||||
- **GPU coverage, two named seams (not "end to end")**:
|
||||
- In-crate (`pmacs-gpu`): the existing
|
||||
`optimistic_insert_text_covers_plain_chars_enter_and_tab` test
|
||||
flips — Enter returns `None`, Tab still optimistic. This is the
|
||||
sole gate at the top of `optimistic_crdt_insert` (`:2079`), so
|
||||
classifier-`None` forces the handler's round-trip branch; the
|
||||
handler itself has no constructible unit seam (named deferral).
|
||||
- Root package (`--features crdt`): a synthetic attached replica
|
||||
plus the root TUI optimistic orchestrator drive pending
|
||||
optimistic self-inserts followed by Enter on an indented line,
|
||||
asserting the daemon dispatches `edit.newline-and-indent` and
|
||||
the resulting multi-byte CRDT op reaches the replica with
|
||||
correct final text — the daemon side of the wire path the GPU
|
||||
takes once it round-trips.
|
||||
|
|
@ -1068,9 +1068,10 @@ impl ApplicationHandler<AppEvent> for App {
|
|||
if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) {
|
||||
eprintln!("pmacs-gpu: send_crdt_op failed: {e}");
|
||||
}
|
||||
// An optimistic Enter near the bottom edge can
|
||||
// scroll; re-declare the scoped viewport so
|
||||
// the producer styles the newly visible lines.
|
||||
// An optimistic edit near the viewport edge can
|
||||
// scroll (a wrap-inducing insert, a Backspace
|
||||
// above the top); re-declare the scoped viewport
|
||||
// so the producer styles the newly visible lines.
|
||||
if let Some(vp) = op.viewport
|
||||
&& let Err(e) =
|
||||
client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
|
||||
|
|
@ -1508,22 +1509,25 @@ fn optimistic_delete_range(
|
|||
/// The literal text `key` inserts when handled optimistically, or
|
||||
/// `None` for keys that must round-trip through the daemon.
|
||||
///
|
||||
/// `Enter` and `Tab` qualify alongside printable chars because their
|
||||
/// default bindings (`buffer.newline` / `buffer.tab`) reduce to plain
|
||||
/// `insert_char(10)` / `insert_char(9)` — byte-identical to a
|
||||
/// self-insert, so the local application cannot diverge from what the
|
||||
/// daemon will do with the same op. Two caveats are the caller's job:
|
||||
/// `Tab` qualifies alongside printable chars because its default
|
||||
/// binding (`buffer.tab`) reduces to a plain `insert_char(9)` —
|
||||
/// byte-identical to a self-insert, so the local application cannot
|
||||
/// diverge from what the daemon will do with the same op. `Enter`
|
||||
/// does NOT: since Q#AI1 (docs/auto-indent-framing.md) RET binds
|
||||
/// `edit.newline-and-indent`, whose inserted text depends on the
|
||||
/// current line's indentation — and round-tripping is also what makes
|
||||
/// RET rebindings (e.g. the buffer list's visit binding) reachable
|
||||
/// from this frontend at all. Two caveats are the caller's job:
|
||||
/// `optimistic_crdt_insert` round-trips when an own-window selection
|
||||
/// is active (the daemon commands consume the region first — CUA
|
||||
/// type-over — which a raw op can't), and modified variants (`S-RET`,
|
||||
/// `C-TAB`, …) return `None` here: a keymap may bind them to anything.
|
||||
/// type-over — which a raw op can't), and modified variants (`C-TAB`,
|
||||
/// …) return `None` here: a keymap may bind them to anything.
|
||||
fn optimistic_insert_text(key: ProtocolKey, mods: Modifiers, chbuf: &mut [u8; 4]) -> Option<&str> {
|
||||
if !is_plain_text_modifiers(mods) {
|
||||
return None;
|
||||
}
|
||||
match key {
|
||||
ProtocolKey::Char(ch) if !ch.is_control() => Some(ch.encode_utf8(chbuf)),
|
||||
ProtocolKey::Enter if mods.is_empty() => Some("\n"),
|
||||
ProtocolKey::Tab if mods.is_empty() => Some("\t"),
|
||||
_ => None,
|
||||
}
|
||||
|
|
@ -2195,10 +2199,10 @@ impl State {
|
|||
self.optimistic_cursor_floor = Some(predicted);
|
||||
self.optimistic_floor_set_at = Some(std::time::Instant::now());
|
||||
// Follow the caret NOW rather than when the daemon's
|
||||
// `CursorByte` confirms — an optimistic Enter on the bottom
|
||||
// visible line (or a Backspace pulling the caret above the
|
||||
// top) moves it outside the slice, and waiting a round trip
|
||||
// to scroll reads as a hitch.
|
||||
// `CursorByte` confirms — an optimistic edit on the bottom
|
||||
// visible line that wraps (or a Backspace pulling the caret
|
||||
// above the top) moves it outside the slice, and waiting a
|
||||
// round trip to scroll reads as a hitch.
|
||||
let viewport = if self.scroll_to_cursor() {
|
||||
self.rebuild_lines_reusing_scroll();
|
||||
self.viewport_send_if_changed(predicted.buffer_id)
|
||||
|
|
@ -7055,7 +7059,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn optimistic_insert_text_covers_plain_chars_enter_and_tab() {
|
||||
fn optimistic_insert_text_covers_plain_chars_and_tab_but_not_enter() {
|
||||
let mut buf = [0u8; 4];
|
||||
let none = Modifiers::NONE;
|
||||
let shift = Modifiers::SHIFT;
|
||||
|
|
@ -7072,8 +7076,10 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
optimistic_insert_text(ProtocolKey::Enter, none, &mut buf),
|
||||
Some("\n"),
|
||||
"RET is bound to buffer.newline = insert_char(10): identical to a self-insert"
|
||||
None,
|
||||
"RET binds edit.newline-and-indent (Q#AI1): the inserted text depends \
|
||||
on the current line, so plain Enter must round-trip — this is also \
|
||||
what makes RET rebindings reachable from the GPU frontend"
|
||||
);
|
||||
assert_eq!(
|
||||
optimistic_insert_text(ProtocolKey::Tab, none, &mut buf),
|
||||
|
|
|
|||
121
src/daemon.rs
121
src/daemon.rs
|
|
@ -2111,6 +2111,19 @@ fn handle_remote_crdt_op(
|
|||
continue;
|
||||
}
|
||||
if Some(*wid) == source_active_window_id {
|
||||
// Q#AI9 (PR #109 round 1): an empty anchor armed at
|
||||
// the pre-edit cursor must not survive the cursor
|
||||
// moving off it — otherwise the optimistic paths
|
||||
// (GPU always; TUI mirror, which tracks no selection
|
||||
// state) re-arm the type-over that
|
||||
// `insert_char_over_region`'s no-region clear fixed
|
||||
// on the dispatch path. Nonempty selections stand:
|
||||
// the TUI gate's missing type-over check is a named
|
||||
// deferral, and guessing here would destroy a real
|
||||
// selection.
|
||||
if win.selection.map(|sel| sel.anchor) == Some(win.cursor) {
|
||||
win.selection = None;
|
||||
}
|
||||
// Source window: set directly to optimistic post-edit
|
||||
// position (matches the source frontend's mirror
|
||||
// cursor after advance/retreat).
|
||||
|
|
@ -2551,6 +2564,114 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Q#AI9 (PR #109 round 1): the optimistic-apply arm clears an
|
||||
/// EMPTY anchor on the source window — the GPU always takes this
|
||||
/// path, and the TUI attach mirror tracks no selection state, so
|
||||
/// neither frontend's gate stops an armed-empty-anchor sequence
|
||||
/// from re-creating the type-over that
|
||||
/// `insert_char_over_region`'s no-region clear fixed on the
|
||||
/// dispatch path. A NONEMPTY selection must survive untouched
|
||||
/// (the TUI gate's missing type-over check is a named deferral).
|
||||
#[cfg(feature = "crdt")]
|
||||
#[test]
|
||||
fn handle_remote_crdt_op_clears_only_an_empty_source_anchor() {
|
||||
use crate::editor::EditorState;
|
||||
use crate::protocol::FrontendId;
|
||||
use crate::window::Selection;
|
||||
|
||||
// Shared fixture: CRDT-backed active buffer + a peer doc that
|
||||
// produces the optimistic op, sourced from LOCAL (which has a
|
||||
// registered view, so the source-window arm runs).
|
||||
fn apply_peer_insert(editor: &mut EditorState, buffer_id: crate::buffer::BufferId) {
|
||||
let snapshot_bytes = {
|
||||
let core = editor.core.borrow();
|
||||
let reg = core.registry.borrow();
|
||||
let buf = reg.get(buffer_id).expect("buffer");
|
||||
buf.crdt_state()
|
||||
.expect("crdt-backed")
|
||||
.export_snapshot()
|
||||
.expect("export snapshot")
|
||||
};
|
||||
let peer = loro::LoroDoc::new();
|
||||
peer.set_peer_id(u64::from(FrontendId::LOCAL.0))
|
||||
.expect("set peer id");
|
||||
peer.import(&snapshot_bytes).expect("import snapshot");
|
||||
let v_before = peer.oplog_vv();
|
||||
peer.get_text("body").insert(0, "x").expect("peer insert");
|
||||
let op_bytes = peer
|
||||
.export(loro::ExportMode::updates(&v_before))
|
||||
.expect("export op");
|
||||
super::handle_remote_crdt_op(
|
||||
editor,
|
||||
FrontendId::LOCAL,
|
||||
buffer_id,
|
||||
crate::rope::CrdtOp {
|
||||
peer_id: FrontendId::LOCAL.0,
|
||||
bytes: op_bytes,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Case 1: empty anchor at the cursor (S-Left-at-BOF shape) —
|
||||
// cleared by the optimistic apply.
|
||||
let mut editor = EditorState::new();
|
||||
let buffer_id = editor.core.borrow().active_window().buffer_id;
|
||||
{
|
||||
let core = editor.core.borrow();
|
||||
let mut reg = core.registry.borrow_mut();
|
||||
reg.get_mut(buffer_id)
|
||||
.expect("active buffer")
|
||||
.upgrade_to_crdt(2)
|
||||
.expect("upgrade to crdt");
|
||||
}
|
||||
{
|
||||
let mut core = editor.core.borrow_mut();
|
||||
let at = core.active_window().cursor;
|
||||
core.active_window_mut().selection = Some(Selection { anchor: at });
|
||||
}
|
||||
apply_peer_insert(&mut editor, buffer_id);
|
||||
{
|
||||
let core = editor.core.borrow();
|
||||
assert!(
|
||||
core.active_window().selection.is_none(),
|
||||
"an empty anchor must not survive an optimistic source edit"
|
||||
);
|
||||
assert_eq!(
|
||||
core.active_window().cursor,
|
||||
1,
|
||||
"cursor at post-edit position"
|
||||
);
|
||||
}
|
||||
|
||||
// Case 2: nonempty selection — the arm must not touch it.
|
||||
let mut editor = EditorState::new();
|
||||
let buffer_id = editor.core.borrow().active_window().buffer_id;
|
||||
editor.core.borrow_mut().insert_char('a');
|
||||
editor.core.borrow_mut().insert_char('b');
|
||||
{
|
||||
let core = editor.core.borrow();
|
||||
let mut reg = core.registry.borrow_mut();
|
||||
reg.get_mut(buffer_id)
|
||||
.expect("active buffer")
|
||||
.upgrade_to_crdt(2)
|
||||
.expect("upgrade to crdt");
|
||||
}
|
||||
{
|
||||
let mut core = editor.core.borrow_mut();
|
||||
core.active_window_mut().selection = Some(Selection { anchor: 0 });
|
||||
// cursor is at 2 after the two inserts: nonempty region.
|
||||
}
|
||||
apply_peer_insert(&mut editor, buffer_id);
|
||||
{
|
||||
let core = editor.core.borrow();
|
||||
assert_eq!(
|
||||
core.active_window().selection,
|
||||
Some(Selection { anchor: 0 }),
|
||||
"a nonempty selection survives the optimistic source edit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill ring Q#KR2 — GPU typing arrives here without touching
|
||||
/// dispatch_key, so it must update the source frontend's command
|
||||
/// boundary or `C-k x C-k` on the GPU would append across the typed
|
||||
|
|
|
|||
|
|
@ -339,6 +339,12 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/comment.lua"),
|
||||
)
|
||||
.expect("load comment builtin chunk");
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/indent.lua"),
|
||||
include_str!("../builtin/runtime/indent.lua"),
|
||||
)
|
||||
.expect("load indent builtin chunk");
|
||||
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
|
||||
// was loaded directly via `eval(include_str!(...))`; the
|
||||
// M7.11 deliverable migrates it to the package system so it
|
||||
|
|
@ -2856,6 +2862,82 @@ mod tests {
|
|||
assert_eq!(s.core.borrow().active_buffer_len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_selection_is_cleared_by_a_landed_self_insert() {
|
||||
// Q#AI9: an armed anchor at the cursor reports no region, so
|
||||
// 'x' inserts plainly — but the insert moves the cursor off
|
||||
// the anchor, and without the clear the region goes live and
|
||||
// 'y' type-overs the 'x'.
|
||||
let mut s = fresh_with(b"");
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load("pmacs.editor.begin_selection(0)")
|
||||
.exec()
|
||||
.unwrap();
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char('x'), KeyModifiers::NONE),
|
||||
);
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char('y'), KeyModifiers::NONE),
|
||||
);
|
||||
let core = s.core.borrow();
|
||||
assert_eq!(
|
||||
core.active_buffer_len(),
|
||||
2,
|
||||
"'y' must append, not type-over the freshly inserted 'x'"
|
||||
);
|
||||
assert!(
|
||||
core.active_window().selection.is_none(),
|
||||
"a landed self-insert clears the lingering anchor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_self_insert_leaves_the_empty_selection_anchor() {
|
||||
// Q#AI9 failure regression: a rejecting intercept means NO
|
||||
// state mutation — the armed anchor must survive.
|
||||
let mut s = fresh_with(b"");
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
r#"
|
||||
_G.reject_once = true
|
||||
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
|
||||
if _G.reject_once then
|
||||
_G.reject_once = false
|
||||
error("rejected by test intercept")
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
pmacs.editor.begin_selection(0)
|
||||
"#,
|
||||
)
|
||||
.exec()
|
||||
.unwrap();
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char('x'), KeyModifiers::NONE),
|
||||
);
|
||||
{
|
||||
let core = s.core.borrow();
|
||||
assert_eq!(core.active_buffer_len(), 0, "the insert was rejected");
|
||||
assert!(
|
||||
core.active_window().selection.is_some(),
|
||||
"a rejected insert must not clear the anchor"
|
||||
);
|
||||
}
|
||||
// The next (allowed) insert lands and clears it.
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char('x'), KeyModifiers::NONE),
|
||||
);
|
||||
let core = s.core.borrow();
|
||||
assert_eq!(core.active_buffer_len(), 1);
|
||||
assert!(core.active_window().selection.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_deletes_previous_char() {
|
||||
let mut s = fresh_with(b"");
|
||||
|
|
|
|||
|
|
@ -714,7 +714,9 @@ impl EditorCore {
|
|||
|
||||
/// `(active_index, total)` for the active buffer's matches, for the
|
||||
/// prompt's "n/m" readout. `active_index` is 0-based and `None`
|
||||
/// when there are no matches.
|
||||
/// when there are no matches. Stale matches read as absent (Q#AI8
|
||||
/// fail-closed): the highlights they count are already suppressed,
|
||||
/// so the prompt must not advertise them either.
|
||||
#[must_use]
|
||||
pub fn search_match_summary(&self) -> (Option<usize>, usize) {
|
||||
let bid = self.active_buffer_id();
|
||||
|
|
@ -722,6 +724,9 @@ impl EditorCore {
|
|||
.search_store
|
||||
.lock()
|
||||
.expect("search store mutex poisoned");
|
||||
if guard.is_stale(bid) {
|
||||
return (None, 0);
|
||||
}
|
||||
guard
|
||||
.for_buffer(bid)
|
||||
.map_or((None, 0), |s| (s.active_index(), s.len()))
|
||||
|
|
@ -1156,17 +1161,22 @@ impl EditorCore {
|
|||
/// Returns a stringified error on buffer or view failure.
|
||||
pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result<u64, String> {
|
||||
let buffer_id = self.active_buffer_id();
|
||||
let mut reg = self.registry.borrow_mut();
|
||||
let buffer = reg.get_mut(buffer_id).map_err(|e| e.to_string())?;
|
||||
let edit = buffer.apply_edit(op).map_err(|e| e.to_string())?;
|
||||
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);
|
||||
// Scope the registry borrow: the origin translation below needs
|
||||
// `&mut self` after the views have been notified.
|
||||
let edit = {
|
||||
let mut reg = self.registry.borrow_mut();
|
||||
let buffer = reg.get_mut(buffer_id).map_err(|e| e.to_string())?;
|
||||
let edit = buffer.apply_edit(op).map_err(|e| e.to_string())?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
edit
|
||||
};
|
||||
// T M10.8 Day 4 — capture CRDT op (if the buffer was in
|
||||
// CRDT mode and produced one) for the dispatcher to
|
||||
// broadcast on the next tick.
|
||||
|
|
@ -1182,16 +1192,53 @@ impl EditorCore {
|
|||
.push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone()));
|
||||
}
|
||||
// Search matches were computed against the pre-edit text, so
|
||||
// their byte positions are now wrong. Mark the buffer's matches
|
||||
// stale (M11.8): the producer / TUI overlay suppress them until
|
||||
// a fresh search re-runs against the current content. No-op for
|
||||
// a buffer with no search state. The headline isearch bet —
|
||||
// "stale-after-edit linger" — is closed here.
|
||||
// their byte positions are now wrong (M11.8): the producer /
|
||||
// TUI overlay suppress them until a fresh search re-runs. The
|
||||
// headline isearch bet — "stale-after-edit linger" — is
|
||||
// closed here.
|
||||
self.search_invalidate_for_edit(buffer_id, &edit);
|
||||
Ok(edit.new_rope.len())
|
||||
}
|
||||
|
||||
/// Q#AI8 search invalidation for a landed edit: mark the buffer's
|
||||
/// matches stale (no-op without search state) and right-gravity-
|
||||
/// translate the live session origin. ONE helper so every edit
|
||||
/// path — dispatch ([`Self::apply_active_edit`]), direct
|
||||
/// notification ([`Self::notify_buffer_edit`]), and history
|
||||
/// ([`Self::undo`] / [`Self::redo`]) — invalidates identically;
|
||||
/// a path that skips this leaves highlights, step targets, and
|
||||
/// the n/m count pointing at pre-edit offsets.
|
||||
fn search_invalidate_for_edit(&mut self, buffer_id: BufferId, edit: &Edit) {
|
||||
self.search_store
|
||||
.lock()
|
||||
.expect("search store mutex poisoned")
|
||||
.mark_stale(buffer_id);
|
||||
Ok(edit.new_rope.len())
|
||||
self.translate_search_origin(buffer_id, edit);
|
||||
}
|
||||
|
||||
/// Right-gravity-translate the live search origin through an edit
|
||||
/// to `buffer_id` (Q#AI8; the `src/daemon.rs` optimistic-arm
|
||||
/// shape). The origin is a raw byte offset captured at
|
||||
/// [`Self::search_begin`]; without translation an insert/delete
|
||||
/// before it skews every later recompute focus and the cancel
|
||||
/// restore, even when the match set itself is fresh.
|
||||
fn translate_search_origin(&mut self, buffer_id: BufferId, edit: &Edit) {
|
||||
let Some(session) = self.search.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if session.origin.0 != buffer_id {
|
||||
return;
|
||||
}
|
||||
let start = edit.range.start;
|
||||
let end = edit.range.end;
|
||||
let pos = session.origin.1;
|
||||
session.origin.1 = if pos < start {
|
||||
pos
|
||||
} else if pos > end {
|
||||
pos - (end - start) + edit.inserted_len
|
||||
} else {
|
||||
start + edit.inserted_len
|
||||
};
|
||||
}
|
||||
|
||||
/// Notify every window displaying `buffer_id` that the buffer was
|
||||
|
|
@ -1204,7 +1251,14 @@ impl EditorCore {
|
|||
/// edited buffer would keep a stale [`crate::text_view::TextView`]
|
||||
/// line cache, causing later cursor motions to land at offsets the
|
||||
/// view cannot map back to display coordinates.
|
||||
///
|
||||
/// Q#AI8: direct edits must also invalidate search state exactly
|
||||
/// like [`Self::apply_active_edit`] does — mark the matches stale
|
||||
/// 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.
|
||||
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;
|
||||
|
|
@ -1677,8 +1731,11 @@ impl EditorCore {
|
|||
aw.goal_col = None;
|
||||
}
|
||||
|
||||
/// Insert a single character at the cursor.
|
||||
pub fn insert_char(&mut self, ch: char) {
|
||||
/// Insert a single character at the cursor. Returns `true` iff the
|
||||
/// edit landed: a rejecting buffer intercept reports via the status
|
||||
/// line and returns `false`, and callers must not mutate dependent
|
||||
/// state (e.g. selection anchors) on a failed insert (Q#AI9).
|
||||
pub fn insert_char(&mut self, ch: char) -> bool {
|
||||
self.active_window_mut().goal_col = None;
|
||||
let mut buf = [0u8; 4];
|
||||
let s = ch.encode_utf8(&mut buf);
|
||||
|
|
@ -1686,9 +1743,10 @@ impl EditorCore {
|
|||
let pos = self.active_window().cursor;
|
||||
if let Err(e) = self.apply_active_edit(EditOp::Insert { pos, bytes }) {
|
||||
self.status = format!("insert failed: {e}");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
self.active_window_mut().cursor += bytes.len() as u64;
|
||||
true
|
||||
}
|
||||
|
||||
/// CUA type-over: insert `ch`, replacing the active region if one
|
||||
|
|
@ -1699,7 +1757,14 @@ impl EditorCore {
|
|||
/// lands just past the inserted bytes and any selection is cleared.
|
||||
pub fn insert_char_over_region(&mut self, ch: char) {
|
||||
let Some((lo, hi)) = self.active_region() else {
|
||||
self.insert_char(ch);
|
||||
// Q#AI9: an empty selection (anchor == cursor) reports no
|
||||
// region yet stays armed — the insert moves the cursor off
|
||||
// the anchor and the very NEXT key type-overs the fresh
|
||||
// text. Clear it, but only when the edit landed: a
|
||||
// rejecting intercept must leave the anchor untouched.
|
||||
if self.insert_char(ch) {
|
||||
self.active_window_mut().selection = None;
|
||||
}
|
||||
return;
|
||||
};
|
||||
self.active_window_mut().goal_col = None;
|
||||
|
|
@ -1839,6 +1904,9 @@ impl EditorCore {
|
|||
}
|
||||
}
|
||||
drop(reg);
|
||||
// Q#AI8 (PR #109 round 1): history edits move bytes
|
||||
// like any other edit — invalidate search state.
|
||||
self.search_invalidate_for_edit(buffer_id, &edit);
|
||||
// Post-audit-round-5 F27: undo on a CRDT-backed
|
||||
// buffer produces a crdt_op that must broadcast to
|
||||
// every replica frontend (including the one whose
|
||||
|
|
@ -1877,6 +1945,8 @@ impl EditorCore {
|
|||
}
|
||||
}
|
||||
drop(reg);
|
||||
// Q#AI8 — same as undo above.
|
||||
self.search_invalidate_for_edit(buffer_id, &edit);
|
||||
// Post-audit-round-5 F27 — same as undo above.
|
||||
self.queue_daemon_origin_crdt_op(buffer_id, &edit);
|
||||
}
|
||||
|
|
@ -3669,6 +3739,237 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_matches_fail_closed_for_step_and_summary() {
|
||||
// Q#AI8: once an edit marks matches stale, the highlights are
|
||||
// suppressed — stepping and the n/m prompt must fail closed
|
||||
// with them instead of navigating/advertising dead offsets.
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
s.search_finish(true); // accept keeps the matches
|
||||
assert_eq!(s.search_match_summary(), (Some(0), 2));
|
||||
s.active_window_mut().cursor = 0;
|
||||
assert!(s.insert_char('x'), "plain insert lands");
|
||||
assert_eq!(
|
||||
s.search_match_summary(),
|
||||
(None, 0),
|
||||
"stale counts must not reach the prompt"
|
||||
);
|
||||
let before = s.cursor();
|
||||
s.search_step(true);
|
||||
assert_eq!(s.cursor(), before, "stale step is a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_search_origin_translates_through_local_edits() {
|
||||
// Q#AI8: the session origin is a raw byte offset; an edit
|
||||
// before it must shift it (right-gravity) so cancel restores
|
||||
// the same TEXT position, not the same number.
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
s.active_window_mut().cursor = 5;
|
||||
s.search_begin(true, false); // origin byte 5
|
||||
type_query(&mut s, "foo");
|
||||
assert_eq!(s.cursor(), 8, "focused the match after the origin");
|
||||
s.active_window_mut().cursor = 0;
|
||||
assert!(s.insert_char('x'));
|
||||
assert!(s.insert_char('y'));
|
||||
s.search_finish(false); // cancel
|
||||
assert_eq!(
|
||||
s.cursor(),
|
||||
7,
|
||||
"cancel restores the translated origin (5 + 2 inserted bytes)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_search_recompute_focuses_from_the_translated_origin() {
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
s.active_window_mut().cursor = 1;
|
||||
s.search_begin(true, false); // origin byte 1
|
||||
type_query(&mut s, "fo");
|
||||
assert_eq!(s.cursor(), 8, "first match at/after the origin");
|
||||
s.active_window_mut().cursor = 0;
|
||||
assert!(s.insert_char('x'));
|
||||
assert!(s.insert_char('y'));
|
||||
assert!(s.insert_char('z'));
|
||||
// "xyzfoo bar foo": origin 1 -> 4. Growing the query recomputes
|
||||
// and must focus from the TRANSLATED origin: the match at 11,
|
||||
// not the pre-edit offset 1's neighbor at 3.
|
||||
type_query(&mut s, "o");
|
||||
assert_eq!(
|
||||
s.cursor(),
|
||||
11,
|
||||
"recompute focuses the first match at/after the translated origin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_buffer_edit_marks_stale_and_translates_the_origin() {
|
||||
// Q#AI8 at the direct-edit seam (Lua mutators / applied CRDT
|
||||
// ops): notify_buffer_edit must invalidate matches and shift
|
||||
// the live origin exactly like apply_active_edit does.
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
let bid = s.active_buffer_id();
|
||||
s.active_window_mut().cursor = 1;
|
||||
s.search_begin(true, false); // origin byte 1
|
||||
type_query(&mut s, "foo");
|
||||
let edit = {
|
||||
let mut reg = s.registry.borrow_mut();
|
||||
let buffer = reg.get_mut(bid).expect("buffer");
|
||||
buffer
|
||||
.apply_edit(crate::buffer::EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"zz",
|
||||
})
|
||||
.expect("direct insert")
|
||||
};
|
||||
s.notify_buffer_edit(bid, &edit);
|
||||
assert!(
|
||||
s.search_store.lock().expect("store").is_stale(bid),
|
||||
"direct edits mark the matches stale"
|
||||
);
|
||||
s.search_finish(false); // cancel
|
||||
assert_eq!(
|
||||
s.cursor(),
|
||||
3,
|
||||
"cancel restores the origin translated through the direct edit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_stale_accepted_search_navigation() {
|
||||
// Q#AI8 (PR #109 round 1): history edits move bytes like any
|
||||
// other edit — undo/redo must invalidate search state.
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
s.active_window_mut().cursor = 0;
|
||||
assert!(s.insert_char('x')); // the history entry: "xfoo bar foo"
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
s.search_finish(true); // accept
|
||||
assert_eq!(s.search_match_summary(), (Some(0), 2));
|
||||
s.undo(); // back to "foo bar foo": offsets moved
|
||||
assert_eq!(
|
||||
s.search_match_summary(),
|
||||
(None, 0),
|
||||
"undo stales the accepted matches"
|
||||
);
|
||||
let before = s.cursor();
|
||||
s.search_step(true);
|
||||
assert_eq!(s.cursor(), before, "stale step is a no-op after undo");
|
||||
|
||||
// Redo the same way: refresh the matches first (a fresh set
|
||||
// clears staleness), then redo must stale them again.
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
s.search_finish(true);
|
||||
assert_eq!(s.search_match_summary().1, 2);
|
||||
s.redo(); // forward to "xfoo bar foo" again
|
||||
assert_eq!(
|
||||
s.search_match_summary(),
|
||||
(None, 0),
|
||||
"redo stales the accepted matches"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_translates_the_live_search_origin() {
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
s.active_window_mut().cursor = 0;
|
||||
assert!(s.insert_char('x')); // "xfoo bar foo"
|
||||
s.active_window_mut().cursor = 5;
|
||||
s.search_begin(true, false); // origin 5 ('b' of "bar")
|
||||
type_query(&mut s, "foo");
|
||||
s.undo(); // removes the 'x' at 0: origin must shift to 4
|
||||
s.search_finish(false); // cancel
|
||||
assert_eq!(
|
||||
s.cursor(),
|
||||
4,
|
||||
"cancel lands on the origin translated through the undo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_translates_through_deletes_on_both_paths() {
|
||||
// Dispatch path (apply_active_edit): backspace before the
|
||||
// origin.
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
s.active_window_mut().cursor = 5;
|
||||
s.search_begin(true, false); // origin 5
|
||||
type_query(&mut s, "foo");
|
||||
s.active_window_mut().cursor = 2;
|
||||
s.backspace(); // deletes byte 1: origin 5 -> 4
|
||||
s.search_finish(false);
|
||||
assert_eq!(s.cursor(), 4, "origin shifted left by the deleted byte");
|
||||
|
||||
// Direct-notification path: a delete edit through
|
||||
// notify_buffer_edit.
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
let bid = s.active_buffer_id();
|
||||
s.active_window_mut().cursor = 5;
|
||||
s.search_begin(true, false); // origin 5
|
||||
type_query(&mut s, "foo");
|
||||
let edit = {
|
||||
let mut reg = s.registry.borrow_mut();
|
||||
let buffer = reg.get_mut(bid).expect("buffer");
|
||||
buffer
|
||||
.apply_edit(crate::buffer::EditOp::Delete {
|
||||
range: Range::new(0, 2),
|
||||
})
|
||||
.expect("direct delete")
|
||||
};
|
||||
s.notify_buffer_edit(bid, &edit);
|
||||
s.search_finish(false);
|
||||
assert_eq!(
|
||||
s.cursor(),
|
||||
3,
|
||||
"origin shifted left by the two directly deleted bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_search_fails_closed_while_stale_and_recovers_on_retype() {
|
||||
// Q#AI8 during a LIVE session: an external edit mid-search
|
||||
// makes step and summary fail closed; the next pattern
|
||||
// keystroke recomputes (set clears staleness) and resumes.
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
let bid = s.active_buffer_id();
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "fo");
|
||||
assert_eq!(s.search_match_summary().1, 2);
|
||||
let edit = {
|
||||
let mut reg = s.registry.borrow_mut();
|
||||
let buffer = reg.get_mut(bid).expect("buffer");
|
||||
buffer
|
||||
.apply_edit(crate::buffer::EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"zz",
|
||||
})
|
||||
.expect("direct insert")
|
||||
};
|
||||
s.notify_buffer_edit(bid, &edit);
|
||||
assert_eq!(
|
||||
s.search_match_summary(),
|
||||
(None, 0),
|
||||
"summary fails closed mid-search"
|
||||
);
|
||||
let before = s.cursor();
|
||||
s.search_step(true);
|
||||
assert_eq!(s.cursor(), before, "step fails closed mid-search");
|
||||
// Growing the query recomputes against the current text.
|
||||
type_query(&mut s, "o");
|
||||
assert_eq!(
|
||||
s.search_match_summary().1,
|
||||
2,
|
||||
"the next pattern keystroke refreshes the match set"
|
||||
);
|
||||
assert_eq!(s.cursor(), 2, "focus lands from the translated origin");
|
||||
s.search_step(true);
|
||||
assert_eq!(s.cursor(), 10, "stepping resumes after the refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_backspace_widens_the_match_set() {
|
||||
let mut s = from_bytes(b"fo foo food");
|
||||
|
|
|
|||
|
|
@ -145,8 +145,15 @@ impl SearchStore {
|
|||
|
||||
/// Step the active match forward or backward, wrapping. Returns
|
||||
/// the new active match's range, or `None` when the buffer has no
|
||||
/// matches.
|
||||
/// matches — or when they are stale (Q#AI8 fail-closed): stale
|
||||
/// ranges were computed against pre-edit text, and stepping
|
||||
/// through them would teleport the cursor to offsets that no
|
||||
/// longer exist. A live search un-sticks on the next pattern
|
||||
/// keystroke ([`Self::set`] clears staleness).
|
||||
pub fn step(&mut self, buffer_id: BufferId, forward: bool) -> Option<ByteRange> {
|
||||
if self.stale.contains(&buffer_id) {
|
||||
return None;
|
||||
}
|
||||
let s = self.by_buffer.get_mut(&buffer_id)?;
|
||||
let n = s.matches.len();
|
||||
if n == 0 {
|
||||
|
|
@ -830,4 +837,20 @@ mod tests {
|
|||
s.mark_stale(other);
|
||||
assert!(!s.is_stale(other));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_fails_closed_while_stale() {
|
||||
// Q#AI8: stale ranges were computed against pre-edit text;
|
||||
// stepping through them would teleport the cursor to offsets
|
||||
// that no longer exist.
|
||||
let mut s = SearchStore::new();
|
||||
let bid = BufferId::next();
|
||||
s.set(bid, "x", vec![r(0, 1), r(4, 5)]);
|
||||
assert!(s.step(bid, true).is_some(), "fresh matches step");
|
||||
s.mark_stale(bid);
|
||||
assert!(s.step(bid, true).is_none(), "stale matches do not");
|
||||
// A re-run (`set`) clears staleness and stepping resumes.
|
||||
s.set(bid, "x", vec![r(0, 1), r(4, 5)]);
|
||||
assert!(s.step(bid, true).is_some(), "fresh set un-sticks stepping");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,505 @@
|
|||
//! Auto-indent acceptance (Arc 2, docs/auto-indent-framing.md).
|
||||
//!
|
||||
//! Dispatch-driven: RET through `dispatch_key`, `M-x` through the real
|
||||
//! minibuffer. Auto-indent is language-agnostic (Q#AI3 copies bytes),
|
||||
//! so buffers are plain in-memory scratch buffers — no files, no
|
||||
//! language detection, no `StateDir`.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::protocol::FrontendId;
|
||||
|
||||
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent {
|
||||
code,
|
||||
modifiers: mods,
|
||||
kind: KeyEventKind::Press,
|
||||
state: KeyEventState::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 press(s: &mut EditorState, code: KeyCode) {
|
||||
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn type_str(s: &mut EditorState, text: &str) {
|
||||
for ch in text.chars() {
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char(ch), KeyModifiers::NONE),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 buffer_text(s: &EditorState) -> String {
|
||||
let b: mlua::String = eval(
|
||||
s,
|
||||
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
|
||||
);
|
||||
String::from_utf8_lossy(&b.as_bytes()).into_owned()
|
||||
}
|
||||
|
||||
fn cursor(s: &EditorState) -> i64 {
|
||||
eval(s, "return pmacs.editor.cursor()")
|
||||
}
|
||||
|
||||
fn status(s: &EditorState) -> String {
|
||||
s.core.borrow().status.clone()
|
||||
}
|
||||
|
||||
/// Fresh editor whose active scratch buffer holds `body`, cursor at 0.
|
||||
fn editor_with(body: &str) -> EditorState {
|
||||
let s = EditorState::new();
|
||||
if !body.is_empty() {
|
||||
exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})"));
|
||||
}
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The indent copy (Q#AI3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ret_at_eol_carries_the_space_indent() {
|
||||
let mut s = editor_with(" foo\nbar\n");
|
||||
exec(&s, "pmacs.editor.goto_byte(7)"); // end of " foo"
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(buffer_text(&s), " foo\n \nbar\n");
|
||||
assert_eq!(cursor(&s), 12, "cursor lands after the carried indent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_and_mixed_indents_round_trip_verbatim() {
|
||||
let mut s = editor_with("\tfoo");
|
||||
exec(&s, "pmacs.editor.goto_byte(4)");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(buffer_text(&s), "\tfoo\n\t", "a tab indent copies as a tab");
|
||||
assert_eq!(cursor(&s), 6);
|
||||
|
||||
let mut s = editor_with("\t foo");
|
||||
exec(&s, "pmacs.editor.goto_byte(6)");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
"\t foo\n\t ",
|
||||
"mixed tab+space indents copy byte-for-byte"
|
||||
);
|
||||
assert_eq!(cursor(&s), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mid_line_split_carries_the_tail_onto_the_indented_line() {
|
||||
let mut s = editor_with(" foobar");
|
||||
exec(&s, "pmacs.editor.goto_byte(7)"); // between "foo" and "bar"
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(buffer_text(&s), " foo\n bar");
|
||||
assert_eq!(cursor(&s), 12, "cursor sits before the carried tail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_inside_the_leading_whitespace_does_not_double_indent() {
|
||||
// Q#AI3 clip rule: `··|··foo` → `··` / `····foo` — the carried
|
||||
// text keeps its TOTAL indentation (4), instead of gaining the
|
||||
// full 4-wide indent on top of its remaining 2 spaces.
|
||||
let mut s = editor_with(" foo");
|
||||
exec(&s, "pmacs.editor.goto_byte(2)");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(buffer_text(&s), " \n foo");
|
||||
assert_eq!(cursor(&s), 5, "cursor after the clipped indent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_indent_and_empty_buffer_match_plain_newline() {
|
||||
let mut s = editor_with("");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(buffer_text(&s), "\n");
|
||||
assert_eq!(cursor(&s), 1);
|
||||
|
||||
let mut s = editor_with("foo");
|
||||
exec(&s, "pmacs.editor.goto_byte(3)");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(buffer_text(&s), "foo\n");
|
||||
assert_eq!(cursor(&s), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn giant_minified_line_splits_correctly() {
|
||||
// PR #109 round 1 finding 4: the indent scan is forward-chunked
|
||||
// and stops at the first non-whitespace byte, so Enter at the end
|
||||
// of a huge unindented line never materializes the line. This
|
||||
// pins the behavior; boundedness is by construction.
|
||||
let long = "x".repeat(64 * 1024);
|
||||
let mut s = editor_with(&long);
|
||||
exec(&s, &format!("pmacs.editor.goto_byte({})", long.len()));
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(buffer_text(&s), format!("{long}\n"));
|
||||
assert_eq!(cursor(&s) as usize, long.len() + 1);
|
||||
|
||||
// And an indented giant line still carries exactly its indent.
|
||||
let body = format!(" {long}");
|
||||
let mut s = editor_with(&body);
|
||||
exec(&s, &format!("pmacs.editor.goto_byte({})", body.len()));
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(buffer_text(&s), format!("{body}\n "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_line_copies_and_the_abandoned_line_keeps_its_whitespace() {
|
||||
// Named non-goal (Q#AI3): no trailing-whitespace cleanup on the
|
||||
// line being left behind.
|
||||
let mut s = editor_with(" ");
|
||||
exec(&s, "pmacs.editor.goto_byte(4)");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(buffer_text(&s), " \n ");
|
||||
assert_eq!(cursor(&s), 9);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Region type-over + selections (Q#AI4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn region_ret_is_one_replace_one_undo_step_and_clears_the_selection() {
|
||||
let mut s = editor_with(" hello world");
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.begin_selection(4); pmacs.editor.goto_byte(15)",
|
||||
);
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
" \n ",
|
||||
"the region is replaced by newline+indent in one edit"
|
||||
);
|
||||
assert_eq!(cursor(&s), 9);
|
||||
let region_active: bool = eval(&s, "return pmacs.editor.region() ~= nil");
|
||||
assert!(!region_active, "selection clears after a region RET");
|
||||
ctrl(&mut s, '/'); // buffer.undo, exactly once
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
" hello world",
|
||||
"one undo restores the whole type-over"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_ret_is_one_undo_step() {
|
||||
let mut s = editor_with(" ab");
|
||||
exec(&s, "pmacs.editor.goto_byte(4)");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(buffer_text(&s), " ab\n ");
|
||||
ctrl(&mut s, '/');
|
||||
assert_eq!(buffer_text(&s), " ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_length_selection_does_not_type_over_the_fresh_newline() {
|
||||
// Q#AI4: an armed anchor at the cursor reports no region; the RET
|
||||
// moves the cursor off it, so without the unconditional clear the
|
||||
// next self-insert would replace the newline ("S-Left at BOF,
|
||||
// RET, x" → "x").
|
||||
let mut s = editor_with("");
|
||||
exec(&s, "pmacs.editor.begin_selection(0)");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
type_str(&mut s, "x");
|
||||
assert_eq!(buffer_text(&s), "\nx", "the newline survives the 'x'");
|
||||
|
||||
// Q#AI9: the retained plain-newline escape hatch (through the
|
||||
// fixed core arm) behaves the same.
|
||||
let mut s = editor_with("");
|
||||
exec(&s, "pmacs.editor.begin_selection(0)");
|
||||
m_x(&mut s, "buffer.newline");
|
||||
type_str(&mut s, "x");
|
||||
assert_eq!(buffer_text(&s), "\nx");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intercept discipline (Q#AI5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn rejecting_intercept_reports_without_throwing_or_mutating() {
|
||||
let mut s = editor_with(" a");
|
||||
exec(&s, "pmacs.editor.goto_byte(3)");
|
||||
exec(
|
||||
&s,
|
||||
r#"
|
||||
_G.reject_once = true
|
||||
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
|
||||
if _G.reject_once then
|
||||
_G.reject_once = false
|
||||
error("rejected by test intercept")
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
"#,
|
||||
);
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert!(status(&s).contains("rejected"), "got: {:?}", status(&s));
|
||||
assert_eq!(buffer_text(&s), " a");
|
||||
assert_eq!(cursor(&s), 3, "no cursor motion on a rejected RET");
|
||||
press(&mut s, KeyCode::Enter); // allowed again: works
|
||||
assert_eq!(buffer_text(&s), " a\n ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relocating_intercept_moves_the_payload_but_does_not_teleport_the_cursor() {
|
||||
// The only transform an insert admits (M6.4): moving its `pos`.
|
||||
// The payload lands where the intercept sent it; the cursor is
|
||||
// translated through the edit, NOT jumped to the remote site.
|
||||
let mut s = editor_with(" abc");
|
||||
exec(&s, "pmacs.editor.goto_byte(5)");
|
||||
exec(
|
||||
&s,
|
||||
r#"
|
||||
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
|
||||
if op.kind == "insert" then
|
||||
return { kind = "insert", pos = 0, bytes = op.bytes }
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
"#,
|
||||
);
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert!(status(&s).contains("altered"), "got: {:?}", status(&s));
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
"\n abc",
|
||||
"the newline+indent payload landed at the intercept's position"
|
||||
);
|
||||
assert_eq!(
|
||||
cursor(&s),
|
||||
8,
|
||||
"cursor shifted right by the inserted length (5+3), not teleported to the edit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_intercept_leaves_a_valid_cursor_and_no_selection() {
|
||||
// Only a replace can shrink the buffer (M6.4): the intercept
|
||||
// expands the replaced range past the payload. The cursor must be
|
||||
// right-gravity-translated into the shrunken buffer — validity,
|
||||
// not immobility — and the selection cleared.
|
||||
let mut s = editor_with(" hello world wide");
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.begin_selection(2); pmacs.editor.goto_byte(7)",
|
||||
);
|
||||
exec(
|
||||
&s,
|
||||
r#"
|
||||
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
|
||||
if op.kind == "replace" then
|
||||
return {
|
||||
kind = "replace",
|
||||
start = op.start,
|
||||
["end"] = op["end"] + 8,
|
||||
bytes = op.bytes,
|
||||
}
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
"#,
|
||||
);
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert!(status(&s).contains("altered"), "got: {:?}", status(&s));
|
||||
assert_eq!(buffer_text(&s), " \n ide", "the expanded replace stands");
|
||||
let len: i64 = eval(&s, "return pmacs.window.buffer():len()");
|
||||
assert_eq!(cursor(&s), 5, "cursor translated to the edit's new end");
|
||||
assert!(cursor(&s) <= len, "cursor within the shrunken buffer");
|
||||
let region_active: bool = eval(&s, "return pmacs.editor.region() ~= nil");
|
||||
assert!(!region_active, "selection cleared under the same guard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_switching_intercept_skips_fixup_and_leaves_the_new_context_alone() {
|
||||
// Q#AI5 guard: an intercept may switch the active window/buffer
|
||||
// (the registry borrow is released). The fix-up must not touch
|
||||
// whatever is active afterwards. This proves the NEW context is
|
||||
// untouched; the original window's state after such an intercept
|
||||
// is the substrate-reconciliation deferral's territory.
|
||||
let mut s = editor_with(" a");
|
||||
exec(&s, "pmacs.editor.goto_byte(3)");
|
||||
exec(
|
||||
&s,
|
||||
r#"
|
||||
_G.orig = pmacs.window.buffer()
|
||||
_G.other = pmacs.buffer.create("*other*")
|
||||
pmacs.buffer.add_intercept(_G.orig, function(_op)
|
||||
pmacs.window.switch_buffer(_G.other)
|
||||
return nil
|
||||
end)
|
||||
"#,
|
||||
);
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert!(
|
||||
status(&s).contains("context changed"),
|
||||
"got: {:?}",
|
||||
status(&s)
|
||||
);
|
||||
let name: String = eval(&s, "return pmacs.window.buffer():name()");
|
||||
assert_eq!(name, "*other*", "the intercept's buffer switch stands");
|
||||
assert_eq!(cursor(&s), 0, "the new context's cursor is untouched");
|
||||
let orig: mlua::String = eval(&s, "return _G.orig:slice(0, _G.orig:len())");
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&orig.as_bytes()),
|
||||
" a\n ",
|
||||
"the edit itself landed in the original buffer"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search staleness through RET (Q#AI8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn accepted_search_navigation_fails_closed_after_ret() {
|
||||
let mut s = editor_with("ind ind ind");
|
||||
ctrl(&mut s, 's');
|
||||
type_str(&mut s, "ind");
|
||||
press(&mut s, KeyCode::Enter); // accept: matches stay until an edit
|
||||
exec(&s, "pmacs.editor.goto_byte(11)");
|
||||
press(&mut s, KeyCode::Enter); // auto-indent RET marks them stale
|
||||
assert_eq!(buffer_text(&s), "ind ind ind\n");
|
||||
let at = cursor(&s);
|
||||
exec(&s, "pmacs.editor.search_step(true)");
|
||||
assert_eq!(
|
||||
cursor(&s),
|
||||
at,
|
||||
"post-accept navigation is a no-op once RET staled the matches"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_lua_edit_stales_accepted_search_navigation() {
|
||||
let mut s = editor_with("foo foo");
|
||||
ctrl(&mut s, 's');
|
||||
type_str(&mut s, "foo");
|
||||
press(&mut s, KeyCode::Enter); // accept
|
||||
exec(&s, "pmacs.window.buffer():insert(0, \"zz\")"); // notify path
|
||||
let at = cursor(&s);
|
||||
exec(&s, "pmacs.editor.search_step(true)");
|
||||
assert_eq!(
|
||||
cursor(&s),
|
||||
at,
|
||||
"a direct buf:insert must stale the matches like any edit"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Substrate plumbing (Q#AI7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn after_edit_fires_exactly_once_per_ret_keybound_and_m_x() {
|
||||
let mut s = editor_with(" a");
|
||||
exec(&s, "pmacs.editor.goto_byte(3)");
|
||||
exec(
|
||||
&s,
|
||||
"_G.ae = 0; pmacs.hook.add('buffer.after-edit', function() _G.ae = _G.ae + 1 end)",
|
||||
);
|
||||
press(&mut s, KeyCode::Enter);
|
||||
let n: i64 = eval(&s, "return _G.ae");
|
||||
assert_eq!(n, 1, "keybound RET fires after-edit once");
|
||||
m_x(&mut s, "edit.newline-and-indent");
|
||||
assert_eq!(buffer_text(&s), " a\n \n ");
|
||||
let n: i64 = eval(&s, "return _G.ae");
|
||||
assert_eq!(n, 2, "M-x RET fires after-edit exactly once more");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ret_between_kills_breaks_the_kill_chain() {
|
||||
let mut s = editor_with("one\ntwo\nthree\n");
|
||||
ctrl(&mut s, 'k'); // kills "one"; line now blank, cursor 0
|
||||
press(&mut s, KeyCode::Enter); // rotates the command boundary
|
||||
ctrl(&mut s, 'k'); // kills "\n" — must push fresh, not append
|
||||
let ring: Vec<String> = eval(&s, "return pmacs.killring.list()");
|
||||
assert_eq!(
|
||||
ring,
|
||||
vec!["\n", "one"],
|
||||
"C-k, RET, C-k yields two ring entries (chain broken)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_command_during_ret_is_the_new_command() {
|
||||
let mut s = editor_with("");
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.hook.add('buffer.after-edit', function() _G.tc = pmacs.editor.this_command() end)",
|
||||
);
|
||||
press(&mut s, KeyCode::Enter);
|
||||
let tc: String = eval(&s, "return _G.tc");
|
||||
assert_eq!(tc, "edit.newline-and-indent");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contexts RET must not disturb (ground truth: consumed before the keymap)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn minibuffer_and_buffer_list_ret_are_unaffected() {
|
||||
// The m_x helper itself proves minibuffer accept (used throughout
|
||||
// this suite). The classic buffer list's RET is a buffer-local
|
||||
// binding through normal dispatch (ground truth) — it must visit,
|
||||
// not newline-and-indent into the list.
|
||||
let mut s = editor_with("hello");
|
||||
ctrl(&mut s, 'x');
|
||||
ctrl(&mut s, 'b');
|
||||
let name: String = eval(&s, "return pmacs.window.buffer():name()");
|
||||
assert_eq!(name, "*buffer-list*");
|
||||
let listed = buffer_text(&s);
|
||||
exec(&s, "_G.list = pmacs.window.buffer()");
|
||||
press(&mut s, KeyCode::Enter); // buffer-local RET: visit
|
||||
let name: String = eval(&s, "return pmacs.window.buffer():name()");
|
||||
assert_ne!(name, "*buffer-list*", "RET visits instead of inserting");
|
||||
let list_after: String = eval(
|
||||
&s,
|
||||
"if not _G.list:is_valid() then return \"<gone>\" end \
|
||||
return _G.list:slice(0, _G.list:len())",
|
||||
);
|
||||
assert!(
|
||||
list_after == "<gone>" || list_after == listed,
|
||||
"no newline landed in the buffer list"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isearch_ret_accepts_instead_of_inserting() {
|
||||
let mut s = editor_with("abc abc");
|
||||
ctrl(&mut s, 's');
|
||||
type_str(&mut s, "abc");
|
||||
press(&mut s, KeyCode::Enter); // isearch accept, consumed pre-keymap
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
"abc abc",
|
||||
"RET during isearch accepts; no newline is inserted"
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
// auto_indent_crdt_acceptance.rs --- RET's daemon CRDT round trip.
|
||||
|
||||
//! Auto-indent daemon-side wire acceptance (Q#AI6, the second of the
|
||||
//! two named GPU seams in docs/auto-indent-framing.md): a synthetic
|
||||
//! attached replica sends pending optimistic self-inserts followed by
|
||||
//! a round-tripped Enter, and the daemon must dispatch
|
||||
//! `edit.newline-and-indent` and broadcast the resulting multi-byte
|
||||
//! CRDT op back to the source replica. This is the daemon side of the
|
||||
//! wire path the GPU frontend takes now that plain Enter is no longer
|
||||
//! optimistic-eligible; the in-crate classifier test in `pmacs-gpu`
|
||||
//! covers the frontend side of the seam.
|
||||
|
||||
#![cfg(feature = "crdt")]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use pmacs::crdt::CrdtState;
|
||||
use pmacs::protocol::{FrontendEvent, FrontendId, Key, KeyEvent, Modifiers};
|
||||
use pmacs::rope::CrdtOp as RopeCrdtOp;
|
||||
use pmacs::transport::write_message;
|
||||
|
||||
mod common;
|
||||
use common::daemon::{TestDaemon, attach_multi};
|
||||
|
||||
/// Read the daemon's initial `BufferSnapshot` for a freshly-attached
|
||||
/// replica stream (the daemon always emits it first).
|
||||
fn read_initial_snapshot(
|
||||
stream: &mut std::os::unix::net::UnixStream,
|
||||
) -> (pmacs::buffer::BufferId, Vec<u8>) {
|
||||
match pmacs::transport::read_message::<pmacs::protocol::InstanceMessage>(stream)
|
||||
.expect("read initial BufferSnapshot")
|
||||
{
|
||||
pmacs::protocol::InstanceMessage::BufferSnapshot {
|
||||
buffer_id,
|
||||
crdt_snapshot,
|
||||
} => (buffer_id, crdt_snapshot),
|
||||
other => panic!("expected initial BufferSnapshot, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutate the local replica, export the delta, and ship it as an
|
||||
/// optimistic `FrontendEvent::CrdtOp` (the m10_11 idiom).
|
||||
fn send_optimistic_op_from<F>(
|
||||
stream: &mut std::os::unix::net::UnixStream,
|
||||
replica: &CrdtState,
|
||||
frontend_id: FrontendId,
|
||||
buffer_id: pmacs::buffer::BufferId,
|
||||
mutate: F,
|
||||
) where
|
||||
F: FnOnce(&CrdtState),
|
||||
{
|
||||
let v = replica.version();
|
||||
mutate(replica);
|
||||
let op_bytes = replica
|
||||
.export_updates_since(&v)
|
||||
.expect("export updates after local mutation");
|
||||
write_message(
|
||||
stream,
|
||||
&FrontendEvent::CrdtOp {
|
||||
frontend_id,
|
||||
buffer_id,
|
||||
op: RopeCrdtOp {
|
||||
peer_id: frontend_id.0,
|
||||
bytes: op_bytes,
|
||||
},
|
||||
},
|
||||
)
|
||||
.expect("write CrdtOp");
|
||||
}
|
||||
|
||||
/// Pump broadcast messages into the replica until it materializes
|
||||
/// `expected` or the deadline passes.
|
||||
fn pump_until(
|
||||
stream: &mut std::os::unix::net::UnixStream,
|
||||
replica: &CrdtState,
|
||||
buffer_id: pmacs::buffer::BufferId,
|
||||
expected: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<(), String> {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
while std::time::Instant::now() < deadline {
|
||||
if replica.materialize_string() == expected {
|
||||
return Ok(());
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
stream
|
||||
.set_read_timeout(Some(remaining.min(Duration::from_millis(100))))
|
||||
.ok();
|
||||
match pmacs::transport::read_message::<pmacs::protocol::InstanceMessage>(stream) {
|
||||
Ok(pmacs::protocol::InstanceMessage::CrdtOp { buffer_id: b, op }) if b == buffer_id => {
|
||||
let _ = replica.import_updates(&op.bytes);
|
||||
}
|
||||
Ok(_) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"expected materialize {expected:?}, got {observed:?} after {timeout:?}",
|
||||
observed = replica.materialize_string()
|
||||
))
|
||||
}
|
||||
|
||||
/// Pending optimistic self-inserts (`"··x"`, one op per keystroke,
|
||||
/// mirroring GPU typing), then Enter as a round-tripped Key. The
|
||||
/// daemon's dispatch must run `edit.newline-and-indent` — carrying
|
||||
/// the two-space indent — and the multi-byte op must come back to the
|
||||
/// source replica. A plain-newline dispatch would converge to
|
||||
/// `" x\n"` instead and fail the assertion.
|
||||
#[test]
|
||||
fn round_tripped_enter_after_pending_optimistic_input_auto_indents() {
|
||||
let daemon = TestDaemon::spawn();
|
||||
let (hello, mut stream) = attach_multi(&daemon);
|
||||
let fid = hello.assigned_frontend_id;
|
||||
|
||||
let (buffer_id, snap) = read_initial_snapshot(&mut stream);
|
||||
let replica = CrdtState::new(fid.0).expect("CrdtState::new");
|
||||
replica.import_snapshot(&snap).expect("import_snapshot");
|
||||
|
||||
// Three pending optimistic self-inserts, ahead of the Enter.
|
||||
send_optimistic_op_from(&mut stream, &replica, fid, buffer_id, |r| {
|
||||
r.insert(0, " ").expect("insert space");
|
||||
});
|
||||
send_optimistic_op_from(&mut stream, &replica, fid, buffer_id, |r| {
|
||||
r.insert(1, " ").expect("insert space");
|
||||
});
|
||||
send_optimistic_op_from(&mut stream, &replica, fid, buffer_id, |r| {
|
||||
r.insert(2, "x").expect("insert x");
|
||||
});
|
||||
|
||||
// Enter round-trips (never optimistic since Q#AI1): the daemon
|
||||
// applies the pending ops first — its cursor for this frontend
|
||||
// tracks the optimistic post-edit position — then dispatches the
|
||||
// keymap's RET binding.
|
||||
write_message(
|
||||
&mut stream,
|
||||
&FrontendEvent::Key(KeyEvent {
|
||||
frontend_id: fid,
|
||||
key: Key::Enter,
|
||||
mods: Modifiers::NONE,
|
||||
timestamp_ns: 0,
|
||||
}),
|
||||
)
|
||||
.expect("send Enter");
|
||||
|
||||
pump_until(
|
||||
&mut stream,
|
||||
&replica,
|
||||
buffer_id,
|
||||
" x\n ",
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.expect("replica converges to the auto-indented text");
|
||||
}
|
||||
Loading…
Reference in New Issue