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    async fn it_returns_empty_results() {
1306        let analyzer = Analyzer::default();
1307        let results = analyzer.analyze(()).await.unwrap();
1308        assert!(results.is_empty());
1309    }
1310
1311    #[tokio::test]
1312    async fn it_analyzes_a_document() {
1313        let dir = TempDir::new().expect("failed to create temporary directory");
1314        let path = dir.path().join("foo.wdl");
1315        fs::write(
1316            &path,
1317            r#"version 1.1
1318
1319task test {
1320    command <<<>>>
1321}
1322
1323workflow test {
1324}
1325"#,
1326        )
1327        .expect("failed to create test file");
1328
1329        // Analyze the file and check the resulting diagnostic
1330        let analyzer = Analyzer::default();
1331        analyzer
1332            .add_document(path_to_uri(&path).expect("should convert to URI"))
1333            .await
1334            .expect("should add document");
1335
1336        let results = analyzer.analyze(()).await.unwrap();
1337        assert_eq!(results.len(), 1);
1338        assert_eq!(results[0].document.diagnostics().count(), 1);
1339        assert_eq!(
1340            results[0].document.diagnostics().next().unwrap().rule(),
1341            None
1342        );
1343        assert_eq!(
1344            results[0].document.diagnostics().next().unwrap().severity(),
1345            Severity::Error
1346        );
1347        assert_eq!(
1348            results[0].document.diagnostics().next().unwrap().message(),
1349            "conflicting workflow name `test`"
1350        );
1351
1352        // Analyze again and ensure the analysis result id is unchanged
1353        let id = results[0].document.id().clone();
1354        let results = analyzer.analyze(()).await.unwrap();
1355        assert_eq!(results.len(), 1);
1356        assert_eq!(results[0].document.id().as_ref(), id.as_ref());
1357        assert_eq!(results[0].document.diagnostics().count(), 1);
1358        assert_eq!(
1359            results[0].document.diagnostics().next().unwrap().rule(),
1360            None
1361        );
1362        assert_eq!(
1363            results[0].document.diagnostics().next().unwrap().severity(),
1364            Severity::Error
1365        );
1366        assert_eq!(
1367            results[0].document.diagnostics().next().unwrap().message(),
1368            "conflicting workflow name `test`"
1369        );
1370    }
1371
1372    #[tokio::test]
1373    async fn it_reanalyzes_a_document_on_change() {
1374        let dir = TempDir::new().expect("failed to create temporary directory");
1375        let path = dir.path().join("foo.wdl");
1376        fs::write(
1377            &path,
1378            r#"version 1.1
1379
1380task test {
1381    command <<<>>>
1382}
1383
1384workflow test {
1385}
1386"#,
1387        )
1388        .expect("failed to create test file");
1389
1390        // Analyze the file and check the resulting diagnostic
1391        let analyzer = Analyzer::default();
1392        analyzer
1393            .add_document(path_to_uri(&path).expect("should convert to URI"))
1394            .await
1395            .expect("should add document");
1396
1397        let results = analyzer.analyze(()).await.unwrap();
1398        assert_eq!(results.len(), 1);
1399        assert_eq!(results[0].document.diagnostics().count(), 1);
1400        assert_eq!(
1401            results[0].document.diagnostics().next().unwrap().rule(),
1402            None
1403        );
1404        assert_eq!(
1405            results[0].document.diagnostics().next().unwrap().severity(),
1406            Severity::Error
1407        );
1408        assert_eq!(
1409            results[0].document.diagnostics().next().unwrap().message(),
1410            "conflicting workflow name `test`"
1411        );
1412
1413        // Rewrite the file to correct the issue
1414        fs::write(
1415            &path,
1416            r#"version 1.1
1417
1418task test {
1419    command <<<>>>
1420}
1421
1422workflow something_else {
1423}
1424"#,
1425        )
1426        .expect("failed to create test file");
1427
1428        let uri = path_to_uri(&path).expect("should convert to URI");
1429        analyzer.notify_change(uri.clone(), false).unwrap();
1430
1431        // Analyze again and ensure the analysis result id is changed and the issue
1432        // fixed
1433        let id = results[0].document.id().clone();
1434        let results = analyzer.analyze(()).await.unwrap();
1435        assert_eq!(results.len(), 1);
1436        assert_ne!(results[0].document.id().as_ref(), id.as_ref());
1437        assert_eq!(results[0].document.diagnostics().count(), 0);
1438
1439        // Analyze again and ensure the analysis result id is unchanged
1440        let id = results[0].document.id().clone();
1441        let results = analyzer.analyze_document((), uri).await.unwrap();
1442        assert_eq!(results.len(), 1);
1443        assert_eq!(results[0].document.id().as_ref(), id.as_ref());
1444        assert_eq!(results[0].document.diagnostics().count(), 0);
1445    }
1446
1447    #[tokio::test]
1448    async fn it_reanalyzes_a_document_on_incremental_change() {
1449        let dir = TempDir::new().expect("failed to create temporary directory");
1450        let path = dir.path().join("foo.wdl");
1451        fs::write(
1452            &path,
1453            r#"version 1.1
1454
1455task test {
1456    command <<<>>>
1457}
1458
1459workflow test {
1460}
1461"#,
1462        )
1463        .expect("failed to create test file");
1464
1465        // Analyze the file and check the resulting diagnostic
1466        let analyzer = Analyzer::default();
1467        analyzer
1468            .add_document(path_to_uri(&path).expect("should convert to URI"))
1469            .await
1470            .expect("should add document");
1471
1472        let results = analyzer.analyze(()).await.unwrap();
1473        assert_eq!(results.len(), 1);
1474        assert_eq!(results[0].document.diagnostics().count(), 1);
1475        assert_eq!(
1476            results[0].document.diagnostics().next().unwrap().rule(),
1477            None
1478        );
1479        assert_eq!(
1480            results[0].document.diagnostics().next().unwrap().severity(),
1481            Severity::Error
1482        );
1483        assert_eq!(
1484            results[0].document.diagnostics().next().unwrap().message(),
1485            "conflicting workflow name `test`"
1486        );
1487
1488        // Edit the file to correct the issue
1489        let uri = path_to_uri(&path).expect("should convert to URI");
1490        analyzer
1491            .notify_incremental_change(
1492                uri.clone(),
1493                IncrementalChange {
1494                    version: 2,
1495                    start: None,
1496                    edits: vec![SourceEdit {
1497                        range: SourcePosition::new(6, 9)..SourcePosition::new(6, 13),
1498                        encoding: SourcePositionEncoding::UTF8,
1499                        text: "something_else".to_string(),
1500                    }],
1501                },
1502            )
1503            .unwrap();
1504
1505        // Analyze again and ensure the analysis result id is changed and the issue was
1506        // fixed
1507        let id = results[0].document.id().clone();
1508        let results = analyzer.analyze_document((), uri).await.unwrap();
1509        assert_eq!(results.len(), 1);
1510        assert_ne!(results[0].document.id().as_ref(), id.as_ref());
1511        assert_eq!(results[0].document.diagnostics().count(), 0);
1512    }
1513
1514    #[tokio::test]
1515    async fn it_removes_documents() {
1516        let dir = TempDir::new().expect("failed to create temporary directory");
1517        let foo = dir.path().join("foo.wdl");
1518        fs::write(
1519            &foo,
1520            r#"version 1.1
1521workflow test {
1522}
1523"#,
1524        )
1525        .expect("failed to create test file");
1526
1527        let bar = dir.path().join("bar.wdl");
1528        fs::write(
1529            &bar,
1530            r#"version 1.1
1531workflow test {
1532}
1533"#,
1534        )
1535        .expect("failed to create test file");
1536
1537        let baz = dir.path().join("baz.wdl");
1538        fs::write(
1539            &baz,
1540            r#"version 1.1
1541workflow test {
1542}
1543"#,
1544        )
1545        .expect("failed to create test file");
1546
1547        // Add all three documents to the analyzer
1548        let analyzer = Analyzer::default();
1549        analyzer
1550            .add_directory(dir.path())
1551            .await
1552            .expect("should add documents");
1553
1554        // Analyze the documents
1555        let results = analyzer.analyze(()).await.unwrap();
1556        assert_eq!(results.len(), 3);
1557        assert!(results[0].document.diagnostics().next().is_none());
1558        assert!(results[1].document.diagnostics().next().is_none());
1559        assert!(results[2].document.diagnostics().next().is_none());
1560
1561        // Analyze the documents again
1562        let results = analyzer.analyze(()).await.unwrap();
1563        assert_eq!(results.len(), 3);
1564
1565        // Remove the documents by directory
1566        analyzer
1567            .unroot_documents(vec![
1568                path_to_uri(dir.path()).expect("should convert to URI"),
1569            ])
1570            .await
1571            .unwrap();
1572        let results = analyzer.analyze(()).await.unwrap();
1573        assert!(results.is_empty());
1574    }
1575
1576    #[tokio::test]
1577    async fn selected_imported_task_conflicts_with_local_workflow() {
1578        let dir = TempDir::new().expect("failed to create temporary directory");
1579        fs::write(
1580            dir.path().join("lib.wdl"),
1581            r#"version 1.4
1582task run {
1583    command <<<>>>
1584}
1585"#,
1586        )
1587        .expect("failed to create library document");
1588        fs::write(
1589            dir.path().join("source.wdl"),
1590            r#"version 1.4
1591import { run } from "lib.wdl"
1592workflow run {
1593}
1594"#,
1595        )
1596        .expect("failed to create source document");
1597
1598        let config = Config::default()
1599            .with_feature_flags(crate::config::FeatureFlags::default().with_wdl_1_4());
1600        let analyzer = Analyzer::new(config, |(), _, _, _| async {});
1601        analyzer
1602            .add_document(path_to_uri(dir.path().join("source.wdl")).expect("should convert"))
1603            .await
1604            .expect("should add document");
1605
1606        let results = analyzer.analyze(()).await.expect("analysis should succeed");
1607        let source = results
1608            .iter()
1609            .find(|result| result.document.uri().path().contains("source.wdl"))
1610            .expect("should find source result");
1611        let errors = source
1612            .document
1613            .diagnostics()
1614            .filter(|diagnostic| diagnostic.severity() == Severity::Error)
1615            .map(|diagnostic| diagnostic.message())
1616            .collect::<Vec<_>>();
1617        assert_eq!(
1618            errors,
1619            ["import of `run` conflicts with an existing definition"]
1620        );
1621    }
1622
1623    #[tokio::test]
1624    async fn symbolic_import_resolves_through_mock_resolver() {
1625        use wdl_modules::Manifest;
1626        use wdl_modules::lockfile::ResolvedSource;
1627        use wdl_modules::resolver::MaterializedFile;
1628        use wdl_modules::resolver::ResolvedTree;
1629        use wdl_modules::resolver::ResolverError;
1630
1631        #[derive(Debug)]
1632        struct MockResolver {
1633            dep_path: PathBuf,
1634        }
1635
1636        #[async_trait::async_trait]
1637        impl wdl_modules::Resolver for MockResolver {
1638            async fn materialize(
1639                &self,
1640                _consumer: &wdl_modules::module::Module,
1641                path: &wdl_modules::symbolic_path::SymbolicPath,
1642            ) -> Result<MaterializedFile, ResolverError> {
1643                let rel = match path.sub_path() {
1644                    Some(sub) => {
1645                        let mut p = sub.to_path_buf();
1646                        p.set_extension("wdl");
1647                        p
1648                    }
1649                    None => std::path::PathBuf::from("index.wdl"),
1650                };
1651                let file_path = self.dep_path.join(rel);
1652                let manifest_bytes = fs::read(self.dep_path.join("module.json")).unwrap();
1653                let manifest = Manifest::parse(&manifest_bytes).unwrap();
1654                Ok(MaterializedFile {
1655                    path: file_path,
1656                    module_root: self.dep_path.clone(),
1657                    source: ResolvedSource::Path {
1658                        path: self.dep_path.clone(),
1659                    },
1660                    manifest: Arc::new(manifest),
1661                })
1662            }
1663
1664            async fn resolve_tree(
1665                &self,
1666                _consumer: &wdl_modules::module::Module,
1667            ) -> Result<ResolvedTree, ResolverError> {
1668                Ok(ResolvedTree::default())
1669            }
1670
1671            async fn discover_versions(
1672                &self,
1673                _name: &wdl_modules::dependency::DependencyName,
1674                _source: &wdl_modules::dependency::DependencySource,
1675                _scope: wdl_modules::resolver::DependencyScope,
1676            ) -> Result<Vec<semver::Version>, ResolverError> {
1677                Ok(Vec::new())
1678            }
1679        }
1680
1681        let dir = TempDir::new().expect("failed to create temporary directory");
1682
1683        let dep_dir = dir.path().join("dep");
1684        fs::create_dir_all(&dep_dir).unwrap();
1685        fs::write(
1686            dep_dir.join("module.json"),
1687            r#"{"name":"dep","version":"1.0.0","license":"MIT"}"#,
1688        )
1689        .unwrap();
1690        fs::write(
1691            dep_dir.join("index.wdl"),
1692            "version 1.4\n\ntask hello {\n    command <<<>>>\n}\n",
1693        )
1694        .unwrap();
1695
1696        let consumer_dir = dir.path().join("consumer");
1697        fs::create_dir_all(&consumer_dir).unwrap();
1698        let dep_path_json = dep_dir.display().to_string().replace('\\', "/");
1699        fs::write(
1700            consumer_dir.join("module.json"),
1701            format!(
1702                r#"{{"name":"consumer","version":"0.1.0","license":"MIT","dependencies":{{"dep":{{"path":"{dep_path_json}"}}}}}}"#
1703            ),
1704        )
1705        .unwrap();
1706        fs::write(
1707            consumer_dir.join("source.wdl"),
1708            "version 1.4\n\nimport dep\nimport \"lib.wdl\"\n\nworkflow main {}\n",
1709        )
1710        .unwrap();
1711        fs::write(
1712            consumer_dir.join("lib.wdl"),
1713            "version 1.4\n\nimport dep\n\ntask lib {\n    command <<<>>>\n}\n",
1714        )
1715        .unwrap();
1716
1717        let config = Config::default()
1718            .with_feature_flags(crate::config::FeatureFlags::default().with_wdl_1_4());
1719        let resolver: Arc<dyn wdl_modules::Resolver> = Arc::new(MockResolver {
1720            dep_path: dep_dir.clone(),
1721        });
1722        let consumer_module = wdl_modules::module::Module::load_from_path(&consumer_dir)
1723            .expect("test consumer module should load");
1724        let resolution = ResolutionContext::enabled(resolver, consumer_module);
1725        let analyzer = Analyzer::new_with_resolution(config, resolution, |(), _, _, _| async {});
1726        analyzer
1727            .add_document(path_to_uri(consumer_dir.join("source.wdl")).expect("should convert"))
1728            .await
1729            .expect("should add document");
1730
1731        let results = analyzer.analyze(()).await.unwrap();
1732        assert!(!results.is_empty(), "should have analysis results");
1733        let consumer_result = results
1734            .iter()
1735            .find(|r| r.document.uri().path().contains("source.wdl"))
1736            .expect("should find consumer result");
1737        let errors: Vec<_> = consumer_result
1738            .document
1739            .diagnostics()
1740            .filter(|d| d.severity() == Severity::Error)
1741            .collect();
1742        assert!(
1743            errors.is_empty(),
1744            "consumer should have no errors, got: {:?}",
1745            errors.iter().map(|d| d.message()).collect::<Vec<_>>()
1746        );
1747        let lib_result = results
1748            .iter()
1749            .find(|r| r.document.uri().path().contains("lib.wdl"))
1750            .expect("should find uri import result");
1751        let errors: Vec<_> = lib_result
1752            .document
1753            .diagnostics()
1754            .filter(|d| d.severity() == Severity::Error)
1755            .collect();
1756        assert!(
1757            errors.is_empty(),
1758            "uri import should have no errors, got: {:?}",
1759            errors.iter().map(|d| d.message()).collect::<Vec<_>>()
1760        );
1761    }
1762
1763    #[tokio::test]
1764    async fn concurrent_symbolic_imports_faster_than_serial() {
1765        use std::sync::atomic::AtomicUsize;
1766        use std::sync::atomic::Ordering;
1767        use std::time::Duration;
1768
1769        use wdl_modules::Manifest;
1770        use wdl_modules::lockfile::ResolvedSource;
1771        use wdl_modules::resolver::MaterializedFile;
1772        use wdl_modules::resolver::ResolvedTree;
1773        use wdl_modules::resolver::ResolverError;
1774
1775        /// A resolver that tracks overlapping `materialize` calls.
1776        #[derive(Debug)]
1777        struct SlowMockResolver {
1778            dep_path: PathBuf,
1779            delay: Duration,
1780            active: AtomicUsize,
1781            max_active: AtomicUsize,
1782        }
1783
1784        #[async_trait::async_trait]
1785        impl wdl_modules::Resolver for SlowMockResolver {
1786            async fn materialize(
1787                &self,
1788                _consumer: &wdl_modules::module::Module,
1789                path: &wdl_modules::symbolic_path::SymbolicPath,
1790            ) -> Result<MaterializedFile, ResolverError> {
1791                let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
1792                self.max_active.fetch_max(active, Ordering::SeqCst);
1793                tokio::time::sleep(self.delay).await;
1794                self.active.fetch_sub(1, Ordering::SeqCst);
1795                let rel = match path.sub_path() {
1796                    Some(sub) => {
1797                        let mut p = sub.to_path_buf();
1798                        p.set_extension("wdl");
1799                        p
1800                    }
1801                    None => std::path::PathBuf::from("index.wdl"),
1802                };
1803                let file_path = self.dep_path.join(rel);
1804                let manifest_bytes = fs::read(self.dep_path.join("module.json")).unwrap();
1805                let manifest = Manifest::parse(&manifest_bytes).unwrap();
1806                Ok(MaterializedFile {
1807                    path: file_path,
1808                    module_root: self.dep_path.clone(),
1809                    source: ResolvedSource::Path {
1810                        path: self.dep_path.clone(),
1811                    },
1812                    manifest: Arc::new(manifest),
1813                })
1814            }
1815
1816            async fn resolve_tree(
1817                &self,
1818                _consumer: &wdl_modules::module::Module,
1819            ) -> Result<ResolvedTree, ResolverError> {
1820                Ok(ResolvedTree::default())
1821            }
1822
1823            async fn discover_versions(
1824                &self,
1825                _name: &wdl_modules::dependency::DependencyName,
1826                _source: &wdl_modules::dependency::DependencySource,
1827                _scope: wdl_modules::resolver::DependencyScope,
1828            ) -> Result<Vec<semver::Version>, ResolverError> {
1829                Ok(Vec::new())
1830            }
1831        }
1832
1833        const IMPORT_COUNT: usize = 8;
1834        const DELAY_MS: u64 = 200;
1835
1836        let dir = TempDir::new().expect("failed to create temporary directory");
1837
1838        let dep_dir = dir.path().join("slowdep");
1839        fs::create_dir_all(&dep_dir).unwrap();
1840        fs::write(
1841            dep_dir.join("module.json"),
1842            r#"{"name":"slowdep","version":"1.0.0","license":"MIT"}"#,
1843        )
1844        .unwrap();
1845        for i in 0..IMPORT_COUNT {
1846            fs::write(
1847                dep_dir.join(format!("sub{i}.wdl")),
1848                "version 1.4\n\ntask noop {\n    command <<<>>>\n}\n",
1849            )
1850            .unwrap();
1851        }
1852        fs::write(
1853            dep_dir.join("index.wdl"),
1854            "version 1.4\n\ntask noop {\n    command <<<>>>\n}\n",
1855        )
1856        .unwrap();
1857
1858        let consumer_dir = dir.path().join("slowconsumer");
1859        fs::create_dir_all(&consumer_dir).unwrap();
1860        let dep_path_json = dep_dir.display().to_string().replace('\\', "/");
1861        fs::write(
1862            consumer_dir.join("module.json"),
1863            format!(
1864                r#"{{"name":"slowconsumer","version":"0.1.0","license":"MIT","dependencies":{{"slowdep":{{"path":"{dep_path_json}"}}}}}}"#
1865            ),
1866        )
1867        .unwrap();
1868
1869        let mut source = "version 1.4\n\n".to_string();
1870        for i in 0..IMPORT_COUNT {
1871            source.push_str(&format!("import slowdep/sub{i}\n"));
1872        }
1873        source.push_str("\nworkflow main {}\n");
1874        fs::write(consumer_dir.join("source.wdl"), &source).unwrap();
1875
1876        let config = Config::default()
1877            .with_feature_flags(crate::config::FeatureFlags::default().with_wdl_1_4());
1878        let resolver = Arc::new(SlowMockResolver {
1879            dep_path: dep_dir.clone(),
1880            delay: Duration::from_millis(DELAY_MS),
1881            active: AtomicUsize::new(0),
1882            max_active: AtomicUsize::new(0),
1883        });
1884        let resolver_trait: Arc<dyn wdl_modules::Resolver> = resolver.clone();
1885        let consumer_module = wdl_modules::module::Module::load_from_path(&consumer_dir)
1886            .expect("test consumer module should load");
1887        let resolution = ResolutionContext::enabled(resolver_trait, consumer_module);
1888        let analyzer = Analyzer::new_with_resolution(config, resolution, |(), _, _, _| async {});
1889        analyzer
1890            .add_document(path_to_uri(consumer_dir.join("source.wdl")).expect("should convert"))
1891            .await
1892            .expect("should add document");
1893
1894        let results = analyzer.analyze(()).await.unwrap();
1895
1896        assert!(!results.is_empty(), "should have analysis results");
1897        assert!(
1898            resolver.max_active.load(Ordering::SeqCst) > 1,
1899            "symbolic imports should materialize concurrently"
1900        );
1901    }
1902
1903    #[tokio::test]
1904    async fn it_deletes_documents() {
1905        let dir = TempDir::new().expect("failed to create temporary directory");
1906        let foo = dir.path().join("foo.wdl");
1907        fs::write(
1908            &foo,
1909            r#"version 1.1
1910import "bar.wdl"
1911
1912workflow test {
1913    call bar.test
1914}
1915"#,
1916        )
1917        .expect("failed to create test file");
1918
1919        let bar = dir.path().join("bar.wdl");
1920        fs::write(
1921            &bar,
1922            r#"version 1.1
1923workflow test {}
1924"#,
1925        )
1926        .expect("failed to create test file");
1927
1928        // Add both documents to the analyzer
1929        let analyzer = Analyzer::default();
1930        analyzer
1931            .add_directory(dir.path())
1932            .await
1933            .expect("should add documents");
1934
1935        // Analyze the documents
1936        let results = analyzer.analyze(()).await.unwrap();
1937        assert_eq!(results.len(), 2);
1938        assert!(results[0].document.diagnostics().next().is_none());
1939        assert!(results[1].document.diagnostics().next().is_none());
1940
1941        // Now delete bar.wdl, which foo.wdl depends on.
1942        //
1943        // Unlike removal, this should *force* the deletion of bar.wdl in the graph (and
1944        // thus cause errors in foo.wdl)
1945        fs::remove_file(&bar).expect("should delete file");
1946        analyzer
1947            .delete_documents(vec![path_to_uri(&bar).expect("should convert to URI")])
1948            .await
1949            .unwrap();
1950
1951        // Now foo.wdl should error
1952        let results = analyzer.analyze(()).await.unwrap();
1953        assert_eq!(results.len(), 1);
1954
1955        let has_import_failed_diagnostic = results[0]
1956            .document
1957            .diagnostics()
1958            .any(|d| d.message().contains("failed to import `bar.wdl`"));
1959        assert!(has_import_failed_diagnostic);
1960    }
1961}