Skip to main content

deps_core/
ecosystem.rs

1use std::any::Any;
2use std::pin::Pin;
3use std::sync::Arc;
4use tower_lsp_server::ls_types::{
5    CodeAction, CodeLens, Diagnostic, DocumentLink, Hover, InlayHint, Position, Uri,
6};
7
8use crate::{
9    Registry,
10    completion::Completions,
11    lsp_helpers::{EcosystemFormatter, VersionData},
12};
13
14pub mod private {
15    pub trait Sealed {}
16}
17
18pub type BoxFuture<'a, T> = Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
19
20/// Canonical, exhaustive identifier for every package ecosystem the workspace supports.
21///
22/// [`Ecosystem::id`] returns a `&'static str` for registry lookups and document
23/// storage, but any code that needs to *branch* on ecosystem identity should match on
24/// this enum instead of re-deriving its own partial match over that string: an
25/// unhandled variant here is a compile error, while an unhandled string is a silent
26/// runtime bug (see the fix for issue #118, where two call sites silently mishandled
27/// ecosystems missing from an incomplete string match).
28///
29/// Deliberately **not** `#[non_exhaustive]`: adding a new ecosystem must force every
30/// exhaustive `match` on this type across the workspace to be updated at compile time.
31///
32/// # Examples
33///
34/// ```
35/// use deps_core::EcosystemId;
36///
37/// let id: EcosystemId = "npm".parse().unwrap();
38/// assert_eq!(id, EcosystemId::Npm);
39/// assert_eq!(id.id(), "npm");
40/// assert_eq!(id.to_string(), "npm");
41///
42/// assert!("unknown".parse::<EcosystemId>().is_err());
43/// ```
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub enum EcosystemId {
46    /// Rust Cargo ecosystem (`Cargo.toml`).
47    Cargo,
48    /// JavaScript/TypeScript npm ecosystem (`package.json`).
49    Npm,
50    /// Python PyPI ecosystem (`pyproject.toml`).
51    Pypi,
52    /// Go modules ecosystem (`go.mod`).
53    Go,
54    /// Ruby Bundler ecosystem (`Gemfile`).
55    Bundler,
56    /// Dart/Flutter pub ecosystem (`pubspec.yaml`).
57    Dart,
58    /// Java/Kotlin Maven ecosystem (`pom.xml`).
59    Maven,
60    /// PHP Composer ecosystem (`composer.json`).
61    Composer,
62    /// Java/Kotlin Gradle ecosystem (`build.gradle`, `build.gradle.kts`, version catalogs).
63    Gradle,
64    /// Swift Package Manager ecosystem (`Package.swift`).
65    Swift,
66    /// .NET NuGet ecosystem (`.csproj`/`.fsproj`/`.vbproj`, `Directory.Packages.props`, `packages.config`).
67    NuGet,
68    /// Deno ecosystem (`deno.json`/`deno.jsonc`), mixing `jsr:` and `npm:` specifiers.
69    Deno,
70    /// GitHub Actions ecosystem (`.github/workflows/*.yml`/`*.yaml`).
71    GithubActions,
72    /// GitLab CI/CD ecosystem (`.gitlab-ci.yml`, `.gitlab/ci/*.yml`/`*.yaml`).
73    GitlabCi,
74}
75
76impl EcosystemId {
77    /// Returns the canonical string identifier, matching [`Ecosystem::id`] for the
78    /// corresponding ecosystem implementation.
79    #[must_use]
80    pub const fn id(self) -> &'static str {
81        match self {
82            Self::Cargo => "cargo",
83            Self::Npm => "npm",
84            Self::Pypi => "pypi",
85            Self::Go => "go",
86            Self::Bundler => "bundler",
87            Self::Dart => "dart",
88            Self::Maven => "maven",
89            Self::Composer => "composer",
90            Self::Gradle => "gradle",
91            Self::Swift => "swift",
92            Self::NuGet => "nuget",
93            Self::Deno => "deno",
94            Self::GithubActions => "github-actions",
95            Self::GitlabCi => "gitlab-ci",
96        }
97    }
98
99    /// OSV.dev `package.ecosystem` value for this ecosystem, or `None` if
100    /// OSV has no equivalent.
101    ///
102    /// An exhaustive `match` rather than a lookup table: adding a 12th
103    /// ecosystem becomes a compile error here instead of a silent
104    /// zero-results ecosystem in OSV queries. Every arm below was verified
105    /// live against `https://api.osv.dev` (each returned real advisories for
106    /// a known-vulnerable version) — see `architecture.md` §2.
107    ///
108    /// # Examples
109    ///
110    /// ```
111    /// use deps_core::EcosystemId;
112    ///
113    /// assert_eq!(EcosystemId::Cargo.osv_ecosystem(), Some("crates.io"));
114    /// assert_eq!(EcosystemId::Gradle.osv_ecosystem(), Some("Maven"));
115    /// ```
116    #[must_use]
117    pub const fn osv_ecosystem(self) -> Option<&'static str> {
118        match self {
119            Self::Cargo => Some("crates.io"),
120            Self::Npm | Self::Deno => Some("npm"),
121            Self::Pypi => Some("PyPI"),
122            Self::Go => Some("Go"),
123            Self::Bundler => Some("RubyGems"),
124            Self::Dart => Some("Pub"),
125            Self::Maven | Self::Gradle => Some("Maven"),
126            Self::Composer => Some("Packagist"),
127            Self::Swift => Some("SwiftURL"),
128            Self::NuGet => Some("NuGet"),
129            Self::GithubActions => Some("GitHub Actions"),
130            // A git-tag/release pin has no OSV coordinate by name (mirrors
131            // `deps_gitlab_ci::formatter::GitlabCiFormatter`'s `OsvNaming` docs).
132            Self::GitlabCi => None,
133        }
134    }
135}
136
137impl std::fmt::Display for EcosystemId {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.write_str(self.id())
140    }
141}
142
143impl std::str::FromStr for EcosystemId {
144    type Err = crate::error::DepsError;
145
146    fn from_str(s: &str) -> Result<Self, Self::Err> {
147        match s {
148            "cargo" => Ok(Self::Cargo),
149            "npm" => Ok(Self::Npm),
150            "pypi" => Ok(Self::Pypi),
151            "go" => Ok(Self::Go),
152            "bundler" => Ok(Self::Bundler),
153            "dart" => Ok(Self::Dart),
154            "maven" => Ok(Self::Maven),
155            "composer" => Ok(Self::Composer),
156            "gradle" => Ok(Self::Gradle),
157            "swift" => Ok(Self::Swift),
158            "nuget" => Ok(Self::NuGet),
159            "deno" => Ok(Self::Deno),
160            "github-actions" => Ok(Self::GithubActions),
161            "gitlab-ci" => Ok(Self::GitlabCi),
162            _ => Err(crate::error::DepsError::UnsupportedEcosystem(s.to_string())),
163        }
164    }
165}
166
167/// Parse result trait containing dependencies and metadata.
168///
169/// Implementations hold ecosystem-specific dependency types
170/// but expose them through trait object interfaces.
171pub trait ParseResult: Send + Sync {
172    /// All dependencies found in the manifest
173    fn dependencies(&self) -> Vec<&dyn Dependency>;
174
175    /// Workspace root path (for monorepo support)
176    fn workspace_root(&self) -> Option<&std::path::Path>;
177
178    /// Document URI
179    fn uri(&self) -> &Uri;
180
181    /// Dependency lines whose registry-index resolution was blocked by a workspace-registry
182    /// reachability policy (spec `.local/specs/023-cargo-custom-registries/plan-1b.md` §1.7,
183    /// #443) — `(name_range, blocked host class, raw declared value)` triples, where the raw
184    /// value is the exact `registry`/`registry-index` alias or URL the dependency declared
185    /// (so two different blocked aliases render as two distinguishable messages, not one
186    /// byte-identical warning). Used by
187    /// [`crate::lsp_helpers::generate_diagnostics_from_cache`] to surface an
188    /// [`tower_lsp_server::ls_types::DiagnosticSeverity::INFORMATION`] diagnostic on the
189    /// blocked dependency's line so the block never degrades silently.
190    ///
191    /// Default empty — only `deps_cargo::parser::ParseResult` overrides this today;
192    /// every other ecosystem has no equivalent reachability policy to report.
193    fn blocked_registries(
194        &self,
195    ) -> Vec<(
196        tower_lsp_server::ls_types::Range,
197        crate::net_policy::HostClass,
198        String,
199    )> {
200        Vec::new()
201    }
202
203    /// Downcast to concrete type for ecosystem-specific operations
204    fn as_any(&self) -> &dyn Any;
205}
206
207/// Generic dependency trait.
208///
209/// All parsed dependencies must implement this for generic handler access.
210pub trait Dependency: Send + Sync {
211    /// Package name
212    fn name(&self) -> &crate::PackageName;
213
214    /// LSP range of the dependency name
215    fn name_range(&self) -> tower_lsp_server::ls_types::Range;
216
217    /// Version requirement string (e.g., "^1.0", ">=2.0")
218    fn version_requirement(&self) -> Option<&crate::VersionReq>;
219
220    /// LSP range of the version string
221    fn version_range(&self) -> Option<tower_lsp_server::ls_types::Range>;
222
223    /// Dependency source (registry, git, path)
224    fn source(&self) -> crate::parser::DependencySource;
225
226    /// Feature flags (ecosystem-specific, empty if not supported)
227    fn features(&self) -> &[String] {
228        &[]
229    }
230
231    /// LSP range of the features array (ecosystem-specific, None if not supported)
232    fn features_range(&self) -> Option<tower_lsp_server::ls_types::Range> {
233        None
234    }
235
236    /// Environment marker expression gating this dependency (e.g. PEP 508's
237    /// `python_version >= '3.8'`). Ecosystem-specific, `None` if not supported
238    /// or not present on this dependency.
239    fn markers(&self) -> Option<&str> {
240        None
241    }
242
243    /// LSP range of the environment marker expression (ecosystem-specific,
244    /// `None` if not supported or not present).
245    fn markers_range(&self) -> Option<tower_lsp_server::ls_types::Range> {
246        None
247    }
248
249    /// The raw manifest text spanned by [`version_range`](Dependency::version_range),
250    /// when it differs from [`version_requirement`](Dependency::version_requirement).
251    ///
252    /// Most ecosystems' `version_requirement()` is (up to whitespace) exactly the text at
253    /// `version_range()`, so the default `None` — telling callers to fall back to
254    /// `version_requirement()` — is correct for them. An ecosystem whose parser synthesizes
255    /// a comparator string from a bare literal (e.g. `deps-swift`'s `.exact("4.50.0")`
256    /// becoming requirement `"=4.50.0"` while `version_range()` still spans only `4.50.0`)
257    /// overrides this to return that literal, so `lsp_helpers`' literal-span guard
258    /// (`literal_span_matches`, used by both `generate_code_actions` and
259    /// `collect_update_all_edits`) compares `version_range`'s slice against the text it was
260    /// actually derived from instead of the synthesized comparator, which would otherwise
261    /// never match and silently suppress every fix action for that dependency.
262    ///
263    /// A sibling mechanism already exists for the same underlying problem: `deps-nuget`
264    /// wraps a bare source version as requirement `[1.0.0]`, and `literal_span_matches`
265    /// special-cases that bracket wrapping inline rather than going through this hook. This
266    /// method exists for the general case — an ecosystem whose synthesized requirement is
267    /// not a simple wrap (`deps-swift`'s comparator range is not recoverable from
268    /// `version_requirement()` by stripping fixed characters) needs its own literal, not a
269    /// transform `literal_span_matches` could hard-code.
270    ///
271    /// **Must not** be set when `version_range()` spans only part of a multi-literal
272    /// requirement whose other part(s) are not being rewritten — e.g. a `"lower"..<"upper"`
273    /// range, where `version_range()` covers only `lower`. Reporting `lower` as the literal
274    /// would let the guard pass and an edit rewrite `lower` alone, corrupting the
275    /// requirement (`deps-swift` leaves this `None` for both its range-literal forms for
276    /// exactly this reason — see `crates/deps-swift/src/parser.rs`'s range-form comments).
277    fn version_literal(&self) -> Option<&str> {
278        None
279    }
280
281    /// Downcast to concrete type
282    fn as_any(&self) -> &dyn Any;
283}
284
285/// Configuration for LSP inlay hints feature.
286#[derive(Debug, Clone)]
287pub struct EcosystemConfig {
288    /// Whether to show inlay hints for up-to-date dependencies
289    pub show_up_to_date_hints: bool,
290    /// Text to display for up-to-date dependencies
291    pub up_to_date_text: String,
292    /// Text to display for dependencies needing updates (use {} for version placeholder)
293    pub needs_update_text: String,
294    /// Text to display while loading registry data
295    pub loading_text: String,
296    /// Whether to show loading hints in inlay hints
297    pub show_loading_hints: bool,
298    /// Whether `network.offline` is set (issue #483): when `true` and no cached latest
299    /// version exists for a dependency, [`crate::lsp_helpers::generate_inlay_hints`]
300    /// shows an offline marker instead of silently falling back to the resolved-version
301    /// display, which would otherwise look identical to a normal pre-fetch state.
302    pub offline: bool,
303}
304
305impl Default for EcosystemConfig {
306    fn default() -> Self {
307        Self {
308            show_up_to_date_hints: true,
309            up_to_date_text: "✅".to_string(),
310            needs_update_text: "❌ {}".to_string(),
311            loading_text: "⏳".to_string(),
312            show_loading_hints: true,
313            offline: false,
314        }
315    }
316}
317
318/// Main trait that all ecosystem implementations must implement.
319///
320/// Each ecosystem (Cargo, npm, PyPI, etc.) provides its own implementation.
321/// This trait defines the contract for parsing manifests, fetching registry data,
322/// and generating LSP responses.
323///
324/// # Type Erasure
325///
326/// This trait uses `Box<dyn Trait>` instead of associated types to allow
327/// runtime polymorphism and dynamic ecosystem registration.
328///
329/// # Examples
330///
331/// ```no_run
332/// use deps_core::{Ecosystem, ParseResult, Registry, EcosystemConfig, PackageName, ConcreteVersion};
333/// use deps_core::completion::Completions;
334/// use deps_core::lsp_helpers::{
335///     DiagnosticMessages, DiagnosticPolicy, EcosystemFormatter, OsvNaming, PackageNaming,
336///     PackageRendering, RequirementResolution, SourcePolicy,
337/// };
338/// use std::sync::Arc;
339/// use std::any::Any;
340/// use tower_lsp_server::ls_types::{Uri, CompletionItem, Position};
341///
342/// struct MyFormatter;
343/// impl PackageNaming for MyFormatter {}
344/// impl PackageRendering for MyFormatter {
345///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String { version.to_string() }
346///     fn package_url(&self, name: &PackageName) -> String { format!("https://example.com/{name}") }
347/// }
348/// impl RequirementResolution for MyFormatter {}
349/// impl DiagnosticMessages for MyFormatter {}
350/// impl DiagnosticPolicy for MyFormatter {}
351/// impl SourcePolicy for MyFormatter {}
352/// impl OsvNaming for MyFormatter {}
353///
354/// struct MyEcosystem {
355///     registry: Arc<dyn Registry>,
356///     formatter: MyFormatter,
357/// }
358///
359/// impl deps_core::ecosystem::private::Sealed for MyEcosystem {}
360///
361/// impl Ecosystem for MyEcosystem {
362///     fn id(&self) -> &'static str { "my-ecosystem" }
363///     fn display_name(&self) -> &'static str { "My Ecosystem" }
364///     fn manifest_filenames(&self) -> &[&'static str] { &["my-manifest.toml"] }
365///
366///     fn parse_manifest<'a>(
367///         &'a self,
368///         _content: &'a str,
369///         _uri: &'a Uri,
370///     ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::error::Result<Box<dyn ParseResult>>> {
371///         Box::pin(async move { todo!() })
372///     }
373///
374///     fn registry(&self) -> Arc<dyn Registry> { self.registry.clone() }
375///
376///     fn formatter(&self) -> &dyn EcosystemFormatter { &self.formatter }
377///
378///     fn generate_completions<'a>(
379///         &'a self,
380///         _parse_result: &'a dyn ParseResult,
381///         _position: Position,
382///         _content: &'a str,
383///         _freshness: deps_core::FreshnessSettings,
384///     ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
385///         Box::pin(async move { Completions::default() })
386///     }
387///
388///     fn as_any(&self) -> &dyn Any { self }
389/// }
390/// ```
391pub trait Ecosystem: Send + Sync + private::Sealed {
392    /// Unique identifier (e.g., "cargo", "npm", "pypi")
393    ///
394    /// This identifier is used for ecosystem registration and routing.
395    fn id(&self) -> &'static str;
396
397    /// Human-readable name (e.g., "Cargo (Rust)", "npm (JavaScript)")
398    ///
399    /// This name is displayed in diagnostic messages and logs.
400    fn display_name(&self) -> &'static str;
401
402    /// Manifest filenames this ecosystem handles (e.g., ["Cargo.toml"])
403    ///
404    /// The ecosystem registry uses these filenames to route file URIs
405    /// to the appropriate ecosystem implementation.
406    fn manifest_filenames(&self) -> &[&'static str];
407
408    /// File extensions this ecosystem handles when the manifest basename is
409    /// not fixed (e.g. `[".csproj", ".fsproj"]` for NuGet project files).
410    ///
411    /// Consulted by [`crate::EcosystemRegistry::get_for_filename`] only after
412    /// an exact [`manifest_filenames`](Ecosystem::manifest_filenames) match
413    /// fails. Empty by default, indicating this ecosystem is routed solely by
414    /// exact filename.
415    fn manifest_extensions(&self) -> &[&'static str] {
416        &[]
417    }
418
419    /// Basename glob patterns this ecosystem handles, each containing exactly
420    /// one `*` wildcard (e.g. `["requirements*.txt"]`).
421    ///
422    /// Consulted by [`crate::EcosystemRegistry::get_for_filename`] as a third
423    /// routing stage, tried after an exact
424    /// [`manifest_filenames`](Ecosystem::manifest_filenames) match fails and
425    /// before [`manifest_extensions`](Ecosystem::manifest_extensions) — for
426    /// basenames that are neither fixed nor identified by extension alone
427    /// (e.g. `requirements.txt`, `requirements-dev.txt`). Empty by default.
428    /// Matching is case-sensitive, unlike the extension stage: these patterns
429    /// target canonically-lowercase filenames (pip, Renovate and Dependabot
430    /// all treat `requirements.txt` as lowercase), whereas the extension
431    /// stage exists specifically for Windows/MSBuild project files whose
432    /// case genuinely varies.
433    fn manifest_patterns(&self) -> &[&'static str] {
434        &[]
435    }
436
437    /// `(directory_path, file_suffix)` pairs identifying a file solely by its
438    /// containing directory path and suffix. `directory_path` may be a single
439    /// segment (e.g. `[("requirements", ".txt")]` for Python's
440    /// `requirements/base.txt` split-file layout) or multiple `/`-joined
441    /// segments (e.g. `[(".github/workflows", ".yml")]` for GitHub Actions
442    /// workflow files) — either way it is matched against the *tail* of the
443    /// file's directory path on segment boundaries, not just the immediate
444    /// parent, so a multi-segment pattern matches regardless of how many
445    /// ancestor directories precede it. Used when the basename alone carries
446    /// no ecosystem signal.
447    ///
448    /// Consulted by [`crate::EcosystemRegistry::get_for_uri`] only, after both
449    /// [`manifest_patterns`](Ecosystem::manifest_patterns) and
450    /// [`manifest_extensions`](Ecosystem::manifest_extensions) miss on the
451    /// basename — it needs the full path, so it is never reachable from
452    /// [`crate::EcosystemRegistry::get_for_filename`]. Empty by default.
453    fn manifest_directory_patterns(&self) -> &[(&'static str, &'static str)] {
454        &[]
455    }
456
457    /// Lock file filenames this ecosystem uses (e.g., ["Cargo.lock"])
458    ///
459    /// Used for file watching - LSP will monitor changes to these files
460    /// and refresh UI when they change. Returns empty slice if ecosystem
461    /// doesn't use lock files.
462    ///
463    /// # Default Implementation
464    ///
465    /// Returns empty slice by default, indicating no lock files are used.
466    fn lockfile_filenames(&self) -> &[&'static str] {
467        &[]
468    }
469
470    /// Non-lockfile config filenames this ecosystem resolves *during* [`Self::parse_manifest`]
471    /// (e.g. `["pnpm-workspace.yaml", ".npmrc"]` for npm's catalog and registry resolution),
472    /// whose values end up baked into a manifest's `ParseResult` rather than looked up
473    /// separately the way a [`Self::lockfile_provider`] is.
474    ///
475    /// Used for file watching alongside [`Self::lockfile_filenames`] — LSP monitors changes
476    /// to these files too, but reacts by fully re-parsing every open document of this
477    /// ecosystem (not merely refreshing cached resolved versions, since the value isn't kept
478    /// separately from the parse result to refresh in place). Returns empty slice by default.
479    fn watched_config_filenames(&self) -> &[&'static str] {
480        &[]
481    }
482
483    /// Parse a manifest file and return parsed result
484    ///
485    /// # Arguments
486    ///
487    /// * `content` - Raw file content
488    /// * `uri` - Document URI for position tracking
489    ///
490    /// # Errors
491    ///
492    /// Returns error if manifest cannot be parsed
493    fn parse_manifest<'a>(
494        &'a self,
495        content: &'a str,
496        uri: &'a Uri,
497    ) -> BoxFuture<'a, crate::error::Result<Box<dyn ParseResult>>>;
498
499    /// Get the registry client for this ecosystem
500    ///
501    /// The registry provides version lookup and package search capabilities.
502    fn registry(&self) -> Arc<dyn Registry>;
503
504    /// Get the lock file provider for this ecosystem.
505    ///
506    /// Returns `None` if the ecosystem doesn't support lock files.
507    /// Lock files provide resolved dependency versions without network requests.
508    fn lockfile_provider(&self) -> Option<Arc<dyn crate::lockfile::LockFileProvider>> {
509        None
510    }
511
512    /// Get the ecosystem-specific formatter for LSP response generation.
513    ///
514    /// The formatter handles version comparison, package URLs, and text formatting.
515    /// Override this to customize LSP response generation.
516    fn formatter(&self) -> &dyn EcosystemFormatter;
517
518    /// Generate inlay hints for the document.
519    ///
520    /// Default implementation delegates to `lsp_helpers::generate_inlay_hints`
521    /// using `self.formatter()`. Override only if custom behavior is needed.
522    fn generate_inlay_hints<'a>(
523        &'a self,
524        parse_result: &'a dyn ParseResult,
525        versions: VersionData<'a>,
526        loading_state: crate::LoadingState,
527        config: &'a EcosystemConfig,
528    ) -> BoxFuture<'a, Vec<InlayHint>> {
529        Box::pin(async move {
530            crate::lsp_helpers::generate_inlay_hints(
531                parse_result,
532                versions,
533                loading_state,
534                config,
535                self.formatter(),
536            )
537        })
538    }
539
540    /// Generate hover information for a position.
541    ///
542    /// Default implementation delegates to `lsp_helpers::generate_hover`
543    /// using `self.formatter()` and `self.registry()`.
544    fn generate_hover<'a>(
545        &'a self,
546        parse_result: &'a dyn ParseResult,
547        position: Position,
548        versions: VersionData<'a>,
549        freshness: crate::freshness::FreshnessSettings,
550    ) -> BoxFuture<'a, Option<Hover>> {
551        Box::pin(async move {
552            let registry = self.registry();
553            crate::lsp_helpers::generate_hover(
554                parse_result,
555                position,
556                versions,
557                registry.as_ref(),
558                self.formatter(),
559                freshness,
560                crate::freshness::PublishTime::now(),
561            )
562            .await
563        })
564    }
565
566    /// Generate code actions for a position.
567    ///
568    /// Default implementation delegates to `lsp_helpers::generate_code_actions`
569    /// using `self.formatter()` and `self.registry()`. `versions` carries the
570    /// same OSV scan results `generate_hover` and `generate_diagnostics` use,
571    /// so a vulnerable dependency at `position` gets a "fix vulnerability"
572    /// quickfix alongside the plain version-update actions. `content` is the
573    /// manifest source, needed to guard against rewriting a `version_range`
574    /// that no longer slices to its declared requirement text (see
575    /// `lsp_helpers::literal_span_matches`).
576    fn generate_code_actions<'a>(
577        &'a self,
578        parse_result: &'a dyn ParseResult,
579        position: Position,
580        uri: &'a Uri,
581        versions: VersionData<'a>,
582        content: &'a str,
583    ) -> BoxFuture<'a, Vec<CodeAction>> {
584        Box::pin(async move {
585            let registry = self.registry();
586            crate::lsp_helpers::generate_code_actions(
587                parse_result,
588                position,
589                uri,
590                versions,
591                content,
592                registry.as_ref(),
593                self.formatter(),
594            )
595            .await
596        })
597    }
598
599    /// Generate diagnostics for the document.
600    ///
601    /// Default implementation delegates to `lsp_helpers::generate_diagnostics_from_cache`
602    /// using `self.formatter()`.
603    fn generate_diagnostics<'a>(
604        &'a self,
605        parse_result: &'a dyn ParseResult,
606        versions: VersionData<'a>,
607        uri: &'a Uri,
608        freshness: crate::freshness::FreshnessSettings,
609        severities: crate::lsp_helpers::DiagnosticSeverities,
610    ) -> BoxFuture<'a, Vec<Diagnostic>> {
611        Box::pin(async move {
612            crate::lsp_helpers::generate_diagnostics_from_cache(
613                parse_result,
614                versions,
615                self.formatter(),
616                uri,
617                freshness,
618                severities,
619                crate::freshness::PublishTime::now(),
620            )
621        })
622    }
623
624    /// Generate `textDocument/documentLink` targets for the document.
625    ///
626    /// A document link is a clickable reference from a byte range in this
627    /// manifest to another resource — e.g. a `-r other.txt` / `-c
628    /// constraints.txt` reference inside a pip requirements file, resolved
629    /// to the absolute file it points at. Purely local (no registry access),
630    /// so unlike the other `generate_*` methods this is synchronous rather
631    /// than a [`BoxFuture`]. Empty by default: most ecosystems' manifest
632    /// formats have no such intra-file-graph references.
633    fn generate_document_links(
634        &self,
635        _parse_result: &dyn ParseResult,
636        _uri: &Uri,
637    ) -> Vec<DocumentLink> {
638        Vec::new()
639    }
640
641    /// Generate the "Update N outdated dependencies" code lens for the document.
642    ///
643    /// Default implementation delegates to `lsp_helpers::generate_code_lenses` using
644    /// `self.formatter()`. Override only if custom behavior is needed.
645    fn generate_code_lenses<'a>(
646        &'a self,
647        parse_result: &'a dyn ParseResult,
648        content: &'a str,
649        versions: VersionData<'a>,
650        uri: &'a Uri,
651        command_id: &'a str,
652    ) -> BoxFuture<'a, Vec<CodeLens>> {
653        Box::pin(async move {
654            crate::lsp_helpers::generate_code_lenses(
655                parse_result,
656                content,
657                versions,
658                self.formatter(),
659                uri,
660                command_id,
661            )
662        })
663    }
664
665    /// Generate completions for a position.
666    ///
667    /// Provides autocomplete suggestions for package names and versions.
668    ///
669    /// `freshness.enabled` gates whether version completion items carry a
670    /// relative-age `label_details` suffix (issue #145); implementations that
671    /// delegate to [`crate::completion::complete_versions_generic`] get this for
672    /// free by threading `freshness` through.
673    ///
674    /// The returned [`Completions::is_incomplete`] must reflect *this specific call*
675    /// (the completion context actually served), not a static worst case for the
676    /// ecosystem as a whole (#427): a package-name search over an unranked,
677    /// truncated index should report `true`, while a version completion or any
678    /// other exhaustive context in the same manifest must report `false`, even for
679    /// an ecosystem where some contexts are incomplete and others are not.
680    fn generate_completions<'a>(
681        &'a self,
682        parse_result: &'a dyn ParseResult,
683        position: Position,
684        content: &'a str,
685        freshness: crate::FreshnessSettings,
686    ) -> BoxFuture<'a, Completions>;
687
688    /// Whether this ecosystem's package-name search may return a truncated view of
689    /// a larger candidate set (see e.g. `PypiRegistry::search`'s doc comment).
690    ///
691    /// [`generate_completions`](Ecosystem::generate_completions) already reports
692    /// this precisely per call via [`Completions::is_incomplete`] whenever a real
693    /// completion context is available. This method exists only for the two
694    /// `deps-lsp` code paths that cannot compute that precise per-call signal
695    /// because no context has been resolved yet:
696    ///
697    /// - the raw-text fallback search (`fallback_completion`), which always
698    ///   performs a package-name lookup via [`crate::Registry::search`] regardless
699    ///   of what completion context (or lack thereof) triggered it;
700    /// - the document-not-loaded early return, before any `ParseResult` — and so
701    ///   any completion context — exists to call `generate_completions` with.
702    ///
703    /// Unlike the ecosystem-wide `completions_are_incomplete()` flag this method
704    /// superseded (#419, removed in #427), it never gates the *primary*
705    /// `generate_completions` response — only these two context-less fallbacks.
706    /// Default `false` preserves existing behavior for every ecosystem whose
707    /// package-name search is always exhaustive.
708    fn package_search_is_incomplete(&self) -> bool {
709        false
710    }
711
712    /// Support for downcasting to concrete ecosystem type
713    ///
714    /// This allows ecosystem-specific operations when needed.
715    fn as_any(&self) -> &dyn Any;
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    use std::assert_matches;
723
724    #[test]
725    fn test_ecosystem_id_roundtrip() {
726        const ALL: &[EcosystemId] = &[
727            EcosystemId::Cargo,
728            EcosystemId::Npm,
729            EcosystemId::Pypi,
730            EcosystemId::Go,
731            EcosystemId::Bundler,
732            EcosystemId::Dart,
733            EcosystemId::Maven,
734            EcosystemId::Composer,
735            EcosystemId::Gradle,
736            EcosystemId::Swift,
737            EcosystemId::NuGet,
738            EcosystemId::Deno,
739            EcosystemId::GithubActions,
740            EcosystemId::GitlabCi,
741        ];
742
743        for id in ALL {
744            let parsed: EcosystemId = id.id().parse().unwrap();
745            assert_eq!(parsed, *id);
746            assert_eq!(id.to_string(), id.id());
747        }
748    }
749
750    #[test]
751    fn test_osv_ecosystem_mapping_pinned() {
752        let expected: &[(EcosystemId, &str)] = &[
753            (EcosystemId::Cargo, "crates.io"),
754            (EcosystemId::Npm, "npm"),
755            (EcosystemId::Pypi, "PyPI"),
756            (EcosystemId::Go, "Go"),
757            (EcosystemId::Bundler, "RubyGems"),
758            (EcosystemId::Dart, "Pub"),
759            (EcosystemId::Maven, "Maven"),
760            (EcosystemId::Composer, "Packagist"),
761            (EcosystemId::Gradle, "Maven"),
762            (EcosystemId::Swift, "SwiftURL"),
763            (EcosystemId::NuGet, "NuGet"),
764            (EcosystemId::Deno, "npm"),
765            (EcosystemId::GithubActions, "GitHub Actions"),
766        ];
767
768        for (id, expected_str) in expected {
769            assert_eq!(
770                id.osv_ecosystem(),
771                Some(*expected_str),
772                "unexpected OSV ecosystem string for {id:?}"
773            );
774        }
775
776        // A git-tag/release pin has no OSV coordinate by name (see `osv_ecosystem`'s doc).
777        assert_eq!(EcosystemId::GitlabCi.osv_ecosystem(), None);
778    }
779
780    #[test]
781    fn test_ecosystem_id_from_str_unknown() {
782        let err = "unknown".parse::<EcosystemId>().unwrap_err();
783        assert_matches!(err, crate::error::DepsError::UnsupportedEcosystem(s) if s == "unknown");
784    }
785
786    #[test]
787    fn test_ecosystem_config_default() {
788        let config = EcosystemConfig::default();
789        assert!(config.show_up_to_date_hints);
790        assert_eq!(config.up_to_date_text, "✅");
791        assert_eq!(config.needs_update_text, "❌ {}");
792    }
793
794    #[test]
795    fn test_ecosystem_config_custom() {
796        let config = EcosystemConfig {
797            show_up_to_date_hints: false,
798            up_to_date_text: "OK".to_string(),
799            needs_update_text: "Update to {}".to_string(),
800            loading_text: "Loading...".to_string(),
801            show_loading_hints: false,
802            offline: false,
803        };
804        assert!(!config.show_up_to_date_hints);
805        assert_eq!(config.up_to_date_text, "OK");
806        assert_eq!(config.needs_update_text, "Update to {}");
807    }
808
809    #[test]
810    fn test_ecosystem_config_clone() {
811        let config1 = EcosystemConfig::default();
812        let config2 = config1.clone();
813        assert_eq!(config1.up_to_date_text, config2.up_to_date_text);
814        assert_eq!(config1.show_up_to_date_hints, config2.show_up_to_date_hints);
815        assert_eq!(config1.needs_update_text, config2.needs_update_text);
816    }
817
818    #[test]
819    fn test_dependency_default_features() {
820        struct MockDep;
821        impl Dependency for MockDep {
822            fn name(&self) -> &crate::PackageName {
823                static NAME: std::sync::LazyLock<crate::PackageName> =
824                    std::sync::LazyLock::new(|| crate::PackageName::new("test"));
825                &NAME
826            }
827            fn name_range(&self) -> tower_lsp_server::ls_types::Range {
828                tower_lsp_server::ls_types::Range::default()
829            }
830            fn version_requirement(&self) -> Option<&crate::VersionReq> {
831                None
832            }
833            fn version_range(&self) -> Option<tower_lsp_server::ls_types::Range> {
834                None
835            }
836            fn source(&self) -> crate::parser::DependencySource {
837                crate::parser::DependencySource::Registry
838            }
839            fn as_any(&self) -> &dyn std::any::Any {
840                self
841            }
842        }
843
844        let dep = MockDep;
845        assert_eq!(dep.features(), &[] as &[String]);
846    }
847}