pmacs/src/completion_framework.rs

964 lines
32 KiB
Rust

// completion_framework.rs --- T M4.11 unified completion framework:
// pluggable providers, priority, dedup, snippets, Lua-defineable
// custom sources.
//! Unified completion provider framework.
//!
//! Per spec §M4.11: "aggregate completion sources (LSP, dabbrev,
//! snippets, project symbols) through a unified provider
//! interface."
//!
//! # Concepts
//!
//! * [`CompletionContext`] is the input every provider sees: the
//! typed prefix, cursor line/col, the buffer's current text, the
//! language tag, and the project root (if any). It is owned and
//! `Clone`, so providers cannot mutate it.
//! * [`CompletionCandidate`] is a [`crate::completion::CompletionItem`]
//! plus three pieces of framework metadata: the source name (which
//! provider produced it), the provider's current priority, and a
//! score derived from the prefix match.
//! * A [`ProviderFn`] is a closure
//! `Fn(&CompletionContext) -> Vec<CompletionItem>`. Built-in
//! providers ([`dabbrev_provider`], [`snippet_provider`],
//! [`project_symbols_provider`], [`lsp_completion_provider`])
//! are constructed by free functions in this module. Lua-defined
//! providers go through the same [`ProviderFn`] type --- the Lua
//! binding wraps a `mlua::Function` in a closure.
//! * [`CompletionRegistry`] owns the providers, a monotonic id
//! counter, and a single sort order. [`CompletionRegistry::collect`]
//! walks every enabled provider, tags each item with framework
//! metadata, dedups by `(label, effective_insert_text)`, and
//! sorts by `(score desc, priority desc, label asc)`.
//!
//! # Acceptance hooks
//!
//! * **"Multiple sources combine without duplicates"**: dedup keeps
//! the highest-priority winner; ties broken by score.
//! * **"Source priority configurable"**:
//! [`CompletionRegistry::set_priority`] adjusts a registered
//! provider in-place; subsequent `collect` calls observe the new
//! value.
//! * **"Custom sources defineable from Lua"**: [`ProviderFn`] is a
//! plain closure; the Lua binding (`pmacs.completion.register`)
//! wraps a Lua function as one.
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::completion::{CompletionItem, CompletionItemKind};
// ---------------------------------------------------------------------------
// Provider id
// ---------------------------------------------------------------------------
/// Stable identifier for one registered provider. Allocated in
/// monotonic order across the whole process.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
pub struct ProviderId(u64);
impl ProviderId {
/// Mint a fresh id.
#[must_use]
pub fn next() -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(1);
Self(COUNTER.fetch_add(1, Ordering::Relaxed))
}
/// Raw counter value. Used at the Lua boundary.
#[must_use]
pub fn raw(self) -> u64 {
self.0
}
}
impl std::fmt::Display for ProviderId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ProviderId({})", self.0)
}
}
// ---------------------------------------------------------------------------
// Context + trigger
// ---------------------------------------------------------------------------
/// What kicked off a completion request. Providers can use this to
/// short-circuit (e.g. dabbrev does nothing on an explicit
/// keystroke trigger if the prefix is empty).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CompletionTrigger {
/// User explicitly invoked completion (e.g. M-x or `C-SPC`).
Invoked,
/// A trigger character was typed (e.g. `.`, `:` for LSP).
Char(char),
/// The previous response was incomplete; refresh.
Incomplete,
}
/// All input a provider sees on a completion request. Owned so
/// providers can't reach back into editor state.
#[derive(Clone, Debug)]
pub struct CompletionContext {
/// What the user has typed so far (the substring the popup is
/// filtering against).
pub prefix: String,
/// Zero-based line of the cursor.
pub line: u32,
/// Zero-based column of the cursor.
pub col: u32,
/// Bytes of the currently active buffer at request time.
/// Cheap to clone (`Rc<str>` keeps it shareable across
/// providers without re-copying).
pub buffer_text: Rc<str>,
/// Language tag (`"rust"`, `"lua"`, …) if known.
pub language: Option<String>,
/// Project root, if the active buffer belongs to one.
pub project_root: Option<PathBuf>,
/// What kicked off the request.
pub trigger: CompletionTrigger,
}
impl CompletionContext {
/// Construct a context with the most common defaults filled in.
#[must_use]
pub fn new(prefix: impl Into<String>, buffer_text: impl Into<Rc<str>>) -> Self {
Self {
prefix: prefix.into(),
line: 0,
col: 0,
buffer_text: buffer_text.into(),
language: None,
project_root: None,
trigger: CompletionTrigger::Invoked,
}
}
}
// ---------------------------------------------------------------------------
// Candidate
// ---------------------------------------------------------------------------
/// A completion candidate together with the framework metadata
/// added at collection time.
#[derive(Clone, Debug)]
pub struct CompletionCandidate {
/// The underlying item.
pub item: CompletionItem,
/// Name of the provider that produced this item.
pub source: String,
/// Provider priority at collection time.
pub priority: i32,
/// Match score against [`CompletionContext::prefix`]. Higher = better.
pub score: i32,
}
impl CompletionCandidate {
/// Effective insert text (`item.insert_text` or fallback to
/// `item.label`). Convenience accessor.
#[must_use]
pub fn insert_text(&self) -> &str {
self.item.effective_insert_text()
}
/// Dedup key: `(label, effective_insert_text)`.
#[must_use]
pub fn dedup_key(&self) -> (String, String) {
(self.item.label.clone(), self.insert_text().to_owned())
}
}
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
/// Type alias for the closure each provider registers. Owned, so
/// the registry holds the closure and the provider's name+priority
/// independently.
pub type ProviderFn = Box<dyn Fn(&CompletionContext) -> Vec<CompletionItem>>;
/// One slot in the [`CompletionRegistry`].
pub struct RegisteredProvider {
/// Stable id.
pub id: ProviderId,
/// Display name (the dedup key callers see in candidate.source).
pub name: String,
/// Higher = more important; ties broken by score then label.
pub priority: i32,
/// Disabled providers contribute nothing to `collect`.
pub enabled: bool,
source: ProviderFn,
}
impl std::fmt::Debug for RegisteredProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RegisteredProvider")
.field("id", &self.id)
.field("name", &self.name)
.field("priority", &self.priority)
.field("enabled", &self.enabled)
.finish_non_exhaustive()
}
}
/// Owns every registered completion provider.
#[derive(Default)]
pub struct CompletionRegistry {
providers: Vec<RegisteredProvider>,
}
impl CompletionRegistry {
/// Empty registry.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Number of registered providers.
#[must_use]
pub fn len(&self) -> usize {
self.providers.len()
}
/// Whether the registry has no providers.
#[must_use]
pub fn is_empty(&self) -> bool {
self.providers.is_empty()
}
/// Register a provider. Returns the new id.
pub fn register(
&mut self,
name: impl Into<String>,
priority: i32,
source: ProviderFn,
) -> ProviderId {
let id = ProviderId::next();
self.providers.push(RegisteredProvider {
id,
name: name.into(),
priority,
enabled: true,
source,
});
id
}
/// Drop a provider. Returns whether something was removed.
pub fn unregister(&mut self, id: ProviderId) -> bool {
let before = self.providers.len();
self.providers.retain(|p| p.id != id);
self.providers.len() != before
}
/// Adjust a provider's priority. Returns whether the id was found.
pub fn set_priority(&mut self, id: ProviderId, priority: i32) -> bool {
if let Some(p) = self.providers.iter_mut().find(|p| p.id == id) {
p.priority = priority;
true
} else {
false
}
}
/// Enable or disable a provider. Disabled providers contribute
/// nothing to `collect` but stay registered.
pub fn set_enabled(&mut self, id: ProviderId, enabled: bool) -> bool {
if let Some(p) = self.providers.iter_mut().find(|p| p.id == id) {
p.enabled = enabled;
true
} else {
false
}
}
/// Borrow a registered provider by id.
#[must_use]
pub fn get(&self, id: ProviderId) -> Option<&RegisteredProvider> {
self.providers.iter().find(|p| p.id == id)
}
/// All providers, in registration order.
#[must_use]
pub fn providers(&self) -> &[RegisteredProvider] {
&self.providers
}
/// Run every enabled provider against `ctx`, dedup, and sort.
/// Higher-priority providers' duplicates win.
#[must_use]
pub fn collect(&self, ctx: &CompletionContext) -> Vec<CompletionCandidate> {
// Sort providers by priority descending so that when we
// walk them in order, the first hit on a dedup key is also
// the highest-priority hit.
let mut order: Vec<&RegisteredProvider> =
self.providers.iter().filter(|p| p.enabled).collect();
order.sort_by(|a, b| b.priority.cmp(&a.priority));
let mut by_key: HashMap<(String, String), usize> = HashMap::new();
let mut out: Vec<CompletionCandidate> = Vec::new();
for p in order {
let items = (p.source)(ctx);
for item in items {
let score = score_match(&item, &ctx.prefix);
let cand = CompletionCandidate {
source: p.name.clone(),
priority: p.priority,
score,
item,
};
let key = cand.dedup_key();
if let Some(&idx) = by_key.get(&key) {
let existing = &out[idx];
let replace = cand.priority > existing.priority
|| (cand.priority == existing.priority && cand.score > existing.score);
if replace {
out[idx] = cand;
}
} else {
by_key.insert(key, out.len());
out.push(cand);
}
}
}
out.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| b.priority.cmp(&a.priority))
.then_with(|| a.item.label.cmp(&b.item.label))
});
out
}
}
/// Cheaply-cloneable shared registry.
pub type SharedCompletionRegistry = Rc<RefCell<CompletionRegistry>>;
// ---------------------------------------------------------------------------
// Scoring (shared with project_index in shape, kept local in body)
// ---------------------------------------------------------------------------
const SCORE_EXACT: i32 = 1000;
const SCORE_PREFIX: i32 = 600;
const SCORE_WORD_BOUNDARY: i32 = 300;
const SCORE_SUBSTRING: i32 = 100;
const SCORE_NO_PREFIX: i32 = 0;
/// Score a candidate against a prefix. Empty prefix returns
/// [`SCORE_NO_PREFIX`] for everything (the framework still
/// surfaces all candidates; the UI is responsible for filtering
/// at the empty-prefix case).
fn score_match(item: &CompletionItem, prefix: &str) -> i32 {
if prefix.is_empty() {
return SCORE_NO_PREFIX;
}
// Use filter_text if the LSP supplied one (it's what the
// server *wants* us to filter by), else the label.
let haystack = item.filter_text.as_deref().unwrap_or(&item.label);
let lower = haystack.to_lowercase();
let needle = prefix.to_lowercase();
if lower == needle {
return SCORE_EXACT;
}
if lower.starts_with(&needle) {
return SCORE_PREFIX;
}
let Some(pos) = lower.find(&needle) else {
return SCORE_NO_PREFIX - 1;
};
let at_boundary = pos > 0 && {
let prev = lower.as_bytes()[pos - 1];
!prev.is_ascii_alphanumeric()
};
let mut score = if at_boundary {
SCORE_WORD_BOUNDARY
} else {
SCORE_SUBSTRING
};
score -= i32::try_from(lower.len().saturating_sub(needle.len())).unwrap_or(0);
score
}
// ---------------------------------------------------------------------------
// Snippet store + provider
// ---------------------------------------------------------------------------
/// One reusable snippet template.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Snippet {
/// Display name; surfaced as the completion `label`.
pub name: String,
/// Trigger prefix the user types. Snippets only fire when the
/// completion-context prefix matches this (case-insensitive).
pub prefix: String,
/// Body of the snippet --- the text that's inserted on accept.
pub body: String,
/// Optional one-line description (rendered as `detail`).
pub description: Option<String>,
/// Optional language scope (`Some("rust")` only fires when
/// the active buffer's language matches; [`None`] = all scopes).
pub scope: Option<String>,
}
impl Snippet {
/// Convert this snippet to a [`CompletionItem`] for the framework.
#[must_use]
pub fn to_completion_item(&self) -> CompletionItem {
CompletionItem {
label: self.name.clone(),
kind: CompletionItemKind::Snippet,
detail: self.description.clone(),
documentation: Some(self.body.clone()),
insert_text: Some(self.body.clone()),
sort_text: None,
filter_text: Some(self.prefix.clone()),
}
}
}
/// Snippet storage. Indexed by snippet name, so adding a snippet
/// with an existing name replaces the prior entry.
#[derive(Clone, Debug, Default)]
pub struct SnippetRegistry {
snippets: HashMap<String, Snippet>,
}
impl SnippetRegistry {
/// Empty registry.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Number of stored snippets.
#[must_use]
pub fn len(&self) -> usize {
self.snippets.len()
}
/// Whether the registry is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.snippets.is_empty()
}
/// Insert (or replace) a snippet.
pub fn add(&mut self, snippet: Snippet) {
self.snippets.insert(snippet.name.clone(), snippet);
}
/// Drop a snippet by name. Returns whether something was removed.
pub fn remove(&mut self, name: &str) -> bool {
self.snippets.remove(name).is_some()
}
/// Borrow a snippet by name.
#[must_use]
pub fn get(&self, name: &str) -> Option<&Snippet> {
self.snippets.get(name)
}
/// All snippets in name order.
#[must_use]
pub fn list(&self) -> Vec<Snippet> {
let mut out: Vec<Snippet> = self.snippets.values().cloned().collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// Snippets whose `prefix` starts with `prefix` (case-
/// insensitive). Used by [`snippet_provider`].
#[must_use]
pub fn find(&self, prefix: &str, language: Option<&str>) -> Vec<Snippet> {
let lower = prefix.to_lowercase();
let mut out: Vec<Snippet> = self
.snippets
.values()
.filter(|s| {
let scope_ok = match (&s.scope, language) {
(None, _) => true,
(Some(scope), Some(lang)) => scope == lang,
(Some(_), None) => false,
};
if !scope_ok {
return false;
}
if lower.is_empty() {
return true;
}
s.prefix.to_lowercase().starts_with(&lower)
})
.cloned()
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
}
/// Cheaply-cloneable shared snippet registry.
pub type SharedSnippetRegistry = Rc<RefCell<SnippetRegistry>>;
// ---------------------------------------------------------------------------
// Built-in providers
// ---------------------------------------------------------------------------
/// dabbrev: scan the buffer text for words starting with the
/// prefix (other than the prefix itself). Cheap, language-
/// agnostic. Empty prefix returns nothing.
#[must_use]
pub fn dabbrev_provider() -> ProviderFn {
Box::new(|ctx: &CompletionContext| -> Vec<CompletionItem> {
if ctx.prefix.is_empty() {
return Vec::new();
}
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut out = Vec::new();
for token in word_tokens(ctx.buffer_text.as_ref()) {
if token == ctx.prefix {
continue;
}
if !token.to_lowercase().starts_with(&ctx.prefix.to_lowercase()) {
continue;
}
if !seen.insert(token.to_owned()) {
continue;
}
out.push(CompletionItem {
label: token.to_owned(),
kind: CompletionItemKind::Text,
detail: None,
documentation: None,
insert_text: None,
sort_text: None,
filter_text: None,
});
if out.len() >= 64 {
break;
}
}
out
})
}
/// Snippet provider: surface snippets whose trigger prefix matches
/// `ctx.prefix`. Filters by `ctx.language` against `Snippet::scope`.
#[must_use]
pub fn snippet_provider(snippets: SharedSnippetRegistry) -> ProviderFn {
Box::new(move |ctx: &CompletionContext| -> Vec<CompletionItem> {
let snips = snippets.borrow();
snips
.find(&ctx.prefix, ctx.language.as_deref())
.into_iter()
.map(|s| s.to_completion_item())
.collect()
})
}
/// Project-symbols provider over a [`crate::project_index::ProjectIndexer`].
/// Looks up symbols whose name matches `ctx.prefix` in the index
/// rooted at `ctx.project_root`.
#[must_use]
pub fn project_symbols_provider(indexer: crate::lua_bindings::SharedProjectIndexer) -> ProviderFn {
Box::new(move |ctx: &CompletionContext| -> Vec<CompletionItem> {
let Some(root) = ctx.project_root.as_deref() else {
return Vec::new();
};
if ctx.prefix.is_empty() {
return Vec::new();
}
let ix_ref = indexer.borrow();
let Some(idx) = ix_ref.get(root) else {
return Vec::new();
};
let hits = idx.search(&ctx.prefix, 64);
hits.iter()
.map(|h| {
let detail = format!(
"{} {}:{}",
h.kind.tag(),
h.relative_path.display(),
h.line + 1,
);
CompletionItem {
label: h.name.clone(),
kind: project_kind_to_completion_kind(&h.kind),
detail: Some(detail),
documentation: None,
insert_text: None,
sort_text: None,
filter_text: None,
}
})
.collect()
})
}
fn project_kind_to_completion_kind(k: &crate::project_index::SymbolKind) -> CompletionItemKind {
use crate::project_index::SymbolKind as K;
match k {
K::Function => CompletionItemKind::Function,
K::Method => CompletionItemKind::Method,
K::Struct => CompletionItemKind::Struct,
K::Class => CompletionItemKind::Class,
K::Trait => CompletionItemKind::Interface,
K::Enum => CompletionItemKind::Enum,
K::Variable => CompletionItemKind::Variable,
K::Constant => CompletionItemKind::Constant,
K::Field => CompletionItemKind::Field,
K::Module => CompletionItemKind::Module,
K::Macro => CompletionItemKind::Snippet,
K::TypeAlias => CompletionItemKind::TypeParameter,
K::Other(_) => CompletionItemKind::Text,
}
}
/// LSP completion provider: surface whatever's currently cached in
/// the LSP completion store. The framework does **not** drive a
/// fresh `textDocument/completion` request --- that's the editor's
/// job; we just read whatever the async pipeline has produced so
/// far, across every cached `(server_id, uri)` key. The registry's
/// dedup collapses identical entries; the prefix score ranks them.
#[must_use]
pub fn lsp_completion_provider(lsp: crate::lsp::SharedLspManager) -> ProviderFn {
Box::new(move |_ctx: &CompletionContext| -> Vec<CompletionItem> {
let store_handle = {
let mgr = lsp.borrow();
mgr.completion_store()
};
let Ok(store) = store_handle.lock() else {
return Vec::new();
};
let mut out: Vec<CompletionItem> = Vec::new();
let keys: Vec<_> = store.keys().cloned().collect();
for key in keys {
for item in store.items(&key) {
out.push(item.clone());
if out.len() >= 256 {
return out;
}
}
}
out
})
}
// ---------------------------------------------------------------------------
// Word tokeniser (used by dabbrev)
// ---------------------------------------------------------------------------
fn word_tokens(text: &str) -> impl Iterator<Item = &str> {
text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.filter(|t| !t.is_empty())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn item(label: &str, kind: CompletionItemKind) -> CompletionItem {
CompletionItem {
label: label.to_owned(),
kind,
detail: None,
documentation: None,
insert_text: None,
sort_text: None,
filter_text: None,
}
}
fn ctx(prefix: &str, buffer_text: &str) -> CompletionContext {
CompletionContext::new(prefix, buffer_text)
}
fn provider_const(items: Vec<CompletionItem>) -> ProviderFn {
Box::new(move |_| items.clone())
}
#[test]
fn registry_register_and_collect_passes_through_items() {
let mut reg = CompletionRegistry::new();
reg.register(
"static",
10,
provider_const(vec![item("alpha", CompletionItemKind::Function)]),
);
let cands = reg.collect(&ctx("al", ""));
assert_eq!(cands.len(), 1);
assert_eq!(cands[0].item.label, "alpha");
assert_eq!(cands[0].source, "static");
assert_eq!(cands[0].priority, 10);
assert!(cands[0].score >= SCORE_PREFIX);
}
#[test]
fn registry_dedups_identical_label_and_insert_text() {
let mut reg = CompletionRegistry::new();
let id_a = reg.register(
"a",
5,
provider_const(vec![item("foo", CompletionItemKind::Function)]),
);
let _id_b = reg.register(
"b",
10,
provider_const(vec![item("foo", CompletionItemKind::Function)]),
);
let cands = reg.collect(&ctx("foo", ""));
assert_eq!(cands.len(), 1, "duplicate labels must collapse");
// Higher-priority provider wins.
assert_eq!(cands[0].source, "b");
assert_eq!(cands[0].priority, 10);
// The lower-priority registration is still in the registry,
// it just lost the dedup race.
assert_eq!(reg.len(), 2);
assert!(reg.get(id_a).is_some());
}
#[test]
fn registry_priority_change_takes_effect() {
let mut reg = CompletionRegistry::new();
let lo = reg.register(
"lo",
1,
provider_const(vec![item("dup", CompletionItemKind::Function)]),
);
let hi = reg.register(
"hi",
100,
provider_const(vec![item("dup", CompletionItemKind::Function)]),
);
let cands = reg.collect(&ctx("dup", ""));
assert_eq!(cands[0].source, "hi");
reg.set_priority(lo, 1000);
reg.set_priority(hi, 0);
let cands2 = reg.collect(&ctx("dup", ""));
assert_eq!(
cands2[0].source, "lo",
"after re-prioritising, lo should win the dedup race"
);
}
#[test]
fn registry_disabled_provider_contributes_nothing() {
let mut reg = CompletionRegistry::new();
let id = reg.register(
"off",
10,
provider_const(vec![item("hidden", CompletionItemKind::Function)]),
);
reg.set_enabled(id, false);
let cands = reg.collect(&ctx("hi", ""));
assert!(cands.is_empty());
// Re-enable and we see it again.
reg.set_enabled(id, true);
let cands2 = reg.collect(&ctx("hi", ""));
assert_eq!(cands2.len(), 1);
}
#[test]
fn registry_unregister_removes_provider() {
let mut reg = CompletionRegistry::new();
let id = reg.register(
"x",
0,
provider_const(vec![item("foo", CompletionItemKind::Function)]),
);
assert_eq!(reg.len(), 1);
assert!(reg.unregister(id));
assert_eq!(reg.len(), 0);
assert!(!reg.unregister(id));
}
#[test]
fn registry_sort_order_is_score_then_priority_then_label() {
let mut reg = CompletionRegistry::new();
reg.register(
"a",
10,
provider_const(vec![
item("zeta_match", CompletionItemKind::Function),
item("alpha_match", CompletionItemKind::Function),
]),
);
reg.register(
"b",
1,
provider_const(vec![item("match_yes", CompletionItemKind::Function)]),
);
let cands = reg.collect(&ctx("match", ""));
// "match_yes" is a *prefix* match; the others are
// word-boundary or substring matches, so it must rank
// first regardless of provider priority.
assert_eq!(cands[0].item.label, "match_yes");
}
#[test]
fn dabbrev_finds_buffer_words_with_matching_prefix() {
let f = dabbrev_provider();
let buf = "let parser = Parser::new();\nfn parse_helper() {}\n";
let cands = f(&ctx("par", buf));
let names: Vec<_> = cands.iter().map(|i| i.label.as_str()).collect();
assert!(names.contains(&"parser"));
assert!(names.contains(&"Parser"));
assert!(names.contains(&"parse_helper"));
// The exact prefix itself is filtered out (a dabbrev pop-up
// is useless if the user types `par<TAB>` and gets `par`).
assert!(!names.contains(&"par"));
}
#[test]
fn dabbrev_returns_empty_on_empty_prefix() {
let f = dabbrev_provider();
let cands = f(&ctx("", "alpha beta gamma"));
assert!(cands.is_empty());
}
#[test]
fn snippet_registry_add_remove_list() {
let mut snips = SnippetRegistry::new();
snips.add(Snippet {
name: "fn".into(),
prefix: "fn".into(),
body: "fn ${1:name}() {\n $0\n}".into(),
description: Some("function".into()),
scope: Some("rust".into()),
});
snips.add(Snippet {
name: "for".into(),
prefix: "for".into(),
body: "for $1 in $2 {\n $0\n}".into(),
description: None,
scope: Some("rust".into()),
});
assert_eq!(snips.len(), 2);
let listed = snips.list();
assert_eq!(listed[0].name, "fn");
assert_eq!(listed[1].name, "for");
assert!(snips.remove("fn"));
assert_eq!(snips.len(), 1);
assert!(!snips.remove("fn"));
}
#[test]
fn snippet_registry_find_filters_by_prefix_and_scope() {
let mut snips = SnippetRegistry::new();
snips.add(Snippet {
name: "fn-rust".into(),
prefix: "fn".into(),
body: "fn _name_() {}".into(),
description: None,
scope: Some("rust".into()),
});
snips.add(Snippet {
name: "function-lua".into(),
prefix: "fn".into(),
body: "function _name_() end".into(),
description: None,
scope: Some("lua".into()),
});
snips.add(Snippet {
name: "fn-any".into(),
prefix: "fn".into(),
body: "fn ()".into(),
description: None,
scope: None,
});
let rust = snips.find("fn", Some("rust"));
let names: Vec<_> = rust.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"fn-rust"));
assert!(names.contains(&"fn-any"));
assert!(!names.contains(&"function-lua"));
let none = snips.find("xy", Some("rust"));
assert!(none.is_empty());
}
#[test]
fn snippet_provider_emits_items() {
let snips = Rc::new(RefCell::new(SnippetRegistry::new()));
snips.borrow_mut().add(Snippet {
name: "fn".into(),
prefix: "fn".into(),
body: "fn name() {}".into(),
description: Some("fn template".into()),
scope: Some("rust".into()),
});
let f = snippet_provider(snips);
let mut c = ctx("fn", "");
c.language = Some("rust".into());
let cands = f(&c);
assert_eq!(cands.len(), 1);
assert_eq!(cands[0].label, "fn");
assert_eq!(cands[0].kind, CompletionItemKind::Snippet);
assert_eq!(cands[0].insert_text.as_deref(), Some("fn name() {}"));
}
#[test]
fn lua_style_custom_provider_via_closure() {
// The Lua surface wraps a `mlua::Function` in a closure;
// here we mimic that with a plain closure to verify the
// boxing pathway works end-to-end.
let counter = Rc::new(RefCell::new(0));
let counter_clone = counter.clone();
let f: ProviderFn = Box::new(move |c: &CompletionContext| {
*counter_clone.borrow_mut() += 1;
vec![CompletionItem {
label: format!("hello-{}", c.prefix),
kind: CompletionItemKind::Text,
detail: None,
documentation: None,
insert_text: None,
sort_text: None,
filter_text: None,
}]
});
let mut reg = CompletionRegistry::new();
let _id = reg.register("custom", 0, f);
let cands = reg.collect(&ctx("xy", ""));
assert_eq!(cands.len(), 1);
assert_eq!(cands[0].item.label, "hello-xy");
assert_eq!(*counter.borrow(), 1);
}
#[test]
fn empty_prefix_zero_score_preserves_order() {
let mut reg = CompletionRegistry::new();
reg.register(
"p",
0,
provider_const(vec![
item("zeta", CompletionItemKind::Function),
item("alpha", CompletionItemKind::Function),
]),
);
let cands = reg.collect(&ctx("", ""));
assert_eq!(cands.len(), 2);
// With equal scores+priority, alphabetical wins.
assert_eq!(cands[0].item.label, "alpha");
assert_eq!(cands[1].item.label, "zeta");
}
#[test]
fn substring_match_below_prefix() {
let mut reg = CompletionRegistry::new();
reg.register(
"p",
0,
provider_const(vec![
item("XparseY", CompletionItemKind::Function),
item("parse_thing", CompletionItemKind::Function),
]),
);
let cands = reg.collect(&ctx("parse", ""));
assert_eq!(cands[0].item.label, "parse_thing");
}
}