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            CompletionKind::Function | CompletionKind::Method => '\u{0192}', // ƒ
45            CompletionKind::Variable => 'v',
46            CompletionKind::Field | CompletionKind::Property => '\u{00B7}', // ·
47            CompletionKind::Class | CompletionKind::Interface => 'C',
48            CompletionKind::Module => 'M',
49            CompletionKind::Enum => 'E',
50            CompletionKind::Constant => 'k',
51            CompletionKind::Snippet => '\u{25C6}', // ◆
52            CompletionKind::Keyword => 'K',
53            CompletionKind::File | CompletionKind::Folder => '\u{25F0}', // ◰
54            CompletionKind::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}
120
121impl Completion {
122    /// Create a new popup anchored at `(anchor_row, anchor_col)` with the
123    /// given item list. All items are immediately visible (empty prefix).
124    ///
125    /// # Example
126    ///
127    /// ```
128    /// use hjkl_completion::{Completion, CompletionItem};
129    ///
130    /// let popup = Completion::new(1, 5, vec![CompletionItem::new("foo")]);
131    /// assert_eq!(popup.anchor_row, 1);
132    /// assert_eq!(popup.anchor_col, 5);
133    /// assert_eq!(popup.visible.len(), 1);
134    /// ```
135    pub fn new(anchor_row: usize, anchor_col: usize, items: Vec<CompletionItem>) -> Self {
136        let visible: Vec<usize> = (0..items.len()).collect();
137        Self {
138            anchor_row,
139            anchor_col,
140            all_items: items,
141            visible,
142            selected: 0,
143            prefix: String::new(),
144            flipped: std::cell::Cell::new(false),
145        }
146    }
147
148    /// Refilter visible items using a case-insensitive subsequence match
149    /// against `prefix`, **ranked by match quality** (best first): an exact
150    /// match outranks a prefix match, which outranks a contiguous run, which
151    /// outranks a scattered subsequence; shorter candidates and earlier / more
152    /// word-boundary-aligned matches score higher. Ties keep the original
153    /// (server-provided) order for stability. Resets `selected` to 0 so the
154    /// best match is auto-selected.
155    pub fn set_prefix(&mut self, prefix: &str) {
156        self.prefix = prefix.to_string();
157        let needle = prefix.to_lowercase();
158        let mut scored: Vec<(usize, i32)> = self
159            .all_items
160            .iter()
161            .enumerate()
162            .filter_map(|(idx, item)| {
163                let haystack = item
164                    .filter_text
165                    .as_deref()
166                    .unwrap_or(&item.label)
167                    .to_lowercase();
168                match_score(&haystack, &needle).map(|score| (idx, score))
169            })
170            .collect();
171        // Higher score first; on a tie fall back to the original index so the
172        // sort is stable and the server's preferred ordering is preserved.
173        scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
174        self.visible = scored.into_iter().map(|(idx, _)| idx).collect();
175        self.selected = 0;
176    }
177
178    /// Move selection down one step, wrapping at the end.
179    pub fn select_next(&mut self) {
180        if self.visible.is_empty() {
181            return;
182        }
183        self.selected = (self.selected + 1) % self.visible.len();
184    }
185
186    /// Move selection up one step, wrapping at the start.
187    pub fn select_prev(&mut self) {
188        if self.visible.is_empty() {
189            return;
190        }
191        if self.selected == 0 {
192            self.selected = self.visible.len() - 1;
193        } else {
194            self.selected -= 1;
195        }
196    }
197
198    /// Record whether the view drew this popup flipped (above the anchor).
199    /// Called by the renderer each frame; navigation then mirrors accordingly.
200    pub fn note_flip(&self, flipped: bool) {
201        self.flipped.set(flipped);
202    }
203
204    /// `true` when the popup was last rendered above the anchor line.
205    pub fn is_flipped(&self) -> bool {
206        self.flipped.get()
207    }
208
209    /// Move the highlight one row *down on screen*, regardless of orientation.
210    /// When not flipped, visual order == logical order, so this is `select_next`.
211    /// When flipped, the list is drawn inverted (best match at the bottom), so
212    /// moving down visually means stepping to the previous logical item.
213    pub fn cycle_down(&mut self) {
214        if self.flipped.get() {
215            self.select_prev();
216        } else {
217            self.select_next();
218        }
219    }
220
221    /// Move the highlight one row *up on screen*, regardless of orientation.
222    /// Inverse of [`Self::cycle_down`].
223    pub fn cycle_up(&mut self) {
224        if self.flipped.get() {
225            self.select_next();
226        } else {
227            self.select_prev();
228        }
229    }
230
231    /// Return the currently selected item, if any.
232    pub fn selected_item(&self) -> Option<&CompletionItem> {
233        self.visible
234            .get(self.selected)
235            .and_then(|&idx| self.all_items.get(idx))
236    }
237
238    /// True when no items match the current prefix — popup should auto-dismiss.
239    pub fn is_empty(&self) -> bool {
240        self.visible.is_empty()
241    }
242}
243
244impl Default for Completion {
245    fn default() -> Self {
246        Self::new(0, 0, Vec::new())
247    }
248}
249
250/// Fuzzy match score for `needle` against `haystack` (both already
251/// case-folded by the caller). Returns `None` when `needle` is not a
252/// subsequence of `haystack`; otherwise a score where **higher is better**.
253///
254/// Heuristics, roughly in order of weight:
255/// - exact equality is the strongest signal,
256/// - a prefix match (`haystack` starts with `needle`) is next,
257/// - contiguous matched runs beat scattered ones (gaps are penalized),
258/// - matches at a word boundary (start, or after `_`) earn a bonus,
259/// - an earlier first match and a shorter candidate score higher.
260///
261/// An empty `needle` matches everything with a neutral score, leaving the
262/// original ordering untouched.
263fn match_score(haystack: &str, needle: &str) -> Option<i32> {
264    if needle.is_empty() {
265        return Some(0);
266    }
267
268    let h: Vec<char> = haystack.chars().collect();
269    let mut needle_iter = needle.chars();
270    let mut want = needle_iter.next();
271    let mut score: i32 = 0;
272    let mut first_match: Option<usize> = None;
273    let mut prev_match: Option<usize> = None;
274
275    for (i, &hc) in h.iter().enumerate() {
276        let Some(nc) = want else { break };
277        if hc == nc {
278            if first_match.is_none() {
279                first_match = Some(i);
280            }
281            match prev_match {
282                Some(p) if p + 1 == i => score += 15,   // contiguous run
283                Some(p) => score -= (i - p - 1) as i32, // gap penalty
284                None => {}
285            }
286            // Word-boundary bonus: start of string or right after `_`.
287            if i == 0 || h[i - 1] == '_' {
288                score += 10;
289            }
290            prev_match = Some(i);
291            want = needle_iter.next();
292        }
293    }
294
295    // Not all needle chars were consumed → not a subsequence.
296    if want.is_some() {
297        return None;
298    }
299
300    if let Some(f) = first_match {
301        score -= f as i32; // earlier first match is better
302    }
303    if h == needle.chars().collect::<Vec<_>>() {
304        score += 1000; // exact match
305    } else if haystack.starts_with(needle) {
306        score += 100; // prefix match
307    }
308    score -= (h.len() as i32) / 4; // mild preference for shorter candidates
309    Some(score)
310}
311
312// ── Map lsp_types completion-item kinds ──────────────────────────────────────
313
314/// Convert an `lsp_types::CompletionItemKind` to our `CompletionKind`.
315pub fn kind_from_lsp(k: Option<lsp_types::CompletionItemKind>) -> CompletionKind {
316    use lsp_types::CompletionItemKind as K;
317    match k {
318        Some(K::FUNCTION) => CompletionKind::Function,
319        Some(K::METHOD) => CompletionKind::Method,
320        Some(K::VARIABLE) => CompletionKind::Variable,
321        Some(K::FIELD) => CompletionKind::Field,
322        Some(K::CLASS) => CompletionKind::Class,
323        Some(K::MODULE) => CompletionKind::Module,
324        Some(K::INTERFACE) => CompletionKind::Interface,
325        Some(K::ENUM) => CompletionKind::Enum,
326        Some(K::CONSTANT) | Some(K::ENUM_MEMBER) => CompletionKind::Constant,
327        Some(K::PROPERTY) => CompletionKind::Property,
328        Some(K::SNIPPET) => CompletionKind::Snippet,
329        Some(K::KEYWORD) => CompletionKind::Keyword,
330        Some(K::FILE) => CompletionKind::File,
331        Some(K::FOLDER) => CompletionKind::Folder,
332        _ => CompletionKind::Other,
333    }
334}
335
336/// Convert an `lsp_types::CompletionItem` to our local `CompletionItem`.
337pub fn item_from_lsp(src: lsp_types::CompletionItem) -> CompletionItem {
338    let insert_text = match src.text_edit.as_ref() {
339        Some(lsp_types::CompletionTextEdit::Edit(te)) => te.new_text.clone(),
340        Some(lsp_types::CompletionTextEdit::InsertAndReplace(ite)) => ite.new_text.clone(),
341        None => src.insert_text.clone().unwrap_or_else(|| src.label.clone()),
342    };
343    CompletionItem {
344        label: src.label.clone(),
345        detail: src.detail.clone(),
346        kind: kind_from_lsp(src.kind),
347        insert_text,
348        filter_text: src.filter_text.clone(),
349    }
350}
351
352// ── Unit tests ────────────────────────────────────────────────────────────────
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    fn make_item(label: &str) -> CompletionItem {
359        CompletionItem {
360            label: label.to_string(),
361            detail: None,
362            kind: CompletionKind::Other,
363            insert_text: label.to_string(),
364            filter_text: None,
365        }
366    }
367
368    fn popup(labels: &[&str]) -> Completion {
369        Completion::new(0, 0, labels.iter().map(|l| make_item(l)).collect())
370    }
371
372    #[test]
373    fn set_prefix_filters_with_subseq_match() {
374        let mut c = popup(&["foo_bar", "foobar", "baz"]);
375        c.set_prefix("fb");
376        // "foo_bar" → f..b.. ✓   "foobar" → f..b.. ✓   "baz" → no f ✗
377        assert_eq!(c.visible.len(), 2, "visible: {:?}", c.visible);
378    }
379
380    #[test]
381    fn set_prefix_case_insensitive() {
382        let mut c = popup(&["FooBar", "foobar"]);
383        c.set_prefix("FB");
384        assert_eq!(c.visible.len(), 2);
385    }
386
387    #[test]
388    fn set_prefix_ranks_exact_match_first() {
389        // The exact keyword "let" must outrank scattered subsequence matches
390        // like "STATUS_LINE_HEIGHT" (l…e…t) and prefix matches like "letter".
391        let mut c = popup(&["STATUS_LINE_HEIGHT", "letter", "let", "delete"]);
392        c.set_prefix("let");
393        let ranked: Vec<&str> = c
394            .visible
395            .iter()
396            .map(|&i| c.all_items[i].label.as_str())
397            .collect();
398        assert_eq!(ranked.first(), Some(&"let"), "ranked: {ranked:?}");
399        // "letter" (prefix match) must beat the scattered ones.
400        let letter_pos = ranked.iter().position(|&l| l == "letter").unwrap();
401        let status_pos = ranked
402            .iter()
403            .position(|&l| l == "STATUS_LINE_HEIGHT")
404            .unwrap();
405        assert!(
406            letter_pos < status_pos,
407            "prefix match must rank above scattered: {ranked:?}"
408        );
409    }
410
411    #[test]
412    fn set_prefix_prefers_shorter_on_prefix_tie() {
413        // Both start with "in"; the shorter identifier should rank first.
414        let mut c = popup(&["instantiate", "in"]);
415        c.set_prefix("in");
416        let first = c.all_items[c.visible[0]].label.as_str();
417        assert_eq!(first, "in");
418    }
419
420    #[test]
421    fn set_prefix_empty_resets_to_all_items() {
422        let mut c = popup(&["alpha", "beta", "gamma"]);
423        c.set_prefix("alp");
424        assert_eq!(c.visible.len(), 1);
425        c.set_prefix("");
426        assert_eq!(c.visible.len(), 3);
427    }
428
429    #[test]
430    fn select_next_wraps_at_end() {
431        let mut c = popup(&["a", "b", "c"]);
432        c.selected = 2;
433        c.select_next();
434        assert_eq!(c.selected, 0);
435    }
436
437    #[test]
438    fn select_prev_wraps_at_start() {
439        let mut c = popup(&["a", "b", "c"]);
440        c.selected = 0;
441        c.select_prev();
442        assert_eq!(c.selected, 2);
443    }
444
445    #[test]
446    fn cycle_matches_logical_direction_when_not_flipped() {
447        let mut c = popup(&["a", "b", "c"]);
448        c.note_flip(false);
449        assert_eq!(c.selected, 0);
450        c.cycle_down(); // down on screen == next logical item
451        assert_eq!(c.selected, 1);
452        c.cycle_up();
453        assert_eq!(c.selected, 0);
454    }
455
456    #[test]
457    fn cycle_inverts_logical_direction_when_flipped() {
458        // Flipped: best match (logical 0) is drawn at the BOTTOM, so moving the
459        // highlight down on screen must step to the *previous* logical item
460        // (wrapping to the worst match), and up steps to the next-best.
461        let mut c = popup(&["a", "b", "c"]);
462        c.note_flip(true);
463        assert_eq!(c.selected, 0);
464        c.cycle_up(); // up on screen, flipped == next logical item
465        assert_eq!(c.selected, 1);
466        c.cycle_up();
467        assert_eq!(c.selected, 2);
468        c.cycle_down(); // down on screen, flipped == prev logical item
469        assert_eq!(c.selected, 1);
470        // From the best match, down on screen wraps past the bottom edge.
471        c.selected = 0;
472        c.cycle_down();
473        assert_eq!(c.selected, 2);
474    }
475
476    #[test]
477    fn is_empty_after_no_match_filter() {
478        let mut c = popup(&["alpha", "beta"]);
479        c.set_prefix("xyz");
480        assert!(c.is_empty());
481    }
482
483    #[test]
484    fn selected_item_returns_correct_item() {
485        let mut c = popup(&["alpha", "beta", "gamma"]);
486        c.set_prefix("bet");
487        // Only "beta" survives.
488        assert_eq!(c.visible.len(), 1);
489        assert_eq!(c.selected_item().map(|i| i.label.as_str()), Some("beta"));
490    }
491
492    #[test]
493    fn default_completion_is_empty() {
494        let c = Completion::default();
495        assert!(c.is_empty());
496        assert_eq!(c.anchor_row, 0);
497        assert_eq!(c.anchor_col, 0);
498    }
499
500    #[test]
501    fn completion_item_new_sets_insert_text_from_label() {
502        let item = CompletionItem::new("my_fn");
503        assert_eq!(item.label, "my_fn");
504        assert_eq!(item.insert_text, "my_fn");
505        assert!(matches!(item.kind, CompletionKind::Other));
506    }
507
508    #[test]
509    fn completion_kind_icon_coverage() {
510        assert_eq!(CompletionKind::Function.icon(), '\u{0192}');
511        assert_eq!(CompletionKind::Snippet.icon(), '\u{25C6}');
512        assert_eq!(CompletionKind::Other.icon(), '\u{00B7}');
513    }
514}