test(completion): popup acceptance suite; fix pmacs.completion table clobber

Seven end-to-end tests through dispatch_key: dabbrev auto-open + TAB
accept, C-n/RET second-candidate accept, Esc dismiss with fall-through
typing (and no same-edit reopen), Home-breaks-anchor validation close,
yank-shaped edits never auto-open (Q#C9), C-M-i below the threshold,
and ctx.uri scoping through the Lua provider surface.

The suite immediately caught a real wiring bug: install_completion
(M4.7, runs at make_lsp_manager time) built pmacs.completion with a
fresh lua.create_table(), clobbering the popup bindings installed at
editor-attach time --- popup_visible was nil at runtime and the driver
hook errored silently into *errors* on every edit. It now merges into
the existing table, the same idiom as install_completion_framework,
so installer order no longer matters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-07 12:49:47 -04:00
parent 53d771a935
commit c01cfd1e93
2 changed files with 257 additions and 1 deletions

View File

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

View File

@ -0,0 +1,249 @@
//! 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");
}
/// The Q#C8 URI scoping: with `ctx.uri` set, the LSP provider only
/// surfaces the matching document's cached items; without it, the
/// legacy global drain applies. Driven through the Lua collect
/// surface against a hand-seeded store.
#[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");
}