Skip to main content

wdl_analysis/
analyzer.rs

1//! Implementation of the analyzer.
2
3use std::ffi::OsStr;
4use std::fmt;
5use std::future::Future;
6use std::mem::ManuallyDrop;
7use std::ops::Range;
8use std::path::Path;
9use std::path::PathBuf;
10use std::path::absolute;
11use std::sync::Arc;
12use std::thread::JoinHandle;
13
14use anyhow::Context;
15use anyhow::Error;
16use anyhow::Result;
17use anyhow::anyhow;
18use anyhow::bail;
19use ignore::WalkBuilder;
20use indexmap::IndexSet;
21use line_index::LineCol;
22use line_index::LineIndex;
23use line_index::WideEncoding;
24use line_index::WideLineCol;
25use lsp_types::CallHierarchyIncomingCall;
26use lsp_types::CallHierarchyItem;
27use lsp_types::CallHierarchyOutgoingCall;
28use lsp_types::CodeLens;
29use lsp_types::CompletionResponse;
30use lsp_types::DocumentSymbolResponse;
31use lsp_types::FoldingRange;
32use lsp_types::GotoDefinitionResponse;
33use lsp_types::Hover;
34use lsp_types::InlayHint;
35use lsp_types::Location;
36use lsp_types::SemanticTokensResult;
37use lsp_types::SignatureHelp;
38use lsp_types::SymbolInformation;
39use lsp_types::WorkspaceEdit;
40use path_clean::PathClean;
41use tokio::runtime::Handle;
42use tokio::sync::mpsc;
43use tokio::sync::oneshot;
44use url::Url;
45
46use crate::config::Config;
47use crate::document::Document;
48use crate::graph::DocumentGraphNode;
49use crate::graph::ParseState;
50use crate::queue::AddRequest;
51use crate::queue::AnalysisQueue;
52use crate::queue::AnalyzeRequest;
53use crate::queue::CallHierarchyRequest;
54use crate::queue::CodeLensRequest;
55use crate::queue::CompletionRequest;
56use crate::queue::DeleteRequest;
57use crate::queue::DocumentSymbolRequest;
58use crate::queue::FindAllReferencesRequest;
59use crate::queue::FoldingRangeRequest;
60use crate::queue::FormatRequest;
61use crate::queue::GotoDefinitionRequest;
62use crate::queue::HoverRequest;
63use crate::queue::IncomingCallsRequest;
64use crate::queue::InlayHintsRequest;
65use crate::queue::NotifyChangeRequest;
66use crate::queue::NotifyIncrementalChangeRequest;
67use crate::queue::OutgoingCallsRequest;
68use crate::queue::RenameRequest;
69use crate::queue::Request;
70use crate::queue::SemanticTokenRequest;
71use crate::queue::SignatureHelpRequest;
72use crate::queue::SwapValidatorRequest;
73use crate::queue::UnrootDocumentsRequest;
74use crate::queue::WorkspaceSymbolRequest;
75use crate::rayon::RayonHandle;
76
77/// Represents the kind of analysis progress being reported.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ProgressKind {
80    /// The progress is for parsing documents.
81    Parsing,
82    /// The progress is for analyzing documents.
83    Analyzing,
84}
85
86impl fmt::Display for ProgressKind {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            Self::Parsing => write!(f, "parsing"),
90            Self::Analyzing => write!(f, "analyzing"),
91        }
92    }
93}
94
95/// Converts a local file path to a file schemed URI.
96pub fn path_to_uri(path: impl AsRef<Path>) -> Option<Url> {
97    Url::from_file_path(absolute(path).ok()?.clean()).ok()
98}
99
100/// Represents the result of an analysis.
101///
102/// Analysis results are cheap to clone.
103#[derive(Debug, Clone)]
104pub struct AnalysisResult {
105    /// The error that occurred when attempting to parse the file (e.g. the file
106    /// could not be opened).
107    error: Option<Arc<Error>>,
108    /// The monotonic version of the document that was parsed.
109    ///
110    /// This value comes from incremental changes to the file.
111    ///
112    /// If `None`, the parsed version had no incremental changes.
113    version: Option<i32>,
114    /// The lines indexed for the parsed file.
115    lines: Option<Arc<LineIndex>>,
116    /// The analyzed document.
117    document: Document,
118}
119
120impl AnalysisResult {
121    /// Constructs a new analysis result for the given graph node.
122    pub(crate) fn new(node: &DocumentGraphNode) -> Self {
123        if let Some(error) = node.analysis_error() {
124            return Self {
125                error: Some(error.clone()),
126                version: node.parse_state().version(),
127                lines: node.parse_state().lines().cloned(),
128                document: Document::default_from_uri(node.uri().clone()),
129            };
130        }
131
132        let (error, version, lines) = match node.parse_state() {
133            ParseState::NotParsed => unreachable!("document should have been parsed"),
134            ParseState::Error(e) => (Some(e), None, None),
135            ParseState::Parsed { version, lines, .. } => (None, *version, Some(lines)),
136        };
137
138        Self {
139            error: error.cloned(),
140            version,
141            lines: lines.cloned(),
142            document: node
143                .document()
144                .expect("analysis should have completed")
145                .clone(),
146        }
147    }
148
149    /// Gets the error that occurred when attempting to parse the document.
150    ///
151    /// An example error would be if the file could not be opened.
152    ///
153    /// Returns `None` if the document was parsed successfully.
154    pub fn error(&self) -> Option<&Arc<Error>> {
155        self.error.as_ref()
156    }
157
158    /// Gets the incremental version of the parsed document.
159    ///
160    /// Returns `None` if there was an error parsing the document or if the
161    /// parsed document had no incremental changes.
162    pub fn version(&self) -> Option<i32> {
163        self.version
164    }
165
166    /// Gets the line index of the parsed document.
167    ///
168    /// Returns `None` if there was an error parsing the document.
169    pub fn lines(&self) -> Option<&Arc<LineIndex>> {
170        self.lines.as_ref()
171    }
172
173    /// Gets the analyzed document.
174    pub fn document(&self) -> &Document {
175        &self.document
176    }
177}
178
179/// Represents a position in a document's source.
180#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone, Default)]
181pub struct SourcePosition {
182    /// Line position in a document (zero-based).
183    // NOTE: this field must come before `character` to maintain a correct sort order.
184    pub line: u32,
185    /// Character offset on a line in a document (zero-based). The meaning of
186    /// this offset is determined by the position encoding.
187    pub character: u32,
188}
189
190impl SourcePosition {
191    /// Constructs a new source position from a line and character offset.
192    pub fn new(line: u32, character: u32) -> Self {
193        Self { line, character }
194    }
195}
196
197/// Represents the encoding of a source position.
198#[derive(Debug, Eq, PartialEq, Copy, Clone)]
199pub enum SourcePositionEncoding {
200    /// The position is UTF8 encoded.
201    ///
202    /// A position's character is the UTF-8 offset from the start of the line.
203    UTF8,
204    /// The position is UTF16 encoded.
205    ///
206    /// A position's character is the UTF-16 offset from the start of the line.
207    UTF16,
208}
209
210/// Represents an edit to a document's source.
211#[derive(Debug, Clone)]
212pub struct SourceEdit {
213    /// The range of the edit.
214    ///
215    /// Note that invalid ranges will cause the edit to be ignored.
216    range: Range<SourcePosition>,
217    /// The encoding of the edit positions.
218    encoding: SourcePositionEncoding,
219    /// The replacement text.
220    text: String,
221}
222
223impl SourceEdit {
224    /// Creates a new source edit for the given range and replacement text.
225    pub fn new(
226        range: Range<SourcePosition>,
227        encoding: SourcePositionEncoding,
228        text: impl Into<String>,
229    ) -> Self {
230        Self {
231            range,
232            encoding,
233            text: text.into(),
234        }
235    }
236
237    /// Gets the range of the edit.
238    pub(crate) fn range(&self) -> Range<SourcePosition> {
239        self.range.start..self.range.end
240    }
241
242    /// Applies the edit to the given string if it's in range.
243    pub(crate) fn apply(&self, source: &mut String, lines: &LineIndex) -> Result<()> {
244        let (start, end) = match self.encoding {
245            SourcePositionEncoding::UTF8 => (
246                LineCol {
247                    line: self.range.start.line,
248                    col: self.range.start.character,
249                },
250                LineCol {
251                    line: self.range.end.line,
252                    col: self.range.end.character,
253                },
254            ),
255            SourcePositionEncoding::UTF16 => (
256                lines
257                    .to_utf8(
258                        WideEncoding::Utf16,
259                        WideLineCol {
260                            line: self.range.start.line,
261                            col: self.range.start.character,
262                        },
263                    )
264                    .context("invalid edit start position")?,
265                lines
266                    .to_utf8(
267                        WideEncoding::Utf16,
268                        WideLineCol {
269                            line: self.range.end.line,
270                            col: self.range.end.character,
271                        },
272                    )
273                    .context("invalid edit end position")?,
274            ),
275        };
276
277        let range: Range<usize> = lines
278            .offset(start)
279            .context("invalid edit start position")?
280            .into()
281            ..lines
282                .offset(end)
283                .context("invalid edit end position")?
284                .into();
285
286        if !source.is_char_boundary(range.start) {
287            bail!("edit start position is not at a character boundary");
288        }
289
290        if !source.is_char_boundary(range.end) {
291            bail!("edit end position is not at a character boundary");
292        }
293
294        source.replace_range(range, &self.text);
295        Ok(())
296    }
297}
298
299/// Represents an incremental change to a document.
300#[derive(Clone, Debug)]
301pub struct IncrementalChange {
302    /// The monotonic version of the document.
303    ///
304    /// This is expected to increase for each incremental change.
305    pub version: i32,
306    /// The source to start from for applying edits.
307    ///
308    /// If this is `Some`, a full reparse will occur after applying edits to
309    /// this string.
310    ///
311    /// If this is `None`, edits will be applied to the existing CST and an
312    /// attempt will be made to incrementally parse the file.
313    pub start: Option<String>,
314    /// The source edits to apply.
315    pub edits: Vec<SourceEdit>,
316}
317
318/// Represents a Workflow Description Language (WDL) document analyzer.
319///
320/// By default, analysis parses documents, performs validation checks, resolves
321/// imports, and performs type checking.
322///
323/// Each analysis operation is processed in order of request; however, the
324/// individual parsing, resolution, and analysis of documents is performed
325/// across a thread pool.
326///
327/// Note that dropping the analyzer is a blocking operation as it will wait for
328/// the queue thread to join.
329///
330/// The type parameter is the context type passed to the progress callback.
331pub struct Analyzer<Context> {
332    /// The sender for sending analysis requests to the queue.
333    sender: ManuallyDrop<mpsc::UnboundedSender<Request<Context>>>,
334    /// The join handle for the queue task.
335    handle: Option<JoinHandle<()>>,
336    /// The config to use during analysis.
337    config: Config,
338    /// The context used to resolve symbolic module imports.
339    resolution: ResolutionContext,
340}
341
342impl<Context> fmt::Debug for Analyzer<Context> {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        f.debug_struct("Analyzer")
345            .field("config", &self.config)
346            .field("resolution", &self.resolution)
347            .finish_non_exhaustive()
348    }
349}
350
351/// The context required to resolve symbolic module imports during analysis.
352///
353/// This is an either/or by construction. Resolution is either
354/// [`Disabled`](ResolutionContext::Disabled), in which case symbolic imports do
355/// not resolve, or [`Enabled`](ResolutionContext::Enabled), which always pairs
356/// a resolver with the consumer module it resolves imports for. The pairing is
357/// kept in one variant so a resolver can never exist without a module, nor a
358/// module without a resolver.
359#[derive(Clone, Debug, Default)]
360pub enum ResolutionContext {
361    /// Module resolution is disabled; symbolic imports do not resolve.
362    #[default]
363    Disabled,
364    /// Module resolution is enabled for a consumer module.
365    Enabled {
366        /// The resolver used to materialize symbolic module imports.
367        resolver: Arc<dyn wdl_modules::Resolver>,
368        /// The consumer [`Module`](wdl_modules::module::Module) governing the
369        /// analyzed sources.
370        ///
371        /// The caller builds this from the manifest it already parsed during
372        /// discovery and hands it over here, so constructing a resolution
373        /// context performs no filesystem I/O and the analysis queue never
374        /// re-reads `module.json`.
375        consumer_module: wdl_modules::module::Module,
376    },
377}
378
379impl ResolutionContext {
380    /// Creates a resolution context that resolves symbolic imports for the
381    /// given consumer module through the given resolver.
382    pub fn enabled(
383        resolver: Arc<dyn wdl_modules::Resolver>,
384        consumer_module: wdl_modules::module::Module,
385    ) -> Self {
386        Self::Enabled {
387            resolver,
388            consumer_module,
389        }
390    }
391
392    /// Returns the root directory of the consumer module governing analysis, if
393    /// resolution is enabled.
394    pub fn module_root(&self) -> Option<&Path> {
395        match self {
396            Self::Disabled => None,
397            Self::Enabled {
398                consumer_module, ..
399            } => Some(consumer_module.root.as_path()),
400        }
401    }
402
403    /// Splits the context into the resolver and consumer module the analysis
404    /// queue runs with.
405    ///
406    /// Both are `None` when resolution is disabled and both are `Some` when it
407    /// is enabled, so the queue never holds a resolver without a module nor a
408    /// module without a resolver.
409    pub(crate) fn into_parts(
410        self,
411    ) -> (
412        Option<Arc<dyn wdl_modules::Resolver>>,
413        Option<wdl_modules::module::Module>,
414    ) {
415        match self {
416            Self::Disabled => (None, None),
417            Self::Enabled {
418                resolver,
419                consumer_module,
420            } => (Some(resolver), Some(consumer_module)),
421        }
422    }
423}
424
425impl<Context> Analyzer<Context>
426where
427    Context: Send + Clone + 'static,
428{
429    /// Constructs a new analyzer with the given config.
430    ///
431    /// The provided progress callback will be invoked during analysis.
432    ///
433    /// The analyzer will use a default validator for validation.
434    ///
435    /// The analyzer must be constructed from the context of a Tokio runtime.
436    pub fn new<Progress, Return>(config: Config, progress: Progress) -> Self
437    where
438        Progress: Fn(Context, ProgressKind, usize, usize) -> Return + Send + 'static,
439        Return: Future<Output = ()>,
440    {
441        Self::new_with_resolution(config, ResolutionContext::default(), progress)
442    }
443
444    /// Constructs a new analyzer with the given config and resolution context.
445    ///
446    /// The provided progress callback will be invoked during analysis.
447    ///
448    /// The analyzer will use a default validator for validation.
449    ///
450    /// The analyzer must be constructed from the context of a Tokio runtime.
451    pub fn new_with_resolution<Progress, Return>(
452        config: Config,
453        resolution: ResolutionContext,
454        progress: Progress,
455    ) -> Self
456    where
457        Progress: Fn(Context, ProgressKind, usize, usize) -> Return + Send + 'static,
458        Return: Future<Output = ()>,
459    {
460        Self::new_with_validator_and_resolution(
461            config,
462            resolution,
463            progress,
464            crate::Validator::default,
465        )
466    }
467
468    /// Constructs a new analyzer with the given config and validator function.
469    ///
470    /// The provided progress callback will be invoked during analysis.
471    ///
472    /// This validator function will be called once per worker thread to
473    /// initialize a thread-local validator.
474    ///
475    /// The analyzer must be constructed from the context of a Tokio runtime.
476    pub fn new_with_validator<Progress, Return, Validator>(
477        config: Config,
478        progress: Progress,
479        validator: Validator,
480    ) -> Self
481    where
482        Progress: Fn(Context, ProgressKind, usize, usize) -> Return + Send + 'static,
483        Return: Future<Output = ()>,
484        Validator: Fn() -> crate::Validator + Send + Sync + 'static,
485    {
486        Self::new_with_validator_and_resolution(
487            config,
488            ResolutionContext::default(),
489            progress,
490            validator,
491        )
492    }
493
494    /// Constructs a new analyzer with the given config, resolution context, and
495    /// validator function.
496    ///
497    /// The provided progress callback will be invoked during analysis.
498    ///
499    /// This validator function will be called once per worker thread to
500    /// initialize a thread-local validator.
501    ///
502    /// The analyzer must be constructed from the context of a Tokio runtime.
503    pub fn new_with_validator_and_resolution<Progress, Return, Validator>(
504        config: Config,
505        resolution: ResolutionContext,
506        progress: Progress,
507        validator: Validator,
508    ) -> Self
509    where
510        Progress: Fn(Context, ProgressKind, usize, usize) -> Return + Send + 'static,
511        Return: Future<Output = ()>,
512        Validator: Fn() -> crate::Validator + Send + Sync + 'static,
513    {
514        let (tx, rx) = mpsc::unbounded_channel();
515        let tokio = Handle::current();
516        let inner_config = config.clone();
517        let inner_resolution = resolution.clone();
518        let handle = std::thread::spawn(move || {
519            let queue = AnalysisQueue::new(
520                inner_config,
521                tokio,
522                inner_resolution,
523                progress,
524                Arc::new(validator),
525            );
526            queue.run(rx);
527        });
528
529        Self {
530            sender: ManuallyDrop::new(tx),
531            handle: Some(handle),
532            config,
533            resolution,
534        }
535    }
536
537    /// Replace the current validator function.
538    ///
539    /// This will mark all documents for re-analysis.
540    pub async fn swap_validator<Validator>(&self, validator: Validator) -> Result<()>
541    where
542        Validator: Fn() -> crate::Validator + Send + Sync + 'static,
543    {
544        let (tx, rx) = oneshot::channel();
545        self.sender
546            .send(Request::SwapValidator(SwapValidatorRequest {
547                validator: Arc::new(validator),
548                completed: tx,
549            }))
550            .map_err(|_| {
551                anyhow!("failed to send request to analysis queue because the channel has closed")
552            })?;
553
554        rx.await.map_err(|_| {
555            anyhow!("failed to receive response from analysis queue because the channel has closed")
556        })?;
557
558        Ok(())
559    }
560
561    /// Adds a document to the analyzer. Document can be a local file or a URL.
562    ///
563    /// Returns an error if the document could not be added.
564    pub async fn add_document(&self, uri: Url) -> Result<()> {
565        let mut documents = IndexSet::new();
566        documents.insert(uri);
567
568        let (tx, rx) = oneshot::channel();
569        self.sender
570            .send(Request::Add(AddRequest {
571                documents,
572                completed: tx,
573            }))
574            .map_err(|_| {
575                anyhow!("failed to send request to analysis queue because the channel has closed")
576            })?;
577
578        rx.await.map_err(|_| {
579            anyhow!("failed to receive response from analysis queue because the channel has closed")
580        })?;
581
582        Ok(())
583    }
584
585    /// Adds a directory to the analyzer. It will recursively search for WDL
586    /// documents in the supplied directory.
587    ///
588    /// Returns an error if there was a problem discovering documents for the
589    /// specified path.
590    pub async fn add_directory(&self, path: impl Into<PathBuf>) -> Result<()> {
591        let path = path.into();
592        let config = self.config.clone();
593        // When the scanned directory lies inside the active module, stop the
594        // walk at nested module boundaries: a subdirectory with its own
595        // `module.json` is a separate (local-path dependency) module whose WDL
596        // files reach the analyzer through symbolic-import materialization, not
597        // directory scanning. Outside an active module there is nothing to scope
598        // to, so scan everything.
599        let stop_at_module_boundaries = self
600            .resolution
601            .module_root()
602            .is_some_and(|root| path.starts_with(root));
603        // Start by searching for documents
604        let documents = RayonHandle::spawn(move || -> Result<IndexSet<Url>> {
605            let mut documents = IndexSet::new();
606
607            let metadata = path.metadata().with_context(|| {
608                format!(
609                    "failed to read metadata for `{path}`",
610                    path = path.display()
611                )
612            })?;
613
614            if metadata.is_file() {
615                bail!("`{path}` is a file, not a directory", path = path.display());
616            }
617
618            let mut walker = WalkBuilder::new(&path);
619            if let Some(ignore_filename) = config.ignore_filename() {
620                walker.add_custom_ignore_filename(ignore_filename);
621            }
622            if stop_at_module_boundaries {
623                // Stop descending into subdirectories that declare their own
624                // module via a `module.json` file. Those directories belong to
625                // a different module (a local-path dependency) and their WDL
626                // files reach the analyzer through symbolic-import
627                // materialization, not directory scanning.
628                let root_for_filter = path.clone();
629                walker.filter_entry(move |entry| {
630                    if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
631                        return true;
632                    }
633                    if entry.path() == root_for_filter {
634                        return true;
635                    }
636                    !wdl_modules::module::is_module_root(entry.path())
637                });
638            }
639            let walker = walker
640                .standard_filters(false)
641                .parents(true)
642                .follow_links(true)
643                .build();
644
645            for result in walker {
646                let entry = result.with_context(|| {
647                    format!("failed to read directory `{path}`", path = path.display())
648                })?;
649
650                // Skip entries without a file type
651                let Some(file_type) = entry.file_type() else {
652                    continue;
653                };
654                // Skip non-files
655                if !file_type.is_file() {
656                    continue;
657                }
658                // Skip files without a `.wdl` extension
659                if entry.path().extension() != Some(OsStr::new("wdl")) {
660                    continue;
661                }
662
663                documents.insert(path_to_uri(entry.path()).with_context(|| {
664                    format!(
665                        "failed to convert path `{path}` to a URI",
666                        path = entry.path().display()
667                    )
668                })?);
669            }
670
671            Ok(documents)
672        })
673        .await?;
674
675        if documents.is_empty() {
676            return Ok(());
677        }
678
679        // Send the add request to the queue
680        let (tx, rx) = oneshot::channel();
681        self.sender
682            .send(Request::Add(AddRequest {
683                documents,
684                completed: tx,
685            }))
686            .map_err(|_| {
687                anyhow!("failed to send request to analysis queue because the channel has closed")
688            })?;
689
690        rx.await.map_err(|_| {
691            anyhow!("failed to receive response from analysis queue because the channel has closed")
692        })?;
693
694        Ok(())
695    }
696
697    /// Removes the specified documents from the analyzer.
698    ///
699    /// If a specified URI is a prefix (i.e. directory) of documents known to
700    /// the analyzer, those documents will be removed.
701    ///
702    /// Documents are only removed when not referenced from importing documents.
703    /// To forcefully delete the documents from the graph, use
704    /// [`Self::delete_documents()`].
705    pub async fn unroot_documents(&self, documents: Vec<Url>) -> Result<()> {
706        // Send the unroot request to the queue
707        let (tx, rx) = oneshot::channel();
708        self.sender
709            .send(Request::UnrootDocuments(UnrootDocumentsRequest {
710                documents,
711                completed: tx,
712            }))
713            .map_err(|_| {
714                anyhow!("failed to send request to analysis queue because the channel has closed")
715            })?;
716
717        rx.await.map_err(|_| {
718            anyhow!("failed to receive response from analysis queue because the channel has closed")
719        })?;
720
721        Ok(())
722    }
723
724    /// Deletes the specified documents from the analyzer.
725    ///
726    /// This differs from [`Self::unroot_documents()`], as a deletion will occur
727    /// even if the document(s) are referenced in other documents.
728    pub async fn delete_documents(&self, documents: Vec<Url>) -> Result<()> {
729        // Send the delete request to the queue
730        let (tx, rx) = oneshot::channel();
731        self.sender
732            .send(Request::Delete(DeleteRequest {
733                documents,
734                completed: tx,
735            }))
736            .map_err(|_| {
737                anyhow!("failed to send request to analysis queue because the channel has closed")
738            })?;
739
740        rx.await.map_err(|_| {
741            anyhow!("failed to receive response from analysis queue because the channel has closed")
742        })?;
743
744        Ok(())
745    }
746
747    /// Notifies the analyzer that a document has an incremental change.
748    ///
749    /// Changes to documents that aren't known to the analyzer are ignored.
750    pub fn notify_incremental_change(
751        &self,
752        document: Url,
753        change: IncrementalChange,
754    ) -> Result<()> {
755        self.sender
756            .send(Request::NotifyIncrementalChange(
757                NotifyIncrementalChangeRequest { document, change },
758            ))
759            .map_err(|_| {
760                anyhow!("failed to send request to analysis queue because the channel has closed")
761            })
762    }
763
764    /// Notifies the analyzer that a document has fully changed and should be
765    /// fetched again.
766    ///
767    /// Changes to documents that aren't known to the analyzer are ignored.
768    ///
769    /// If `discard_pending` is true, then any pending incremental changes are
770    /// discarded; otherwise, the full change is ignored if there are pending
771    /// incremental changes.
772    pub fn notify_change(&self, document: Url, discard_pending: bool) -> Result<()> {
773        self.sender
774            .send(Request::NotifyChange(NotifyChangeRequest {
775                document,
776                discard_pending,
777            }))
778            .map_err(|_| {
779                anyhow!("failed to send request to analysis queue because the channel has closed")
780            })
781    }
782
783    /// Analyzes a specific document.
784    ///
785    /// The provided context is passed to the progress callback.
786    ///
787    /// If the document is up-to-date and was previously analyzed, the current
788    /// analysis result is returned.
789    ///
790    /// Returns an analysis result for each document that was analyzed.
791    pub async fn analyze_document(
792        &self,
793        context: Context,
794        document: Url,
795    ) -> Result<Vec<AnalysisResult>> {
796        // Send the analyze request to the queue
797        let (tx, rx) = oneshot::channel();
798        self.sender
799            .send(Request::Analyze(AnalyzeRequest {
800                document: Some(document),
801                context,
802                completed: tx,
803            }))
804            .map_err(|_| {
805                anyhow!("failed to send request to analysis queue because the channel has closed")
806            })?;
807
808        rx.await.map_err(|_| {
809            anyhow!("failed to receive response from analysis queue because the channel has closed")
810        })?
811    }
812
813    /// Performs analysis of all documents.
814    ///
815    /// The provided context is passed to the progress callback.
816    ///
817    /// If a document is up-to-date and was previously analyzed, the current
818    /// analysis result is returned.
819    ///
820    /// Returns an analysis result for each document that was analyzed.
821    pub async fn analyze(&self, context: Context) -> Result<Vec<AnalysisResult>> {
822        // Send the analyze request to the queue
823        let (tx, rx) = oneshot::channel();
824        self.sender
825            .send(Request::Analyze(AnalyzeRequest {
826                document: None, // analyze all documents
827                context,
828                completed: tx,
829            }))
830            .map_err(|_| {
831                anyhow!("failed to send request to analysis queue because the channel has closed")
832            })?;
833
834        rx.await.map_err(|_| {
835            anyhow!("failed to receive response from analysis queue because the channel has closed")
836        })?
837    }
838
839    /// Get the call hierarchy for the symbol at the current position.
840    pub async fn call_hierarchy(
841        &self,
842        document: Url,
843        position: SourcePosition,
844        encoding: SourcePositionEncoding,
845    ) -> Result<Option<Vec<CallHierarchyItem>>> {
846        let (tx, rx) = oneshot::channel();
847        self.sender
848            .send(Request::CallHierarchy(CallHierarchyRequest {
849                document,
850                position,
851                encoding,
852                completed: tx,
853            }))
854            .map_err(|_| {
855                anyhow!(
856                    "failed to send call hierarchy request to analysis queue because the channel \
857                     has closed"
858                )
859            })?;
860
861        rx.await.map_err(|_| {
862            anyhow!(
863                "failed to receive call hierarchy response from analysis queue because the \
864                 channel has closed"
865            )
866        })
867    }
868
869    /// Formats a document.
870    pub async fn format_document(&self, document: Url) -> Result<Option<(u32, u32, String)>> {
871        let (tx, rx) = oneshot::channel();
872        self.sender
873            .send(Request::Format(FormatRequest {
874                document,
875                completed: tx,
876            }))
877            .map_err(|_| {
878                anyhow!("failed to send format request to the queue because the channel has closed")
879            })?;
880
881        rx.await.map_err(|_| {
882            anyhow!("failed to send format request to the queue because the channel has closed")
883        })
884    }
885
886    /// Get all folding ranges in a document.
887    pub async fn folding_range(&self, document: Url) -> Result<Option<Vec<FoldingRange>>> {
888        let (tx, rx) = oneshot::channel();
889        self.sender
890            .send(Request::FoldingRange(FoldingRangeRequest {
891                document,
892                completed: tx,
893            }))
894            .map_err(|_| {
895                anyhow!(
896                    "failed to send folding range request to the queue because the channel has \
897                     closed"
898                )
899            })?;
900
901        rx.await.map_err(|_| {
902            anyhow!(
903                "failed to receive folding range response from analysis queue because the channel \
904                 has closed"
905            )
906        })
907    }
908
909    /// Performs a "goto definition" for a symbol at the current position.
910    pub async fn goto_definition(
911        &self,
912        document: Url,
913        position: SourcePosition,
914        encoding: SourcePositionEncoding,
915    ) -> Result<Option<GotoDefinitionResponse>> {
916        let (tx, rx) = oneshot::channel();
917        self.sender
918            .send(Request::GotoDefinition(GotoDefinitionRequest {
919                document,
920                position,
921                encoding,
922                completed: tx,
923            }))
924            .map_err(|_| {
925                anyhow!(
926                    "failed to send goto definition request to analysis queue because the channel \
927                     has closed"
928                )
929            })?;
930
931        rx.await.map_err(|_| {
932            anyhow!(
933                "failed to receive goto definition response from analysis queue because the \
934                 channel has closed"
935            )
936        })
937    }
938
939    /// Performs a `find references` for a symbol across all the documents.
940    pub async fn find_all_references(
941        &self,
942        document: Url,
943        position: SourcePosition,
944        encoding: SourcePositionEncoding,
945        include_declaration: bool,
946    ) -> Result<Vec<Location>> {
947        let (tx, rx) = oneshot::channel();
948        self.sender
949            .send(Request::FindAllReferences(FindAllReferencesRequest {
950                document,
951                position,
952                encoding,
953                include_declaration,
954                completed: tx,
955            }))
956            .map_err(|_| {
957                anyhow!(
958                    "failed to send find all references request to analysis queue because the \
959                     channel has closed"
960                )
961            })?;
962
963        rx.await.map_err(|_| {
964            anyhow!(
965                "failed to receive find all references response from analysis queue because the \
966                 client channel has closed"
967            )
968        })
969    }
970
971    /// Get all code lenses in a document.
972    pub async fn code_lens(&self, document: Url) -> Result<Option<Vec<CodeLens>>> {
973        let (tx, rx) = oneshot::channel();
974        self.sender
975            .send(Request::CodeLens(CodeLensRequest {
976                document,
977                completed: tx,
978            }))
979            .map_err(|_| {
980                anyhow!(
981                    "failed to send codelens request to analysis queue because the channel has \
982                     closed"
983                )
984            })?;
985
986        rx.await.map_err(|_| {
987            anyhow!(
988                "failed to send codelens request to analysis queue because the channel has closed"
989            )
990        })
991    }
992
993    /// Performs a `auto-completion` for a symbol.
994    pub async fn completion(
995        &self,
996        context: Context,
997        document: Url,
998        position: SourcePosition,
999        encoding: SourcePositionEncoding,
1000    ) -> Result<Option<CompletionResponse>> {
1001        let (tx, rx) = oneshot::channel();
1002        self.sender
1003            .send(Request::Completion(CompletionRequest {
1004                document,
1005                position,
1006                encoding,
1007                context,
1008                completed: tx,
1009            }))
1010            .map_err(|_| {
1011                anyhow!(
1012                    "failed to send completion request to analysis queue because the channel has \
1013                     closed"
1014                )
1015            })?;
1016
1017        rx.await.map_err(|_| {
1018            anyhow!(
1019                "failed to send completion request to analysis queue because the channel has \
1020                 closed"
1021            )
1022        })
1023    }
1024
1025    /// Performs a `hover` for a symbol at a given position in a document.
1026    pub async fn hover(
1027        &self,
1028        document: Url,
1029        position: SourcePosition,
1030        encoding: SourcePositionEncoding,
1031    ) -> Result<Option<Hover>> {
1032        let (tx, rx) = oneshot::channel();
1033        self.sender
1034            .send(Request::Hover(HoverRequest {
1035                document,
1036                position,
1037                encoding,
1038                completed: tx,
1039            }))
1040            .map_err(|_| {
1041                anyhow!(
1042                    "failed to send hover request to analysis queue because the channel has closed"
1043                )
1044            })?;
1045
1046        rx.await.map_err(|_| {
1047            anyhow!("failed to send hover request to analysis queue because the channel has closed")
1048        })
1049    }
1050
1051    /// Renames a symbol at a given position across the workspace.
1052    pub async fn rename(
1053        &self,
1054        document: Url,
1055        position: SourcePosition,
1056        encoding: SourcePositionEncoding,
1057        new_name: String,
1058    ) -> Result<Option<WorkspaceEdit>> {
1059        let (tx, rx) = oneshot::channel();
1060        self.sender
1061            .send(Request::Rename(RenameRequest {
1062                document,
1063                position,
1064                encoding,
1065                new_name,
1066                completed: tx,
1067            }))
1068            .map_err(|_| {
1069                anyhow!(
1070                    "failed to send rename request to analysis queue because the channel has \
1071                     closed"
1072                )
1073            })?;
1074
1075        rx.await.map_err(|_| {
1076            anyhow!(
1077                "failed to receive rename response from analysis queue because the channel has \
1078                 closed"
1079            )
1080        })
1081    }
1082
1083    /// Gets semantic tokens for a document
1084    pub async fn semantic_tokens(&self, document: Url) -> Result<Option<SemanticTokensResult>> {
1085        let (tx, rx) = oneshot::channel();
1086        self.sender
1087            .send(Request::SemanticTokens(SemanticTokenRequest {
1088                document,
1089                completed: tx,
1090            }))
1091            .map_err(|_| {
1092                anyhow!(
1093                    "failed to send semantic tokens request to analysis queue because the channel \
1094                     has closed"
1095                )
1096            })?;
1097
1098        rx.await.map_err(|_| {
1099            anyhow!(
1100                "failed to receive semantic tokens response from analysis queue because the \
1101                 channel has closed"
1102            )
1103        })
1104    }
1105
1106    /// Gets document symbols for a document.
1107    pub async fn document_symbol(&self, document: Url) -> Result<Option<DocumentSymbolResponse>> {
1108        let (tx, rx) = oneshot::channel();
1109        self.sender
1110            .send(Request::DocumentSymbol(DocumentSymbolRequest {
1111                document,
1112                completed: tx,
1113            }))
1114            .map_err(|_| {
1115                anyhow!(
1116                    "failed to send document symbol request to analysis queue because the channel \
1117                     has closed"
1118                )
1119            })?;
1120
1121        rx.await.map_err(|_| {
1122            anyhow!(
1123                "failed to receive document symbol request to analysis queue because the channel \
1124                 has closed"
1125            )
1126        })
1127    }
1128
1129    /// Gets document symbols for the workspace.
1130    pub async fn workspace_symbol(&self, query: String) -> Result<Option<Vec<SymbolInformation>>> {
1131        let (tx, rx) = oneshot::channel();
1132        self.sender
1133            .send(Request::WorkspaceSymbol(WorkspaceSymbolRequest {
1134                query,
1135                completed: tx,
1136            }))
1137            .map_err(|_| {
1138                anyhow!(
1139                    "failed to send workspace symbol request to analysis queue because the \
1140                     channel has closed"
1141                )
1142            })?;
1143
1144        rx.await.map_err(|_| {
1145            anyhow!(
1146                "failed to receive workspace symbol response from analysis queue because the \
1147                 channel has closed"
1148            )
1149        })
1150    }
1151
1152    /// Get the incoming calls for the symbol at the current position.
1153    pub async fn incoming_calls(
1154        &self,
1155        document: Url,
1156        position: SourcePosition,
1157        encoding: SourcePositionEncoding,
1158    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1159        let (tx, rx) = oneshot::channel();
1160        self.sender
1161            .send(Request::IncomingCalls(IncomingCallsRequest {
1162                document,
1163                position,
1164                encoding,
1165                completed: tx,
1166            }))
1167            .map_err(|_| {
1168                anyhow!(
1169                    "failed to send incoming calls request to analysis queue because the channel \
1170                     has closed"
1171                )
1172            })?;
1173
1174        rx.await.map_err(|_| {
1175            anyhow!(
1176                "failed to receive incoming calls response from analysis queue because the \
1177                 channel has closed"
1178            )
1179        })
1180    }
1181
1182    /// Get the outgoing calls for the symbol at the current position.
1183    pub async fn outgoing_calls(
1184        &self,
1185        document: Url,
1186        position: SourcePosition,
1187        encoding: SourcePositionEncoding,
1188    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1189        let (tx, rx) = oneshot::channel();
1190        self.sender
1191            .send(Request::OutgoingCalls(OutgoingCallsRequest {
1192                document,
1193                position,
1194                encoding,
1195                completed: tx,
1196            }))
1197            .map_err(|_| {
1198                anyhow!(
1199                    "failed to send outgoing calls request to analysis queue because the channel \
1200                     has closed"
1201                )
1202            })?;
1203
1204        rx.await.map_err(|_| {
1205            anyhow!(
1206                "failed to receive outgoing calls response from analysis queue because the \
1207                 channel has closed"
1208            )
1209        })
1210    }
1211
1212    /// Gets signature help for a function call at a given position.
1213    pub async fn signature_help(
1214        &self,
1215        document: Url,
1216        position: SourcePosition,
1217        encoding: SourcePositionEncoding,
1218    ) -> Result<Option<SignatureHelp>> {
1219        let (tx, rx) = oneshot::channel();
1220        self.sender
1221            .send(Request::SignatureHelp(SignatureHelpRequest {
1222                document,
1223                position,
1224                encoding,
1225                completed: tx,
1226            }))
1227            .map_err(|_| {
1228                anyhow!(
1229                    "failed to send signature help request to analysis queue because the channel \
1230                     has closed"
1231                )
1232            })?;
1233
1234        rx.await.map_err(|_| {
1235            anyhow!(
1236                "failed to receive signature help response from analysis queue because the \
1237                 channel has closed"
1238            )
1239        })
1240    }
1241
1242    /// Requests inlay hints for a document.
1243    pub async fn inlay_hints(
1244        &self,
1245        document: Url,
1246        range: lsp_types::Range,
1247    ) -> Result<Option<Vec<InlayHint>>> {
1248        let (tx, rx) = oneshot::channel();
1249        self.sender
1250            .send(Request::InlayHints(InlayHintsRequest {
1251                document,
1252                range,
1253                completed: tx,
1254            }))
1255            .map_err(|_| {
1256                anyhow!(
1257                    "failed to send inlay hints request to analysis queue because the channel has \
1258                     closed"
1259                )
1260            })?;
1261
1262        rx.await.map_err(|_| {
1263            anyhow!(
1264                "failed to receive inlay hints response from analysis queue because the channel \
1265                 has closed"
1266            )
1267        })
1268    }
1269}
1270
1271impl Default for Analyzer<()> {
1272    fn default() -> Self {
1273        Self::new(Default::default(), |_, _, _, _| async {})
1274    }
1275}
1276
1277impl<C> Drop for Analyzer<C> {
1278    fn drop(&mut self) {
1279        unsafe { ManuallyDrop::drop(&mut self.sender) };
1280        if let Some(handle) = self.handle.take() {
1281            handle.join().unwrap();
1282        }
1283    }
1284}
1285
1286/// Constant that asserts `Analyzer` is `Send + Sync`; if not, it fails to
1287/// compile.
1288const _: () = {
1289    /// Helper that will fail to compile if T is not `Send + Sync`.
1290    const fn _assert<T: Send + Sync>() {}
1291    _assert::<Analyzer<()>>();
1292};
1293
1294#[cfg(test)]
1295mod test {
1296    use std::fs;
1297    use std::path::PathBuf;
1298
1299    use tempfile::TempDir;
1300    use wdl_ast::Severity;
1301
1302    use super::*;
1303
1304    #[tokio::test]
1305    #[test_log::test]
1306    async fn it_returns_empty_results() {
1307        let analyzer = Analyzer::default();
1308        let results = analyzer.analyze(()).await.unwrap();
1309        assert!(results.is_empty());
1310    }
1311
1312    #[tokio::test]
1313    #[test_log::test]
1314    async fn it_analyzes_a_document() {
1315        let dir = TempDir::new().expect("failed to create temporary directory");
1316        let path = dir.path().join("foo.wdl");
1317        fs::write(
1318            &path,
1319            r#"version 1.1
1320
1321task test {
1322    command <<<>>>
1323}
1324
1325workflow test {
1326}
1327"#,
1328        )
1329        .expect("failed to create test file");
1330
1331        // Analyze the file and check the resulting diagnostic
1332        let analyzer = Analyzer::default();
1333        analyzer
1334            .add_document(path_to_uri(&path).expect("should convert to URI"))
1335            .await
1336            .expect("should add document");
1337
1338        let results = analyzer.analyze(()).await.unwrap();
1339        assert_eq!(results.len(), 1);
1340        assert_eq!(results[0].document.diagnostics().count(), 1);
1341        assert_eq!(
1342            results[0].document.diagnostics().next().unwrap().rule(),
1343            None
1344        );
1345        assert_eq!(
1346            results[0].document.diagnostics().next().unwrap().severity(),
1347            Severity::Error
1348        );
1349        assert_eq!(
1350            results[0].document.diagnostics().next().unwrap().message(),
1351            "conflicting workflow name `test`"
1352        );
1353
1354        // Analyze again and ensure the analysis result id is unchanged
1355        let id = results[0].document.id().clone();
1356        let results = analyzer.analyze(()).await.unwrap();
1357        assert_eq!(results.len(), 1);
1358        assert_eq!(results[0].document.id().as_ref(), id.as_ref());
1359        assert_eq!(results[0].document.diagnostics().count(), 1);
1360        assert_eq!(
1361            results[0].document.diagnostics().next().unwrap().rule(),
1362            None
1363        );
1364        assert_eq!(
1365            results[0].document.diagnostics().next().unwrap().severity(),
1366            Severity::Error
1367        );
1368        assert_eq!(
1369            results[0].document.diagnostics().next().unwrap().message(),
1370            "conflicting workflow name `test`"
1371        );
1372    }
1373
1374    #[tokio::test]
1375    #[test_log::test]
1376    async fn it_reanalyzes_a_document_on_change() {
1377        let dir = TempDir::new().expect("failed to create temporary directory");
1378        let path = dir.path().join("foo.wdl");
1379        fs::write(
1380            &path,
1381            r#"version 1.1
1382
1383task test {
1384    command <<<>>>
1385}
1386
1387workflow test {
1388}
1389"#,
1390        )
1391        .expect("failed to create test file");
1392
1393        // Analyze the file and check the resulting diagnostic
1394        let analyzer = Analyzer::default();
1395        analyzer
1396            .add_document(path_to_uri(&path).expect("should convert to URI"))
1397            .await
1398            .expect("should add document");
1399
1400        let results = analyzer.analyze(()).await.unwrap();
1401        assert_eq!(results.len(), 1);
1402        assert_eq!(results[0].document.diagnostics().count(), 1);
1403        assert_eq!(
1404            results[0].document.diagnostics().next().unwrap().rule(),
1405            None
1406        );
1407        assert_eq!(
1408            results[0].document.diagnostics().next().unwrap().severity(),
1409            Severity::Error
1410        );
1411        assert_eq!(
1412            results[0].document.diagnostics().next().unwrap().message(),
1413            "conflicting workflow name `test`"
1414        );
1415
1416        // Rewrite the file to correct the issue
1417        fs::write(
1418            &path,
1419            r#"version 1.1
1420
1421task test {
1422    command <<<>>>
1423}
1424
1425workflow something_else {
1426}
1427"#,
1428        )
1429        .expect("failed to create test file");
1430
1431        let uri = path_to_uri(&path).expect("should convert to URI");
1432        analyzer.notify_change(uri.clone(), false).unwrap();
1433
1434        // Analyze again and ensure the analysis result id is changed and the issue
1435        // fixed
1436        let id = results[0].document.id().clone();
1437        let results = analyzer.analyze(()).await.unwrap();
1438        assert_eq!(results.len(), 1);
1439        assert_ne!(results[0].document.id().as_ref(), id.as_ref());
1440        assert_eq!(results[0].document.diagnostics().count(), 0);
1441
1442        // Analyze again and ensure the analysis result id is unchanged
1443        let id = results[0].document.id().clone();
1444        let results = analyzer.analyze_document((), uri).await.unwrap();
1445        assert_eq!(results.len(), 1);
1446        assert_eq!(results[0].document.id().as_ref(), id.as_ref());
1447        assert_eq!(results[0].document.diagnostics().count(), 0);
1448    }
1449
1450    #[tokio::test]
1451    #[test_log::test]
1452    async fn it_reanalyzes_a_document_on_incremental_change() {
1453        let dir = TempDir::new().expect("failed to create temporary directory");
1454        let path = dir.path().join("foo.wdl");
1455        fs::write(
1456            &path,
1457            r#"version 1.1
1458
1459task test {
1460    command <<<>>>
1461}
1462
1463workflow test {
1464}
1465"#,
1466        )
1467        .expect("failed to create test file");
1468
1469        // Analyze the file and check the resulting diagnostic
1470        let analyzer = Analyzer::default();
1471        analyzer
1472            .add_document(path_to_uri(&path).expect("should convert to URI"))
1473            .await
1474            .expect("should add document");
1475
1476        let results = analyzer.analyze(()).await.unwrap();
1477        assert_eq!(results.len(), 1);
1478        assert_eq!(results[0].document.diagnostics().count(), 1);
1479        assert_eq!(
1480            results[0].document.diagnostics().next().unwrap().rule(),
1481            None
1482        );
1483        assert_eq!(
1484            results[0].document.diagnostics().next().unwrap().severity(),
1485            Severity::Error
1486        );
1487        assert_eq!(
1488            results[0].document.diagnostics().next().unwrap().message(),
1489            "conflicting workflow name `test`"
1490        );
1491
1492        // Edit the file to correct the issue
1493        let uri = path_to_uri(&path).expect("should convert to URI");
1494        analyzer
1495            .notify_incremental_change(
1496                uri.clone(),
1497                IncrementalChange {
1498                    version: 2,
1499                    start: None,
1500                    edits: vec![SourceEdit {
1501                        range: SourcePosition::new(6, 9)..SourcePosition::new(6, 13),
1502                        encoding: SourcePositionEncoding::UTF8,
1503                        text: "something_else".to_string(),
1504                    }],
1505                },
1506            )
1507            .unwrap();
1508
1509        // Analyze again and ensure the analysis result id is changed and the issue was
1510        // fixed
1511        let id = results[0].document.id().clone();
1512        let results = analyzer.analyze_document((), uri).await.unwrap();
1513        assert_eq!(results.len(), 1);
1514        assert_ne!(results[0].document.id().as_ref(), id.as_ref());
1515        assert_eq!(results[0].document.diagnostics().count(), 0);
1516    }
1517
1518    #[tokio::test]
1519    #[test_log::test]
1520    async fn it_removes_documents() {
1521        let dir = TempDir::new().expect("failed to create temporary directory");
1522        let foo = dir.path().join("foo.wdl");
1523        fs::write(
1524            &foo,
1525            r#"version 1.1
1526workflow test {
1527}
1528"#,
1529        )
1530        .expect("failed to create test file");
1531
1532        let bar = dir.path().join("bar.wdl");
1533        fs::write(
1534            &bar,
1535            r#"version 1.1
1536workflow test {
1537}
1538"#,
1539        )
1540        .expect("failed to create test file");
1541
1542        let baz = dir.path().join("baz.wdl");
1543        fs::write(
1544            &baz,
1545            r#"version 1.1
1546workflow test {
1547}
1548"#,
1549        )
1550        .expect("failed to create test file");
1551
1552        // Add all three documents to the analyzer
1553        let analyzer = Analyzer::default();
1554        analyzer
1555            .add_directory(dir.path())
1556            .await
1557            .expect("should add documents");
1558
1559        // Analyze the documents
1560        let results = analyzer.analyze(()).await.unwrap();
1561        assert_eq!(results.len(), 3);
1562        assert!(results[0].document.diagnostics().next().is_none());
1563        assert!(results[1].document.diagnostics().next().is_none());
1564        assert!(results[2].document.diagnostics().next().is_none());
1565
1566        // Analyze the documents again
1567        let results = analyzer.analyze(()).await.unwrap();
1568        assert_eq!(results.len(), 3);
1569
1570        // Remove the documents by directory
1571        analyzer
1572            .unroot_documents(vec![
1573                path_to_uri(dir.path()).expect("should convert to URI"),
1574            ])
1575            .await
1576            .unwrap();
1577        let results = analyzer.analyze(()).await.unwrap();
1578        assert!(results.is_empty());
1579    }
1580
1581    #[tokio::test]
1582    #[test_log::test]
1583    async fn selected_imported_task_conflicts_with_local_workflow() {
1584        let dir = TempDir::new().expect("failed to create temporary directory");
1585        fs::write(
1586            dir.path().join("lib.wdl"),
1587            r#"version 1.4
1588task run {
1589    command <<<>>>
1590}
1591"#,
1592        )
1593        .expect("failed to create library document");
1594        fs::write(
1595            dir.path().join("source.wdl"),
1596            r#"version 1.4
1597import { run } from "lib.wdl"
1598workflow run {
1599}
1600"#,
1601        )
1602        .expect("failed to create source document");
1603
1604        let config = Config::default()
1605            .with_feature_flags(crate::config::FeatureFlags::default().with_wdl_1_4());
1606        let analyzer = Analyzer::new(config, |(), _, _, _| async {});
1607        analyzer
1608            .add_document(path_to_uri(dir.path().join("source.wdl")).expect("should convert"))
1609            .await
1610            .expect("should add document");
1611
1612        let results = analyzer.analyze(()).await.expect("analysis should succeed");
1613        let source = results
1614            .iter()
1615            .find(|result| result.document.uri().path().contains("source.wdl"))
1616            .expect("should find source result");
1617        let errors = source
1618            .document
1619            .diagnostics()
1620            .filter(|diagnostic| diagnostic.severity() == Severity::Error)
1621            .map(|diagnostic| diagnostic.message())
1622            .collect::<Vec<_>>();
1623        assert_eq!(
1624            errors,
1625            ["import of `run` conflicts with an existing definition"]
1626        );
1627    }
1628
1629    #[tokio::test]
1630    #[test_log::test]
1631    async fn symbolic_import_resolves_through_mock_resolver() {
1632        use wdl_modules::Manifest;
1633        use wdl_modules::lockfile::ResolvedSource;
1634        use wdl_modules::resolver::MaterializedFile;
1635        use wdl_modules::resolver::ResolvedTree;
1636        use wdl_modules::resolver::ResolverError;
1637
1638        #[derive(Debug)]
1639        struct MockResolver {
1640            dep_path: PathBuf,
1641        }
1642
1643        #[async_trait::async_trait]
1644        impl wdl_modules::Resolver for MockResolver {
1645            async fn materialize(
1646                &self,
1647                _consumer: &wdl_modules::module::Module,
1648                path: &wdl_modules::symbolic_path::SymbolicPath,
1649            ) -> Result<MaterializedFile, ResolverError> {
1650                let rel = match path.sub_path() {
1651                    Some(sub) => {
1652                        let mut p = sub.to_path_buf();
1653                        p.set_extension("wdl");
1654                        p
1655                    }
1656                    None => std::path::PathBuf::from("index.wdl"),
1657                };
1658                let file_path = self.dep_path.join(rel);
1659                let manifest_bytes = fs::read(self.dep_path.join("module.json")).unwrap();
1660                let manifest = Manifest::parse(&manifest_bytes).unwrap();
1661                Ok(MaterializedFile {
1662                    path: file_path,
1663                    module_root: self.dep_path.clone(),
1664                    source: ResolvedSource::Path {
1665                        path: self.dep_path.clone(),
1666                    },
1667                    manifest: Arc::new(manifest),
1668                })
1669            }
1670
1671            async fn resolve_tree(
1672                &self,
1673                _consumer: &wdl_modules::module::Module,
1674            ) -> Result<ResolvedTree, ResolverError> {
1675                Ok(ResolvedTree::default())
1676            }
1677
1678            async fn discover_versions(
1679                &self,
1680                _name: &wdl_modules::dependency::DependencyName,
1681                _source: &wdl_modules::dependency::DependencySource,
1682                _scope: wdl_modules::resolver::DependencyScope,
1683            ) -> Result<Vec<semver::Version>, ResolverError> {
1684                Ok(Vec::new())
1685            }
1686        }
1687
1688        let dir = TempDir::new().expect("failed to create temporary directory");
1689
1690        let dep_dir = dir.path().join("dep");
1691        fs::create_dir_all(&dep_dir).unwrap();
1692        fs::write(
1693            dep_dir.join("module.json"),
1694            r#"{"name":"dep","version":"1.0.0","license":"MIT"}"#,
1695        )
1696        .unwrap();
1697        fs::write(
1698            dep_dir.join("index.wdl"),
1699            "version 1.4\n\ntask hello {\n    command <<<>>>\n}\n",
1700        )
1701        .unwrap();
1702
1703        let consumer_dir = dir.path().join("consumer");
1704        fs::create_dir_all(&consumer_dir).unwrap();
1705        let dep_path_json = dep_dir.display().to_string().replace('\\', "/");
1706        fs::write(
1707            consumer_dir.join("module.json"),
1708            format!(
1709                r#"{{"name":"consumer","version":"0.1.0","license":"MIT","dependencies":{{"dep":{{"path":"{dep_path_json}"}}}}}}"#
1710            ),
1711        )
1712        .unwrap();
1713        fs::write(
1714            consumer_dir.join("source.wdl"),
1715            "version 1.4\n\nimport dep\nimport \"lib.wdl\"\n\nworkflow main {}\n",
1716        )
1717        .unwrap();
1718        fs::write(
1719            consumer_dir.join("lib.wdl"),
1720            "version 1.4\n\nimport dep\n\ntask lib {\n    command <<<>>>\n}\n",
1721        )
1722        .unwrap();
1723
1724        let config = Config::default()
1725            .with_feature_flags(crate::config::FeatureFlags::default().with_wdl_1_4());
1726        let resolver: Arc<dyn wdl_modules::Resolver> = Arc::new(MockResolver {
1727            dep_path: dep_dir.clone(),
1728        });
1729        let consumer_module = wdl_modules::module::Module::load_from_path(&consumer_dir)
1730            .expect("test consumer module should load");
1731        let resolution = ResolutionContext::enabled(resolver, consumer_module);
1732        let analyzer = Analyzer::new_with_resolution(config, resolution, |(), _, _, _| async {});
1733        analyzer
1734            .add_document(path_to_uri(consumer_dir.join("source.wdl")).expect("should convert"))
1735            .await
1736            .expect("should add document");
1737
1738        let results = analyzer.analyze(()).await.unwrap();
1739        assert!(!results.is_empty(), "should have analysis results");
1740        let consumer_result = results
1741            .iter()
1742            .find(|r| r.document.uri().path().contains("source.wdl"))
1743            .expect("should find consumer result");
1744        let errors: Vec<_> = consumer_result
1745            .document
1746            .diagnostics()
1747            .filter(|d| d.severity() == Severity::Error)
1748            .collect();
1749        assert!(
1750            errors.is_empty(),
1751            "consumer should have no errors, got: {:?}",
1752            errors.iter().map(|d| d.message()).collect::<Vec<_>>()
1753        );
1754        let lib_result = results
1755            .iter()
1756            .find(|r| r.document.uri().path().contains("lib.wdl"))
1757            .expect("should find uri import result");
1758        let errors: Vec<_> = lib_result
1759            .document
1760            .diagnostics()
1761            .filter(|d| d.severity() == Severity::Error)
1762            .collect();
1763        assert!(
1764            errors.is_empty(),
1765            "uri import should have no errors, got: {:?}",
1766            errors.iter().map(|d| d.message()).collect::<Vec<_>>()
1767        );
1768    }
1769
1770    #[tokio::test]
1771    #[test_log::test]
1772    async fn concurrent_symbolic_imports_faster_than_serial() {
1773        use std::sync::atomic::AtomicUsize;
1774        use std::sync::atomic::Ordering;
1775        use std::time::Duration;
1776
1777        use wdl_modules::Manifest;
1778        use wdl_modules::lockfile::ResolvedSource;
1779        use wdl_modules::resolver::MaterializedFile;
1780        use wdl_modules::resolver::ResolvedTree;
1781        use wdl_modules::resolver::ResolverError;
1782
1783        /// A resolver that tracks overlapping `materialize` calls.
1784        #[derive(Debug)]
1785        struct SlowMockResolver {
1786            dep_path: PathBuf,
1787            delay: Duration,
1788            active: AtomicUsize,
1789            max_active: AtomicUsize,
1790        }
1791
1792        #[async_trait::async_trait]
1793        impl wdl_modules::Resolver for SlowMockResolver {
1794            async fn materialize(
1795                &self,
1796                _consumer: &wdl_modules::module::Module,
1797                path: &wdl_modules::symbolic_path::SymbolicPath,
1798            ) -> Result<MaterializedFile, ResolverError> {
1799                let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
1800                self.max_active.fetch_max(active, Ordering::SeqCst);
1801                tokio::time::sleep(self.delay).await;
1802                self.active.fetch_sub(1, Ordering::SeqCst);
1803                let rel = match path.sub_path() {
1804                    Some(sub) => {
1805                        let mut p = sub.to_path_buf();
1806                        p.set_extension("wdl");
1807                        p
1808                    }
1809                    None => std::path::PathBuf::from("index.wdl"),
1810                };
1811                let file_path = self.dep_path.join(rel);
1812                let manifest_bytes = fs::read(self.dep_path.join("module.json")).unwrap();
1813                let manifest = Manifest::parse(&manifest_bytes).unwrap();
1814                Ok(MaterializedFile {
1815                    path: file_path,
1816                    module_root: self.dep_path.clone(),
1817                    source: ResolvedSource::Path {
1818                        path: self.dep_path.clone(),
1819                    },
1820                    manifest: Arc::new(manifest),
1821                })
1822            }
1823
1824            async fn resolve_tree(
1825                &self,
1826                _consumer: &wdl_modules::module::Module,
1827            ) -> Result<ResolvedTree, ResolverError> {
1828                Ok(ResolvedTree::default())
1829            }
1830
1831            async fn discover_versions(
1832                &self,
1833                _name: &wdl_modules::dependency::DependencyName,
1834                _source: &wdl_modules::dependency::DependencySource,
1835                _scope: wdl_modules::resolver::DependencyScope,
1836            ) -> Result<Vec<semver::Version>, ResolverError> {
1837                Ok(Vec::new())
1838            }
1839        }
1840
1841        const IMPORT_COUNT: usize = 8;
1842        const DELAY_MS: u64 = 200;
1843
1844        let dir = TempDir::new().expect("failed to create temporary directory");
1845
1846        let dep_dir = dir.path().join("slowdep");
1847        fs::create_dir_all(&dep_dir).unwrap();
1848        fs::write(
1849            dep_dir.join("module.json"),
1850            r#"{"name":"slowdep","version":"1.0.0","license":"MIT"}"#,
1851        )
1852        .unwrap();
1853        for i in 0..IMPORT_COUNT {
1854            fs::write(
1855                dep_dir.join(format!("sub{i}.wdl")),
1856                "version 1.4\n\ntask noop {\n    command <<<>>>\n}\n",
1857            )
1858            .unwrap();
1859        }
1860        fs::write(
1861            dep_dir.join("index.wdl"),
1862            "version 1.4\n\ntask noop {\n    command <<<>>>\n}\n",
1863        )
1864        .unwrap();
1865
1866        let consumer_dir = dir.path().join("slowconsumer");
1867        fs::create_dir_all(&consumer_dir).unwrap();
1868        let dep_path_json = dep_dir.display().to_string().replace('\\', "/");
1869        fs::write(
1870            consumer_dir.join("module.json"),
1871            format!(
1872                r#"{{"name":"slowconsumer","version":"0.1.0","license":"MIT","dependencies":{{"slowdep":{{"path":"{dep_path_json}"}}}}}}"#
1873            ),
1874        )
1875        .unwrap();
1876
1877        let mut source = "version 1.4\n\n".to_string();
1878        for i in 0..IMPORT_COUNT {
1879            source.push_str(&format!("import slowdep/sub{i}\n"));
1880        }
1881        source.push_str("\nworkflow main {}\n");
1882        fs::write(consumer_dir.join("source.wdl"), &source).unwrap();
1883
1884        let config = Config::default()
1885            .with_feature_flags(crate::config::FeatureFlags::default().with_wdl_1_4());
1886        let resolver = Arc::new(SlowMockResolver {
1887            dep_path: dep_dir.clone(),
1888            delay: Duration::from_millis(DELAY_MS),
1889            active: AtomicUsize::new(0),
1890            max_active: AtomicUsize::new(0),
1891        });
1892        let resolver_trait: Arc<dyn wdl_modules::Resolver> = resolver.clone();
1893        let consumer_module = wdl_modules::module::Module::load_from_path(&consumer_dir)
1894            .expect("test consumer module should load");
1895        let resolution = ResolutionContext::enabled(resolver_trait, consumer_module);
1896        let analyzer = Analyzer::new_with_resolution(config, resolution, |(), _, _, _| async {});
1897        analyzer
1898            .add_document(path_to_uri(consumer_dir.join("source.wdl")).expect("should convert"))
1899            .await
1900            .expect("should add document");
1901
1902        let results = analyzer.analyze(()).await.unwrap();
1903
1904        assert!(!results.is_empty(), "should have analysis results");
1905        assert!(
1906            resolver.max_active.load(Ordering::SeqCst) > 1,
1907            "symbolic imports should materialize concurrently"
1908        );
1909    }
1910
1911    #[tokio::test]
1912    #[test_log::test]
1913    async fn it_deletes_documents() {
1914        let dir = TempDir::new().expect("failed to create temporary directory");
1915        let foo = dir.path().join("foo.wdl");
1916        fs::write(
1917            &foo,
1918            r#"version 1.1
1919import "bar.wdl"
1920
1921workflow test {
1922    call bar.test
1923}
1924"#,
1925        )
1926        .expect("failed to create test file");
1927
1928        let bar = dir.path().join("bar.wdl");
1929        fs::write(
1930            &bar,
1931            r#"version 1.1
1932workflow test {}
1933"#,
1934        )
1935        .expect("failed to create test file");
1936
1937        // Add both documents to the analyzer
1938        let analyzer = Analyzer::default();
1939        analyzer
1940            .add_directory(dir.path())
1941            .await
1942            .expect("should add documents");
1943
1944        // Analyze the documents
1945        let results = analyzer.analyze(()).await.unwrap();
1946        assert_eq!(results.len(), 2);
1947        assert!(results[0].document.diagnostics().next().is_none());
1948        assert!(results[1].document.diagnostics().next().is_none());
1949
1950        // Now delete bar.wdl, which foo.wdl depends on.
1951        //
1952        // Unlike removal, this should *force* the deletion of bar.wdl in the graph (and
1953        // thus cause errors in foo.wdl)
1954        fs::remove_file(&bar).expect("should delete file");
1955        analyzer
1956            .delete_documents(vec![path_to_uri(&bar).expect("should convert to URI")])
1957            .await
1958            .unwrap();
1959
1960        // Now foo.wdl should error
1961        let results = analyzer.analyze(()).await.unwrap();
1962        assert_eq!(results.len(), 1);
1963
1964        let has_import_failed_diagnostic = results[0]
1965            .document
1966            .diagnostics()
1967            .any(|d| d.message().contains("failed to import `bar.wdl`"));
1968        assert!(has_import_failed_diagnostic);
1969    }
1970}