Skip to main content

scrive_core/intel/
completion.rs

1//! The completion controller state machine — core view-state, driven by
2//! the widget and headlessly tested against a stub provider. It owns the popup
3//! list, local prefix filtering, Escape-stickiness, and the provider-call budget
4//! (at most one `complete()` per input event); it never touches the document.
5//! Accepting returns the chosen item for the caller to apply as one sealed
6//! transaction.
7
8use super::providers::{CompletionCx, CompletionItem, CompletionTrigger, Completions};
9
10/// The popup's open/closed/dismissed state.
11pub enum CompletionState {
12    /// No popup.
13    Closed,
14    /// A popup is showing `PopupList`.
15    Open(PopupList),
16    /// Dismissed by Escape and sticky until a word boundary: word chars
17    /// extending the same word do NOT reopen the popup; a word boundary (or any
18    /// non-word input) restores normal rules.
19    DismissedUntilBoundary,
20}
21
22/// The live popup: the provider's items plus the local filter/selection over
23/// them. The widget renders `items[filtered[i]]` for the visible window and
24/// highlights `filtered[selected]`.
25pub struct PopupList {
26    /// The provider's items, unfiltered (the reference set the filter indexes).
27    pub items: Vec<CompletionItem>,
28    /// Indices into `items` passing the local prefix filter, in `sort_key`
29    /// order. Rebuilt on every refilter.
30    pub filtered: Vec<u32>,
31    /// Index into `filtered` (not `items`); resets to 0 on every refilter.
32    pub selected: u32,
33    /// Absolute byte offset of the completion-word start — the popup's x anchor.
34    pub anchor: u32,
35}
36
37impl PopupList {
38    /// Rebuild `filtered` as the items whose label starts with `word` (folding
39    /// ASCII case — completion labels are code identifiers), in `sort_key` order
40    /// (so the provider's tiers survive), and reset the selection to the top.
41    ///
42    /// Runs on **every word char while the popup is open**, so it allocates
43    /// nothing: it reuses `filtered`'s capacity (`clear` + `extend`) and compares
44    /// prefixes in place — no per-item lowercased `String` and no fresh index
45    /// `Vec` per keystroke, keeping the filter O(items) in comparisons only.
46    fn refilter(&mut self, word: &str) {
47        self.filtered.clear();
48        self.filtered.extend(
49            (0..self.items.len() as u32)
50                .filter(|&i| prefix_matches(&self.items[i as usize].label, word)),
51        );
52        self.filtered
53            .sort_by(|&a, &b| self.items[a as usize].sort_key.cmp(&self.items[b as usize].sort_key));
54        self.selected = 0;
55    }
56}
57
58/// Whether `label` begins with `prefix`, folding ASCII case only. Compares the
59/// raw bytes (`as_bytes` never panics on a non-char-boundary split, and
60/// `eq_ignore_ascii_case` leaves multi-byte UTF-8 exact), so no lowercased copy
61/// is allocated — the hot completion-filter primitive.
62fn prefix_matches(label: &str, prefix: &str) -> bool {
63    let (l, p) = (label.as_bytes(), prefix.as_bytes());
64    l.len() >= p.len() && l[..p.len()].eq_ignore_ascii_case(p)
65}
66
67/// The completion controller. Holds only the popup state; the document, provider,
68/// and caret are supplied by the caller per event.
69pub struct CompletionController {
70    state: CompletionState,
71}
72
73impl Default for CompletionController {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl CompletionController {
80    /// A fresh, closed controller.
81    #[must_use]
82    pub fn new() -> Self {
83        Self { state: CompletionState::Closed }
84    }
85
86    /// The current state (for the widget to render).
87    #[must_use]
88    pub fn state(&self) -> &CompletionState {
89        &self.state
90    }
91
92    /// Whether a popup is showing.
93    #[must_use]
94    pub fn is_open(&self) -> bool {
95        matches!(self.state, CompletionState::Open(_))
96    }
97
98    /// Drive the machine on a completion-relevant keystroke, per `cx.trigger`:
99    /// a **trigger char / Ctrl+Space** requests fresh items in any state (even
100    /// while dismissed); a **word char** opens from `Closed`, refilters locally
101    /// while `Open` (no provider call), and is ignored while dismissed. `word` is
102    /// the live completion-word text under the caret. Calls `provider.complete`
103    /// at most once per event.
104    pub fn on_input(&mut self, cx: &CompletionCx, word: &str, provider: &mut dyn Completions) {
105        match cx.trigger {
106            CompletionTrigger::Typed(_) => match &mut self.state {
107                CompletionState::Open(list) => {
108                    list.refilter(word);
109                    if list.filtered.is_empty() {
110                        self.state = CompletionState::Closed;
111                    }
112                }
113                CompletionState::DismissedUntilBoundary => {} // stay dismissed
114                CompletionState::Closed => {
115                    let items = provider.complete(cx);
116                    self.set_from_items(items, word, cx.word.start);
117                }
118            },
119            CompletionTrigger::TriggerChar(_) | CompletionTrigger::Manual => {
120                // A fresh request in any state — a trigger char reopens even when
121                // Escape-dismissed.
122                let items = provider.complete(cx);
123                self.set_from_items(items, word, cx.word.start);
124            }
125        }
126    }
127
128    /// Ingest an externally-produced item list — an off-thread or
129    /// language-server completion result — as if a provider had returned it:
130    /// open and filter against the live `word` (with `anchor` the word start),
131    /// or close if nothing matches. The controller stays document-agnostic; the
132    /// caller is responsible for staleness (only call with results computed at
133    /// the current revision, refiltered here against the *current* word so a
134    /// result that arrives after the user typed more still filters correctly).
135    pub fn set_items(&mut self, items: Vec<CompletionItem>, word: &str, anchor: u32) {
136        self.set_from_items(items, word, anchor);
137    }
138
139    /// Open from a fresh item list, or close if empty (either the provider
140    /// returned nothing or nothing prefix-matches the live word).
141    fn set_from_items(&mut self, items: Vec<CompletionItem>, word: &str, anchor: u32) {
142        if items.is_empty() {
143            self.state = CompletionState::Closed;
144            return;
145        }
146        let mut list = PopupList { items, filtered: Vec::new(), selected: 0, anchor };
147        list.refilter(word);
148        self.state = if list.filtered.is_empty() {
149            CompletionState::Closed
150        } else {
151            CompletionState::Open(list)
152        };
153    }
154
155    /// A word boundary was crossed (a non-word / non-trigger char, or backspacing
156    /// out of the word) — clears Escape-stickiness so the next word can reopen.
157    pub fn on_boundary(&mut self) {
158        if matches!(self.state, CompletionState::DismissedUntilBoundary) {
159            self.state = CompletionState::Closed;
160        }
161    }
162
163    /// Set the selected row to `index` (a filtered-list index), clamped; no-op
164    /// when closed. Used by a popup row click.
165    pub fn set_selected(&mut self, index: u32) {
166        if let CompletionState::Open(list) = &mut self.state {
167            let n = list.filtered.len() as u32;
168            if n > 0 {
169                list.selected = index.min(n - 1);
170            }
171        }
172    }
173
174    /// Move the selection (Up/Down) with wrap; no-op when closed.
175    pub fn move_selection(&mut self, down: bool) {
176        if let CompletionState::Open(list) = &mut self.state {
177            let n = list.filtered.len() as u32;
178            if n == 0 {
179                return;
180            }
181            list.selected = if down {
182                (list.selected + 1) % n
183            } else {
184                (list.selected + n - 1) % n
185            };
186        }
187    }
188
189    /// Escape while open → sticky-dismiss; returns whether the event was captured
190    /// (so the widget knows not to let Escape fall through to other handlers).
191    pub fn escape(&mut self) -> bool {
192        if matches!(self.state, CompletionState::Open(_)) {
193            self.state = CompletionState::DismissedUntilBoundary;
194            true
195        } else {
196            false
197        }
198    }
199
200    /// Accept the selected item: returns it (cloned) and closes the popup. The
201    /// caller applies it as one sealed transaction — replace range = the item's
202    /// `replace`, else the live completion word — and, if `retrigger`, fires one
203    /// `Manual` `on_input`. Returns `None` when not open / nothing selected.
204    pub fn accept(&mut self) -> Option<CompletionItem> {
205        let item = match &self.state {
206            CompletionState::Open(list) => {
207                list.filtered.get(list.selected as usize).map(|&i| list.items[i as usize].clone())
208            }
209            _ => None,
210        };
211        if item.is_some() {
212            self.state = CompletionState::Closed;
213        }
214        item
215    }
216
217    /// Hard close — a caret move not caused by typing (arrow/click/find-jump),
218    /// focus loss, `set_text`, or undo/redo. Idempotent.
219    pub fn close(&mut self) {
220        self.state = CompletionState::Closed;
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::intel::providers::CompletionKind;
228    use crate::Point;
229
230    fn kw(label: &str, sort: &str) -> CompletionItem {
231        CompletionItem::plain(label, CompletionKind::Keyword).with_sort_key(sort)
232    }
233
234    struct Stub {
235        items: Vec<CompletionItem>,
236        calls: u32,
237    }
238    impl Completions for Stub {
239        fn complete(&mut self, _cx: &CompletionCx) -> Vec<CompletionItem> {
240            self.calls += 1;
241            self.items.clone()
242        }
243    }
244
245    fn cx(word: &str, trigger: CompletionTrigger) -> CompletionCx {
246        let doc = crate::Buffer::new("").unwrap().doc_id();
247        CompletionCx {
248            doc,
249            revision: 0,
250            position: Point::new(0, word.len() as u32),
251            word: 0..word.len() as u32,
252            lookback: word.to_string(),
253            trigger,
254        }
255    }
256
257    fn labels(c: &CompletionController) -> Vec<String> {
258        match c.state() {
259            CompletionState::Open(list) => {
260                list.filtered.iter().map(|&i| list.items[i as usize].label.clone()).collect()
261            }
262            _ => vec![],
263        }
264    }
265
266    #[test]
267    fn word_char_opens_from_closed_and_prefix_filters() {
268        let mut stub = Stub { items: vec![kw("send", "3_send"), kw("set", "3_set"), kw("let", "3_let")], calls: 0 };
269        let mut c = CompletionController::new();
270        c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
271        assert!(c.is_open());
272        assert_eq!(labels(&c), ["send", "set"], "prefix 's' in sort_key order");
273        assert_eq!(stub.calls, 1);
274    }
275
276    #[test]
277    fn open_word_char_refilters_without_a_provider_call() {
278        let mut stub = Stub { items: vec![kw("send", "3_send"), kw("set", "3_set")], calls: 0 };
279        let mut c = CompletionController::new();
280        c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
281        assert_eq!(stub.calls, 1);
282        // Extending the word refilters locally — no second provider call.
283        c.on_input(&cx("se", CompletionTrigger::Typed('e')), "se", &mut stub);
284        assert_eq!(stub.calls, 1, "Open × word char must not call the provider");
285        assert_eq!(labels(&c), ["send", "set"]);
286        c.on_input(&cx("set", CompletionTrigger::Typed('t')), "set", &mut stub);
287        assert_eq!(labels(&c), ["set"]);
288        // Filtering to nothing closes it.
289        c.on_input(&cx("setx", CompletionTrigger::Typed('x')), "setx", &mut stub);
290        assert!(!c.is_open());
291    }
292
293    #[test]
294    fn empty_provider_result_closes() {
295        let mut stub = Stub { items: vec![], calls: 0 };
296        let mut c = CompletionController::new();
297        c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
298        assert!(!c.is_open());
299    }
300
301    #[test]
302    fn escape_is_sticky_until_a_boundary_but_a_trigger_char_reopens() {
303        let mut stub = Stub { items: vec![kw("send", "3_send")], calls: 0 };
304        let mut c = CompletionController::new();
305        c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
306        assert!(c.escape(), "Escape while open is captured");
307        assert!(matches!(c.state(), CompletionState::DismissedUntilBoundary));
308        // Extending the word stays dismissed — no reopen, no provider call.
309        c.on_input(&cx("se", CompletionTrigger::Typed('e')), "se", &mut stub);
310        assert!(!c.is_open());
311        assert_eq!(stub.calls, 1);
312        // A trigger char reopens even while dismissed.
313        c.on_input(&cx("", CompletionTrigger::TriggerChar('(')), "", &mut stub);
314        assert!(c.is_open());
315        assert_eq!(stub.calls, 2);
316        // Re-dismiss, then a word boundary restores normal rules.
317        c.escape();
318        c.on_boundary();
319        assert!(matches!(c.state(), CompletionState::Closed));
320        c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
321        assert!(c.is_open());
322    }
323
324    #[test]
325    fn selection_wraps_and_accept_returns_it_then_closes() {
326        let mut stub = Stub { items: vec![kw("a", "1"), kw("b", "2"), kw("c", "3")], calls: 0 };
327        let mut c = CompletionController::new();
328        c.on_input(&cx("", CompletionTrigger::Manual), "", &mut stub);
329        assert_eq!(labels(&c), ["a", "b", "c"]);
330        c.move_selection(true); // -> b
331        c.move_selection(true); // -> c
332        c.move_selection(true); // wrap -> a
333        c.move_selection(false); // wrap -> c
334        let accepted = c.accept().expect("open → accepts");
335        assert_eq!(accepted.label, "c");
336        assert!(matches!(c.state(), CompletionState::Closed));
337        assert!(c.accept().is_none(), "closed → nothing to accept");
338    }
339
340    #[test]
341    fn set_selected_selects_a_row_and_clamps() {
342        let mut stub = Stub { items: vec![kw("a", "1"), kw("b", "2"), kw("c", "3")], calls: 0 };
343        let mut c = CompletionController::new();
344        c.on_input(&cx("", CompletionTrigger::Manual), "", &mut stub);
345        c.set_selected(1);
346        assert_eq!(c.accept().unwrap().label, "b", "clicked row 1");
347        // Reopen; a past-the-end index clamps to the last row.
348        c.on_input(&cx("", CompletionTrigger::Manual), "", &mut stub);
349        c.set_selected(99);
350        assert_eq!(c.accept().unwrap().label, "c", "clamped to the last row");
351    }
352
353    #[test]
354    fn caret_move_closes_and_escape_when_closed_is_not_captured() {
355        let mut stub = Stub { items: vec![kw("a", "1")], calls: 0 };
356        let mut c = CompletionController::new();
357        c.on_input(&cx("a", CompletionTrigger::Typed('a')), "a", &mut stub);
358        assert!(c.is_open());
359        c.close();
360        assert!(matches!(c.state(), CompletionState::Closed));
361        assert!(!c.escape(), "Escape while closed is not captured");
362    }
363}