Skip to main content

lattice_ui_tui/app/
completion.rs

1//! Insert-mode completion popup state machine -- the
2//! in-buffer completion UI's selection / cancel / docs-scroll
3//! surface. The popup is a minor mode whose chord layer
4//! (`<C-n>` / `<C-p>` / `<C-d>` / `<C-f>` / `<C-b>`) is the
5//! main actor here.
6//!
7//! Methods that live here:
8//! - `do_completion_next` / `do_completion_prev` -- popup
9//!   selection navigation; both hook through the docs-popup
10//!   refresh when documentation is open.
11//! - `do_completion_docs_scroll_down` /
12//!   `do_completion_docs_scroll_up` -- page the side docs
13//!   panel.
14//! - `do_completion_cancel` -- close the popup, clear the
15//!   path-context flag.
16//! - `refresh_docs_popup_for_selection` (private helper) --
17//!   re-targets the docs popup when the selection changes
18//!   (fires `completionItem/resolve` when the new
19//!   candidate has no cached body).
20//!
21//! What does NOT live here at all: the completion provider
22//! registry, source plugins, snippet parser -- those live
23//! in `crate::completion` / `crate::snippet`. The actual
24//! popup state machine (`populate_*`, `refilter_*`,
25//! `do_completion_trigger`, accept paths, `expand_snippet`,
26//! LSP request/drain/apply) lives host-side in
27//! `lattice_host::dispatch::Editor`; the methods in this file
28//! are renderer-thread delegates that route through the
29//! `mutate_editor` / `read_editor` seam.
30
31use super::App;
32
33// Slice 3c.final.E.5g: `Position`, `SnippetCandidateMeta`, and
34// `EffectiveCompletionConfig` are reached only from the
35// `#[cfg(test)] impl App` block + the `mod tests` block below.
36#[cfg(test)]
37use super::SnippetCandidateMeta;
38#[cfg(test)]
39use lattice_host::dispatch::EffectiveCompletionConfig;
40
41impl App {
42    // SN.3c.1 (2026-06-14): the `do_snippet_expand_at_cursor` App
43    // delegate is gone. `<C-x><C-s>` is mode-owned now — `snippet-mode`
44    // contributes the chord (`keymap()`) + a global handler
45    // (`action_handlers()`) that emits `Effect::ExpandSnippet`; the
46    // host resolves + expands via `Editor::expand_snippet_from_range`,
47    // which the `Effect::ExpandSnippet` apply arm calls directly.
48
49    // SN.2b (2026-06-12): the `do_snippet_next/prev_placeholder`
50    // App delegates are gone. `<Tab>` / `<S-Tab>` placeholder
51    // navigation is mode-owned — `active-snippet-mode` registers
52    // `ActionContext -> Effect` closures on the
53    // `ActionHandlerRegistry`, and the real chord flows through
54    // the host's generic dispatch → registry-lookup path (the
55    // same path the project-search `<CR>` / `gr` chords use). The
56    // authoritative handler-level test lives in
57    // `lattice_snippet::modes`.
58
59    /// `:reload-snippets` -- 5.8.AF.3: body migrated to
60    /// [`lattice_host::dispatch::Editor::do_reload_snippets`].
61    pub fn do_reload_snippets(&mut self) {
62        self.mutate_editor(|e| e.do_reload_snippets());
63    }
64
65    // 5.5.H: `move_cursor_to_snippet_group` retired (zero
66    // callers; `Editor::move_cursor_to_snippet_group` host-side
67    // is the live copy used by the migrated snippet-nav arms).
68}
69
70impl App {
71    pub fn do_completion_next(&mut self) {
72        // Phase 5.8.AD.4: body migrated.
73        self.mutate_editor(|e| e.do_completion_next());
74    }
75
76    pub fn do_completion_prev(&mut self) {
77        // Phase 5.8.AD.4: body migrated.
78        self.mutate_editor(|e| e.do_completion_prev());
79    }
80
81    // 5.5.G.14: `do_completion_docs_scroll_down` /
82    // `do_completion_docs_scroll_up` migrated to
83    // [`lattice_host::dispatch::Editor`].
84
85    // Slice 3c.final.E.5g: `populate_insert_completion_sync` +
86    // `refilter_insert_completion` App-side delegates retired.
87    // `populate_*` had no callers (host invokes its own version
88    // inside `Editor::do_completion_trigger`); `refilter_*` is
89    // test-fixture surface, now in the `#[cfg(test)] impl App`
90    // block below.
91
92    // Phase 5.8.AD.4: `completion_total_bonus` migrated to host.
93
94    // Phase 5.8.AD.4: `priority_for_source` migrated to host.
95
96    /// Manual trigger / refresh. Opens the popup if it's
97    /// closed; refreshes raw + rendered candidates if it's
98    /// already open. Sources contributing today: buffer-words.
99    /// LSP / snippets / path / tree-sitter follow in 4.2.g.2+.
100    pub fn do_completion_trigger(&mut self) {
101        // Phase 5.8.AD.4: body migrated.
102        self.mutate_editor(|e| e.do_completion_trigger());
103    }
104
105    // Slice 3c.final.E.5g: `do_completion_accept_then_insert`
106    // App-side delegate moved to `#[cfg(test)] impl App` below
107    // — only test fixtures call it; production path goes through
108    // the host directly inside `Editor::dispatch`.
109
110    /// Suffix of the top-ranked completion candidate that would
111    /// extend the user's current query, or `None` when the
112    /// renderer should paint nothing (Phase 4.2.g.7 ghost-text
113    /// polish). Returned suffix is the part of the candidate
114    /// `text` BEYOND the case-insensitive prefix-match against
115    /// `state.query`. Phase 5.8.AD.4: migrated.
116    ///
117    /// Returns `None` when:
118    /// - `completion.ghost_text` option is off (default).
119    /// - The popup is closed.
120    /// - The top-ranked candidate doesn't case-insensitively
121    ///   prefix-match the query.
122    /// - The popup is in path-completion mode (filenames are
123    ///   already shown in full inside the string literal --
124    ///   ghost would double up).
125    /// - The query is empty (an empty popup just lists
126    ///   everything; ghosting the first arbitrary candidate
127    ///   would surprise the user).
128    pub fn completion_ghost_text_suffix(&self) -> Option<String> {
129        self.read_editor(move |e| e.completion_ghost_text_suffix())
130    }
131
132    // Phase 5.8.AD.4: second `completion_ghost_text_suffix` body
133    // retired (delegate above suffices).
134    // Phase 5.8.AD.4: `effective_commit_chars_for` migrated to host.
135
136    /// Accept the focused candidate. Three routing paths:
137    /// 1. **Snippet candidate** (sync source `gen:snippet` or
138    ///    LSP item with `insertTextFormat == Snippet`):
139    ///    expand the body via `lattice-snippet`, splice the
140    ///    rendered text, start an `ActiveSnippet`.
141    /// 2. **LSP candidate**: apply the LSP-shaped insert
142    ///    (`textEdit` range when present) plus any
143    ///    `additionalTextEdits` as one undo unit.
144    /// 3. **Sync-source candidate**: simple replace-`[anchor,
145    ///    cursor]` splice.
146    pub fn do_completion_accept(&mut self) {
147        // Phase 5.8.AD.4: body migrated.
148        self.mutate_editor(|e| e.do_completion_accept());
149    }
150
151    /// `<C-d>` inside the completion-popup minor mode.
152    /// Toggles the side documentation popup. When opening,
153    /// pre-fills `body` from the focused candidate's cached
154    /// metadata when available; fires
155    /// `completionItem/resolve` when the documentation is
156    /// missing AND the originating server advertises the
157    /// resolve provider.
158    pub fn do_completion_toggle_docs(&mut self) {
159        // Phase 5.8.AD.4: body migrated.
160        self.mutate_editor(|e| e.do_completion_toggle_docs());
161    }
162
163    /// Build the docs body for the popup's currently-focused
164    /// candidate. Phase 5.8.AD.4: migrated.
165    pub(super) fn docs_body_for_selected(&self) -> Option<String> {
166        self.read_editor(move |e| e.docs_body_for_selected())
167    }
168
169    /// True when the focused candidate needs resolve. Phase 5.8.AD.4.
170    pub(super) fn selected_needs_resolve(&self) -> bool {
171        self.read_editor(move |e| e.selected_needs_resolve())
172    }
173
174    // Slice 3c.final.E.5g:
175    // `maybe_refresh_insert_completion_after_edit` App-side
176    // delegate retired — zero callers anywhere. The host
177    // version (`Editor::maybe_refresh_insert_completion_after_edit`)
178    // is invoked directly inside `Editor::dispatch` after edit
179    // application; LSP `isIncomplete` follow-up Actions flow
180    // through `DispatchOutcome::next_actions` on the dispatch
181    // tail like every other deferred action.
182
183    // 5.5.G.14: body migrated to
184    // [`lattice_host::dispatch::Editor::do_completion_cancel`].
185    // Kept as a delegate -- a handful of LSP-side and App-side
186    // paths still call `do_completion_cancel` directly
187    // (`run_invocation` exit, `<Esc>` flush, snippet abort).
188    #[allow(dead_code)]
189    pub fn do_completion_cancel(&mut self) {
190        self.mutate_editor(|e| e.do_completion_cancel());
191    }
192
193    /// CSM.K2: restrict the open completion popup to a single
194    /// source. `id` is the `SourceId` as a raw string (e.g.
195    /// `"gen:buffer-words"`, `"gen:lsp-completion"`). When the
196    /// referenced source has no candidates in the current
197    /// `state.raw`, refilter yields an empty rendered list --
198    /// the popup stays open so the user can switch chords or
199    /// clear the filter without losing the trigger context.
200    pub fn do_completion_filter_to_source(&mut self, id: String) {
201        // Phase 5.8.AD.4: body migrated.
202        self.mutate_editor(move |e| e.do_completion_filter_to_source(id));
203    }
204
205    /// CSM.K2: clear the active source filter. Phase 5.8.AD.4.
206    pub fn do_completion_filter_clear(&mut self) {
207        self.mutate_editor(|e| e.do_completion_filter_clear());
208    }
209
210    // Phase 5.8.AD.4: `refresh_docs_popup_for_selection`
211    // migrated to host as a private helper.
212
213    /// Build a `VariableContext` for snippet expansion from
214    /// the active buffer / cursor / clipboard / etc. 5.5.SNIPPET.1:
215    /// body migrated to
216    /// [`lattice_host::dispatch::Editor::snippet_variable_context`].
217    pub(super) fn snippet_variable_context(&self) -> lattice_snippet::VariableContext {
218        self.read_editor(move |e| e.snippet_variable_context())
219    }
220
221    // Slice 3c.final.E.5g: `snippet_meta_for` App-side delegate
222    // moved to `#[cfg(test)] impl App` below — only test fixtures
223    // call it; production path is `Editor::snippet_meta_for`
224    // invoked from inside `Editor::do_completion_accept`.
225
226    // Slice 3c.final.E.5g: `expand_snippet_with_lsp_edits` +
227    // `expand_snippet` App-side delegates retired — zero callers
228    // anywhere. The host versions are invoked directly from
229    // `Editor::do_completion_accept` and the snippet picker
230    // accept arm.
231
232    /// Active buffer's snippet language id. 5.5.SNIPPET.1: body
233    /// migrated to
234    /// [`lattice_host::dispatch::Editor::active_language_id`].
235    /// Delegate retained for App-side completion / LSP callers.
236    pub(super) fn active_language_id(&self) -> String {
237        self.read_editor(move |e| e.active_language_id())
238    }
239
240    // Slice 3c.final.E.5g: `effective_completion_for` App-side
241    // delegate moved to `#[cfg(test)] impl App` below — host-side
242    // call sites (`Editor::do_completion_trigger`,
243    // `Editor::do_completion_accept`, etc.) reach the host
244    // method directly; only test fixtures need an App-side
245    // delegate.
246}
247
248// Slice 3c.final.E.5g — test-fixture surface.
249//
250// These four delegates have no production callers; their bodies
251// are reached directly inside `Editor::dispatch` for the
252// production paths. Tests still need to poke the host method
253// against a fully-built `App`, so the delegates survive behind a
254// `#[cfg(test)]` gate. Once `App.editor: Editor` becomes
255// `App.editor_actor: EditorActorHandle` (the slice-E.swap
256// follow-up), each body flips to read through the audit's
257// planned `App::editor()` / `App::editor_mut()` cfg-gated
258// accessors.
259#[cfg(test)]
260impl App {
261    pub(super) fn refilter_insert_completion(
262        &self,
263        state: &mut lattice_completion::InsertCompletionState,
264    ) {
265        self.editor.refilter_insert_completion(state);
266    }
267
268    pub fn do_completion_accept_then_insert(&mut self, ch: char) {
269        let mut out = lattice_host::dispatch::DispatchOutcome::default();
270        self.editor.do_completion_accept_then_insert(ch, &mut out);
271        for follow_up in std::mem::take(&mut out.next_actions) {
272            self.apply(follow_up);
273        }
274    }
275
276    pub(super) fn snippet_meta_for(
277        &self,
278        candidate: &lattice_completion::RenderedCandidate,
279    ) -> Option<SnippetCandidateMeta> {
280        self.editor.snippet_meta_for(candidate)
281    }
282
283    pub(crate) fn effective_completion_for(&self, language: &str) -> EffectiveCompletionConfig {
284        self.editor.effective_completion_for(language)
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    #![allow(clippy::unwrap_used, clippy::panic)]
291
292    use crate::app::completion_kind_glyph;
293    use crate::app::test_helpers::{
294        app_in_command_mode, app_with, app_with_path, fresh_path_workspace, install_snippet,
295        open_popup_with_top_text, press, press_chars, set_rust_syntax,
296    };
297    use crate::app::*;
298
299    #[test]
300    fn auto_insert_single_default_is_on() {
301        let a = app_with("xx", 10);
302        assert!(a.completion_auto_insert_single());
303    }
304
305    #[test]
306    fn auto_insert_single_replaces_command_line_for_one_candidate() {
307        // `:set foldmethod=ind` is a unique fuzzy match against the
308        // four enumerated `foldmethod=*` values (manual / indent /
309        // markdown / syntax) -- only `foldmethod=indent` survives.
310        // Tab should auto-insert it without opening a popup.
311        let mut a = app_in_command_mode("set foldmethod=ind");
312        assert!(a.completion_auto_insert_single(), "default should be on");
313        a.apply(Action::CommandLineCompleteOrAdvance);
314        assert!(
315            a.editor.completion_state.is_none(),
316            "popup must not open when the only candidate auto-inserts"
317        );
318        assert_eq!(a.editor.command_line(), "set foldmethod=indent");
319    }
320
321    #[test]
322    fn auto_insert_single_off_keeps_popup_for_one_candidate() {
323        // Disabling reverts to "always show popup, even with one row".
324        let mut a = app_in_command_mode("set foldmethod=ind");
325        a.set_completion_auto_insert_single_for_test(false);
326        a.apply(Action::CommandLineCompleteOrAdvance);
327        let state = a
328            .editor
329            .completion_state
330            .as_ref()
331            .expect("popup should open when option is off");
332        assert_eq!(state.candidates.len(), 1);
333        assert_eq!(
334            a.editor.command_line(),
335            "set foldmethod=ind",
336            "cmdline must not change until user confirms"
337        );
338    }
339
340    #[test]
341    fn auto_insert_single_does_not_fire_for_multiple_candidates() {
342        // Multiple matches → popup opens whether or not the option
343        // is on. The auto-insert path is only the one-candidate case.
344        let mut a = app_in_command_mode("descri");
345        a.apply(Action::CommandLineCompleteOrAdvance);
346        let state = a
347            .editor
348            .completion_state
349            .as_ref()
350            .expect("popup should open with multiple candidates");
351        assert!(
352            state.candidates.len() >= 2,
353            "expected several describe-* candidates: {:?}",
354            state
355                .candidates
356                .iter()
357                .map(|c| &c.raw.text)
358                .collect::<Vec<_>>()
359        );
360    }
361
362    #[test]
363    fn auto_insert_single_does_not_fire_when_narrowing_open_popup() {
364        // Sub-decision (i): only fires at popup-open. Opening on a
365        // multi-candidate prefix and narrowing while typing must
366        // leave the popup open (even if it shrinks to one) -- vim's
367        // default and the less surprising behaviour.
368        let mut a = app_in_command_mode("set foldmethod=");
369        a.apply(Action::CommandLineCompleteOrAdvance);
370        let initial = a
371            .editor
372            .completion_state
373            .as_ref()
374            .expect("popup should open for the value list");
375        assert!(initial.candidates.len() >= 2);
376        // Narrow by typing toward `indent`.
377        for c in "ind".chars() {
378            a.apply(Action::CommandLineAppend(c));
379        }
380        // Popup is still open even after narrowing to the unique
381        // match -- auto-insert only fires at popup-open, not on
382        // refilter-while-open.
383        assert!(
384            a.editor.completion_state.is_some(),
385            "popup must stay open when narrowed mid-typing"
386        );
387        assert_eq!(a.editor.command_line(), "set foldmethod=ind");
388    }
389
390    #[test]
391    fn auto_insert_single_set_via_set_command() {
392        // `:set nocompletion.auto_insert_single` flips the bool;
393        // `:set completion.auto_insert_single` flips it back.
394        let mut a = app_with("xx", 10);
395        a.editor
396            .set_command_line_text("set nocompletion.auto_insert_single");
397        a.editor.modal = ModalState::Command;
398        a.apply(Action::CommandLineSubmit);
399        assert!(!a.completion_auto_insert_single());
400        a.editor
401            .set_command_line_text("set completion.auto_insert_single");
402        a.editor.modal = ModalState::Command;
403        a.apply(Action::CommandLineSubmit);
404        assert!(a.completion_auto_insert_single());
405    }
406
407    #[test]
408    fn completion_kind_glyph_distinct_for_common_kinds() {
409        use lattice_lsp::lsp_types::CompletionItemKind as K;
410        let f = completion_kind_glyph(Some(K::FUNCTION));
411        let s = completion_kind_glyph(Some(K::SNIPPET));
412        let v = completion_kind_glyph(Some(K::VARIABLE));
413        assert_ne!(f, s);
414        assert_ne!(f, v);
415    }
416
417    // SN.3c.1 (2026-06-14): the expand path is now driven by the
418    // host's `Editor::expand_snippet_from_range(replace_range)` — the
419    // mode-owned `<C-x><C-s>` handler scans the word prefix and emits
420    // `Effect::ExpandSnippet { replace_range }`; these tests exercise
421    // the host's resolution + expansion mechanics by calling it with
422    // the range the scan would produce (`token-start..cursor`). The
423    // scan itself (range computation) is covered in
424    // `lattice_snippet::modes` (`trigger_range_*`).
425
426    #[test]
427    fn snippet_expand_from_range_splices_body_and_focuses_first_tabstop() {
428        // Buffer: `for`; the trigger range covers the prefix `for`
429        // (token-start..cursor = (0,0)..(0,3)). After expansion we
430        // expect the snippet's literal text in the buffer and an
431        // active snippet pointing at $1.
432        let mut a = app_with("for", 10);
433        a.editor.modal = ModalState::Insert;
434        a.editor.cursor = Position::new(0, 3);
435        install_snippet(
436            &mut a,
437            "*",
438            "for-loop",
439            "for",
440            "for ${1:i} in ${2:iter} { $0 }",
441        );
442        a.editor
443            .expand_snippet_from_range(lattice_protocol::position::Range::new(
444                Position::new(0, 0),
445                Position::new(0, 3),
446            ));
447        // Buffer text should be the rendered snippet.
448        let text = a.editor.document.snapshot().buffer.as_string();
449        assert_eq!(text, "for i in iter {  }");
450        // Active snippet present, focused on $1.
451        let snippet_index = a
452            .editor
453            .snippet_session
454            .with_mut(a.editor.document_buffer_id, |s| {
455                s.as_ref().expect("snippet active").current_index()
456            });
457        assert_eq!(snippet_index, Some(1));
458        // Cursor at start of `i`.
459        assert_eq!(a.editor.cursor, Position::new(0, 4));
460    }
461
462    /// SN.3d.3: expanding a snippet whose first placeholder has a
463    /// MULTI-char default selects the default in Select mode, so the
464    /// next printable key overtypes the whole thing and drops to Insert
465    /// — the conventional "type-to-replace-placeholder" snippet UX.
466    #[test]
467    fn snippet_expand_selects_multichar_default_then_overtypes() {
468        let mut a = app_with("for", 10);
469        a.editor.modal = ModalState::Insert;
470        a.editor.cursor = Position::new(0, 3);
471        install_snippet(&mut a, "*", "for-loop", "for", "for ${1:iter} {}");
472        a.editor
473            .expand_snippet_from_range(lattice_protocol::position::Range::new(
474                Position::new(0, 0),
475                Position::new(0, 3),
476            ));
477        // Rendered with the default; "iter" (bytes 4..8) is SELECTED and
478        // the buffer is in Select mode.
479        assert_eq!(
480            a.editor.document.snapshot().buffer.as_string(),
481            "for iter {}"
482        );
483        assert!(
484            matches!(
485                a.editor.modal,
486                ModalState::Select(lattice_grammar::VisualKind::Charwise)
487            ),
488            "first multi-char placeholder focuses in Select, got {:?}",
489            a.editor.modal
490        );
491        let sel = *a.editor.document.selections().primary();
492        assert_eq!(sel.anchor, Position::new(0, 4), "anchor at default start");
493        assert_eq!(
494            sel.head,
495            Position::new(0, 7),
496            "head on the default's last byte"
497        );
498
499        // A printable key overtypes the whole default and lands in Insert.
500        a.apply(Action::SelectOvertype('x'));
501        assert_eq!(a.editor.document.snapshot().buffer.as_string(), "for x {}");
502        assert!(
503            matches!(a.editor.modal, ModalState::Insert),
504            "overtype drops to Insert, got {:?}",
505            a.editor.modal
506        );
507    }
508
509    /// VM.4 / decision (A): typing over a snippet placeholder with REAL
510    /// keystrokes replaces it, even when the text starts with a motion key.
511    ///
512    /// The test above calls `a.apply(Action::SelectOvertype('x'))`, which
513    /// skips the keymap lookup, so it could never catch the bug: in Select a
514    /// BOUND printable takes the key before the overtype fallback runs. `f` was
515    /// a find-char prefix there, so `foo` extended the placeholder instead of
516    /// replacing it, and `w` was a motion. The keymap's mirror no longer puts a
517    /// printable motion in Select, which fixes both.
518    #[test]
519    fn typing_over_a_snippet_placeholder_replaces_it_even_with_motion_keys() {
520        // `foo` / `work`: `f` and `w` were printable motions in Select (VM.4).
521        // `aim` / `info` / `owl`: `a` / `i` were text-object prefixes and `o`
522        // was swap-ends, all bound explicitly until VM.5.
523        for typed in ["foo", "work", "aim", "info", "owl"] {
524            let mut a = app_with("for", 10);
525            a.editor.modal = ModalState::Insert;
526            a.editor.cursor = Position::new(0, 3);
527            install_snippet(&mut a, "*", "for-loop", "for", "for ${1:iter} {}");
528            a.editor
529                .expand_snippet_from_range(lattice_protocol::position::Range::new(
530                    Position::new(0, 0),
531                    Position::new(0, 3),
532                ));
533            // Publish the post-expansion state (Select) that `press` translates
534            // against, as the snippet <Tab> test does.
535            a.sync_keymap_overlays();
536            assert!(
537                matches!(a.editor.modal, ModalState::Select(_)),
538                "test premise: the placeholder is selected in Select"
539            );
540
541            press_chars(&mut a, typed);
542
543            assert_eq!(
544                a.editor.document.snapshot().buffer.as_string(),
545                format!("for {typed} {{}}"),
546                "typing {typed:?} must replace the placeholder"
547            );
548        }
549    }
550
551    /// Vim's Select rule: "Printable characters, <NL> and <CR> cause the
552    /// selection to be deleted, and Vim enters Insert mode." A typed newline
553    /// carries its auto-indent (IN.1), so `<CR>` over a placeholder on an
554    /// indented line lands on an indented line, in the same single replace
555    /// edit the overtype always was.
556    #[test]
557    fn enter_over_a_snippet_placeholder_types_an_indented_newline() {
558        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
559        let mut a = app_with("    for", 10);
560        a.editor.modal = ModalState::Insert;
561        a.editor.cursor = Position::new(0, 7);
562        install_snippet(&mut a, "*", "for-loop", "for", "for ${1:iter} {}");
563        a.editor
564            .expand_snippet_from_range(lattice_protocol::position::Range::new(
565                Position::new(0, 4),
566                Position::new(0, 7),
567            ));
568        a.sync_keymap_overlays();
569        assert!(
570            matches!(a.editor.modal, ModalState::Select(_)),
571            "test premise: the placeholder is selected in Select"
572        );
573
574        press(&mut a, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
575
576        let text = a.editor.document.snapshot().buffer.as_string();
577        assert!(
578            matches!(a.editor.modal, ModalState::Insert),
579            "<CR> overtypes and lands in Insert, got {:?}",
580            a.editor.modal
581        );
582        assert_eq!(
583            a.editor.cursor,
584            Position::new(1, 4),
585            "the cursor lands after the carried indent, in {text:?}"
586        );
587        assert!(
588            text.lines().nth(1).is_some_and(|l| l.starts_with("    ")),
589            "the new line carries the head's indent: {text:?}"
590        );
591    }
592
593    /// SN.3d.3: an EMPTY first tabstop (`$1`) keeps the bare Insert
594    /// cursor — there is nothing to overtype, so we do NOT enter Select.
595    #[test]
596    fn snippet_expand_empty_tabstop_stays_in_insert() {
597        let mut a = app_with("for", 10);
598        a.editor.modal = ModalState::Insert;
599        a.editor.cursor = Position::new(0, 3);
600        install_snippet(&mut a, "*", "call", "for", "foo($1)");
601        a.editor
602            .expand_snippet_from_range(lattice_protocol::position::Range::new(
603                Position::new(0, 0),
604                Position::new(0, 3),
605            ));
606        assert_eq!(a.editor.document.snapshot().buffer.as_string(), "foo()");
607        assert!(
608            matches!(a.editor.modal, ModalState::Insert),
609            "empty tabstop keeps Insert, got {:?}",
610            a.editor.modal
611        );
612        assert_eq!(
613            a.editor.cursor,
614            Position::new(0, 4),
615            "cursor inside the parens"
616        );
617    }
618
619    // SN.2b (2026-06-12): the placeholder-navigation tests
620    // (`snippet_next_placeholder_walks_through_groups_and_drops_on_zero`,
621    // `snippet_prev_placeholder_walks_back`) moved to
622    // `lattice_snippet::modes` as a handler-level dispatch test —
623    // the `<Tab>` / `<S-Tab>` bodies are now `active-snippet-mode`
624    // `ActionHandlerRegistry` closures, so the session-transition
625    // coverage belongs where the handlers live.
626
627    /// SN.3c.2b: end-to-end keystroke proof of the `<Esc>` fall-through.
628    /// Expand a snippet (session live), reconcile so
629    /// `active-snippet-mode` activates, then drive `<Esc>` through the
630    /// full `translate` → `App::apply` path. The mode's `<Esc>` is
631    /// `fall_through: true`, so `dispatch_insert` resolves it to a
632    /// `Chain([snippet-leave, enter-normal])`; `App::apply` runs both —
633    /// the leave handler clears the session AND the native `<Esc>` exits
634    /// insert. Exercises the whole pipeline the unit tests cover in
635    /// pieces (gating → fall-through resolution → Chain apply).
636    #[test]
637    fn snippet_esc_clears_session_and_exits_insert_end_to_end() {
638        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
639
640        let mut a = app_with("for", 10);
641        a.editor.modal = ModalState::Insert;
642        a.editor.cursor = Position::new(0, 3);
643        install_snippet(
644            &mut a,
645            "*",
646            "for-loop",
647            "for",
648            "for ${1:i} in ${2:iter} { $0 }",
649        );
650        a.editor
651            .expand_snippet_from_range(lattice_protocol::position::Range::new(
652                Position::new(0, 0),
653                Position::new(0, 3),
654            ));
655        // Reconcile session-backed minors so `active-snippet-mode`
656        // activates on the buffer (mirrors the per-apply reconcile).
657        a.sync_keymap_overlays();
658        assert!(
659            a.editor
660                .snippet_session
661                .is_active(a.editor.document_buffer_id),
662            "snippet session live after expand"
663        );
664        let minors = a
665            .editor
666            .active_modes
667            .get(&a.editor.document_buffer_id)
668            .map(|m| m.minors().to_vec())
669            .unwrap_or_default();
670        assert!(
671            minors.contains(&lattice_snippet::modes::SnippetActiveMode::mode_id()),
672            "active-snippet-mode must be active so <Esc> resolves to the fall_through binding, got {minors:?}"
673        );
674
675        // The real keystroke: <Esc> through translate + apply.
676        press(&mut a, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
677
678        assert!(
679            !a.editor
680                .snippet_session
681                .is_active(a.editor.document_buffer_id),
682            "leave handler cleared the session"
683        );
684        assert_eq!(
685            a.editor.modal,
686            ModalState::Normal,
687            "fall-through continued to the native <Esc> → exit insert"
688        );
689    }
690
691    /// SN.3d.4: `<Tab>` navigates between placeholders WHILE a
692    /// default-bearing placeholder is focused in Select — proving the
693    /// snippet minor-mode bindings are live in Select, not just Insert.
694    /// Tabbing past a default keeps it (no overtype), landing on the
695    /// next placeholder, still in Select.
696    #[test]
697    fn snippet_tab_navigates_between_placeholders_in_select_mode() {
698        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
699
700        let mut a = app_with("for", 10);
701        a.editor.modal = ModalState::Insert;
702        a.editor.cursor = Position::new(0, 3);
703        install_snippet(
704            &mut a,
705            "*",
706            "for-loop",
707            "for",
708            "for ${1:i} in ${2:iter} { $0 }",
709        );
710        a.editor
711            .expand_snippet_from_range(lattice_protocol::position::Range::new(
712                Position::new(0, 0),
713                Position::new(0, 3),
714            ));
715        a.sync_keymap_overlays();
716        assert_eq!(
717            a.editor.document.snapshot().buffer.as_string(),
718            "for i in iter {  }"
719        );
720        // First placeholder `i` (bytes 4..5) selected in Select.
721        assert!(matches!(
722            a.editor.modal,
723            ModalState::Select(lattice_grammar::VisualKind::Charwise)
724        ));
725
726        // <Tab> through translate + apply: navigate to placeholder 2
727        // WITHOUT overtyping `i`.
728        press(&mut a, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
729
730        // `i` is untouched; we're on `iter` (bytes 9..13), still Select.
731        assert_eq!(
732            a.editor.document.snapshot().buffer.as_string(),
733            "for i in iter {  }",
734            "tabbing past a default keeps it"
735        );
736        assert!(
737            matches!(
738                a.editor.modal,
739                ModalState::Select(lattice_grammar::VisualKind::Charwise)
740            ),
741            "next placeholder is focused in Select, got {:?}",
742            a.editor.modal
743        );
744        let sel = *a.editor.document.selections().primary();
745        assert_eq!(sel.anchor, Position::new(0, 9), "anchor at `iter` start");
746        assert_eq!(sel.head, Position::new(0, 12), "head on `iter` last byte");
747    }
748
749    #[test]
750    fn snippet_expand_from_range_with_no_match_is_a_no_op() {
751        let mut a = app_with("xyz", 10);
752        a.editor.modal = ModalState::Insert;
753        a.editor.cursor = Position::new(0, 3);
754        a.editor
755            .expand_snippet_from_range(lattice_protocol::position::Range::new(
756                Position::new(0, 0),
757                Position::new(0, 3),
758            ));
759        assert!(
760            !a.editor
761                .snippet_session
762                .is_active(a.editor.document_buffer_id)
763        );
764        // Buffer unchanged.
765        assert_eq!(a.editor.document.snapshot().buffer.as_string(), "xyz");
766    }
767
768    // SN.3c.1 (2026-06-14): the old `snippet_expand_outside_insert_mode_is_a_no_op`
769    // test is retired. The Insert-mode guard moved off the host method
770    // onto the keymap layer — `<C-x><C-s>` is an Insert-only entry on
771    // `snippet-mode`'s `keymap()`, so the chord cannot fire in Normal
772    // mode and `expand_snippet_from_range` no longer carries a modal
773    // check. Modal scoping is now a keymap concern, not a host-method
774    // concern.
775
776    #[test]
777    fn completion_trigger_includes_snippet_candidate_for_matching_prefix() {
778        let mut a = app_with("for", 10);
779        a.editor.modal = ModalState::Insert;
780        a.editor.cursor = Position::new(0, 3);
781        install_snippet(&mut a, "*", "for-loop", "for", "for ${1:i} in ${2:iter} {}");
782        a.do_completion_trigger();
783        let state = a.editor.insert_completion.as_ref().expect("popup open");
784        // `for-loop` snippet appears as a candidate. The
785        // candidate's text is the prefix; CSM.5 carries the
786        // snippet's stable name in the `Extension::payload`
787        // bytes, the accept path resolves the body via
788        // `snippet_meta_for` -> `SnippetRegistry::by_name`.
789        let cand = state
790            .rendered
791            .iter()
792            .find(|r| r.raw.text == "for")
793            .expect("snippet candidate present");
794        let meta = a.snippet_meta_for(cand).expect("snippet meta resolves");
795        assert_eq!(meta.name, "for-loop");
796    }
797
798    #[test]
799    fn completion_accept_on_snippet_candidate_starts_active_snippet() {
800        let mut a = app_with("for", 10);
801        a.editor.modal = ModalState::Insert;
802        a.editor.cursor = Position::new(0, 3);
803        install_snippet(&mut a, "*", "for-loop", "for", "for ${1:i} in ${2:iter} {}");
804        a.do_completion_trigger();
805        // Find the snippet candidate index and select it.
806        let state = a.editor.insert_completion.as_mut().expect("popup");
807        let idx = state
808            .rendered
809            .iter()
810            .position(|r| {
811                matches!(
812                    r.raw.data,
813                    lattice_completion::CandidateData::Extension {
814                        kind_id,
815                        ..
816                    } if kind_id == SNIPPET_COMPLETION_KIND_ID
817                )
818            })
819            .expect("snippet candidate present");
820        state.selected = idx;
821        a.do_completion_accept();
822        // Popup closed; active snippet is in flight focused on
823        // $1; buffer reflects expansion.
824        assert!(a.editor.insert_completion.is_none());
825        let snippet_index = a
826            .editor
827            .snippet_session
828            .with_mut(a.editor.document_buffer_id, |s| {
829                s.as_ref().expect("active snippet").current_index()
830            });
831        assert_eq!(snippet_index, Some(1));
832        let text = a.editor.document.snapshot().buffer.as_string();
833        assert_eq!(text, "for i in iter {}");
834    }
835
836    #[test]
837    fn completion_accept_bumps_frequency_map_for_text_kind_pair() {
838        // Trigger completion against a buffer-words source and
839        // accept a candidate. The App's accept-frequency map
840        // gets a new entry keyed by `(text, kind)` with count 1.
841        let mut a = app_with("alpha bravo charlie ", 10);
842        a.editor.modal = ModalState::Insert;
843        a.editor.cursor = Position::new(0, 20);
844        a.do_completion_trigger();
845        // Empty query at end of line -> all three buffer words
846        // surface as candidates. Find `bravo` and select it.
847        let state = a.editor.insert_completion.as_mut().expect("popup");
848        let idx = state
849            .rendered
850            .iter()
851            .position(|r| r.raw.text == "bravo")
852            .expect("bravo present");
853        state.selected = idx;
854        a.do_completion_accept();
855        // Map records exactly one accept of (bravo, Plain).
856        let key = (
857            "bravo".to_string(),
858            lattice_completion::CandidateKind::Plain,
859        );
860        assert_eq!(a.editor.completion_accept_freq.get(&key).copied(), Some(1));
861    }
862
863    #[test]
864    fn completion_trigger_ranks_previously_accepted_above_tied_peer() {
865        // Two buffer words tie on matcher score (empty query
866        // -> uniform 100); a previous accept of `bravo` lifts
867        // it to the top of the rendered list.
868        let mut a = app_with("alpha bravo charlie ", 10);
869        a.editor.modal = ModalState::Insert;
870        a.editor.cursor = Position::new(0, 20);
871        // Seed the freq map directly -- this is the integration
872        // boundary we care about (the App's map fed into the
873        // ranker), not the accept-then-retrigger cycle.
874        a.editor.completion_accept_freq.insert(
875            (
876                "bravo".to_string(),
877                lattice_completion::CandidateKind::Plain,
878            ),
879            3,
880        );
881        a.do_completion_trigger();
882        let state = a.editor.insert_completion.as_ref().expect("popup");
883        // First rendered candidate is the previously-accepted
884        // one, ahead of its tied peers.
885        assert_eq!(
886            state.rendered.first().expect("at least one").raw.text,
887            "bravo"
888        );
889    }
890
891    #[test]
892    fn path_source_emits_filesystem_entries_inside_string_literal() {
893        let ws = fresh_path_workspace("emits-entries");
894        // Populate the workspace with two files + one dir.
895        std::fs::write(ws.join("alpha.rs"), "// alpha").unwrap();
896        std::fs::write(ws.join("beta.rs"), "// beta").unwrap();
897        std::fs::create_dir_all(ws.join("subdir")).unwrap();
898        // Buffer with a string literal; we'll set the
899        // document path so relative resolution lands in `ws`.
900        let source = "let p = \"\";\n";
901        let doc_path = ws.join("buffer.rs");
902        let mut a = app_with_path(source, 10, doc_path);
903        set_rust_syntax(&mut a, source);
904        a.editor.modal = ModalState::Insert;
905        // Cursor between the empty string's quotes -> string
906        // scope.
907        a.editor.cursor = Position::new(0, source.find("\"\"").unwrap() as u32 + 1);
908        a.do_completion_trigger();
909        assert!(a.editor.completion_in_path_context, "path-context detected");
910        let state = a.editor.insert_completion.as_ref().expect("popup");
911        let path_id = lattice_completion::PATH_SOURCE_ID;
912        let texts: Vec<&str> = state
913            .raw
914            .iter()
915            .filter(|c| c.source.as_ref().map(|s| s.as_str()) == Some(path_id))
916            .map(|c| c.text.as_str())
917            .collect();
918        assert!(texts.contains(&"alpha.rs"), "alpha in {texts:?}");
919        assert!(texts.contains(&"beta.rs"), "beta in {texts:?}");
920        assert!(
921            texts.contains(&"subdir/"),
922            "subdir/ (with trailing slash) in {texts:?}",
923        );
924        // No buffer-words / tree-sitter / snippet candidates
925        // intermix with the path popup.
926        for cand in &state.raw {
927            let src = cand.source.as_ref().map(|s| s.as_str()).unwrap_or("");
928            assert_eq!(
929                src, path_id,
930                "non-path source `{src}` slipped into path-context popup",
931            );
932        }
933    }
934
935    #[test]
936    fn path_source_skips_hidden_and_ignored_entries() {
937        let ws = fresh_path_workspace("skip-hidden");
938        std::fs::write(ws.join("visible.txt"), "v").unwrap();
939        std::fs::write(ws.join(".hidden"), "h").unwrap();
940        std::fs::create_dir_all(ws.join(".git")).unwrap();
941        std::fs::create_dir_all(ws.join("node_modules")).unwrap();
942        let source = "let p = \"\";\n";
943        let mut a = app_with_path(source, 10, ws.join("buffer.rs"));
944        set_rust_syntax(&mut a, source);
945        a.editor.modal = ModalState::Insert;
946        a.editor.cursor = Position::new(0, source.find("\"\"").unwrap() as u32 + 1);
947        a.do_completion_trigger();
948        let state = a.editor.insert_completion.as_ref().expect("popup");
949        let texts: Vec<&str> = state.raw.iter().map(|c| c.text.as_str()).collect();
950        assert!(texts.contains(&"visible.txt"));
951        assert!(!texts.contains(&".hidden"), "dotfile filtered");
952        assert!(!texts.contains(&".git/"), ".git filtered");
953        assert!(!texts.contains(&"node_modules/"), "node_modules filtered",);
954    }
955
956    #[test]
957    fn path_source_silent_outside_string_scope() {
958        let source = "fn main() { let x = 1; }\n";
959        let mut a = app_with(source, 10);
960        set_rust_syntax(&mut a, source);
961        a.editor.modal = ModalState::Insert;
962        // Cursor at end of line -- outside any string.
963        a.editor.cursor = Position::new(0, source.trim_end().len() as u32);
964        a.do_completion_trigger();
965        assert!(!a.editor.completion_in_path_context);
966        if let Some(state) = a.editor.insert_completion.as_ref() {
967            let path_id = lattice_completion::PATH_SOURCE_ID;
968            for cand in &state.raw {
969                assert_ne!(
970                    cand.source.as_ref().map(|s| s.as_str()),
971                    Some(path_id),
972                    "no path candidates outside string scope",
973                );
974            }
975        }
976    }
977
978    #[test]
979    fn path_source_skipped_by_per_language_override() {
980        let ws = fresh_path_workspace("disabled-via-override");
981        std::fs::write(ws.join("alpha.rs"), "//").unwrap();
982        let source = "let p = \"\";\n";
983        let mut a = app_with_path(source, 10, ws.join("buffer.rs"));
984        set_rust_syntax(&mut a, source);
985        a.editor.modal = ModalState::Insert;
986        a.editor.cursor = Position::new(0, source.find("\"\"").unwrap() as u32 + 1);
987        // Override the active language ("rust", since the
988        // buffer path ends in `.rs`) to drop path source.
989        a.editor.per_language_completion.insert(
990            "rust".into(),
991            lattice_completion::PerLanguageOverrides {
992                sources: Some(vec![lattice_completion::SourceId::new(
993                    lattice_completion::BufferWordsSource::ID,
994                )]),
995                ..Default::default()
996            },
997        );
998        a.do_completion_trigger();
999        assert!(
1000            !a.editor.completion_in_path_context,
1001            "path source disabled -> no path context",
1002        );
1003    }
1004
1005    #[test]
1006    fn path_source_resolves_subdirectory_from_partial_path() {
1007        let ws = fresh_path_workspace("subdir-walk");
1008        std::fs::create_dir_all(ws.join("src")).unwrap();
1009        std::fs::write(ws.join("src/foo.rs"), "//").unwrap();
1010        std::fs::write(ws.join("src/bar.rs"), "//").unwrap();
1011        let source = "let p = \"src/\";\n";
1012        let mut a = app_with_path(source, 10, ws.join("buffer.rs"));
1013        set_rust_syntax(&mut a, source);
1014        a.editor.modal = ModalState::Insert;
1015        // Cursor after `src/`.
1016        let after_slash = source.find("src/").unwrap() + "src/".len();
1017        a.editor.cursor = Position::new(0, after_slash as u32);
1018        a.do_completion_trigger();
1019        assert!(a.editor.completion_in_path_context);
1020        let state = a.editor.insert_completion.as_ref().expect("popup");
1021        let texts: Vec<&str> = state.raw.iter().map(|c| c.text.as_str()).collect();
1022        assert!(
1023            texts.contains(&"foo.rs"),
1024            "src/foo.rs surfaced -- got {texts:?}"
1025        );
1026        assert!(texts.contains(&"bar.rs"), "src/bar.rs surfaced");
1027    }
1028
1029    #[test]
1030    fn ghost_text_off_by_default_returns_none() {
1031        let mut a = app_with("foo", 5);
1032        a.editor.modal = ModalState::Insert;
1033        a.editor.cursor = Position::new(0, 3);
1034        open_popup_with_top_text(&mut a, "foo", "foobar");
1035        // Default: completion.ghost_text = false -> no ghost.
1036        assert!(a.completion_ghost_text_suffix().is_none());
1037    }
1038
1039    #[test]
1040    fn ghost_text_returns_suffix_for_prefix_matching_top_candidate() {
1041        let mut a = app_with("foo", 5);
1042        a.editor.modal = ModalState::Insert;
1043        a.editor.cursor = Position::new(0, 3);
1044        a.do_set("completion.ghost_text=true");
1045        open_popup_with_top_text(&mut a, "foo", "foobar");
1046        assert_eq!(
1047            a.completion_ghost_text_suffix(),
1048            Some("bar".to_string()),
1049            "ghost suffix is the part of the candidate beyond the query prefix",
1050        );
1051    }
1052
1053    #[test]
1054    fn ghost_text_case_insensitive_prefix_match() {
1055        let mut a = app_with("Foo", 5);
1056        a.editor.modal = ModalState::Insert;
1057        a.editor.cursor = Position::new(0, 3);
1058        a.do_set("completion.ghost_text=true");
1059        open_popup_with_top_text(&mut a, "Foo", "foobar");
1060        assert_eq!(a.completion_ghost_text_suffix(), Some("bar".to_string()),);
1061    }
1062
1063    #[test]
1064    fn ghost_text_none_when_top_doesnt_prefix_match_query() {
1065        let mut a = app_with("xyz", 5);
1066        a.editor.modal = ModalState::Insert;
1067        a.editor.cursor = Position::new(0, 3);
1068        a.do_set("completion.ghost_text=true");
1069        // Top candidate is `bar`; query `xyz` doesn't prefix
1070        // it (matcher's substring tier still puts it on
1071        // screen, but ghost demands prefix-match).
1072        open_popup_with_top_text(&mut a, "xyz", "bar");
1073        assert!(a.completion_ghost_text_suffix().is_none());
1074    }
1075
1076    #[test]
1077    fn ghost_text_none_when_query_is_empty() {
1078        let mut a = app_with("", 5);
1079        a.editor.modal = ModalState::Insert;
1080        a.editor.cursor = Position::new(0, 0);
1081        a.do_set("completion.ghost_text=true");
1082        open_popup_with_top_text(&mut a, "", "alpha");
1083        assert!(
1084            a.completion_ghost_text_suffix().is_none(),
1085            "empty query -> no ghost (any candidate would match)",
1086        );
1087    }
1088
1089    #[test]
1090    fn ghost_text_none_in_path_context() {
1091        let mut a = app_with("\"\"", 5);
1092        a.editor.modal = ModalState::Insert;
1093        a.editor.cursor = Position::new(0, 1);
1094        a.do_set("completion.ghost_text=true");
1095        a.editor.completion_in_path_context = true;
1096        open_popup_with_top_text(&mut a, "src", "src/foo.rs");
1097        assert!(
1098            a.completion_ghost_text_suffix().is_none(),
1099            "path popup already shows full filenames; ghost would double up",
1100        );
1101    }
1102
1103    #[test]
1104    fn ghost_text_none_when_popup_closed() {
1105        let mut a = app_with("foo", 5);
1106        a.editor.modal = ModalState::Insert;
1107        a.editor.cursor = Position::new(0, 3);
1108        a.do_set("completion.ghost_text=true");
1109        // No open_popup_with_top_text call -> insert_completion = None.
1110        assert!(a.completion_ghost_text_suffix().is_none());
1111    }
1112
1113    #[test]
1114    fn completion_high_priority_source_beats_tied_low_priority_peer() {
1115        // Two candidates tied on matcher score: one tagged
1116        // gen:lsp-completion (default priority 200), one tagged
1117        // gen:buffer-words (default 100). The LSP candidate
1118        // sorts above the buffer-words peer.
1119        let mut a = app_with("", 10);
1120        a.editor.modal = ModalState::Insert;
1121        a.editor.cursor = Position::new(0, 0);
1122        let mut state = lattice_completion::InsertCompletionState::open(
1123            lattice_completion::CompletionTrigger::Manual,
1124            Position::ZERO,
1125            Position::ZERO,
1126            String::new(),
1127        );
1128        state.raw.push(
1129            lattice_completion::RawCandidate::plain(
1130                "from_words",
1131                lattice_completion::CandidateKind::Plain,
1132            )
1133            .with_source(lattice_completion::SourceId::new(
1134                lattice_completion::BufferWordsSource::ID,
1135            )),
1136        );
1137        state.raw.push(
1138            lattice_completion::RawCandidate::plain(
1139                "from_lsp",
1140                lattice_completion::CandidateKind::Plain,
1141            )
1142            .with_source(lattice_completion::SourceId::new(
1143                lattice_completion::LSP_COMPLETION_SOURCE_ID,
1144            )),
1145        );
1146        a.refilter_insert_completion(&mut state);
1147        assert_eq!(state.rendered[0].raw.text, "from_lsp");
1148        assert_eq!(state.rendered[1].raw.text, "from_words");
1149    }
1150
1151    #[test]
1152    fn completion_priority_override_via_set_flips_source_order() {
1153        // After `:set completion.source.buffer-words.priority=300`
1154        // the buffer-words candidate outranks the LSP one
1155        // (300 > 200) at tied matcher score.
1156        let mut a = app_with("", 10);
1157        a.editor.modal = ModalState::Insert;
1158        a.editor.cursor = Position::new(0, 0);
1159        a.do_set("completion.source.buffer-words.priority=300");
1160        let mut state = lattice_completion::InsertCompletionState::open(
1161            lattice_completion::CompletionTrigger::Manual,
1162            Position::ZERO,
1163            Position::ZERO,
1164            String::new(),
1165        );
1166        state.raw.push(
1167            lattice_completion::RawCandidate::plain(
1168                "from_lsp",
1169                lattice_completion::CandidateKind::Plain,
1170            )
1171            .with_source(lattice_completion::SourceId::new(
1172                lattice_completion::LSP_COMPLETION_SOURCE_ID,
1173            )),
1174        );
1175        state.raw.push(
1176            lattice_completion::RawCandidate::plain(
1177                "from_words",
1178                lattice_completion::CandidateKind::Plain,
1179            )
1180            .with_source(lattice_completion::SourceId::new(
1181                lattice_completion::BufferWordsSource::ID,
1182            )),
1183        );
1184        a.refilter_insert_completion(&mut state);
1185        assert_eq!(state.rendered[0].raw.text, "from_words");
1186        assert_eq!(state.rendered[1].raw.text, "from_lsp");
1187    }
1188
1189    #[test]
1190    fn completion_untagged_candidate_gets_no_priority_lift() {
1191        // Candidate with no source field (plugin source not yet
1192        // wired into config, or test fixture) gets 0 priority
1193        // bonus; sorts below a tagged peer at tied matcher
1194        // score, but still appears in the rendered list.
1195        let mut a = app_with("", 10);
1196        a.editor.modal = ModalState::Insert;
1197        a.editor.cursor = Position::new(0, 0);
1198        let mut state = lattice_completion::InsertCompletionState::open(
1199            lattice_completion::CompletionTrigger::Manual,
1200            Position::ZERO,
1201            Position::ZERO,
1202            String::new(),
1203        );
1204        state.raw.push(lattice_completion::RawCandidate::plain(
1205            "untagged",
1206            lattice_completion::CandidateKind::Plain,
1207        ));
1208        state.raw.push(
1209            lattice_completion::RawCandidate::plain(
1210                "tagged",
1211                lattice_completion::CandidateKind::Plain,
1212            )
1213            .with_source(lattice_completion::SourceId::new(
1214                lattice_completion::BufferWordsSource::ID,
1215            )),
1216        );
1217        a.refilter_insert_completion(&mut state);
1218        assert_eq!(state.rendered[0].raw.text, "tagged");
1219        assert_eq!(state.rendered[1].raw.text, "untagged");
1220    }
1221
1222    #[test]
1223    fn completion_buffer_words_candidates_carry_their_source_tag() {
1224        // Regression: the buffer-words `InsertSource` impl
1225        // tags every produced candidate with its own id so the
1226        // ranker can apply per-source priority without the host
1227        // having to remember to tag.
1228        let mut a = app_with("alpha bravo ", 10);
1229        a.editor.modal = ModalState::Insert;
1230        a.editor.cursor = Position::new(0, 12);
1231        a.do_completion_trigger();
1232        let state = a.editor.insert_completion.as_ref().expect("popup");
1233        assert!(!state.rendered.is_empty());
1234        for cand in &state.rendered {
1235            let src = cand
1236                .raw
1237                .source
1238                .as_ref()
1239                .unwrap_or_else(|| panic!("candidate `{}` missing source tag", cand.raw.text));
1240            assert_eq!(src.as_str(), lattice_completion::BufferWordsSource::ID);
1241        }
1242    }
1243
1244    #[test]
1245    fn completion_accept_increments_existing_frequency_count() {
1246        // Two accepts of the same item bump the count to 2.
1247        let mut a = app_with("alpha bravo charlie ", 10);
1248        a.editor.modal = ModalState::Insert;
1249        a.editor.cursor = Position::new(0, 20);
1250        let key = (
1251            "bravo".to_string(),
1252            lattice_completion::CandidateKind::Plain,
1253        );
1254        a.editor.completion_accept_freq.insert(key.clone(), 4);
1255        a.do_completion_trigger();
1256        let state = a.editor.insert_completion.as_mut().expect("popup");
1257        let idx = state
1258            .rendered
1259            .iter()
1260            .position(|r| r.raw.text == "bravo")
1261            .expect("bravo present");
1262        state.selected = idx;
1263        a.do_completion_accept();
1264        assert_eq!(a.editor.completion_accept_freq.get(&key).copied(), Some(5));
1265    }
1266
1267    // ---- CSM.2: completion-mode tracks popup state ----
1268
1269    /// Triggering the completion popup activates `completion-mode`
1270    /// on the document buffer. The mode is the architectural gate
1271    /// the keymap-overlay + active-source resolver read.
1272    #[test]
1273    fn completion_mode_activates_when_popup_opens() {
1274        let mut a = app_with("alpha bravo charlie ", 10);
1275        a.editor.modal = ModalState::Insert;
1276        a.editor.cursor = Position::new(0, 20);
1277        assert!(
1278            !a.completion_popup_active(),
1279            "mode should be inactive before popup opens",
1280        );
1281        a.apply(Action::CompletionTrigger);
1282        assert!(
1283            a.completion_popup_active(),
1284            "mode should be active after popup opens",
1285        );
1286    }
1287
1288    /// Cancelling the popup deactivates `completion-mode`.
1289    #[test]
1290    fn completion_mode_deactivates_after_cancel() {
1291        let mut a = app_with("alpha bravo charlie ", 10);
1292        a.editor.modal = ModalState::Insert;
1293        a.editor.cursor = Position::new(0, 20);
1294        a.apply(Action::CompletionTrigger);
1295        assert!(a.completion_popup_active());
1296        a.apply(Action::CompletionCancel);
1297        assert!(
1298            !a.completion_popup_active(),
1299            "mode should deactivate on cancel",
1300        );
1301    }
1302
1303    /// Accepting the popup deactivates `completion-mode`.
1304    #[test]
1305    fn completion_mode_deactivates_after_accept() {
1306        let mut a = app_with("alpha bravo charlie ", 10);
1307        a.editor.modal = ModalState::Insert;
1308        a.editor.cursor = Position::new(0, 20);
1309        a.apply(Action::CompletionTrigger);
1310        assert!(a.completion_popup_active());
1311        a.apply(Action::CompletionAccept);
1312        assert!(
1313            !a.completion_popup_active(),
1314            "mode should deactivate on accept",
1315        );
1316    }
1317
1318    // ---- CSM.K1: completion-mode / completion-popup-mode pair ----
1319
1320    /// `completion-mode` auto-activates on the initial Document
1321    /// buffer so `<C-Space>` works out-of-the-box.
1322    #[test]
1323    fn completion_mode_auto_active_on_document_buffer() {
1324        let a = app_with("hi", 5);
1325        assert!(
1326            a.completion_mode_active_for(a.editor.document_buffer_id),
1327            "completion-mode should be auto-active on the initial Document",
1328        );
1329    }
1330
1331    /// Read-only buffer kinds (Help here) don't auto-activate
1332    /// `completion-mode`; `<C-Space>` is a silent no-op there.
1333    #[test]
1334    fn completion_mode_not_active_on_help_buffer() {
1335        let mut a = app_with("hi", 5);
1336        let help = crate::help::HelpContent::from_lines("t", vec!["body".into()]);
1337        let help_id = a.open_help_in_pane(help);
1338        assert!(
1339            !a.completion_mode_active_for(help_id),
1340            "completion-mode should be inactive on Help buffers",
1341        );
1342    }
1343
1344    /// Trigger gate: `do_completion_trigger` no-ops when
1345    /// `completion-mode` is inactive on the active document.
1346    #[test]
1347    fn completion_trigger_noop_when_completion_mode_inactive() {
1348        use lattice_grammar::ModalState;
1349        let mut a = app_with("alpha bravo charlie ", 10);
1350        a.editor.modal = ModalState::Insert;
1351        a.editor.cursor = Position::new(0, 20);
1352        // Force-deactivate completion-mode so the gate kicks in.
1353        let buffer_id = a.editor.document_buffer_id;
1354        a.deactivate_mode_by_id(buffer_id, lattice_mode::CompletionMode::mode_id());
1355        assert!(!a.completion_mode_active_for(buffer_id));
1356        a.do_completion_trigger();
1357        assert!(
1358            a.editor.insert_completion.is_none(),
1359            "popup should not open when completion-mode is inactive",
1360        );
1361    }
1362
1363    /// `completion-popup-mode` (the transient) tracks popup state
1364    /// independently from `completion-mode` (the persistent
1365    /// gate). Both modes coexist while the popup is open.
1366    #[test]
1367    fn completion_popup_mode_distinct_from_completion_mode() {
1368        use lattice_grammar::ModalState;
1369        let mut a = app_with("alpha bravo charlie ", 10);
1370        a.editor.modal = ModalState::Insert;
1371        a.editor.cursor = Position::new(0, 20);
1372        let buffer_id = a.editor.document_buffer_id;
1373        // completion-mode is on (auto-activated); popup-mode is
1374        // off (no popup open yet).
1375        assert!(a.completion_mode_active_for(buffer_id));
1376        assert!(!a.completion_popup_mode_active_for(buffer_id));
1377        a.apply(Action::CompletionTrigger);
1378        // Both on once the popup opens.
1379        assert!(a.completion_mode_active_for(buffer_id));
1380        assert!(a.completion_popup_mode_active_for(buffer_id));
1381        a.apply(Action::CompletionCancel);
1382        // completion-mode stays on (persistent); popup-mode
1383        // deactivates (transient).
1384        assert!(a.completion_mode_active_for(buffer_id));
1385        assert!(!a.completion_popup_mode_active_for(buffer_id));
1386    }
1387
1388    // ---- CSM.3: ActiveCompletionSources cache ----
1389
1390    /// CSM.8a: the LSP completion source rides on the M.6.1
1391    /// cascade -- it does NOT auto-activate on Document at
1392    /// boot. The cache is empty for `gen:lsp-completion` until
1393    /// `lsp-mode` activates on the buffer; toggling
1394    /// `lsp-mode` on (via the auto-generated `:lsp-mode`
1395    /// command or its programmatic equivalent) attaches every
1396    /// LSP sub-mode including `lsp-completion-mode`, which the
1397    /// recompute hook picks up.
1398    #[test]
1399    fn lsp_completion_source_activates_on_lsp_mode_cascade() {
1400        let mut a = app_with("hi", 5);
1401        let buffer_id = a.editor.document_buffer_id;
1402        let pre_ids: Vec<_> = a
1403            .editor
1404            .buffer_locals
1405            .get(&buffer_id)
1406            .and_then(|locals| locals.get::<lattice_mode::ActiveCompletionSources>())
1407            .map(|c| c.0.iter().map(|c| c.id.as_str().to_string()).collect())
1408            .unwrap_or_default();
1409        assert!(
1410            !pre_ids.contains(&"gen:lsp-completion".to_string()),
1411            "pre-cascade cache should not contain LSP: {pre_ids:?}",
1412        );
1413        a.toggle_mode_by_name("lsp-mode");
1414        let cache = a
1415            .editor
1416            .buffer_locals
1417            .get(&buffer_id)
1418            .and_then(|locals| locals.get::<lattice_mode::ActiveCompletionSources>())
1419            .expect("cache present");
1420        let post_ids: Vec<_> = cache.0.iter().map(|c| c.id.as_str().to_string()).collect();
1421        assert!(
1422            post_ids.contains(&"gen:lsp-completion".to_string()),
1423            "post-cascade cache should include gen:lsp-completion; got {post_ids:?}",
1424        );
1425        let lsp = cache
1426            .0
1427            .iter()
1428            .find(|c| c.id.as_str() == "gen:lsp-completion")
1429            .unwrap();
1430        assert_eq!(lsp.popup_filter_chord, Some('o'));
1431        assert_eq!(lsp.kind.kind_label(), "async");
1432    }
1433
1434    /// CSM.4–CSM.7: source-contributing modes auto-activate on
1435    /// Document. The cache seeds with buffer-words, snippet,
1436    /// tree-sitter, and path contributions at boot. LSP (CSM.8a)
1437    /// rides on the M.6.1 cascade -- only attaches when the
1438    /// `lsp-mode` umbrella activates; tested separately.
1439    #[test]
1440    fn active_completion_sources_seeded_with_default_modes_at_boot() {
1441        let a = app_with("alpha bravo", 10);
1442        let cache = a
1443            .editor
1444            .buffer_locals
1445            .get(&a.editor.document_buffer_id)
1446            .and_then(|locals| locals.get::<lattice_mode::ActiveCompletionSources>())
1447            .expect("cache should be seeded at boot");
1448        let ids: Vec<_> = cache.0.iter().map(|c| c.id.as_str().to_string()).collect();
1449        assert!(ids.contains(&"gen:buffer-words".to_string()), "got {ids:?}");
1450        assert!(ids.contains(&"gen:snippet".to_string()), "got {ids:?}");
1451        assert!(
1452            ids.contains(&"gen:tree-sitter-symbol".to_string()),
1453            "got {ids:?}",
1454        );
1455        assert!(ids.contains(&"gen:path".to_string()), "got {ids:?}");
1456        let buffer_words = cache
1457            .0
1458            .iter()
1459            .find(|c| c.id.as_str() == "gen:buffer-words")
1460            .unwrap();
1461        assert_eq!(buffer_words.popup_filter_chord, Some('b'));
1462        let snippet = cache
1463            .0
1464            .iter()
1465            .find(|c| c.id.as_str() == "gen:snippet")
1466            .unwrap();
1467        // Snippets have no dedicated filter chord per §12.
1468        assert!(snippet.popup_filter_chord.is_none());
1469        let tree_sitter = cache
1470            .0
1471            .iter()
1472            .find(|c| c.id.as_str() == "gen:tree-sitter-symbol")
1473            .unwrap();
1474        assert_eq!(tree_sitter.popup_filter_chord, Some('t'));
1475        let path = cache
1476            .0
1477            .iter()
1478            .find(|c| c.id.as_str() == "gen:path")
1479            .unwrap();
1480        assert_eq!(path.popup_filter_chord, Some('f'));
1481    }
1482
1483    /// CSM.4: triggering the popup populates candidates via
1484    /// the mode-contributed `buffer-words` source through the
1485    /// cache reader. No hardcoded call path anymore -- the
1486    /// only way candidates show up is via the
1487    /// `ActiveCompletionSources` walk.
1488    #[test]
1489    fn buffer_words_populates_via_mode_contributed_source() {
1490        let mut a = app_with("alpha bravo charlie ", 10);
1491        a.editor.modal = ModalState::Insert;
1492        a.editor.cursor = Position::new(0, 20);
1493        a.do_completion_trigger();
1494        let state = a.editor.insert_completion.as_ref().expect("popup open");
1495        let labels: Vec<String> = state.rendered.iter().map(|c| c.raw.text.clone()).collect();
1496        assert!(
1497            labels.contains(&"alpha".to_string()),
1498            "buffer-words should populate via the mode-contributed path; \
1499             got candidates: {labels:?}",
1500        );
1501    }
1502
1503    /// The cache recomputes on mode transitions -- registering
1504    /// and activating a synthetic source-contributing mode adds
1505    /// its contribution to the cache (alongside the auto-active
1506    /// buffer-words contribution).
1507    #[test]
1508    fn active_completion_sources_recomputes_after_activation() {
1509        use lattice_completion::{
1510            CompletionSourceContribution, CompletionSourceKind, RawCandidate, SyncCompletionSource,
1511        };
1512        use std::sync::Arc;
1513
1514        #[derive(Debug)]
1515        struct StubSource;
1516        impl SyncCompletionSource for StubSource {
1517            fn produce(&self, _ctx: &lattice_completion::InsertContext<'_>) -> Vec<RawCandidate> {
1518                vec![RawCandidate::plain(
1519                    "stub".to_string(),
1520                    lattice_completion::CandidateKind::Plain,
1521                )]
1522            }
1523        }
1524        struct StubMode;
1525        impl lattice_mode::Mode for StubMode {
1526            type Guard = ();
1527            fn id(&self) -> lattice_mode::ModeId {
1528                lattice_mode::ModeId::new("stub-csm3-mode")
1529            }
1530            fn kind(&self) -> lattice_mode::ModeKind {
1531                lattice_mode::ModeKind::Minor
1532            }
1533            fn completion_sources(&self) -> Vec<CompletionSourceContribution> {
1534                vec![CompletionSourceContribution {
1535                    accepts_non_word_query: false,
1536                    id: lattice_completion::SourceId::new("gen:stub-csm3"),
1537                    default_priority: 50,
1538                    auto_trigger: true,
1539                    trigger_chars: Vec::new(),
1540                    popup_filter_chord: None,
1541                    kind: CompletionSourceKind::Sync(Arc::new(StubSource)),
1542                }]
1543            }
1544            fn on_activate(
1545                &self,
1546                _ctx: lattice_mode::ModeContext,
1547            ) -> lattice_mode::LifecycleFuture<'_, ()> {
1548                Box::pin(async { Ok(()) })
1549            }
1550        }
1551
1552        let mut a = app_with("hi", 5);
1553        let mode_id = {
1554            let mut registry = (**a.editor.mode_registry.load()).clone();
1555            let id = registry.register(StubMode).expect("register");
1556            a.editor.mode_registry.store(std::sync::Arc::new(registry));
1557            id
1558        };
1559        let buffer_id = a.editor.document_buffer_id;
1560        a.activate_mode_by_id(buffer_id, mode_id);
1561
1562        // CSM.4: buffer-words-mode contributes too, so the
1563        // cache holds two entries -- the auto-active
1564        // `gen:buffer-words` plus the freshly-activated
1565        // `gen:stub-csm3`.
1566        let cache = a
1567            .editor
1568            .buffer_locals
1569            .get(&buffer_id)
1570            .and_then(|locals| locals.get::<lattice_mode::ActiveCompletionSources>())
1571            .expect("cache present");
1572        let ids: Vec<_> = cache.0.iter().map(|c| c.id.as_str().to_string()).collect();
1573        assert!(
1574            ids.contains(&"gen:buffer-words".to_string()),
1575            "buffer-words contribution should remain; got {ids:?}",
1576        );
1577        assert!(
1578            ids.contains(&"gen:stub-csm3".to_string()),
1579            "stub mode's source should be cached; got {ids:?}",
1580        );
1581
1582        a.deactivate_mode_by_id(buffer_id, mode_id);
1583        let cache = a
1584            .editor
1585            .buffer_locals
1586            .get(&buffer_id)
1587            .and_then(|locals| locals.get::<lattice_mode::ActiveCompletionSources>())
1588            .expect("cache present");
1589        let ids: Vec<_> = cache.0.iter().map(|c| c.id.as_str().to_string()).collect();
1590        assert!(
1591            !ids.contains(&"gen:stub-csm3".to_string()),
1592            "stub source should drop after deactivation; got {ids:?}",
1593        );
1594        // buffer-words remains (its mode is still active).
1595        assert!(ids.contains(&"gen:buffer-words".to_string()));
1596    }
1597
1598    /// `completion_popup_active()` reads the mode-active state,
1599    /// not the `insert_completion` field. With the field manually
1600    /// nulled (test-only foot-gun -- production code uses
1601    /// `do_completion_cancel`), the mode stays active until the
1602    /// next reconcile. This pins the gate's source-of-truth
1603    /// inversion: external readers see the mode, not the state.
1604    #[test]
1605    fn completion_popup_active_reads_mode_not_state_field() {
1606        let mut a = app_with("alpha bravo charlie ", 10);
1607        a.editor.modal = ModalState::Insert;
1608        a.editor.cursor = Position::new(0, 20);
1609        a.apply(Action::CompletionTrigger);
1610        assert!(a.completion_popup_active());
1611        // Manually drop the popup state (skipping the reconcile).
1612        a.editor.insert_completion = None;
1613        // Mode is still active because nothing's run
1614        // `sync_keymap_overlays` between the manual drop and the
1615        // read. External readers see "popup is active" until the
1616        // next dispatch tail.
1617        assert!(a.completion_popup_active());
1618        // Reconcile brings mode + state back into lockstep.
1619        a.sync_keymap_overlays();
1620        assert!(!a.completion_popup_active());
1621    }
1622
1623    /// CSM.K2: `do_completion_filter_to_source` narrows the
1624    /// rendered list to candidates whose `source` matches the
1625    /// supplied id. The other source's candidates stay in
1626    /// `state.raw` so a subsequent `do_completion_filter_clear`
1627    /// can restore them.
1628    #[test]
1629    fn completion_filter_to_source_narrows_rendered_list() {
1630        let mut a = app_with("", 10);
1631        a.editor.modal = ModalState::Insert;
1632        a.editor.cursor = Position::new(0, 0);
1633        let mut state = lattice_completion::InsertCompletionState::open(
1634            lattice_completion::CompletionTrigger::Manual,
1635            Position::ZERO,
1636            Position::ZERO,
1637            String::new(),
1638        );
1639        state.raw.push(
1640            lattice_completion::RawCandidate::plain(
1641                "from_words",
1642                lattice_completion::CandidateKind::Plain,
1643            )
1644            .with_source(lattice_completion::SourceId::new(
1645                lattice_completion::BufferWordsSource::ID,
1646            )),
1647        );
1648        state.raw.push(
1649            lattice_completion::RawCandidate::plain(
1650                "from_lsp",
1651                lattice_completion::CandidateKind::Plain,
1652            )
1653            .with_source(lattice_completion::SourceId::new(
1654                lattice_completion::LSP_COMPLETION_SOURCE_ID,
1655            )),
1656        );
1657        a.refilter_insert_completion(&mut state);
1658        assert_eq!(state.rendered.len(), 2);
1659        a.editor.insert_completion = Some(state);
1660        a.do_completion_filter_to_source(lattice_completion::LSP_COMPLETION_SOURCE_ID.to_string());
1661        let s = a.editor.insert_completion.as_ref().unwrap();
1662        assert_eq!(s.rendered.len(), 1);
1663        assert_eq!(s.rendered[0].raw.text, "from_lsp");
1664        // Both raw rows survive so `clear` can restore them.
1665        assert_eq!(s.raw.len(), 2);
1666    }
1667
1668    /// CSM.K2: `do_completion_filter_clear` removes the active
1669    /// filter and refilters against the full raw pool.
1670    #[test]
1671    fn completion_filter_clear_restores_full_list() {
1672        let mut a = app_with("", 10);
1673        a.editor.modal = ModalState::Insert;
1674        a.editor.cursor = Position::new(0, 0);
1675        let mut state = lattice_completion::InsertCompletionState::open(
1676            lattice_completion::CompletionTrigger::Manual,
1677            Position::ZERO,
1678            Position::ZERO,
1679            String::new(),
1680        );
1681        state.raw.push(
1682            lattice_completion::RawCandidate::plain(
1683                "from_words",
1684                lattice_completion::CandidateKind::Plain,
1685            )
1686            .with_source(lattice_completion::SourceId::new(
1687                lattice_completion::BufferWordsSource::ID,
1688            )),
1689        );
1690        state.raw.push(
1691            lattice_completion::RawCandidate::plain(
1692                "from_lsp",
1693                lattice_completion::CandidateKind::Plain,
1694            )
1695            .with_source(lattice_completion::SourceId::new(
1696                lattice_completion::LSP_COMPLETION_SOURCE_ID,
1697            )),
1698        );
1699        state.source_filter = Some(lattice_completion::SourceId::new(
1700            lattice_completion::LSP_COMPLETION_SOURCE_ID,
1701        ));
1702        a.refilter_insert_completion(&mut state);
1703        assert_eq!(state.rendered.len(), 1);
1704        a.editor.insert_completion = Some(state);
1705        a.do_completion_filter_clear();
1706        let s = a.editor.insert_completion.as_ref().unwrap();
1707        assert!(s.source_filter.is_none());
1708        assert_eq!(s.rendered.len(), 2);
1709    }
1710}