Skip to main content

wdl_analysis/
analyzer.rs

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