Merge pull request #92 from levineuwirth/session-completion-popup-p1
feat(completion): in-buffer completion popup — Arc 1a phase 1 (core + TUI)
This commit is contained in:
commit
d856a1bdc4
|
|
@ -0,0 +1,241 @@
|
|||
-- completion.lua --- in-buffer completion popup driver (Arc 1a).
|
||||
--
|
||||
-- Wires the M4.11 provider framework (`pmacs.completion.collect`) and
|
||||
-- the M4.7 LSP request path to the core's popup session
|
||||
-- (`pmacs.completion.popup_show/hide`, Q#C2). The dispatcher owns
|
||||
-- navigation and accept (Q#C3/Q#C7); this file decides WHEN the popup
|
||||
-- opens, WHAT it shows, and keeps it fresh as the user types.
|
||||
--
|
||||
-- Trigger policy (Q#C9): `buffer.after-edit` carries no payload, so
|
||||
-- intent is reconstructed from state. A snapshot of {buffer, cursor}
|
||||
-- from the previous invocation recognizes the single-char typing
|
||||
-- signature (cursor advanced exactly one byte --- word and LSP
|
||||
-- trigger characters are all ASCII); paste, undo, kill, and remote
|
||||
-- edits (any other delta) never auto-open. `C-M-i`
|
||||
-- (`completion.at-point`) covers deliberate invocation.
|
||||
--
|
||||
-- Framing: docs/in-buffer-completion-framing.md.
|
||||
|
||||
local MIN_PREFIX = 2 -- typed word length before the popup auto-opens
|
||||
local MAX_ROWS = 64 -- cap on candidates published to the session
|
||||
|
||||
-- Snapshot of the previous after-edit invocation (Q#C9).
|
||||
local last = { key = nil, cursor = nil }
|
||||
|
||||
-- Driver-side mirror of the session we opened: { key, anchor,
|
||||
-- pending }. `popup_visible()` is the truth about the popup --- the
|
||||
-- core closes it independently (validation, accept, dismiss, a modal
|
||||
-- opening) --- so the mirror only remembers the anchor and detects
|
||||
-- "the core closed it since we last looked", which doubles as the
|
||||
-- reopen-after-accept suppressor. `pending = true` marks a session
|
||||
-- whose popup hasn't opened yet (awaiting the LSP response).
|
||||
local session = nil
|
||||
|
||||
local function word_prefix_before(buf, cursor)
|
||||
local start = cursor - 64
|
||||
if start < 0 then start = 0 end
|
||||
local ok, chunk = pcall(function() return buf:slice(start, cursor) end)
|
||||
if not ok or type(chunk) ~= "string" then return "" end
|
||||
return chunk:match("[%w_]*$") or ""
|
||||
end
|
||||
|
||||
local function char_before(buf, cursor)
|
||||
if cursor < 1 then return nil end
|
||||
local ok, ch = pcall(function() return buf:slice(cursor - 1, cursor) end)
|
||||
if ok and type(ch) == "string" and #ch == 1 then return ch end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function close_popup()
|
||||
session = nil
|
||||
if pmacs.completion.popup_visible() then pmacs.completion.popup_hide() end
|
||||
end
|
||||
|
||||
-- Collect through the framework, drop non-matches (collect keeps
|
||||
-- negative-score rows, merely sorted last --- Q#C1), cap, and shape
|
||||
-- rows for popup_show. Returns the rows plus the uncapped match count.
|
||||
local function collect_rows(buf, prefix, trigger, trigger_char)
|
||||
local rec = pmacs.lsp.active_attachment() -- peek: uri/language only
|
||||
local ok_text, text = pcall(function() return buf:slice(0, buf:len()) end)
|
||||
if not ok_text or type(text) ~= "string" then return {}, 0 end
|
||||
local ctx = {
|
||||
prefix = prefix,
|
||||
line = pmacs.editor.cursor_line(),
|
||||
col = pmacs.editor.cursor_col(),
|
||||
buffer_text = text,
|
||||
language = rec and rec.language or nil,
|
||||
uri = rec and rec.uri or nil, -- Q#C8: scope URI-keyed providers
|
||||
trigger = trigger,
|
||||
trigger_char = trigger_char,
|
||||
}
|
||||
local ok, cands = pcall(pmacs.completion.collect, ctx)
|
||||
if not ok or type(cands) ~= "table" then return {}, 0 end
|
||||
local rows, total = {}, 0
|
||||
for _, c in ipairs(cands) do
|
||||
if (c.score or -1) >= 0 then
|
||||
total = total + 1
|
||||
if #rows < MAX_ROWS then
|
||||
rows[#rows + 1] = {
|
||||
label = c.label,
|
||||
kind = c.kind,
|
||||
detail = c.detail,
|
||||
insert_text = c.insert_text,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
return rows, total
|
||||
end
|
||||
|
||||
-- Re-collect and show the session at `anchor`. On zero matches the
|
||||
-- popup hides; the caller decides whether the session survives as
|
||||
-- `pending` (initial trigger-char / at-point opens awaiting the LSP)
|
||||
-- or dies (a refresh that narrowed to nothing). Returns true when the
|
||||
-- popup is showing afterwards.
|
||||
local function publish(buf, anchor, prefix, trigger, trigger_char)
|
||||
local rows, total = collect_rows(buf, prefix, trigger, trigger_char)
|
||||
if #rows == 0 then
|
||||
if pmacs.completion.popup_visible() then pmacs.completion.popup_hide() end
|
||||
return false
|
||||
end
|
||||
session = { key = tostring(buf), anchor = anchor }
|
||||
pmacs.completion.popup_show {
|
||||
buffer = buf,
|
||||
anchor = anchor,
|
||||
prefix = prefix,
|
||||
total = total,
|
||||
candidates = rows,
|
||||
}
|
||||
return true
|
||||
end
|
||||
|
||||
-- Q#C8 "show fast, refresh on arrival": fire textDocument/completion
|
||||
-- through the FLUSHING accessor (the server must see current text),
|
||||
-- then re-publish when the response lands --- if the session is still
|
||||
-- anchored where it was when the request left.
|
||||
local function request_lsp_then_refresh()
|
||||
local rec = pmacs.lsp.attachment_for_request()
|
||||
if not rec or not session then return end
|
||||
local line = pmacs.editor.cursor_line()
|
||||
local col = pmacs.editor.cursor_col()
|
||||
local anchor_at_request = session.anchor
|
||||
local key_at_request = session.key
|
||||
pmacs.async(function()
|
||||
local ok = pcall(function()
|
||||
pmacs.lsp.request_completion(rec.server, rec.uri, line, col):await()
|
||||
end)
|
||||
if not ok or not session then return end
|
||||
if session.key ~= key_at_request or session.anchor ~= anchor_at_request then return end
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf or tostring(buf) ~= session.key then return end
|
||||
local cursor = pmacs.editor.cursor()
|
||||
local prefix = word_prefix_before(buf, cursor)
|
||||
if cursor - #prefix ~= session.anchor then return end
|
||||
if not publish(buf, session.anchor, prefix, "incomplete", nil) and session.pending then
|
||||
-- Still nothing, even with the server's answer: the pending
|
||||
-- session is dead.
|
||||
session = nil
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
pmacs.hook.add("buffer.after-edit", function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
close_popup()
|
||||
last.key, last.cursor = nil, nil
|
||||
return
|
||||
end
|
||||
local key = tostring(buf)
|
||||
local cursor = pmacs.editor.cursor()
|
||||
local prev_key, prev_cursor = last.key, last.cursor
|
||||
last.key, last.cursor = key, cursor
|
||||
|
||||
local visible = pmacs.completion.popup_visible()
|
||||
|
||||
if session and not session.pending and not visible then
|
||||
-- The core closed the popup since we opened it (accept, dismiss,
|
||||
-- validation, or a modal). Drop the mirror and do NOT reopen off
|
||||
-- this same edit --- this is what stops an accept's own
|
||||
-- after-edit from instantly re-raising the popup it just closed.
|
||||
session = nil
|
||||
return
|
||||
end
|
||||
|
||||
if visible and session then
|
||||
-- Refresh the open session from the text. A prefix that no longer
|
||||
-- reaches back to the anchor means the word died; close (the
|
||||
-- core's post-dispatch validation independently enforces the same
|
||||
-- invariant).
|
||||
if key ~= session.key then
|
||||
close_popup()
|
||||
return
|
||||
end
|
||||
local prefix = word_prefix_before(buf, cursor)
|
||||
if cursor < session.anchor or cursor - #prefix ~= session.anchor then
|
||||
close_popup()
|
||||
return
|
||||
end
|
||||
if publish(buf, session.anchor, prefix, "incomplete", nil) then
|
||||
-- isIncomplete contract: a partial server response must be
|
||||
-- re-queried as the user keeps typing, not merely re-filtered.
|
||||
local rec = pmacs.lsp.active_attachment()
|
||||
if rec then
|
||||
local ok, incomplete = pcall(pmacs.completion.is_incomplete, rec.server, rec.uri)
|
||||
if ok and incomplete then request_lsp_then_refresh() end
|
||||
end
|
||||
else
|
||||
session = nil -- narrowed to nothing: the session is over
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Popup closed: the Q#C9 auto-open policy. Same buffer, cursor
|
||||
-- advanced by exactly one byte since the previous edit.
|
||||
if key ~= prev_key or not prev_cursor or cursor - prev_cursor ~= 1 then return end
|
||||
local prefix = word_prefix_before(buf, cursor)
|
||||
if #prefix >= MIN_PREFIX then
|
||||
-- Fire the LSP request even when the synchronous providers came
|
||||
-- up empty: for an LSP-only word (no dabbrev/snippet/index hit,
|
||||
-- cold store) the popup materializes when the response lands ---
|
||||
-- the same pending-session shape as the trigger-char path.
|
||||
if not publish(buf, cursor - #prefix, prefix, "invoked", nil) then
|
||||
session = { key = key, anchor = cursor - #prefix, pending = true }
|
||||
end
|
||||
request_lsp_then_refresh()
|
||||
return
|
||||
end
|
||||
if #prefix == 0 then
|
||||
-- Maybe a server trigger character (`.`, `:`, ...): a pending
|
||||
-- session anchored at the cursor, opening when candidates arrive.
|
||||
local ch = char_before(buf, cursor)
|
||||
local rec = pmacs.lsp.active_attachment()
|
||||
if not (ch and rec) then return end
|
||||
local ok, fires = pcall(pmacs.completion.should_fire, rec.server, ch)
|
||||
if not (ok and fires) then return end
|
||||
if not publish(buf, cursor, "", "char", ch) then
|
||||
session = { key = key, anchor = cursor, pending = true }
|
||||
end
|
||||
request_lsp_then_refresh()
|
||||
end
|
||||
end)
|
||||
|
||||
local function completion_at_point()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return end
|
||||
local cursor = pmacs.editor.cursor()
|
||||
local prefix = word_prefix_before(buf, cursor)
|
||||
local anchor = cursor - #prefix
|
||||
if not publish(buf, anchor, prefix, "invoked", nil) then
|
||||
session = { key = tostring(buf), anchor = anchor, pending = true }
|
||||
end
|
||||
request_lsp_then_refresh()
|
||||
end
|
||||
|
||||
pmacs.command.define {
|
||||
name = "completion.at-point",
|
||||
description = "Open the in-buffer completion popup at the cursor.",
|
||||
fn = completion_at_point,
|
||||
}
|
||||
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-M-i", command = "completion.at-point" }
|
||||
|
|
@ -544,6 +544,25 @@ function pmacs.lsp.active_attachment()
|
|||
return attachments[tostring(buf)]
|
||||
end
|
||||
|
||||
-- Flushing variant for request-issuing callers outside this file
|
||||
-- (Q#C8): when the active buffer already has a server attached,
|
||||
-- flush any debounced didChange first and return the record, so the
|
||||
-- caller's request is answered against the current text. Unlike the
|
||||
-- local `attached_for_active`, this NEVER triggers an attach: the
|
||||
-- in-buffer completion driver calls it on ordinary typing, and
|
||||
-- spawning language servers as a typing side effect is wrong (and,
|
||||
-- concretely, wedged the m4 suite with per-keystroke spawn attempts
|
||||
-- across parallel tests). Attachment remains buffer-open policy.
|
||||
function pmacs.lsp.attachment_for_request()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return nil end
|
||||
local key = tostring(buf)
|
||||
local rec = attachments[key]
|
||||
if not rec then return nil end
|
||||
flush_did_change(key)
|
||||
return rec
|
||||
end
|
||||
|
||||
-- Hooks --------------------------------------------------------------------
|
||||
|
||||
pmacs.hook.add("buffer.after-load", function()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,326 @@
|
|||
# In-buffer completion popup — framing (Arc 1a)
|
||||
|
||||
pmacs has a complete completion system that nothing drives. M4.7 shipped
|
||||
the LSP completion store + `CompletionView` popup (`src/completion.rs`);
|
||||
M4.11 shipped the provider framework — registry, scoring, dedup, and
|
||||
four providers (lsp=100, snippets=80, project_symbols=60, dabbrev=20 by
|
||||
priority; `src/completion_framework.rs`); the async LSP request path
|
||||
(`LspManager::request_completion`, `src/lsp.rs:1665`) works and is
|
||||
exercised by m4 acceptance tests. But there is no keybinding, no
|
||||
typed-char trigger, `CompletionView` is never instantiated, and no GPU
|
||||
wire message exists. This arc wires it end-to-end in both frontends:
|
||||
type, see candidates, TAB/RET accept.
|
||||
|
||||
Roadmap context: `docs/roadmap-2026-07.md` Arc 1a. Closest wire
|
||||
precedent: `docs/gpu-minibuffer-framing.md` (the
|
||||
`SearchPrompt`/`MenuPrompt`/`MinibufferPrompt` family). Tightened
|
||||
2026-07-07 after a code review pass against the current tree
|
||||
(findings folded into Q#C1/C3/C6/C7/C8/C9 below).
|
||||
|
||||
## What already exists (verified)
|
||||
|
||||
- **Framework**: `CompletionRegistry::collect(ctx)` is synchronous —
|
||||
filters enabled providers by priority, scores against `ctx.prefix`
|
||||
(exact=1000 / prefix=600 / word-boundary=300 / substring=100), dedups
|
||||
by `(label, insert_text)`, sorts (`completion_framework.rs:292-333`).
|
||||
`Rc<RefCell<…>>` — main-thread only. Instantiated at startup
|
||||
(`editor.rs:243`) and exposed as `pmacs.completion.{register,collect,
|
||||
context_for,snippets.*}` — never called by builtin Lua.
|
||||
- **The LSP provider does not request, and is globally scoped** — it
|
||||
drains **every** cached `(server_id, uri)` entry in the store,
|
||||
ignoring context (`completion_framework.rs:617-645`), so unmodified
|
||||
it can surface stale candidates from other buffers/servers. Fresh
|
||||
requests are the editor's job: `pmacs.lsp._request_completion_raw` →
|
||||
awaitable handle (`builtin/runtime/lsp.lua:585-597`), response
|
||||
absorbed into the store with positions already normalized to bytes
|
||||
(`lsp.rs:2630-2647`).
|
||||
- **`collect` does not filter non-matches** — `score_match` returns −1
|
||||
for a candidate that misses the prefix, but `collect` keeps it and
|
||||
merely sorts it last (`completion_framework.rs:292-333`); a no-hit
|
||||
prefix would still yield a popup full of unrelated rows.
|
||||
- **The public attachment accessor does not flush** — interactive LSP
|
||||
commands resolve via the *local* `attached_for_active()`, which runs
|
||||
`flush_did_change` first so the server answers against current text
|
||||
(`builtin/runtime/lsp.lua:520-533`); the public
|
||||
`pmacs.lsp.active_attachment()` is deliberately side-effect-free and
|
||||
skips the flush (`lsp.lua:541-545`).
|
||||
- **`buffer.after-edit` carries no payload** — both edit paths fire it
|
||||
with empty args (`editor.rs:575`, `daemon.rs:1923`); a driver cannot
|
||||
see what changed, only that something did.
|
||||
- **Trigger characters**: `CompletionTriggers::from_capabilities` +
|
||||
`should_fire(ch)` per server (`completion.rs:406-454`), Lua-exposed.
|
||||
No prefix-extraction helper exists anywhere — the driver must derive
|
||||
the word before the cursor itself.
|
||||
- **`CompletionView`** is a cell-owning popup (kind glyph + label +
|
||||
detail, reverse-video selection, width ≤ 40, CJK-aware) — but it
|
||||
reads the raw LSP store, not framework candidates, and the TUI
|
||||
overlay loop hands every overlay the full window viewport
|
||||
(`editor.rs:1667-1669`); nothing positions a sub-rect popup today.
|
||||
`MenuView` is the precedent for a self-positioning cell popup.
|
||||
- **Dispatcher shadows** already exist for minibuffer
|
||||
(`editor.rs:520`), menu (`:500`), isearch (`:511`).
|
||||
|
||||
## Decisions
|
||||
|
||||
### Q#C1 — The driver is builtin Lua, core stays a library
|
||||
|
||||
A new `builtin/runtime/completion.lua`: derives the prefix (word chars
|
||||
`[A-Za-z0-9_]` before the cursor), decides *when* to open (Q#C9),
|
||||
fires the LSP request through a flushing accessor (Q#C8), runs
|
||||
`pmacs.completion.collect`, **filters to `score >= 0`** (collect
|
||||
returns non-matches sorted last; Lua receives `score`, so the driver
|
||||
drops them — a Rust-side filter inside `collect` would change M4.11
|
||||
semantics/tests and is left as a follow-up), and publishes the session
|
||||
(Q#C2) only when at least one row survives. Mirrors how `lsp.lua`
|
||||
drives every other LSP surface. Refresh-on-typing rides the same
|
||||
after-edit hook while the session is open; the session closes when the
|
||||
prefix dies (Q#C3's validation, `C-g`, or an edit that kills the
|
||||
word). Nit rolled into phase 1: `pmacs.completion.context_for` cannot
|
||||
express a `Char` trigger (`lua_bindings/mod.rs:9964` maps only
|
||||
incomplete-vs-invoked) — either extend it or have the driver pass raw
|
||||
ctx tables (already supported).
|
||||
|
||||
### Q#C2 — Core-owned session state, the search-store pattern
|
||||
|
||||
A frontend-agnostic `CompletionPopup` session on `EditorCore` (like
|
||||
`menu: SharedMenu`): `{buffer_id, anchor_byte, prefix_len, candidates,
|
||||
selected, total}`. Lua publishes into it; both frontends render from
|
||||
it; keys resolve against it daemon-side. Control is daemon-owned even
|
||||
though rendering is frontend-local — the Q#UX1 lesson, applied from
|
||||
day one this time. Candidates carry `{label, kind, detail,
|
||||
insert_text}` (framework `CompletionCandidate` projected down).
|
||||
|
||||
### Q#C3 — Key capture: a partial dispatcher shadow, not buffer-local binds
|
||||
|
||||
While the session is open, `dispatch_key` routes **only**
|
||||
`TAB / RET / C-n / C-p / Up / Down / ESC / C-g` to
|
||||
`dispatch_completion_key` (accept / accept / next / prev / next / prev
|
||||
/ close / close); *everything else falls through* to normal dispatch,
|
||||
so printable keys keep self-inserting and motion keys work. This is
|
||||
the fourth member of the existing shadow family and avoids the
|
||||
bind/unbind lifecycle discipline that transient buffer-local keymaps
|
||||
would need. Considered and rejected: minibuffer-style full shadow
|
||||
(typing must insert); `add_intercept` (sees edits only, never nav
|
||||
chords); buffer-local binds (workable — the `*buffer-list*` idiom —
|
||||
but the teardown hazard buys nothing here).
|
||||
|
||||
**Session validity is enforced in core, post-dispatch — not by hooks.**
|
||||
Cursor motion fires no hook (`buffer.after-edit` is edits-only,
|
||||
`editor.rs:573`), so "close when the cursor leaves the word" cannot be
|
||||
Lua-driven. After any dispatched action while the session is open, the
|
||||
dispatcher runs a validation step: the session survives only if the
|
||||
active buffer still matches `buffer_id`, the cursor sits inside
|
||||
`[anchor .. anchor + current-word-end]`, and the prefix re-derived
|
||||
from the buffer still starts at `anchor`. Anything else — motion off
|
||||
the word, buffer switch, window change, undo that rewrote the region —
|
||||
closes the session. This also covers remote/CRDT edits landing under
|
||||
the popup: the next dispatch (or the producer tick) revalidates
|
||||
against the shifted text.
|
||||
|
||||
### Q#C4 — TUI popup: rework `CompletionView` on the MenuView pattern
|
||||
|
||||
`CompletionView` switches its data source to the Q#C2 session and
|
||||
becomes self-positioning like `MenuView`: compute its own sub-rect
|
||||
anchored to the row below the cursor (above when near the window
|
||||
bottom), clamped to the window and offset past the gutter
|
||||
(`viewport.gutter_w`), painting ≤ `POPUP_VISIBLE` rows. Attached once
|
||||
per window like `MenuView`, self-suppressing when the session is
|
||||
closed or belongs to another buffer.
|
||||
|
||||
### Q#C5 — Wire: `InstanceMessage::CompletionPopup`, protocol v15
|
||||
|
||||
Additive over v14; `SUPPORTED = [6..15]`, daemon-gated `>= 15`,
|
||||
produced by `semantic_render::completion_popup_msg` with the family's
|
||||
cached-compare suppression:
|
||||
|
||||
```
|
||||
InstanceMessage::CompletionPopup {
|
||||
buffer_id: BufferId,
|
||||
anchor: Option<u64>, // byte offset of the prefix start; None = closed
|
||||
prefix_len: u32, // bytes of typed prefix (frontend may embolden)
|
||||
rows: Vec<CompletionRow>, // windowed slice (≤ POPUP_VISIBLE = 10)
|
||||
selected: Option<u32>, // within `rows`
|
||||
total: u32,
|
||||
}
|
||||
CompletionRow { label: String, kind: u8, detail: Option<String> }
|
||||
```
|
||||
|
||||
Byte anchor, not pixels — the GPU already maps byte→glyph rect for the
|
||||
caret and presence washes; first byte-anchored popup on the wire
|
||||
(menu anchors at the click pixel locally). Rows are display-only;
|
||||
accept is a daemon round-trip, so `insert_text` never ships.
|
||||
|
||||
### Q#C6 — GPU input while the popup is open: an explicit key predicate
|
||||
|
||||
The existing gates are all-or-nothing (`daemon_intercepts_keys`,
|
||||
`pmacs-gpu/src/main.rs:1913`; `dispatch_idle`) — reusing them would
|
||||
round-trip *typing* too, killing optimistic latency exactly when the
|
||||
user is mid-word. Instead the GPU gets an explicit
|
||||
`is_completion_control_key(key, mods)` predicate, checked **before**
|
||||
the optimistic-insert path and before local key handling, active only
|
||||
while `CompletionLocal.is_some()`: exactly
|
||||
`TAB / RET / ESC / C-g / C-n / C-p / Up / Down` forward as
|
||||
`FrontendEvent::Key` round-trips into `dispatch_completion_key`;
|
||||
every other key stays on its normal path (plain chars optimistic,
|
||||
chords per the usual forwarding rules). Three keys need the gate
|
||||
specifically because their default handling is wrong under a popup:
|
||||
RET and TAB are optimistic-eligible today (they reduce to plain
|
||||
inserts, `main.rs:5300`-area), and **ESC is a hardcoded local
|
||||
exception** (`main.rs:1423`) that would be swallowed and never reach
|
||||
the daemon — it must be conditionally forwarded while the popup is
|
||||
open. Rendering: a `CompletionLocal` mirror + a dropdown layer cloned
|
||||
from the `mb_dropdown_*` functions, anchored at the anchor byte's
|
||||
glyph rect instead of the status band, clamped to the window (the
|
||||
F-007 fit logic).
|
||||
|
||||
### Q#C7 — Accept semantics: validate, then replace
|
||||
|
||||
Accept **re-validates before touching the buffer**: the active buffer,
|
||||
cursor, anchor, and the prefix currently in the text must all still
|
||||
match the session (the Q#C3 invariant, re-checked at the moment of
|
||||
accept — a remote edit or race can invalidate between frames). On
|
||||
mismatch, accept is a no-op that closes the session. On match, it
|
||||
replaces `[anchor .. cursor]` with the candidate's
|
||||
`effective_insert_text()` through the normal command/edit layer (one
|
||||
undo entry; fires `buffer.after-edit`, so LSP `didChange` and styling
|
||||
refresh ride existing machinery), then closes the session. Snippet
|
||||
bodies insert literally in v1 — no tabstop engine (deferred).
|
||||
|
||||
### Q#C8 — LSP scoping + freshness: flush first, scope to the attachment
|
||||
|
||||
Two correctness rules, then the UX:
|
||||
|
||||
- **Flush before requesting.** `didChange` is debounced; a request
|
||||
issued without flushing answers against stale text. The driver must
|
||||
not use the non-flushing `pmacs.lsp.active_attachment()` — `lsp.lua`
|
||||
grows a public flushing accessor (wrapping the local
|
||||
`attached_for_active()`, `lsp.lua:520-533`) that the driver calls
|
||||
before `request_completion`, exactly as every interactive LSP
|
||||
command already does internally.
|
||||
- **Scope candidates to the attachment.** The built-in LSP provider
|
||||
drains the whole store across all `(server, uri)` keys. Fix in the
|
||||
framework: `CompletionContext` gains `uri: Option<String>` (the
|
||||
driver fills it from the attachment record) and the provider reads
|
||||
only that URI's entries. Fallback if the context change proves
|
||||
awkward: disable the built-in LSP provider for the driver path and
|
||||
have Lua merge `pmacs.completion.items(rec.server, rec.uri)` itself
|
||||
— either way, no cross-buffer candidates.
|
||||
|
||||
UX: publish immediately from the synchronous providers
|
||||
(dabbrev/snippets/project-symbols feel instant), fire the LSP request,
|
||||
and re-collect + re-publish when the response lands
|
||||
(`handle:on_complete`). `isIncomplete` responses re-request on further
|
||||
typing. No spinner; the popup just gets better a beat later.
|
||||
|
||||
### Q#C9 — Trigger policy without edit metadata
|
||||
|
||||
`buffer.after-edit` fires with no payload from both edit paths
|
||||
(`editor.rs:575`, `daemon.rs:1923`), so the driver cannot see *what*
|
||||
changed — it must reconstruct intent from state. Policy (v1):
|
||||
|
||||
- The driver keeps a per-invocation snapshot `{buffer, revision,
|
||||
cursor}`.
|
||||
- **Open** on after-edit only when: the active buffer is unchanged,
|
||||
the cursor sits at the end of a word with prefix ≥ 2, **and** the
|
||||
cursor advanced by exactly one codepoint since the last snapshot
|
||||
(the single-char-typing signature) — or the char before the cursor
|
||||
is a server trigger character (`should_fire`).
|
||||
- **Paste, undo/redo, kill, and remote edits never auto-open** (their
|
||||
cursor delta ≠ 1 or the region signature doesn't match); `C-M-i`
|
||||
covers the deliberate cases.
|
||||
- While **open**, any after-edit re-derives the prefix from the buffer
|
||||
and refreshes or closes — no metadata needed, the text is the truth.
|
||||
|
||||
Named alternative, deliberately not taken now: giving
|
||||
`buffer.after-edit` an edit payload (buffer id, kind, range). Variadic
|
||||
Lua handlers would tolerate the new args, but the PR #52 revert
|
||||
history says hook-signature changes must not ride along in a feature
|
||||
bundle — if the heuristic proves flaky, the payload becomes its own
|
||||
small, separately-validated PR.
|
||||
|
||||
## Phasing (each phase independently green + user-validated)
|
||||
|
||||
1. **Core + TUI, no wire change.** Session store + dispatcher shadow
|
||||
with post-dispatch validation (Q#C3) + `CompletionView` rework +
|
||||
`builtin/runtime/completion.lua` driver (Q#C1/C9 policy, score ≥ 0
|
||||
filter) + validated accept (Q#C7) + `completion.at-point` — plus
|
||||
the small Rust/Lua seams the review surfaced:
|
||||
`CompletionContext.uri` + LSP-provider scoping, the flushing
|
||||
attachment accessor, and the `context_for` char-trigger fix
|
||||
(Q#C8/C1). Validate in the standalone TUI and a TUI-attached
|
||||
daemon.
|
||||
2. **Wire + GPU.** Protocol v15, producer + daemon gate, GPU
|
||||
`CompletionLocal` + dropdown layer + the RET/TAB optimistic gate.
|
||||
3. **Polish + docs.** `isIncomplete` re-query, per-server
|
||||
trigger-chars, as-built notes folded into this doc.
|
||||
|
||||
Arc 2 interleave points: after phase 1 and after phase 2 (query-replace
|
||||
and kill-ring are the named next table-stakes items).
|
||||
|
||||
## Categorical bets (score at close)
|
||||
|
||||
1. **The prompt-family pattern generalizes a fourth time** — producer /
|
||||
cached-compare / gate / local-mirror drop in without core surgery.
|
||||
Risk concentrates in the one new element: byte-anchored positioning.
|
||||
2. **Synchronous `collect` is fast enough per keystroke.** dabbrev
|
||||
scans the whole buffer text per call — O(buffer) on every refresh.
|
||||
Bet: fine at typical file sizes; a cap/debounce is the fallback,
|
||||
not a redesign.
|
||||
3. **RET/TAB/ESC routing** — some path will both insert and accept
|
||||
(or neither), or ESC will die at the GPU's local handler, until the
|
||||
Q#C6 predicate is exactly right. Predicted highest-likelihood
|
||||
finding.
|
||||
4. **Popup placement edge cases** — bottom-of-window flip, gutter
|
||||
offset, narrow windows. Clamping bugs, MenuView-class.
|
||||
5. **The Q#C9 single-char heuristic holds.** Paste/undo/kill/remote
|
||||
edits stay quiet and ordinary typing always opens. If validation
|
||||
shows misfires, the fix is the named hook-payload PR, not
|
||||
heuristic patching.
|
||||
|
||||
## As-built notes — phase 1 (PR #92)
|
||||
|
||||
Landed close to the framing; the user's TUI validation pass surfaced
|
||||
five findings, all addressed in-branch:
|
||||
|
||||
1. **LSP-only words never queried the server** — the auto-open path
|
||||
fired `request_completion` only when the synchronous providers had
|
||||
already produced rows. Now an empty sweep leaves a *pending*
|
||||
session (the trigger-char shape) and the request always fires;
|
||||
`isIncomplete` responses re-request on further typing via
|
||||
`pmacs.completion.is_incomplete`. Corollary found while fixing:
|
||||
`attachment_for_request` must flush-if-attached but **never
|
||||
attach** — the first cut wrapped `attached_for_active`, which
|
||||
spawns a server on demand, i.e. per-keystroke spawn attempts in
|
||||
every unattached buffer (wedged the parallel m4 suite).
|
||||
Attachment stays buffer-open policy.
|
||||
2. **Strict URI scoping** — the built-in LSP provider now returns
|
||||
*nothing* without `ctx.uri` (the framing's "legacy global drain
|
||||
when absent" allowed unattached/scratch buffers to show another
|
||||
file's cached completions).
|
||||
3. **Pending prefixes own the keyboard** — `Action::Pending` (`C-x
|
||||
...`) dismisses the popup, and the popup shadow is additionally
|
||||
guarded on `dispatcher.pending().is_empty()`, so a sequence's
|
||||
continuation and its `C-g` abort reach the dispatcher.
|
||||
4. **Window-scoped sessions** — `CompletionPopupState.window_id`
|
||||
(stamped by `completion_popup_open`; Lua never sees it): only the
|
||||
owning window's overlay paints (same-buffer splits each carry a
|
||||
persistent overlay), and a focus change invalidates the session.
|
||||
5. The worker-pool teardown fix (signal-only `EditorState::drop`)
|
||||
rode along in the PR — unrelated to completion, surfaced by
|
||||
running the m4 gate.
|
||||
|
||||
Also caught by the new acceptance suite pre-validation:
|
||||
`install_completion` rebuilt `pmacs.completion` and clobbered the
|
||||
popup bindings — all `pmacs.completion` installers now merge.
|
||||
|
||||
## Deferred (named, not silently dropped)
|
||||
|
||||
- Snippet tabstops/placeholders (v1 inserts bodies literally).
|
||||
- `completionItem/resolve` (lazy documentation/detail) and
|
||||
`additionalTextEdits` (auto-import) — needs a resolve round-trip on
|
||||
selection change.
|
||||
- A documentation panel beside the popup (company-style doc buffer).
|
||||
- Fuzzy matching beyond the current prefix/word-boundary/substring
|
||||
scorer.
|
||||
- TUI/GPU visual unification (GPU leads, as with the minibuffer).
|
||||
- Minibuffer-style persisted ranking / frequency weighting.
|
||||
|
|
@ -602,6 +602,20 @@ impl AsyncRuntime {
|
|||
Self::with_pool(WorkerPool::new(size))
|
||||
}
|
||||
|
||||
/// Signal the worker-pool threads to exit (see
|
||||
/// [`crate::worker::WorkerPool::signal_shutdown`]). Idempotent,
|
||||
/// non-blocking. Called from [`crate::editor::EditorState`]'s
|
||||
/// `Drop`: the runtime's `Rc` is captured into Lua-VM reference
|
||||
/// cycles and never reaches a zero refcount, so without this
|
||||
/// explicit teardown every editor instance leaks its whole pool
|
||||
/// --- one leaked pool per test in a suite that builds real
|
||||
/// editors. Signal-only (no join): a worker blocked handing its
|
||||
/// reply to the main thread must not deadlock the main thread's
|
||||
/// drop.
|
||||
pub fn shutdown_workers(&self) {
|
||||
self.pool.signal_shutdown();
|
||||
}
|
||||
|
||||
/// Returns the number of in-flight or settled-but-not-yet-taken
|
||||
/// pending entries.
|
||||
#[must_use]
|
||||
|
|
|
|||
|
|
@ -30,8 +30,9 @@ use std::sync::{Arc, Mutex};
|
|||
use serde_json::Value;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use crate::buffer::Buffer;
|
||||
use crate::cell::{Cell, CellCoord, CellGrid, Color, Glyph, Style};
|
||||
use crate::buffer::{Buffer, BufferId};
|
||||
use crate::cell::{CellCoord, CellGrid, Color, Glyph, Style};
|
||||
use crate::rope::Position;
|
||||
use crate::view::{View, Viewport};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -453,6 +454,123 @@ impl CompletionTriggers {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-buffer completion popup session (Arc 1a, Q#C2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One row of the in-buffer completion popup: a projection of a
|
||||
/// [`crate::completion_framework::CompletionCandidate`] carrying only
|
||||
/// what rendering and the accept path need. `insert_text` is already
|
||||
/// resolved (label fallback applied) so accept never re-derives it.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PopupCandidate {
|
||||
/// Display label.
|
||||
pub label: String,
|
||||
/// Item kind (drives the glyph column).
|
||||
pub kind: CompletionItemKind,
|
||||
/// Optional one-line detail rendered after the label.
|
||||
pub detail: Option<String>,
|
||||
/// Text that replaces `[anchor .. cursor]` on accept.
|
||||
pub insert_text: String,
|
||||
}
|
||||
|
||||
/// Live state of the in-buffer completion popup (Q#C2). Frontend-
|
||||
/// agnostic, mirroring [`crate::menu::MenuState`]: the Lua driver
|
||||
/// publishes into it, the TUI [`CompletionView`] overlay renders from
|
||||
/// it, the dispatcher's completion shadow navigates/accepts against
|
||||
/// it, and (phase 2) the semantic producer ships it to the GPU.
|
||||
///
|
||||
/// Unlike the menu's cell anchor, `anchor` is a **byte offset** (the
|
||||
/// prefix start) --- each frontend maps byte → screen position itself,
|
||||
/// so the instance never learns a pixel.
|
||||
pub struct CompletionPopupState {
|
||||
/// Buffer the popup targets. The session closes the moment the
|
||||
/// active buffer differs (Q#C3 validation).
|
||||
pub buffer_id: BufferId,
|
||||
/// Window the popup belongs to. Stamped by
|
||||
/// [`crate::editor_core::EditorCore::completion_popup_open`]
|
||||
/// (Lua publishers don't know window identity), `None` only
|
||||
/// before that stamp. Two splits showing the same buffer each
|
||||
/// carry a persistent overlay --- without this, both would paint
|
||||
/// the popup; with it, only the owning window's overlay renders,
|
||||
/// and a focus change closes the session (Q#C3).
|
||||
pub window_id: Option<crate::window::WindowId>,
|
||||
/// Byte offset where the typed prefix starts. For a
|
||||
/// trigger-character session (e.g. right after `.`) the prefix is
|
||||
/// empty and `anchor` equals the cursor.
|
||||
pub anchor: Position,
|
||||
/// The prefix as of the last publish (refresh keeps it current).
|
||||
pub prefix: String,
|
||||
/// Candidates, best-first. The driver has already scored, dropped
|
||||
/// non-matches, and capped.
|
||||
pub candidates: Vec<PopupCandidate>,
|
||||
/// Highlighted row index into `candidates`.
|
||||
pub selected: usize,
|
||||
/// Full candidate count before any cap the driver applied.
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
impl CompletionPopupState {
|
||||
/// Build a session. Returns `None` when `candidates` is empty ---
|
||||
/// an empty popup never opens (the driver enforces this too; this
|
||||
/// is the belt to its suspenders).
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
buffer_id: BufferId,
|
||||
anchor: Position,
|
||||
prefix: String,
|
||||
candidates: Vec<PopupCandidate>,
|
||||
total: usize,
|
||||
) -> Option<Self> {
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
buffer_id,
|
||||
window_id: None,
|
||||
anchor,
|
||||
prefix,
|
||||
candidates,
|
||||
selected: 0,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
/// Move the highlight by `delta`, wrapping at the ends.
|
||||
#[allow(
|
||||
clippy::cast_possible_wrap,
|
||||
reason = "candidate indices are bounded by Vec::len() which fits in isize on every supported target"
|
||||
)]
|
||||
pub fn step(&mut self, delta: isize) {
|
||||
let len = self.candidates.len() as isize;
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let mut next = (self.selected as isize + delta) % len;
|
||||
if next < 0 {
|
||||
next += len;
|
||||
}
|
||||
self.selected = next as usize;
|
||||
}
|
||||
|
||||
/// The highlighted candidate.
|
||||
#[must_use]
|
||||
pub fn selected_candidate(&self) -> Option<&PopupCandidate> {
|
||||
self.candidates.get(self.selected)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared handle to the open popup (`None` when closed). Held by
|
||||
/// [`crate::editor_core::EditorCore`] and read by [`CompletionView`],
|
||||
/// the completion twin of [`crate::menu::SharedMenu`].
|
||||
pub type SharedCompletionPopup = Arc<Mutex<Option<CompletionPopupState>>>;
|
||||
|
||||
/// A fresh, closed shared popup.
|
||||
#[must_use]
|
||||
pub fn make_shared_popup() -> SharedCompletionPopup {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -462,6 +580,18 @@ impl CompletionTriggers {
|
|||
/// rust-analyzer reply.
|
||||
const DEFAULT_POPUP_WIDTH: u32 = 40;
|
||||
|
||||
/// Rows the popup shows at once; when more candidates are live the
|
||||
/// visible slice windows around the selection (mirroring the
|
||||
/// minibuffer dropdown's `MB_VISIBLE` cap).
|
||||
const POPUP_MAX_ROWS: u32 = 10;
|
||||
|
||||
/// Minimum popup width in cells (glyph column + a readable label).
|
||||
const POPUP_MIN_WIDTH: u32 = 12;
|
||||
|
||||
/// Tab-stop width in display columns, matching [`crate::diag`] /
|
||||
/// [`crate::text_view`].
|
||||
const TAB_WIDTH: u32 = 8;
|
||||
|
||||
/// Style for the currently-selected row (reverse video so it pops on
|
||||
/// any base palette).
|
||||
fn selected_style() -> Style {
|
||||
|
|
@ -475,111 +605,275 @@ fn selected_style() -> Style {
|
|||
fn kind_style() -> Style {
|
||||
Style {
|
||||
fg: Color::Indexed(8),
|
||||
bg: Color::Indexed(236),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Popup view that draws the completion list at its viewport's
|
||||
/// origin. The viewport's `cell_size` bounds the popup; the host
|
||||
/// (Lua) decides where to put it.
|
||||
/// Popup background (non-selected rows) --- the same dim fill as the
|
||||
/// context menu, so the popup reads as a floating surface over the
|
||||
/// buffer text it occludes.
|
||||
fn popup_style() -> Style {
|
||||
Style {
|
||||
fg: Color::Indexed(252),
|
||||
bg: Color::Indexed(236),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The visible slice of `n` candidates windowed around `selected`:
|
||||
/// returns `(start, len)`. Mirrors the minibuffer dropdown's centered
|
||||
/// window so the highlight stays in view as the user cycles.
|
||||
#[must_use]
|
||||
pub(crate) fn popup_window(n: usize, selected: usize, max: usize) -> (usize, usize) {
|
||||
if n <= max {
|
||||
return (0, n);
|
||||
}
|
||||
let half = max / 2;
|
||||
let start = selected.saturating_sub(half).min(n - max);
|
||||
(start, max)
|
||||
}
|
||||
|
||||
/// Self-positioning popup overlay for the in-buffer completion session
|
||||
/// (Q#C4). Persistent on the active window once attached (deduped by
|
||||
/// [`View::kind`]); renders nothing while the popup is closed or the
|
||||
/// window shows a different buffer, mirroring [`crate::menu::MenuView`]'s
|
||||
/// self-suppressing model. Owns every cell inside the popup rectangle.
|
||||
///
|
||||
/// Unlike [`crate::diag::DiagnosticView`], this view does **not**
|
||||
/// compose over a buffer's text --- it owns every cell inside its
|
||||
/// viewport. The `_buf` parameter is unused.
|
||||
/// Placement: the row *below* the anchor's screen row, with as many
|
||||
/// rows as fit; when nothing fits below, it flips *above* the anchor.
|
||||
/// The left edge sits at the anchor's display column, shifted left when
|
||||
/// the popup would overflow the window's right edge.
|
||||
pub struct CompletionView {
|
||||
key: CompletionKey,
|
||||
store: SharedCompletionStore,
|
||||
popup: SharedCompletionPopup,
|
||||
/// The window this overlay instance is attached to. Overlays
|
||||
/// persist per window; only the one matching the session's
|
||||
/// `window_id` paints, so same-buffer splits don't each show
|
||||
/// the popup.
|
||||
window_id: crate::window::WindowId,
|
||||
}
|
||||
|
||||
impl CompletionView {
|
||||
/// Construct a popup view for `key` against `store`.
|
||||
/// Build a view reading `popup`, rendering only when the open
|
||||
/// session belongs to `window_id`.
|
||||
#[must_use]
|
||||
pub fn new(key: CompletionKey, store: SharedCompletionStore) -> Self {
|
||||
Self { key, store }
|
||||
pub fn new(popup: SharedCompletionPopup, window_id: crate::window::WindowId) -> Self {
|
||||
Self { popup, window_id }
|
||||
}
|
||||
}
|
||||
|
||||
/// Display column of `byte_end` within `line_bytes` (tab-aware,
|
||||
/// UTF-8-aware). The completion twin of the diagnostic underline's
|
||||
/// column resolution.
|
||||
fn display_col_for_byte(line_bytes: &[u8], byte_end: u32) -> u32 {
|
||||
let end = (byte_end as usize).min(line_bytes.len());
|
||||
let text = String::from_utf8_lossy(&line_bytes[..end]);
|
||||
let mut col = 0u32;
|
||||
for ch in text.chars() {
|
||||
if ch == '\t' {
|
||||
col += TAB_WIDTH - (col % TAB_WIDTH);
|
||||
} else {
|
||||
col += char_display_width(ch);
|
||||
}
|
||||
}
|
||||
col
|
||||
}
|
||||
|
||||
/// Resolved popup rectangle, in window-relative cells.
|
||||
struct PopupRect {
|
||||
/// First popup row, relative to the viewport's top.
|
||||
top: u32,
|
||||
/// Left edge, relative to the viewport's left.
|
||||
left: u32,
|
||||
/// Popup width in cells.
|
||||
width: u32,
|
||||
/// Rows actually shown (≤ the windowed candidate count).
|
||||
shown: u32,
|
||||
}
|
||||
|
||||
/// Map the popup's byte anchor to a clamped on-screen rectangle:
|
||||
/// below the anchor row when at least one row fits, flipped above
|
||||
/// otherwise; left edge at the anchor's display column, shifted back
|
||||
/// from the right margin. `None` when the anchor is scrolled out of
|
||||
/// the viewport or nothing fits.
|
||||
fn resolve_popup_rect(
|
||||
buf: &Buffer,
|
||||
viewport: Viewport,
|
||||
anchor: Position,
|
||||
rows: &[PopupCandidate],
|
||||
) -> Option<PopupRect> {
|
||||
// Anchor byte → (screen row, display col), the diag-view walk.
|
||||
let source: Vec<u8> = {
|
||||
let mut bytes = vec![0u8; buf.len() as usize];
|
||||
if !bytes.is_empty() {
|
||||
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
|
||||
}
|
||||
bytes
|
||||
};
|
||||
let anchor = (anchor as usize).min(source.len()) as u32;
|
||||
let line_offsets = crate::diag::compute_line_offsets(&source);
|
||||
let start_line = crate::diag::line_at_offset(&line_offsets, viewport.buffer_start as u32);
|
||||
let anchor_line = crate::diag::line_at_offset(&line_offsets, anchor);
|
||||
if anchor_line < start_line {
|
||||
return None; // anchor scrolled above the viewport
|
||||
}
|
||||
let anchor_row = anchor_line - start_line;
|
||||
let max_rows = viewport.cell_size.rows;
|
||||
let max_cols = viewport.cell_size.cols;
|
||||
if anchor_row >= max_rows || max_cols == 0 {
|
||||
return None; // anchor scrolled below the viewport
|
||||
}
|
||||
let line_start = line_offsets[anchor_line as usize];
|
||||
let line_end = line_offsets
|
||||
.get(anchor_line as usize + 1)
|
||||
.copied()
|
||||
.unwrap_or(source.len() as u32);
|
||||
let line_bytes = &source[line_start as usize..line_end as usize];
|
||||
let anchor_col = display_col_for_byte(line_bytes, anchor - line_start);
|
||||
|
||||
// Vertical placement: below the anchor row when at least one row
|
||||
// fits, else flipped above.
|
||||
let want_rows = rows.len() as u32;
|
||||
let below = max_rows - anchor_row - 1;
|
||||
let (top, shown) = if below > 0 {
|
||||
(anchor_row + 1, want_rows.min(below))
|
||||
} else {
|
||||
let shown = want_rows.min(anchor_row);
|
||||
(anchor_row - shown, shown)
|
||||
};
|
||||
if shown == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
/// The key this view is keyed under.
|
||||
#[must_use]
|
||||
pub fn key(&self) -> &CompletionKey {
|
||||
&self.key
|
||||
// Width: glyph column + widest visible "label detail", clamped to
|
||||
// the window; left edge shifts back from the right margin.
|
||||
let widest = rows
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let detail = c.detail.as_deref().map_or(0, |d| d.chars().count() + 2);
|
||||
(c.label.chars().count() + detail) as u32
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let width = (widest + 3)
|
||||
.clamp(POPUP_MIN_WIDTH, DEFAULT_POPUP_WIDTH)
|
||||
.min(max_cols);
|
||||
let left = anchor_col.min(max_cols - width);
|
||||
Some(PopupRect {
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
shown,
|
||||
})
|
||||
}
|
||||
|
||||
/// Paint one popup row (background fill, kind glyph, label + detail)
|
||||
/// at absolute row `r`, columns `[abs_left .. abs_left + width)`.
|
||||
fn paint_popup_row(
|
||||
cells: &mut CellGrid<'_>,
|
||||
item: &PopupCandidate,
|
||||
r: u32,
|
||||
abs_left: u32,
|
||||
width: u32,
|
||||
selected: bool,
|
||||
) {
|
||||
let row_style = if selected {
|
||||
selected_style()
|
||||
} else {
|
||||
popup_style()
|
||||
};
|
||||
// Paint the whole row's background first so the selected row's
|
||||
// reverse video covers the trailing whitespace.
|
||||
for c in 0..width {
|
||||
let cell = cells.at(CellCoord::new(r, abs_left + c));
|
||||
cell.glyph = Glyph::Char(' ');
|
||||
cell.style = row_style;
|
||||
cell.attachment = None;
|
||||
}
|
||||
// Column 0: kind glyph.
|
||||
let kind_cell = cells.at(CellCoord::new(r, abs_left));
|
||||
kind_cell.glyph = Glyph::Char(item.kind.glyph());
|
||||
kind_cell.style = if selected {
|
||||
selected_style()
|
||||
} else {
|
||||
kind_style()
|
||||
};
|
||||
// Columns 2..: label, optionally followed by the detail.
|
||||
let mut text = String::with_capacity(item.label.len() + 4);
|
||||
text.push_str(&item.label);
|
||||
if let Some(detail) = item.detail.as_deref() {
|
||||
text.push_str(" ");
|
||||
text.push_str(detail);
|
||||
}
|
||||
let mut col: u32 = 2;
|
||||
for ch in text.chars() {
|
||||
if col >= width {
|
||||
break;
|
||||
}
|
||||
let cw = char_display_width(ch);
|
||||
if cw == 0 {
|
||||
continue;
|
||||
}
|
||||
let cell = cells.at(CellCoord::new(r, abs_left + col));
|
||||
cell.glyph = Glyph::Char(ch);
|
||||
cell.style = row_style;
|
||||
cell.attachment = None;
|
||||
col += 1;
|
||||
if cw == 2 && col < width {
|
||||
let cont = cells.at(CellCoord::new(r, abs_left + col));
|
||||
cont.glyph = Glyph::Continuation;
|
||||
cont.style = row_style;
|
||||
cont.attachment = None;
|
||||
col += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for CompletionView {
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
let (items, selected) = {
|
||||
let guard = self.store.lock().expect("completion store poisoned");
|
||||
let items = guard.items(&self.key).to_vec();
|
||||
(items, guard.selected(&self.key))
|
||||
};
|
||||
fn kind(&self) -> &'static str {
|
||||
"completion-popup"
|
||||
}
|
||||
|
||||
let max_rows = viewport.cell_size.rows;
|
||||
let max_cols = viewport.cell_size.cols.max(1);
|
||||
let origin = viewport.cell_origin;
|
||||
let popup_cols = max_cols.min(DEFAULT_POPUP_WIDTH);
|
||||
|
||||
// Clear the popup region.
|
||||
for r in 0..max_rows {
|
||||
for c in 0..max_cols {
|
||||
*cells.at(CellCoord::new(origin.row + r, origin.col + c)) = Cell::default();
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
// Snapshot under the lock, then drop it before touching the rope.
|
||||
let (anchor, rows_data, selected_in_window): (Position, Vec<PopupCandidate>, usize) = {
|
||||
let guard = self.popup.lock().expect("completion popup poisoned");
|
||||
let Some(popup) = guard.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if popup.window_id != Some(self.window_id) {
|
||||
return; // the session belongs to another window
|
||||
}
|
||||
}
|
||||
if items.is_empty() {
|
||||
if popup.buffer_id != buf.id() {
|
||||
return; // this window shows a different buffer
|
||||
}
|
||||
let (start, len) = popup_window(
|
||||
popup.candidates.len(),
|
||||
popup.selected,
|
||||
POPUP_MAX_ROWS as usize,
|
||||
);
|
||||
(
|
||||
popup.anchor,
|
||||
popup.candidates[start..start + len].to_vec(),
|
||||
popup.selected - start,
|
||||
)
|
||||
};
|
||||
if rows_data.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for row in 0..max_rows.min(items.len() as u32) {
|
||||
let item = &items[row as usize];
|
||||
let row_style = if row as usize == selected {
|
||||
selected_style()
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
// Paint the whole row's background first so the selected
|
||||
// row's reverse video covers the trailing whitespace.
|
||||
for c in 0..popup_cols {
|
||||
let cell = cells.at(CellCoord::new(origin.row + row, origin.col + c));
|
||||
cell.glyph = Glyph::Char(' ');
|
||||
cell.style = row_style;
|
||||
cell.attachment = None;
|
||||
}
|
||||
// Column 0: kind glyph.
|
||||
let kind_cell = cells.at(CellCoord::new(origin.row + row, origin.col));
|
||||
kind_cell.glyph = Glyph::Char(item.kind.glyph());
|
||||
kind_cell.style = if row as usize == selected {
|
||||
selected_style()
|
||||
} else {
|
||||
kind_style()
|
||||
};
|
||||
// Columns 2..: label, optionally followed by " : detail".
|
||||
let mut text = String::with_capacity(item.label.len() + 4);
|
||||
text.push_str(&item.label);
|
||||
if let Some(detail) = item.detail.as_deref() {
|
||||
text.push_str(" ");
|
||||
text.push_str(detail);
|
||||
}
|
||||
let mut col: u32 = 2;
|
||||
for ch in text.chars() {
|
||||
if col >= popup_cols {
|
||||
break;
|
||||
}
|
||||
let width = char_display_width(ch);
|
||||
if width == 0 {
|
||||
continue;
|
||||
}
|
||||
let cell = cells.at(CellCoord::new(origin.row + row, origin.col + col));
|
||||
cell.glyph = Glyph::Char(ch);
|
||||
cell.style = row_style;
|
||||
cell.attachment = None;
|
||||
col += 1;
|
||||
if width == 2 && col < popup_cols {
|
||||
let cont = cells.at(CellCoord::new(origin.row + row, origin.col + col));
|
||||
cont.glyph = Glyph::Continuation;
|
||||
cont.style = row_style;
|
||||
cont.attachment = None;
|
||||
col += 1;
|
||||
}
|
||||
}
|
||||
let Some(rect) = resolve_popup_rect(buf, viewport, anchor, &rows_data) else {
|
||||
return;
|
||||
};
|
||||
let origin = viewport.cell_origin;
|
||||
for (i, item) in rows_data.iter().take(rect.shown as usize).enumerate() {
|
||||
paint_popup_row(
|
||||
cells,
|
||||
item,
|
||||
origin.row + rect.top + i as u32,
|
||||
origin.col + rect.left,
|
||||
rect.width,
|
||||
i == selected_in_window,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -746,4 +1040,62 @@ mod tests {
|
|||
assert!(r.items.is_empty());
|
||||
assert!(!r.is_incomplete);
|
||||
}
|
||||
|
||||
// ---- popup session (Arc 1a, Q#C2) ---------------------------------------
|
||||
|
||||
fn cand(label: &str) -> PopupCandidate {
|
||||
PopupCandidate {
|
||||
label: label.to_owned(),
|
||||
kind: CompletionItemKind::Text,
|
||||
detail: None,
|
||||
insert_text: label.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_state_refuses_empty_candidates() {
|
||||
assert!(
|
||||
CompletionPopupState::new(BufferId::from_raw(1), 0, String::new(), vec![], 0).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_state_step_wraps_both_directions() {
|
||||
let mut p = CompletionPopupState::new(
|
||||
BufferId::from_raw(1),
|
||||
0,
|
||||
"ab".into(),
|
||||
vec![cand("a"), cand("b"), cand("c")],
|
||||
3,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.selected, 0);
|
||||
p.step(1);
|
||||
assert_eq!(p.selected, 1);
|
||||
p.step(4); // 1 + 4 = 5, mod 3 = 2
|
||||
assert_eq!(p.selected, 2);
|
||||
p.step(1); // wraps to the top
|
||||
assert_eq!(p.selected, 0);
|
||||
p.step(-1); // wraps to the bottom
|
||||
assert_eq!(p.selected, 2);
|
||||
assert_eq!(p.selected_candidate().unwrap().label, "c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_window_keeps_selection_visible() {
|
||||
// Fits: identity window.
|
||||
assert_eq!(popup_window(3, 0, 10), (0, 3));
|
||||
// Overflow: centers on the selection…
|
||||
assert_eq!(popup_window(30, 15, 10), (10, 10));
|
||||
// …clamps at the top…
|
||||
assert_eq!(popup_window(30, 0, 10), (0, 10));
|
||||
assert_eq!(popup_window(30, 2, 10), (0, 10));
|
||||
// …and at the bottom.
|
||||
assert_eq!(popup_window(30, 29, 10), (20, 10));
|
||||
// The selected index always falls inside the window.
|
||||
for sel in 0..30 {
|
||||
let (start, len) = popup_window(30, sel, 10);
|
||||
assert!(sel >= start && sel < start + len, "sel {sel} escaped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,11 @@ pub struct CompletionContext {
|
|||
pub project_root: Option<PathBuf>,
|
||||
/// What kicked off the request.
|
||||
pub trigger: CompletionTrigger,
|
||||
/// Document URI of the buffer being completed (Q#C8 scoping).
|
||||
/// When set, URI-keyed providers (LSP) surface only this
|
||||
/// document's entries; when `None` they fall back to the legacy
|
||||
/// global drain across every cached key.
|
||||
pub uri: Option<String>,
|
||||
}
|
||||
|
||||
impl CompletionContext {
|
||||
|
|
@ -133,6 +138,7 @@ impl CompletionContext {
|
|||
language: None,
|
||||
project_root: None,
|
||||
trigger: CompletionTrigger::Invoked,
|
||||
uri: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -618,11 +624,18 @@ fn project_kind_to_completion_kind(k: &crate::project_index::SymbolKind) -> Comp
|
|||
/// the LSP completion store. The framework does **not** drive a
|
||||
/// fresh `textDocument/completion` request --- that's the editor's
|
||||
/// job; we just read whatever the async pipeline has produced so
|
||||
/// far, across every cached `(server_id, uri)` key. The registry's
|
||||
/// dedup collapses identical entries; the prefix score ranks them.
|
||||
/// far. Strictly scoped to `ctx.uri` (Q#C8): only that document's
|
||||
/// entries surface, across all servers keyed to it. **No URI → no
|
||||
/// LSP candidates** --- an unattached/scratch buffer must never show
|
||||
/// another file's cached completions (the original global drain did
|
||||
/// exactly that). The registry's dedup collapses identical entries;
|
||||
/// the prefix score ranks them.
|
||||
#[must_use]
|
||||
pub fn lsp_completion_provider(lsp: crate::lsp::SharedLspManager) -> ProviderFn {
|
||||
Box::new(move |_ctx: &CompletionContext| -> Vec<CompletionItem> {
|
||||
Box::new(move |ctx: &CompletionContext| -> Vec<CompletionItem> {
|
||||
let Some(uri) = ctx.uri.clone() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let store_handle = {
|
||||
let mgr = lsp.borrow();
|
||||
mgr.completion_store()
|
||||
|
|
@ -631,7 +644,7 @@ pub fn lsp_completion_provider(lsp: crate::lsp::SharedLspManager) -> ProviderFn
|
|||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<CompletionItem> = Vec::new();
|
||||
let keys: Vec<_> = store.keys().cloned().collect();
|
||||
let keys: Vec<_> = store.keys().filter(|k| k.uri == uri).cloned().collect();
|
||||
for key in keys {
|
||||
for item in store.items(&key) {
|
||||
out.push(item.clone());
|
||||
|
|
|
|||
155
src/editor.rs
155
src/editor.rs
|
|
@ -102,6 +102,32 @@ pub struct EditorState {
|
|||
mouse_click: Option<MouseClickState>,
|
||||
}
|
||||
|
||||
impl Drop for EditorState {
|
||||
/// Tear down the worker-pool threads.
|
||||
///
|
||||
/// The `Rc<AsyncRuntime>` is cloned into dozens of Lua closures
|
||||
/// (`pmacs.workers`, LSP request wrappers, ...), and several of
|
||||
/// the registries those closures capture themselves store
|
||||
/// `mlua::Function` values --- reference cycles through the Lua
|
||||
/// VM that keep the `Rc` from ever reaching zero. Harmless for a
|
||||
/// single editor per process (the OS reclaims at exit), but a
|
||||
/// test binary that builds one `EditorState` per test would leak
|
||||
/// one full worker pool (`cores - 1` threads, each waking every
|
||||
/// 100ms) per test --- observed as 1000+ live threads in the m4
|
||||
/// acceptance suite. Dropping the editor reaches the pool through
|
||||
/// its own `Rc` clone and signals the threads down regardless of
|
||||
/// the cycle; parked workers exit within their 100ms wakeup.
|
||||
///
|
||||
/// Signal-only, NO join: a worker can be blocked publishing its
|
||||
/// reply onto the bus that this (main) thread drains --- joining
|
||||
/// here deadlocked the m4 suite at teardown for hours. A worker
|
||||
/// stuck mid-handoff stays alive (bounded by its job), which is
|
||||
/// still a ~15x improvement over leaking every pool whole.
|
||||
fn drop(&mut self) {
|
||||
self.async_runtime.shutdown_workers();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
struct MouseClickState {
|
||||
frontend_id: FrontendId,
|
||||
|
|
@ -256,6 +282,16 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/lsp.lua"),
|
||||
)
|
||||
.expect("load lsp builtin chunk");
|
||||
// Arc 1a: the in-buffer completion popup driver. Loaded after
|
||||
// lsp.lua because it drives `pmacs.lsp.request_completion` /
|
||||
// `pmacs.lsp.attachment_for_request` and after the framework
|
||||
// install above because it calls `pmacs.completion.collect`.
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/completion.lua"),
|
||||
include_str!("../builtin/runtime/completion.lua"),
|
||||
)
|
||||
.expect("load completion builtin chunk");
|
||||
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
|
||||
// was loaded directly via `eval(include_str!(...))`; the
|
||||
// M7.11 deliverable migrates it to the package system so it
|
||||
|
|
@ -493,6 +529,19 @@ impl EditorState {
|
|||
core.active_frontend = frontend_id;
|
||||
}
|
||||
|
||||
// Modal surfaces beat the completion popup (Q#C3): if a menu /
|
||||
// search / minibuffer opened while the popup was up, close the
|
||||
// popup before the modal shadow swallows this key --- otherwise
|
||||
// it would linger, rendered but unreachable.
|
||||
{
|
||||
let mut core = self.core.borrow_mut();
|
||||
if core.completion_popup_is_open()
|
||||
&& (core.menu_is_open() || core.search_active() || core.minibuffer.is_active())
|
||||
{
|
||||
core.completion_popup_close();
|
||||
}
|
||||
}
|
||||
|
||||
// Context-menu interception (Q#CM1): while a menu is open every
|
||||
// key drives it (navigate / invoke / dismiss), shadowing the
|
||||
// global keymap like search and the minibuffer. Same shared path
|
||||
|
|
@ -522,6 +571,26 @@ impl EditorState {
|
|||
return;
|
||||
}
|
||||
|
||||
// In-buffer completion popup (Q#C3): a PARTIAL shadow, the
|
||||
// fourth member of the family above. Only the popup-control
|
||||
// chords (TAB / RET / C-n / C-p / Up / Down / Esc / C-g) are
|
||||
// intercepted; every other key falls through to normal dispatch
|
||||
// below, so typing keeps self-inserting and motion keys keep
|
||||
// moving. The post-dispatch validation at the bottom of this
|
||||
// function closes the session when a fallen-through key breaks
|
||||
// the anchor/prefix invariant. A pending multi-key prefix owns
|
||||
// the keyboard: while one is in flight (`C-x ...`) the popup
|
||||
// must not steal its continuation or its `C-g` abort --- and
|
||||
// the Pending arm below closes the popup anyway, so this guard
|
||||
// only covers the same-dispatch race.
|
||||
if self.core.borrow().completion_popup_is_open()
|
||||
&& self.dispatcher.pending().is_empty()
|
||||
&& let Some(key) = CompletionPopupKey::from_chord(chord)
|
||||
{
|
||||
self.dispatch_completion_key(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer-scope keybindings need the active buffer id passed
|
||||
// through the dispatcher (otherwise `keymap_stack::resolve`
|
||||
// skips the buffer-local map entirely and every "scope =
|
||||
|
|
@ -552,7 +621,12 @@ impl EditorState {
|
|||
}
|
||||
Action::Pending { .. } => {
|
||||
// The pending prefix is rendered from
|
||||
// `dispatcher.pending()`; no command runs yet.
|
||||
// `dispatcher.pending()`; no command runs yet. Starting
|
||||
// a command sequence dismisses the completion popup:
|
||||
// leaving it open would route the sequence's `C-g`
|
||||
// abort (and its continuation chords) into the popup's
|
||||
// shadow instead of the dispatcher.
|
||||
self.core.borrow_mut().completion_popup_close();
|
||||
}
|
||||
Action::Unbound { sequence } => match printable_char(&sequence) {
|
||||
Some(ch) => {
|
||||
|
|
@ -575,6 +649,13 @@ impl EditorState {
|
|||
self.lua_host
|
||||
.run_hook("buffer.after-edit", mlua::MultiValue::new());
|
||||
}
|
||||
|
||||
// Q#C3 post-dispatch validation, deliberately AFTER the
|
||||
// after-edit hook: the Lua driver may have just refreshed (or
|
||||
// re-anchored) the popup for this very edit, and validation
|
||||
// must judge the fresh session, not the stale one. A closed
|
||||
// popup makes this a single mutex peek.
|
||||
self.core.borrow_mut().completion_popup_validate();
|
||||
}
|
||||
|
||||
/// Active buffer's edit revision, or `None` if the registry no
|
||||
|
|
@ -691,6 +772,28 @@ impl EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Drive the open completion popup from an intercepted control
|
||||
/// chord (Q#C3). Accept (Q#C7) re-validates inside
|
||||
/// [`EditorCore::completion_popup_accept`] and applies a single
|
||||
/// Replace edit; when that edit lands, `buffer.after-edit` fires
|
||||
/// here exactly as it does on the normal dispatch path, so LSP
|
||||
/// `didChange` and styling refresh ride the existing machinery.
|
||||
fn dispatch_completion_key(&mut self, key: CompletionPopupKey) {
|
||||
match key {
|
||||
CompletionPopupKey::Next => self.core.borrow_mut().completion_popup_step(1),
|
||||
CompletionPopupKey::Prev => self.core.borrow_mut().completion_popup_step(-1),
|
||||
CompletionPopupKey::Dismiss => self.core.borrow_mut().completion_popup_close(),
|
||||
CompletionPopupKey::Accept => {
|
||||
let pre_revision = self.active_buffer_revision();
|
||||
self.core.borrow_mut().completion_popup_accept();
|
||||
if pre_revision != self.active_buffer_revision() {
|
||||
self.lua_host
|
||||
.run_hook("buffer.after-edit", mlua::MultiValue::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive an open context menu from a keystroke (Q#CM1).
|
||||
fn dispatch_menu_key(&mut self, chord: Chord) {
|
||||
match MenuKey::from_chord(chord) {
|
||||
|
|
@ -1558,6 +1661,56 @@ impl MenuKey {
|
|||
}
|
||||
}
|
||||
|
||||
/// Keys intercepted while the in-buffer completion popup is open
|
||||
/// (Q#C3). Unlike [`SearchKey`] / [`MenuKey`] this is a **partial**
|
||||
/// shadow: `from_chord` returns `None` for every chord outside the
|
||||
/// popup-control set, and the dispatcher lets those fall through to
|
||||
/// normal dispatch --- printable keys keep self-inserting, motion keys
|
||||
/// keep moving (the post-dispatch validation then decides whether the
|
||||
/// session survives). The same decode runs in both frontends via the
|
||||
/// daemon's `FrontendEvent::Key` round-trip.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
enum CompletionPopupKey {
|
||||
/// Highlight the next candidate (Down / C-n).
|
||||
Next,
|
||||
/// Highlight the previous candidate (Up / C-p).
|
||||
Prev,
|
||||
/// Accept the highlighted candidate (TAB / RET).
|
||||
Accept,
|
||||
/// Close the popup without accepting (Esc / C-g).
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
impl CompletionPopupKey {
|
||||
/// Decode `chord` into a popup action, or `None` when the chord is
|
||||
/// not popup control and must fall through to normal dispatch.
|
||||
fn from_chord(chord: Chord) -> Option<Self> {
|
||||
let ctrl = chord.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let alt = chord.modifiers.contains(KeyModifiers::ALT);
|
||||
if !ctrl && !alt {
|
||||
return match chord.code {
|
||||
KeyCode::Down => Some(Self::Next),
|
||||
KeyCode::Up => Some(Self::Prev),
|
||||
KeyCode::Tab | KeyCode::Enter => Some(Self::Accept),
|
||||
KeyCode::Esc => Some(Self::Dismiss),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if ctrl
|
||||
&& !alt
|
||||
&& let KeyCode::Char(c) = chord.code
|
||||
{
|
||||
return match c {
|
||||
'n' => Some(Self::Next),
|
||||
'p' => Some(Self::Prev),
|
||||
'g' => Some(Self::Dismiss),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint one full frame into `grid` and return the desired terminal
|
||||
/// cursor position.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -187,6 +187,12 @@ pub struct EditorCore {
|
|||
/// from the same state the dispatch path mutates — the menu twin of
|
||||
/// `search_store`.
|
||||
pub menu: crate::menu::SharedMenu,
|
||||
/// Open in-buffer completion popup (Arc 1a, Q#C2), or `None` when
|
||||
/// closed. Shared `Arc<Mutex>` so the TUI
|
||||
/// [`crate::completion::CompletionView`] overlay renders from the
|
||||
/// same state the dispatch path navigates and the Lua driver
|
||||
/// publishes into — the completion twin of `menu`.
|
||||
pub completion_popup: crate::completion::SharedCompletionPopup,
|
||||
}
|
||||
|
||||
impl EditorCore {
|
||||
|
|
@ -226,6 +232,7 @@ impl EditorCore {
|
|||
clipboard_slot: Vec::new(),
|
||||
pending_clipboard: None,
|
||||
menu: crate::menu::make_shared_menu(),
|
||||
completion_popup: crate::completion::make_shared_popup(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1818,6 +1825,174 @@ impl EditorCore {
|
|||
}
|
||||
}
|
||||
|
||||
// ---- in-buffer completion popup (Arc 1a, Q#C2/Q#C3) --------------------
|
||||
|
||||
/// True while the in-buffer completion popup is open.
|
||||
#[must_use]
|
||||
pub fn completion_popup_is_open(&self) -> bool {
|
||||
self.completion_popup
|
||||
.lock()
|
||||
.expect("completion popup poisoned")
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Open (or replace) the completion popup session. Attaches the
|
||||
/// self-suppressing [`crate::completion::CompletionView`] overlay to
|
||||
/// the active window on first use (deduped by kind, like the menu).
|
||||
/// Emptiness is enforced upstream:
|
||||
/// [`crate::completion::CompletionPopupState::new`] refuses to build
|
||||
/// a candidate-less session.
|
||||
pub fn completion_popup_open(&mut self, mut state: crate::completion::CompletionPopupState) {
|
||||
// Stamp the owning window (Lua publishers don't know window
|
||||
// identity): only that window's overlay paints the popup, and
|
||||
// a focus change invalidates the session.
|
||||
state.window_id = Some(self.active_window_id());
|
||||
*self
|
||||
.completion_popup
|
||||
.lock()
|
||||
.expect("completion popup poisoned") = Some(state);
|
||||
self.ensure_completion_overlay();
|
||||
}
|
||||
|
||||
/// Close the popup (the overlay then self-suppresses).
|
||||
pub fn completion_popup_close(&mut self) {
|
||||
*self
|
||||
.completion_popup
|
||||
.lock()
|
||||
.expect("completion popup poisoned") = None;
|
||||
}
|
||||
|
||||
/// Move the popup highlight by `delta` (wrapping).
|
||||
pub fn completion_popup_step(&mut self, delta: isize) {
|
||||
if let Some(p) = self
|
||||
.completion_popup
|
||||
.lock()
|
||||
.expect("completion popup poisoned")
|
||||
.as_mut()
|
||||
{
|
||||
p.step(delta);
|
||||
}
|
||||
}
|
||||
|
||||
/// Q#C3 session invariant: the popup only survives while the
|
||||
/// active buffer still matches, the cursor sits at or after the
|
||||
/// anchor, and every byte between them is a word byte (`[A-Za-z0-9_]`
|
||||
/// --- the same ASCII word definition the Lua driver uses). A
|
||||
/// trigger-character session (empty prefix, `cursor == anchor`)
|
||||
/// holds trivially. Returns the `(anchor, cursor)` pair while the
|
||||
/// invariant holds.
|
||||
#[must_use]
|
||||
fn completion_session_holds(&self) -> Option<(Position, Position)> {
|
||||
/// Longest byte run still plausibly a completion prefix; past
|
||||
/// this the session is stale, not a prefix.
|
||||
const MAX_PREFIX_BYTES: u64 = 512;
|
||||
|
||||
let (buffer_id, window_id, anchor) = {
|
||||
let guard = self
|
||||
.completion_popup
|
||||
.lock()
|
||||
.expect("completion popup poisoned");
|
||||
let p = guard.as_ref()?;
|
||||
(p.buffer_id, p.window_id, p.anchor)
|
||||
};
|
||||
if window_id != Some(self.active_window_id()) {
|
||||
return None; // focus moved to another window/split
|
||||
}
|
||||
if self.active_buffer_id() != buffer_id {
|
||||
return None;
|
||||
}
|
||||
let cursor = self.active_window().cursor;
|
||||
if cursor < anchor || cursor - anchor > MAX_PREFIX_BYTES {
|
||||
return None;
|
||||
}
|
||||
let reg = self.registry.borrow();
|
||||
let buffer = reg.get(buffer_id).ok()?;
|
||||
if cursor > buffer.len() {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = vec![0u8; (cursor - anchor) as usize];
|
||||
if !bytes.is_empty() {
|
||||
buffer.snapshot_rope().slice(anchor, cursor, &mut bytes);
|
||||
}
|
||||
bytes
|
||||
.iter()
|
||||
.all(|b| b.is_ascii_alphanumeric() || *b == b'_')
|
||||
.then_some((anchor, cursor))
|
||||
}
|
||||
|
||||
/// Q#C3 post-dispatch validation: close the popup unless the
|
||||
/// session invariant still holds. Called by the dispatcher after
|
||||
/// every fallen-through key (motion, edits, buffer switches) and
|
||||
/// cheap enough to call unconditionally --- a closed popup is a
|
||||
/// single mutex peek.
|
||||
pub fn completion_popup_validate(&mut self) {
|
||||
if self.completion_popup_is_open() && self.completion_session_holds().is_none() {
|
||||
self.completion_popup_close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Q#C7 accept: re-validate the session at the moment of accept,
|
||||
/// close the popup, and --- only when the invariant still holds ---
|
||||
/// replace `[anchor .. cursor]` with the highlighted candidate's
|
||||
/// insert text as a **single** edit (one undo step, mirroring
|
||||
/// [`Self::insert_char_over_region`]). Returns `true` iff the
|
||||
/// buffer was edited (the dispatcher fires `buffer.after-edit`
|
||||
/// off that signal).
|
||||
pub fn completion_popup_accept(&mut self) -> bool {
|
||||
let holds = self.completion_session_holds();
|
||||
let snap = {
|
||||
let guard = self
|
||||
.completion_popup
|
||||
.lock()
|
||||
.expect("completion popup poisoned");
|
||||
guard
|
||||
.as_ref()
|
||||
.and_then(|p| p.selected_candidate().map(|c| c.insert_text.clone()))
|
||||
};
|
||||
self.completion_popup_close();
|
||||
let (Some((anchor, cursor)), Some(text)) = (holds, snap) else {
|
||||
return false;
|
||||
};
|
||||
self.active_window_mut().goal_col = None;
|
||||
// An empty range degenerates to a plain insert (the
|
||||
// trigger-character case, where nothing was typed yet).
|
||||
let result = if cursor > anchor {
|
||||
self.apply_active_edit(EditOp::Replace {
|
||||
range: Range {
|
||||
start: anchor,
|
||||
end: cursor,
|
||||
},
|
||||
bytes: text.as_bytes(),
|
||||
})
|
||||
} else {
|
||||
self.apply_active_edit(EditOp::Insert {
|
||||
pos: anchor,
|
||||
bytes: text.as_bytes(),
|
||||
})
|
||||
};
|
||||
if let Err(e) = result {
|
||||
self.status = format!("completion accept failed: {e}");
|
||||
return false;
|
||||
}
|
||||
let aw = self.active_window_mut();
|
||||
aw.cursor = anchor + text.len() as u64;
|
||||
aw.selection = None;
|
||||
true
|
||||
}
|
||||
|
||||
/// Ensure the active window carries a
|
||||
/// [`crate::completion::CompletionView`] overlay (deduped by kind).
|
||||
/// The view reads the shared popup, so one instance suffices; it
|
||||
/// renders nothing while the popup is closed.
|
||||
fn ensure_completion_overlay(&mut self) {
|
||||
let popup = self.completion_popup.clone();
|
||||
let wid = self.active_window_id();
|
||||
let win = self.active_window_mut();
|
||||
if !win.overlay_kinds().contains(&"completion-popup") {
|
||||
win.push_overlay(Box::new(crate::completion::CompletionView::new(popup, wid)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Safely remove `buffer_id` from the registry. Any window that
|
||||
/// was displaying it is redirected to a fallback buffer (`*scratch*`,
|
||||
/// created on demand) so window state never refers to a missing id.
|
||||
|
|
@ -3072,4 +3247,167 @@ mod tests {
|
|||
assert!(!s.search_is_regex());
|
||||
assert_eq!(s.search_match_summary().1, 1);
|
||||
}
|
||||
|
||||
// ---- in-buffer completion popup (Arc 1a) --------------------------------
|
||||
|
||||
fn text_of(s: &EditorCore) -> String {
|
||||
let id = s.active_buffer_id();
|
||||
let reg = s.registry.borrow();
|
||||
let buf = reg.get(id).expect("active buffer present");
|
||||
let mut bytes = vec![0u8; buf.len() as usize];
|
||||
if !bytes.is_empty() {
|
||||
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
|
||||
}
|
||||
String::from_utf8(bytes).expect("test buffers are UTF-8")
|
||||
}
|
||||
|
||||
fn open_popup(s: &mut EditorCore, anchor: u64, prefix: &str, insert_text: &str) {
|
||||
let state = crate::completion::CompletionPopupState::new(
|
||||
s.active_buffer_id(),
|
||||
anchor,
|
||||
prefix.to_owned(),
|
||||
vec![crate::completion::PopupCandidate {
|
||||
label: insert_text.to_owned(),
|
||||
kind: crate::completion::CompletionItemKind::Text,
|
||||
detail: None,
|
||||
insert_text: insert_text.to_owned(),
|
||||
}],
|
||||
1,
|
||||
)
|
||||
.expect("non-empty candidate list");
|
||||
s.completion_popup_open(state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_popup_open_attaches_self_suppressing_overlay() {
|
||||
let mut s = from_bytes(b"he\n");
|
||||
s.active_window_mut().cursor = 2;
|
||||
open_popup(&mut s, 0, "he", "hello");
|
||||
assert!(s.completion_popup_is_open());
|
||||
assert!(
|
||||
s.active_window()
|
||||
.overlay_kinds()
|
||||
.contains(&"completion-popup")
|
||||
);
|
||||
// Re-opening dedups the overlay by kind.
|
||||
open_popup(&mut s, 0, "he", "hello");
|
||||
let kinds = s.active_window().overlay_kinds();
|
||||
assert_eq!(
|
||||
kinds.iter().filter(|k| **k == "completion-popup").count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_popup_validate_survives_word_growth_and_empty_prefix() {
|
||||
let mut s = from_bytes(b"he world\n");
|
||||
s.active_window_mut().cursor = 2;
|
||||
open_popup(&mut s, 0, "he", "hello");
|
||||
s.completion_popup_validate();
|
||||
assert!(s.completion_popup_is_open(), "prefix `he` holds");
|
||||
// Typing extends the word: still valid.
|
||||
s.insert_char('l');
|
||||
s.completion_popup_validate();
|
||||
assert!(s.completion_popup_is_open(), "prefix `hel` holds");
|
||||
// Trigger-char shape (cursor == anchor, empty prefix) holds too.
|
||||
s.completion_popup_close();
|
||||
s.active_window_mut().cursor = 2;
|
||||
open_popup(&mut s, 2, "", "llo");
|
||||
s.completion_popup_validate();
|
||||
assert!(s.completion_popup_is_open(), "empty prefix at anchor holds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_popup_validate_closes_when_invariant_breaks() {
|
||||
let mut s = from_bytes(b"he world\n");
|
||||
s.active_window_mut().cursor = 2;
|
||||
open_popup(&mut s, 0, "he", "hello");
|
||||
// Cursor moved past the word: `[anchor..cursor]` spans a space.
|
||||
s.active_window_mut().cursor = 4;
|
||||
s.completion_popup_validate();
|
||||
assert!(!s.completion_popup_is_open(), "non-word bytes close it");
|
||||
|
||||
// Cursor moved before the anchor.
|
||||
s.active_window_mut().cursor = 2;
|
||||
open_popup(&mut s, 2, "", "x");
|
||||
s.active_window_mut().cursor = 1;
|
||||
s.completion_popup_validate();
|
||||
assert!(!s.completion_popup_is_open(), "cursor < anchor closes it");
|
||||
|
||||
// Session bound to a buffer that is not the active one.
|
||||
let other = s.registry.borrow_mut().create("*other*");
|
||||
let state = crate::completion::CompletionPopupState::new(
|
||||
other,
|
||||
0,
|
||||
String::new(),
|
||||
vec![crate::completion::PopupCandidate {
|
||||
label: "x".into(),
|
||||
kind: crate::completion::CompletionItemKind::Text,
|
||||
detail: None,
|
||||
insert_text: "x".into(),
|
||||
}],
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
s.completion_popup_open(state);
|
||||
s.completion_popup_validate();
|
||||
assert!(!s.completion_popup_is_open(), "wrong buffer closes it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_popup_accept_replaces_prefix_as_one_undo_step() {
|
||||
let mut s = from_bytes(b"he and more\n");
|
||||
s.active_window_mut().cursor = 2;
|
||||
open_popup(&mut s, 0, "he", "hello_world");
|
||||
assert!(s.completion_popup_accept());
|
||||
assert_eq!(text_of(&s), "hello_world and more\n");
|
||||
assert_eq!(s.active_window().cursor, 11);
|
||||
assert!(!s.completion_popup_is_open(), "accept closes the popup");
|
||||
// Q#C7: the replace is a single edit — one undo restores the
|
||||
// original text (not an intermediate delete-then-insert state).
|
||||
s.undo();
|
||||
assert_eq!(text_of(&s), "he and more\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_popup_accept_empty_prefix_inserts_at_anchor() {
|
||||
let mut s = from_bytes(b"x.\n");
|
||||
s.active_window_mut().cursor = 2;
|
||||
open_popup(&mut s, 2, "", "method");
|
||||
assert!(s.completion_popup_accept());
|
||||
assert_eq!(text_of(&s), "x.method\n");
|
||||
assert_eq!(s.active_window().cursor, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_popup_accept_is_noop_when_session_stale() {
|
||||
let mut s = from_bytes(b"he world\n");
|
||||
s.active_window_mut().cursor = 2;
|
||||
open_popup(&mut s, 0, "he", "hello");
|
||||
// Simulate a race: the cursor left the word before accept ran.
|
||||
s.active_window_mut().cursor = 5;
|
||||
assert!(!s.completion_popup_accept());
|
||||
assert_eq!(text_of(&s), "he world\n", "buffer untouched");
|
||||
assert!(!s.completion_popup_is_open(), "stale accept still closes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_popup_validate_closes_on_window_focus_change() {
|
||||
// Two splits on the SAME buffer: the session is window-scoped,
|
||||
// so moving focus (buffer unchanged!) must invalidate it ---
|
||||
// this is also what keeps the persistent overlay in the other
|
||||
// split from painting a popup it doesn't own.
|
||||
let mut s = from_bytes(b"he world\n");
|
||||
s.split_active(Orientation::Horizontal, true);
|
||||
s.active_window_mut().cursor = 2;
|
||||
open_popup(&mut s, 0, "he", "hello");
|
||||
s.completion_popup_validate();
|
||||
assert!(s.completion_popup_is_open(), "session holds in its window");
|
||||
s.focus_next();
|
||||
s.completion_popup_validate();
|
||||
assert!(
|
||||
!s.completion_popup_is_open(),
|
||||
"focus change closes the session even with the same buffer"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ impl LuaHost {
|
|||
/// or loading the builtin chunks.
|
||||
pub fn attach_editor(&mut self, core: &SharedCore) -> mlua::Result<()> {
|
||||
lua_bindings::install_editor(&self.lua, core)?;
|
||||
lua_bindings::install_completion_popup(&self.lua, core)?;
|
||||
self.core = Some(core.clone());
|
||||
// Hooks first: command bodies in default.lua reference them.
|
||||
self.load_builtin(
|
||||
|
|
|
|||
|
|
@ -8165,7 +8165,14 @@ fn completion_item_to_lua(lua: &Lua, item: &CompletionItem) -> mlua::Result<Tabl
|
|||
#[allow(clippy::too_many_lines, reason = "linear list of raw bindings")]
|
||||
pub fn install_completion(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
|
||||
let pmacs: Table = lua.globals().get("pmacs")?;
|
||||
let m = lua.create_table()?;
|
||||
// Merge into an existing `pmacs.completion` (the popup surface
|
||||
// installs at editor-attach time, before the LSP manager exists)
|
||||
// instead of clobbering it --- the same idiom as
|
||||
// `install_completion_framework`.
|
||||
let m: Table = match pmacs.get::<Option<Table>>("completion")? {
|
||||
Some(t) => t,
|
||||
None => lua.create_table()?,
|
||||
};
|
||||
|
||||
{
|
||||
let mgr = manager.clone();
|
||||
|
|
@ -9638,7 +9645,7 @@ fn lua_table_to_completion_item(t: &Table) -> mlua::Result<crate::completion::Co
|
|||
}
|
||||
|
||||
fn ctx_to_lua(lua: &Lua, ctx: &CompletionContext) -> mlua::Result<Table> {
|
||||
let t = lua.create_table_with_capacity(0, 7)?;
|
||||
let t = lua.create_table_with_capacity(0, 8)?;
|
||||
t.set("prefix", ctx.prefix.as_str())?;
|
||||
t.set("line", ctx.line)?;
|
||||
t.set("col", ctx.col)?;
|
||||
|
|
@ -9649,6 +9656,9 @@ fn ctx_to_lua(lua: &Lua, ctx: &CompletionContext) -> mlua::Result<Table> {
|
|||
if let Some(p) = &ctx.project_root {
|
||||
t.set("project_root", p.display().to_string())?;
|
||||
}
|
||||
if let Some(u) = &ctx.uri {
|
||||
t.set("uri", u.as_str())?;
|
||||
}
|
||||
let (trigger_tag, trigger_char): (&'static str, Option<String>) = match ctx.trigger {
|
||||
CompletionTrigger::Invoked => ("invoked", None),
|
||||
CompletionTrigger::Char(c) => ("char", Some(c.to_string())),
|
||||
|
|
@ -9677,6 +9687,7 @@ fn lua_table_to_ctx(t: &Table) -> CompletionContext {
|
|||
Some("incomplete") => CompletionTrigger::Incomplete,
|
||||
_ => CompletionTrigger::Invoked,
|
||||
};
|
||||
let uri: Option<String> = t.get::<Option<String>>("uri").ok().flatten();
|
||||
CompletionContext {
|
||||
prefix,
|
||||
line,
|
||||
|
|
@ -9685,6 +9696,7 @@ fn lua_table_to_ctx(t: &Table) -> CompletionContext {
|
|||
language,
|
||||
project_root: project_root.map(std::path::PathBuf::from),
|
||||
trigger,
|
||||
uri,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -9949,11 +9961,22 @@ pub fn install_completion_framework(
|
|||
// ctx_to_lua exposed as `pmacs.completion.context_for(...)`
|
||||
// for callers that need to construct a context table from
|
||||
// primitives. Convenience only --- callers can build their
|
||||
// own.
|
||||
// own. Trailing optionals: a "char" trigger needs its
|
||||
// `trigger_char` (Q#C1 nit --- the helper previously could
|
||||
// not express `CompletionTrigger::Char` at all), and `uri`
|
||||
// scopes URI-keyed providers (Q#C8).
|
||||
m.set(
|
||||
"context_for",
|
||||
lua.create_function(move |lua, args: ContextForArgs| {
|
||||
let (prefix, line, col, buffer_text, language, project_root, trigger) = args;
|
||||
let (prefix, line, col, buffer_text, language, project_root, trigger, ch, uri) =
|
||||
args;
|
||||
let trigger = match trigger.as_deref() {
|
||||
Some("incomplete") => CompletionTrigger::Incomplete,
|
||||
Some("char") => ch
|
||||
.and_then(|s| s.chars().next())
|
||||
.map_or(CompletionTrigger::Invoked, CompletionTrigger::Char),
|
||||
_ => CompletionTrigger::Invoked,
|
||||
};
|
||||
let ctx = CompletionContext {
|
||||
prefix,
|
||||
line: line.unwrap_or(0),
|
||||
|
|
@ -9961,11 +9984,8 @@ pub fn install_completion_framework(
|
|||
buffer_text: Rc::from(buffer_text.unwrap_or_default()),
|
||||
language,
|
||||
project_root: project_root.map(std::path::PathBuf::from),
|
||||
trigger: if trigger.as_deref() == Some("incomplete") {
|
||||
CompletionTrigger::Incomplete
|
||||
} else {
|
||||
CompletionTrigger::Invoked
|
||||
},
|
||||
trigger,
|
||||
uri,
|
||||
};
|
||||
ctx_to_lua(lua, &ctx)
|
||||
})?,
|
||||
|
|
@ -10007,6 +10027,97 @@ pub fn make_completion_framework(
|
|||
Ok((registry, snippets))
|
||||
}
|
||||
|
||||
/// Install the in-buffer completion popup surface (Arc 1a, Q#C2) into
|
||||
/// `pmacs.completion`: `popup_show{...}` publishes a session into the
|
||||
/// core's shared popup (the Lua driver's write path), `popup_hide()`
|
||||
/// closes it, `popup_visible()` peeks. Separate from
|
||||
/// [`install_completion_framework`] because these need the
|
||||
/// [`SharedCore`], which only exists once the editor attaches.
|
||||
pub fn install_completion_popup(lua: &Lua, core: &SharedCore) -> mlua::Result<()> {
|
||||
let pmacs: Table = lua.globals().get("pmacs")?;
|
||||
let m: Table = match pmacs.get::<Option<Table>>("completion")? {
|
||||
Some(t) => t,
|
||||
None => lua.create_table()?,
|
||||
};
|
||||
|
||||
{
|
||||
// popup_show{ buffer, anchor, prefix?, total?, candidates = {
|
||||
// { label, kind?, detail?, insert_text? }, ... } } -> bool
|
||||
//
|
||||
// Returns false (popup left closed) for an empty candidate
|
||||
// list. `kind` uses the same string tags as
|
||||
// `pmacs.completion.collect` rows, so driver code can pass
|
||||
// collect() output straight through.
|
||||
let cc = core.clone();
|
||||
m.set(
|
||||
"popup_show",
|
||||
lua.create_function(move |_, spec: Table| {
|
||||
let buffer: BufferIdLua = spec.get("buffer")?;
|
||||
let anchor: u64 = spec.get("anchor")?;
|
||||
let prefix: String = spec
|
||||
.get::<Option<String>>("prefix")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let rows: Table = spec.get("candidates")?;
|
||||
let mut candidates = Vec::new();
|
||||
for row in rows.sequence_values::<Table>() {
|
||||
let row = row?;
|
||||
let label: String = row.get("label")?;
|
||||
let kind_tag: Option<String> = row.get::<Option<String>>("kind").ok().flatten();
|
||||
let kind = kind_tag.as_deref().map_or(
|
||||
crate::completion::CompletionItemKind::Text,
|
||||
completion_kind_from_tag,
|
||||
);
|
||||
let detail: Option<String> = row.get::<Option<String>>("detail").ok().flatten();
|
||||
let insert_text: Option<String> =
|
||||
row.get::<Option<String>>("insert_text").ok().flatten();
|
||||
candidates.push(crate::completion::PopupCandidate {
|
||||
insert_text: insert_text.unwrap_or_else(|| label.clone()),
|
||||
label,
|
||||
kind,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
let total: usize = spec
|
||||
.get::<Option<usize>>("total")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(candidates.len());
|
||||
let Some(state) = crate::completion::CompletionPopupState::new(
|
||||
buffer.0, anchor, prefix, candidates, total,
|
||||
) else {
|
||||
return Ok(false);
|
||||
};
|
||||
cc.borrow_mut().completion_popup_open(state);
|
||||
Ok(true)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let cc = core.clone();
|
||||
m.set(
|
||||
"popup_hide",
|
||||
lua.create_function(move |_, ()| {
|
||||
cc.borrow_mut().completion_popup_close();
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let cc = core.clone();
|
||||
m.set(
|
||||
"popup_visible",
|
||||
lua.create_function(move |_, ()| Ok(cc.borrow().completion_popup_is_open()))?,
|
||||
)?;
|
||||
}
|
||||
|
||||
pmacs.set("completion", m)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Argument tuple passed to a Lua-registered completion provider.
|
||||
/// Positional rather than table-based because the provider closure
|
||||
/// has no `&Lua` to build a table with at call time.
|
||||
|
|
@ -10019,6 +10130,7 @@ type LuaProviderArgs = (
|
|||
Option<String>,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
/// Argument tuple for `pmacs.completion.context_for`: positional
|
||||
|
|
@ -10031,6 +10143,8 @@ type ContextForArgs = (
|
|||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
/// mlua doesn't accept arbitrary Rust types as call arguments
|
||||
|
|
@ -10053,6 +10167,9 @@ fn lua_compat_ctx_args(ctx: &CompletionContext) -> LuaProviderArgs {
|
|||
ctx.project_root.as_ref().map(|p| p.display().to_string()),
|
||||
trigger_tag.to_owned(),
|
||||
trigger_char,
|
||||
// Trailing addition (Q#C8): existing Lua providers that
|
||||
// ignore the ninth positional arg are unaffected.
|
||||
ctx.uri.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -175,7 +175,17 @@ impl PoolShared {
|
|||
/// thread).
|
||||
pub struct WorkerPool {
|
||||
shared: Arc<PoolShared>,
|
||||
workers: Vec<JoinHandle<()>>,
|
||||
/// Join handles, drained exactly once by [`Self::shutdown`]
|
||||
/// (directly or via `Drop`). Behind a `Mutex` so shutdown works
|
||||
/// from a shared reference: the pool's owner is typically an
|
||||
/// `Rc<AsyncRuntime>` cloned into Lua closures, and those clones
|
||||
/// form VM reference cycles that keep the `Rc` from ever
|
||||
/// reaching zero --- an embedder that merely *drops* its handle
|
||||
/// would leak every worker thread. `EditorState::drop` calls
|
||||
/// `shutdown()` explicitly instead.
|
||||
workers: Mutex<Vec<JoinHandle<()>>>,
|
||||
/// Thread count at construction (stable across shutdown).
|
||||
size: usize,
|
||||
}
|
||||
|
||||
impl WorkerPool {
|
||||
|
|
@ -194,7 +204,7 @@ impl WorkerPool {
|
|||
next_id: AtomicU64::new(0),
|
||||
parker: (Mutex::new(()), Condvar::new()),
|
||||
});
|
||||
let workers = local_queues
|
||||
let workers: Vec<JoinHandle<()>> = local_queues
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, local)| {
|
||||
|
|
@ -205,7 +215,11 @@ impl WorkerPool {
|
|||
.expect("spawn worker thread")
|
||||
})
|
||||
.collect();
|
||||
Self { shared, workers }
|
||||
Self {
|
||||
shared,
|
||||
size,
|
||||
workers: Mutex::new(workers),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a pool sized at `available_parallelism - 1`, with a
|
||||
|
|
@ -218,10 +232,48 @@ impl WorkerPool {
|
|||
Self::new(cores.saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Number of worker threads owned by this pool.
|
||||
/// Number of worker threads this pool was built with.
|
||||
#[must_use]
|
||||
pub fn size(&self) -> usize {
|
||||
self.workers.len()
|
||||
self.size
|
||||
}
|
||||
|
||||
/// Signal every worker to exit, without joining. Idle (parked)
|
||||
/// workers observe the flag within their 100ms park timeout and
|
||||
/// return; a worker mid-job exits when its job finishes. Queued
|
||||
/// jobs that haven't been picked up are dropped without running;
|
||||
/// jobs dispatched *after* the signal are never picked up.
|
||||
///
|
||||
/// Exists as an explicit method (not just `Drop`) because the
|
||||
/// pool's owning `Rc<AsyncRuntime>` is captured into Lua-VM
|
||||
/// reference cycles and may never be reclaimed --- callers that
|
||||
/// know the editor is going away (`EditorState::drop`) signal the
|
||||
/// threads down regardless.
|
||||
///
|
||||
/// Deliberately does NOT join: a worker can be blocked publishing
|
||||
/// its reply onto the message bus that only the *main thread*
|
||||
/// drains, so a main-thread join here is a deadlock (observed as
|
||||
/// the m4 acceptance suite wedging for hours at teardown). Callers
|
||||
/// that own the whole world and want the join use
|
||||
/// [`Self::shutdown`] (or just drop the pool).
|
||||
pub fn signal_shutdown(&self) {
|
||||
self.shared.shutdown.store(true, Ordering::Release);
|
||||
self.shared.notify_all();
|
||||
}
|
||||
|
||||
/// [`Self::signal_shutdown`] plus a join of every worker thread.
|
||||
/// Idempotent: the second call finds no handles and returns
|
||||
/// immediately. Only safe where no worker can be blocked on the
|
||||
/// caller's own thread (see `signal_shutdown`); `Drop` uses it
|
||||
/// because a pool being dropped has no live bus consumer to
|
||||
/// deadlock against in the bare-pool case.
|
||||
pub fn shutdown(&self) {
|
||||
self.signal_shutdown();
|
||||
let handles: Vec<JoinHandle<()>> =
|
||||
std::mem::take(&mut *self.workers.lock().expect("worker pool mutex poisoned"));
|
||||
for handle in handles {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit `work` to be run on a worker. Returns a [`JobHandle`]
|
||||
|
|
@ -263,18 +315,11 @@ impl WorkerPool {
|
|||
}
|
||||
|
||||
impl Drop for WorkerPool {
|
||||
/// Shutdown semantics: dropping the pool signals every worker
|
||||
/// to exit at its next idle wakeup and joins them. Running
|
||||
/// jobs run to completion (or to their own cancellation
|
||||
/// check); queued jobs that haven't been picked up are dropped
|
||||
/// without running. Tests and callers that want explicit
|
||||
/// shutdown just `drop(pool)`.
|
||||
/// Dropping the pool is an implicit [`Self::shutdown`]: every
|
||||
/// worker is signalled to exit at its next idle wakeup and
|
||||
/// joined. No-op when `shutdown` already ran.
|
||||
fn drop(&mut self) {
|
||||
self.shared.shutdown.store(true, Ordering::Release);
|
||||
self.shared.notify_all();
|
||||
for handle in self.workers.drain(..) {
|
||||
let _ = handle.join();
|
||||
}
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,307 @@
|
|||
//! In-buffer completion popup acceptance (Arc 1a) --- the phase-1
|
||||
//! TUI/core path end-to-end: the Lua driver's Q#C9 auto-open policy,
|
||||
//! the Q#C3 partial dispatcher shadow, and Q#C7 validated accept, all
|
||||
//! driven through `dispatch_key` exactly as a terminal user would.
|
||||
//!
|
||||
//! The LSP provider's request path is covered by the `m4_5` fake-LSP
|
||||
//! suite; these tests run hermetic (dabbrev + custom Lua providers)
|
||||
//! so no server binary is needed.
|
||||
//!
|
||||
//! Framing: docs/in-buffer-completion-framing.md.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::protocol::FrontendId;
|
||||
|
||||
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent {
|
||||
code,
|
||||
modifiers: mods,
|
||||
kind: KeyEventKind::Press,
|
||||
state: KeyEventState::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn type_str(s: &mut EditorState, text: &str) {
|
||||
for ch in text.chars() {
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char(ch), KeyModifiers::NONE),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `(buffer text, popup visible?, cursor)` probed through the Lua
|
||||
/// surface --- the same introspection a user-facing script would use.
|
||||
fn probe(s: &EditorState) -> (String, bool, i64) {
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
local b = pmacs.window.buffer()
|
||||
local text = b:slice(0, b:len())
|
||||
return text, pmacs.completion.popup_visible(), pmacs.editor.cursor()
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("probe buffer/popup state")
|
||||
}
|
||||
|
||||
/// Typing a two-char prefix of an existing buffer word auto-opens the
|
||||
/// popup off dabbrev (Q#C9 single-char signature), and TAB accepts:
|
||||
/// the prefix is replaced by the candidate in one step.
|
||||
#[test]
|
||||
fn typing_opens_popup_and_tab_accepts() {
|
||||
let mut s = EditorState::new();
|
||||
type_str(&mut s, "hello_world ");
|
||||
let (_, visible, _) = probe(&s);
|
||||
assert!(
|
||||
!visible,
|
||||
"no popup while typing the only word in the buffer"
|
||||
);
|
||||
|
||||
type_str(&mut s, "he");
|
||||
let (_, visible, _) = probe(&s);
|
||||
assert!(
|
||||
visible,
|
||||
"prefix `he` with dabbrev match `hello_world` opens"
|
||||
);
|
||||
|
||||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Tab, KeyModifiers::NONE));
|
||||
let (text, visible, cursor) = probe(&s);
|
||||
assert_eq!(text, "hello_world hello_world", "TAB replaces the prefix");
|
||||
assert!(!visible, "accept closes the popup");
|
||||
assert_eq!(cursor, 23, "cursor lands just past the inserted text");
|
||||
}
|
||||
|
||||
/// C-n moves the highlight before RET accepts, so the second
|
||||
/// candidate wins. Uses a custom provider for a deterministic order.
|
||||
#[test]
|
||||
fn ctrl_n_navigates_then_ret_accepts_second_candidate() {
|
||||
let mut s = EditorState::new();
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
pmacs.completion.register({
|
||||
name = 'test_src',
|
||||
priority = 200,
|
||||
fn = function()
|
||||
return {
|
||||
{ label = 'aardvark', kind = 'text' },
|
||||
{ label = 'aardwolf', kind = 'text' },
|
||||
}
|
||||
end,
|
||||
})
|
||||
",
|
||||
)
|
||||
.exec()
|
||||
.expect("register test provider");
|
||||
|
||||
type_str(&mut s, "aa");
|
||||
let (_, visible, _) = probe(&s);
|
||||
assert!(visible, "custom provider matches the `aa` prefix");
|
||||
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char('n'), KeyModifiers::CONTROL),
|
||||
);
|
||||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let (text, visible, _) = probe(&s);
|
||||
assert_eq!(text, "aardwolf", "C-n selected the second candidate");
|
||||
assert!(!visible);
|
||||
}
|
||||
|
||||
/// Esc dismisses without touching the buffer, and the next printable
|
||||
/// key self-inserts normally (the shadow is partial, not modal).
|
||||
#[test]
|
||||
fn esc_dismisses_and_typing_falls_through() {
|
||||
let mut s = EditorState::new();
|
||||
type_str(&mut s, "hello_world he");
|
||||
let (_, visible, _) = probe(&s);
|
||||
assert!(visible);
|
||||
|
||||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let (text, visible, _) = probe(&s);
|
||||
assert_eq!(text, "hello_world he", "Esc leaves the buffer untouched");
|
||||
assert!(!visible, "Esc dismisses");
|
||||
|
||||
type_str(&mut s, "x");
|
||||
let (text, visible, _) = probe(&s);
|
||||
assert_eq!(text, "hello_world hex", "typing after Esc self-inserts");
|
||||
assert!(!visible, "the dismissed popup does not reopen off that key");
|
||||
}
|
||||
|
||||
/// Motion that breaks the anchor invariant closes the popup via the
|
||||
/// post-dispatch validation (Q#C3): Home moves the cursor before the
|
||||
/// anchor.
|
||||
#[test]
|
||||
fn motion_before_anchor_closes_popup() {
|
||||
let mut s = EditorState::new();
|
||||
type_str(&mut s, "hello_world he");
|
||||
let (_, visible, _) = probe(&s);
|
||||
assert!(visible);
|
||||
|
||||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Home, KeyModifiers::NONE));
|
||||
let (text, visible, _) = probe(&s);
|
||||
assert_eq!(text, "hello_world he");
|
||||
assert!(!visible, "cursor before the anchor invalidates the session");
|
||||
}
|
||||
|
||||
/// A multi-byte edit (kill-ring yank) never auto-opens the popup ---
|
||||
/// the Q#C9 single-char signature rejects paste-shaped deltas.
|
||||
#[test]
|
||||
fn yank_shaped_edit_does_not_auto_open() {
|
||||
let mut s = EditorState::new();
|
||||
type_str(&mut s, "hello_world hello");
|
||||
// Select the trailing word and cut it (C-w): the popup that was
|
||||
// open over `hello` closes as its word dies.
|
||||
for _ in 0..5 {
|
||||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::SHIFT));
|
||||
}
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char('w'), KeyModifiers::CONTROL),
|
||||
);
|
||||
let (text, visible, _) = probe(&s);
|
||||
assert_eq!(text, "hello_world ");
|
||||
assert!(!visible, "cutting the word closes the popup");
|
||||
|
||||
// Yank it back: one edit, five bytes --- not a typing signature.
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char('y'), KeyModifiers::CONTROL),
|
||||
);
|
||||
let (text, visible, _) = probe(&s);
|
||||
assert_eq!(text, "hello_world hello", "yank restored the word");
|
||||
assert!(!visible, "a 5-byte edit must not auto-open the popup");
|
||||
}
|
||||
|
||||
/// `completion.at-point` (C-M-i) opens deliberately, even below the
|
||||
/// auto-open prefix threshold.
|
||||
#[test]
|
||||
fn at_point_command_opens_below_threshold() {
|
||||
let mut s = EditorState::new();
|
||||
type_str(&mut s, "hello_world h");
|
||||
let (_, visible, _) = probe(&s);
|
||||
assert!(!visible, "a 1-char prefix stays below the auto-open bar");
|
||||
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(
|
||||
KeyCode::Char('i'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||||
),
|
||||
);
|
||||
let (_, visible, _) = probe(&s);
|
||||
assert!(visible, "C-M-i opens the popup on demand");
|
||||
|
||||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Tab, KeyModifiers::NONE));
|
||||
let (text, _, _) = probe(&s);
|
||||
assert_eq!(text, "hello_world hello_world");
|
||||
}
|
||||
|
||||
/// A multi-key prefix owns the keyboard: starting `C-x` while the
|
||||
/// popup is open dismisses it, so the sequence's continuation (and a
|
||||
/// `C-g` abort) reaches the dispatcher instead of the popup shadow.
|
||||
#[test]
|
||||
fn pending_prefix_dismisses_popup_and_keeps_dispatcher_control() {
|
||||
let mut s = EditorState::new();
|
||||
type_str(&mut s, "hello_world he");
|
||||
let (_, visible, _) = probe(&s);
|
||||
assert!(visible);
|
||||
|
||||
// C-x starts a prefix: the popup must close immediately...
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
let (text, visible, _) = probe(&s);
|
||||
assert_eq!(text, "hello_world he", "the prefix key edits nothing");
|
||||
assert!(!visible, "a pending prefix dismisses the popup");
|
||||
|
||||
// ...so this C-g aborts the prefix (not a popup), and typing
|
||||
// afterwards self-inserts normally.
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char('g'), KeyModifiers::CONTROL),
|
||||
);
|
||||
type_str(&mut s, "x");
|
||||
let (text, _, _) = probe(&s);
|
||||
assert_eq!(
|
||||
text, "hello_world hex",
|
||||
"after the aborted prefix, keys dispatch normally"
|
||||
);
|
||||
}
|
||||
|
||||
/// LSP-only words must still query the server: when the synchronous
|
||||
/// providers return nothing at auto-open, a pending session is left
|
||||
/// behind and the request fires anyway (previously the request was
|
||||
/// gated on the popup having opened, so an empty dabbrev/snippet/
|
||||
/// index sweep meant the server was never asked).
|
||||
#[test]
|
||||
fn empty_sync_sweep_still_leaves_a_pending_session() {
|
||||
let mut s = EditorState::new();
|
||||
// A buffer whose only word is the one being typed: dabbrev is
|
||||
// structurally empty, no LSP attached. The popup cannot open...
|
||||
type_str(&mut s, "qz");
|
||||
let (_, visible, _) = probe(&s);
|
||||
assert!(!visible, "nothing to show without providers");
|
||||
// ...but the driver's session mirror must be pending rather than
|
||||
// absent, so a (mocked) late LSP arrival could materialize it.
|
||||
// With no attachment at all the request path no-ops; what we can
|
||||
// assert end-to-end is that the state machine stays consistent:
|
||||
// further typing neither crashes nor opens a bogus popup.
|
||||
type_str(&mut s, "q");
|
||||
let (text, visible, _) = probe(&s);
|
||||
assert_eq!(text, "qzq");
|
||||
assert!(!visible);
|
||||
}
|
||||
|
||||
/// The Q#C8 URI plumbing: `ctx.uri` reaches Lua providers as the
|
||||
/// ninth positional arg, so a URI-aware provider can scope itself
|
||||
/// (the BUILT-IN LSP provider is stricter still: no uri → no rows).
|
||||
/// Driven through the Lua collect surface with an emulating provider
|
||||
/// because the real LSP store is Rust-side.
|
||||
#[test]
|
||||
fn collect_scopes_lsp_candidates_to_ctx_uri() {
|
||||
let s = EditorState::new();
|
||||
let (scoped, unscoped): (u64, u64) = s
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
-- Seed the M4.7 LSP store for two URIs through a custom
|
||||
-- provider is not possible (the store is Rust-side), so
|
||||
-- emulate the shape: a URI-aware provider that mirrors
|
||||
-- what the built-in LSP provider does with ctx.uri.
|
||||
pmacs.completion.register({
|
||||
name = 'uri_aware',
|
||||
priority = 150,
|
||||
fn = function(prefix, line, col, text, lang, root, trig, tchar, uri)
|
||||
local by_uri = {
|
||||
['file:///a.rs'] = { { label = 'alpha_from_a' } },
|
||||
['file:///b.rs'] = { { label = 'alpha_from_b' } },
|
||||
}
|
||||
if uri then
|
||||
return by_uri[uri] or {}
|
||||
end
|
||||
local all = {}
|
||||
for _, items in pairs(by_uri) do
|
||||
for _, it in ipairs(items) do all[#all + 1] = it end
|
||||
end
|
||||
return all
|
||||
end,
|
||||
})
|
||||
local scoped = pmacs.completion.collect({
|
||||
prefix = 'alpha', uri = 'file:///a.rs',
|
||||
})
|
||||
local unscoped = pmacs.completion.collect({ prefix = 'alpha' })
|
||||
return #scoped, #unscoped
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("collect with and without uri");
|
||||
assert_eq!(scoped, 1, "ctx.uri reaches Lua providers (9th arg)");
|
||||
assert_eq!(unscoped, 2, "no uri → all documents");
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
//! Worker-pool teardown regression: dropping an `EditorState` must
|
||||
//! release its worker threads even though the `Rc<AsyncRuntime>` is
|
||||
//! trapped in Lua-VM reference cycles and never reaches refcount
|
||||
//! zero (`EditorState::drop` → `AsyncRuntime::shutdown_workers`,
|
||||
//! signal-only --- a join here deadlocks against workers blocked
|
||||
//! handing replies to the main thread).
|
||||
//!
|
||||
//! Before the fix, every `EditorState` ever constructed leaked a
|
||||
//! full `cores - 1` worker pool: the m4 acceptance suite (54 editor-
|
||||
//! building tests) accumulated 1000+ live threads, each waking every
|
||||
//! 100ms.
|
||||
//!
|
||||
//! The thread-count probe and the idempotence check share ONE test
|
||||
//! function: they both build `EditorState`s, and as separate tests
|
||||
//! libtest may run them concurrently, polluting the /proc-based
|
||||
//! baseline on high-core machines.
|
||||
|
||||
use pmacs::editor::EditorState;
|
||||
|
||||
/// Thread-count probe via /proc; Linux-only (macOS CI runs the
|
||||
/// non-Linux variant below --- the leak and the fix are platform-
|
||||
/// independent, the *probe* isn't).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn live_threads() -> usize {
|
||||
std::fs::read_dir("/proc/self/task").map_or(0, std::iter::Iterator::count)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn editor_state_drop_releases_workers_and_shutdown_is_idempotent() {
|
||||
let baseline = live_threads();
|
||||
for _ in 0..3 {
|
||||
let s = EditorState::new();
|
||||
drop(s);
|
||||
}
|
||||
// Signal-only shutdown: parked workers exit within their 100ms
|
||||
// park timeout; give them a beat.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
let after = live_threads();
|
||||
assert!(
|
||||
after <= baseline + 2,
|
||||
"worker threads leak across EditorState drop: \
|
||||
baseline {baseline}, after 3 create/drop cycles {after}"
|
||||
);
|
||||
|
||||
// Idempotence: explicit shutdown twice, then drop runs it a
|
||||
// third time --- none may hang or panic.
|
||||
let s = EditorState::new();
|
||||
s.async_runtime.shutdown_workers();
|
||||
s.async_runtime.shutdown_workers();
|
||||
drop(s);
|
||||
}
|
||||
|
||||
/// Platform-independent idempotence check for hosts without /proc.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn explicit_shutdown_is_idempotent() {
|
||||
let s = EditorState::new();
|
||||
s.async_runtime.shutdown_workers();
|
||||
s.async_runtime.shutdown_workers(); // second call must not hang or panic
|
||||
drop(s); // drop runs shutdown a third time
|
||||
}
|
||||
Loading…
Reference in New Issue