use std::panic::AssertUnwindSafe;
use std::path::Path;
use std::sync::Arc;
use lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position, Range};
use rowan::{TextRange, TextSize};
use crate::incremental::Analysis;
use crate::index::{FunctionGroup, ModuleIndex, PackageIndex};
use crate::parser::parse;
use crate::resolve::{
ModulePath, Namespace, PackageSource, Resolution, Resolver, module_at, resolve_submodule,
};
use crate::semantic::{BindingId, BindingKind, LoadKind, SemanticModel};
use crate::text::{LineIndex, PositionEncoding};
use super::render::{binding_detail, render_method, render_param, type_detail};
const MAX_METHODS: usize = 10;
pub fn compute_hover<P: PackageSource>(
text: &str,
position: Position,
encoding: PositionEncoding,
packages: &P,
) -> Option<Hover> {
let model = SemanticModel::build(&parse(text).cst);
let line_index = LineIndex::new(text);
let offset = TextSize::new(line_index.position_to_byte(position, encoding) as u32);
hover_for(&model, packages, None, text, offset, &line_index, encoding)
}
pub(crate) fn hover_via_db(
snapshot: &Analysis,
path: &Path,
text: &str,
position: Position,
encoding: PositionEncoding,
) -> Option<Hover> {
let line_index = LineIndex::new(text);
let offset = TextSize::new(line_index.position_to_byte(position, encoding) as u32);
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
let model = snapshot.semantic_model(file);
let workspace = snapshot.workspace_member(path);
Some(hover_for(
model,
snapshot,
workspace,
text,
offset,
&line_index,
encoding,
))
}));
match cached {
Ok(Some(hover)) => hover,
Ok(None) | Err(_) => compute_hover(text, position, encoding, snapshot),
}
}
#[allow(clippy::too_many_arguments)]
fn hover_for<P: PackageSource>(
model: &SemanticModel,
packages: &P,
workspace: Option<(Arc<PackageIndex>, ModulePath)>,
text: &str,
offset: TextSize,
line_index: &LineIndex,
encoding: PositionEncoding,
) -> Option<Hover> {
let (value, range) = hover_content(model, packages, workspace, text, offset)?;
Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value,
}),
range: Some(to_range(range, line_index, encoding)),
})
}
fn hover_content<P: PackageSource>(
model: &SemanticModel,
packages: &P,
workspace: Option<(Arc<PackageIndex>, ModulePath)>,
text: &str,
offset: TextSize,
) -> Option<(String, TextRange)> {
if let Some(q) = model
.qualified_reads()
.iter()
.find(|q| q.range.contains_inclusive(offset))
{
let (name, module_path) = q.path.split_last()?;
let head = module_path.first()?;
let pkg = packages.package(head)?;
let rest: Vec<&str> = module_path[1..].iter().map(|s| s.as_str()).collect();
let module = resolve_submodule(&pkg.root, &rest)?;
return Some((render_library_symbol(module, name)?, q.range));
}
if let Some(ident) = model.ident_at(offset) {
if let Some(bid) = ident.binding {
return Some((render_local(model, bid, text), ident.range));
}
let ns = if ident.is_macro {
Namespace::Macro
} else {
Namespace::Value
};
return Some((
render_free_read(model, packages, workspace, text, &ident.name, offset, ns)?,
ident.range,
));
}
if let Some(bid) = model.binding_at(offset) {
let range = model.binding(bid).def_range;
return Some((render_local(model, bid, text), range));
}
None
}
fn render_free_read<P: PackageSource>(
model: &SemanticModel,
packages: &P,
workspace: Option<(Arc<PackageIndex>, ModulePath)>,
text: &str,
name: &str,
offset: TextSize,
ns: Namespace,
) -> Option<String> {
match Resolver::new(model, packages)
.with_workspace(workspace.clone())
.resolve(name, offset, ns)
{
Resolution::Binding(bid) => Some(render_local(model, bid, text)),
Resolution::Workspace { module, name } => {
let pkg = &workspace.as_ref()?.0;
render_library_symbol(module_at(&pkg.root, &module)?, &name)
}
Resolution::System { module, name } => {
let pkg = packages.package(&module)?;
render_library_symbol(&pkg.root, &name)
}
Resolution::Using { module, name } => library_from_using(model, packages, &module, &name),
Resolution::Unresolved => None,
}
}
fn library_from_using<P: PackageSource>(
model: &SemanticModel,
packages: &P,
module: &str,
name: &str,
) -> Option<String> {
if let Some(pkg) = packages.package(module)
&& let Some(rendered) = render_library_symbol(&pkg.root, name)
{
return Some(rendered);
}
for load in model.module_loads() {
if load.kind != LoadKind::Using || load.items.is_some() {
continue;
}
let comps = &load.path.components;
if comps.last().map(|c| c.as_str()) != Some(module) {
continue;
}
let Some(first) = comps.first() else { continue };
let Some(pkg) = packages.package(first.as_str()) else {
continue;
};
let rest: Vec<&str> = comps[1..].iter().map(|c| c.as_str()).collect();
if let Some(m) = resolve_submodule(&pkg.root, &rest)
&& let Some(rendered) = render_library_symbol(m, name)
{
return Some(rendered);
}
}
None
}
fn render_library_symbol(module: &ModuleIndex, name: &str) -> Option<String> {
if name.starts_with('@') {
let m = module.macros.iter().find(|m| m.name == name)?;
let mut head = m.name.clone();
if !m.params.is_empty() {
let ps: Vec<String> = m.params.iter().map(render_param).collect();
head.push('(');
head.push_str(&ps.join(", "));
head.push(')');
}
return Some(markdown(&head, m.doc.as_ref().map(|d| d.text.as_str())));
}
if let Some(f) = module.functions.iter().find(|f| f.name == name) {
return Some(markdown(
&method_group(f),
f.doc.as_ref().map(|d| d.text.as_str()),
));
}
if let Some(t) = module.types.iter().find(|t| t.name == name) {
return Some(markdown(
&type_detail(t),
t.doc.as_ref().map(|d| d.text.as_str()),
));
}
if let Some(c) = module.consts.iter().find(|c| c.name == name) {
let head = match &c.value_repr {
Some(repr) => format!("{name} = {repr}"),
None => name.to_string(),
};
return Some(markdown(&head, c.doc.as_ref().map(|d| d.text.as_str())));
}
None
}
fn method_group(group: &FunctionGroup) -> String {
if group.methods.is_empty() {
return group.name.clone();
}
let mut lines: Vec<String> = group
.methods
.iter()
.take(MAX_METHODS)
.map(|m| format!("{}{}", group.name, render_method(m)))
.collect();
let extra = group.methods.len().saturating_sub(MAX_METHODS);
if extra > 0 {
let plural = if extra == 1 { "" } else { "s" };
lines.push(format!("# + {extra} more method{plural}"));
}
lines.join("\n")
}
fn render_local(model: &SemanticModel, bid: BindingId, text: &str) -> String {
let binding = model.binding(bid);
let code = match binding.kind {
BindingKind::Function | BindingKind::Type | BindingKind::Macro => {
definition_line(text, binding.def_range.start().into())
.unwrap_or_else(|| binding.name.to_string())
}
_ => binding.name.to_string(),
};
format!(
"```julia\n{code}\n```\n\n*{}*",
binding_detail(binding.kind)
)
}
fn definition_line(text: &str, offset: usize) -> Option<String> {
let offset = offset.min(text.len());
let start = text[..offset].rfind('\n').map(|i| i + 1).unwrap_or(0);
let end = text[offset..]
.find('\n')
.map(|i| offset + i)
.unwrap_or(text.len());
let line = text[start..end].trim();
(!line.is_empty()).then(|| line.to_string())
}
fn markdown(code: &str, doc: Option<&str>) -> String {
let mut out = format!("```julia\n{code}\n```");
if let Some(doc) = doc {
let doc = doc.trim();
if !doc.is_empty() {
out.push_str("\n\n---\n\n");
out.push_str(doc);
}
}
out
}
fn to_range(range: TextRange, line_index: &LineIndex, encoding: PositionEncoding) -> Range {
Range {
start: line_index.byte_to_position(range.start().into(), encoding),
end: line_index.byte_to_position(range.end().into(), encoding),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::incremental::IncrementalDatabase;
use crate::index::model::{DefLocation, ExportedName, PackageIndex, Span, Visibility};
use crate::index::{
ConstDef, Docstring, FunctionGroup, MacroDef, Method, Param, TypeDef, TypeExpr, TypeKind,
};
fn loc() -> DefLocation {
DefLocation {
file: "src/x.jl".into(),
range: Span { start: 0, end: 0 },
}
}
fn doc(text: &str) -> Option<Docstring> {
Some(Docstring {
text: text.to_string(),
loc: loc(),
})
}
fn module(name: &str, exports: &[&str]) -> ModuleIndex {
ModuleIndex {
name: name.to_string(),
bare: false,
loc: loc(),
exports: exports
.iter()
.map(|n| ExportedName {
name: n.to_string(),
visibility: Visibility::Exported,
loc: loc(),
})
.collect(),
functions: Vec::new(),
types: Vec::new(),
consts: Vec::new(),
macros: Vec::new(),
submodules: Vec::new(),
}
}
fn package(root: ModuleIndex) -> Arc<PackageIndex> {
Arc::new(PackageIndex {
name: root.name.clone(),
root,
members: Vec::new(),
member_modules: Default::default(),
diagnostics: Vec::new(),
})
}
fn library(pkgs: Vec<Arc<PackageIndex>>) -> BTreeMap<String, Arc<PackageIndex>> {
pkgs.into_iter().map(|p| (p.name.clone(), p)).collect()
}
fn method(params: &[&str]) -> Method {
Method {
params: params
.iter()
.map(|p| Param {
name: Some(p.to_string()),
type_annotation: None,
default: None,
is_vararg: false,
})
.collect(),
keyword_params: Vec::new(),
where_clauses: Vec::new(),
return_type: None,
has_body: true,
doc: None,
loc: loc(),
}
}
fn hover_at(
src: &str,
needle: &str,
lib: &BTreeMap<String, Arc<PackageIndex>>,
) -> Option<String> {
let offset = src.find(needle).unwrap() + needle.len();
let line_index = LineIndex::new(src);
let position = line_index.byte_to_position(offset, PositionEncoding::Utf16);
compute_hover(src, position, PositionEncoding::Utf16, lib).map(|h| match h.contents {
HoverContents::Markup(m) => m.value,
_ => panic!("expected markup hover"),
})
}
fn hover_ws(src: &str, needle: &str, workspace: Arc<PackageIndex>) -> Option<String> {
let model = SemanticModel::build(&parse(src).cst);
let offset = TextSize::new((src.find(needle).unwrap() + needle.len()) as u32);
let lib: BTreeMap<String, Arc<PackageIndex>> = BTreeMap::new();
hover_content(&model, &lib, Some((workspace, Vec::new())), src, offset)
.map(|(value, _)| value)
}
#[test]
fn workspace_sibling_shows_its_signature() {
let root = ModuleIndex {
functions: vec![FunctionGroup {
name: "sibling".to_string(),
owner: None,
methods: vec![method(&["x"])],
doc: doc("a sibling function"),
}],
..module("MyPkg", &[])
};
let value = hover_ws("sibling(1)", "sibling", package(root)).unwrap();
assert!(value.contains("sibling"), "{value}");
assert!(value.contains("a sibling function"), "{value}");
}
#[test]
fn local_parameter_shows_kind() {
let lib = library(vec![]);
let value = hover_at("function f(abc)\n abc\nend", " ab", &lib).unwrap();
assert!(value.contains("abc"), "{value}");
assert!(value.contains("*parameter*"), "{value}");
}
#[test]
fn local_function_shows_signature() {
let lib = library(vec![]);
let value = hover_at(
"function greet(a, b)\n a + b\nend\ngreet(1, 2)",
"gre",
&lib,
)
.unwrap();
assert!(value.contains("function greet(a, b)"), "{value}");
assert!(value.contains("*function*"), "{value}");
}
#[test]
fn library_function_shows_method_group_and_docstring() {
let mut base = module("Base", &["map"]);
base.functions.push(FunctionGroup {
name: "map".into(),
owner: None,
methods: vec![method(&["f", "iter"]), method(&["f", "A"])],
doc: doc("Transform collection `iter` by applying `f`."),
});
let lib = library(vec![package(base)]);
let value = hover_at("map(sin, xs)", "ma", &lib).unwrap();
assert!(value.contains("map(f, iter)"), "{value}");
assert!(value.contains("map(f, A)"), "{value}");
assert!(value.contains("Transform collection"), "{value}");
}
#[test]
fn method_group_is_capped_with_a_count() {
let mut base = module("Base", &["f"]);
base.functions.push(FunctionGroup {
name: "f".into(),
owner: None,
methods: (0..15).map(|_| method(&["x"])).collect(),
doc: None,
});
let lib = library(vec![package(base)]);
let value = hover_at("f(1)", "f", &lib).unwrap();
assert_eq!(value.matches("f(x)").count(), MAX_METHODS);
assert!(value.contains("# + 5 more methods"), "{value}");
}
#[test]
fn library_type_shows_definition() {
let mut base = module("Base", &["Dict"]);
base.types.push(TypeDef {
name: "Dict".into(),
kind: TypeKind::Struct { mutable: true },
type_params: Vec::new(),
supertype: Some(TypeExpr::Name {
path: vec!["AbstractDict".into()],
}),
fields: Vec::new(),
doc: doc("A hash table."),
loc: loc(),
});
let lib = library(vec![package(base)]);
let value = hover_at("Dict()", "Di", &lib).unwrap();
assert!(
value.contains("mutable struct Dict <: AbstractDict"),
"{value}"
);
assert!(value.contains("A hash table."), "{value}");
}
#[test]
fn library_macro_shows_at_name() {
let mut base = module("Base", &["@time"]);
base.macros.push(MacroDef {
name: "@time".into(),
params: vec![Param {
name: Some("expr".into()),
type_annotation: None,
default: None,
is_vararg: false,
}],
doc: doc("Time an expression."),
loc: loc(),
});
let lib = library(vec![package(base)]);
let value = hover_at("@time f()", "@ti", &lib).unwrap();
assert!(value.contains("@time(expr)"), "{value}");
assert!(value.contains("Time an expression."), "{value}");
}
#[test]
fn qualified_read_resolves_member() {
let mut root = module("LinearAlgebra", &[]);
root.functions.push(FunctionGroup {
name: "norm".into(),
owner: None,
methods: vec![method(&["x"])],
doc: doc("The norm."),
});
let lib = library(vec![package(root)]);
let value = hover_at("LinearAlgebra.norm(v)", "LinearAlgebra.no", &lib).unwrap();
assert!(value.contains("norm(x)"), "{value}");
assert!(value.contains("The norm."), "{value}");
}
#[test]
fn const_shows_value() {
let mut base = module("Base", &["pi"]);
base.consts.push(ConstDef {
name: "pi".into(),
value_repr: Some("3.14159".into()),
doc: doc("The constant pi."),
loc: loc(),
});
let lib = library(vec![package(base)]);
let value = hover_at("pi", "p", &lib).unwrap();
assert!(value.contains("pi = 3.14159"), "{value}");
}
#[test]
fn unresolved_name_has_no_hover() {
let lib = library(vec![]);
assert!(hover_at("unknown_symbol", "unk", &lib).is_none());
}
#[test]
fn hover_via_db_matches_compute_and_falls_back() {
let path = Path::new("/work/a.jl");
let mut base = module("Base", &["map"]);
base.functions.push(FunctionGroup {
name: "map".into(),
owner: None,
methods: vec![method(&["f", "iter"])],
doc: doc("Map."),
});
let lib = library(vec![package(base)]);
let buffer = "map(sin, xs)\n";
let position = {
let li = LineIndex::new(buffer);
li.byte_to_position(1, PositionEncoding::Utf8)
};
let expected = compute_hover(buffer, position, PositionEncoding::Utf8, &lib);
assert!(expected.is_some());
let mut db = IncrementalDatabase::default();
db.set_library_packages(lib.clone());
db.upsert_file(path, buffer.to_string());
assert_eq!(
hover_via_db(
&db.snapshot(),
path,
buffer,
position,
PositionEncoding::Utf8
),
expected
);
let other = "xs\n";
assert_eq!(
hover_via_db(
&db.snapshot(),
path,
other,
position,
PositionEncoding::Utf8
),
compute_hover(other, position, PositionEncoding::Utf8, &db.snapshot())
);
}
}