use super::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t")]
enum CompletionData {
Member { package: SmolStr, name: SmolStr },
Bare { name: SmolStr },
Local,
}
enum CompletionContext {
Member {
package: SmolStr,
internal: bool,
prefix: String,
},
Bare { prefix: String, offset: TextSize },
None,
}
struct Candidate {
label: String,
kind: CompletionItemKind,
sort_group: u8,
data: CompletionData,
}
pub(crate) fn completion_via_db(
snapshot: &Analysis,
path: &Path,
text: &str,
position: Position,
) -> Option<CompletionResponse> {
let line_index = LineIndex::new(text);
let offset = TextSize::new(line_index.position_to_byte(position).min(text.len()) as u32);
let index = snapshot.library_data().unwrap_or_default();
let remote = snapshot.remote_exports().unwrap_or_default();
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
let root = snapshot.parsed_tree(file);
Some(completions_from_node(&root, offset, &index, &remote))
}));
match cached {
Ok(Some(resp)) => resp,
Ok(None) | Err(_) => {
let root = parse(text).cst;
completions_from_node(&root, offset, &index, &remote)
}
}
}
pub fn compute_completions(
text: &str,
offset: usize,
indexed: &IndexedProvider,
) -> Option<CompletionResponse> {
let root = parse(text).cst;
let offset = TextSize::new(offset.min(text.len()) as u32);
completions_from_node(&root, offset, indexed, &RemoteExports::new())
}
pub fn resolve_completion(mut item: CompletionItem, indexed: &IndexedProvider) -> CompletionItem {
let Some(data) = item
.data
.clone()
.and_then(|v| serde_json::from_value::<CompletionData>(v).ok())
else {
return item;
};
let resolved = match &data {
CompletionData::Member { package, name } => indexed
.lookup(package, name)
.map(|entry| (package.clone(), entry)),
CompletionData::Bare { name } => base_package_of(name)
.and_then(|pkg| indexed.lookup(pkg, name).map(|entry| (pkg.clone(), entry))),
CompletionData::Local => None,
};
if let Some((package, entry)) = resolved {
item.documentation = Some(Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value: render_hover_markdown(&package, entry),
}));
item.detail = signature_of(entry);
}
item
}
pub(crate) fn completions_from_node(
root: &SyntaxNode,
offset: TextSize,
indexed: &IndexedProvider,
remote: &RemoteExports,
) -> Option<CompletionResponse> {
match classify_context(root, offset) {
CompletionContext::None => None,
CompletionContext::Member {
package,
internal,
prefix,
} => Some(build_response(
member_candidates(indexed, remote, &package, internal),
&prefix,
true,
)),
CompletionContext::Bare { prefix, offset } => Some(build_response(
bare_candidates(root, offset, indexed, remote),
&prefix,
false,
)),
}
}
fn classify_context(root: &SyntaxNode, offset: TextSize) -> CompletionContext {
if in_string_or_comment(root, offset) {
return CompletionContext::None;
}
if let Some((package, internal, prefix)) = member_context(root, offset) {
return CompletionContext::Member {
package,
internal,
prefix,
};
}
bare_context(root, offset)
}
fn in_string_or_comment(root: &SyntaxNode, offset: TextSize) -> bool {
let bad = |k: SyntaxKind| matches!(k, SyntaxKind::STRING | SyntaxKind::COMMENT);
match root.token_at_offset(offset) {
TokenAtOffset::None => false,
TokenAtOffset::Single(t) => bad(t.kind()),
TokenAtOffset::Between(left, _) => left.kind() == SyntaxKind::COMMENT,
}
}
fn member_context(root: &SyntaxNode, offset: TextSize) -> Option<(SmolStr, bool, String)> {
if let Some(token) = pick_name_token(root, offset) {
for ancestor in token.parent_ancestors() {
if ancestor.kind() == SyntaxKind::BINARY_EXPR
&& let Some(access) = BinaryExpr::cast(ancestor).and_then(|b| b.namespace_access())
&& access.name_token == token
{
return Some((
access.package,
access.internal,
prefix_in_token(&token, offset),
));
}
}
}
recover_namespace_at(root, offset).map(|(pkg, internal)| (pkg, internal, String::new()))
}
fn recover_namespace_at(root: &SyntaxNode, offset: TextSize) -> Option<(SmolStr, bool)> {
let left = match root.token_at_offset(offset) {
TokenAtOffset::Single(t) => Some(t),
TokenAtOffset::Between(l, _) => Some(l),
TokenAtOffset::None => None,
}?;
let op = skip_trivia_left(left)?;
let internal = match op.kind() {
SyntaxKind::COLON2 => false,
SyntaxKind::COLON3 => true,
_ => return None,
};
let pkg = prev_non_trivia(&op)?;
if !matches!(pkg.kind(), SyntaxKind::IDENT | SyntaxKind::STRING) {
return None;
}
Some((token_text_unquoted(&pkg), internal))
}
fn bare_context(root: &SyntaxNode, offset: TextSize) -> CompletionContext {
if let Some(token) = pick_name_token(root, offset) {
for ancestor in token.parent_ancestors() {
if ancestor.kind() == SyntaxKind::BINARY_EXPR
&& let Some(access) = BinaryExpr::cast(ancestor).and_then(|b| b.namespace_access())
&& access.package_token == token
{
return CompletionContext::None;
}
}
return CompletionContext::Bare {
prefix: prefix_in_token(&token, offset),
offset,
};
}
CompletionContext::Bare {
prefix: String::new(),
offset,
}
}
fn member_candidates(
indexed: &IndexedProvider,
remote: &RemoteExports,
package: &str,
internal: bool,
) -> Vec<Candidate> {
if let Some(pkg) = indexed.package(package) {
return pkg
.symbols
.iter()
.filter(|s| internal || s.exported)
.map(|s| Candidate {
label: s.name.to_string(),
kind: kind_of(s.kind),
sort_group: 3,
data: CompletionData::Member {
package: SmolStr::new(package),
name: s.name.clone(),
},
})
.collect();
}
let names = remote
.package_exports(package)
.map(|it| it.collect::<Vec<_>>())
.or_else(|| bundled_exports(package).map(|it| it.collect::<Vec<_>>()));
match names {
Some(names) => names
.into_iter()
.map(|n| Candidate {
label: n.to_string(),
kind: CompletionItemKind::FUNCTION,
sort_group: 3,
data: CompletionData::Member {
package: SmolStr::new(package),
name: n.clone(),
},
})
.collect(),
None => Vec::new(),
}
}
fn bare_candidates(
root: &SyntaxNode,
offset: TextSize,
indexed: &IndexedProvider,
remote: &RemoteExports,
) -> Vec<Candidate> {
let model = SemanticModel::build(root);
let mut out: Vec<Candidate> = Vec::new();
for (name, _kind) in model.names_in_scope_at(offset) {
out.push(Candidate {
label: name.to_string(),
kind: CompletionItemKind::VARIABLE,
sort_group: 0,
data: CompletionData::Local,
});
}
for pkg in model.loaded_packages() {
if let Some(idx) = indexed.package(&pkg.name) {
out.extend(
idx.symbols
.iter()
.filter(|s| s.exported)
.map(|s| Candidate {
label: s.name.to_string(),
kind: kind_of(s.kind),
sort_group: 1,
data: CompletionData::Member {
package: pkg.name.clone(),
name: s.name.clone(),
},
}),
);
} else if let Some(names) = remote
.package_exports(&pkg.name)
.map(|it| it.collect::<Vec<_>>())
.or_else(|| bundled_exports(&pkg.name).map(|it| it.collect::<Vec<_>>()))
{
out.extend(names.into_iter().map(|n| Candidate {
label: n.to_string(),
kind: CompletionItemKind::FUNCTION,
sort_group: 1,
data: CompletionData::Member {
package: pkg.name.clone(),
name: n.clone(),
},
}));
}
}
out.extend(base_names().map(|name| Candidate {
label: name.to_string(),
kind: CompletionItemKind::FUNCTION,
sort_group: 2,
data: CompletionData::Bare { name: name.clone() },
}));
out
}
fn build_response(mut cands: Vec<Candidate>, prefix: &str, member: bool) -> CompletionResponse {
if !prefix.is_empty() {
cands.retain(|c| c.label.starts_with(prefix));
}
cands.sort_by(|a, b| a.label.cmp(&b.label).then(a.sort_group.cmp(&b.sort_group)));
cands.dedup_by(|a, b| a.label == b.label);
let items = cands
.into_iter()
.map(|c| CompletionItem {
sort_text: Some(format!("{}{}", c.sort_group, c.label)),
filter_text: Some(c.label.clone()),
kind: Some(c.kind),
data: serde_json::to_value(c.data).ok(),
label: c.label,
..Default::default()
})
.collect();
CompletionResponse::List(CompletionList {
is_incomplete: !member,
items,
})
}
fn kind_of(kind: SymbolKind) -> CompletionItemKind {
match kind {
SymbolKind::Function => CompletionItemKind::FUNCTION,
SymbolKind::Data => CompletionItemKind::VALUE,
SymbolKind::Other => CompletionItemKind::FIELD,
}
}
fn prefix_in_token(token: &SyntaxToken<RLanguage>, offset: TextSize) -> String {
let start = token.text_range().start();
let rel = offset.checked_sub(start).map_or(0, u32::from) as usize;
let text = token.text();
text.get(..rel.min(text.len())).unwrap_or(text).to_string()
}
fn token_text_unquoted(token: &SyntaxToken<RLanguage>) -> SmolStr {
if token.kind() == SyntaxKind::STRING {
let text = token.text();
if text.len() >= 2 {
return SmolStr::new(&text[1..text.len() - 1]);
}
}
SmolStr::new(token.text())
}
fn is_trivia_kind(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT
)
}
fn skip_trivia_left(token: SyntaxToken<RLanguage>) -> Option<SyntaxToken<RLanguage>> {
let mut cur = Some(token);
while let Some(t) = cur {
if !is_trivia_kind(t.kind()) {
return Some(t);
}
cur = t.prev_token();
}
None
}
fn prev_non_trivia(token: &SyntaxToken<RLanguage>) -> Option<SyntaxToken<RLanguage>> {
let mut cur = token.prev_token();
while let Some(t) = cur {
if !is_trivia_kind(t.kind()) {
return Some(t);
}
cur = t.prev_token();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rindex::schema::{PackageIndex, SCHEMA_VERSION, SymbolEntry, SymbolKind};
fn items(resp: CompletionResponse) -> Vec<CompletionItem> {
match resp {
CompletionResponse::Array(v) => v,
CompletionResponse::List(l) => l.items,
}
}
fn labels(resp: CompletionResponse) -> Vec<String> {
items(resp).into_iter().map(|i| i.label).collect()
}
fn at_end(src: &str, needle: &str) -> usize {
src.find(needle).expect("needle present") + needle.len()
}
fn provider_with_unexported() -> IndexedProvider {
let idx = PackageIndex {
schema_version: SCHEMA_VERSION,
package: "pkg".into(),
version: "1.0".into(),
lib_path: "/lib".into(),
r_version: None,
harvested_at: 0,
symbols: vec![
SymbolEntry {
name: "pub_fn".into(),
kind: SymbolKind::Function,
exported: true,
formals: None,
help: None,
},
SymbolEntry {
name: "priv_fn".into(),
kind: SymbolKind::Function,
exported: false,
formals: None,
help: None,
},
],
};
IndexedProvider::from_indices([idx])
}
#[test]
fn member_completion_after_pkg_colons() {
let src = "dplyr::\n";
let got = labels(compute_completions(src, at_end(src, "::"), &indexed_dplyr()).unwrap());
assert!(got.contains(&"across".to_string()), "{got:?}");
}
#[test]
fn member_completion_with_partial_prefix() {
let p = indexed_dplyr();
let hit = "dplyr::acr\n";
assert!(
labels(compute_completions(hit, at_end(hit, "acr"), &p).unwrap())
.contains(&"across".to_string())
);
let miss = "dplyr::zzz\n";
assert!(labels(compute_completions(miss, at_end(miss, "zzz"), &p).unwrap()).is_empty());
}
#[test]
fn member_internal_includes_unexported() {
let p = provider_with_unexported();
let public = labels(compute_completions("pkg::\n", at_end("pkg::\n", "::"), &p).unwrap());
assert!(public.contains(&"pub_fn".to_string()));
assert!(!public.contains(&"priv_fn".to_string()), "{public:?}");
let internal =
labels(compute_completions("pkg:::\n", at_end("pkg:::\n", ":::"), &p).unwrap());
assert!(internal.contains(&"pub_fn".to_string()));
assert!(internal.contains(&"priv_fn".to_string()), "{internal:?}");
}
#[test]
fn member_falls_back_to_bundled() {
let src = "data.table::\n";
let got =
labels(compute_completions(src, at_end(src, "::"), &IndexedProvider::empty()).unwrap());
assert!(got.contains(&"fread".to_string()), "{got:?}");
}
fn remote(pkg: &str, names: &[&str]) -> RemoteExports {
let mut r = RemoteExports::new();
r.insert_package(pkg, names.iter().map(|n| SmolStr::new(*n)));
r
}
fn labels_with_remote(src: &str, needle: &str, remote: &RemoteExports) -> Vec<String> {
let root = parse(src).cst;
let offset = TextSize::new(at_end(src, needle) as u32);
labels(completions_from_node(&root, offset, &IndexedProvider::empty(), remote).unwrap())
}
#[test]
fn member_uses_remote_for_uninstalled_unbundled_package() {
let got = labels_with_remote(
"tinytable::\n",
"::",
&remote("tinytable", &["tt", "theme_tt"]),
);
assert!(got.contains(&"tt".to_string()), "{got:?}");
assert!(got.contains(&"theme_tt".to_string()), "{got:?}");
}
#[test]
fn bare_uses_remote_for_attached_uninstalled_package() {
let got = labels_with_remote(
"library(tinytable)\ntt\n",
"\ntt",
&remote("tinytable", &["tt"]),
);
assert!(got.contains(&"tt".to_string()), "{got:?}");
}
#[test]
fn bare_prefix_includes_local_and_base() {
let src = "value <- 1\nv\n";
let off = src.rfind('v').unwrap() + 1;
let its = items(compute_completions(src, off, &IndexedProvider::empty()).unwrap());
let value = its
.iter()
.find(|i| i.label == "value")
.expect("local value");
assert_eq!(value.kind, Some(CompletionItemKind::VARIABLE));
assert!(value.sort_text.as_deref().unwrap().starts_with('0'));
assert!(its.iter().any(|i| i.label == "vector"), "a base name");
}
#[test]
fn bare_local_masks_base_duplicate() {
let src = "mean <- 1\nmea\n";
let off = at_end(src, "mea");
let its = items(compute_completions(src, off, &IndexedProvider::empty()).unwrap());
let means: Vec<_> = its.iter().filter(|i| i.label == "mean").collect();
assert_eq!(means.len(), 1, "one `mean`: {its:?}");
assert!(means[0].sort_text.as_deref().unwrap().starts_with('0'));
}
#[test]
fn bare_includes_attached_export() {
let src = "library(dplyr)\nacr\n";
let got = labels(compute_completions(src, at_end(src, "acr"), &indexed_dplyr()).unwrap());
assert!(got.contains(&"across".to_string()), "{got:?}");
}
#[test]
fn locals_respect_scope() {
let src = "f <- function(a) {\n \n}\ng <- function(b) {\n b\n}\n";
let off_f = src.find(" \n").unwrap() + 2;
let in_f = labels(compute_completions(src, off_f, &IndexedProvider::empty()).unwrap());
assert!(in_f.contains(&"a".to_string()), "f sees a: {in_f:?}");
assert!(!in_f.contains(&"b".to_string()), "f hides b: {in_f:?}");
}
#[test]
fn no_completion_in_string() {
let src = "x <- \"dpl\"\n";
let off = src.find("dpl").unwrap() + 1;
assert!(compute_completions(src, off, &documented_dplyr()).is_none());
}
#[test]
fn no_completion_in_comment() {
let src = "# dplyr::acr\n";
assert!(compute_completions(src, at_end(src, "acr"), &indexed_dplyr()).is_none());
}
#[test]
fn resolve_attaches_docs() {
let item = CompletionItem {
label: "across".into(),
data: serde_json::to_value(CompletionData::Member {
package: "dplyr".into(),
name: "across".into(),
})
.ok(),
..Default::default()
};
let resolved = resolve_completion(item, &documented_dplyr());
let doc = match resolved.documentation {
Some(Documentation::MarkupContent(m)) => m.value,
other => panic!("expected markdown, got {other:?}"),
};
assert!(doc.contains("dplyr::across"), "{doc}");
assert_eq!(resolved.detail.as_deref(), Some("across(.cols, .fns)"));
}
#[test]
fn resolve_local_unchanged() {
let item = CompletionItem {
label: "x".into(),
data: serde_json::to_value(CompletionData::Local).ok(),
..Default::default()
};
let resolved = resolve_completion(item, &documented_dplyr());
assert!(resolved.documentation.is_none());
assert!(resolved.detail.is_none());
}
}