use super::*;
pub(crate) fn hover_via_db(
snapshot: &Analysis,
path: &Path,
text: &str,
position: Position,
) -> Option<Hover> {
let line_index = LineIndex::new(text);
let offset = line_index.position_to_byte(position).min(text.len());
let index = snapshot.library_data().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(hover_from_node(&root, &line_index, offset, &index))
}));
match cached {
Ok(Some(hover)) => hover,
Ok(None) | Err(_) => {
let root = parse(text).cst;
hover_from_node(&root, &line_index, offset, &index)
}
}
}
pub(crate) enum SymbolQuery {
Namespaced {
package: SmolStr,
name: SmolStr,
range: TextRange,
},
Bare {
name: SmolStr,
range: TextRange,
},
}
pub fn compute_hover(text: &str, offset: usize, indexed: &IndexedProvider) -> Option<Hover> {
let root = parse(text).cst;
let line_index = LineIndex::new(text);
hover_from_node(&root, &line_index, offset.min(text.len()), indexed)
}
pub(crate) fn hover_from_node(
root: &SyntaxNode,
line_index: &LineIndex,
offset: usize,
indexed: &IndexedProvider,
) -> Option<Hover> {
let offset = TextSize::new(offset as u32);
let query = symbol_query_at(root, offset)?;
let (package, entry, range) = resolve_query(query, root, indexed)?;
let lsp_range = Range {
start: line_index.byte_to_position(u32::from(range.start()) as usize),
end: line_index.byte_to_position(u32::from(range.end()) as usize),
};
Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: render_hover_markdown(&package, entry),
}),
range: Some(lsp_range),
})
}
pub(crate) fn symbol_query_at(root: &SyntaxNode, offset: TextSize) -> Option<SymbolQuery> {
let 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(SymbolQuery::Namespaced {
package: access.package,
name: access.name,
range: token.text_range(),
});
}
}
Some(SymbolQuery::Bare {
name: SmolStr::new(token.text()),
range: token.text_range(),
})
}
pub(crate) fn pick_name_token(
root: &SyntaxNode,
offset: TextSize,
) -> Option<SyntaxToken<RLanguage>> {
let is_name = |k: SyntaxKind| matches!(k, SyntaxKind::IDENT | SyntaxKind::USER_OP);
match root.token_at_offset(offset) {
TokenAtOffset::None => None,
TokenAtOffset::Single(t) => is_name(t.kind()).then_some(t),
TokenAtOffset::Between(left, right) => {
if is_name(right.kind()) {
Some(right)
} else if is_name(left.kind()) {
Some(left)
} else {
None
}
}
}
}
pub(crate) fn resolve_query<'p>(
query: SymbolQuery,
root: &SyntaxNode,
indexed: &'p IndexedProvider,
) -> Option<(SmolStr, &'p SymbolEntry, TextRange)> {
match query {
SymbolQuery::Namespaced {
package,
name,
range,
} => {
let entry = indexed.lookup(&package, &name)?;
Some((package, entry, range))
}
SymbolQuery::Bare { name, range } => {
let model = SemanticModel::build(root);
let remote = RemoteExports::new();
let package = match resolve_origin(indexed, &remote, &name, model.loaded_packages()) {
PackageOrigin::Resolved(p) => p,
PackageOrigin::Ambiguous(mut v) => v.pop()?,
PackageOrigin::Unknown => return None,
};
let entry = indexed.lookup(&package, &name)?;
Some((package, entry, range))
}
}
}
pub(crate) fn render_hover_markdown(package: &str, entry: &SymbolEntry) -> String {
use std::fmt::Write as _;
let mut out = String::new();
if let Some(signature) = signature_of(entry) {
let _ = write!(out, "```r\n{signature}\n```\n");
}
let kind = match entry.kind {
SymbolKind::Function => "function",
SymbolKind::Data => "data",
SymbolKind::Other => "object",
};
let _ = write!(out, "`{package}::{}` · {kind}", entry.name);
if let Some(help) = &entry.help {
if let Some(title) = &help.title {
let _ = write!(out, "\n\n**{title}**");
}
if let Some(description) = &help.description {
let _ = write!(out, "\n\n{description}");
}
if !help.arguments.is_empty() {
out.push_str("\n\n**Arguments**\n");
for arg in &help.arguments {
let _ = write!(out, "\n- `{}` — {}", arg.name, arg.description);
}
}
}
out
}
pub(crate) fn signature_of(entry: &SymbolEntry) -> Option<String> {
let usage = entry.help.as_ref().and_then(|h| h.usage.as_deref());
usage.map(str::to_string).or_else(|| {
entry.formals.as_ref().map(|formals| {
let args = formals
.iter()
.map(format_formal)
.collect::<Vec<_>>()
.join(", ");
format!("{}({})", entry.name, args)
})
})
}
pub(crate) fn format_formal(formal: &Formal) -> String {
match &formal.default {
Some(default) => format!("{} = {}", formal.name, default),
None => formal.name.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hover_resolves_bare_name_via_attached_package() {
let provider = documented_dplyr();
let src = "library(dplyr)\nacross(a, mean)\n";
let md = hover_markdown(src, "across(a", &provider).expect("hover for across");
assert!(md.contains("across(.cols, .fns)"), "signature: {md}");
assert!(md.contains("dplyr::across"), "origin: {md}");
assert!(
md.contains("Apply a function across columns"),
"title: {md}"
);
assert!(md.contains("`.cols`"), "arguments: {md}");
}
#[test]
fn hover_resolves_base_r_bare_name() {
use crate::rindex::schema::{HelpDoc, PackageIndex, SCHEMA_VERSION};
let idx = PackageIndex {
schema_version: SCHEMA_VERSION,
package: "base".into(),
version: "4.5.3".into(),
lib_path: "/lib".into(),
r_version: None,
harvested_at: 0,
symbols: vec![SymbolEntry {
name: "as.matrix".into(),
kind: SymbolKind::Function,
exported: true,
formals: None,
help: Some(HelpDoc {
title: Some("Matrices".into()),
description: None,
usage: Some("as.matrix(x, ...)".into()),
arguments: vec![],
}),
}],
};
let provider = IndexedProvider::from_indices([idx]);
let src = "x <- cbind(1:5, 6:10)\nas.matrix(x)\n";
let md = hover_markdown(src, "as.matrix(x)", &provider).expect("hover for as.matrix");
assert!(md.contains("as.matrix(x, ...)"), "signature: {md}");
assert!(md.contains("base::as.matrix"), "origin: {md}");
assert!(md.contains("Matrices"), "title: {md}");
}
#[test]
fn hover_resolves_namespaced_without_library() {
let provider = documented_dplyr();
let src = "dplyr::across(a)\n";
let md = hover_markdown(src, "across", &provider).expect("hover for dplyr::across");
assert!(md.contains("dplyr::across"));
}
#[test]
fn hover_none_for_unknown_and_non_name() {
let provider = documented_dplyr();
assert!(compute_hover("bogus()\n", 1, &provider).is_none());
let src = "across (a)\n";
assert!(compute_hover(src, offset_of(src, " (a"), &provider).is_none());
}
#[test]
fn hover_via_db_matches_compute() {
use crate::incremental::IncrementalDatabase;
let path = test_path();
let src = "library(dplyr)\nacross(a, mean)\n";
let position = pos(1, 0);
let mut db = IncrementalDatabase::default();
db.set_library_index(documented_dplyr());
db.upsert_file(path, src.to_string());
let hover =
hover_via_db(&db.snapshot(), path, src, position).expect("hover for across via db");
let md = match hover.contents {
HoverContents::Markup(m) => m.value,
other => panic!("expected markup, got {other:?}"),
};
assert!(md.contains("dplyr::across"), "origin: {md}");
let mut empty = IncrementalDatabase::default();
empty.set_library_index(documented_dplyr());
assert!(
hover_via_db(&empty.snapshot(), path, src, position).is_some(),
"fallback hover should resolve too"
);
}
}