964 lines
32 KiB
Rust
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");
|
|
}
|
|
}
|