Merge canonical main into vterm-tui
Integrate mode-system wiring and handoff updates before PR #130 lands. Preserve per-frontend terminal dispatch while resolving major-mode keymaps, and expose mode, terminal, and LSP statusline providers together.
This commit is contained in:
commit
3f0252fb97
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, <https://github.com/levineuwirth/pmacs/pull/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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>` 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 <file>` (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
|
||||
|
|
|
|||
|
|
@ -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<String>, // 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<String>) {
|
||||
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<String>` 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<String>` 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:<id>` target for buffer-local bindings,
|
||||
add `@mode:<name>` 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.
|
||||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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<String>) {
|
||||
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| {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
255
src/help.rs
255
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<BufferId>,
|
||||
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<BufferId>)> {
|
||||
let Some((sequence, raw)) = target.rsplit_once(" @buffer:") else {
|
||||
return Some((target.to_owned(), None));
|
||||
};
|
||||
let Ok(raw) = raw.trim().parse::<u64>() 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<String> {
|
||||
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<BufferId>, Option<String>)> {
|
||||
if let Some((sequence, raw)) = target.rsplit_once(" @buffer:") {
|
||||
let raw = raw.trim().parse::<u64>().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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<BufferId, Keymap>,
|
||||
|
|
@ -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<BufferId>,
|
||||
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<BufferId>,
|
||||
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()));
|
||||
|
|
|
|||
|
|
@ -3034,6 +3034,39 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
|||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let reg = registry.clone();
|
||||
buffer.set(
|
||||
"major_mode",
|
||||
lua.create_function(move |_, id: BufferIdLua| {
|
||||
let r = reg.borrow();
|
||||
Ok(resolve(&r, id.0)?.major_mode().map(str::to_owned))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let reg = registry.clone();
|
||||
buffer.set(
|
||||
"set_major_mode",
|
||||
lua.create_function(move |_, (id, mode): (BufferIdLua, Value)| {
|
||||
let mode = match mode {
|
||||
Value::Nil => 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::<SharedCore>()
|
||||
.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::<SharedCore>()
|
||||
.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).
|
||||
|
|
|
|||
|
|
@ -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<Cell>,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
cells: vec![Cell::default(); (ROWS * COLS) as usize],
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&mut self, spans: Vec<pmacs::cell::DiffSpan>) {
|
||||
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::<InstanceMessage>(&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::<InstanceMessage>(&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("<f" .. n .. ">", "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"),
|
||||
);
|
||||
}
|
||||
|
|
@ -135,8 +135,8 @@ fn a01_04_registry_contract_limits_epochs_and_results() {
|
|||
.iter()
|
||||
.map(|provider| provider.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue