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}