use std::ffi::OsStr;
use std::fmt;
use std::future::Future;
use std::mem::ManuallyDrop;
use std::ops::Range;
use std::path::Path;
use std::path::PathBuf;
use std::path::absolute;
use std::sync::Arc;
use std::thread::JoinHandle;
use anyhow::Context;
use anyhow::Error;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use ignore::WalkBuilder;
use indexmap::IndexSet;
use line_index::LineCol;
use line_index::LineIndex;
use line_index::WideEncoding;
use line_index::WideLineCol;
use lsp_types::CallHierarchyIncomingCall;
use lsp_types::CallHierarchyItem;
use lsp_types::CallHierarchyOutgoingCall;
use lsp_types::CodeLens;
use lsp_types::CompletionResponse;
use lsp_types::DocumentSymbolResponse;
use lsp_types::FoldingRange;
use lsp_types::GotoDefinitionResponse;
use lsp_types::Hover;
use lsp_types::InlayHint;
use lsp_types::Location;
use lsp_types::SemanticTokensResult;
use lsp_types::SignatureHelp;
use lsp_types::SymbolInformation;
use lsp_types::WorkspaceEdit;
use path_clean::PathClean;
use tokio::runtime::Handle;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use url::Url;
use crate::config::Config;
use crate::document::Document;
use crate::graph::DocumentGraphNode;
use crate::graph::ParseState;
use crate::queue::AddRequest;
use crate::queue::AnalysisQueue;
use crate::queue::AnalyzeRequest;
use crate::queue::CallHierarchyRequest;
use crate::queue::CodeLensRequest;
use crate::queue::CompletionRequest;
use crate::queue::DocumentSymbolRequest;
use crate::queue::FindAllReferencesRequest;
use crate::queue::FoldingRangeRequest;
use crate::queue::FormatRequest;
use crate::queue::GotoDefinitionRequest;
use crate::queue::HoverRequest;
use crate::queue::IncomingCallsRequest;
use crate::queue::InlayHintsRequest;
use crate::queue::NotifyChangeRequest;
use crate::queue::NotifyIncrementalChangeRequest;
use crate::queue::OutgoingCallsRequest;
use crate::queue::RemoveRequest;
use crate::queue::RenameRequest;
use crate::queue::Request;
use crate::queue::SemanticTokenRequest;
use crate::queue::SignatureHelpRequest;
use crate::queue::WorkspaceSymbolRequest;
use crate::rayon::RayonHandle;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProgressKind {
Parsing,
Analyzing,
}
impl fmt::Display for ProgressKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Parsing => write!(f, "parsing"),
Self::Analyzing => write!(f, "analyzing"),
}
}
}
pub fn path_to_uri(path: impl AsRef<Path>) -> Option<Url> {
Url::from_file_path(absolute(path).ok()?.clean()).ok()
}
#[derive(Debug, Clone)]
pub struct AnalysisResult {
error: Option<Arc<Error>>,
version: Option<i32>,
lines: Option<Arc<LineIndex>>,
document: Document,
}
impl AnalysisResult {
pub(crate) fn new(node: &DocumentGraphNode) -> Self {
if let Some(error) = node.analysis_error() {
return Self {
error: Some(error.clone()),
version: node.parse_state().version(),
lines: node.parse_state().lines().cloned(),
document: Document::default_from_uri(node.uri().clone()),
};
}
let (error, version, lines) = match node.parse_state() {
ParseState::NotParsed => unreachable!("document should have been parsed"),
ParseState::Error(e) => (Some(e), None, None),
ParseState::Parsed { version, lines, .. } => (None, *version, Some(lines)),
};
Self {
error: error.cloned(),
version,
lines: lines.cloned(),
document: node
.document()
.expect("analysis should have completed")
.clone(),
}
}
pub fn error(&self) -> Option<&Arc<Error>> {
self.error.as_ref()
}
pub fn version(&self) -> Option<i32> {
self.version
}
pub fn lines(&self) -> Option<&Arc<LineIndex>> {
self.lines.as_ref()
}
pub fn document(&self) -> &Document {
&self.document
}
}
#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone, Default)]
pub struct SourcePosition {
pub line: u32,
pub character: u32,
}
impl SourcePosition {
pub fn new(line: u32, character: u32) -> Self {
Self { line, character }
}
}
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
pub enum SourcePositionEncoding {
UTF8,
UTF16,
}
#[derive(Debug, Clone)]
pub struct SourceEdit {
range: Range<SourcePosition>,
encoding: SourcePositionEncoding,
text: String,
}
impl SourceEdit {
pub fn new(
range: Range<SourcePosition>,
encoding: SourcePositionEncoding,
text: impl Into<String>,
) -> Self {
Self {
range,
encoding,
text: text.into(),
}
}
pub(crate) fn range(&self) -> Range<SourcePosition> {
self.range.start..self.range.end
}
pub(crate) fn apply(&self, source: &mut String, lines: &LineIndex) -> Result<()> {
let (start, end) = match self.encoding {
SourcePositionEncoding::UTF8 => (
LineCol {
line: self.range.start.line,
col: self.range.start.character,
},
LineCol {
line: self.range.end.line,
col: self.range.end.character,
},
),
SourcePositionEncoding::UTF16 => (
lines
.to_utf8(
WideEncoding::Utf16,
WideLineCol {
line: self.range.start.line,
col: self.range.start.character,
},
)
.context("invalid edit start position")?,
lines
.to_utf8(
WideEncoding::Utf16,
WideLineCol {
line: self.range.end.line,
col: self.range.end.character,
},
)
.context("invalid edit end position")?,
),
};
let range: Range<usize> = lines
.offset(start)
.context("invalid edit start position")?
.into()
..lines
.offset(end)
.context("invalid edit end position")?
.into();
if !source.is_char_boundary(range.start) {
bail!("edit start position is not at a character boundary");
}
if !source.is_char_boundary(range.end) {
bail!("edit end position is not at a character boundary");
}
source.replace_range(range, &self.text);
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct IncrementalChange {
pub version: i32,
pub start: Option<String>,
pub edits: Vec<SourceEdit>,
}
pub struct Analyzer<Context> {
sender: ManuallyDrop<mpsc::UnboundedSender<Request<Context>>>,
handle: Option<JoinHandle<()>>,
config: Config,
resolution: ResolutionContext,
}
impl<Context> fmt::Debug for Analyzer<Context> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Analyzer")
.field("config", &self.config)
.field("resolution", &self.resolution)
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug, Default)]
pub enum ResolutionContext {
#[default]
Disabled,
Enabled {
resolver: Arc<dyn wdl_modules::Resolver>,
consumer_module: wdl_modules::module::Module,
},
}
impl ResolutionContext {
pub fn enabled(
resolver: Arc<dyn wdl_modules::Resolver>,
consumer_module: wdl_modules::module::Module,
) -> Self {
Self::Enabled {
resolver,
consumer_module,
}
}
pub fn module_root(&self) -> Option<&Path> {
match self {
Self::Disabled => None,
Self::Enabled {
consumer_module, ..
} => Some(consumer_module.root.as_path()),
}
}
pub(crate) fn into_parts(
self,
) -> (
Option<Arc<dyn wdl_modules::Resolver>>,
Option<wdl_modules::module::Module>,
) {
match self {
Self::Disabled => (None, None),
Self::Enabled {
resolver,
consumer_module,
} => (Some(resolver), Some(consumer_module)),
}
}
}
impl<Context> Analyzer<Context>
where
Context: Send + Clone + 'static,
{
pub fn new<Progress, Return>(config: Config, progress: Progress) -> Self
where
Progress: Fn(Context, ProgressKind, usize, usize) -> Return + Send + 'static,
Return: Future<Output = ()>,
{
Self::new_with_resolution(config, ResolutionContext::default(), progress)
}
pub fn new_with_resolution<Progress, Return>(
config: Config,
resolution: ResolutionContext,
progress: Progress,
) -> Self
where
Progress: Fn(Context, ProgressKind, usize, usize) -> Return + Send + 'static,
Return: Future<Output = ()>,
{
Self::new_with_validator_and_resolution(
config,
resolution,
progress,
crate::Validator::default,
)
}
pub fn new_with_validator<Progress, Return, Validator>(
config: Config,
progress: Progress,
validator: Validator,
) -> Self
where
Progress: Fn(Context, ProgressKind, usize, usize) -> Return + Send + 'static,
Return: Future<Output = ()>,
Validator: Fn() -> crate::Validator + Send + Sync + 'static,
{
Self::new_with_validator_and_resolution(
config,
ResolutionContext::default(),
progress,
validator,
)
}
pub fn new_with_validator_and_resolution<Progress, Return, Validator>(
config: Config,
resolution: ResolutionContext,
progress: Progress,
validator: Validator,
) -> Self
where
Progress: Fn(Context, ProgressKind, usize, usize) -> Return + Send + 'static,
Return: Future<Output = ()>,
Validator: Fn() -> crate::Validator + Send + Sync + 'static,
{
let (tx, rx) = mpsc::unbounded_channel();
let tokio = Handle::current();
let inner_config = config.clone();
let inner_resolution = resolution.clone();
let handle = std::thread::spawn(move || {
let queue =
AnalysisQueue::new(inner_config, tokio, inner_resolution, progress, validator);
queue.run(rx);
});
Self {
sender: ManuallyDrop::new(tx),
handle: Some(handle),
config,
resolution,
}
}
pub async fn add_document(&self, uri: Url) -> Result<()> {
let mut documents = IndexSet::new();
documents.insert(uri);
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::Add(AddRequest {
documents,
completed: tx,
}))
.map_err(|_| {
anyhow!("failed to send request to analysis queue because the channel has closed")
})?;
rx.await.map_err(|_| {
anyhow!("failed to receive response from analysis queue because the channel has closed")
})?;
Ok(())
}
pub async fn add_directory(&self, path: impl Into<PathBuf>) -> Result<()> {
let path = path.into();
let config = self.config.clone();
let stop_at_module_boundaries = self
.resolution
.module_root()
.is_some_and(|root| path.starts_with(root));
let documents = RayonHandle::spawn(move || -> Result<IndexSet<Url>> {
let mut documents = IndexSet::new();
let metadata = path.metadata().with_context(|| {
format!(
"failed to read metadata for `{path}`",
path = path.display()
)
})?;
if metadata.is_file() {
bail!("`{path}` is a file, not a directory", path = path.display());
}
let mut walker = WalkBuilder::new(&path);
if let Some(ignore_filename) = config.ignore_filename() {
walker.add_custom_ignore_filename(ignore_filename);
}
if stop_at_module_boundaries {
let root_for_filter = path.clone();
walker.filter_entry(move |entry| {
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
return true;
}
if entry.path() == root_for_filter {
return true;
}
!wdl_modules::module::is_module_root(entry.path())
});
}
let walker = walker
.standard_filters(false)
.parents(true)
.follow_links(true)
.build();
for result in walker {
let entry = result.with_context(|| {
format!("failed to read directory `{path}`", path = path.display())
})?;
let Some(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_file() {
continue;
}
if entry.path().extension() != Some(OsStr::new("wdl")) {
continue;
}
documents.insert(path_to_uri(entry.path()).with_context(|| {
format!(
"failed to convert path `{path}` to a URI",
path = entry.path().display()
)
})?);
}
Ok(documents)
})
.await?;
if documents.is_empty() {
return Ok(());
}
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::Add(AddRequest {
documents,
completed: tx,
}))
.map_err(|_| {
anyhow!("failed to send request to analysis queue because the channel has closed")
})?;
rx.await.map_err(|_| {
anyhow!("failed to receive response from analysis queue because the channel has closed")
})?;
Ok(())
}
pub async fn remove_documents(&self, documents: Vec<Url>) -> Result<()> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::Remove(RemoveRequest {
documents,
completed: tx,
}))
.map_err(|_| {
anyhow!("failed to send request to analysis queue because the channel has closed")
})?;
rx.await.map_err(|_| {
anyhow!("failed to receive response from analysis queue because the channel has closed")
})?;
Ok(())
}
pub fn notify_incremental_change(
&self,
document: Url,
change: IncrementalChange,
) -> Result<()> {
self.sender
.send(Request::NotifyIncrementalChange(
NotifyIncrementalChangeRequest { document, change },
))
.map_err(|_| {
anyhow!("failed to send request to analysis queue because the channel has closed")
})
}
pub fn notify_change(&self, document: Url, discard_pending: bool) -> Result<()> {
self.sender
.send(Request::NotifyChange(NotifyChangeRequest {
document,
discard_pending,
}))
.map_err(|_| {
anyhow!("failed to send request to analysis queue because the channel has closed")
})
}
pub async fn analyze_document(
&self,
context: Context,
document: Url,
) -> Result<Vec<AnalysisResult>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::Analyze(AnalyzeRequest {
document: Some(document),
context,
completed: tx,
}))
.map_err(|_| {
anyhow!("failed to send request to analysis queue because the channel has closed")
})?;
rx.await.map_err(|_| {
anyhow!("failed to receive response from analysis queue because the channel has closed")
})?
}
pub async fn analyze(&self, context: Context) -> Result<Vec<AnalysisResult>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::Analyze(AnalyzeRequest {
document: None, context,
completed: tx,
}))
.map_err(|_| {
anyhow!("failed to send request to analysis queue because the channel has closed")
})?;
rx.await.map_err(|_| {
anyhow!("failed to receive response from analysis queue because the channel has closed")
})?
}
pub async fn call_hierarchy(
&self,
document: Url,
position: SourcePosition,
encoding: SourcePositionEncoding,
) -> Result<Option<Vec<CallHierarchyItem>>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::CallHierarchy(CallHierarchyRequest {
document,
position,
encoding,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send call hierarchy request to analysis queue because the channel \
has closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to receive call hierarchy response from analysis queue because the \
channel has closed"
)
})
}
pub async fn format_document(&self, document: Url) -> Result<Option<(u32, u32, String)>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::Format(FormatRequest {
document,
completed: tx,
}))
.map_err(|_| {
anyhow!("failed to send format request to the queue because the channel has closed")
})?;
rx.await.map_err(|_| {
anyhow!("failed to send format request to the queue because the channel has closed")
})
}
pub async fn folding_range(&self, document: Url) -> Result<Option<Vec<FoldingRange>>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::FoldingRange(FoldingRangeRequest {
document,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send folding range request to the queue because the channel has \
closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to receive folding range response from analysis queue because the channel \
has closed"
)
})
}
pub async fn goto_definition(
&self,
document: Url,
position: SourcePosition,
encoding: SourcePositionEncoding,
) -> Result<Option<GotoDefinitionResponse>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::GotoDefinition(GotoDefinitionRequest {
document,
position,
encoding,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send goto definition request to analysis queue because the channel \
has closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to receive goto definition response from analysis queue because the \
channel has closed"
)
})
}
pub async fn find_all_references(
&self,
document: Url,
position: SourcePosition,
encoding: SourcePositionEncoding,
include_declaration: bool,
) -> Result<Vec<Location>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::FindAllReferences(FindAllReferencesRequest {
document,
position,
encoding,
include_declaration,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send find all references request to analysis queue because the \
channel has closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to receive find all references response from analysis queue because the \
client channel has closed"
)
})
}
pub async fn code_lens(&self, document: Url) -> Result<Option<Vec<CodeLens>>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::CodeLens(CodeLensRequest {
document,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send codelens request to analysis queue because the channel has \
closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to send codelens request to analysis queue because the channel has closed"
)
})
}
pub async fn completion(
&self,
context: Context,
document: Url,
position: SourcePosition,
encoding: SourcePositionEncoding,
) -> Result<Option<CompletionResponse>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::Completion(CompletionRequest {
document,
position,
encoding,
context,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send completion request to analysis queue because the channel has \
closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to send completion request to analysis queue because the channel has \
closed"
)
})
}
pub async fn hover(
&self,
document: Url,
position: SourcePosition,
encoding: SourcePositionEncoding,
) -> Result<Option<Hover>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::Hover(HoverRequest {
document,
position,
encoding,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send hover request to analysis queue because the channel has closed"
)
})?;
rx.await.map_err(|_| {
anyhow!("failed to send hover request to analysis queue because the channel has closed")
})
}
pub async fn rename(
&self,
document: Url,
position: SourcePosition,
encoding: SourcePositionEncoding,
new_name: String,
) -> Result<Option<WorkspaceEdit>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::Rename(RenameRequest {
document,
position,
encoding,
new_name,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send rename request to analysis queue because the channel has \
closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to receive rename response from analysis queue because the channel has \
closed"
)
})
}
pub async fn semantic_tokens(&self, document: Url) -> Result<Option<SemanticTokensResult>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::SemanticTokens(SemanticTokenRequest {
document,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send semantic tokens request to analysis queue because the channel \
has closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to receive semantic tokens response from analysis queue because the \
channel has closed"
)
})
}
pub async fn document_symbol(&self, document: Url) -> Result<Option<DocumentSymbolResponse>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::DocumentSymbol(DocumentSymbolRequest {
document,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send document symbol request to analysis queue because the channel \
has closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to receive document symbol request to analysis queue because the channel \
has closed"
)
})
}
pub async fn workspace_symbol(&self, query: String) -> Result<Option<Vec<SymbolInformation>>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::WorkspaceSymbol(WorkspaceSymbolRequest {
query,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send workspace symbol request to analysis queue because the \
channel has closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to receive workspace symbol response from analysis queue because the \
channel has closed"
)
})
}
pub async fn incoming_calls(
&self,
document: Url,
position: SourcePosition,
encoding: SourcePositionEncoding,
) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::IncomingCalls(IncomingCallsRequest {
document,
position,
encoding,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send incoming calls request to analysis queue because the channel \
has closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to receive incoming calls response from analysis queue because the \
channel has closed"
)
})
}
pub async fn outgoing_calls(
&self,
document: Url,
position: SourcePosition,
encoding: SourcePositionEncoding,
) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::OutgoingCalls(OutgoingCallsRequest {
document,
position,
encoding,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send outgoing calls request to analysis queue because the channel \
has closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to receive outgoing calls response from analysis queue because the \
channel has closed"
)
})
}
pub async fn signature_help(
&self,
document: Url,
position: SourcePosition,
encoding: SourcePositionEncoding,
) -> Result<Option<SignatureHelp>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::SignatureHelp(SignatureHelpRequest {
document,
position,
encoding,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send signature help request to analysis queue because the channel \
has closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to receive signature help response from analysis queue because the \
channel has closed"
)
})
}
pub async fn inlay_hints(
&self,
document: Url,
range: lsp_types::Range,
) -> Result<Option<Vec<InlayHint>>> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Request::InlayHints(InlayHintsRequest {
document,
range,
completed: tx,
}))
.map_err(|_| {
anyhow!(
"failed to send inlay hints request to analysis queue because the channel has \
closed"
)
})?;
rx.await.map_err(|_| {
anyhow!(
"failed to receive inlay hints response from analysis queue because the channel \
has closed"
)
})
}
}
impl Default for Analyzer<()> {
fn default() -> Self {
Self::new(Default::default(), |_, _, _, _| async {})
}
}
impl<C> Drop for Analyzer<C> {
fn drop(&mut self) {
unsafe { ManuallyDrop::drop(&mut self.sender) };
if let Some(handle) = self.handle.take() {
handle.join().unwrap();
}
}
}
const _: () = {
const fn _assert<T: Send + Sync>() {}
_assert::<Analyzer<()>>();
};
#[cfg(test)]
mod test {
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
use wdl_ast::Severity;
use super::*;
#[tokio::test]
async fn it_returns_empty_results() {
let analyzer = Analyzer::default();
let results = analyzer.analyze(()).await.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn it_analyzes_a_document() {
let dir = TempDir::new().expect("failed to create temporary directory");
let path = dir.path().join("foo.wdl");
fs::write(
&path,
r#"version 1.1
task test {
command <<<>>>
}
workflow test {
}
"#,
)
.expect("failed to create test file");
let analyzer = Analyzer::default();
analyzer
.add_document(path_to_uri(&path).expect("should convert to URI"))
.await
.expect("should add document");
let results = analyzer.analyze(()).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].document.diagnostics().count(), 1);
assert_eq!(
results[0].document.diagnostics().next().unwrap().rule(),
None
);
assert_eq!(
results[0].document.diagnostics().next().unwrap().severity(),
Severity::Error
);
assert_eq!(
results[0].document.diagnostics().next().unwrap().message(),
"conflicting workflow name `test`"
);
let id = results[0].document.id().clone();
let results = analyzer.analyze(()).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].document.id().as_ref(), id.as_ref());
assert_eq!(results[0].document.diagnostics().count(), 1);
assert_eq!(
results[0].document.diagnostics().next().unwrap().rule(),
None
);
assert_eq!(
results[0].document.diagnostics().next().unwrap().severity(),
Severity::Error
);
assert_eq!(
results[0].document.diagnostics().next().unwrap().message(),
"conflicting workflow name `test`"
);
}
#[tokio::test]
async fn it_reanalyzes_a_document_on_change() {
let dir = TempDir::new().expect("failed to create temporary directory");
let path = dir.path().join("foo.wdl");
fs::write(
&path,
r#"version 1.1
task test {
command <<<>>>
}
workflow test {
}
"#,
)
.expect("failed to create test file");
let analyzer = Analyzer::default();
analyzer
.add_document(path_to_uri(&path).expect("should convert to URI"))
.await
.expect("should add document");
let results = analyzer.analyze(()).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].document.diagnostics().count(), 1);
assert_eq!(
results[0].document.diagnostics().next().unwrap().rule(),
None
);
assert_eq!(
results[0].document.diagnostics().next().unwrap().severity(),
Severity::Error
);
assert_eq!(
results[0].document.diagnostics().next().unwrap().message(),
"conflicting workflow name `test`"
);
fs::write(
&path,
r#"version 1.1
task test {
command <<<>>>
}
workflow something_else {
}
"#,
)
.expect("failed to create test file");
let uri = path_to_uri(&path).expect("should convert to URI");
analyzer.notify_change(uri.clone(), false).unwrap();
let id = results[0].document.id().clone();
let results = analyzer.analyze(()).await.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].document.id().as_ref() != id.as_ref());
assert_eq!(results[0].document.diagnostics().count(), 0);
let id = results[0].document.id().clone();
let results = analyzer.analyze_document((), uri).await.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].document.id().as_ref() == id.as_ref());
assert_eq!(results[0].document.diagnostics().count(), 0);
}
#[tokio::test]
async fn it_reanalyzes_a_document_on_incremental_change() {
let dir = TempDir::new().expect("failed to create temporary directory");
let path = dir.path().join("foo.wdl");
fs::write(
&path,
r#"version 1.1
task test {
command <<<>>>
}
workflow test {
}
"#,
)
.expect("failed to create test file");
let analyzer = Analyzer::default();
analyzer
.add_document(path_to_uri(&path).expect("should convert to URI"))
.await
.expect("should add document");
let results = analyzer.analyze(()).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].document.diagnostics().count(), 1);
assert_eq!(
results[0].document.diagnostics().next().unwrap().rule(),
None
);
assert_eq!(
results[0].document.diagnostics().next().unwrap().severity(),
Severity::Error
);
assert_eq!(
results[0].document.diagnostics().next().unwrap().message(),
"conflicting workflow name `test`"
);
let uri = path_to_uri(&path).expect("should convert to URI");
analyzer
.notify_incremental_change(
uri.clone(),
IncrementalChange {
version: 2,
start: None,
edits: vec![SourceEdit {
range: SourcePosition::new(6, 9)..SourcePosition::new(6, 13),
encoding: SourcePositionEncoding::UTF8,
text: "something_else".to_string(),
}],
},
)
.unwrap();
let id = results[0].document.id().clone();
let results = analyzer.analyze_document((), uri).await.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].document.id().as_ref() != id.as_ref());
assert_eq!(results[0].document.diagnostics().count(), 0);
}
#[tokio::test]
async fn it_removes_documents() {
let dir = TempDir::new().expect("failed to create temporary directory");
let foo = dir.path().join("foo.wdl");
fs::write(
&foo,
r#"version 1.1
workflow test {
}
"#,
)
.expect("failed to create test file");
let bar = dir.path().join("bar.wdl");
fs::write(
&bar,
r#"version 1.1
workflow test {
}
"#,
)
.expect("failed to create test file");
let baz = dir.path().join("baz.wdl");
fs::write(
&baz,
r#"version 1.1
workflow test {
}
"#,
)
.expect("failed to create test file");
let analyzer = Analyzer::default();
analyzer
.add_directory(dir.path())
.await
.expect("should add documents");
let results = analyzer.analyze(()).await.unwrap();
assert_eq!(results.len(), 3);
assert!(results[0].document.diagnostics().next().is_none());
assert!(results[1].document.diagnostics().next().is_none());
assert!(results[2].document.diagnostics().next().is_none());
let results = analyzer.analyze(()).await.unwrap();
assert_eq!(results.len(), 3);
analyzer
.remove_documents(vec![
path_to_uri(dir.path()).expect("should convert to URI"),
])
.await
.unwrap();
let results = analyzer.analyze(()).await.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn selected_imported_task_conflicts_with_local_workflow() {
let dir = TempDir::new().expect("failed to create temporary directory");
fs::write(
dir.path().join("lib.wdl"),
r#"version 1.4
task run {
command <<<>>>
}
"#,
)
.expect("failed to create library document");
fs::write(
dir.path().join("source.wdl"),
r#"version 1.4
import { run } from "lib.wdl"
workflow run {
}
"#,
)
.expect("failed to create source document");
let config = Config::default()
.with_feature_flags(crate::config::FeatureFlags::default().with_wdl_1_4());
let analyzer = Analyzer::new(config, |(), _, _, _| async {});
analyzer
.add_document(path_to_uri(dir.path().join("source.wdl")).expect("should convert"))
.await
.expect("should add document");
let results = analyzer.analyze(()).await.expect("analysis should succeed");
let source = results
.iter()
.find(|result| result.document.uri().path().contains("source.wdl"))
.expect("should find source result");
let errors = source
.document
.diagnostics()
.filter(|diagnostic| diagnostic.severity() == Severity::Error)
.map(|diagnostic| diagnostic.message())
.collect::<Vec<_>>();
assert_eq!(
errors,
["import of `run` conflicts with an existing definition"]
);
}
#[tokio::test]
async fn symbolic_import_resolves_through_mock_resolver() {
use wdl_modules::Manifest;
use wdl_modules::lockfile::ResolvedSource;
use wdl_modules::resolver::MaterializedFile;
use wdl_modules::resolver::ResolvedTree;
use wdl_modules::resolver::ResolverError;
#[derive(Debug)]
struct MockResolver {
dep_path: PathBuf,
}
#[async_trait::async_trait]
impl wdl_modules::Resolver for MockResolver {
async fn materialize(
&self,
_consumer: &wdl_modules::module::Module,
path: &wdl_modules::symbolic_path::SymbolicPath,
) -> Result<MaterializedFile, ResolverError> {
let rel = match path.sub_path() {
Some(sub) => {
let mut p = sub.to_path_buf();
p.set_extension("wdl");
p
}
None => std::path::PathBuf::from("index.wdl"),
};
let file_path = self.dep_path.join(rel);
let manifest_bytes = fs::read(self.dep_path.join("module.json")).unwrap();
let manifest = Manifest::parse(&manifest_bytes).unwrap();
Ok(MaterializedFile {
path: file_path,
module_root: self.dep_path.clone(),
source: ResolvedSource::Path {
path: self.dep_path.clone(),
},
manifest: Arc::new(manifest),
})
}
async fn resolve_tree(
&self,
_consumer: &wdl_modules::module::Module,
) -> Result<ResolvedTree, ResolverError> {
Ok(ResolvedTree::default())
}
async fn discover_versions(
&self,
_name: &wdl_modules::dependency::DependencyName,
_source: &wdl_modules::dependency::DependencySource,
_scope: wdl_modules::resolver::DependencyScope,
) -> Result<Vec<semver::Version>, ResolverError> {
Ok(Vec::new())
}
}
let dir = TempDir::new().expect("failed to create temporary directory");
let dep_dir = dir.path().join("dep");
fs::create_dir_all(&dep_dir).unwrap();
fs::write(
dep_dir.join("module.json"),
r#"{"name":"dep","version":"1.0.0","license":"MIT"}"#,
)
.unwrap();
fs::write(
dep_dir.join("index.wdl"),
"version 1.4\n\ntask hello {\n command <<<>>>\n}\n",
)
.unwrap();
let consumer_dir = dir.path().join("consumer");
fs::create_dir_all(&consumer_dir).unwrap();
let dep_path_json = dep_dir.display().to_string().replace('\\', "/");
fs::write(
consumer_dir.join("module.json"),
format!(
r#"{{"name":"consumer","version":"0.1.0","license":"MIT","dependencies":{{"dep":{{"path":"{dep_path_json}"}}}}}}"#
),
)
.unwrap();
fs::write(
consumer_dir.join("source.wdl"),
"version 1.4\n\nimport dep\nimport \"lib.wdl\"\n\nworkflow main {}\n",
)
.unwrap();
fs::write(
consumer_dir.join("lib.wdl"),
"version 1.4\n\nimport dep\n\ntask lib {\n command <<<>>>\n}\n",
)
.unwrap();
let config = Config::default()
.with_feature_flags(crate::config::FeatureFlags::default().with_wdl_1_4());
let resolver: Arc<dyn wdl_modules::Resolver> = Arc::new(MockResolver {
dep_path: dep_dir.clone(),
});
let consumer_module = wdl_modules::module::Module::load_from_path(&consumer_dir)
.expect("test consumer module should load");
let resolution = ResolutionContext::enabled(resolver, consumer_module);
let analyzer = Analyzer::new_with_resolution(config, resolution, |(), _, _, _| async {});
analyzer
.add_document(path_to_uri(consumer_dir.join("source.wdl")).expect("should convert"))
.await
.expect("should add document");
let results = analyzer.analyze(()).await.unwrap();
assert!(!results.is_empty(), "should have analysis results");
let consumer_result = results
.iter()
.find(|r| r.document.uri().path().contains("source.wdl"))
.expect("should find consumer result");
let errors: Vec<_> = consumer_result
.document
.diagnostics()
.filter(|d| d.severity() == Severity::Error)
.collect();
assert!(
errors.is_empty(),
"consumer should have no errors, got: {:?}",
errors.iter().map(|d| d.message()).collect::<Vec<_>>()
);
let lib_result = results
.iter()
.find(|r| r.document.uri().path().contains("lib.wdl"))
.expect("should find uri import result");
let errors: Vec<_> = lib_result
.document
.diagnostics()
.filter(|d| d.severity() == Severity::Error)
.collect();
assert!(
errors.is_empty(),
"uri import should have no errors, got: {:?}",
errors.iter().map(|d| d.message()).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn concurrent_symbolic_imports_faster_than_serial() {
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use wdl_modules::Manifest;
use wdl_modules::lockfile::ResolvedSource;
use wdl_modules::resolver::MaterializedFile;
use wdl_modules::resolver::ResolvedTree;
use wdl_modules::resolver::ResolverError;
#[derive(Debug)]
struct SlowMockResolver {
dep_path: PathBuf,
delay: Duration,
active: AtomicUsize,
max_active: AtomicUsize,
}
#[async_trait::async_trait]
impl wdl_modules::Resolver for SlowMockResolver {
async fn materialize(
&self,
_consumer: &wdl_modules::module::Module,
path: &wdl_modules::symbolic_path::SymbolicPath,
) -> Result<MaterializedFile, ResolverError> {
let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
self.max_active.fetch_max(active, Ordering::SeqCst);
tokio::time::sleep(self.delay).await;
self.active.fetch_sub(1, Ordering::SeqCst);
let rel = match path.sub_path() {
Some(sub) => {
let mut p = sub.to_path_buf();
p.set_extension("wdl");
p
}
None => std::path::PathBuf::from("index.wdl"),
};
let file_path = self.dep_path.join(rel);
let manifest_bytes = fs::read(self.dep_path.join("module.json")).unwrap();
let manifest = Manifest::parse(&manifest_bytes).unwrap();
Ok(MaterializedFile {
path: file_path,
module_root: self.dep_path.clone(),
source: ResolvedSource::Path {
path: self.dep_path.clone(),
},
manifest: Arc::new(manifest),
})
}
async fn resolve_tree(
&self,
_consumer: &wdl_modules::module::Module,
) -> Result<ResolvedTree, ResolverError> {
Ok(ResolvedTree::default())
}
async fn discover_versions(
&self,
_name: &wdl_modules::dependency::DependencyName,
_source: &wdl_modules::dependency::DependencySource,
_scope: wdl_modules::resolver::DependencyScope,
) -> Result<Vec<semver::Version>, ResolverError> {
Ok(Vec::new())
}
}
const IMPORT_COUNT: usize = 8;
const DELAY_MS: u64 = 200;
let dir = TempDir::new().expect("failed to create temporary directory");
let dep_dir = dir.path().join("slowdep");
fs::create_dir_all(&dep_dir).unwrap();
fs::write(
dep_dir.join("module.json"),
r#"{"name":"slowdep","version":"1.0.0","license":"MIT"}"#,
)
.unwrap();
for i in 0..IMPORT_COUNT {
fs::write(
dep_dir.join(format!("sub{i}.wdl")),
"version 1.4\n\ntask noop {\n command <<<>>>\n}\n",
)
.unwrap();
}
fs::write(
dep_dir.join("index.wdl"),
"version 1.4\n\ntask noop {\n command <<<>>>\n}\n",
)
.unwrap();
let consumer_dir = dir.path().join("slowconsumer");
fs::create_dir_all(&consumer_dir).unwrap();
let dep_path_json = dep_dir.display().to_string().replace('\\', "/");
fs::write(
consumer_dir.join("module.json"),
format!(
r#"{{"name":"slowconsumer","version":"0.1.0","license":"MIT","dependencies":{{"slowdep":{{"path":"{dep_path_json}"}}}}}}"#
),
)
.unwrap();
let mut source = "version 1.4\n\n".to_string();
for i in 0..IMPORT_COUNT {
source.push_str(&format!("import slowdep/sub{i}\n"));
}
source.push_str("\nworkflow main {}\n");
fs::write(consumer_dir.join("source.wdl"), &source).unwrap();
let config = Config::default()
.with_feature_flags(crate::config::FeatureFlags::default().with_wdl_1_4());
let resolver = Arc::new(SlowMockResolver {
dep_path: dep_dir.clone(),
delay: Duration::from_millis(DELAY_MS),
active: AtomicUsize::new(0),
max_active: AtomicUsize::new(0),
});
let resolver_trait: Arc<dyn wdl_modules::Resolver> = resolver.clone();
let consumer_module = wdl_modules::module::Module::load_from_path(&consumer_dir)
.expect("test consumer module should load");
let resolution = ResolutionContext::enabled(resolver_trait, consumer_module);
let analyzer = Analyzer::new_with_resolution(config, resolution, |(), _, _, _| async {});
analyzer
.add_document(path_to_uri(consumer_dir.join("source.wdl")).expect("should convert"))
.await
.expect("should add document");
let results = analyzer.analyze(()).await.unwrap();
assert!(!results.is_empty(), "should have analysis results");
assert!(
resolver.max_active.load(Ordering::SeqCst) > 1,
"symbolic imports should materialize concurrently"
);
}
}