//! 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"); }