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/docs/active-work.md b/docs/active-work.md index c1212a9..0349d14 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` @ `f1a2f75` (#128 merged; protocol v18). + `githubsucks/main` @ `d5d9b9c` (mode system handoff #131 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 @@ -48,18 +49,18 @@ git worktree list git status --short --branch ``` -The first command must expose `f1a2f75` or a newer intentional main. +The first command must expose `d5d9b9c` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. - ## Vterm Stage 2 implementation lane - Portable branch: `githubsucks/vterm-tui` - Feature head after review round 2: `b9a7e40` -- Base: canonical `main` @ `f1a2f75` (handoff refresh #128 atop config - registry #127 and Vterm Stage 1 #126). +- Base: originally canonical `main` @ `f1a2f75`; current canonical `main` + @ `d5d9b9c` (mode system wiring #129 and handoff #131) is integrated before + merge. - PR: #130, , open against - canonical `main`. + canonical `main` and explicitly authorized for merge. - State: `docs/vterm-framing.md` Revision 7 criteria 15–27 are implemented. The lane composes per-frontend/window terminal views in the TUI, installs the strict `pmacs.terminal` API and terminal-local bindings, drains @@ -84,7 +85,8 @@ If it does not, stop and repair the remote/fetch configuration. 4 CRDT; statusline acceptance 7 default + 8 CRDT; M4 114 passed (3 ignored, 1 filtered); required GPU 109; workspace 2,872 passed across 81 suites (19 ignored, 1 filtered); `git diff --check` clean. -- Next: user review. Do not merge without explicit authorization. +- Next: integrate canonical `main`, rerun gates, then merge PR #130 as + authorized. Recovery worktree on a machine that does not already own the branch: diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 9eada35..7589299 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,10 +1,10 @@ # Agent handoff — cross-machine continuity **Last updated: 2026-07-22, after Vterm Stage 2 PR #130 review round 2 -was addressed on `vterm-tui`; config registry (#127), Vterm Stage 1 -terminal core (#126), and handoff refresh (#128) are landed on `main`, -atop completed Themes Arc 4 (#120/#124/#125). Vterm Stage 3 is not -implemented.** +was addressed and approved for merge; mode system wiring (#129) and its +handoff (#131), config registry (#127), Vterm Stage 1 terminal core (#126), +and completed Themes Arc 4 (#120/#124/#125) are landed on `main`. Vterm +Stage 3 is 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` @@ -16,10 +16,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` @ `f1a2f75` (handoff refresh #128 atop config registry #127), - protocol **v18** +- `main` @ `d5d9b9c` (mode system handoff #131), protocol **v18** (`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`). - **Config registry LANDED — #127** (`docs/config-registry-framing.md` @@ -42,8 +41,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; @@ -63,6 +62,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 @@ -296,6 +314,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). @@ -320,8 +340,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) @@ -475,6 +496,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) @@ -517,6 +546,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/mode-system-wiring-framing.md b/docs/mode-system-wiring-framing.md new file mode 100644 index 0000000..68d233a --- /dev/null +++ b/docs/mode-system-wiring-framing.md @@ -0,0 +1,408 @@ +# 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. + 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 + 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. +- **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. + +## 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. 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". 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 368b1c2..38a8003 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -885,14 +885,24 @@ impl EditorState { let Some(chord) = chord else { return; }; - 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.dispatchers .entry(frontend_id) .or_default() .dispatcher - .dispatch(chord, &stack, active_buffer, &[]) + .dispatch(chord, &stack, Some(active_buffer), active_mode.as_slice()) }; let pre_revision = self.active_buffer_revision(); @@ -4346,6 +4356,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 @@ -9153,9 +9191,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/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 d35967b..ae04b3a 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -3034,6 +3034,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( @@ -5482,18 +5515,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); @@ -5876,6 +5917,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, @@ -5902,27 +5947,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( @@ -6186,6 +6234,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)?; @@ -13414,6 +13482,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..bd79b4e --- /dev/null +++ b/tests/mode_system_wiring_acceptance.rs @@ -0,0 +1,504 @@ +//! 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; +// Each split must leave room for a macOS temp path plus the mode segment. +const COLS: u32 = 320; + +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_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( + &mut stream, + &AttachRequest { + protocol_version: PROTOCOL_VERSION, + frontend_capabilities: build_default_caps(), + initial_size: CellSize::new(ROWS, COLS), + }, + ) + .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(); + 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 1d0d896..120c71f 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -135,8 +135,8 @@ fn a01_04_registry_contract_limits_epochs_and_results() { .iter() .map(|provider| provider.name.as_str()) .collect::>(), - ["terminal", "lsp"], - "built-in providers are discoverable" + ["mode", "terminal", "lsp"], + "built-in providers are discoverable in registration order" ); let before_epochs = { let registry = state.statusline_registry.borrow();