Skip to main content

lattice_completion/
insert.rs

1// `single_range_in_vec_init`: the fuzzy matcher returns a
2// `Vec<Range<usize>>` of highlight regions; a single-range
3// vec is the natural shape for exact / prefix / substring
4// hits and rewriting to `iter::once(...).collect()` would
5// muddy the API.
6#![allow(clippy::single_range_in_vec_init)]
7
8//! Insert-mode completion -- the editor surface that turns the
9//! existing pipeline (cmdline today) into a buffer-level input
10//! flow.
11//!
12//! Behavioural spec lives in
13//! [`docs/dev/architecture/insert-completion.md`](../../../docs/dev/architecture/insert-completion.md).
14//! This module is the data-flow layer: state types, trigger
15//! enum, sync source trait, fuzzy matcher tuned for code
16//! completion, and the per-buffer aggregator that holds it all
17//! together. The host (`lattice-ui-tui`) owns the async glue
18//! (LSP request fan-out, tokio spawn, the popup widget); this
19//! crate stays sync + pure-data so plugins can target it
20//! without pulling in tokio.
21//!
22//! ## Two kinds of source
23//!
24//! - **Sync ([`InsertSource`]).** Buffer-words, snippets, path,
25//!   tree-sitter -- everything that runs in microseconds. The
26//!   aggregator calls `produce()` directly when the popup query
27//!   changes.
28//! - **Async (host-orchestrated).** LSP, plugin generators,
29//!   anything that round-trips. The host spawns a tokio task
30//!   that pushes [`RawCandidate`]s through a channel into
31//!   [`InsertCompletionState::raw`]; the aggregator coalesces
32//!   pushes on a 16 ms tick and re-runs matcher / ranker.
33//!
34//! The split keeps this crate dependency-light. Hosts add the
35//! async dance; we don't.
36
37use std::collections::HashMap;
38use std::ops::Range;
39
40use lattice_core::Buffer;
41use lattice_protocol::Position;
42
43use crate::candidate::{RawCandidate, RenderedCandidate, ScoredCandidate};
44use crate::traits::{CandidateMatcher, CandidateRanker};
45
46/// Stable identifier for a source. Strings keep the registry
47/// transparent (`"gen:lsp-completion"`, `"gen:buffer-words"`,
48/// `"plugin:foo"`); the host's per-source priority / enable
49/// config keys off this string so users see the same name in
50/// `:set completion.source.<id>.priority=…` and in `:help
51/// completion-sources`.
52#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
53pub struct SourceId(pub String);
54
55impl SourceId {
56    pub fn new(s: impl Into<String>) -> Self {
57        Self(s.into())
58    }
59
60    pub fn as_str(&self) -> &str {
61        &self.0
62    }
63}
64
65/// What opened the popup. Stays constant for the popup's
66/// lifetime; rides on LSP completion requests as
67/// `CompletionTriggerKind`.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum CompletionTrigger {
70    /// User typed a server-advertised character (`'.'`, `'::'`,
71    /// etc.). The char rides along so the host's LSP request
72    /// builder fills `triggerCharacter`.
73    TriggerChar(char),
74    /// Auto-trigger threshold reached: the inserted char was an
75    /// identifier char and the prefix at the cursor crossed the
76    /// `min_chars` config bar. Off by default
77    /// (`completion.auto_trigger = false`).
78    IdentifierThreshold,
79    /// Manual: `<C-x><C-o>` / `<C-Space>` / smart-tab /
80    /// `cmd:completion-trigger`. Manual triggers always re-fire
81    /// the LSP source even when prior responses were cached.
82    Manual,
83    /// Server returned `isIncomplete: true` and the user kept
84    /// typing -- the host re-fires the LSP source on each
85    /// keystroke.
86    IncompleteRefresh,
87}
88
89/// Snapshot of editor state a source reads when producing
90/// candidates. Held by reference so the aggregator borrows from
91/// the surrounding frame for the duration of `produce()`.
92///
93/// Sources that need richer state (the LSP source needs the
94/// `ServerHandle`, the snippet source needs the per-language
95/// snippet registry) get those out-of-band -- they're not part
96/// of the generic context.
97pub struct InsertContext<'a> {
98    /// Active buffer's rope text. Sources read it via
99    /// `buffer.line(line_idx)` etc.
100    pub buffer: &'a Buffer,
101    /// Cursor position at popup-open time. The popup's
102    /// "current word" is `buffer[anchor..cursor]`.
103    pub cursor: Position,
104    /// Anchor: where the replacement region starts. Same as
105    /// `InsertCompletionState::anchor`.
106    pub anchor: Position,
107    /// Live filter text -- `buffer[anchor..cursor]`. Sources
108    /// can use this to filter their own output (snippet source
109    /// looks up by prefix, e.g.) but the aggregator's matcher
110    /// also filters globally so sources don't strictly need to.
111    pub query: &'a str,
112    /// What triggered the popup.
113    pub trigger: &'a CompletionTrigger,
114    /// Whether matching should be case-sensitive. Default
115    /// matcher honours this.
116    pub case_sensitive: bool,
117    /// CSM.5: active buffer's language id (e.g. `"rust"`,
118    /// `"markdown"`). The snippet source filters its registry
119    /// by language; tree-sitter / LSP / dictionary sources may
120    /// branch on it too. Empty string when no language is
121    /// detected (plain-text buffer); sources should treat
122    /// empty + the literal `"*"` as the catch-all bucket.
123    pub language: &'a str,
124    /// CSM.6: pre-computed local-symbol identifiers from the
125    /// buffer's tree-sitter parse (function names, struct
126    /// names, let bindings, parameters -- whatever the
127    /// language's `symbols.scm` query selects). Empty when no
128    /// syntax handle is attached or the language ships no
129    /// query. The host walks `collect_symbols()` once per
130    /// populate / refilter; the tree-sitter source iterates
131    /// this slice without re-traversing the tree.
132    pub tree_sitter_symbols: &'a [String],
133    /// CSM.7: true when the cursor sits inside a string scope
134    /// (host-detected via the active buffer's tree-sitter
135    /// parse). The path source produces candidates only when
136    /// this is true -- outside a string literal, file-name
137    /// completion would interleave with prose / code and
138    /// surprise the user. Defaults to `false`; the host sets
139    /// it from `App.completion_in_path_context`.
140    pub path_context: bool,
141    /// CSM.7: pre-resolved base directory for the path source's
142    /// filesystem walk. The host computes this from the active
143    /// document's parent (or `std::env::current_dir()` for
144    /// unsaved buffers); the source joins relative path
145    /// segments onto it. `None` when no usable base resolved
146    /// (the source skips the walk in that case).
147    pub buffer_dir: Option<&'a std::path::Path>,
148    /// CSM.8b: generic buffer URI as a string (e.g.
149    /// `file:///path/to/file.rs`). `None` for scratch / unsaved
150    /// buffers. The LSP source parses it back into an
151    /// `lsp_types::Uri`; other sources ignore.
152    pub uri: Option<&'a str>,
153    /// CSM.8b: pre-computed LSP position (line, character) in
154    /// UTF-16 encoding. Populated when the host has an LSP
155    /// server attached and the URI is known; the LSP source
156    /// uses it to build `lsp_types::Position` without
157    /// re-running the conversion. `None` when LSP isn't
158    /// applicable to this buffer.
159    pub lsp_position: Option<(u32, u32)>,
160}
161
162/// One side popup showing the focused candidate's full
163/// documentation. Lazy: not opened until `<C-d>` /
164/// `cmd:completion-toggle-docs` flips it on.
165#[derive(Debug, Clone)]
166pub struct DocPopupState {
167    /// Index into `InsertCompletionState::rendered` the popup
168    /// is showing for. Re-resolves when the selection changes.
169    pub for_index: usize,
170    /// Resolved markdown body. `Some(empty)` means "we asked
171    /// the server but the item has no documentation"; `None`
172    /// means "we haven't asked yet" (in which case the popup
173    /// renders a placeholder while the resolve fires).
174    pub body: Option<String>,
175    /// Per-row scroll offset into `body` -- `<C-f>` / `<C-b>`
176    /// page through long markdown bodies. Reset when
177    /// `for_index` changes.
178    pub scroll: u32,
179}
180
181/// Live state for an in-flight Insert-mode completion. Held by
182/// the host (`App.insert_completion: Option<…>`) while the
183/// popup is up; dropped on dismiss. Per-source channels and
184/// cancellation tokens live host-side -- they pull in tokio
185/// types this crate avoids -- so this struct stays
186/// dependency-light enough that any host (TUI today, GPU
187/// later) can reuse it.
188#[derive(Debug, Clone)]
189pub struct InsertCompletionState {
190    pub trigger: CompletionTrigger,
191    /// Where the replacement region starts. The aggregator's
192    /// `query` string is `buffer[anchor..cursor]`.
193    pub anchor: Position,
194    /// The cursor at open-time -- carried so the host can
195    /// detect cursor moves outside `[anchor, cursor]` and
196    /// dismiss.
197    pub cursor: Position,
198    /// Live filter text. Re-derived by the host on every
199    /// keystroke from `buffer[anchor..cursor]`.
200    pub query: String,
201    /// All raw candidates seen so far. Sync sources push their
202    /// full output once per query change; async sources push
203    /// incrementally as their tokio tasks return.
204    pub raw: Vec<RawCandidate>,
205    /// Matched + scored + ranked + annotated. Re-derived from
206    /// `raw` whenever `query` or `raw` changes.
207    pub rendered: Vec<RenderedCandidate>,
208    /// Selected index into `rendered`. Sticky across re-rank
209    /// when the same candidate is still in the list.
210    pub selected: usize,
211    /// "Pinned" -- the user moved off the default top selection
212    /// at least once. After pinning, refilter doesn't reset to
213    /// index 0; we hold the user's latest pick instead.
214    pub user_picked: bool,
215    /// Documentation popup, when open. `None` means no popup.
216    pub doc_popup: Option<DocPopupState>,
217    /// Whether the LSP source said `isIncomplete: true` last
218    /// time. The host uses this to decide whether to re-fire
219    /// LSP on each keystroke.
220    pub lsp_incomplete: bool,
221    /// CSM.K2: active filter -- narrows `rendered` to
222    /// candidates whose `source` matches this id. `None` ⇒
223    /// unfiltered (every source contributes). Set by the
224    /// per-source filter chords inside `completion-popup-mode`
225    /// (`<C-b>` / `<C-o>` / `<C-f>` / `<C-t>` / …); cleared by
226    /// `<C-Space>` while the popup is live (or any retrigger).
227    pub source_filter: Option<SourceId>,
228}
229
230impl InsertCompletionState {
231    /// Open a fresh popup state at `cursor` with the given
232    /// trigger. `anchor` is computed by the caller (typically
233    /// "scan back to a word boundary"). `query` starts as the
234    /// text between `anchor` and `cursor`.
235    pub fn open(
236        trigger: CompletionTrigger,
237        anchor: Position,
238        cursor: Position,
239        query: String,
240    ) -> Self {
241        Self {
242            trigger,
243            anchor,
244            cursor,
245            query,
246            raw: Vec::new(),
247            rendered: Vec::new(),
248            selected: 0,
249            user_picked: false,
250            doc_popup: None,
251            lsp_incomplete: false,
252            source_filter: None,
253        }
254    }
255
256    /// True if no candidates have been produced yet. The host
257    /// renders an empty popup with a "loading…" placeholder
258    /// in this state when an async source is in-flight.
259    pub fn is_empty(&self) -> bool {
260        self.rendered.is_empty()
261    }
262
263    /// Currently-selected candidate, if any.
264    pub fn selected_candidate(&self) -> Option<&RenderedCandidate> {
265        self.rendered.get(self.selected)
266    }
267
268    /// Move the selection one step down, wrapping at the end.
269    /// Marks `user_picked` so subsequent refilters don't snap
270    /// back to index 0.
271    pub fn select_next(&mut self) {
272        if self.rendered.is_empty() {
273            return;
274        }
275        self.selected = (self.selected + 1) % self.rendered.len();
276        self.user_picked = true;
277    }
278
279    /// Move the selection one step up, wrapping at the start.
280    pub fn select_prev(&mut self) {
281        if self.rendered.is_empty() {
282            return;
283        }
284        self.selected = if self.selected == 0 {
285            self.rendered.len() - 1
286        } else {
287            self.selected - 1
288        };
289        self.user_picked = true;
290    }
291}
292
293/// A sync candidate source. Implementations are cheap to call
294/// -- the aggregator invokes `produce()` once per query change
295/// (a re-filter on each keystroke). Async sources don't
296/// implement this trait; they are orchestrated host-side via
297/// `tokio::spawn` + a channel that pushes into
298/// `InsertCompletionState.raw` directly.
299///
300/// Default priority is 100; the host's `[completion.source.<id>]`
301/// config can override.
302pub trait InsertSource: Send + Sync + std::fmt::Debug {
303    /// Stable id (e.g. `"gen:buffer-words"`). Surfaces in
304    /// `:set completion.source.<id>.priority=…` keys.
305    fn id(&self) -> &SourceId;
306
307    /// Default priority bucket. Higher buckets sort above
308    /// lower; the ranker adds it to the matcher's score.
309    fn default_priority(&self) -> u32 {
310        100
311    }
312
313    /// Whether this source contributes when the popup opens
314    /// via auto-trigger (identifier threshold). Manual
315    /// triggers and `<C-x>`-prefixed dedicated chords always
316    /// include every enabled source.
317    fn auto_trigger(&self) -> bool {
318        true
319    }
320
321    /// Server-advertised characters that should fire this
322    /// source. Empty = "I'm fine on identifier-threshold or
323    /// manual." The LSP source's bridge populates this from
324    /// `completionProvider.triggerCharacters`.
325    fn trigger_chars(&self, _ctx: &InsertContext<'_>) -> Vec<char> {
326        Vec::new()
327    }
328
329    /// Produce raw candidates for the supplied context. Cheap
330    /// (microseconds); the aggregator calls this whenever the
331    /// query changes.
332    fn produce(&self, ctx: &InsertContext<'_>) -> Vec<RawCandidate>;
333}
334
335/// Built-in fuzzy matcher tuned for Insert-mode completion.
336///
337/// Scoring tiers (descending):
338///
339/// | Tier | Meaning | Score base |
340/// |---|---|---|
341/// | Exact | `query == text` (case-insensitive) | 1000 |
342/// | Prefix | `text.starts_with(query)` (case-insensitive) | 800 |
343/// | Word boundary | All query chars match at word boundaries (camelCase / snake_case) | 600 |
344/// | Substring | `text.contains(query)` (case-insensitive) | 400 |
345/// | Subsequence | Each query char appears in order in text | 200 - skipped × 5 |
346///
347/// Empty query yields a uniform score of 100 -- everything
348/// matches, sorted by per-source priority + ranker bonuses.
349///
350/// Returns `None` for non-matches (filtered out before the
351/// ranker stage).
352#[derive(Debug, Default)]
353pub struct FuzzyInsertMatcher;
354
355impl FuzzyInsertMatcher {
356    pub const ID: &'static str = "match:fuzzy-insert";
357
358    pub fn new() -> Self {
359        Self
360    }
361}
362
363impl CandidateMatcher for FuzzyInsertMatcher {
364    fn matches(
365        &self,
366        query: &str,
367        candidate: &RawCandidate,
368    ) -> Option<(crate::candidate::MatchScore, Vec<Range<usize>>)> {
369        // The trait matches on `candidate.text` -- the canonical
370        // searchable form. Surfaces that want to match on a
371        // different field (e.g. picker rows whose `text` carries
372        // routing payload separate from the user-visible label)
373        // call [`fuzzy_match`] directly with their target string.
374        fuzzy_match(query, &candidate.text)
375    }
376}
377
378/// Free-function fuzzy match -- the algorithm beneath
379/// [`FuzzyInsertMatcher`]. Exposed so surfaces that need to
380/// match on a string other than `RawCandidate.text` (e.g. the
381/// vertico picker, which matches on the user-visible `display`
382/// while `text` carries a routing payload) get the same
383/// 5-tier scoring without duplicating the algorithm.
384///
385/// Empty `query` yields a uniform score of 100; non-matches
386/// return `None`. Returned byte ranges are into `target`.
387pub fn fuzzy_match(
388    query: &str,
389    target: &str,
390) -> Option<(crate::candidate::MatchScore, Vec<Range<usize>>)> {
391    if query.is_empty() {
392        return Some((crate::candidate::MatchScore(100), Vec::new()));
393    }
394    let q_lower: String = query.to_lowercase();
395    let t_lower: String = target.to_lowercase();
396    let q_bytes = q_lower.as_bytes();
397    let t_bytes = t_lower.as_bytes();
398
399    // Tier 1: exact (case-insensitive).
400    if q_lower == t_lower {
401        return Some((crate::candidate::MatchScore(1000), vec![0..target.len()]));
402    }
403
404    // Tier 2: prefix (case-insensitive).
405    if t_lower.starts_with(&q_lower) {
406        let prefix_len = shared_prefix_byte_len(target, query);
407        return Some((
408            crate::candidate::MatchScore(800),
409            vec![0..prefix_len.max(query.len())],
410        ));
411    }
412
413    // Tier 3: word-boundary subsequence (camelCase /
414    // snake_case / after-separator).
415    if let Some(ranges) = match_word_boundary(target, query) {
416        return Some((crate::candidate::MatchScore(600), ranges));
417    }
418
419    // Tier 4: contiguous substring.
420    if let Some(start) = t_lower.find(&q_lower) {
421        return Some((
422            crate::candidate::MatchScore(400),
423            vec![start..start + q_lower.len()],
424        ));
425    }
426
427    // Tier 5: subsequence with skip-decay.
428    let (matched, ranges) = subsequence_match(t_bytes, q_bytes);
429    if matched {
430        let skipped = target.len().saturating_sub(query.len());
431        let score = 200u32.saturating_sub(skipped as u32 * 5);
432        return Some((crate::candidate::MatchScore(score.max(1)), ranges));
433    }
434
435    None
436}
437
438fn shared_prefix_byte_len(text: &str, query: &str) -> usize {
439    let mut t = text.chars();
440    let mut q = query.chars();
441    let mut consumed = 0;
442    loop {
443        match (t.next(), q.next()) {
444            (Some(tc), Some(qc)) if tc.eq_ignore_ascii_case(&qc) => {
445                consumed += tc.len_utf8();
446            }
447            _ => return consumed,
448        }
449    }
450}
451
452/// Walk `text` matching `query` characters at word boundaries
453/// only. Three boundary conditions:
454///
455/// - **Separator boundary.** Previous char was a non-identifier
456///   character (whitespace, punctuation, etc.), or we're at the
457///   start of `text`. Covers `"foo bar".matches("fb")` and
458///   `"foo.bar".matches("fb")`.
459/// - **Snake-case boundary.** Previous char was `_`. Covers
460///   `"foo_bar".matches("fb")` -- `_` is part of the
461///   identifier (so `foo_bar` is one word) but the position
462///   *after* it is a boundary.
463/// - **Camel-case boundary.** This char is uppercase and the
464///   previous was lowercase. Covers `"fooBar".matches("fb")`.
465///
466/// Returns the matched byte ranges in `text` if every query
467/// character found a boundary match in order.
468fn match_word_boundary(text: &str, query: &str) -> Option<Vec<Range<usize>>> {
469    let q_lower: Vec<char> = query.to_lowercase().chars().collect();
470    let mut q_iter = q_lower.iter().peekable();
471    let mut ranges: Vec<Range<usize>> = Vec::new();
472    let mut prev_was_lower = false;
473    let mut prev_was_sep = true; // start of text counts as boundary
474    let mut prev_was_underscore = false;
475    let mut byte_idx = 0;
476    for c in text.chars() {
477        let len = c.len_utf8();
478        let lower = c.to_ascii_lowercase();
479        let is_alpha = c.is_alphanumeric() || c == '_';
480        let is_sep = !is_alpha;
481        let at_boundary =
482            prev_was_sep || prev_was_underscore || (c.is_ascii_uppercase() && prev_was_lower);
483        if at_boundary
484            && let Some(&&want) = q_iter.peek()
485            && want == lower
486        {
487            ranges.push(byte_idx..byte_idx + len);
488            q_iter.next();
489        }
490        prev_was_sep = is_sep;
491        prev_was_lower = c.is_ascii_lowercase();
492        prev_was_underscore = c == '_';
493        byte_idx += len;
494    }
495    if q_iter.peek().is_none() {
496        Some(ranges)
497    } else {
498        None
499    }
500}
501
502/// Subsequence match -- every query byte appears in `text` in
503/// order. Returns the per-byte match positions.
504fn subsequence_match(t: &[u8], q: &[u8]) -> (bool, Vec<Range<usize>>) {
505    let mut ranges = Vec::with_capacity(q.len());
506    let mut i = 0;
507    let mut j = 0;
508    while i < t.len() && j < q.len() {
509        if t[i] == q[j] {
510            ranges.push(i..i + 1);
511            j += 1;
512        }
513        i += 1;
514    }
515    (j == q.len(), ranges)
516}
517
518/// Built-in ranker tuned for Insert-mode completion. Sorts by
519/// `final_score` descending, where:
520///
521/// ```text
522/// final_score = base_score                  // matcher output
523///             + per_source_priority         // CandidateData::Plain → no per-source bias
524///             + frequency_bonus             // 0–50, host-side LRU
525///             + preselect_bonus             // +200 for LSP preselect items
526///             - deprecated_penalty          // -100 for deprecated tags
527/// ```
528///
529/// 4.2.g.1 ships only `base_score` (the matcher's tier score).
530/// Per-source priority + frequency + preselect + deprecated
531/// land in 4.2.g.2 (LSP source) and 4.2.g.5 (ranking polish);
532/// the host adds them via the existing `RawCandidate.data`
533/// payload before calling the ranker.
534#[derive(Debug, Default)]
535pub struct InsertRanker;
536
537impl InsertRanker {
538    pub const ID: &'static str = "rank:insert";
539
540    /// Cap on the per-item frequency bonus, per
541    /// `docs/dev/architecture/insert-completion.md` §3.6.
542    pub const FREQUENCY_BONUS_CAP: u32 = 50;
543
544    pub fn new() -> Self {
545        Self
546    }
547
548    /// Rank by `score + bonus(raw)`, descending. The host
549    /// owns the bonus composition: per
550    /// `docs/dev/architecture/insert-completion.md` §3.6,
551    ///
552    /// ```text
553    /// final_score = base_score
554    ///             + per_source_priority
555    ///             + frequency_bonus       // capped at FREQUENCY_BONUS_CAP
556    ///             + preselect_bonus
557    ///             - deprecated_penalty
558    /// ```
559    ///
560    /// Each term has its own clamp; the ranker doesn't apply
561    /// policy because the per-term ranges and signs differ
562    /// (frequency is bounded; preselect is a fixed jolt;
563    /// deprecated subtracts). Hosts wrap the live lookups
564    /// (`App::completion_accept_freq`,
565    /// `App::priority_for_source`, ...) into a single bonus
566    /// closure.
567    pub fn rank_with_bonus(
568        &self,
569        scored: &mut [ScoredCandidate],
570        bonus: impl Fn(&RawCandidate) -> u32,
571    ) {
572        scored.sort_by_cached_key(|s| std::cmp::Reverse(s.score.0.saturating_add(bonus(&s.raw))));
573    }
574}
575
576impl CandidateRanker for InsertRanker {
577    fn rank(&self, scored: &mut Vec<ScoredCandidate>) {
578        scored.sort_by(|a, b| b.score.0.cmp(&a.score.0));
579    }
580}
581
582// ---- Per-language overrides ----
583
584/// Per-language overrides for the insert-completion popup. Each
585/// field is `Option` so a TOML override at
586/// `[completion.per-language.<lang>]` can flip exactly the keys
587/// it cares about; unset fields fall back to the global typed
588/// option (or, for `sources`, "every enabled source contributes").
589///
590/// The host (App) layers a TOML override on top of the spec-
591/// driven defaults from [`per_language_defaults`]; the
592/// effective-config resolver in the host walks
593/// `per_language -> global option -> hardcoded fallback` for
594/// every read.
595#[derive(Debug, Clone, Default, PartialEq)]
596pub struct PerLanguageOverrides {
597    /// Subset of source ids that contribute for this language.
598    /// `None` = every enabled source contributes (the global
599    /// default). The producer layer skips emit + LSP fan-out
600    /// for sources outside this list.
601    pub sources: Option<Vec<SourceId>>,
602    /// Whether typing identifier chars opens the popup
603    /// automatically. `None` = inherit
604    /// `completion.auto_trigger`. Plumbed today; auto-trigger
605    /// firing itself lands later.
606    pub auto_trigger: Option<bool>,
607    /// Whether a single-candidate popup auto-accepts. `None` =
608    /// inherit `completion.auto_insert_single`.
609    pub auto_insert_single: Option<bool>,
610    /// Tree-sitter scope strings (e.g. `"string"`, `"comment"`)
611    /// where the popup should not fire. `None` = inherit; empty
612    /// list = "fire everywhere." Plumbed today; scope-detect
613    /// enforcement lands with the tree-sitter scope queries
614    /// slice.
615    pub suppress_in: Option<Vec<String>>,
616}
617
618impl PerLanguageOverrides {
619    /// Layer `other` on top of `self`: every `Some` field in
620    /// `other` wins; `None` fields preserve `self`'s value.
621    /// Used when a TOML override merges onto the spec defaults.
622    pub fn merge(&mut self, other: PerLanguageOverrides) {
623        if other.sources.is_some() {
624            self.sources = other.sources;
625        }
626        if other.auto_trigger.is_some() {
627            self.auto_trigger = other.auto_trigger;
628        }
629        if other.auto_insert_single.is_some() {
630            self.auto_insert_single = other.auto_insert_single;
631        }
632        if other.suppress_in.is_some() {
633            self.suppress_in = other.suppress_in;
634        }
635    }
636}
637
638/// Spec-driven defaults shipping with v1
639/// (`docs/dev/architecture/insert-completion.md` §9). Markdown / text restrict
640/// to snippet + buffer-words (no LSP for prose); rust enables
641/// auto-fire + auto-insert-single since rust-analyzer's items
642/// are precise.
643///
644/// Returned as a fresh map -- callers (App init) own the data
645/// and merge TOML overrides on top.
646pub fn per_language_defaults() -> std::collections::HashMap<String, PerLanguageOverrides> {
647    let mut m = std::collections::HashMap::new();
648    let prose_sources = vec![
649        SourceId::new(SNIPPET_SOURCE_ID),
650        SourceId::new(BufferWordsSource::ID),
651    ];
652    m.insert(
653        "markdown".into(),
654        PerLanguageOverrides {
655            sources: Some(prose_sources.clone()),
656            auto_trigger: Some(false),
657            ..Default::default()
658        },
659    );
660    m.insert(
661        "text".into(),
662        PerLanguageOverrides {
663            sources: Some(prose_sources),
664            ..Default::default()
665        },
666    );
667    m.insert(
668        "rust".into(),
669        PerLanguageOverrides {
670            auto_trigger: Some(true),
671            auto_insert_single: Some(true),
672            ..Default::default()
673        },
674    );
675    m
676}
677
678/// Map a user-friendly source label (`"lsp"`, `"snippet"`,
679/// `"buffer-words"`) to its canonical source id. Unknown labels
680/// pass through as-is, so plugin sources can be referenced by
681/// their full id (`"plugin:my-source"`).
682pub fn canonical_source_id(label: &str) -> SourceId {
683    match label {
684        "lsp" => SourceId::new(LSP_COMPLETION_SOURCE_ID),
685        "snippet" | "snippets" => SourceId::new(SNIPPET_SOURCE_ID),
686        "buffer-words" | "buffer_words" | "words" => SourceId::new(BufferWordsSource::ID),
687        // `path` (4.2.g.6) and `tree-sitter` (4.2.g.6) are
688        // recognised here so users can list them in TOML
689        // ahead of the source landing -- the producer skips
690        // unknown ids gracefully.
691        "path" => SourceId::new(PATH_SOURCE_ID),
692        "tree-sitter" | "treesitter" | "ts" => SourceId::new(TREE_SITTER_SYMBOL_SOURCE_ID),
693        other => SourceId::new(other),
694    }
695}
696
697// ---- Source id constants (host-orchestrated sources) ----
698//
699// `BufferWordsSource::ID` lives on the impl below; the LSP and
700// snippet sources are orchestrated host-side (LSP via the
701// async tokio path, snippet via the app's per-language
702// registry) and have no `InsertSource` struct to hang an `ID`
703// off. Surface their canonical ids here so the host's tagging
704// matches the strings used in `:set
705// completion.source.<id>.priority=…` and in `:help
706// completion-sources`.
707
708/// Source id for the host-orchestrated LSP completion source.
709pub const LSP_COMPLETION_SOURCE_ID: &str = "gen:lsp-completion";
710
711/// Source id for the host-orchestrated snippet completion source.
712pub const SNIPPET_SOURCE_ID: &str = "gen:snippet";
713
714/// Source id for the host-orchestrated tree-sitter local-symbol
715/// source (Phase 4.2.g.6 (1/2)). Walks the buffer's syntax tree
716/// per popup-trigger via `lattice_syntax::Syntax::collect_symbols`.
717pub const TREE_SITTER_SYMBOL_SOURCE_ID: &str = "gen:tree-sitter-symbol";
718
719/// Source id for the host-orchestrated path-completion source
720/// (Phase 4.2.g.6 (2/2)). Triggered when the cursor sits inside
721/// a string literal (per tree-sitter scope detection); walks
722/// the directory of the partial path and emits filesystem
723/// entries.
724pub const PATH_SOURCE_ID: &str = "gen:path";
725
726// ---- Built-in: gen:buffer-words ----
727
728/// Sync source emitting word-completions from a buffer's text.
729/// Cheap enough to walk the rope once per query change. Words
730/// shorter than `min_word_length` are skipped; duplicates are
731/// deduped.
732///
733/// The cursor's own current word (`query`) is NOT included --
734/// no point completing a word with itself.
735#[derive(Debug)]
736pub struct BufferWordsSource {
737    id: SourceId,
738    pub min_word_length: usize,
739    pub max_words: usize,
740}
741
742impl Default for BufferWordsSource {
743    fn default() -> Self {
744        Self::new()
745    }
746}
747
748impl BufferWordsSource {
749    pub const ID: &'static str = "gen:buffer-words";
750
751    pub fn new() -> Self {
752        Self {
753            id: SourceId::new(Self::ID),
754            min_word_length: 3,
755            max_words: 200,
756        }
757    }
758}
759
760impl InsertSource for BufferWordsSource {
761    fn id(&self) -> &SourceId {
762        &self.id
763    }
764
765    fn default_priority(&self) -> u32 {
766        100
767    }
768
769    fn produce(&self, ctx: &InsertContext<'_>) -> Vec<RawCandidate> {
770        // Walk the rope as a string. For very large buffers
771        // (> 1 MB) this would be too costly; future phases
772        // walk only the visible region first and the rest in
773        // a background pass. v1 is the simple shape.
774        let text = ctx.buffer.as_string();
775        let mut seen: HashMap<String, ()> = HashMap::new();
776        let mut out: Vec<RawCandidate> = Vec::new();
777        for word in iter_words(&text) {
778            if word.len() < self.min_word_length {
779                continue;
780            }
781            // Skip the cursor's own word (avoid completing a
782            // word with itself when the user typed the prefix
783            // and the buffer already contains that prefix
784            // verbatim somewhere).
785            if word == ctx.query {
786                continue;
787            }
788            if seen.insert(word.to_string(), ()).is_some() {
789                continue;
790            }
791            out.push(
792                RawCandidate::plain(word.to_string(), crate::candidate::CandidateKind::Plain)
793                    .with_source(self.id().clone()),
794            );
795            if out.len() >= self.max_words {
796                break;
797            }
798        }
799        out
800    }
801}
802
803/// CSM.4: the new mode-driven trait surface. Same `produce()`
804/// body as the legacy `InsertSource` impl; carried as a peer
805/// during the migration so the existing hardcoded path keeps
806/// working while the mode-contributed path lights up. The
807/// hardcoded call will retire once every source has migrated
808/// (CSM.8 wraps it up).
809impl crate::source::SyncCompletionSource for BufferWordsSource {
810    fn produce(&self, ctx: &InsertContext<'_>) -> Vec<RawCandidate> {
811        <Self as InsertSource>::produce(self, ctx)
812    }
813}
814
815/// Walk `text` yielding contiguous identifier-character runs
816/// (alphanumeric + `_`). Allocation-free; iteration cost is
817/// linear in `text.len()`.
818fn iter_words(text: &str) -> impl Iterator<Item = &str> {
819    let bytes = text.as_bytes();
820    let mut start: Option<usize> = None;
821    let len = bytes.len();
822    let mut i = 0;
823    std::iter::from_fn(move || {
824        while i < len {
825            let b = bytes[i];
826            let is_word = b.is_ascii_alphanumeric() || b == b'_';
827            match (start, is_word) {
828                (None, true) => {
829                    start = Some(i);
830                    i += 1;
831                }
832                (Some(s), false) => {
833                    let word = &text[s..i];
834                    start = None;
835                    i += 1;
836                    return Some(word);
837                }
838                (None, false) => {
839                    i += 1;
840                }
841                (Some(_), true) => {
842                    i += 1;
843                }
844            }
845        }
846        if let Some(s) = start.take() {
847            return Some(&text[s..len]);
848        }
849        None
850    })
851}
852
853#[cfg(test)]
854mod tests {
855    #![allow(clippy::unwrap_used)]
856    use super::*;
857    use crate::candidate::CandidateKind;
858    use lattice_core::Buffer;
859    use lattice_protocol::Edit;
860
861    fn buffer_with(text: &str) -> Buffer {
862        let mut b = Buffer::empty();
863        let _ = b.apply_edit(&Edit::insert(Position::ZERO, text));
864        b
865    }
866
867    fn ctx<'a>(buffer: &'a Buffer, query: &'a str) -> InsertContext<'a> {
868        InsertContext {
869            buffer,
870            cursor: Position::new(0, query.len() as u32),
871            anchor: Position::ZERO,
872            query,
873            trigger: CONTEXT_TRIGGER,
874            case_sensitive: false,
875            language: "",
876            tree_sitter_symbols: &[],
877            path_context: false,
878            buffer_dir: None,
879            uri: None,
880            lsp_position: None,
881        }
882    }
883
884    static CONTEXT_TRIGGER: &CompletionTrigger = &CompletionTrigger::Manual;
885
886    #[test]
887    fn fuzzy_exact_match_wins_top_score() {
888        let m = FuzzyInsertMatcher::new();
889        let c = RawCandidate::plain("foo", CandidateKind::Plain);
890        let (score, ranges) = m.matches("foo", &c).unwrap();
891        assert_eq!(score.0, 1000);
892        assert_eq!(ranges, vec![0..3]);
893    }
894
895    #[test]
896    fn fuzzy_exact_is_case_insensitive() {
897        let m = FuzzyInsertMatcher::new();
898        let c = RawCandidate::plain("Foo", CandidateKind::Plain);
899        let (score, _) = m.matches("foo", &c).unwrap();
900        assert_eq!(score.0, 1000);
901    }
902
903    #[test]
904    fn fuzzy_prefix_scores_below_exact() {
905        let m = FuzzyInsertMatcher::new();
906        let c = RawCandidate::plain("foobar", CandidateKind::Plain);
907        let (score, ranges) = m.matches("foo", &c).unwrap();
908        assert_eq!(score.0, 800);
909        assert_eq!(ranges, vec![0..3]);
910    }
911
912    #[test]
913    fn fuzzy_word_boundary_matches_camelcase() {
914        let m = FuzzyInsertMatcher::new();
915        let c = RawCandidate::plain("getFooBar", CandidateKind::Plain);
916        // gFB matches at 'g', 'F', 'B' boundaries.
917        let (score, ranges) = m.matches("gfb", &c).unwrap();
918        assert_eq!(score.0, 600);
919        assert_eq!(ranges.len(), 3);
920    }
921
922    #[test]
923    fn fuzzy_word_boundary_matches_snake_case() {
924        let m = FuzzyInsertMatcher::new();
925        let c = RawCandidate::plain("get_foo_bar", CandidateKind::Plain);
926        let (score, _) = m.matches("gfb", &c).unwrap();
927        assert_eq!(score.0, 600);
928    }
929
930    #[test]
931    fn fuzzy_substring_falls_below_word_boundary() {
932        let m = FuzzyInsertMatcher::new();
933        // "ooba" is a substring but not at a word boundary in
934        // "getFooBar".
935        let c = RawCandidate::plain("getFooBar", CandidateKind::Plain);
936        let (score, _) = m.matches("ooba", &c).unwrap();
937        assert_eq!(score.0, 400);
938    }
939
940    #[test]
941    fn fuzzy_subsequence_matches_when_no_substring() {
942        let m = FuzzyInsertMatcher::new();
943        let c = RawCandidate::plain("alphaBetaGamma", CandidateKind::Plain);
944        // "abg" -- subsequence but not substring.
945        let (score, _) = m.matches("abg", &c).unwrap();
946        // Word-boundary actually matches alpha/Beta/Gamma so
947        // tier 3 wins (score 600), not tier 5.
948        assert_eq!(score.0, 600);
949    }
950
951    #[test]
952    fn fuzzy_no_match_returns_none() {
953        let m = FuzzyInsertMatcher::new();
954        let c = RawCandidate::plain("foo", CandidateKind::Plain);
955        assert!(m.matches("xyz", &c).is_none());
956    }
957
958    #[test]
959    fn fuzzy_empty_query_uniform_score() {
960        let m = FuzzyInsertMatcher::new();
961        let c = RawCandidate::plain("anything", CandidateKind::Plain);
962        let (score, ranges) = m.matches("", &c).unwrap();
963        assert_eq!(score.0, 100);
964        assert!(ranges.is_empty());
965    }
966
967    #[test]
968    fn buffer_words_returns_unique_words_above_threshold() {
969        let buf = buffer_with("hello world hello\nfoo bar foo baz");
970        let src = BufferWordsSource::new();
971        let words: Vec<String> = src
972            .produce(&ctx(&buf, ""))
973            .into_iter()
974            .map(|c| c.text)
975            .collect();
976        // "hello", "world", "foo", "bar", "baz" -- in order of
977        // first appearance, each unique.
978        assert_eq!(words, vec!["hello", "world", "foo", "bar", "baz"]);
979    }
980
981    #[test]
982    fn buffer_words_skips_words_below_min_length() {
983        let buf = buffer_with("ok foo at ax");
984        let mut src = BufferWordsSource::new();
985        src.min_word_length = 3;
986        let words: Vec<String> = src
987            .produce(&ctx(&buf, ""))
988            .into_iter()
989            .map(|c| c.text)
990            .collect();
991        assert_eq!(words, vec!["foo"]);
992    }
993
994    #[test]
995    fn buffer_words_skips_cursor_own_word() {
996        // The user is typing `foo` and the buffer already
997        // contains the literal `foo` at the cursor; we
998        // shouldn't surface `foo` as a completion of itself.
999        let buf = buffer_with("foo bar baz foo");
1000        let src = BufferWordsSource::new();
1001        let words: Vec<String> = src
1002            .produce(&ctx(&buf, "foo"))
1003            .into_iter()
1004            .map(|c| c.text)
1005            .collect();
1006        assert_eq!(words, vec!["bar", "baz"]);
1007    }
1008
1009    #[test]
1010    fn iter_words_ignores_punctuation() {
1011        let s = "foo, bar.baz! qux";
1012        let words: Vec<&str> = iter_words(s).collect();
1013        assert_eq!(words, vec!["foo", "bar", "baz", "qux"]);
1014    }
1015
1016    #[test]
1017    fn iter_words_handles_underscores_and_digits() {
1018        let s = "foo_bar baz123 _under";
1019        let words: Vec<&str> = iter_words(s).collect();
1020        assert_eq!(words, vec!["foo_bar", "baz123", "_under"]);
1021    }
1022
1023    #[test]
1024    fn state_select_next_wraps() {
1025        let mut s = InsertCompletionState::open(
1026            CompletionTrigger::Manual,
1027            Position::ZERO,
1028            Position::ZERO,
1029            String::new(),
1030        );
1031        let cand = |t: &str| crate::candidate::ScoredCandidate {
1032            raw: RawCandidate::plain(t, CandidateKind::Plain),
1033            score: crate::candidate::MatchScore(0),
1034            match_ranges: Vec::new(),
1035        };
1036        s.rendered = vec![
1037            crate::candidate::RenderedCandidate::from_scored(cand("a")),
1038            crate::candidate::RenderedCandidate::from_scored(cand("b")),
1039            crate::candidate::RenderedCandidate::from_scored(cand("c")),
1040        ];
1041        assert_eq!(s.selected, 0);
1042        s.select_next();
1043        assert_eq!(s.selected, 1);
1044        s.select_next();
1045        s.select_next();
1046        // wrapped
1047        assert_eq!(s.selected, 0);
1048        assert!(s.user_picked);
1049    }
1050
1051    #[test]
1052    fn state_select_prev_wraps_from_zero_to_last() {
1053        let mut s = InsertCompletionState::open(
1054            CompletionTrigger::Manual,
1055            Position::ZERO,
1056            Position::ZERO,
1057            String::new(),
1058        );
1059        let cand = |t: &str| crate::candidate::ScoredCandidate {
1060            raw: RawCandidate::plain(t, CandidateKind::Plain),
1061            score: crate::candidate::MatchScore(0),
1062            match_ranges: Vec::new(),
1063        };
1064        s.rendered = vec![
1065            crate::candidate::RenderedCandidate::from_scored(cand("a")),
1066            crate::candidate::RenderedCandidate::from_scored(cand("b")),
1067        ];
1068        assert_eq!(s.selected, 0);
1069        s.select_prev();
1070        assert_eq!(s.selected, 1);
1071    }
1072
1073    #[test]
1074    fn ranker_sorts_descending_by_score() {
1075        let r = InsertRanker::new();
1076        let mut scored = vec![
1077            ScoredCandidate {
1078                raw: RawCandidate::plain("low", CandidateKind::Plain),
1079                score: crate::candidate::MatchScore(10),
1080                match_ranges: Vec::new(),
1081            },
1082            ScoredCandidate {
1083                raw: RawCandidate::plain("high", CandidateKind::Plain),
1084                score: crate::candidate::MatchScore(100),
1085                match_ranges: Vec::new(),
1086            },
1087            ScoredCandidate {
1088                raw: RawCandidate::plain("mid", CandidateKind::Plain),
1089                score: crate::candidate::MatchScore(50),
1090                match_ranges: Vec::new(),
1091            },
1092        ];
1093        r.rank(&mut scored);
1094        assert_eq!(scored[0].raw.text, "high");
1095        assert_eq!(scored[1].raw.text, "mid");
1096        assert_eq!(scored[2].raw.text, "low");
1097    }
1098
1099    #[test]
1100    fn rank_with_bonus_lifts_higher_bonus_above_tied_peer() {
1101        // Two candidates tied on matcher score; the one with
1102        // the larger host-supplied bonus sorts above the peer.
1103        let r = InsertRanker::new();
1104        let mut scored = vec![
1105            ScoredCandidate {
1106                raw: RawCandidate::plain("alpha", CandidateKind::Plain),
1107                score: crate::candidate::MatchScore(100),
1108                match_ranges: Vec::new(),
1109            },
1110            ScoredCandidate {
1111                raw: RawCandidate::plain("bravo", CandidateKind::Plain),
1112                score: crate::candidate::MatchScore(100),
1113                match_ranges: Vec::new(),
1114            },
1115        ];
1116        r.rank_with_bonus(&mut scored, |raw| match raw.text.as_str() {
1117            "bravo" => 5,
1118            _ => 0,
1119        });
1120        assert_eq!(scored[0].raw.text, "bravo");
1121        assert_eq!(scored[1].raw.text, "alpha");
1122    }
1123
1124    #[test]
1125    fn rank_with_bonus_zero_bonus_matches_plain_rank() {
1126        // With every lookup returning 0, behaviour matches the
1127        // plain `rank` call: pure descending sort by score.
1128        let r = InsertRanker::new();
1129        let mut scored = vec![
1130            ScoredCandidate {
1131                raw: RawCandidate::plain("low", CandidateKind::Plain),
1132                score: crate::candidate::MatchScore(10),
1133                match_ranges: Vec::new(),
1134            },
1135            ScoredCandidate {
1136                raw: RawCandidate::plain("high", CandidateKind::Plain),
1137                score: crate::candidate::MatchScore(100),
1138                match_ranges: Vec::new(),
1139            },
1140        ];
1141        r.rank_with_bonus(&mut scored, |_| 0);
1142        assert_eq!(scored[0].raw.text, "high");
1143        assert_eq!(scored[1].raw.text, "low");
1144    }
1145
1146    #[test]
1147    fn rank_with_bonus_respects_host_supplied_cap() {
1148        // Host caps the frequency bonus at FREQUENCY_BONUS_CAP
1149        // (50) before passing it in; even a huge raw count
1150        // can't overtake a much higher base score.
1151        let r = InsertRanker::new();
1152        let mut scored = vec![
1153            ScoredCandidate {
1154                raw: RawCandidate::plain("rare-but-strong", CandidateKind::Plain),
1155                score: crate::candidate::MatchScore(200),
1156                match_ranges: Vec::new(),
1157            },
1158            ScoredCandidate {
1159                raw: RawCandidate::plain("frequent-but-weak", CandidateKind::Plain),
1160                score: crate::candidate::MatchScore(100),
1161                match_ranges: Vec::new(),
1162            },
1163        ];
1164        let raw_count = 9999_u32;
1165        r.rank_with_bonus(&mut scored, |raw| {
1166            if raw.text == "frequent-but-weak" {
1167                raw_count.min(InsertRanker::FREQUENCY_BONUS_CAP)
1168            } else {
1169                0
1170            }
1171        });
1172        // 100 + 50 = 150 < 200, so strong stays first.
1173        assert_eq!(scored[0].raw.text, "rare-but-strong");
1174        assert_eq!(scored[1].raw.text, "frequent-but-weak");
1175    }
1176
1177    #[test]
1178    fn per_language_overrides_merge_replaces_only_some_fields() {
1179        let mut base = PerLanguageOverrides {
1180            sources: Some(vec![SourceId::new("a")]),
1181            auto_trigger: Some(false),
1182            auto_insert_single: Some(false),
1183            suppress_in: Some(vec!["string".into()]),
1184        };
1185        let overlay = PerLanguageOverrides {
1186            auto_trigger: Some(true),
1187            ..Default::default()
1188        };
1189        base.merge(overlay);
1190        // Only auto_trigger flipped; the others survive.
1191        assert_eq!(base.auto_trigger, Some(true));
1192        assert_eq!(base.auto_insert_single, Some(false));
1193        assert_eq!(base.sources, Some(vec![SourceId::new("a")]));
1194        assert_eq!(base.suppress_in, Some(vec!["string".into()]));
1195    }
1196
1197    #[test]
1198    fn per_language_defaults_match_spec_examples() {
1199        let m = per_language_defaults();
1200        let md = m.get("markdown").expect("markdown default");
1201        assert_eq!(md.auto_trigger, Some(false));
1202        let md_sources = md.sources.as_ref().expect("markdown sources set");
1203        assert!(md_sources.iter().any(|s| s.as_str() == SNIPPET_SOURCE_ID));
1204        assert!(
1205            md_sources
1206                .iter()
1207                .any(|s| s.as_str() == BufferWordsSource::ID),
1208        );
1209        assert!(
1210            !md_sources
1211                .iter()
1212                .any(|s| s.as_str() == LSP_COMPLETION_SOURCE_ID),
1213            "markdown drops LSP per spec",
1214        );
1215        let rust = m.get("rust").expect("rust default");
1216        assert_eq!(rust.auto_trigger, Some(true));
1217        assert_eq!(rust.auto_insert_single, Some(true));
1218    }
1219
1220    #[test]
1221    fn canonical_source_id_maps_short_labels() {
1222        assert_eq!(
1223            canonical_source_id("lsp").as_str(),
1224            LSP_COMPLETION_SOURCE_ID,
1225        );
1226        assert_eq!(canonical_source_id("snippet").as_str(), SNIPPET_SOURCE_ID);
1227        assert_eq!(canonical_source_id("snippets").as_str(), SNIPPET_SOURCE_ID,);
1228        assert_eq!(
1229            canonical_source_id("buffer-words").as_str(),
1230            BufferWordsSource::ID,
1231        );
1232        assert_eq!(canonical_source_id("words").as_str(), BufferWordsSource::ID,);
1233        // Unknown label passes through verbatim so plugin
1234        // sources work by full id.
1235        assert_eq!(
1236            canonical_source_id("plugin:my-source").as_str(),
1237            "plugin:my-source",
1238        );
1239    }
1240
1241    #[test]
1242    fn rank_with_bonus_combines_priority_and_frequency_terms() {
1243        // Spec §3.6 stacks per-source priority on top of the
1244        // frequency bonus. Demonstrate that the host can roll
1245        // both into one closure: high-priority source wins at
1246        // tied matcher score, even when the low-priority side
1247        // has a larger frequency count.
1248        let r = InsertRanker::new();
1249        let lsp_src = SourceId::new("gen:lsp-completion");
1250        let words_src = SourceId::new("gen:buffer-words");
1251        let mut scored = vec![
1252            ScoredCandidate {
1253                raw: RawCandidate::plain("from_lsp", CandidateKind::Plain)
1254                    .with_source(lsp_src.clone()),
1255                score: crate::candidate::MatchScore(100),
1256                match_ranges: Vec::new(),
1257            },
1258            ScoredCandidate {
1259                raw: RawCandidate::plain("from_words", CandidateKind::Plain)
1260                    .with_source(words_src.clone()),
1261                score: crate::candidate::MatchScore(100),
1262                match_ranges: Vec::new(),
1263            },
1264        ];
1265        r.rank_with_bonus(&mut scored, |raw| {
1266            let priority = match raw.source.as_ref().map(|s| s.as_str()) {
1267                Some("gen:lsp-completion") => 200,
1268                Some("gen:buffer-words") => 100,
1269                _ => 0,
1270            };
1271            let freq = if raw.text == "from_words" {
1272                InsertRanker::FREQUENCY_BONUS_CAP
1273            } else {
1274                0
1275            };
1276            priority + freq
1277        });
1278        // LSP: 100 + 200 = 300; words: 100 + 100 + 50 = 250.
1279        // LSP wins despite the freq lift.
1280        assert_eq!(scored[0].raw.text, "from_lsp");
1281    }
1282}