use std::panic::AssertUnwindSafe;
use std::path::Path;
use lsp_types::{DocumentLink, Range};
use rowan::ast::AstNode;
use crate::ast::CallExpr;
use crate::incremental::{Analysis, normalize_path};
use crate::parser::parse;
use crate::project::{include_literal, resolve_target};
use crate::syntax::SyntaxNode;
use crate::text::{LineIndex, PositionEncoding};
use super::uri;
pub fn compute_document_links(
text: &str,
base_dir: Option<&Path>,
encoding: PositionEncoding,
) -> Vec<DocumentLink> {
let root = parse(text).cst;
links_for_tree(&root, text, base_dir, encoding)
}
pub(crate) fn document_links_via_db(
snapshot: &Analysis,
path: &Path,
text: &str,
encoding: PositionEncoding,
) -> Vec<DocumentLink> {
let base_dir = path.parent().filter(|dir| !dir.as_os_str().is_empty());
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(links_for_tree(&root, text, base_dir, encoding))
}));
match cached {
Ok(Some(links)) => links,
Ok(None) | Err(_) => compute_document_links(text, base_dir, encoding),
}
}
fn links_for_tree(
root: &SyntaxNode,
text: &str,
base_dir: Option<&Path>,
encoding: PositionEncoding,
) -> Vec<DocumentLink> {
let line_index = LineIndex::new(text);
root.descendants()
.filter_map(CallExpr::cast)
.filter_map(|call| {
let literal = include_literal(&call)?;
let raw: String = literal
.content_tokens()
.map(|token| token.text().to_string())
.collect();
let target = resolve_target(&raw, base_dir)?;
let target = uri::from_path(&normalize_path(&target))?;
let first = literal.content_tokens().next()?;
let last = literal.content_tokens().last()?;
let range = Range::new(
line_index.byte_to_position(first.text_range().start().into(), encoding),
line_index.byte_to_position(last.text_range().end().into(), encoding),
);
Some(DocumentLink {
range,
target: Some(target),
tooltip: None,
data: None,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::incremental::IncrementalDatabase;
use lsp_types::Position;
fn links(text: &str, base_dir: Option<&str>) -> Vec<DocumentLink> {
compute_document_links(text, base_dir.map(Path::new), PositionEncoding::Utf16)
}
fn target(link: &DocumentLink) -> &str {
link.target.as_ref().expect("link target").as_str()
}
fn abs(path: &str) -> String {
if cfg!(windows) {
format!("C:{path}")
} else {
path.to_string()
}
}
fn file_uri(path: &str) -> String {
if cfg!(windows) {
format!("file:///C:{path}")
} else {
format!("file://{path}")
}
}
#[test]
fn static_include_links_and_dynamic_forms_do_not() {
let text = concat!(
"include(\"sub/a.jl\")\n", "include(path)\n", "include(\"b$(dir).jl\")\n", "include(raw\"c.jl\")\n", "include(mapexpr, \"d.jl\")\n", "M.include(\"e.jl\")\n", );
let links = links(text, Some(&abs("/work")));
assert_eq!(links.len(), 1, "only the static include links");
assert_eq!(target(&links[0]), file_uri("/work/sub/a.jl"));
assert_eq!(
links[0].range,
Range::new(Position::new(0, 9), Position::new(0, 17)),
);
}
#[test]
fn relative_paths_normalize_and_absolute_paths_ignore_base() {
let text = format!(
"include(\"../lib/b.jl\")\ninclude(\"{}\")\n",
abs("/abs/c.jl")
);
let links = links(&text, Some(&abs("/work/src")));
let targets: Vec<_> = links.iter().map(target).collect();
assert_eq!(targets, [file_uri("/work/lib/b.jl"), file_uri("/abs/c.jl")]);
}
#[test]
fn relative_include_without_a_base_dir_has_no_link() {
let text = format!("include(\"a.jl\")\ninclude(\"{}\")\n", abs("/abs/c.jl"));
let links = links(&text, None);
let targets: Vec<_> = links.iter().map(target).collect();
assert_eq!(targets, [file_uri("/abs/c.jl")]);
}
#[test]
fn empty_path_has_no_link() {
assert_eq!(links("include(\"\")\n", Some("/work")), []);
}
#[test]
fn positions_count_units_of_the_negotiated_encoding() {
let text = "s = \"α\"; include(\"a.jl\")\n";
let utf16 = compute_document_links(text, Some(Path::new("/work")), PositionEncoding::Utf16);
let utf8 = compute_document_links(text, Some(Path::new("/work")), PositionEncoding::Utf8);
assert_eq!(
utf16[0].range,
Range::new(Position::new(0, 18), Position::new(0, 22)),
);
assert_eq!(
utf8[0].range,
Range::new(Position::new(0, 19), Position::new(0, 23)),
);
}
#[test]
fn links_via_db_match_compute_and_fall_back() {
let path = Path::new("/work/a.jl");
let buffer = "include(\"sub/b.jl\")\n";
let expected =
compute_document_links(buffer, Some(Path::new("/work")), PositionEncoding::Utf16);
assert_eq!(expected.len(), 1, "fixture must yield a link");
let mut db = IncrementalDatabase::default();
db.upsert_file(path, buffer.to_string());
assert_eq!(
document_links_via_db(&db.snapshot(), path, buffer, PositionEncoding::Utf16),
expected,
"cached-tree links must match the re-parse path"
);
let mut stale = IncrementalDatabase::default();
stale.upsert_file(path, "y = 1\n".to_string());
assert_eq!(
document_links_via_db(&stale.snapshot(), path, buffer, PositionEncoding::Utf16),
expected,
"version skew must fall back to the buffer text"
);
let empty = IncrementalDatabase::default();
assert_eq!(
document_links_via_db(&empty.snapshot(), path, buffer, PositionEncoding::Utf16),
expected,
"untracked path must fall back to the buffer text"
);
}
}