diff --git a/builtin/runtime/completion.lua b/builtin/runtime/completion.lua new file mode 100644 index 0000000..fa480b8 --- /dev/null +++ b/builtin/runtime/completion.lua @@ -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" } diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 032f3db..61cbef5 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -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() diff --git a/src/completion_framework.rs b/src/completion_framework.rs index c16f6f3..a9761b0 100644 --- a/src/completion_framework.rs +++ b/src/completion_framework.rs @@ -119,6 +119,11 @@ pub struct CompletionContext { pub project_root: Option, /// 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, } 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 { + Box::new(move |ctx: &CompletionContext| -> Vec { 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 = 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()); diff --git a/src/editor.rs b/src/editor.rs index 4967a2e..c913191 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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 diff --git a/src/lua.rs b/src/lua.rs index 91221fc..da6f5fd 100644 --- a/src/lua.rs +++ b/src/lua.rs @@ -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( diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 523acc6..f70f2e9 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -9638,7 +9638,7 @@ fn lua_table_to_completion_item(t: &Table) -> mlua::Result mlua::Result { - 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
{ 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) = 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 = t.get::>("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::>("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::>("prefix") + .ok() + .flatten() + .unwrap_or_default(); + let rows: Table = spec.get("candidates")?; + let mut candidates = Vec::new(); + for row in rows.sequence_values::
() { + let row = row?; + let label: String = row.get("label")?; + let kind_tag: Option = row.get::>("kind").ok().flatten(); + let kind = kind_tag.as_deref().map_or( + crate::completion::CompletionItemKind::Text, + completion_kind_from_tag, + ); + let detail: Option = row.get::>("detail").ok().flatten(); + let insert_text: Option = + row.get::>("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::>("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, Option, + Option, ); /// Argument tuple for `pmacs.completion.context_for`: positional @@ -10031,6 +10136,8 @@ type ContextForArgs = ( Option, Option, Option, + Option, + Option, ); /// 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(), ) }