use std::panic::AssertUnwindSafe;
use std::path::{Path, PathBuf};
use lsp_types::{
DocumentLink, Hover, HoverContents, InlayHint, InlayHintLabel, InlayHintTooltip, Location,
MarkupContent, MarkupKind, Position, Range,
};
use rowan::TextRange;
use crate::environment::{PackageKind, PackageMeta, project_file_in, resolve_dev_path};
use crate::incremental::{Analysis, normalize_path};
use crate::index::Span;
use crate::project_files::{dep_at, dep_entries, manifest_paths};
use crate::resolve::PackageSource;
use crate::text::{LineIndex, PositionEncoding, TextBuffer};
use super::definition::site_locations;
use super::uri;
pub(crate) trait ProjectLibrary: PackageSource {
fn package_meta(&self, name: &str) -> Option<PackageMeta>;
}
impl ProjectLibrary for Analysis {
fn package_meta(&self, name: &str) -> Option<PackageMeta> {
Analysis::package_meta(self, name)
}
}
fn package_entry<P: PackageSource>(packages: &P, name: &str) -> Option<(PathBuf, Span)> {
let root = packages.package_root(name)?;
let package = packages.package(name)?;
let entry = normalize_path(&root.join(&package.root.loc.file));
Some((entry, package.root.loc.range))
}
pub(crate) fn project_definition<P: PackageSource>(
text: &TextBuffer,
position: Position,
encoding: PositionEncoding,
packages: &P,
) -> Vec<Location> {
let offset = text.line_index().position_to_byte(position, encoding);
let Some(dep) = dep_at(text, offset) else {
return Vec::new();
};
let Some(site) = package_entry(packages, &dep.name) else {
return Vec::new();
};
site_locations(vec![site], encoding)
}
pub(crate) fn project_definition_via_db(
snapshot: &Analysis,
text: &TextBuffer,
position: Position,
encoding: PositionEncoding,
) -> Vec<Location> {
salsa::Cancelled::catch(AssertUnwindSafe(|| {
project_definition(text, position, encoding, snapshot)
}))
.unwrap_or_default()
}
pub(crate) fn project_hover<L: ProjectLibrary>(
text: &TextBuffer,
position: Position,
encoding: PositionEncoding,
library: &L,
) -> Option<Hover> {
let line_index = text.line_index();
let offset = line_index.position_to_byte(position, encoding);
let dep = dep_at(text, offset)?;
Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: dep_markdown(&dep.name, library)?,
}),
range: Some(to_range(dep.name_range, &line_index, encoding)),
})
}
pub(crate) fn project_hover_via_db(
snapshot: &Analysis,
text: &TextBuffer,
position: Position,
encoding: PositionEncoding,
) -> Option<Hover> {
salsa::Cancelled::catch(AssertUnwindSafe(|| {
project_hover(text, position, encoding, snapshot)
}))
.unwrap_or_default()
}
fn dep_markdown<L: ProjectLibrary>(name: &str, library: &L) -> Option<String> {
let meta = library.package_meta(name);
let root = library.package_root(name);
if meta.is_none() && root.is_none() {
return None;
}
let mut out = format!("**{name}**");
if let Some(version) = meta.as_ref().and_then(|meta| meta.version.as_deref()) {
out.push_str(" v");
out.push_str(version);
}
if let Some(meta) = &meta {
out.push_str("\n\n");
out.push_str(match meta.kind {
PackageKind::Registered => "Registered package",
PackageKind::Dev => "Development dependency",
PackageKind::Stdlib => "Standard library",
});
}
if let Some(root) = root {
out.push_str(&format!("\n\n`{}`", normalize_path(&root).display()));
}
Some(out)
}
pub(crate) fn project_document_links<P: PackageSource>(
text: &TextBuffer,
encoding: PositionEncoding,
packages: &P,
) -> Vec<DocumentLink> {
let line_index = text.line_index();
dep_entries(text)
.into_iter()
.filter_map(|dep| {
let (entry, _) = package_entry(packages, &dep.name)?;
Some(DocumentLink {
range: to_range(dep.name_range, &line_index, encoding),
target: Some(uri::from_path(&entry)?),
tooltip: None,
data: None,
})
})
.collect()
}
pub(crate) fn project_document_links_via_db(
snapshot: &Analysis,
text: &TextBuffer,
encoding: PositionEncoding,
) -> Vec<DocumentLink> {
salsa::Cancelled::catch(AssertUnwindSafe(|| {
project_document_links(text, encoding, snapshot)
}))
.unwrap_or_default()
}
pub(crate) fn manifest_document_links(
text: &TextBuffer,
path: &Path,
encoding: PositionEncoding,
) -> Vec<DocumentLink> {
let Some(base_dir) = uri::anchor_dir(path) else {
return Vec::new();
};
let line_index = text.line_index();
manifest_paths(text)
.into_iter()
.filter_map(|entry| {
if entry.path.is_empty() {
return None;
}
let root = normalize_path(&resolve_dev_path(base_dir, &entry.path));
let target = project_file_in(&root).unwrap_or(root);
Some(DocumentLink {
range: to_range(entry.range, &line_index, encoding),
target: Some(uri::from_path(&target)?),
tooltip: None,
data: None,
})
})
.collect()
}
pub(crate) fn project_inlay_hints<L: ProjectLibrary>(
text: &TextBuffer,
range: Range,
encoding: PositionEncoding,
library: &L,
) -> Vec<InlayHint> {
let line_index = text.line_index();
let start = line_index.position_to_byte(range.start, encoding);
let end = line_index.position_to_byte(range.end, encoding);
dep_entries(text)
.into_iter()
.filter_map(|dep| {
let at = usize::from(dep.uuid_range.end());
if at < start || at > end {
return None;
}
let version = library.package_meta(&dep.name)?.version?;
Some(InlayHint {
position: line_index.byte_to_position(at, encoding),
label: InlayHintLabel::String(format!("v{version}")),
kind: None,
text_edits: None,
tooltip: dep_markdown(&dep.name, library).map(|value| {
InlayHintTooltip::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value,
})
}),
padding_left: Some(true),
padding_right: None,
data: None,
})
})
.collect()
}
pub(crate) fn project_inlay_hints_via_db(
snapshot: &Analysis,
text: &TextBuffer,
range: Range,
encoding: PositionEncoding,
) -> Vec<InlayHint> {
salsa::Cancelled::catch(AssertUnwindSafe(|| {
project_inlay_hints(text, range, encoding, snapshot)
}))
.unwrap_or_default()
}
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 std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::index::{PackageIndex, harvest_package_named};
use crate::resolve::ModulePath;
use super::super::uri::to_path;
use super::*;
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new() -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("fatou-projnav-{}-{}", std::process::id(), n));
fs::create_dir_all(&path).unwrap();
Self { path }
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[derive(Default)]
struct TestLib {
packages: BTreeMap<String, Arc<PackageIndex>>,
roots: BTreeMap<String, PathBuf>,
deps: BTreeMap<String, PackageMeta>,
}
impl PackageSource for TestLib {
fn package(&self, name: &str) -> Option<Arc<PackageIndex>> {
self.packages.get(name).cloned()
}
fn package_root(&self, name: &str) -> Option<PathBuf> {
self.roots.get(name).cloned()
}
fn workspace_member(&self, _path: &Path) -> Option<(Arc<PackageIndex>, ModulePath)> {
None
}
}
impl ProjectLibrary for TestLib {
fn package_meta(&self, name: &str) -> Option<PackageMeta> {
self.deps.get(name).cloned()
}
}
fn meta(version: Option<&str>, kind: PackageKind) -> PackageMeta {
PackageMeta {
version: version.map(str::to_string),
kind,
}
}
fn greetings() -> (TempDir, TestLib, PathBuf) {
let tmp = TempDir::new();
let entry = tmp.path.join("src").join("Greetings.jl");
fs::create_dir_all(entry.parent().unwrap()).unwrap();
fs::write(&entry, "module Greetings\ngreet(name) = name\nend\n").unwrap();
let mut lib = TestLib::default();
lib.packages.insert(
"Greetings".to_string(),
Arc::new(harvest_package_named(&tmp.path, "Greetings")),
);
lib.roots.insert("Greetings".to_string(), tmp.path.clone());
(tmp, lib, entry)
}
const PROJECT: &str = "\
name = \"Demo\"
[deps]
Greetings = \"1520ce14-60c1-5f80-bbc7-55ef81b5835c\"
";
fn def_at(marked: &str, lib: &impl PackageSource) -> Vec<Location> {
let offset = marked.find('|').expect("a cursor marker");
let text = TextBuffer::new(marked.replacen('|', "", 1));
let position = text
.line_index()
.byte_to_position(offset, PositionEncoding::Utf16);
project_definition(&text, position, PositionEncoding::Utf16, lib)
}
#[test]
fn a_dependency_name_jumps_to_its_entry_file() {
let (_tmp, lib, entry) = greetings();
let marked = PROJECT.replace("Greetings =", "Greet|ings =");
let locations = def_at(&marked, &lib);
let [location] = &locations[..] else {
panic!("expected exactly one location, got {locations:?}");
};
assert_eq!(to_path(&location.uri), Some(entry));
assert_eq!(location.range.start, Position::new(0, 7));
assert_eq!(location.range.end, Position::new(0, 16));
}
#[test]
fn nothing_else_in_the_file_jumps() {
let (_tmp, lib, _entry) = greetings();
for marked in [
PROJECT.replace("name =", "na|me ="),
PROJECT.replace("[deps]", "[de|ps]"),
PROJECT.replace("\"1520ce14", "\"1520|ce14"),
] {
assert!(def_at(&marked, &lib).is_empty(), "for {marked:?}");
}
}
#[test]
fn an_unharvested_dependency_has_no_definition() {
let marked = PROJECT.replace("Greetings =", "Greet|ings =");
assert!(def_at(&marked, &TestLib::default()).is_empty());
}
#[test]
fn a_broken_project_file_has_no_definition() {
let (_tmp, lib, _entry) = greetings();
let marked = "[deps\nGreet|ings = \"1520ce14\"\n";
assert!(def_at(marked, &lib).is_empty());
}
fn hover_at(marked: &str, lib: &TestLib) -> Option<String> {
let offset = marked.find('|').expect("a cursor marker");
let text = TextBuffer::new(marked.replacen('|', "", 1));
let position = text
.line_index()
.byte_to_position(offset, PositionEncoding::Utf16);
let hover = project_hover(&text, position, PositionEncoding::Utf16, lib)?;
assert_eq!(
hover.range,
Some(Range::new(Position::new(3, 0), Position::new(3, 9))),
);
match hover.contents {
HoverContents::Markup(markup) => Some(markup.value),
other => panic!("expected markdown, got {other:?}"),
}
}
#[test]
fn a_dependency_hover_reports_version_kind_and_path() {
let (tmp, mut lib, _entry) = greetings();
lib.deps.insert(
"Greetings".to_string(),
meta(Some("0.4.5"), PackageKind::Registered),
);
let marked = PROJECT.replace("Greetings =", "Greet|ings =");
assert_eq!(
hover_at(&marked, &lib),
Some(format!(
"**Greetings** v0.4.5\n\nRegistered package\n\n`{}`",
tmp.path.display()
)),
);
}
#[test]
fn an_unknown_version_or_path_is_simply_omitted() {
let marked = PROJECT.replace("Greetings =", "Greet|ings =");
let mut lib = TestLib::default();
lib.deps
.insert("Greetings".to_string(), meta(None, PackageKind::Stdlib));
assert_eq!(
hover_at(&marked, &lib),
Some("**Greetings**\n\nStandard library".to_string()),
);
lib.deps
.insert("Greetings".to_string(), meta(None, PackageKind::Dev));
assert_eq!(
hover_at(&marked, &lib),
Some("**Greetings**\n\nDevelopment dependency".to_string()),
);
}
#[test]
fn a_dependency_nothing_is_known_about_has_no_hover() {
let marked = PROJECT.replace("Greetings =", "Greet|ings =");
assert_eq!(hover_at(&marked, &TestLib::default()), None);
}
#[test]
fn every_located_dependency_becomes_a_link() {
let (_tmp, lib, entry) = greetings();
let text = TextBuffer::new(format!(
"{PROJECT}\n[extras]\nGreetings = \"1520ce14-60c1-5f80-bbc7-55ef81b5835c\"\n"
));
let links = project_document_links(&text, PositionEncoding::Utf16, &lib);
let target = uri::from_path(&entry).unwrap();
assert_eq!(
links
.iter()
.map(|link| (link.range, link.target.clone()))
.collect::<Vec<_>>(),
vec![
(
Range::new(Position::new(3, 0), Position::new(3, 9)),
Some(target.clone())
),
(
Range::new(Position::new(6, 0), Position::new(6, 9)),
Some(target)
),
],
);
}
#[test]
fn an_unlocated_dependency_has_no_link() {
let text = TextBuffer::new(PROJECT.to_string());
assert!(
project_document_links(&text, PositionEncoding::Utf16, &TestLib::default()).is_empty()
);
}
#[test]
fn a_broken_project_file_has_no_links() {
let (_tmp, lib, _entry) = greetings();
let text = TextBuffer::new("[deps\nGreetings = \"1520ce14\"\n".to_string());
assert!(project_document_links(&text, PositionEncoding::Utf16, &lib).is_empty());
}
fn manifest(dir: &Path) -> String {
format!(
"manifest_format = \"2.0\"\n\n\
[[deps.Greetings]]\npath = \"Greetings\"\n\n\
[[deps.Bare]]\npath = \"{}\"\n\n\
[[deps.AbstractTrees]]\ngit-tree-sha1 = \"deadbeef\"\n",
dir.join("Bare").display().to_string().replace('\\', "/"),
)
}
fn manifest_links(text: &str, path: &Path) -> Vec<(Range, String)> {
manifest_document_links(
&TextBuffer::new(text.to_string()),
path,
PositionEncoding::Utf16,
)
.into_iter()
.map(|link| (link.range, link.target.expect("a link target").to_string()))
.collect()
}
#[test]
fn every_manifest_path_links_to_the_package_it_pins() {
let tmp = TempDir::new();
fs::create_dir_all(tmp.path.join("Greetings")).unwrap();
fs::write(
tmp.path.join("Greetings/JuliaProject.toml"),
"name = \"Greetings\"\n",
)
.unwrap();
fs::create_dir_all(tmp.path.join("Bare")).unwrap();
let text = manifest(&tmp.path);
let links = manifest_links(&text, &tmp.path.join("Manifest.toml"));
assert_eq!(
links,
vec![
(
Range::new(Position::new(3, 8), Position::new(3, 17)),
uri::from_path(&tmp.path.join("Greetings/JuliaProject.toml"))
.unwrap()
.to_string(),
),
(
Range::new(
Position::new(6, 8),
Position::new(
6,
u32::try_from(tmp.path.join("Bare").display().to_string().len() + 8)
.unwrap(),
),
),
uri::from_path(&tmp.path.join("Bare")).unwrap().to_string(),
),
],
);
}
#[test]
fn a_relative_manifest_path_normalizes_against_the_manifest() {
let tmp = TempDir::new();
let nested = tmp.path.join("MyPkg");
fs::create_dir_all(&nested).unwrap();
let text = "[[deps.Greetings]]\npath = \"../Greetings\"\n";
let links = manifest_links(text, &nested.join("Manifest.toml"));
assert_eq!(
links,
vec![(
Range::new(Position::new(1, 8), Position::new(1, 20)),
uri::from_path(&tmp.path.join("Greetings"))
.unwrap()
.to_string(),
)],
"the link names `<tmp>/Greetings`, not `<tmp>/MyPkg/../Greetings`"
);
}
#[test]
fn a_manifest_with_nothing_to_anchor_to_has_no_links() {
let untitled = <lsp_types::Uri as std::str::FromStr>::from_str("untitled:Untitled-1")
.expect("a valid uri");
let text = "[[deps.Greetings]]\npath = \"../Greetings\"\n";
assert!(manifest_links(text, &uri::to_path_or_synthetic(&untitled)).is_empty());
let tmp = TempDir::new();
let empty = "[[deps.Greetings]]\npath = \"\"\n";
assert!(manifest_links(empty, &tmp.path.join("Manifest.toml")).is_empty());
}
#[test]
fn a_broken_manifest_has_no_links() {
let tmp = TempDir::new();
let text = "[[deps.Greetings\npath = \"../Greetings\"\n";
assert!(manifest_links(text, &tmp.path.join("Manifest.toml")).is_empty());
}
const WHOLE_FILE: Range = Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: u32::MAX,
character: 0,
},
};
fn label(hint: &InlayHint) -> &str {
match &hint.label {
InlayHintLabel::String(text) => text,
other => panic!("expected a plain string label, got {other:?}"),
}
}
fn two_deps() -> (TextBuffer, TestLib) {
let text = TextBuffer::new(format!(
"{PROJECT}Silent = \"682c06a0-de6a-54ab-a142-c8b1cf79cde6\"\n"
));
let mut lib = TestLib::default();
lib.deps.insert(
"Greetings".to_string(),
meta(Some("0.4.5"), PackageKind::Registered),
);
lib.deps
.insert("Silent".to_string(), meta(None, PackageKind::Registered));
(text, lib)
}
#[test]
fn each_resolved_dependency_shows_its_version() {
let (text, lib) = two_deps();
let hints = project_inlay_hints(&text, WHOLE_FILE, PositionEncoding::Utf16, &lib);
let [hint] = &hints[..] else {
panic!("expected exactly one hint, got {hints:?}");
};
assert_eq!(label(hint), "v0.4.5");
let deps_line = text.lines().nth(3).expect("the Greetings line");
assert_eq!(
hint.position,
Position::new(3, u32::try_from(deps_line.len()).unwrap()),
);
assert_eq!(hint.padding_left, Some(true));
assert!(matches!(
&hint.tooltip,
Some(InlayHintTooltip::MarkupContent(markup))
if markup.value.contains("Registered package"),
));
}
#[test]
fn hints_outside_the_viewport_are_dropped() {
let (text, mut lib) = two_deps();
lib.deps.insert(
"Silent".to_string(),
meta(Some("0.21.4"), PackageKind::Registered),
);
let viewport = Range::new(Position::new(4, 0), Position::new(5, 0));
let hints = project_inlay_hints(&text, viewport, PositionEncoding::Utf16, &lib);
assert_eq!(hints.iter().map(label).collect::<Vec<_>>(), vec!["v0.21.4"],);
}
#[test]
fn a_broken_project_file_has_no_hints() {
let (_tmp, mut lib, _entry) = greetings();
lib.deps.insert(
"Greetings".to_string(),
meta(Some("0.4.5"), PackageKind::Registered),
);
let text = TextBuffer::new("[deps\nGreetings = \"1520ce14\"\n".to_string());
assert!(project_inlay_hints(&text, WHOLE_FILE, PositionEncoding::Utf16, &lib).is_empty());
}
#[test]
fn nothing_else_in_the_file_hovers() {
let (_tmp, mut lib, _entry) = greetings();
lib.deps.insert(
"Greetings".to_string(),
meta(Some("0.4.5"), PackageKind::Registered),
);
for marked in [
PROJECT.replace("name =", "na|me ="),
PROJECT.replace("[deps]", "[de|ps]"),
PROJECT.replace("\"1520ce14", "\"1520|ce14"),
] {
let offset = marked.find('|').expect("a cursor marker");
let text = TextBuffer::new(marked.replacen('|', "", 1));
let position = text
.line_index()
.byte_to_position(offset, PositionEncoding::Utf16);
assert!(
project_hover(&text, position, PositionEncoding::Utf16, &lib).is_none(),
"for {marked:?}"
);
}
}
}