feat(completion): Lua driver, popup bindings, LSP scoping + flush seams

Q#C1/C9: builtin/runtime/completion.lua reconstructs typing intent
from state (buffer.after-edit has no payload): a {buffer, cursor}
snapshot recognizes the single-byte-advance typing signature, so
paste/undo/kill/remote edits never auto-open; prefix >= 2 opens off
the synchronous providers, server trigger chars open a pending session
that materializes when the LSP answer lands; refresh-on-typing
re-derives the prefix from the text; a core-closed popup suppresses
reopen off the same edit (the accept case). completion.at-point on
C-M-i covers deliberate invocation; the driver filters collect() to
score >= 0 (collect keeps non-matches, merely sorted last).

Q#C8: CompletionContext gains uri; the built-in LSP provider scopes to
it (legacy global drain only when absent); Lua providers get uri as a
trailing ninth positional arg; context_for can now express char
triggers + uri. pmacs.lsp.attachment_for_request() exposes the
flushing accessor (attached_for_active) so completion requests answer
against current text, not the debounced didChange backlog.

Q#C2 write path: pmacs.completion.popup_show/popup_hide/popup_visible
publish into the core session (kind tags shared with collect() rows,
so driver code passes rows straight through).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-07 12:09:25 -04:00
parent 361cc542c7
commit 53d771a935
6 changed files with 388 additions and 12 deletions

View File

@ -0,0 +1,228 @@
-- 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 not publish(buf, session.anchor, prefix, "incomplete", nil) then
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
if publish(buf, cursor - #prefix, prefix, "invoked", nil) then
request_lsp_then_refresh()
end
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" }

View File

@ -544,6 +544,19 @@ function pmacs.lsp.active_attachment()
return attachments[tostring(buf)]
end
-- Flushing variant for request-issuing callers outside this file
-- (Q#C8): resolves (or attaches) the active buffer's server AND
-- flushes any debounced didChange first, so the server answers the
-- caller's request against the current text --- exactly what every
-- interactive command in this file gets from the local
-- `attached_for_active`. The in-buffer completion driver
-- (builtin/runtime/completion.lua) calls this before
-- textDocument/completion; a non-flushing peek would hand the server
-- stale text after a typing burst.
function pmacs.lsp.attachment_for_request()
return attached_for_active()
end
-- Hooks --------------------------------------------------------------------
pmacs.hook.add("buffer.after-load", function()

View File

@ -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,15 @@ 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. With `ctx.uri` set (Q#C8 scoping, the popup driver's path)
/// only that document's entries surface --- across all servers keyed
/// to it --- so a popup never shows another buffer's candidates.
/// Without a URI the legacy global drain across every cached
/// `(server_id, uri)` key applies. 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 store_handle = {
let mgr = lsp.borrow();
mgr.completion_store()
@ -631,7 +641,11 @@ 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| ctx.uri.as_ref().is_none_or(|uri| k.uri == *uri))
.cloned()
.collect();
for key in keys {
for item in store.items(&key) {
out.push(item.clone());

View File

@ -256,6 +256,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

View File

@ -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(

View File

@ -9638,7 +9638,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 +9649,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 +9680,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 +9689,7 @@ fn lua_table_to_ctx(t: &Table) -> CompletionContext {
language,
project_root: project_root.map(std::path::PathBuf::from),
trigger,
uri,
}
}
@ -9949,11 +9954,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 +9977,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 +10020,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 +10123,7 @@ type LuaProviderArgs = (
Option<String>,
String,
Option<String>,
Option<String>,
);
/// Argument tuple for `pmacs.completion.context_for`: positional
@ -10031,6 +10136,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 +10160,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(),
)
}