Skip to main content

hjkl_completion/
lib.rs

1//! Completion popup data model for the hjkl editor stack.
2//!
3//! `Completion` is the data half; the TUI renderer (`hjkl-completion-tui`)
4//! is the view half. This crate has no UI dependencies.
5//!
6//! # Example
7//!
8//! ```
9//! use hjkl_completion::{Completion, CompletionItem};
10//!
11//! let items = vec![CompletionItem::new("println")];
12//! let popup = Completion::new(0, 0, items);
13//! assert_eq!(popup.visible.len(), 1);
14//! assert!(!popup.is_empty());
15//! ```
16
17/// What kind of symbol this completion item represents.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19#[non_exhaustive]
20pub enum CompletionKind {
21    Function,
22    Method,
23    Variable,
24    Field,
25    Class,
26    Module,
27    Interface,
28    Enum,
29    Constant,
30    Property,
31    Snippet,
32    Keyword,
33    File,
34    Folder,
35    #[default]
36    Other,
37}
38
39impl CompletionKind {
40    /// Single-character icon for this completion kind, suitable for display
41    /// in a narrow gutter column.
42    pub fn icon(self) -> char {
43        match self {
44            Self::Function | Self::Method => '\u{0192}', // ƒ
45            Self::Variable => 'v',
46            Self::Field | Self::Property => '\u{00B7}', // ·
47            Self::Class | Self::Interface => 'C',
48            Self::Module => 'M',
49            Self::Enum => 'E',
50            Self::Constant => 'k',
51            Self::Snippet => '\u{25C6}', // ◆
52            Self::Keyword => 'K',
53            Self::File | Self::Folder => '\u{25F0}', // ◰
54            Self::Other => '\u{00B7}',               // ·
55        }
56    }
57}
58
59/// A single item in a completion list.
60#[derive(Debug, Clone)]
61#[non_exhaustive]
62pub struct CompletionItem {
63    pub label: String,
64    pub detail: Option<String>,
65    pub kind: CompletionKind,
66    /// Text to insert; may equal `label` for plain word completions.
67    pub insert_text: String,
68    /// Text used for fuzzy matching (often the same as `label`).
69    pub filter_text: Option<String>,
70    // sort_text is parsed from the server but ordering by it is deferred to
71    // a follow-up; server-provided order is preserved as-is in Phase 4.
72}
73
74impl CompletionItem {
75    /// Construct a minimal item from a label, using `Other` as the kind and
76    /// `label` as the `insert_text`.
77    pub fn new(label: impl Into<String>) -> Self {
78        let label = label.into();
79        let insert_text = label.clone();
80        Self {
81            label,
82            detail: None,
83            kind: CompletionKind::Other,
84            insert_text,
85            filter_text: None,
86        }
87    }
88}
89
90impl Default for CompletionItem {
91    fn default() -> Self {
92        Self::new("")
93    }
94}
95
96/// Active completion popup state.
97#[derive(Debug, Clone)]
98#[non_exhaustive]
99pub struct Completion {
100    /// Cursor row (0-based) when the popup was opened.
101    pub anchor_row: usize,
102    /// Cursor column (0-based) when the popup was opened.
103    pub anchor_col: usize,
104    /// All items returned by the server (unfiltered).
105    pub all_items: Vec<CompletionItem>,
106    /// Indices into `all_items` that survive the current `prefix` filter.
107    pub visible: Vec<usize>,
108    /// Selected index into `visible`.
109    pub selected: usize,
110    /// Prefix the user has typed since the popup was opened.
111    pub prefix: String,
112    /// Whether the renderer last drew the popup *above* the anchor line
113    /// (flipped) rather than below it. Set by the view each frame via
114    /// [`Completion::note_flip`]; consumed by [`Completion::cycle_down`] /
115    /// [`Completion::cycle_up`] so cursor-key navigation always moves the
116    /// highlight in the on-screen direction the user pressed. `Cell` so the
117    /// view can record it through a shared `&Completion` borrow.
118    flipped: std::cell::Cell<bool>,
119    /// Per-item fold cache: `(lowercased filter text, char vec)` for fuzzy
120    /// matching. `set_prefix` re-runs on every keystroke, and re-lowercasing
121    /// and re-chunking every item each time dominated the cost. The item
122    /// list is fixed for a popup's lifetime — consumers build a fresh
123    /// `Completion` per item fetch — so the cache is rebuilt only when
124    /// `all_items` changes size.
125    lower_cache: Vec<(String, Vec<char>)>,
126    /// `all_items.len()` the cache was built for; a mismatch triggers a
127    /// rebuild.
128    lower_cache_len: usize,
129}
130
131impl Completion {
132    /// Create a new popup anchored at `(anchor_row, anchor_col)` with the
133    /// given item list. All items are immediately visible (empty prefix).
134    ///
135    /// # Example
136    ///
137    /// ```
138    /// use hjkl_completion::{Completion, CompletionItem};
139    ///
140    /// let popup = Completion::new(1, 5, vec![CompletionItem::new("foo")]);
141    /// assert_eq!(popup.anchor_row, 1);
142    /// assert_eq!(popup.anchor_col, 5);
143    /// assert_eq!(popup.visible.len(), 1);
144    /// ```
145    pub fn new(anchor_row: usize, anchor_col: usize, items: Vec<CompletionItem>) -> Self {
146        let visible: Vec<usize> = (0..items.len()).collect();
147        Self {
148            anchor_row,
149            anchor_col,
150            all_items: items,
151            visible,
152            selected: 0,
153            prefix: String::new(),
154            flipped: std::cell::Cell::new(false),
155            lower_cache: Vec::new(),
156            lower_cache_len: 0,
157        }
158    }
159
160    /// Refilter visible items using a case-insensitive subsequence match
161    /// against `prefix`, **ranked by match quality** (best first): an exact
162    /// match outranks a prefix match, which outranks a contiguous run, which
163    /// outranks a scattered subsequence; shorter candidates and earlier / more
164    /// word-boundary-aligned matches score higher. Ties keep the original
165    /// (server-provided) order for stability. Resets `selected` to 0 so the
166    /// best match is auto-selected.
167    pub fn set_prefix(&mut self, prefix: &str) {
168        self.prefix = prefix.to_string();
169        // Rebuild the per-item fold cache only when the item list changed;
170        // the folds don't depend on the prefix, so every keystroke after the
171        // first reuses them (see `lower_cache` docs).
172        if self.lower_cache_len != self.all_items.len() {
173            self.lower_cache = self
174                .all_items
175                .iter()
176                .map(|item| {
177                    let haystack = item
178                        .filter_text
179                        .as_deref()
180                        .unwrap_or(&item.label)
181                        .to_lowercase();
182                    let chars: Vec<char> = haystack.chars().collect();
183                    (haystack, chars)
184                })
185                .collect();
186            self.lower_cache_len = self.all_items.len();
187        }
188        let needle = prefix.to_lowercase();
189        let needle_chars: Vec<char> = needle.chars().collect();
190        let mut scored: Vec<(usize, i32)> = (0..self.all_items.len())
191            .filter_map(|idx| {
192                let (haystack, chars) = &self.lower_cache[idx];
193                match_score_chars(haystack, chars, &needle, &needle_chars).map(|score| (idx, score))
194            })
195            .collect();
196        // Higher score first; on a tie fall back to the original index so the
197        // sort is stable and the server's preferred ordering is preserved.
198        scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
199        self.visible = scored.into_iter().map(|(idx, _)| idx).collect();
200        self.selected = 0;
201    }
202
203    /// Move selection down one step, wrapping at the end.
204    pub fn select_next(&mut self) {
205        if self.visible.is_empty() {
206            return;
207        }
208        self.selected = (self.selected + 1) % self.visible.len();
209    }
210
211    /// Move selection up one step, wrapping at the start.
212    pub fn select_prev(&mut self) {
213        if self.visible.is_empty() {
214            return;
215        }
216        if self.selected == 0 {
217            self.selected = self.visible.len() - 1;
218        } else {
219            self.selected -= 1;
220        }
221    }
222
223    /// Record whether the view drew this popup flipped (above the anchor).
224    /// Called by the renderer each frame; navigation then mirrors accordingly.
225    pub fn note_flip(&self, flipped: bool) {
226        self.flipped.set(flipped);
227    }
228
229    /// `true` when the popup was last rendered above the anchor line.
230    pub fn is_flipped(&self) -> bool {
231        self.flipped.get()
232    }
233
234    /// Move the highlight one row *down on screen*, regardless of orientation.
235    /// When not flipped, visual order == logical order, so this is `select_next`.
236    /// When flipped, the list is drawn inverted (best match at the bottom), so
237    /// moving down visually means stepping to the previous logical item.
238    pub fn cycle_down(&mut self) {
239        if self.flipped.get() {
240            self.select_prev();
241        } else {
242            self.select_next();
243        }
244    }
245
246    /// Move the highlight one row *up on screen*, regardless of orientation.
247    /// Inverse of [`Self::cycle_down`].
248    pub fn cycle_up(&mut self) {
249        if self.flipped.get() {
250            self.select_next();
251        } else {
252            self.select_prev();
253        }
254    }
255
256    /// Return the currently selected item, if any.
257    pub fn selected_item(&self) -> Option<&CompletionItem> {
258        self.visible
259            .get(self.selected)
260            .and_then(|&idx| self.all_items.get(idx))
261    }
262
263    /// True when no items match the current prefix — popup should auto-dismiss.
264    pub fn is_empty(&self) -> bool {
265        self.visible.is_empty()
266    }
267}
268
269impl Default for Completion {
270    fn default() -> Self {
271        Self::new(0, 0, Vec::new())
272    }
273}
274
275/// Fuzzy match score for `needle` against `haystack` (both already
276/// case-folded by the caller). `h` is the pre-collected char vec of
277/// `haystack` and `needle_chars` the pre-collected char vec of `needle`,
278/// both hoisted out of the per-item loop by [`Completion::set_prefix`] so
279/// repeated keystrokes don't re-chunk every candidate. Returns `None` when
280/// `needle` is not a subsequence of `haystack`; otherwise a score where
281/// **higher is better**.
282///
283/// Heuristics, roughly in order of weight:
284/// - exact equality is the strongest signal,
285/// - a prefix match (`haystack` starts with `needle`) is next,
286/// - contiguous matched runs beat scattered ones (gaps are penalized),
287/// - matches at a word boundary (start, or after `_`) earn a bonus,
288/// - an earlier first match and a shorter candidate score higher.
289///
290/// An empty `needle` matches everything with a neutral score, leaving the
291/// original ordering untouched.
292fn match_score_chars(
293    haystack: &str,
294    h: &[char],
295    needle: &str,
296    needle_chars: &[char],
297) -> Option<i32> {
298    if needle.is_empty() {
299        return Some(0);
300    }
301
302    let mut needle_iter = needle.chars();
303    let mut want = needle_iter.next();
304    let mut score: i32 = 0;
305    let mut first_match: Option<usize> = None;
306    let mut prev_match: Option<usize> = None;
307
308    for (i, &hc) in h.iter().enumerate() {
309        let Some(nc) = want else { break };
310        if hc == nc {
311            if first_match.is_none() {
312                first_match = Some(i);
313            }
314            match prev_match {
315                Some(p) if p + 1 == i => score += 15,   // contiguous run
316                Some(p) => score -= (i - p - 1) as i32, // gap penalty
317                None => {}
318            }
319            // Word-boundary bonus: start of string or right after `_`.
320            if i == 0 || h[i - 1] == '_' {
321                score += 10;
322            }
323            prev_match = Some(i);
324            want = needle_iter.next();
325        }
326    }
327
328    // Not all needle chars were consumed → not a subsequence.
329    if want.is_some() {
330        return None;
331    }
332
333    if let Some(f) = first_match {
334        score -= f as i32; // earlier first match is better
335    }
336    if h == needle_chars {
337        score += 1000; // exact match
338    } else if haystack.starts_with(needle) {
339        score += 100; // prefix match
340    }
341    score -= (h.len() as i32) / 4; // mild preference for shorter candidates
342    Some(score)
343}
344
345// ── Map lsp_types completion-item kinds ──────────────────────────────────────
346
347/// Convert an `lsp_types::CompletionItemKind` to our `CompletionKind`.
348pub fn kind_from_lsp(k: Option<lsp_types::CompletionItemKind>) -> CompletionKind {
349    use lsp_types::CompletionItemKind as K;
350    match k {
351        Some(K::FUNCTION) => CompletionKind::Function,
352        Some(K::METHOD) => CompletionKind::Method,
353        Some(K::VARIABLE) => CompletionKind::Variable,
354        Some(K::FIELD) => CompletionKind::Field,
355        Some(K::CLASS) => CompletionKind::Class,
356        Some(K::MODULE) => CompletionKind::Module,
357        Some(K::INTERFACE) => CompletionKind::Interface,
358        Some(K::ENUM) => CompletionKind::Enum,
359        Some(K::CONSTANT) | Some(K::ENUM_MEMBER) => CompletionKind::Constant,
360        Some(K::PROPERTY) => CompletionKind::Property,
361        Some(K::SNIPPET) => CompletionKind::Snippet,
362        Some(K::KEYWORD) => CompletionKind::Keyword,
363        Some(K::FILE) => CompletionKind::File,
364        Some(K::FOLDER) => CompletionKind::Folder,
365        _ => CompletionKind::Other,
366    }
367}
368
369/// Convert an `lsp_types::CompletionItem` to our local `CompletionItem`.
370pub fn item_from_lsp(src: lsp_types::CompletionItem) -> CompletionItem {
371    let insert_text = match src.text_edit.as_ref() {
372        Some(lsp_types::CompletionTextEdit::Edit(te)) => te.new_text.clone(),
373        Some(lsp_types::CompletionTextEdit::InsertAndReplace(ite)) => ite.new_text.clone(),
374        None => src.insert_text.clone().unwrap_or_else(|| src.label.clone()),
375    };
376    CompletionItem {
377        label: src.label.clone(),
378        detail: src.detail.clone(),
379        kind: kind_from_lsp(src.kind),
380        insert_text,
381        filter_text: src.filter_text,
382    }
383}
384
385// ── Unit tests ────────────────────────────────────────────────────────────────
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    fn make_item(label: &str) -> CompletionItem {
392        CompletionItem {
393            label: label.to_string(),
394            detail: None,
395            kind: CompletionKind::Other,
396            insert_text: label.to_string(),
397            filter_text: None,
398        }
399    }
400
401    fn popup(labels: &[&str]) -> Completion {
402        Completion::new(0, 0, labels.iter().map(|l| make_item(l)).collect())
403    }
404
405    #[test]
406    fn set_prefix_filters_with_subseq_match() {
407        let mut c = popup(&["foo_bar", "foobar", "baz"]);
408        c.set_prefix("fb");
409        // "foo_bar" → f..b.. ✓   "foobar" → f..b.. ✓   "baz" → no f ✗
410        assert_eq!(c.visible.len(), 2, "visible: {:?}", c.visible);
411    }
412
413    #[test]
414    fn set_prefix_case_insensitive() {
415        let mut c = popup(&["FooBar", "foobar"]);
416        c.set_prefix("FB");
417        assert_eq!(c.visible.len(), 2);
418    }
419
420    #[test]
421    fn set_prefix_ranks_exact_match_first() {
422        // The exact keyword "let" must outrank scattered subsequence matches
423        // like "STATUS_LINE_HEIGHT" (l…e…t) and prefix matches like "letter".
424        let mut c = popup(&["STATUS_LINE_HEIGHT", "letter", "let", "delete"]);
425        c.set_prefix("let");
426        let ranked: Vec<&str> = c
427            .visible
428            .iter()
429            .map(|&i| c.all_items[i].label.as_str())
430            .collect();
431        assert_eq!(ranked.first(), Some(&"let"), "ranked: {ranked:?}");
432        // "letter" (prefix match) must beat the scattered ones.
433        let letter_pos = ranked.iter().position(|&l| l == "letter").unwrap();
434        let status_pos = ranked
435            .iter()
436            .position(|&l| l == "STATUS_LINE_HEIGHT")
437            .unwrap();
438        assert!(
439            letter_pos < status_pos,
440            "prefix match must rank above scattered: {ranked:?}"
441        );
442    }
443
444    #[test]
445    fn set_prefix_prefers_shorter_on_prefix_tie() {
446        // Both start with "in"; the shorter identifier should rank first.
447        let mut c = popup(&["instantiate", "in"]);
448        c.set_prefix("in");
449        let first = c.all_items[c.visible[0]].label.as_str();
450        assert_eq!(first, "in");
451    }
452
453    #[test]
454    fn set_prefix_empty_resets_to_all_items() {
455        let mut c = popup(&["alpha", "beta", "gamma"]);
456        c.set_prefix("alp");
457        assert_eq!(c.visible.len(), 1);
458        c.set_prefix("");
459        assert_eq!(c.visible.len(), 3);
460    }
461
462    #[test]
463    fn set_prefix_cache_preserves_results_and_invalidates_on_growth() {
464        let mut c = popup(&["foo_bar", "foobar", "baz", "FooBar"]);
465        c.set_prefix("fb");
466        let first: Vec<String> = c
467            .visible
468            .iter()
469            .map(|&i| c.all_items[i].label.clone())
470            .collect();
471        assert_eq!(first, vec!["foo_bar", "foobar", "FooBar"]);
472
473        // A different prefix must not leak cached state; results are the
474        // pre-cache matcher's.
475        c.set_prefix("baz");
476        let baz: Vec<String> = c
477            .visible
478            .iter()
479            .map(|&i| c.all_items[i].label.clone())
480            .collect();
481        assert_eq!(baz, vec!["baz"]);
482
483        // Same prefix again → identical item set and order.
484        c.set_prefix("fb");
485        let second: Vec<String> = c
486            .visible
487            .iter()
488            .map(|&i| c.all_items[i].label.clone())
489            .collect();
490        assert_eq!(first, second);
491
492        // An item-list change invalidates the cache: the new item joins the
493        // match and previously-ranked items keep their relative order.
494        c.all_items.push(make_item("foo_bar2"));
495        c.set_prefix("fb");
496        let third: Vec<String> = c
497            .visible
498            .iter()
499            .map(|&i| c.all_items[i].label.clone())
500            .collect();
501        assert_eq!(third, vec!["foo_bar", "foo_bar2", "foobar", "FooBar"]);
502    }
503
504    #[test]
505    fn select_next_wraps_at_end() {
506        let mut c = popup(&["a", "b", "c"]);
507        c.selected = 2;
508        c.select_next();
509        assert_eq!(c.selected, 0);
510    }
511
512    #[test]
513    fn select_prev_wraps_at_start() {
514        let mut c = popup(&["a", "b", "c"]);
515        c.selected = 0;
516        c.select_prev();
517        assert_eq!(c.selected, 2);
518    }
519
520    #[test]
521    fn cycle_matches_logical_direction_when_not_flipped() {
522        let mut c = popup(&["a", "b", "c"]);
523        c.note_flip(false);
524        assert_eq!(c.selected, 0);
525        c.cycle_down(); // down on screen == next logical item
526        assert_eq!(c.selected, 1);
527        c.cycle_up();
528        assert_eq!(c.selected, 0);
529    }
530
531    #[test]
532    fn cycle_inverts_logical_direction_when_flipped() {
533        // Flipped: best match (logical 0) is drawn at the BOTTOM, so moving the
534        // highlight down on screen must step to the *previous* logical item
535        // (wrapping to the worst match), and up steps to the next-best.
536        let mut c = popup(&["a", "b", "c"]);
537        c.note_flip(true);
538        assert_eq!(c.selected, 0);
539        c.cycle_up(); // up on screen, flipped == next logical item
540        assert_eq!(c.selected, 1);
541        c.cycle_up();
542        assert_eq!(c.selected, 2);
543        c.cycle_down(); // down on screen, flipped == prev logical item
544        assert_eq!(c.selected, 1);
545        // From the best match, down on screen wraps past the bottom edge.
546        c.selected = 0;
547        c.cycle_down();
548        assert_eq!(c.selected, 2);
549    }
550
551    #[test]
552    fn is_empty_after_no_match_filter() {
553        let mut c = popup(&["alpha", "beta"]);
554        c.set_prefix("xyz");
555        assert!(c.is_empty());
556    }
557
558    #[test]
559    fn selected_item_returns_correct_item() {
560        let mut c = popup(&["alpha", "beta", "gamma"]);
561        c.set_prefix("bet");
562        // Only "beta" survives.
563        assert_eq!(c.visible.len(), 1);
564        assert_eq!(c.selected_item().map(|i| i.label.as_str()), Some("beta"));
565    }
566
567    #[test]
568    fn default_completion_is_empty() {
569        let c = Completion::default();
570        assert!(c.is_empty());
571        assert_eq!(c.anchor_row, 0);
572        assert_eq!(c.anchor_col, 0);
573    }
574
575    #[test]
576    fn completion_item_new_sets_insert_text_from_label() {
577        let item = CompletionItem::new("my_fn");
578        assert_eq!(item.label, "my_fn");
579        assert_eq!(item.insert_text, "my_fn");
580        assert!(matches!(item.kind, CompletionKind::Other));
581    }
582
583    #[test]
584    fn completion_kind_icon_coverage() {
585        assert_eq!(CompletionKind::Function.icon(), '\u{0192}');
586        assert_eq!(CompletionKind::Snippet.icon(), '\u{25C6}');
587        assert_eq!(CompletionKind::Other.icon(), '\u{00B7}');
588    }
589}