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}