From 424f82be5f9f6f2492d4de0c50707144cfac0930 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 19:47:48 -0400 Subject: [PATCH 1/7] docs: frame mode system wiring Record approved decisions and acceptance criteria for major-mode storage, dispatch, introspection, initialization, and statusline integration. --- docs/mode-system-wiring-framing.md | 401 +++++++++++++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 docs/mode-system-wiring-framing.md diff --git a/docs/mode-system-wiring-framing.md b/docs/mode-system-wiring-framing.md new file mode 100644 index 0000000..d45a9d6 --- /dev/null +++ b/docs/mode-system-wiring-framing.md @@ -0,0 +1,401 @@ +# Mode system wiring — side-quest (cross-cutting substrate) + +The keymap stack already supports mode-scoped bindings (`Scope::Mode`, +`KeymapStack::modes`, `bind_mode()`, and `resolve()` iteration over +`active_modes`), but **dispatch passes an empty mode list** (`&[]` at +`src/editor.rs:809`), so every `scope = "mode"` binding silently falls +through to the global keymap. This frames the minimal wiring to make +mode-scoped keybindings resolve, enabling per-language bindings (a future +markdown mode's `C-c C-c` family, Lua-mode bindings, etc.). + +Side-quest backlog: `docs/side-quest-backlog.md:133-134` and the +prioritization at lines 246-249. The latter also mentions mode-scoped +settings, but `pmacs.config` deliberately has only global and buffer-local +scopes; this side-quest wires key resolution only. Per-language settings +remain the shipped pattern: a `buffer.after-load` hook calling +`pmacs.config.set_local`. + +The auto-indent framing records the same empty-mode dispatch gap at lines +66-68, 291-294, and 434-436. This wiring is a necessary substrate for +modeline detection (backlog line 43), which will need somewhere to store +its result, but that feature is not blocked on it (language detection +already works through the extension chain). + +## Ground truth (as of canonical `main` @ `2e37c04`, protocol v18) + +All line numbers below reflect that tree. + +- **`KeymapStack` already mode-aware** (`src/keymap_stack.rs`): + - `Scope::Mode(String)` — variant ready in the scope enum. + - `KeymapStack::modes: Vec<(String, Keymap)>` — the per-mode keymap + storage, populated by `bind_mode()`. + - `KeymapStack::bind_mode(name, seq, cmd)` — **works today**. + Any Lua code can call + `pmacs.keymap.bind { scope = "mode", mode = "rust", ... }` without + error; it just never fires because dispatch never passes the mode name. + - `KeymapStack::resolve(seq, buffer, active_modes)` — mode iteration + over `active_modes` is ready, just never reached in active-context + production resolution. + - `KeyDispatcher::dispatch(chord, stack, buffer, active_modes)` — + signature accepts modes; passes them through to `resolve`. + +- **Dispatch callsite** (`src/editor.rs:800-810`): + ```rust + let active_buffer = Some(self.core.borrow().active_buffer_id()); + let action = { + let stack = self.lua_host.keymaps().borrow(); + self.dispatcher.dispatch(chord, &stack, active_buffer, &[]) + }; + ``` + This is the sole production `KeyDispatcher::dispatch` call. The remaining + `dispatch(…, &[])` calls are tests in `keymap_stack.rs`. + +- **Effective-key introspection is independently mode-blind**: + - `pmacs.describe.key` resolves with the active buffer but `&[]` for + modes (`src/lua_bindings/mod.rs:5893-5907`). Its own comment says it + and dispatch must update together when modes land. + - `help::render_key`, reached by `pmacs.help.show_key` / describe-key + and by followed `[key: …]` links, also resolves with `&[]` + (`src/help.rs:84-102`, called at + `src/lua_bindings/mod.rs:5466-5479` and `src/help.rs:417-435`). + Existing links encode buffer context only for buffer-scoped bindings; + a mode-scoped link carries no mode context. + - `pmacs.keymap.lookup` resolves with neither a buffer nor modes + (`src/lua_bindings/mod.rs:6112-6117`); it is the raw global lookup, + not an effective-key query, and stays that way in this side-quest. + +- **Buffer struct has no mode field** (`src/buffer.rs:158-219`). Language + is tracked externally: `parse_lang_by_buffer` in `syntax.lua`, + `attachments` in `lsp.lua`. + +- **Lua API for mode bindings already exists**: `parse_scope_arg` at + `src/lua_bindings/mod.rs:12548-12575` handles `scope = "mode"` with a + `mode` field. `BindArgs::apply` calls `stack.bind_mode()`. Tests exercise + this. + +- **Keymap resolution order** (buffer-local → modes → global) is + correct for the major-mode-as-primary design: buffer-local bindings + (compile-mode's panels) take priority over mode bindings, which take + priority over global. + +- **Language detection** already runs per-buffer in `buffer.after-load` + (`syntax.lua`), resolving through extension → LSP filetype → + filename → shebang. The resulting language string is the natural major + mode name. Detection can return a language with no bundled grammar; the + syntax attach path deliberately rejects only the parse, not the language. + +- **Config registry (#127):** `pmacs.config` has global and buffer-local + scopes only. Language/project conventions are hooks that call + `set_local`; this wiring does not add a third config scope. + +- **Statusline provider mechanism (#125, protocol v18):** composable + `pmacs.statusline` providers evaluate per frontend/window; the TUI + composes their output into the mode line via `paint_mode_line`. + This is the both-frontends display mechanism for any new fact that + should appear in the mode line. + +## Decisions + +### Q#MSW1 — Store the major mode on `Buffer` (private field + accessors) + +```rust +// src/buffer.rs +pub struct Buffer { + // …existing fields… + major_mode: Option, // private, per Buffer convention +} + +impl Buffer { + pub fn major_mode(&self) -> Option<&str> { + self.major_mode.as_deref() + } + pub fn set_major_mode(&mut self, mode: Option) { + self.major_mode = mode; + } +} +``` + +Motivation: the Rust core needs the mode at dispatch time without calling +into Lua. Adding it to `Buffer` makes it accessible via `Registry::get()` +from `EditorState`, and the field follows buffer identity without a second +lifecycle-managed map. + +**Hot-path contract: resolving a key allocates nothing for mode lookup.** +Change `KeymapStack::resolve` and `KeyDispatcher::dispatch` from +`active_modes: &[String]` to `active_modes: &[&str]`. At dispatch, hold the +buffer-registry borrow only across pure keymap resolution, take +`Buffer::major_mode()` by reference, and pass `Option<&str>::as_slice()` as +the zero-or-one mode slice. `Scope::Mode` owns the name only when a binding +actually resolves, so the returned action remains valid after the registry +borrow drops and before any Lua command runs. + +Rejected alternatives: +- **Lua table only** — would need a Lua call during dispatch to retrieve + modes, risking borrow conflicts and adding latency. +- **EditorCore map** — duplicates what `Buffer` already owns and would + need lifecycle management on buffer kill. +- **Clone into `Vec` per keypress** — avoidable allocation in the + input hot path. + +### Q#MSW2 — Major mode name = detected language name, one field + +The language detection chain already produces a string like `"rust"`, +`"python"`, `"markdown"`. Store exactly that as `Buffer.major_mode`. +No separate mode name space — the language name IS the mode name. +This means `pmacs.keymap.bind { scope = "mode", mode = "rust", ... }` +is both a "rust mode" binding and a "rust language" binding. + +`active_modes` at dispatch will be `[major_mode]` — a single-element +slice for v1. The keymap resolution iterates modes in order, so a single +mode works without special-casing. + +Displayed through the provider without cosmetic name transformation. The +raw language string (`"rust"`, `"cpp"`, `"javascript"`) enters the mode +line; the statusline framework's universal one-line sanitation still +applies. Cosmetic overrides belong in a user-registered provider. The +built-in provider (Q#MSW5) stays simple and faithful. + +### Q#MSW3 — Minor modes deferred entirely + +This wiring is the minimal bridge to make mode-scoped bindings resolve. +Minor modes (flycheck, evil, spellcheck) would need activation/deactivation +ordering, a toggle API, per-buffer minor-mode lists, and likely a +`buffer.after-mode-change` hook. None of that is needed for the mode +system to **work** — defer all of it to a follow-up. + +What this means concretely: +- No `minor_modes: Vec` field on `Buffer`. +- No `buffer.after-mode-change` hook; dispatch and statusline read the + setter's value live, but packages receive no transition notification. +- No `pmacs.minor_mode` Lua API. +- No mode-line indication for minor modes. + +### Q#MSW4 — Auto-initialize once on `buffer.after-load`; never on switch + +The Rust side provides storage. The Lua side initializes it alongside +existing language detection in `syntax.lua`. Refactor +`attach_for_active_buffer` to accept an `initialize_mode` boolean and use +the language it already resolves: + +```lua +local function attach_for_active_buffer(initialize_mode) + local buf = pmacs.window.buffer() + if not buf then return end + local key = tostring(buf) + local lang = pmacs.parse._has_view(buf) and parse_lang_by_buffer[key] + or resolve_active_language(buf) + + -- Initialization is before the grammar gate: server-only languages + -- are valid major modes even though syntax cannot attach a parse view. + if initialize_mode and lang and pmacs.buffer.major_mode(buf) == nil then + pmacs.buffer.set_major_mode(buf, lang) + end + + if not lang or not pmacs.parse._has_language(lang) then return end + -- Existing parse dispatch / overlay attach follows unchanged. +end +``` + +`buffer.after-load` calls `attach_for_active_buffer(true)`. +`buffer.after-switch` calls `attach_for_active_buffer(false)` after its +existing overlay reset. A switch reattaches syntax but **never** +auto-initializes or rewrites the mode. + +This separation is load-bearing: + +- A user hook can override the detected mode after load; later switches + preserve it. +- `pmacs.buffer.set_major_mode(buf, nil)` is a real persistent clear, not + an “uninitialized” state that the next switch silently repopulates. +- A server-only language detected via filetype/shebang gets a mode before + `_has_language` rejects only its missing grammar. +- First language wins for parsing exactly as today. Changing the major mode + explicitly does not silently swap an installed grammar or LSP attachment. + +### Q#MSW5 — Mode displayed via a built-in statusline provider (both frontends) + +`#125` already ships composable `pmacs.statusline` providers whose +output feeds `paint_mode_line` (TUI) and `StatuslineSegments` (GPU). +Instead of adding a `mode_name` parameter to the painter, ship a +built-in provider: + +```lua +-- Registration: strict typed fields, unknown key = error. +-- ctx.buffer is the buffer handle for the window being painted, +-- NOT the active/focused buffer — this is correct for passive splits. +pmacs.statusline.register { + name = "mode", + side = "left", + priority = 0, + face = "ui.modeline", + fn = function(ctx) + local mode = pmacs.buffer.major_mode(ctx.buffer) + if mode == nil then return "" end + return "(" .. mode .. ")" + end, +} +``` + +`ctx.buffer` is critical: the provider evaluates per-window, so a +passive split showing a Python buffer must say `"(python)"`, not +`"(rust)"` from the focused buffer. `pmacs.editor.active_modes()` is +unsuitable here for the same reason `pmacs.lsp.active_buffer_language()` +was replaced by `ctx.buffer` in #125's built-in LSP provider. + +Position: after the protected left chrome (active marker, modified flag, +buffer name), formatted as `+* name (mode)`. `paint_mode_line` appends +custom left segments after `protected_left`, so the "between name and +modified marker" claim is impossible without painter changes — the +honest position is after the full protected block. + +This gives both frontends the display at once, requires zero painter +signature changes (the `too_many_arguments` allow stays untouched), +and lets users disable/unregister the built-in handle and register their +own formatting. The `"ui.modeline"` face is inherited from the surrounding +chrome. Returning `""` for no mode is an ordinary successful omitted +segment under the provider framework; no failure latch is involved. + +### Q#MSW6 — New Lua API surface + +Two functions on `pmacs.buffer.*`: + +- `pmacs.buffer.major_mode(id) -> string|nil` — returns the buffer's + major mode name, or nil if none is set. +- `pmacs.buffer.set_major_mode(id, name)` — sets it; `name` is a string + or nil to clear. A clear persists across buffer switches because only + `buffer.after-load`, never `buffer.after-switch`, auto-initializes. + +Additionally, `pmacs.editor.active_modes() -> table` returns the current +active mode list (for the active buffer), matching what dispatch would +resolve at the time of the call: either `{major_mode}` or `{}`. It is useful +for introspection from modes or the minibuffer. Passive-window consumers +must use the parameterized `pmacs.buffer.major_mode(ctx.buffer)` instead. + +### Q#MSW7 — No protocol changes + +Mode is a daemon-side concept. Frontends never see mode names — they +receive resolved key actions and styled text. The single exception is the +status line, where the GPU frontend receives the existing +`StatuslineSegments` payload and can display the mode if the provider +includes it. Zero new protocol messages. + +### Q#MSW8 — Effective-key introspection uses the dispatch context + +Every API that claims to describe the binding effective in the current +buffer must resolve with both that buffer and its major mode: + +- `pmacs.describe.key` +- `pmacs.help.show_key` / `help::render_key` (the interactive + describe-key path) + +Both derive the borrowed zero-or-one mode slice from the same `Buffer` +field dispatch uses. `help::render_key` therefore accepts explicit borrowed +mode context rather than hard-coding `&[]`. + +Help-link targets must also preserve the scope needed after `*help*` becomes +active. Keep the existing `@buffer:` target for buffer-local bindings, +add `@mode:` for mode bindings, and parse either into the context +passed to `render_key`. Global links carry neither. Following a mode link +therefore describes that mode binding rather than resolving against the +help buffer and falling through to global. + +`pmacs.keymap.lookup` deliberately remains the raw global lookup. It has no +buffer parameter today and changing it into an ambient-context query would +silently change an existing API unrelated to describe-key. + +## Bets + +1. **Single-element `active_modes` covers the useful cases** — no one + needs multiple active modes before minor-mode semantics exist. + Compile-mode's buffer-local bindings already work as a substitute. +2. **Language name is the right mode name** — no user will want a + `"rust-mode"` that differs from `"rust"`. If they do, init.lua can + call `pmacs.buffer.set_major_mode` with a custom name. +3. **After-load-only initialization is sufficient** — every normally + opened buffer gets `buffer.after-load`; later switches only restore + views. The hidden-buffer (registry-only) gap is pre-existing: those + buffers receive neither language detection nor syntax attachment and + need their own fix. +4. **GPU optimistic-edit bypass is a non-issue for mode-scoped + bindings** — the GPU frontend optimistically inserts plain printable + characters (outside `BUILTIN_PAIR_CHARS`) and Tab without round-tripping + through dispatch (RET and built-in pair chars round-trip, per Q#AI1 + and Q#AP1). A mode-scoped binding on a plain printable or Tab would + silently not fire on GPU, but mode bindings are `C-c C-c`-style + control chords, which the optimistic path never touches. Documented + here so it is not a surprise if someone binds a plain printable in a + mode. + +## Deferred (named) + +- **Minor mode system** — activation/deactivation ordering, toggle, + minor mode list in buffer, minor-mode indicator in the mode line. +- **`buffer.after-mode-change` hook** — the explicit setter changes what + dispatch/statusline read immediately, but there is no notification API + for packages that want to react to transitions. Add that with dynamic + mode-aware package semantics, not for this single built-in initializer. +- **Mode-scoped settings** — `pmacs.config` remains global + + buffer-local. Per-language configuration uses an after-load hook calling + `set_local`; a first-class mode scope needs its own precedence, + introspection, and mode-change invalidation design. +- **Modeline detection** (`-*- mode: … -*-`, `vim: ft=…`) — a separate + side-quest (`side-quest-backlog.md:43`). This framing just wires the + mechanism; modeline detection can override the initialized mode via + `pmacs.buffer.set_major_mode` when it lands. +- **Mode help display** (`describe-mode`) — straightforward once the mode + is stored, but not table-stakes for wiring. + +## Acceptance + +Keymap acceptance is dispatch-driven (keypress → action) against the daemon +process. Statusline and introspection cases exercise their real evaluator / +Lua surfaces. Rust fixtures must empty `pmacs.lsp.config` before creation +unless the test intentionally exercises the LSP path — otherwise a real +server starts on buffer open. + +1. **Mode binding resolves**: a Lua test registers + `pmacs.keymap.bind { scope = "mode", mode = "rust", sequence = "C-c C-c", + command = "test.cmd" }`, opens a Rust buffer (LSP config cleared), + sends `C-c C-c`, and asserts `test.cmd` runs. The same sequence in a + Python buffer is unbound and never runs `test.cmd`. + +2. **No mode → no mode bindings**: a file with no detected language + (e.g. a `.txt` with no config) has `major_mode = nil`; mode-scoped + bindings never fire. + +3. **Mode displayed in mode line**: the built-in `"mode"` statusline + provider returns `"(rust)"` for a Rust buffer and no segment for an + unknown-language buffer. Evaluation uses `ctx.buffer`; a split with + Rust active and Python passive produces the correct per-window strings. + +4. **Mode survives buffer switch**: open A (rust) and B (python), switch + back and forth; `pmacs.buffer.major_mode` returns the correct language + each time. + +5. **Buffer-local beats mode beats global**: register the same sequence + at all three scopes and drive all three cases: buffer-local fires when + present; after removing it the active mode binding fires; in a buffer + with no matching mode the global binding fires. + +6. **`pmacs.editor.active_modes()` returns current mode list**: matches + the single-element `{lang}` or empty table for unknown-language buffers. + +7. **Explicit mode override is not clobbered**: call + `pmacs.buffer.set_major_mode(id, "markdown")`, switch away and back; + `pmacs.buffer.major_mode(id)` still returns `"markdown"`. + +8. **Explicit clear is not clobbered**: clear a detected mode with + `pmacs.buffer.set_major_mode(id, nil)`, switch away and back; the getter + still returns nil and the detected-language mode binding does not fire. + +9. **Server-only language receives a mode**: add a filetype/shebang mapping + to a language with no bundled grammar, open a matching file, and assert + that `major_mode` and `active_modes()` contain that language while no + parse view is attached. + +10. **Describe-key agrees with dispatch**: for a mode binding that dispatch + resolves, `pmacs.describe.key` reports the mode command and + `scope = "mode:rust"`, and `pmacs.help.show_key` renders the same command + and scope rather than the global fallback. Following that mode binding's + `[key: … @mode:rust]` link from command help produces the same result + after `*help*` is active. From a5488504233e5c3f56dcdf23551e5eaadd1ddbb0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 19:49:05 -0400 Subject: [PATCH 2/7] docs: require truthful authorship attribution Remove the standing instruction to claim Claude co-authorship and clarify that commit trailers and PR attribution must reflect actual contributions. --- docs/agent-handoff.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index bb2fb8d..e776e07 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -277,8 +277,9 @@ real bugs in round after round. The cadence that has worked for ~40 PRs: 7. After merge: update this handoff + your memory. Commit/PR conventions: commit messages via `git commit -F ` (no -inline backticks through the shell); end with the Claude co-author -line. PR bodies end with the Claude Code attribution. Clippy runs as +inline backticks through the shell). **Authorship trailers and PR +attributions must be truthful:** do not add a Claude co-author trailer or +Claude Code attribution unless Claude actually contributed. Clippy runs as its own step, never `&&`-chained. ## 3. Gate suite (all green before any PR) From 99cd7ec240ad287858216b805d134ecf67e49add Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 20:25:48 -0400 Subject: [PATCH 3/7] feat: wire major modes through key dispatch Store a detected major mode on each buffer and expose it through Lua. Resolve mode-scoped bindings in dispatch, describe-key, and help links, including exact encoded mode context after entering the help buffer. Initialize modes once at buffer load, preserve explicit overrides and clears across switches, and publish the mode through a per-window statusline provider. Add daemon acceptance for the complete mode lifecycle. --- builtin/runtime/syntax.lua | 30 +- src/buffer.rs | 29 ++ src/editor.rs | 49 ++- src/help.rs | 255 ++++++++++-- src/keymap_stack.rs | 30 +- src/lua_bindings/mod.rs | 208 +++++++++- tests/mode_system_wiring_acceptance.rs | 500 ++++++++++++++++++++++++ tests/statusline_segments_acceptance.rs | 5 +- 8 files changed, 1031 insertions(+), 75 deletions(-) create mode 100644 tests/mode_system_wiring_acceptance.rs diff --git a/builtin/runtime/syntax.lua b/builtin/runtime/syntax.lua index 18164dc..2d33368 100644 --- a/builtin/runtime/syntax.lua +++ b/builtin/runtime/syntax.lua @@ -228,7 +228,7 @@ local function resolve_active_language(buf) return pmacs.parse.language_from_shebang(buf) end -local function attach_for_active_buffer() +local function attach_for_active_buffer(initialize_mode) local buf = pmacs.window.buffer() if not buf then return end local key = tostring(buf) @@ -245,6 +245,14 @@ local function attach_for_active_buffer() -- never gives a wrong-grammar tree. local lang = pmacs.parse._has_view(buf) and parse_lang_by_buffer[key] or resolve_active_language(buf) + -- The detected language is also the initial major-mode name. Do this + -- before grammar gating: a language supplied only by an LSP filetype or + -- shebang mapping is still a valid mode even when no parser is bundled. + -- Only after-load initializes it; after-switch must preserve explicit + -- overrides and explicit nil clears. + if initialize_mode and lang and pmacs.buffer.major_mode(buf) == nil then + pmacs.buffer.set_major_mode(buf, lang) + end if not lang or not pmacs.parse._has_language(lang) then return end pmacs.parse._dispatch(buf, lang) -- T M4.3: install the syntax-highlight overlay for this buffer. @@ -266,7 +274,7 @@ end pmacs.hook.add("buffer.after-load", function() -- Best-effort: a missing grammar / re-entry / stale buffer -- mustn't poison the rest of the after-load chain. - local ok, err = pcall(attach_for_active_buffer) + local ok, err = pcall(function() attach_for_active_buffer(true) end) if not ok and pmacs.error then pmacs.error("syntax.after-load: " .. tostring(err)) end @@ -284,13 +292,29 @@ pmacs.hook.add("buffer.after-switch", function() local buf = pmacs.window.buffer() if not buf then return end highlighted_buffers[tostring(buf)] = nil - attach_for_active_buffer() + attach_for_active_buffer(false) end) if not ok and pmacs.error then pmacs.error("syntax.after-switch: " .. tostring(err)) end end) +-- Major mode is window-local presentation state because each split may show +-- a different buffer. The provider therefore reads ctx.buffer rather than +-- the focused buffer. Empty text omits the segment without tripping the +-- statusline failure latch. +pmacs.statusline.register { + name = "mode", + side = "left", + priority = 0, + face = "ui.modeline", + fn = function(ctx) + local mode = pmacs.buffer.major_mode(ctx.buffer) + if mode == nil then return "" end + return "(" .. mode .. ")" + end, +} + local function reparse_active_buffer_after_edit() local buf = pmacs.window.buffer() if not buf then return end diff --git a/src/buffer.rs b/src/buffer.rs index c8fbb0a..304918b 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -159,6 +159,8 @@ pub struct Buffer { id: BufferId, rope: Rope, name: String, + /// The buffer's single active major mode, if one has been selected. + major_mode: Option, is_modified: bool, /// When set, every content mutation is rejected before touching the /// rope, CRDT, history, revision, modified bit, marks, or views. @@ -245,6 +247,7 @@ impl Buffer { id, rope, name: name.into(), + major_mode: None, is_modified: false, read_only: false, revision: 0, @@ -451,6 +454,20 @@ impl Buffer { self.name = name.into(); } + /// This buffer's active major mode, if any. + /// + /// The returned name borrows the buffer-owned mode string so key + /// dispatch can resolve mode bindings without cloning on its hot path. + #[must_use] + pub fn major_mode(&self) -> Option<&str> { + self.major_mode.as_deref() + } + + /// Replace this buffer's active major mode, or clear it with `None`. + pub fn set_major_mode(&mut self, major_mode: Option) { + self.major_mode = major_mode; + } + /// Whether the buffer has been modified since the last save / load. #[must_use] pub fn is_modified(&self) -> bool { @@ -1869,6 +1886,18 @@ mod tests { out } + #[test] + fn major_mode_is_buffer_owned_and_replaceable() { + let mut buf = Buffer::new(BufferId::next(), "*mode-test*"); + assert_eq!(buf.major_mode(), None); + + buf.set_major_mode(Some("rust".to_owned())); + assert_eq!(buf.major_mode(), Some("rust")); + + buf.set_major_mode(None); + assert_eq!(buf.major_mode(), None); + } + dual_mode_test!( read_only_rejects_direct_skip_history_mutations, |make, make_bytes| { diff --git a/src/editor.rs b/src/editor.rs index a8590ae..dc7deea 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -797,16 +797,21 @@ impl EditorState { return; } - // Buffer-scope keybindings need the active buffer id passed - // through the dispatcher (otherwise `keymap_stack::resolve` - // skips the buffer-local map entirely and every "scope = - // buffer" binding falls through to global). The id is read - // outside the keymap borrow so a single-buffer focus check - // doesn't collide with the stack lookup below. - let active_buffer = Some(self.core.borrow().active_buffer_id()); + // Buffer- and mode-scope keybindings resolve against the active + // buffer. Keep its mode borrowed from the registry only while the + // pure keymap lookup runs: `Option::as_slice` provides the required + // zero-or-one borrowed slice without allocating or cloning. Both + // RefCell borrows end with this block, before any Lua command runs. + let active_buffer = self.core.borrow().active_buffer_id(); let action = { + let registry = self.lua_host.registry().borrow(); + let active_mode = registry + .get(active_buffer) + .ok() + .and_then(|buffer| buffer.major_mode()); let stack = self.lua_host.keymaps().borrow(); - self.dispatcher.dispatch(chord, &stack, active_buffer, &[]) + self.dispatcher + .dispatch(chord, &stack, Some(active_buffer), active_mode.as_slice()) }; // Snapshot the active buffer's edit revision before the command @@ -3722,6 +3727,34 @@ mod tests { } } + #[test] + fn dispatch_uses_active_buffer_major_mode_and_releases_borrows() { + let mut s = fresh_with(b""); + let buffer_id = s.core.borrow().active_buffer_id(); + s.lua_host + .registry() + .borrow_mut() + .get_mut(buffer_id) + .unwrap() + .set_major_mode(Some("dispatch-test".to_owned())); + s.lua_host + .keymaps() + .borrow_mut() + .bind_mode( + "dispatch-test", + &crate::key::parse_sequence("C-b").unwrap(), + "editor.list-buffers", + crate::command::SourceLocation::default(), + ) + .unwrap(); + + // `editor.list-buffers` mutably borrows the buffer registry. Reaching + // the resulting buffer therefore proves dispatch released both its + // registry and keymap borrows before invoking the mode-bound command. + s.dispatch_key(FrontendId::LOCAL, ctrl('b')); + assert_eq!(s.core.borrow().active_buffer_name(), "*buffer-list*"); + } + #[test] fn cx_cb_invokes_list_buffers() { // Regression for the user-reported "C-x C-b stalls" bug. After diff --git a/src/help.rs b/src/help.rs index c3688b7..b5b0985 100644 --- a/src/help.rs +++ b/src/help.rs @@ -13,6 +13,7 @@ //! * `[command: cursor.left]` --- navigate to that command's help. //! * `[key: C-x C-s]` --- describe the chord. //! * `[key: s @buffer:3]` --- describe a buffer-local chord. +//! * `[key: g @mode:rust]` --- describe a mode-scoped chord. //! * `[buffer: *errors*]` --- describe a buffer by name. //! * `[mode: normal]`, `[hook: buffer.before-save]`, `[view: *help*]`. //! @@ -84,21 +85,20 @@ pub fn render_command( /// Render help for a chord sequence. Returns the help buffer id if /// the sequence resolves to a binding, [`None`] otherwise. /// -/// `active_buffer` is the buffer scope to consult when resolving -/// the chord sequence. Pass `Some(id)` to surface buffer-local -/// bindings (matching what `dispatch_key` would see) and `None` -/// for global-only resolution. Buffer-scope keys (e.g., -/// `pmacs-magit.stage` bound to `s` on the magit buffer) are -/// invisible without this, which is the M8.7 describe-key gap. +/// `active_buffer` and `active_modes` are the exact scope context to +/// consult when resolving the chord sequence. Pass the active buffer +/// and its zero-or-one major-mode slice to match dispatch, or no +/// context for global-only resolution. pub fn render_key( registry: &mut BufferRegistry, commands: &CommandRegistry, keymaps: &KeymapStack, active_buffer: Option, + active_modes: &[&str], sequence: &str, ) -> RenderResult { let chords = parse_sequence(sequence).ok()?; - let resolution = keymaps.resolve(&chords, active_buffer, &[]); + let resolution = keymaps.resolve(&chords, active_buffer, active_modes); let StackResolution::Bound(rb) = resolution else { return None; }; @@ -150,7 +150,7 @@ pub fn render_mode( let mut text = String::new(); let _ = writeln!(text, "Mode: {name}"); let _ = writeln!(text); - write_mode_bindings(&mut text, map); + write_mode_bindings(&mut text, name, map); Some(replace_help_buffer(registry, &text)) } @@ -245,6 +245,16 @@ fn write_command_bindings( scope.render() ); } + Scope::Mode(name) => { + let encoded_name = encode_mode_target(name); + let _ = writeln!( + out, + " [key: {} @mode:{}] ({})", + display_sequence(seq), + encoded_name, + scope.render() + ); + } _ => { let _ = writeln!( out, @@ -258,33 +268,84 @@ fn write_command_bindings( } } -fn parse_key_target(registry: &BufferRegistry, target: &str) -> Option<(String, Option)> { - let Some((sequence, raw)) = target.rsplit_once(" @buffer:") else { - return Some((target.to_owned(), None)); - }; - let Ok(raw) = raw.trim().parse::() else { - return None; - }; - let id = BufferId::from_raw(raw); - if registry.contains(id) { - Some((sequence.trim().to_owned(), Some(id))) - } else { - None - } +fn is_mode_target_unreserved(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'~' | b'-') } -fn write_mode_bindings(out: &mut String, map: &Keymap) { +fn encode_mode_target(mode: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + + let mut encoded = String::with_capacity(mode.len()); + for byte in mode.bytes() { + if is_mode_target_unreserved(byte) { + encoded.push(char::from(byte)); + } else { + encoded.push('%'); + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0F)])); + } + } + encoded +} + +fn decode_mode_target(encoded: &str) -> Option { + let encoded = encoded.as_bytes(); + let mut decoded = Vec::with_capacity(encoded.len()); + let mut index = 0; + while index < encoded.len() { + let byte = encoded[index]; + if byte == b'%' { + let high = *encoded.get(index + 1)?; + let low = *encoded.get(index + 2)?; + let high = char::from(high).to_digit(16)?; + let low = char::from(low).to_digit(16)?; + decoded.push(u8::try_from((high << 4) | low).ok()?); + index += 3; + } else { + if !is_mode_target_unreserved(byte) { + return None; + } + decoded.push(byte); + index += 1; + } + } + String::from_utf8(decoded).ok() +} + +fn parse_key_target( + registry: &BufferRegistry, + target: &str, +) -> Option<(String, Option, Option)> { + if let Some((sequence, raw)) = target.rsplit_once(" @buffer:") { + let raw = raw.trim().parse::().ok()?; + let id = BufferId::from_raw(raw); + return registry + .contains(id) + .then(|| (sequence.trim().to_owned(), Some(id), None)); + } + + if let Some((sequence, encoded_mode)) = target.rsplit_once(" @mode:") { + let mode = decode_mode_target(encoded_mode)?; + return Some((sequence.trim().to_owned(), None, Some(mode))); + } + + Some((target.to_owned(), None, None)) +} + +fn write_mode_bindings(out: &mut String, mode: &str, map: &Keymap) { let entries: Vec<_> = map.iter().collect(); if entries.is_empty() { let _ = writeln!(out, "(empty mode keymap)"); return; } let _ = writeln!(out, "Bindings:"); + let encoded_mode = encode_mode_target(mode); for (seq, binding) in entries { let _ = writeln!( out, - " [key: {}] -> [command: {}]", + " [key: {} @mode:{}] -> [command: {}]", display_sequence(&seq), + encoded_mode, binding.command ); } @@ -430,8 +491,17 @@ pub fn follow_link_at( match link.kind.as_str() { "command" => render_command(registry, commands, keymaps, &link.target), "key" => { - let (sequence, active_buffer) = parse_key_target(registry, &link.target)?; - render_key(registry, commands, keymaps, active_buffer, &sequence) + let (sequence, active_buffer, mode) = parse_key_target(registry, &link.target)?; + let mode = mode.as_deref(); + let active_modes = mode.as_slice(); + render_key( + registry, + commands, + keymaps, + active_buffer, + active_modes, + &sequence, + ) } "buffer" => { let id = registry.find_by_name(&link.target)?; @@ -542,7 +612,7 @@ mod tests { }, ) .unwrap(); - let (id, _) = render_key(&mut reg, &cmds, &kms, None, "C-x C-s").unwrap(); + let (id, _) = render_key(&mut reg, &cmds, &kms, None, &[], "C-x C-s").unwrap(); let body = read_buffer_text(reg.get(id).unwrap()); assert!(body.contains("Key: C-x C-s")); assert!(body.contains("[command: save]")); @@ -554,7 +624,38 @@ mod tests { let mut reg = BufferRegistry::new(); let cmds = CommandRegistry::new(); let kms = KeymapStack::new(); - assert!(render_key(&mut reg, &cmds, &kms, None, "C-q").is_none()); + assert!(render_key(&mut reg, &cmds, &kms, None, &[], "C-q").is_none()); + } + + #[test] + fn render_key_uses_explicit_mode_context_before_global() { + let lua = Lua::new(); + let mut reg = BufferRegistry::new(); + let mut cmds = CommandRegistry::new(); + let mut kms = KeymapStack::new(); + cmds.define(make_command(&lua, "rust.save", "Rust save.")) + .unwrap(); + cmds.define(make_command(&lua, "global.save", "Global save.")) + .unwrap(); + kms.bind_global( + &parse_sequence("C-s").unwrap(), + "global.save", + SourceLocation::default(), + ) + .unwrap(); + kms.bind_mode( + "rust", + &parse_sequence("C-s").unwrap(), + "rust.save", + SourceLocation::default(), + ) + .unwrap(); + + render_key(&mut reg, &cmds, &kms, None, &["rust"], "C-s").unwrap(); + let body = read_help(®); + assert!(body.contains("Scope: mode:rust"), "{body}"); + assert!(body.contains("[command: rust.save]"), "{body}"); + assert!(!body.contains("[command: global.save]"), "{body}"); } #[test] @@ -643,7 +744,7 @@ mod tests { let _ = render_mode(&mut reg, &kms, "demo").unwrap(); let body = read_help(®); assert!(body.contains("Mode: demo")); - assert!(body.contains("[key: C-x]")); + assert!(body.contains("[key: C-x @mode:demo]")); assert!(body.contains("[command: x]")); } @@ -678,6 +779,26 @@ mod tests { assert_eq!(span.target, "C-x C-s"); } + #[test] + fn mode_key_target_codec_is_strict_and_round_trips_utf8() { + for mode in ["rust", "", " rust ", "]", "%", "雪", "x @buffer:1"] { + let encoded = encode_mode_target(mode); + assert_eq!(decode_mode_target(&encoded).as_deref(), Some(mode)); + } + assert_eq!(encode_mode_target("rust"), "rust"); + for malformed in ["%", "%0", "%GG", "%FF", "raw space", "雪"] { + assert!( + decode_mode_target(malformed).is_none(), + "accepted malformed mode target {malformed:?}" + ); + } + + let reg = BufferRegistry::new(); + let (_, _, mode) = parse_key_target(®, "s @mode:").unwrap(); + assert_eq!(mode.as_deref(), Some("")); + assert!(parse_key_target(®, "s @mode:%FF").is_none()); + } + #[test] fn link_at_off_link_returns_none() { let text = "Plain text with no [command: foo] here.\n"; @@ -752,4 +873,82 @@ mod tests { assert!(body.contains("Scope: buffer"), "{body}"); assert!(body.contains("[command: pmacs-magit.stage]"), "{body}"); } + + #[test] + fn follow_mode_key_link_preserves_mode_after_help_buffer_activation() { + let lua = Lua::new(); + let mut reg = BufferRegistry::new(); + let mut cmds = CommandRegistry::new(); + let mut kms = KeymapStack::new(); + let hooks = HookRegistry::new(); + cmds.define(make_command(&lua, "rust.action", "Mode action.")) + .unwrap(); + cmds.define(make_command(&lua, "global.action", "Global fallback.")) + .unwrap(); + kms.bind_mode( + "rust", + &parse_sequence("s").unwrap(), + "rust.action", + SourceLocation::default(), + ) + .unwrap(); + kms.bind_global( + &parse_sequence("s").unwrap(), + "global.action", + SourceLocation::default(), + ) + .unwrap(); + + render_command(&mut reg, &cmds, &kms, "rust.action").unwrap(); + let body = read_help(®); + assert!( + body.contains("[key: s @mode:rust]"), + "mode key link must carry its mode scope: {body}" + ); + let cursor = body.find("s @mode").unwrap() as u64; + follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap(); + let body = read_help(®); + assert!(body.contains("Scope: mode:rust"), "{body}"); + assert!(body.contains("[command: rust.action]"), "{body}"); + assert!(!body.contains("[command: global.action]"), "{body}"); + } + + #[test] + fn follow_mode_key_link_round_trips_reserved_utf8_mode_exactly() { + let lua = Lua::new(); + let mut reg = BufferRegistry::new(); + let mut cmds = CommandRegistry::new(); + let mut kms = KeymapStack::new(); + let hooks = HookRegistry::new(); + let mode = " rust]雪% @buffer:7 "; + cmds.define(make_command(&lua, "exact.mode", "Exact mode action.")) + .unwrap(); + cmds.define(make_command(&lua, "global.fallback", "Global fallback.")) + .unwrap(); + kms.bind_mode( + mode, + &parse_sequence("x").unwrap(), + "exact.mode", + SourceLocation::default(), + ) + .unwrap(); + kms.bind_global( + &parse_sequence("x").unwrap(), + "global.fallback", + SourceLocation::default(), + ) + .unwrap(); + + render_command(&mut reg, &cmds, &kms, "exact.mode").unwrap(); + let body = read_help(®); + let encoded = encode_mode_target(mode); + let link = format!("[key: x @mode:{encoded}]"); + assert!(body.contains(&link), "encoded mode link missing: {body}"); + let cursor = body.find("@mode:").unwrap() as u64; + follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap(); + let body = read_help(®); + assert!(body.contains(&format!("Scope: mode:{mode}")), "{body}"); + assert!(body.contains("[command: exact.mode]"), "{body}"); + assert!(!body.contains("[command: global.fallback]"), "{body}"); + } } diff --git a/src/keymap_stack.rs b/src/keymap_stack.rs index 42df49d..64f9eea 100644 --- a/src/keymap_stack.rs +++ b/src/keymap_stack.rs @@ -6,9 +6,8 @@ //! //! 1. **Buffer-local**: bindings that apply only when a specific //! buffer is the active one. Most-specific scope. -//! 2. **Mode**: bindings that apply when a mode is active. Modes -//! aren't a real concept until T M2.5+ but the stack accepts them -//! today so the resolver doesn't grow a dimension when we add them. +//! 2. **Mode**: bindings that apply when the active buffer's major mode +//! matches the mode keymap. //! 3. **Global**: the universal fallback. Last-resort scope. //! //! [`KeymapStack::resolve`] walks them in order and returns the @@ -38,7 +37,7 @@ use crate::keymap_tree::{Binding, Keymap, KeymapError, Resolution}; pub enum Scope { /// Buffer-local --- specific to one [`BufferId`]. Buffer(BufferId), - /// Mode-active --- one of the active mode keymaps. + /// Mode-active --- the active major mode's keymap. Mode(String), /// The global fallback. Global, @@ -91,10 +90,8 @@ pub enum StackResolution { pub struct KeymapStack { /// The global keymap (always consulted last). pub global: Keymap, - /// Per-mode keymaps. Mode activation order is preserved by `Vec`; - /// the resolver consults them after buffer-local but before - /// global. The top of the vector is the most recently activated - /// mode and wins ties. + /// Per-mode keymaps. Registration order is preserved by `Vec`; resolution + /// follows the borrowed mode names supplied to [`Self::resolve`]. pub modes: Vec<(String, Keymap)>, /// Per-buffer keymaps. Buffer-local always beats mode and global. pub buffers: HashMap, @@ -227,9 +224,8 @@ impl KeymapStack { /// Resolve `sequence` in scope priority order. /// /// `active_buffer` is the [`BufferId`] currently in focus (if any). - /// `active_modes` lists the active mode names in - /// most-recent-first order; the first match in that order wins - /// among modes. + /// `active_modes` borrows active mode names in priority order; the first + /// match in that order wins among modes. /// /// Resolution semantics: the resolver returns the *most-specific* /// complete binding it finds. If no scope has a complete match @@ -240,7 +236,7 @@ impl KeymapStack { &self, sequence: &[Chord], active_buffer: Option, - active_modes: &[String], + active_modes: &[&str], ) -> StackResolution { let mut any_pending = false; @@ -262,12 +258,12 @@ impl KeymapStack { // 2) Modes --- ordered by `active_modes`. for mode_name in active_modes { - if let Some((_, map)) = self.modes.iter().find(|(n, _)| n == mode_name) { + if let Some((_, map)) = self.modes.iter().find(|(n, _)| n == *mode_name) { match map.lookup(sequence) { Resolution::Bound(b) => { return StackResolution::Bound(ResolvedBinding { binding: b, - scope: Scope::Mode(mode_name.clone()), + scope: Scope::Mode((*mode_name).to_owned()), }); } Resolution::Pending => any_pending = true, @@ -378,7 +374,7 @@ impl KeyDispatcher { chord: Chord, stack: &KeymapStack, active_buffer: Option, - active_modes: &[String], + active_modes: &[&str], ) -> Action { self.pending.push(chord); match stack.resolve(&self.pending, active_buffer, active_modes) { @@ -474,12 +470,12 @@ mod tests { s.bind_buffer(id, &seq("C-s"), "buffer.save", src(3)) .unwrap(); // Buffer wins. - match s.resolve(&seq("C-s"), Some(id), &["normal".into()]) { + match s.resolve(&seq("C-s"), Some(id), &["normal"]) { StackResolution::Bound(rb) => assert_eq!(rb.binding.command, "buffer.save"), other => panic!("got {other:?}"), } // No buffer: mode wins. - match s.resolve(&seq("C-s"), None, &["normal".into()]) { + match s.resolve(&seq("C-s"), None, &["normal"]) { StackResolution::Bound(rb) => { assert_eq!(rb.binding.command, "mode.save"); assert_eq!(rb.scope, Scope::Mode("normal".into())); diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index fd104ef..71d4c3a 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -3033,6 +3033,39 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result None, + Value::String(mode) => Some(mode.to_str()?.to_owned()), + other => { + return Err(mlua::Error::external(format!( + "pmacs.buffer.set_major_mode: mode must be a string or nil, got {}", + other.type_name() + ))); + } + }; + let mut r = reg.borrow_mut(); + resolve_mut(&mut r, id.0)?.set_major_mode(mode); + Ok(()) + })?, + )?; + } + { let reg = registry.clone(); buffer.set( @@ -5465,18 +5498,26 @@ fn install_help_module( help_t.set( "show_key", lua.create_function(move |lua, sequence: String| { - // Resolve against the active window's buffer so - // help.show_key surfaces buffer-local bindings --- - // mirrors the same fix as pmacs.describe.key (M8.8 - // audit finding 1). let active_buffer = lua .app_data_ref::() .map(|core| core.borrow().active_buffer_id()); + // Copy the mode name before taking the mutable registry + // borrow used to replace *help*. The borrowed slice below + // then cannot outlive or conflict with help-buffer mutation. + let active_mode = { + let r = reg.borrow(); + active_buffer + .and_then(|id| r.get(id).ok()) + .and_then(crate::buffer::Buffer::major_mode) + .map(str::to_owned) + }; + let active_mode = active_mode.as_deref(); + let active_modes = active_mode.as_slice(); let result = { let mut r = reg.borrow_mut(); let c = cmds.borrow(); let k = kms.borrow(); - help::render_key(&mut r, &c, &k, active_buffer, &sequence) + help::render_key(&mut r, &c, &k, active_buffer, active_modes, &sequence) }; if let Some((id, edits)) = result.as_ref() { queue_generated_buffer_edits(lua, *id, edits); @@ -5859,6 +5900,10 @@ fn log_buffer_removed_error(lua: &Lua, source: &SourceLocation, err: &mlua::Erro } } +#[allow( + clippy::too_many_lines, + reason = "six describe bindings share one coherent registry surface; splitting them adds ceremony without clarifying borrow lifetimes" +)] fn install_describe_module( lua: &Lua, registry: &SharedRegistry, @@ -5885,27 +5930,30 @@ fn install_describe_module( } { + let reg = registry.clone(); let cmds = commands.clone(); let kms = keymaps.clone(); describe.set( "key", lua.create_function(move |lua, sequence: String| { let chords = parse_sequence(&sequence).map_err(mlua::Error::external)?; - // Resolve against the active window's buffer scope so - // buffer-local bindings (`scope = "buffer"`) actually - // surface --- without this, a `pmacs-magit.stage` - // binding on the magit buffer is invisible to - // describe-key, even when the user is sitting on - // that buffer with their cursor. `&[]` for the - // mode list mirrors what `dispatch_key` passes - // today (no mode system yet); when modes land, both - // call sites update together. let active_buffer = lua .app_data_ref::() .map(|core| core.borrow().active_buffer_id()); - let km = kms.borrow(); - let r = km.resolve(&chords, active_buffer, &[]); - match r { + // Keep the registry borrow only across pure resolution. + // `ResolvedBinding` owns its scope, so creating the Lua + // result table cannot retain a Buffer borrow or re-enter + // Lua while one is live. + let resolution = { + let r = reg.borrow(); + let active_mode = active_buffer + .and_then(|id| r.get(id).ok()) + .and_then(crate::buffer::Buffer::major_mode); + let active_modes = active_mode.as_slice(); + let km = kms.borrow(); + km.resolve(&chords, active_buffer, active_modes) + }; + match resolution { crate::keymap_stack::StackResolution::Bound(rb) => { let cmds = cmds.borrow(); Ok(Value::Table(key_info_table( @@ -6169,6 +6217,26 @@ pub fn install_editor(lua: &Lua, core: &SharedCore) -> mlua::Result<()> { let pmacs: Table = lua.globals().get("pmacs")?; let editor = lua.create_table()?; + { + let cc = core.clone(); + let registry = core.borrow().registry.clone(); + editor.set( + "active_modes", + lua.create_function(move |lua, ()| { + let active_buffer = cc.borrow().active_buffer_id(); + let mode = { + let r = registry.borrow(); + resolve(&r, active_buffer)?.major_mode().map(str::to_owned) + }; + let modes = lua.create_table()?; + if let Some(mode) = mode { + modes.set(1, mode)?; + } + Ok(modes) + })?, + )?; + } + install_motion(&editor, lua, core)?; install_editing(&editor, lua, core)?; install_history(&editor, lua, core)?; @@ -12796,6 +12864,112 @@ mod tests { (lua, reg, cmds, kms, hks) } + fn attach_test_editor(lua: &Lua, registry: &SharedRegistry) -> SharedCore { + let core = Rc::new(RefCell::new(EditorCore::new(registry.clone()))); + install_editor(lua, &core).expect("install editor"); + core + } + + #[test] + fn buffer_major_mode_is_strict_and_rejects_stale_ids() { + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let (wrong_mode, wrong_id, stale_get, stale_set): (String, String, String, String) = lua + .load( + r#" + local id = pmacs.buffer.create("mode-test") + assert(pmacs.buffer.major_mode(id) == nil) + pmacs.buffer.set_major_mode(id, "rust") + assert(pmacs.buffer.major_mode(id) == "rust") + pmacs.buffer.set_major_mode(id, nil) + assert(pmacs.buffer.major_mode(id) == nil) + + local ok_type, err_type = + pcall(pmacs.buffer.set_major_mode, id, 42) + assert(not ok_type) + local ok_id, err_id = pcall(pmacs.buffer.major_mode, 1) + assert(not ok_id) + pmacs.buffer.remove(id) + local ok_get, err_get = pcall(pmacs.buffer.major_mode, id) + local ok_set, err_set = + pcall(pmacs.buffer.set_major_mode, id, "rust") + assert(not ok_get and not ok_set) + return tostring(err_type), tostring(err_id), + tostring(err_get), tostring(err_set) + "#, + ) + .eval() + .unwrap(); + assert!(wrong_mode.contains("string"), "{wrong_mode}"); + assert!(wrong_id.contains("buffer handle"), "{wrong_id}"); + assert!(stale_get.contains("stale buffer handle"), "{stale_get}"); + assert!(stale_set.contains("stale buffer handle"), "{stale_set}"); + } + + #[test] + fn editor_active_modes_tracks_the_active_buffers_major_mode() { + let (lua, reg, _cmds, _kms, _hks) = fresh(); + let core = attach_test_editor(&lua, ®); + let active = core.borrow().active_buffer_id(); + lua.globals() + .set("active_buffer", BufferIdLua(active)) + .unwrap(); + + lua.load( + r#" + local modes = pmacs.editor.active_modes() + assert(type(modes) == "table" and #modes == 0) + pmacs.buffer.set_major_mode(active_buffer, "rust") + modes = pmacs.editor.active_modes() + assert(#modes == 1 and modes[1] == "rust") + pmacs.buffer.set_major_mode(active_buffer, nil) + assert(#pmacs.editor.active_modes() == 0) + "#, + ) + .exec() + .unwrap(); + } + + #[test] + fn describe_key_uses_the_active_buffers_major_mode() { + let (lua, reg, _cmds, kms, _hks) = fresh(); + let core = attach_test_editor(&lua, ®); + let active = core.borrow().active_buffer_id(); + reg.borrow_mut() + .get_mut(active) + .unwrap() + .set_major_mode(Some("rust".to_owned())); + { + let mut keymaps = kms.borrow_mut(); + keymaps + .bind_global( + &parse_sequence("C-s").unwrap(), + "global.save", + SourceLocation::default(), + ) + .unwrap(); + keymaps + .bind_mode( + "rust", + &parse_sequence("C-s").unwrap(), + "rust.save", + SourceLocation::default(), + ) + .unwrap(); + } + + let (command, scope): (String, String) = lua + .load( + r#" + local info = pmacs.describe.key("C-s") + return info.command, info.scope + "#, + ) + .eval() + .unwrap(); + assert_eq!(command, "rust.save"); + assert_eq!(scope, "mode:rust"); + } + /// A theme handle with one syntax entry and nonzero counters, for /// pinning that failed commits change nothing and successful ones /// bump from the PRIOR values (themes arc Q#TH6). diff --git a/tests/mode_system_wiring_acceptance.rs b/tests/mode_system_wiring_acceptance.rs new file mode 100644 index 0000000..e1c9d0a --- /dev/null +++ b/tests/mode_system_wiring_acceptance.rs @@ -0,0 +1,500 @@ +//! Mode-system wiring acceptance over the real daemon process. +//! +//! One ordered scenario keeps the dispatch assertions observable: every +//! checkpoint is itself reached through a wire key event, and the final +//! statusline marker is published only after all Lua-side assertions pass. +//! Statusline assertions consume the daemon's real grid-render payloads. + +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::time::{Duration, Instant}; + +use pmacs::cell::{Cell, CellSize, Glyph}; +use pmacs::protocol::{ + AttachRequest, FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers, + PROTOCOL_VERSION, +}; +use pmacs::transport::{read_message, write_message}; + +mod common; +use common::daemon::{TestDaemon, build_default_caps}; + +struct Client { + stream: UnixStream, + frontend_id: FrontendId, +} + +const ROWS: u32 = 30; +const COLS: u32 = 160; + +struct Grid { + cells: Vec, +} + +impl Grid { + fn new() -> Self { + Self { + cells: vec![Cell::default(); (ROWS * COLS) as usize], + } + } + + fn apply(&mut self, spans: Vec) { + for span in spans { + let start = (span.start.row * COLS + span.start.col) as usize; + for (offset, cell) in span.cells.into_iter().enumerate() { + self.cells[start + offset] = cell; + } + } + } + + fn text(&self) -> String { + let mut text = String::with_capacity((ROWS * (COLS + 1)) as usize); + for row in 0..ROWS { + for column in 0..COLS { + let cell = &self.cells[(row * COLS + column) as usize]; + let ch = match &cell.glyph { + Glyph::Char(ch) => *ch, + Glyph::Cluster(bytes) => std::str::from_utf8(bytes) + .ok() + .and_then(|value| value.chars().next()) + .unwrap_or(' '), + Glyph::Continuation => ' ', + }; + text.push(ch); + } + text.push('\n'); + } + text + } +} + +fn attach(daemon: &TestDaemon) -> (Client, Grid) { + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_millis(100))) + .expect("set daemon read timeout"); + let hello: Hello = read_message(&mut stream).expect("read daemon Hello"); + assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + write_message( + &mut stream, + &AttachRequest { + protocol_version: PROTOCOL_VERSION, + frontend_capabilities: build_default_caps(), + initial_size: CellSize::new(ROWS, COLS), + }, + ) + .expect("attach grid frontend"); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut grid = Grid::new(); + loop { + assert!( + Instant::now() < deadline, + "initial full-grid frame timed out" + ); + if let Ok(InstanceMessage::CellDelta { + spans, + full_grid: true, + }) = read_message::(&mut stream) + { + grid.apply(spans); + break; + } + } + + ( + Client { + stream, + frontend_id: hello.assigned_frontend_id, + }, + grid, + ) +} + +fn send_key(client: &mut Client, key: Key, mods: Modifiers) { + write_message( + &mut client.stream, + &FrontendEvent::Key(KeyEvent { + frontend_id: client.frontend_id, + key, + mods, + timestamp_ns: 0, + }), + ) + .expect("send daemon key event"); +} + +fn send_ctrl_chord(client: &mut Client, second: char) { + send_key(client, Key::Char('c'), Modifiers::CTRL); + send_key(client, Key::Char(second), Modifiers::CTRL); +} + +fn checkpoint(client: &mut Client, n: u8) { + send_key(client, Key::F(n), Modifiers::NONE); +} + +fn pump_grid_until( + client: &mut Client, + grid: &mut Grid, + what: &str, + predicate: impl Fn(&str) -> bool, +) -> String { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let text = grid.text(); + assert!( + !text.contains("MSW_ERROR:"), + "daemon-side Lua checkpoint failed:\n{text}" + ); + if predicate(&text) { + return text; + } + assert!( + Instant::now() < deadline, + "grid update timed out waiting for {what}; current grid:\n{text}" + ); + if let Ok(InstanceMessage::CellDelta { spans, .. }) = + read_message::(&mut client.stream) + { + grid.apply(spans); + } + } +} + +fn lua_string(path: &Path) -> String { + format!("{:?}", path.to_string_lossy()) +} + +#[allow( + clippy::too_many_lines, + reason = "one ordered daemon session preserves dispatch state across all ten acceptance checks" +)] +#[test] +fn mode_system_wiring_is_observable_end_to_end() { + let fixtures = tempfile::tempdir().expect("fixture tempdir"); + let rust = fixtures.path().join("dispatch.rs"); + let python = fixtures.path().join("dispatch.py"); + let unknown = fixtures.path().join("dispatch.txt"); + let server = fixtures.path().join("dispatch.msw"); + std::fs::write(&rust, "// MSW_RUST_FIXTURE\nfn main() {}\n").unwrap(); + std::fs::write(&python, "# MSW_PYTHON_FIXTURE\nprint('ok')\n").unwrap(); + std::fs::write(&unknown, "MSW_UNKNOWN_FIXTURE\n").unwrap(); + std::fs::write(&server, "MSW_SERVER_ONLY_FIXTURE\n").unwrap(); + + let init_template = r#" +-- Ordinary fixtures must never inherit the built-in real-server registry. +pmacs.lsp.config = {} +pmacs.lsp.filetypes.msw = "serveronly" + +local RUST_PATH = __RUST_PATH__ +local PYTHON_PATH = __PYTHON_PATH__ +local UNKNOWN_PATH = __UNKNOWN_PATH__ +local SERVER_PATH = __SERVER_PATH__ +local S = { rust_hits = 0 } +_G.MSW_STATE = S +_G.MSW_RESULT = false +_G.MSW_ERROR = nil + +local function eq(actual, expected, label) + assert(actual == expected, + label .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual)) +end + +local function current_modes(expected) + local modes = pmacs.editor.active_modes() + if expected == nil then + eq(#modes, 0, "active mode count") + else + eq(#modes, 1, "active mode count") + eq(modes[1], expected, "active mode") + end +end + +local function command(name, body) + pmacs.command.define { + name = name, + description = "mode-system wiring acceptance command " .. name, + fn = function() + local ok, err = pcall(body) + if not ok then + _G.MSW_ERROR = ("MSW_ERROR:" .. tostring(err)):sub(1, 200) + end + end, + } +end + +local function global_key(sequence, name) + pmacs.keymap.bind { scope = "global", sequence = sequence, command = name } +end + +command("test.rust-only", function() + S.rust_hits = S.rust_hits + 1 +end) +command("test.priority-buffer", function() S.priority_hit = "buffer" end) +command("test.priority-mode", function() S.priority_hit = "mode" end) +command("test.priority-global", function() S.priority_hit = "global" end) +command("test.parity-mode", function() S.parity_hit = "mode" end) +command("test.parity-global", function() S.parity_hit = "global" end) + +pmacs.keymap.bind { + scope = "mode", mode = "rust", sequence = "C-c C-c", command = "test.rust-only", +} +pmacs.keymap.bind { + scope = "mode", mode = "rust", sequence = "C-c C-p", command = "test.priority-mode", +} +pmacs.keymap.bind { + scope = "global", sequence = "C-c C-p", command = "test.priority-global", +} +pmacs.keymap.bind { + scope = "mode", mode = "rust", sequence = "C-c C-d", command = "test.parity-mode", +} +pmacs.keymap.bind { + scope = "global", sequence = "C-c C-d", command = "test.parity-global", +} + +pmacs.statusline.register { + name = "mode-system-result", + side = "right", + priority = 999, + face = "ui.modeline", + fn = function() + if _G.MSW_ERROR then return _G.MSW_ERROR end + if _G.MSW_RESULT then return "MODE-SYSTEM-WIRING-PASS" end + return nil + end, +} + +command("test.step-1", function() + local provider + for _, candidate in ipairs(pmacs.statusline.providers()) do + if candidate.name == "mode" then provider = candidate end + end + assert(provider ~= nil, "built-in mode provider is registered") + eq(provider.side, "left", "mode provider side") + eq(provider.priority, 0, "mode provider priority") + eq(provider.face, "ui.modeline", "mode provider face") + eq(provider.enabled, true, "mode provider enabled") + + S.rust = pmacs.buffer.find_or_open(RUST_PATH) + eq(pmacs.buffer.major_mode(S.rust), "rust", "rust initialization") + current_modes("rust") + + S.python = pmacs.buffer.find_or_open(PYTHON_PATH) + eq(pmacs.buffer.major_mode(S.python), "python", "python initialization") + current_modes("python") + + S.unknown = pmacs.buffer.find_or_open(UNKNOWN_PATH) + eq(pmacs.buffer.major_mode(S.unknown), nil, "unknown initialization") + current_modes(nil) + + S.server = pmacs.buffer.find_or_open(SERVER_PATH) + eq(pmacs.buffer.major_mode(S.server), "serveronly", "server-only initialization") + current_modes("serveronly") + eq(pmacs.parse._has_view(S.server), false, "server-only parse view") + + pmacs.keymap.bind { + scope = "buffer", buffer = S.rust, + sequence = "C-c C-p", command = "test.priority-buffer", + } + + -- Leave a Rust-active/Python-passive split for real statusline evaluation. + pmacs.window.switch_buffer(S.rust) + pmacs.window.split_vertical() + pmacs.window.focus_next() + pmacs.window.switch_buffer(S.python) + pmacs.window.focus_next() + eq(tostring(pmacs.window.buffer()), tostring(S.rust), "focused split buffer") + eq(pmacs.buffer.major_mode(S.rust), "rust", "rust survived switching") + eq(pmacs.buffer.major_mode(S.python), "python", "python survived switching") +end) + +command("test.step-2", function() + eq(S.rust_hits, 1, "Rust mode dispatch") + pmacs.window.switch_buffer(S.python) + current_modes("python") +end) + +command("test.step-3", function() + eq(S.rust_hits, 1, "Python must not dispatch Rust binding") + pmacs.window.switch_buffer(S.unknown) + eq(pmacs.buffer.major_mode(S.unknown), nil, "unknown stays mode-less") + current_modes(nil) +end) + +command("test.step-4", function() + eq(S.rust_hits, 1, "mode-less buffer must not dispatch Rust binding") + pmacs.window.switch_buffer(S.rust) + S.priority_hit = nil +end) + +command("test.step-5", function() + eq(S.priority_hit, "buffer", "buffer scope precedence") + pmacs.keymap.unbind { + scope = "buffer", buffer = S.rust, sequence = "C-c C-p", + } + S.priority_hit = nil +end) + +command("test.step-6", function() + eq(S.priority_hit, "mode", "mode scope precedence") + pmacs.window.switch_buffer(S.python) + S.priority_hit = nil +end) + +command("test.step-7", function() + eq(S.priority_hit, "global", "global scope fallback") + pmacs.window.switch_buffer(S.rust) + S.parity_hit = nil +end) + +command("test.step-8", function() + eq(S.parity_hit, "mode", "parity binding dispatch") + + local described = pmacs.describe.key("C-c C-d") + assert(described ~= nil, "describe.key returns the mode binding") + eq(described.command, "test.parity-mode", "describe.key command") + eq(described.scope, "mode:rust", "describe.key scope") + + local help_id = pmacs.help.show_key("C-c C-d") + assert(help_id ~= nil, "help.show_key returns a help buffer") + local body = help_id:slice(0, help_id:len()) + assert(body:find("Runs: %[command: test%.parity%-mode%]"), body) + assert(body:find("Scope: mode:rust", 1, true), body) + + help_id = pmacs.help.show_command("test.parity-mode") + body = help_id:slice(0, help_id:len()) + local link_start = body:find("%[key: C%-c C%-d @mode:rust%]") + assert(link_start ~= nil, "mode command help link carries @mode:rust: " .. body) + pmacs.window.switch_buffer(help_id) + local followed = pmacs.help.follow_link(link_start + 6) + assert(followed ~= nil, "mode key link follows while *help* is active") + local followed_body = followed:slice(0, followed:len()) + assert(followed_body:find("Runs: %[command: test%.parity%-mode%]"), followed_body) + assert(followed_body:find("Scope: mode:rust", 1, true), followed_body) + + pmacs.window.switch_buffer(S.rust) + pmacs.buffer.set_major_mode(S.rust, "markdown") + pmacs.window.switch_buffer(S.python) + pmacs.window.switch_buffer(S.rust) + eq(pmacs.buffer.major_mode(S.rust), "markdown", "explicit override survives switches") + current_modes("markdown") +end) + +command("test.step-9", function() + eq(pmacs.buffer.major_mode(S.rust), "markdown", "override remains live") + pmacs.buffer.set_major_mode(S.rust, nil) + pmacs.window.switch_buffer(S.python) + pmacs.window.switch_buffer(S.rust) + eq(pmacs.buffer.major_mode(S.rust), nil, "explicit clear survives switches") + current_modes(nil) + S.clear_baseline = S.rust_hits +end) + +command("test.step-10", function() + eq(S.rust_hits, S.clear_baseline, "cleared Rust mode must not dispatch") + + pmacs.window.switch_buffer(S.server) + eq(pmacs.buffer.major_mode(S.server), "serveronly", "server-only mode survives") + current_modes("serveronly") + eq(pmacs.parse._has_view(S.server), false, "server-only language stays parser-free") + + pmacs.window.switch_buffer(S.unknown) + eq(pmacs.buffer.major_mode(S.unknown), nil, "unknown mode remains nil") + current_modes(nil) + _G.MSW_RESULT = true +end) + +for n = 1, 10 do + global_key("", "test.step-" .. n) +end +"#; + + let init = init_template + .replace("__RUST_PATH__", &lua_string(&rust)) + .replace("__PYTHON_PATH__", &lua_string(&python)) + .replace("__UNKNOWN_PATH__", &lua_string(&unknown)) + .replace("__SERVER_PATH__", &lua_string(&server)); + + let daemon = TestDaemon::spawn_with_config(&init); + let (mut client, mut grid) = attach(&daemon); + + // Initialize through real after-load hooks and leave Rust focused with + // Python in the passive split. The daemon's ordinary grid painter must + // render each window from its own buffer context. + checkpoint(&mut client, 1); + let split_text = pump_grid_until( + &mut client, + &mut grid, + "Rust-active/Python-passive mode lines", + |text| { + text.contains("dispatch.rs") + && text.contains("dispatch.py") + && text.contains("(rust)") + && text.contains("(python)") + }, + ); + let split_modeline = split_text + .lines() + .find(|line| line.contains("dispatch.rs") && line.contains("dispatch.py")) + .expect("both split mode lines occupy the split's modeline row"); + assert!(split_modeline.contains("(rust)"), "{split_modeline}"); + assert!(split_modeline.contains("(python)"), "{split_modeline}"); + + // 1-2: the exact mode binding fires in Rust, not Python or no-mode text. + send_ctrl_chord(&mut client, 'c'); + checkpoint(&mut client, 2); + send_ctrl_chord(&mut client, 'c'); + checkpoint(&mut client, 3); + send_ctrl_chord(&mut client, 'c'); + + // The active unknown-language pane omits its mode segment while the + // passive Python pane retains its own. This distinguishes empty output + // from a provider accidentally reading the focused/other buffer. + let unknown_text = pump_grid_until(&mut client, &mut grid, "mode-less active buffer", |text| { + text.contains("dispatch.txt") && text.contains("dispatch.py") + }); + let unknown_modeline = unknown_text + .lines() + .find(|line| line.contains("dispatch.txt") && line.contains("dispatch.py")) + .expect("unknown and passive Python mode lines share a row"); + let passive_start = unknown_modeline + .find("dispatch.py") + .expect("passive Python buffer name"); + assert!( + !unknown_modeline[..passive_start].contains('('), + "unknown-language mode line must omit a mode segment: {unknown_modeline}" + ); + assert!( + unknown_modeline[passive_start..].contains("(python)"), + "passive Python mode line keeps its own mode: {unknown_modeline}" + ); + + // 5: buffer-local, then mode, then global, all driven through dispatch. + checkpoint(&mut client, 4); + send_ctrl_chord(&mut client, 'p'); + checkpoint(&mut client, 5); + send_ctrl_chord(&mut client, 'p'); + checkpoint(&mut client, 6); + send_ctrl_chord(&mut client, 'p'); + + // 10: first prove the mode binding dispatched, then compare describe, + // show-key, and followed @mode link rendering against that result. + checkpoint(&mut client, 7); + send_ctrl_chord(&mut client, 'd'); + checkpoint(&mut client, 8); + + // 7-8: override and clear each survive switch-away/back; the cleared mode + // no longer dispatches the detected-language binding. + checkpoint(&mut client, 9); + send_ctrl_chord(&mut client, 'c'); + checkpoint(&mut client, 10); + + // The marker is painted only if every Lua assertion, including + // active_modes and server-only parse gating, completed successfully. + pump_grid_until( + &mut client, + &mut grid, + "mode-system success marker", + |text| text.contains("MODE-SYSTEM-WIRING-PASS"), + ); +} diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index b2f22aa..67ce0c0 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -124,8 +124,9 @@ fn a01_04_registry_contract_limits_epochs_and_results() { assert!(baseline_mode.ends_with(" L1:C1 All ")); let initial = state.statusline_registry.borrow().providers(); - assert_eq!(initial.len(), 1, "builtin lsp provider is discoverable"); - assert_eq!(initial[0].name, "lsp"); + assert_eq!(initial.len(), 2, "builtin providers are discoverable"); + assert!(initial.iter().any(|provider| provider.name == "mode")); + assert!(initial.iter().any(|provider| provider.name == "lsp")); let before_epochs = { let registry = state.statusline_registry.borrow(); (registry.layout_epoch(), registry.face_set_epoch()) From 692c3c5fdc34c69e6c9da236c997972e80a35497 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 20:27:03 -0400 Subject: [PATCH 4/7] docs: record mode system review lane Record the portable branch, pull request, implementation checkpoint, verification, and cross-machine recovery command for mode-system wiring. --- docs/active-work.md | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index c30e04e..252549e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -14,7 +14,8 @@ backlog. machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `2e37c04` (#127 merged; protocol v18). + `githubsucks/main` @ `f1a2f75` (#128 documentation merge; runtime remains + config registry #127 at protocol v18). - On the transfer source, `origin/main` named a release mirror at `d3fa632` and lagged badly. On the current destination, `origin` names the canonical URL. This difference is why all recovery begins by @@ -48,18 +49,44 @@ git worktree list git status --short --branch ``` -The first command must expose `2e37c04` or a newer intentional main. +The first command must expose `f1a2f75` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. +## Mode system wiring lane + +- Portable branch: `githubsucks/mode-system-wiring` +- PR: #129, +- Base: canonical `main` @ `f1a2f75` (protocol v18). +- Approved framing commit: `424f82b` +- Implementation checkpoint: `99cd7ec` +- State: implementation complete and awaiting user review. One detected major + mode is stored per buffer; dispatch, describe-key, show-key, and encoded + help links resolve it; after-load initializes it once; explicit overrides + and clears survive switches; the existing statusline provider path renders + it per window. No protocol change. +- Verification at `99cd7ec`: formatting and Clippy clean; 1,742 default + + 1,918 CRDT library tests; 8 default + 9 CRDT touched acceptance tests; 114 + M4 tests; 109 required-GPU tests; workspace sweep 2,867 passed across 81 + suites (19 ignored, 1 filtered); diff check clean. + +Recovery worktree on a machine that does not already own the branch: + +```sh +git worktree add --track \ + -b mode-system-wiring \ + ../pmacs-mode-system \ + githubsucks/mode-system-wiring +``` + ## Vterm Stage 2 framing lane - Portable branch: `githubsucks/vterm-framing` - Approved framing head: `fb4f8f0` - Base: canonical `main` @ `643d1e1` (Vterm Stage 1 / PR #126 merged). - `main` has since advanced to `2e37c04` (config registry #127, no - runtime overlap with vterm); cut the Stage 2 lane from current `main`, - not from `643d1e1`. + `main` has since advanced to `f1a2f75` (config registry #127 plus the #128 + documentation merge, with no runtime overlap with vterm); cut the Stage 2 + lane from current `main`, not from `643d1e1`. - State: `docs/vterm-framing.md` Revision 7 is framing-only, reviewed, and approved for implementation. It closes the final `at_bottom`, terminal `C-c` binding-reachability, and context-implicit Lua failure-mode findings. From 4c382ae797d00d805592a2a95a821ac1829d397d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 22 Jul 2026 08:18:18 -0400 Subject: [PATCH 5/7] fix: harden mode acceptance startup Give the daemon its normal five-second handshake window before switching the mode-system acceptance client to short frame polling. Document reload and session-persistence boundaries and correct stale describe-key guidance. --- docs/mode-system-wiring-framing.md | 7 +++++++ src/editor.rs | 6 +++--- tests/mode_system_wiring_acceptance.rs | 7 +++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/mode-system-wiring-framing.md b/docs/mode-system-wiring-framing.md index d45a9d6..68d233a 100644 --- a/docs/mode-system-wiring-framing.md +++ b/docs/mode-system-wiring-framing.md @@ -316,6 +316,9 @@ silently change an existing API unrelated to describe-key. views. The hidden-buffer (registry-only) gap is pre-existing: those buffers receive neither language detection nor syntax attachment and need their own fix. + An explicit nil clear is persistent across switches, not across a future + reload path that deliberately fires `buffer.after-load`; reload re-detects + the language just like a fresh open. 4. **GPU optimistic-edit bypass is a non-issue for mode-scoped bindings** — the GPU frontend optimistically inserts plain printable characters (outside `BUILTIN_PAIR_CHARS`) and Tab without round-tripping @@ -342,6 +345,10 @@ silently change an existing API unrelated to describe-key. side-quest (`side-quest-backlog.md:43`). This framing just wires the mechanism; modeline detection can override the initialized mode via `pmacs.buffer.set_major_mode` when it lands. +- **Explicit-mode session persistence** — desktop restore reopens files and + recovers detected modes through `buffer.after-load`, but explicit overrides + and clears are not serialized. Design that with session/settings persistence, + not as hidden state in this wiring layer. - **Mode help display** (`describe-mode`) — straightforward once the mode is stored, but not table-stakes for wiring. diff --git a/src/editor.rs b/src/editor.rs index dc7deea..642ecd0 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -8552,9 +8552,9 @@ mod tests { /// T M5.6f: `M-x editor.describe-instance-buffer` switches the /// active window to *pmacs-instance* and binds buffer-local `q` - /// to `buffer.kill-this`. The buffer-local binding is verified by - /// resolving directly against the keymap stack — `pmacs.describe.key` - /// only consults global scope. + /// to `buffer.kill-this`. Resolve directly against the keymap stack + /// to pin the exact buffer-local scope independently of the Lua + /// describe-key rendering surface. #[test] fn editor_describe_instance_buffer_switches_and_binds_q() { let mut state = EditorState::new(); diff --git a/tests/mode_system_wiring_acceptance.rs b/tests/mode_system_wiring_acceptance.rs index e1c9d0a..256da9b 100644 --- a/tests/mode_system_wiring_acceptance.rs +++ b/tests/mode_system_wiring_acceptance.rs @@ -71,8 +71,8 @@ impl Grid { fn attach(daemon: &TestDaemon) -> (Client, Grid) { let mut stream = daemon.connect(); stream - .set_read_timeout(Some(Duration::from_millis(100))) - .expect("set daemon read timeout"); + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set daemon handshake timeout"); let hello: Hello = read_message(&mut stream).expect("read daemon Hello"); assert_eq!(hello.protocol_version, PROTOCOL_VERSION); write_message( @@ -84,6 +84,9 @@ fn attach(daemon: &TestDaemon) -> (Client, Grid) { }, ) .expect("attach grid frontend"); + stream + .set_read_timeout(Some(Duration::from_millis(100))) + .expect("set daemon frame timeout"); let deadline = Instant::now() + Duration::from_secs(5); let mut grid = Grid::new(); From 1b4d022fd1c781f390f169e7fcbba34926a4cb3d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 22 Jul 2026 08:28:57 -0400 Subject: [PATCH 6/7] test: preserve mode segments with long paths Widen the daemon acceptance grid so each split can show a macOS temporary path and the following mode segment without protected-right clipping. --- tests/mode_system_wiring_acceptance.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/mode_system_wiring_acceptance.rs b/tests/mode_system_wiring_acceptance.rs index 256da9b..bd79b4e 100644 --- a/tests/mode_system_wiring_acceptance.rs +++ b/tests/mode_system_wiring_acceptance.rs @@ -25,7 +25,8 @@ struct Client { } const ROWS: u32 = 30; -const COLS: u32 = 160; +// Each split must leave room for a macOS temp path plus the mode segment. +const COLS: u32 = 320; struct Grid { cells: Vec, From aafa47514cb8b67b9e0decc8b24bd82c8131ac12 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 22 Jul 2026 08:40:35 -0400 Subject: [PATCH 7/7] docs: record landed mode system Move mode-system wiring from the active ledger into the durable handoff, refresh the side-quest priorities, and preserve the macOS acceptance lessons from the final CI review round. --- docs/active-work.md | 39 ++++++------------------------- docs/agent-handoff.md | 48 +++++++++++++++++++++++++++++++------- docs/side-quest-backlog.md | 16 +++++-------- 3 files changed, 53 insertions(+), 50 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 252549e..9046819 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -1,6 +1,6 @@ # Active work — cross-machine resume ledger -**Snapshot: 2026-07-21.** This file records volatile work that has not +**Snapshot: 2026-07-22.** This file records volatile work that has not landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed entries when their PR merges; do not let this become a second permanent backlog. @@ -14,8 +14,8 @@ backlog. machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `f1a2f75` (#128 documentation merge; runtime remains - config registry #127 at protocol v18). + `githubsucks/main` @ `b4b925d` (mode system wiring #129 merged; + protocol v18). - On the transfer source, `origin/main` named a release mirror at `d3fa632` and lagged badly. On the current destination, `origin` names the canonical URL. This difference is why all recovery begins by @@ -49,44 +49,19 @@ git worktree list git status --short --branch ``` -The first command must expose `f1a2f75` or a newer intentional main. +The first command must expose `b4b925d` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. -## Mode system wiring lane - -- Portable branch: `githubsucks/mode-system-wiring` -- PR: #129, -- Base: canonical `main` @ `f1a2f75` (protocol v18). -- Approved framing commit: `424f82b` -- Implementation checkpoint: `99cd7ec` -- State: implementation complete and awaiting user review. One detected major - mode is stored per buffer; dispatch, describe-key, show-key, and encoded - help links resolve it; after-load initializes it once; explicit overrides - and clears survive switches; the existing statusline provider path renders - it per window. No protocol change. -- Verification at `99cd7ec`: formatting and Clippy clean; 1,742 default + - 1,918 CRDT library tests; 8 default + 9 CRDT touched acceptance tests; 114 - M4 tests; 109 required-GPU tests; workspace sweep 2,867 passed across 81 - suites (19 ignored, 1 filtered); diff check clean. - -Recovery worktree on a machine that does not already own the branch: - -```sh -git worktree add --track \ - -b mode-system-wiring \ - ../pmacs-mode-system \ - githubsucks/mode-system-wiring -``` ## Vterm Stage 2 framing lane - Portable branch: `githubsucks/vterm-framing` - Approved framing head: `fb4f8f0` - Base: canonical `main` @ `643d1e1` (Vterm Stage 1 / PR #126 merged). - `main` has since advanced to `f1a2f75` (config registry #127 plus the #128 - documentation merge, with no runtime overlap with vterm); cut the Stage 2 - lane from current `main`, not from `643d1e1`. + `main` has since advanced to `b4b925d` (config registry #127, the #128 + documentation merge, and mode system wiring #129); cut the Stage 2 lane + from current `main`, not from `643d1e1`. - State: `docs/vterm-framing.md` Revision 7 is framing-only, reviewed, and approved for implementation. It closes the final `at_bottom`, terminal `C-c` binding-reachability, and context-implicit Lua failure-mode findings. diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index e776e07..81a202a 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,9 +1,9 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-21, after the config registry (#127) and Vterm -Stage 1 terminal core (#126) both landed on `main`, atop completed -Themes Arc 4 (#120/#124/#125). Vterm Stages 2 and 3 are not -implemented.** +**Last updated: 2026-07-22, after mode system wiring (#129) landed on +`main`, atop the config registry (#127), Vterm Stage 1 terminal core +(#126), and completed Themes Arc 4 (#120/#124/#125). Vterm Stages 2 and 3 +are not implemented.** 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` @@ -15,9 +15,9 @@ reads it the way you just did. For volatile branches, checkpoints, verification, and recovery commands, read `docs/active-work.md` immediately after this file. -## 1. Where the project stands (2026-07-21) +## 1. Where the project stands (2026-07-22) -- `main` @ `2e37c04` (config registry #127), protocol **v18** +- `main` @ `b4b925d` (mode system wiring #129), protocol **v18** (`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`). - **Config registry LANDED — #127** (`docs/config-registry-framing.md` @@ -40,8 +40,8 @@ commands, read `docs/active-work.md` immediately after this file. local → global → default; **`get(name)` resolves the GLOBAL CHAIN ONLY** and never consults an ambient buffer. Per-language and per-project are *patterns* (a hook calling `set_local`), not scopes - the registry knows about. Mode scope is impossible until the mode - system is wired — every editor `KeymapStack::resolve` passes `&[]`. + the registry knows about. Mode keymaps now resolve through #129, but + `pmacs.config` deliberately remains global + buffer-local. - Buffer-locals live in a registry side table purged at `after_buffer_removed`, beside the keymap purge. - Listeners: commit → snapshot → **drop the borrow** → re-enter Lua; @@ -61,6 +61,25 @@ commands, read `docs/active-work.md` immediately after this file. their legacy coercion** — the registry is strict, the legacy setters stay lenient (`trim_on_save("yes")`, `interval_ms(1500.7)`). - `M-x describe-setting` renders into `*help*`. +- **Mode system wiring LANDED — #129** (`docs/mode-system-wiring-framing.md`; + merge `b4b925d`; one review round). The existing mode-keymap substrate is + now live without a protocol change. + - `Buffer.major_mode: Option` owns the single major mode. The + detected language name initializes it once on `buffer.after-load`, before + grammar gating, so server-only languages work; switches never rewrite it. + Explicit overrides and clears survive switches. A future reload that fires + after-load re-detects, and explicit mode state is not session-persisted. + - Dispatch borrows the zero-or-one mode through `Option<&str>::as_slice()` + and `&[&str]`: no hot-path mode allocation. Resolution remains + buffer-local → mode → global, and registry/keymap borrows end before Lua + command invocation. + - Lua surfaces: `pmacs.buffer.major_mode` / `set_major_mode` and + `pmacs.editor.active_modes`. `pmacs.describe.key`, `pmacs.help.show_key`, + and followed percent-encoded `@mode:` links use the same effective context; + `pmacs.keymap.lookup` remains raw-global. + - The built-in `mode` statusline provider reads `ctx.buffer`, so passive + splits render their own mode. Real-daemon acceptance covers all ten framing + criteria across both Lua backends and Linux/macOS CI. - **Syntax-highlight / language-detection side-quest (#114–#118) LANDED** — a one-shot arc built in sibling worktrees off main while the user's themes lane (`theme-faces`) ran concurrently in the shared @@ -253,6 +272,8 @@ commands, read `docs/active-work.md` immediately after this file. cross-cutting substrate ranked first on `docs/side-quest-backlog.md`'s north star, and it unblocks the editing/indent/comment items that were config-blocked. + - **Mode system wiring COMPLETE (#129)** — major-mode keymaps, + introspection, lifecycle initialization, and statusline display shipped. - Remaining ranked arcs: 6 folding, 7 DAP, 8 GPU splits, plus the `.ipynb` arc (its JSON-grammar prerequisite shipped in #123). @@ -433,6 +454,14 @@ acceptance. and go through `pmacs.command.invoke("buffer.save")`. Caught only because the *other* case failed and the cause was chased instead of the assertion adjusted. +- **Real-grid acceptance must budget for macOS startup and path width.** + A 100 ms first-Hello timeout failed under loaded macOS CI; use the normal + five-second handshake window, then short polling reads. An 80-column split + also clipped a custom statusline segment after macOS's long + `/var/folders/...` temp path while passing on Linux; size the grid for the + longest supported fixture path (the mode-system test uses 160 columns per + split). + ## 6. Named deferrals (the standing backlog, consolidated) @@ -475,6 +504,9 @@ setters (`async_config` ×2, `killring.max`, the `enable` booleans) and currently accepted for `autosave.interval-ms`, where a per-buffer value is meaningless. **Tab width is NOT a config gap** — see §5. +Mode system (SHIPPED #129): minor modes, `buffer.after-mode-change`, +mode-scoped settings, modeline detection, `describe-mode`, and persistence of +explicit major-mode overrides/clears across sessions. Highlight/detection (from the #114–#118 side-quest + injections #122): locals-query processing (run each grammar's LOCALS_QUERY so `#is?`/`#is-not? local` is honored instead of the current fail-closed diff --git a/docs/side-quest-backlog.md b/docs/side-quest-backlog.md index ac31f4f..7e23980 100644 --- a/docs/side-quest-backlog.md +++ b/docs/side-quest-backlog.md @@ -130,8 +130,8 @@ The direct continuation of the #114–#118 grammar/detection stack. decision. Deferred from #127 on those grounds. - **Real `read_only` buffer flag** on both edit paths — true immutability for panels / REPL / generated buffers. -- **Mode system wiring** — dispatch passes an empty mode list (`&[]`); - mode-scoped keybindings unresolved everywhere. +- ~~**Mode system wiring**~~ — **SHIPPED as #129.** Per-buffer major modes + now drive key dispatch, effective-key introspection, and statusline display. - **Buffer-aware edit epoch + origin-pinned `after-edit` fan-out** — a command that edits buffer A then switches to B currently evades `didChange` / reparse / autosave observers. @@ -237,17 +237,13 @@ guides (visual, not color). ## North star (highest-leverage first) -**Both original north-star items have now shipped** — multi-language -injections (#122) and the config registry (#127) — and JSON + YAML -(#123) merged too. The remaining board: +**The original north-star items and mode-system wiring have now shipped** — +multi-language injections (#122), the config registry (#127), JSON + YAML +(#123), and mode-system wiring (#129). The remaining board: 1. **Locals-query processing** — restores `.builtin` styling for non-shadowed builtins, the last rough edge of the highlight stack. -2. **Mode-system wiring** — every editor `KeymapStack::resolve` still - passes `&[]`, so mode-scoped keybindings and any mode-scoped setting - remain unreachable. Promoted here because #127 made it the largest - remaining scoping gap. -3. **Tab-width rendering parity** — five constants across two crates +2. **Tab-width rendering parity** — five constants across two crates with two different values, and no tab expansion at all on the GPU main text path. Explicitly NOT a config-registry task; see the entry under "Cross-cutting substrate".