deps_core/ecosystem.rs
1use std::any::Any;
2use std::pin::Pin;
3use std::sync::Arc;
4#[cfg(feature = "lsp-responses")]
5use tower_lsp_server::ls_types::{
6 CodeAction, CodeLens, DocumentLink, Hover, InlayHint, Position, TextEdit,
7};
8
9#[cfg(feature = "lsp-responses")]
10use crate::completion::Completions;
11use crate::{
12 Registry,
13 diagnostic::Diagnostic,
14 lsp_helpers::{EcosystemFormatter, VersionData},
15 position::Range,
16 registry::Metadata,
17};
18
19/// Soft-sealing mechanism for [`Ecosystem`], shared by every ecosystem crate in this
20/// workspace (`deps-cargo`, `deps-npm`, ...) — each implements [`private::Sealed`] for
21/// its own ecosystem type.
22///
23/// Rust's privacy system has no "visible to this workspace, not beyond" level: `pub(crate)`
24/// would restrict `Sealed` to `deps-core` alone, breaking every sibling ecosystem crate's
25/// `impl Sealed for ...`, since each of those is a separate compilation unit. Making this
26/// module `pub` is therefore required, not a mistake — but it means [`private::Sealed`] is
27/// technically nameable, and implementable, from any crate that depends on `deps-core`, not
28/// only from within this workspace. `#[doc(hidden)]` keeps it out of generated public docs to
29/// avoid inviting that. There is no compiler-enforced wall against it: this is a documented
30/// contract enforced by code review, not a hard guarantee, and [`Ecosystem`]'s default
31/// methods may gain new required behavior without that counting as a breaking change for an
32/// external implementor who ignored this notice.
33#[doc(hidden)]
34pub mod private {
35 /// Marker trait every ecosystem crate in this workspace implements for its own
36 /// ecosystem type, per [`super::private`]'s module doc.
37 ///
38 /// [`Ecosystem`](super::Ecosystem) requires `Self: Sealed`, which is how the
39 /// trait stays extensible (new default methods can be added without
40 /// breaking in-workspace implementors).
41 pub trait Sealed {}
42}
43
44/// A boxed, type-erased future used throughout the [`Ecosystem`] trait's async methods.
45pub type BoxFuture<'a, T> = Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
46
47/// Runs `ecosystem.parse_manifest(content, uri)` on the blocking-thread pool instead of the
48/// calling tokio worker.
49///
50/// Every [`Ecosystem::parse_manifest`] implementation is synchronous work wrapped in an
51/// immediately-ready `async move` block (verified: no implementation contains a real
52/// `.await`). Driving that future to completion via [`tokio::runtime::Handle::block_on`]
53/// from inside [`tokio::task::spawn_blocking`] moves the CPU-bound parse off the tokio
54/// worker without changing the trait's async signature — mirrors
55/// [`crate::lockfile::read_and_parse_lockfile`]'s `spawn_blocking` pattern for the
56/// equivalent lock-file path (#723/#730). This closes #743: a large minified manifest
57/// parsed synchronously on an LSP request's tokio worker would otherwise stall every other
58/// request sharing that worker for the parse's full duration.
59///
60/// `content`/`uri` are cloned internally rather than moved+returned: the caller's own copy
61/// must remain valid (to store into `DocumentState`) even if the blocking task panics, and a
62/// panic here is already an anomalous condition, not a path worth optimizing a clone away
63/// for. Manifest sizes are capped (10MB) and typically far smaller, so the clone's added
64/// *time* cost is negligible next to the parse itself and the thread-pool hop. It does
65/// briefly double transient *peak memory* (both the caller's and the cloned copy live at
66/// once) on exactly the large-manifest path this function targets; a future `Arc<str>`
67/// threaded through the caller would remove that duplication if it ever proves significant.
68///
69/// `Handle::block_on` called from inside a `spawn_blocking` closure is a documented,
70/// supported tokio pattern (distinct from `Runtime::block_on`, which panics if called from
71/// within a runtime). Since the future never actually yields (`Poll::Pending`), `block_on`
72/// resolves on the first poll — negligible overhead beyond the `spawn_blocking` thread-hop
73/// itself.
74///
75/// # Errors
76///
77/// Returns whatever [`Ecosystem::parse_manifest`] returns for a malformed manifest, or a
78/// [`crate::error::DepsError::ParseError`] if the blocking task panics or is cancelled.
79///
80/// # Examples
81///
82/// ```no_run
83/// use deps_core::Ecosystem;
84/// use std::sync::Arc;
85/// use url::Url;
86///
87/// # async fn example(ecosystem: Arc<dyn Ecosystem>, uri: Url) -> deps_core::error::Result<()> {
88/// let parsed = deps_core::ecosystem::parse_manifest_blocking(&ecosystem, "content", &uri).await?;
89/// println!("{} dependencies", parsed.dependencies().len());
90/// # Ok(())
91/// # }
92/// ```
93pub async fn parse_manifest_blocking(
94 ecosystem: &Arc<dyn Ecosystem>,
95 content: &str,
96 uri: &url::Url,
97) -> crate::error::Result<Box<dyn ParseResult>> {
98 let ecosystem = Arc::clone(ecosystem);
99 let owned_content = content.to_owned();
100 let owned_uri = uri.clone();
101 let handle = tokio::runtime::Handle::current();
102 let parsed = tokio::task::spawn_blocking(move || {
103 handle.block_on(ecosystem.parse_manifest(&owned_content, &owned_uri))
104 })
105 .await
106 .map_err(|e| crate::error::DepsError::ParseError {
107 file_type: format!("manifest at {uri:?}"),
108 source: Box::new(std::io::Error::other(e)),
109 })??;
110
111 // Single chokepoint for #796: every ecosystem's parse result is capped here, once,
112 // rather than each ecosystem crate's own parser bounding its dependency count
113 // independently.
114 Ok(crate::dependency_cap::cap_dependencies(
115 parsed,
116 crate::dependency_cap::MAX_DEPENDENCIES_PER_DOCUMENT,
117 ))
118}
119
120/// Defines [`EcosystemId`] together with [`EcosystemId::ALL`], [`EcosystemId::id`] and its
121/// [`std::str::FromStr`] impl from one variant list, so the four can never drift apart
122/// (#758) — a 15th ecosystem is added in exactly this one place or the workspace does not
123/// compile. `macro_rules!` is textual-order scoped, so this definition must precede its
124/// invocation below.
125macro_rules! ecosystem_ids {
126 ( $( $(#[$vmeta:meta])* $variant:ident => $id:literal ),+ $(,)? ) => {
127 /// Canonical, exhaustive identifier for every package ecosystem the workspace supports.
128 ///
129 /// [`Ecosystem::id`] returns a `&'static str` for registry lookups and document
130 /// storage, but any code that needs to *branch* on ecosystem identity should match on
131 /// this enum instead of re-deriving its own partial match over that string: an
132 /// unhandled variant here is a compile error, while an unhandled string is a silent
133 /// runtime bug (see the fix for issue #118, where two call sites silently mishandled
134 /// ecosystems missing from an incomplete string match).
135 ///
136 /// Deliberately **not** `#[non_exhaustive]`: adding a new ecosystem must force every
137 /// exhaustive `match` on this type across the workspace to be updated at compile time.
138 ///
139 /// # Examples
140 ///
141 /// ```
142 /// use deps_core::EcosystemId;
143 ///
144 /// let id: EcosystemId = "npm".parse().unwrap();
145 /// assert_eq!(id, EcosystemId::Npm);
146 /// assert_eq!(id.id(), "npm");
147 /// assert_eq!(id.to_string(), "npm");
148 ///
149 /// assert!("unknown".parse::<EcosystemId>().is_err());
150 /// ```
151 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
152 pub enum EcosystemId {
153 $( $(#[$vmeta])* $variant, )+
154 }
155
156 impl EcosystemId {
157 /// Every [`EcosystemId`] variant, in declaration order.
158 ///
159 /// Generated from the same list as [`Self::id`] and this type's
160 /// [`std::str::FromStr`] impl, so none of the three can silently drift out of
161 /// sync with the enum's variants (#758) — a forgotten 15th variant here is a
162 /// missing-match compile error, not a silently incomplete set.
163 pub const ALL: &'static [Self] = &[ $( Self::$variant ),+ ];
164
165 /// Returns the canonical string identifier, matching [`Ecosystem::id`] for the
166 /// corresponding ecosystem implementation.
167 #[must_use]
168 pub const fn id(self) -> &'static str {
169 match self {
170 $( Self::$variant => $id, )+
171 }
172 }
173 }
174
175 impl std::str::FromStr for EcosystemId {
176 type Err = crate::error::DepsError;
177
178 fn from_str(s: &str) -> Result<Self, Self::Err> {
179 match s {
180 $( $id => Ok(Self::$variant), )+
181 _ => Err(crate::error::DepsError::UnsupportedEcosystem(s.to_string())),
182 }
183 }
184 }
185 };
186}
187
188ecosystem_ids! {
189 /// Rust Cargo ecosystem (`Cargo.toml`).
190 Cargo => "cargo",
191 /// JavaScript/TypeScript npm ecosystem (`package.json`).
192 Npm => "npm",
193 /// Python PyPI ecosystem (`pyproject.toml`).
194 Pypi => "pypi",
195 /// Go modules ecosystem (`go.mod`).
196 Go => "go",
197 /// Ruby Bundler ecosystem (`Gemfile`).
198 Bundler => "bundler",
199 /// Dart/Flutter pub ecosystem (`pubspec.yaml`).
200 Dart => "dart",
201 /// Java/Kotlin Maven ecosystem (`pom.xml`).
202 Maven => "maven",
203 /// PHP Composer ecosystem (`composer.json`).
204 Composer => "composer",
205 /// Java/Kotlin Gradle ecosystem (`build.gradle`, `build.gradle.kts`, version catalogs).
206 Gradle => "gradle",
207 /// Swift Package Manager ecosystem (`Package.swift`).
208 Swift => "swift",
209 /// .NET NuGet ecosystem (`.csproj`/`.fsproj`/`.vbproj`, `Directory.Packages.props`, `packages.config`).
210 NuGet => "nuget",
211 /// Deno ecosystem (`deno.json`/`deno.jsonc`), mixing `jsr:` and `npm:` specifiers.
212 Deno => "deno",
213 /// GitHub Actions ecosystem (`.github/workflows/*.yml`/`*.yaml`).
214 GithubActions => "github-actions",
215 /// GitLab CI/CD ecosystem (`.gitlab-ci.yml`, `.gitlab/ci/*.yml`/`*.yaml`).
216 GitlabCi => "gitlab-ci",
217}
218
219impl EcosystemId {
220 /// OSV.dev `package.ecosystem` value for this ecosystem, or `None` if
221 /// OSV has no equivalent.
222 ///
223 /// An exhaustive `match` rather than a lookup table: adding a 12th
224 /// ecosystem becomes a compile error here instead of a silent
225 /// zero-results ecosystem in OSV queries. Every arm below was verified
226 /// live against `https://api.osv.dev` (each returned real advisories for
227 /// a known-vulnerable version) — see `architecture.md` §2.
228 ///
229 /// Hand-written rather than generated by the `ecosystem_ids!` macro: this mapping is not 1:1
230 /// (`Npm | Deno` both map to `"npm"`, `Maven | Gradle` both map to `"Maven"`, `GitlabCi`
231 /// maps to `None`), so folding it into the shared list would need per-arm escape
232 /// hatches that cost more than they save — and match exhaustiveness already forces
233 /// this `match` to be updated for a new variant, which is the property that matters.
234 ///
235 /// # Examples
236 ///
237 /// ```
238 /// use deps_core::EcosystemId;
239 ///
240 /// assert_eq!(EcosystemId::Cargo.osv_ecosystem(), Some("crates.io"));
241 /// assert_eq!(EcosystemId::Gradle.osv_ecosystem(), Some("Maven"));
242 /// ```
243 #[must_use]
244 pub const fn osv_ecosystem(self) -> Option<&'static str> {
245 match self {
246 Self::Cargo => Some("crates.io"),
247 Self::Npm | Self::Deno => Some("npm"),
248 Self::Pypi => Some("PyPI"),
249 Self::Go => Some("Go"),
250 Self::Bundler => Some("RubyGems"),
251 Self::Dart => Some("Pub"),
252 Self::Maven | Self::Gradle => Some("Maven"),
253 Self::Composer => Some("Packagist"),
254 Self::Swift => Some("SwiftURL"),
255 Self::NuGet => Some("NuGet"),
256 Self::GithubActions => Some("GitHub Actions"),
257 // A git-tag/release pin has no OSV coordinate by name (mirrors
258 // `deps_gitlab_ci::formatter::GitlabCiFormatter`'s `OsvNaming` docs).
259 Self::GitlabCi => None,
260 }
261 }
262}
263
264impl std::fmt::Display for EcosystemId {
265 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266 f.write_str(self.id())
267 }
268}
269
270/// One dependency declaration whose registry-index resolution was blocked by a
271/// workspace-registry reachability policy — returned by [`ParseResult::blocked_registries`].
272///
273/// A named struct rather than a positional tuple (#944 M9): [`Self::raw_value`] and
274/// [`Self::declaration_key`] are both `String`s, so a positional tuple would let them be
275/// silently swapped at any call site with no compile error — the same shape as the value/key
276/// mixup this type exists to rule out.
277#[derive(Clone)]
278pub struct BlockedRegistryOccurrence {
279 /// Range of the affected dependency's name in the manifest.
280 pub range: Range,
281 /// The blocked host's classification.
282 pub class: crate::net_policy::HostClass,
283 /// The exact `registry`/`registry-index` alias or URL the dependency declared, so two
284 /// different blocked aliases render as two distinguishable diagnostic messages, not one
285 /// byte-identical warning.
286 pub raw_value: String,
287 /// Implementation-opaque string identifying *which underlying config declaration*
288 /// produced this entry — never the resolved value itself (code-review correctness fix,
289 /// #925). See [`ParseResult::blocked_registries`]'s doc for why this must be distinct
290 /// from [`Self::raw_value`].
291 pub declaration_key: String,
292}
293
294impl std::fmt::Debug for BlockedRegistryOccurrence {
295 /// Manual, not derived: `raw_value`/`declaration_key` are raw, potentially
296 /// credential-shaped strings, already redacted the same way on the Display path
297 /// (`lsp_helpers::diagnostics::build_blocked_registry_diagnostic`) but not on `Debug`
298 /// (CWE-532, #1222).
299 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300 f.debug_struct("BlockedRegistryOccurrence")
301 .field("range", &self.range)
302 .field("class", &self.class)
303 .field(
304 "raw_value",
305 &crate::net_policy::RedactedUrl::new(&self.raw_value),
306 )
307 .field(
308 "declaration_key",
309 &crate::net_policy::redact_declaration_key(&self.declaration_key),
310 )
311 .finish()
312 }
313}
314
315/// One host-class/raw-value/declaration-key classification an ecosystem config's own
316/// `blocked_class_for` helper produces.
317///
318/// One layer below the [`ParseResult::blocked_registries`] trait boundary — same field shape as
319/// [`BlockedRegistryOccurrence`] minus [`BlockedRegistryOccurrence::range`], which the config
320/// layer never knows: the caller attaches it per affected dependency via
321/// [`Self::into_occurrence`].
322///
323/// A named struct rather than a positional tuple (#966): [`Self::raw_value`] and
324/// [`Self::declaration_key`] are both `String`s, so a positional tuple would let them be
325/// silently swapped at any call site with no compile error — the exact field-swap risk
326/// [`BlockedRegistryOccurrence`] itself was already converted off of (#944 M9). Implemented by
327/// `deps_nuget::config::NuGetConfig::blocked_class_for`, `deps_npm::config::NpmConfig::blocked_class_for`,
328/// and `deps_pypi::config::PypiIndexConfig::blocked_class_for`.
329#[derive(Clone, PartialEq, Eq)]
330pub struct BlockedSourceClass {
331 /// The blocked host's classification.
332 pub class: crate::net_policy::HostClass,
333 /// The exact declared value (URL/alias) that resolved to a blocked host.
334 pub raw_value: String,
335 /// Implementation-opaque string identifying which underlying config declaration produced
336 /// this entry — see [`BlockedRegistryOccurrence::declaration_key`]'s doc for why this must
337 /// stay distinct from [`Self::raw_value`].
338 pub declaration_key: String,
339}
340
341impl std::fmt::Debug for BlockedSourceClass {
342 /// Manual, not derived: same `raw_value`/`declaration_key` leak class and redactor pairing
343 /// as [`BlockedRegistryOccurrence`]'s own manual `Debug` impl (CWE-532, #1222).
344 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345 f.debug_struct("BlockedSourceClass")
346 .field("class", &self.class)
347 .field(
348 "raw_value",
349 &crate::net_policy::RedactedUrl::new(&self.raw_value),
350 )
351 .field(
352 "declaration_key",
353 &crate::net_policy::redact_declaration_key(&self.declaration_key),
354 )
355 .finish()
356 }
357}
358
359impl BlockedSourceClass {
360 /// Attaches the affected dependency's `range` to produce the [`BlockedRegistryOccurrence`]
361 /// a `ParseResult::blocked_registries()` override reports.
362 ///
363 /// # Examples
364 ///
365 /// ```
366 /// use deps_core::net_policy::HostClass;
367 /// use deps_core::BlockedSourceClass;
368 /// use deps_core::position::{Position, Range};
369 ///
370 /// let class = BlockedSourceClass {
371 /// class: HostClass::Loopback,
372 /// raw_value: "https://internal.example/index".to_string(),
373 /// declaration_key: "primary".to_string(),
374 /// };
375 /// let range = Range::new(Position::new(0, 0), Position::new(0, 4));
376 /// let occurrence = class.into_occurrence(range);
377 /// assert_eq!(occurrence.declaration_key, "primary");
378 /// ```
379 #[must_use]
380 pub fn into_occurrence(self, range: Range) -> BlockedRegistryOccurrence {
381 BlockedRegistryOccurrence {
382 range,
383 class: self.class,
384 raw_value: self.raw_value,
385 declaration_key: self.declaration_key,
386 }
387 }
388}
389
390/// Parse result trait containing dependencies and metadata.
391///
392/// Implementations hold ecosystem-specific dependency types
393/// but expose them through trait object interfaces.
394pub trait ParseResult: Send + Sync {
395 /// All dependencies found in the manifest
396 fn dependencies(&self) -> Vec<&dyn Dependency>;
397
398 /// Workspace root path (for monorepo support)
399 fn workspace_root(&self) -> Option<&std::path::Path>;
400
401 /// Document URI
402 fn uri(&self) -> &url::Url;
403
404 /// Dependency lines whose registry-index resolution was blocked by a workspace-registry
405 /// reachability policy (spec `.local/specs/023-cargo-custom-registries/plan-1b.md` §1.7,
406 /// #443). Used by [`crate::lsp_helpers::generate_diagnostics_from_cache`] to surface an
407 /// [`tower_lsp_server::ls_types::DiagnosticSeverity::INFORMATION`] diagnostic on the
408 /// blocked dependency's line so the block never degrades silently.
409 ///
410 /// [`BlockedRegistryOccurrence::declaration_key`] is an implementation-opaque string
411 /// identifying *which underlying config declaration* produced this entry — never the
412 /// resolved value itself (code-review correctness fix, #925): a config-global declaration
413 /// (e.g. a single blocked npm `.npmrc` `registry=` line, or one `NuGet.Config` `<add key>`
414 /// source) applies identically to every dependency it affects, so
415 /// [`crate::lsp_helpers::generate_diagnostics_from_cache`] groups by this key alone to
416 /// collapse that fan-out — but two *independently* declared sources that merely happen to
417 /// share the same raw value/host class (e.g. an npm top-level `registry=` and an unrelated
418 /// `@scope:registry=` both blocked to the same URL) must carry *different* keys, or the
419 /// second declaration's own dependents would silently lose their diagnostic even though
420 /// nothing links the two declarations. An implementor with no realistic risk of two
421 /// distinct declarations coincidentally sharing a raw value (e.g. `deps_cargo`, where the
422 /// raw value is always either a unique alias name or the literal URL the dependency itself
423 /// wrote) may reuse the raw value as its own declaration key.
424 ///
425 /// Default empty. An ecosystem overrides this when it enforces `net_policy` reachability
426 /// restrictions on a configurable registry/proxy host via
427 /// [`crate::net_policy::validate_index_url`] with [`crate::net_policy::PolicyGate::Enforce`]
428 /// — an ecosystem with no such configurable host (nothing a workspace or user config could
429 /// point at an unreachable-by-policy host) has nothing to report here and keeps the default.
430 fn blocked_registries(&self) -> Vec<BlockedRegistryOccurrence> {
431 Vec::new()
432 }
433
434 /// Downcast to concrete type for ecosystem-specific operations
435 fn as_any(&self) -> &dyn Any;
436
437 /// Dependency-count ceiling info (#796): `Some((kept, total))` once this document's
438 /// dependency count was truncated to [`crate::dependency_cap::MAX_DEPENDENCIES_PER_DOCUMENT`]
439 /// by [`parse_manifest_blocking`], `None` otherwise.
440 ///
441 /// The default (and every concrete ecosystem parser) returns `None` — only the
442 /// wrapper [`parse_manifest_blocking`] installs when truncating overrides it, so this
443 /// needs no per-ecosystem implementation. Read by
444 /// `deps_core::lsp_helpers::diagnostics`' truncation notice.
445 fn dependency_truncation(&self) -> Option<(usize, usize)> {
446 None
447 }
448}
449
450/// Generic dependency trait.
451///
452/// All parsed dependencies must implement this for generic handler access.
453pub trait Dependency: Send + Sync {
454 /// Package name
455 fn name(&self) -> &crate::PackageName;
456
457 /// Range of the dependency name
458 fn name_range(&self) -> Range;
459
460 /// Version requirement string (e.g., "^1.0", ">=2.0")
461 fn version_requirement(&self) -> Option<&crate::VersionReq>;
462
463 /// Range of the version string
464 fn version_range(&self) -> Option<Range>;
465
466 /// Dependency source (registry, git, path)
467 fn source(&self) -> crate::parser::DependencySource;
468
469 /// Feature flags (ecosystem-specific, empty if not supported)
470 fn features(&self) -> &[String] {
471 &[]
472 }
473
474 /// Range of the features array (ecosystem-specific, None if not supported)
475 fn features_range(&self) -> Option<Range> {
476 None
477 }
478
479 /// Environment marker expression gating this dependency (e.g. PEP 508's
480 /// `python_version >= '3.8'`). Ecosystem-specific, `None` if not supported
481 /// or not present on this dependency.
482 fn markers(&self) -> Option<&str> {
483 None
484 }
485
486 /// Range of the environment marker expression (ecosystem-specific,
487 /// `None` if not supported or not present).
488 fn markers_range(&self) -> Option<Range> {
489 None
490 }
491
492 /// The raw manifest text spanned by [`version_range`](Dependency::version_range),
493 /// when it differs from [`version_requirement`](Dependency::version_requirement).
494 ///
495 /// Most ecosystems' `version_requirement()` is (up to whitespace) exactly the text at
496 /// `version_range()`, so the default `None` — telling callers to fall back to
497 /// `version_requirement()` — is correct for them. An ecosystem whose parser synthesizes
498 /// a comparator string from a bare literal (e.g. `deps-swift`'s `.exact("4.50.0")`
499 /// becoming requirement `"=4.50.0"` while `version_range()` still spans only `4.50.0`)
500 /// overrides this to return that literal, so `lsp_helpers`' literal-span guard
501 /// (`literal_span_matches`, used by both `generate_code_actions` and
502 /// `collect_update_all_edits`) compares `version_range`'s slice against the text it was
503 /// actually derived from instead of the synthesized comparator, which would otherwise
504 /// never match and silently suppress every fix action for that dependency.
505 ///
506 /// A sibling mechanism already exists for the same underlying problem: `deps-nuget`
507 /// wraps a bare source version as requirement `[1.0.0]`, and `literal_span_matches`
508 /// special-cases that bracket wrapping inline rather than going through this hook. This
509 /// method exists for the general case — an ecosystem whose synthesized requirement is
510 /// not a simple wrap (`deps-swift`'s comparator range is not recoverable from
511 /// `version_requirement()` by stripping fixed characters) needs its own literal, not a
512 /// transform `literal_span_matches` could hard-code.
513 ///
514 /// **Must not** be set when `version_range()` spans only part of a multi-literal
515 /// requirement whose other part(s) are not being rewritten — e.g. a `"lower"..<"upper"`
516 /// range, where `version_range()` covers only `lower`. Reporting `lower` as the literal
517 /// would let the guard pass and an edit rewrite `lower` alone, corrupting the
518 /// requirement (`deps-swift` leaves this `None` for both its range-literal forms for
519 /// exactly this reason — see `crates/deps-swift/src/parser.rs`'s range-form comments).
520 fn version_literal(&self) -> Option<&str> {
521 None
522 }
523
524 /// Whether [`name_range`](Dependency::name_range) is a synthetic placeholder rather than a
525 /// real position in the manifest, for a dependency whose name was resolved from something
526 /// other than the live document text — e.g. `deps-dart`'s container-anchor alias
527 /// resolution (issue #905), where a whole `dependencies:`/`environment:` section aliased
528 /// via `*anchor` is reconstructed from a buffered subtree replayed at the alias site, so no
529 /// position in *this* occurrence's own text corresponds to the resolved name.
530 ///
531 /// `name_range()` itself has no `Option` to express this (unlike `version_range()`), so
532 /// callers that would otherwise treat it as a stable, real, per-dependency position must
533 /// check this hook first. In particular: a `HashMap` keyed by `name_range()` (e.g.
534 /// [`crate::osv::vulnerability_keys`]) must not key on it when this returns `true` — every
535 /// such dependency would share the exact same synthetic range and silently collide, one
536 /// evicting another's entry — and a hover/diagnostic anchor must not render at it either
537 /// (it typically resolves to `Range::default()`, i.e. document start).
538 ///
539 /// The default `false` is correct for every ecosystem whose dependency name is always
540 /// literal text at a real position — which, as of this writing, is every ecosystem except
541 /// the one case named above.
542 fn name_range_is_synthetic(&self) -> bool {
543 false
544 }
545
546 /// Downcast to concrete type
547 fn as_any(&self) -> &dyn Any;
548}
549
550/// Configuration for LSP inlay hints feature.
551///
552/// Since this type is `#[non_exhaustive]`, `deps-lsp`'s `handlers/inlay_hints.rs` builds one
553/// via [`Self::new`] plus a `with_*` chain rather than a struct literal — adding a field here
554/// no longer forces that call site to update at compile time. A new field must be wired into
555/// it by hand, or it silently keeps [`Self::new`]'s default forever.
556#[non_exhaustive]
557#[derive(Debug, Clone)]
558pub struct EcosystemConfig {
559 /// Whether to show inlay hints for up-to-date dependencies
560 pub show_up_to_date_hints: bool,
561 /// Text to display for up-to-date dependencies
562 pub up_to_date_text: String,
563 /// Text to display for dependencies needing updates (use {} for version placeholder)
564 pub needs_update_text: String,
565 /// Text to display while loading registry data
566 pub loading_text: String,
567 /// Whether to show loading hints in inlay hints
568 pub show_loading_hints: bool,
569 /// Whether `network.offline` is set (issue #483): when `true` and no cached latest
570 /// version exists for a dependency, [`crate::lsp_helpers::generate_inlay_hints`]
571 /// shows an offline marker instead of silently falling back to the resolved-version
572 /// display, which would otherwise look identical to a normal pre-fetch state.
573 pub offline: bool,
574}
575
576impl Default for EcosystemConfig {
577 fn default() -> Self {
578 Self::new()
579 }
580}
581
582impl EcosystemConfig {
583 /// Builds the default configuration (mirrors [`Self::default`]).
584 ///
585 /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
586 /// this crate, so every other crate must chain the `with_*` setters onto this
587 /// constructor instead.
588 ///
589 /// # Examples
590 ///
591 /// ```
592 /// use deps_core::EcosystemConfig;
593 ///
594 /// let config = EcosystemConfig::new().with_offline(true);
595 /// assert!(config.offline);
596 /// ```
597 #[must_use]
598 pub fn new() -> Self {
599 Self {
600 show_up_to_date_hints: true,
601 up_to_date_text: "✅".to_string(),
602 needs_update_text: "❌ {}".to_string(),
603 loading_text: "⏳".to_string(),
604 show_loading_hints: true,
605 offline: false,
606 }
607 }
608
609 /// Overrides [`Self::show_up_to_date_hints`]. See [`Self::new`].
610 #[must_use]
611 pub const fn with_show_up_to_date_hints(mut self, show_up_to_date_hints: bool) -> Self {
612 self.show_up_to_date_hints = show_up_to_date_hints;
613 self
614 }
615
616 /// Overrides [`Self::up_to_date_text`]. See [`Self::new`].
617 #[must_use]
618 pub fn with_up_to_date_text(mut self, up_to_date_text: impl Into<String>) -> Self {
619 self.up_to_date_text = up_to_date_text.into();
620 self
621 }
622
623 /// Overrides [`Self::needs_update_text`]. See [`Self::new`].
624 #[must_use]
625 pub fn with_needs_update_text(mut self, needs_update_text: impl Into<String>) -> Self {
626 self.needs_update_text = needs_update_text.into();
627 self
628 }
629
630 /// Overrides [`Self::loading_text`]. See [`Self::new`].
631 #[must_use]
632 pub fn with_loading_text(mut self, loading_text: impl Into<String>) -> Self {
633 self.loading_text = loading_text.into();
634 self
635 }
636
637 /// Overrides [`Self::show_loading_hints`]. See [`Self::new`].
638 #[must_use]
639 pub const fn with_show_loading_hints(mut self, show_loading_hints: bool) -> Self {
640 self.show_loading_hints = show_loading_hints;
641 self
642 }
643
644 /// Overrides [`Self::offline`]. See [`Self::new`].
645 #[must_use]
646 pub const fn with_offline(mut self, offline: bool) -> Self {
647 self.offline = offline;
648 self
649 }
650}
651
652/// How this ecosystem's license strings are sourced.
653///
654/// Drives three ecosystem-specific license behaviors that used to be re-derived
655/// independently at each consumer via a non-exhaustive `matches!`/`==` on
656/// [`EcosystemId`] instead of the sealed [`Ecosystem`] trait every other per-ecosystem
657/// capability goes through (issue #688): hover's "(detected)" qualifier, which only
658/// applies to [`Self::DetectedSpdx`]; whether
659/// [`crate::licenses::resolve_license_entries`] must normalize free text to SPDX
660/// identifiers before hover displays it or a [`crate::licenses::LicensePolicy`]
661/// evaluates it, which only applies to [`Self::PomFreeText`]; and, via
662/// [`Self::requires_dedicated_fetch`] (issue #697), whether
663/// [`Ecosystem::fetch_license`] is a dedicated async fetch or a no-op because the
664/// license already arrived in the hot-path registry response.
665///
666/// # Examples
667///
668/// ```
669/// use deps_core::LicenseSource;
670///
671/// assert_eq!(LicenseSource::default(), LicenseSource::RegistryDeclaredSpdx);
672/// assert_ne!(LicenseSource::PomFreeText, LicenseSource::DetectedSpdx);
673/// ```
674#[non_exhaustive]
675#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
676pub enum LicenseSource {
677 /// Author-declared SPDX identifier(s) already present in the hot-path registry
678 /// response — the default for every ecosystem that doesn't override
679 /// [`Ecosystem::license_source`], and the only variant for which
680 /// [`Self::requires_dedicated_fetch`] is `false`.
681 #[default]
682 RegistryDeclaredSpdx,
683 /// Author-declared SPDX identifier(s) that require a dedicated fetch separate
684 /// from the hot-path registry response: JSR's package-metadata endpoint (Deno).
685 FetchedDeclaredSpdx,
686 /// A detector's best-effort guess, not an author declaration, that requires a
687 /// dedicated fetch: pub.dev's `/score` endpoint (Dart) and GitHub's
688 /// `license.spdx_id` (Swift) are both driven by a license-detection heuristic run
689 /// against repository content, rather than metadata the package author explicitly
690 /// declared to a registry.
691 DetectedSpdx,
692 /// Maven POM `<licenses><license><name>` free text (Gradle), fetched via a
693 /// dedicated request — never an SPDX identifier, so it must be normalized via
694 /// [`crate::licenses::resolve_license_entries`] before display or policy evaluation.
695 PomFreeText,
696}
697
698impl LicenseSource {
699 /// Whether this source requires [`Ecosystem::fetch_license`]'s dedicated async
700 /// fetch, as opposed to arriving for free in the hot-path registry response.
701 ///
702 /// This is the single answer to "is this a tier-3 license ecosystem" (issue #697):
703 /// capability and shape can no longer disagree, because both come from the same
704 /// [`Ecosystem::license_source`] call.
705 ///
706 /// # Examples
707 ///
708 /// ```
709 /// use deps_core::LicenseSource;
710 ///
711 /// assert!(!LicenseSource::RegistryDeclaredSpdx.requires_dedicated_fetch());
712 /// assert!(LicenseSource::FetchedDeclaredSpdx.requires_dedicated_fetch());
713 /// assert!(LicenseSource::DetectedSpdx.requires_dedicated_fetch());
714 /// assert!(LicenseSource::PomFreeText.requires_dedicated_fetch());
715 /// ```
716 #[must_use]
717 pub const fn requires_dedicated_fetch(self) -> bool {
718 match self {
719 Self::RegistryDeclaredSpdx => false,
720 Self::FetchedDeclaredSpdx | Self::DetectedSpdx | Self::PomFreeText => true,
721 }
722 }
723}
724
725/// Main trait that all ecosystem implementations must implement.
726///
727/// Each ecosystem (Cargo, npm, PyPI, etc.) provides its own implementation.
728/// This trait defines the contract for parsing manifests, fetching registry data,
729/// and generating LSP responses.
730///
731/// # Type Erasure
732///
733/// This trait uses `Box<dyn Trait>` instead of associated types to allow
734/// runtime polymorphism and dynamic ecosystem registration.
735///
736/// # Sealing
737///
738/// This trait requires `Self: private::Sealed`, making it sealed in the sense described
739/// on that module's doc: a documented contract enforced by code review, not a
740/// compiler-enforced wall. Every sibling ecosystem crate in this workspace (`deps-cargo`,
741/// `deps-npm`, ...) implements [`private::Sealed`] for its own ecosystem type, which requires
742/// `private` to be `pub`; Rust has no visibility level that admits sibling crates while
743/// excluding a truly external one, so this guarantee cannot be enforced any harder than that
744/// without inverting the crate's whole multi-crate extension-point architecture. See
745/// [`private::Sealed`]'s own doc for the full reasoning, and the `impl private::Sealed`
746/// line in the example below for what implementing it in practice looks like.
747///
748/// # Examples
749///
750/// ```no_run
751/// use deps_core::{Ecosystem, ParseResult, Registry, EcosystemConfig, PackageName, ConcreteVersion, Metadata};
752/// use deps_core::completion::{Completions, build_feature_completion};
753/// use deps_core::lsp_helpers::{
754/// DiagnosticMessages, DiagnosticPolicy, EcosystemFormatter, OsvNaming, PackageNaming,
755/// PackageRendering, RequirementResolution, SourcePolicy,
756/// };
757/// use std::sync::Arc;
758/// use std::any::Any;
759/// use tower_lsp_server::ls_types::{
760/// CompletionItem, CompletionTextEdit, Position, Range, TextEdit,
761/// };
762/// use url::Url;
763///
764/// struct MyFormatter;
765/// impl PackageNaming for MyFormatter {}
766/// impl PackageRendering for MyFormatter {
767/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String { version.to_string() }
768/// fn package_url(&self, name: &PackageName) -> String { format!("https://example.com/{}", name.as_str()) }
769/// }
770/// impl RequirementResolution for MyFormatter {}
771/// impl DiagnosticMessages for MyFormatter {}
772/// impl DiagnosticPolicy for MyFormatter {}
773/// impl SourcePolicy for MyFormatter {}
774/// impl OsvNaming for MyFormatter {}
775///
776/// struct MyEcosystem {
777/// registry: Arc<dyn Registry>,
778/// formatter: MyFormatter,
779/// }
780///
781/// // Real in-workspace ecosystem crates implement `Sealed` exactly like this. This line
782/// // compiling here, out-of-crate, is not a bug: as the `# Sealing` section above explains,
783/// // `private::Sealed` is a documented contract, not a compiler-enforced wall — Rust has no
784/// // visibility level that admits sibling workspace crates while excluding a truly external
785/// // one, so any crate that names this path can technically do the same.
786/// impl deps_core::ecosystem::private::Sealed for MyEcosystem {}
787///
788/// impl Ecosystem for MyEcosystem {
789/// fn ecosystem_id(&self) -> deps_core::EcosystemId { deps_core::EcosystemId::Cargo }
790/// fn display_name(&self) -> &'static str { "My Ecosystem" }
791/// fn manifest_filenames(&self) -> &[&'static str] { &["my-manifest.toml"] }
792///
793/// fn parse_manifest<'a>(
794/// &'a self,
795/// _content: &'a str,
796/// _uri: &'a Url,
797/// ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::error::Result<Box<dyn ParseResult>>> {
798/// Box::pin(async move { todo!() })
799/// }
800///
801/// fn registry(&self) -> Arc<dyn Registry> { self.registry.clone() }
802///
803/// fn formatter(&self) -> &dyn EcosystemFormatter { &self.formatter }
804///
805/// // Hook for `CompletionContext::PackageName` — called by `generate_completions`'s
806/// // default dispatch (below) whenever the cursor sits in a package-name token. A
807/// // real ecosystem would search `self.registry` for `prefix` and build one item per
808/// // match, e.g. via `deps_core::completion::complete_package_names_generic`.
809/// fn complete_package_name<'a>(
810/// &'a self,
811/// _request: deps_core::completion::CompletionRequest<'a>,
812/// prefix: String,
813/// range: Range,
814/// ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
815/// Box::pin(async move {
816/// let item = CompletionItem {
817/// label: prefix.clone(),
818/// text_edit: Some(CompletionTextEdit::Edit(TextEdit { range, new_text: prefix })),
819/// ..Default::default()
820/// };
821/// Completions::new(vec![item])
822/// })
823/// }
824///
825/// // Hook for `CompletionContext::Version` — called by `generate_completions`'s
826/// // default dispatch whenever the cursor sits in a version-requirement token.
827/// fn complete_version<'a>(
828/// &'a self,
829/// _request: deps_core::completion::CompletionRequest<'a>,
830/// _package_name: PackageName,
831/// _prefix: String,
832/// ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
833/// Box::pin(async move { Completions::default() })
834/// }
835///
836/// // Hook for `CompletionContext::Feature` — called by `generate_completions`'s
837/// // default dispatch whenever the cursor sits in a feature-flag array entry. Reuses
838/// // the shared `build_feature_completion` helper rather than hand-building the item.
839/// fn complete_feature<'a>(
840/// &'a self,
841/// _request: deps_core::completion::CompletionRequest<'a>,
842/// package_name: PackageName,
843/// prefix: String,
844/// ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
845/// Box::pin(async move {
846/// let item = build_feature_completion(&prefix, &package_name, None);
847/// Completions::new(vec![item])
848/// })
849/// }
850///
851/// fn completion_insert_text(&self, metadata: &dyn Metadata) -> Option<String> {
852/// Some(format!("\"{}\" = \"{}\"", metadata.name().as_str(), metadata.latest_version()))
853/// }
854///
855/// fn as_any(&self) -> &dyn Any { self }
856/// }
857///
858/// // `generate_completions` is not overridden above, so `MyEcosystem` inherits this
859/// // trait's default implementation: it detects the `CompletionContext` at `position` and
860/// // dispatches to whichever of the three hooks above matches — the "three-hook pattern".
861/// # async fn dispatch_demo(ecosystem: &MyEcosystem, parse_result: &dyn ParseResult) {
862/// let freshness = deps_core::FreshnessSettings::default();
863/// let position = Position { line: 0, character: 0 };
864/// let content = r#"serde = "1.0""#;
865/// let _completions = ecosystem
866/// .generate_completions(parse_result, position, content, freshness)
867/// .await;
868/// # }
869/// ```
870pub trait Ecosystem: Send + Sync + private::Sealed {
871 /// Exhaustively typed ecosystem identity.
872 ///
873 /// Every implementor lives in this sealed trait's own workspace, so the enum is
874 /// authoritative for identity and [`Self::id`] is derived from it rather than
875 /// independently authored — a typo or rename here is a compile error instead of a
876 /// runtime mismatch between [`EcosystemId::ALL`] and this ecosystem's registration.
877 fn ecosystem_id(&self) -> EcosystemId;
878
879 /// Unique identifier (e.g., "cargo", "npm", "pypi")
880 ///
881 /// This identifier is used for ecosystem registration and routing. The default
882 /// implementation derives it from [`Self::ecosystem_id`], so an implementor that leaves
883 /// `id()` at its default can no longer author a mismatched string independently — but
884 /// `id()` remains a provided (overridable) method, so this guarantee holds only for that
885 /// default, not for an implementor that overrides `id()` directly (as some test doubles
886 /// deliberately do). Code needing the two to agree should verify it explicitly, e.g. via
887 /// [`crate::conformance::assert_ecosystem_id`].
888 fn id(&self) -> &'static str {
889 self.ecosystem_id().id()
890 }
891
892 /// Human-readable name (e.g., "Cargo (Rust)", "npm (JavaScript)")
893 ///
894 /// This name is displayed in diagnostic messages and logs.
895 fn display_name(&self) -> &'static str;
896
897 /// Manifest filenames this ecosystem handles (e.g., ["Cargo.toml"])
898 ///
899 /// The ecosystem registry uses these filenames to route file URIs
900 /// to the appropriate ecosystem implementation.
901 fn manifest_filenames(&self) -> &[&'static str];
902
903 /// File extensions this ecosystem handles when the manifest basename is
904 /// not fixed (e.g. `[".csproj", ".fsproj"]` for NuGet project files).
905 ///
906 /// Consulted by [`crate::EcosystemRegistry::for_filename`] only after
907 /// an exact [`manifest_filenames`](Ecosystem::manifest_filenames) match
908 /// fails. Empty by default, indicating this ecosystem is routed solely by
909 /// exact filename.
910 fn manifest_extensions(&self) -> &[&'static str] {
911 &[]
912 }
913
914 /// Basename glob patterns this ecosystem handles, each containing exactly
915 /// one `*` wildcard (e.g. `["requirements*.txt"]`).
916 ///
917 /// Consulted by [`crate::EcosystemRegistry::for_filename`] as a third
918 /// routing stage, tried after an exact
919 /// [`manifest_filenames`](Ecosystem::manifest_filenames) match fails and
920 /// before [`manifest_extensions`](Ecosystem::manifest_extensions) — for
921 /// basenames that are neither fixed nor identified by extension alone
922 /// (e.g. `requirements.txt`, `requirements-dev.txt`). Empty by default.
923 /// Matching is case-sensitive, unlike the extension stage: these patterns
924 /// target canonically-lowercase filenames (pip, Renovate and Dependabot
925 /// all treat `requirements.txt` as lowercase), whereas the extension
926 /// stage exists specifically for Windows/MSBuild project files whose
927 /// case genuinely varies.
928 fn manifest_patterns(&self) -> &[&'static str] {
929 &[]
930 }
931
932 /// `(directory_path, file_suffix)` pairs identifying a file solely by its
933 /// containing directory path and suffix. `directory_path` may be a single
934 /// segment (e.g. `[("requirements", ".txt")]` for Python's
935 /// `requirements/base.txt` split-file layout) or multiple `/`-joined
936 /// segments (e.g. `[(".github/workflows", ".yml")]` for GitHub Actions
937 /// workflow files) — either way it is matched against the *tail* of the
938 /// file's directory path on segment boundaries, not just the immediate
939 /// parent, so a multi-segment pattern matches regardless of how many
940 /// ancestor directories precede it. Used when the basename alone carries
941 /// no ecosystem signal.
942 ///
943 /// Consulted by [`crate::EcosystemRegistry::for_uri`] only, after both
944 /// [`manifest_patterns`](Ecosystem::manifest_patterns) and
945 /// [`manifest_extensions`](Ecosystem::manifest_extensions) miss on the
946 /// basename — it needs the full path, so it is never reachable from
947 /// [`crate::EcosystemRegistry::for_filename`]. Empty by default.
948 fn manifest_directory_patterns(&self) -> &[(&'static str, &'static str)] {
949 &[]
950 }
951
952 /// Lock file filenames this ecosystem uses (e.g., ["Cargo.lock"])
953 ///
954 /// Used for file watching - LSP will monitor changes to these files
955 /// and refresh UI when they change. Returns empty slice if ecosystem
956 /// doesn't use lock files.
957 ///
958 /// # Default Implementation
959 ///
960 /// Returns empty slice by default, indicating no lock files are used.
961 fn lockfile_filenames(&self) -> &[&'static str] {
962 &[]
963 }
964
965 /// Non-lockfile config filenames this ecosystem resolves *during* [`Self::parse_manifest`]
966 /// (e.g. `["pnpm-workspace.yaml", ".npmrc"]` for npm's catalog and registry resolution),
967 /// whose values end up baked into a manifest's `ParseResult` rather than looked up
968 /// separately the way a [`Self::lockfile_provider`] is.
969 ///
970 /// Used for file watching alongside [`Self::lockfile_filenames`] — LSP monitors changes
971 /// to these files too, but reacts by fully re-parsing every open document of this
972 /// ecosystem (not merely refreshing cached resolved versions, since the value isn't kept
973 /// separately from the parse result to refresh in place). Returns empty slice by default.
974 fn watched_config_filenames(&self) -> &[&'static str] {
975 &[]
976 }
977
978 /// Subset of [`Self::watched_config_filenames`] whose change can alter dependency
979 /// *routing* (which registry a dependency resolves through) without changing the
980 /// dependency names or version requirements themselves — a caller reacting to a change
981 /// in one of these filenames must force a full re-fetch rather than diff against the
982 /// previous parse result, since a routing-only edit would otherwise look like a no-op
983 /// (issue #1232).
984 ///
985 /// # Default Implementation
986 ///
987 /// Returns an empty slice: most watched configs (e.g. npm's `pnpm-workspace.yaml`
988 /// catalog) rewrite a dependency's version requirement directly, which an ordinary diff
989 /// against the parse result already detects.
990 fn routing_affecting_watched_configs(&self) -> &[&'static str] {
991 &[]
992 }
993
994 /// Parse a manifest file and return parsed result
995 ///
996 /// # Arguments
997 ///
998 /// * `content` - Raw file content
999 /// * `uri` - Document URI for position tracking
1000 ///
1001 /// # Errors
1002 ///
1003 /// Returns error if manifest cannot be parsed
1004 ///
1005 /// # Invariant
1006 ///
1007 /// Implementations must not contain a real `.await` (no network/file I/O, no yielding to
1008 /// the scheduler) — the body must be synchronous work wrapped in an immediately-ready
1009 /// `async move` block. [`parse_manifest_blocking`] relies on this to drive the future via
1010 /// `Handle::block_on` on the blocking-thread pool; violating it doesn't deadlock, but
1011 /// silently reintroduces the exact worker-thread stall #743 fixed.
1012 fn parse_manifest<'a>(
1013 &'a self,
1014 content: &'a str,
1015 uri: &'a url::Url,
1016 ) -> BoxFuture<'a, crate::error::Result<Box<dyn ParseResult>>>;
1017
1018 /// Get the registry client for this ecosystem
1019 ///
1020 /// The registry provides version lookup and package search capabilities.
1021 fn registry(&self) -> Arc<dyn Registry>;
1022
1023 /// Get the lock file provider for this ecosystem.
1024 ///
1025 /// Returns `None` if the ecosystem doesn't support lock files.
1026 /// Lock files provide resolved dependency versions without network requests.
1027 fn lockfile_provider(&self) -> Option<Arc<dyn crate::lockfile::LockFileProvider>> {
1028 None
1029 }
1030
1031 /// Get the ecosystem-specific formatter for LSP response generation.
1032 ///
1033 /// The formatter handles version comparison, package URLs, and text formatting.
1034 /// Override this to customize LSP response generation.
1035 fn formatter(&self) -> &dyn EcosystemFormatter;
1036
1037 /// Generate inlay hints for the document.
1038 ///
1039 /// Default implementation delegates to `lsp_helpers::generate_inlay_hints`
1040 /// using `self.formatter()`. Override only if custom behavior is needed.
1041 #[cfg(feature = "lsp-responses")]
1042 fn generate_inlay_hints<'a>(
1043 &'a self,
1044 parse_result: &'a dyn ParseResult,
1045 versions: VersionData<'a>,
1046 loading_state: crate::LoadingState,
1047 config: &'a EcosystemConfig,
1048 ) -> BoxFuture<'a, Vec<InlayHint>> {
1049 Box::pin(async move {
1050 crate::lsp_helpers::generate_inlay_hints(
1051 parse_result,
1052 versions,
1053 loading_state,
1054 config,
1055 self.formatter(),
1056 )
1057 })
1058 }
1059
1060 /// Generate hover information for a position.
1061 ///
1062 /// Default implementation delegates to `lsp_helpers::generate_hover`
1063 /// using `self.formatter()` and `self.registry()`. `versions.license_source` is
1064 /// attached by the caller (`deps-lsp::handlers::hover`), not here — the single
1065 /// `VersionData` construction site every path funnels through, override or not
1066 /// (issue #688 critic M1: attaching it in this default only would silently drop it
1067 /// for the ecosystems that override `generate_hover` instead of using this default).
1068 #[cfg(feature = "lsp-responses")]
1069 fn generate_hover<'a>(
1070 &'a self,
1071 parse_result: &'a dyn ParseResult,
1072 position: Position,
1073 versions: VersionData<'a>,
1074 freshness: crate::freshness::FreshnessSettings,
1075 ) -> BoxFuture<'a, Option<Hover>> {
1076 Box::pin(async move {
1077 let registry = self.registry();
1078 crate::lsp_helpers::generate_hover(
1079 parse_result,
1080 position,
1081 versions,
1082 registry.as_ref(),
1083 self.formatter(),
1084 freshness,
1085 crate::freshness::PublishTime::now(),
1086 )
1087 .await
1088 })
1089 }
1090
1091 /// Generate code actions for a position.
1092 ///
1093 /// Default implementation delegates to `lsp_helpers::generate_code_actions`
1094 /// using `self.formatter()` and `self.registry()`. `versions` carries the
1095 /// same OSV scan results `generate_hover` and `generate_diagnostics` use,
1096 /// so a vulnerable dependency at `position` gets a "fix vulnerability"
1097 /// quickfix alongside the plain version-update actions. `content` is the
1098 /// manifest source, needed to guard against rewriting a `version_range`
1099 /// that no longer slices to its declared requirement text (see
1100 /// `lsp_helpers::literal_span_matches`).
1101 #[cfg(feature = "lsp-responses")]
1102 fn generate_code_actions<'a>(
1103 &'a self,
1104 parse_result: &'a dyn ParseResult,
1105 position: Position,
1106 uri: &'a url::Url,
1107 versions: VersionData<'a>,
1108 content: &'a str,
1109 ) -> BoxFuture<'a, Vec<CodeAction>> {
1110 Box::pin(async move {
1111 let registry = self.registry();
1112 crate::lsp_helpers::generate_code_actions(
1113 parse_result,
1114 position,
1115 uri,
1116 versions,
1117 content,
1118 registry.as_ref(),
1119 self.formatter(),
1120 )
1121 .await
1122 })
1123 }
1124
1125 /// Generate diagnostics for the document.
1126 ///
1127 /// Default implementation delegates to `lsp_helpers::generate_diagnostics_from_cache`
1128 /// using `self.formatter()`. `versions.license_source` is attached by the caller
1129 /// (`deps-lsp::handlers::diagnostics`), not here — see [`Self::generate_hover`]'s doc
1130 /// for why (issue #688 critic M1).
1131 fn generate_diagnostics<'a>(
1132 &'a self,
1133 parse_result: &'a dyn ParseResult,
1134 versions: VersionData<'a>,
1135 uri: &'a url::Url,
1136 freshness: crate::freshness::FreshnessSettings,
1137 severities: crate::lsp_helpers::DiagnosticSeverities,
1138 ) -> BoxFuture<'a, Vec<Diagnostic>> {
1139 Box::pin(async move {
1140 crate::lsp_helpers::generate_diagnostics_from_cache(
1141 parse_result,
1142 versions,
1143 self.formatter(),
1144 uri,
1145 freshness,
1146 severities,
1147 crate::freshness::PublishTime::now(),
1148 )
1149 })
1150 }
1151
1152 /// Generate `textDocument/documentLink` targets for the document.
1153 ///
1154 /// A document link is a clickable reference from a byte range in this
1155 /// manifest to another resource — e.g. a `-r other.txt` / `-c
1156 /// constraints.txt` reference inside a pip requirements file, resolved
1157 /// to the absolute file it points at. Purely local (no registry access),
1158 /// so unlike the other `generate_*` methods this is synchronous rather
1159 /// than a [`BoxFuture`]. Empty by default: most ecosystems' manifest
1160 /// formats have no such intra-file-graph references.
1161 #[cfg(feature = "lsp-responses")]
1162 fn generate_document_links(
1163 &self,
1164 _parse_result: &dyn ParseResult,
1165 _uri: &url::Url,
1166 ) -> Vec<DocumentLink> {
1167 Vec::new()
1168 }
1169
1170 /// Generate the "Update N outdated dependencies" code lens for the document.
1171 ///
1172 /// The shared "update all outdated" lens has no enable/disable toggle of its own —
1173 /// that's `code_lens.enabled` in `deps-lsp`'s config, gating the whole handler before
1174 /// this method is ever called. An ecosystem-specific bulk lens gated on a
1175 /// `DiagnosticSeverities` flag (e.g. GitHub Actions' "Pin N actions to commit SHA",
1176 /// issue #633) is wired directly in `deps-lsp`'s `handlers::code_lens`, not through an
1177 /// override of this method — see [`Self::collect_pin_all_to_sha_edits`].
1178 ///
1179 /// Default implementation delegates to `lsp_helpers::generate_code_lenses` using
1180 /// `self.formatter()`. Override only if custom behavior is needed.
1181 #[cfg(feature = "lsp-responses")]
1182 fn generate_code_lenses<'a>(
1183 &'a self,
1184 parse_result: &'a dyn ParseResult,
1185 content: &'a str,
1186 versions: VersionData<'a>,
1187 uri: &'a url::Url,
1188 command_id: &'a str,
1189 ) -> BoxFuture<'a, Vec<CodeLens>> {
1190 Box::pin(async move {
1191 crate::lsp_helpers::generate_code_lenses(
1192 parse_result,
1193 content,
1194 versions,
1195 self.formatter(),
1196 uri,
1197 command_id,
1198 )
1199 })
1200 }
1201
1202 /// Lifecycle hook run synchronously, once per [`Self::generate_completions`] call,
1203 /// before context detection — for setup that must happen regardless of which
1204 /// [`crate::completion::CompletionContext`] the request turns out to be (e.g. warming a
1205 /// local search index that a later `PackageName` completion in the *same* manifest will
1206 /// want ready, even though this specific call might resolve to a different context).
1207 ///
1208 /// Must return immediately: it runs inside the async dispatch before any `.await`, so
1209 /// any real work (an index rebuild, a network warm-up) must be spawned rather than
1210 /// awaited here — never block the dispatch. Default no-op; the sole override today is
1211 /// `deps-pypi`'s `warm_search_index()`.
1212 fn prepare_completions(&self) {}
1213
1214 /// Generate completions for a position.
1215 ///
1216 /// Provides autocomplete suggestions for package names, versions, and features by
1217 /// detecting the [`crate::completion::CompletionContext`] at `position` and dispatching
1218 /// to the matching hook — [`Self::complete_package_name`], [`Self::complete_version`], or
1219 /// [`Self::complete_feature`].
1220 ///
1221 /// The exhaustive `match` over [`crate::completion::CompletionContext`] lives here, in
1222 /// the crate that defines the enum, so `#[non_exhaustive]` does not apply and a new
1223 /// variant is a compile error in this one place instead of a silent no-op downstream
1224 /// (issue #793). An ecosystem that overrides this default opts out of that guarantee and
1225 /// takes the obligation on itself — `deps-maven` and `deps-gradle` do, because they route
1226 /// on their own XML/Groovy context type instead of
1227 /// [`crate::completion::CompletionContext`].
1228 ///
1229 /// `freshness.enabled` gates whether version completion items carry a
1230 /// relative-age `label_details` suffix (issue #145); implementations that
1231 /// delegate to [`crate::completion::complete_versions_at_position`] get this for
1232 /// free by threading `freshness` through.
1233 ///
1234 /// The returned [`Completions::is_incomplete`] must reflect *this specific call*
1235 /// (the completion context actually served), not a static worst case for the
1236 /// ecosystem as a whole (#427): a package-name search over an unranked,
1237 /// truncated index should report `true`, while a version completion or any
1238 /// other exhaustive context in the same manifest must report `false`, even for
1239 /// an ecosystem where some contexts are incomplete and others are not.
1240 #[cfg(feature = "lsp-responses")]
1241 fn generate_completions<'a>(
1242 &'a self,
1243 parse_result: &'a dyn ParseResult,
1244 position: Position,
1245 content: &'a str,
1246 freshness: crate::FreshnessSettings,
1247 ) -> BoxFuture<'a, Completions> {
1248 Box::pin(async move {
1249 self.prepare_completions();
1250 let request =
1251 crate::completion::CompletionRequest::new(parse_result, position, freshness);
1252 // Exhaustive on purpose (#793): no wildcard arm, so a new
1253 // `CompletionContext` variant is a compile error right here.
1254 match crate::completion::detect_completion_context(parse_result, position, content) {
1255 crate::completion::CompletionContext::PackageName { prefix, range } => self
1256 .complete_package_name(request, prefix, range)
1257 .await
1258 .with_origin(crate::completion::CompletionOrigin::PackageName),
1259 crate::completion::CompletionContext::Version {
1260 package_name,
1261 prefix,
1262 } => self
1263 .complete_version(request, package_name, prefix)
1264 .await
1265 .with_origin(crate::completion::CompletionOrigin::Version),
1266 crate::completion::CompletionContext::Feature {
1267 package_name,
1268 prefix,
1269 } => self
1270 .complete_feature(request, package_name, prefix)
1271 .await
1272 .with_origin(crate::completion::CompletionOrigin::Feature),
1273 crate::completion::CompletionContext::None => Completions::default(),
1274 }
1275 })
1276 }
1277
1278 /// Completion hook for a [`crate::completion::CompletionContext::PackageName`]
1279 /// context — called only by [`Self::generate_completions`]'s default dispatch.
1280 ///
1281 /// `_request` is unused by most implementations (a package-name search is driven purely
1282 /// by `prefix`/`range`), but kept uniform with [`Self::complete_version`]/
1283 /// [`Self::complete_feature`] rather than dropped per-hook — a future input costs one
1284 /// field on [`crate::completion::CompletionRequest`] instead of a signature break on only
1285 /// the hooks that happen to need it today.
1286 ///
1287 /// Default: no package-name search (`Completions::default()`) — correct for an ecosystem
1288 /// with no package-name index (`deps-github-actions`, `deps-gitlab-ci`). Says nothing
1289 /// about an ecosystem that overrides [`Self::generate_completions`] wholesale
1290 /// (`deps-maven`, `deps-gradle`) and so never calls this hook at all — both actually do
1291 /// serve package-name completion (`groupId`/`artifactId`, a Gradle coordinate), just not
1292 /// through this method.
1293 #[cfg(feature = "lsp-responses")]
1294 fn complete_package_name<'a>(
1295 &'a self,
1296 _request: crate::completion::CompletionRequest<'a>,
1297 _prefix: String,
1298 _range: tower_lsp_server::ls_types::Range,
1299 ) -> BoxFuture<'a, Completions> {
1300 Box::pin(std::future::ready(Completions::default()))
1301 }
1302
1303 /// Leading version-constraint operators this ecosystem's syntax allows before a bare
1304 /// version (e.g. npm's `^`/`~`, PyPI's `>=`/`!=`, NuGet's bracket-interval `[`/`(`),
1305 /// stripped from a completion prefix before matching it against registry versions.
1306 ///
1307 /// Consumed by [`Self::complete_version`]'s default implementation, which threads it
1308 /// into [`crate::completion::complete_versions_at_position`]. Empty by default —
1309 /// correct for an ecosystem whose version requirements are bare version strings with no
1310 /// operator prefix (e.g. `deps-go`, `deps-swift`). An ecosystem overriding
1311 /// [`Self::complete_version`] directly instead of using the default is free to ignore
1312 /// this method entirely.
1313 ///
1314 /// A `&self` method rather than an associated const: an unconstrained associated const
1315 /// would make [`Ecosystem`] dyn-incompatible (it would have to appear in every
1316 /// implementor's vtable, which a bare `&'static [char]` value cannot), and a
1317 /// `where Self: Sized`-bounded const to opt back out of the vtable needs the unstable
1318 /// `generic_const_items` feature — not available on this workspace's stable toolchain.
1319 /// `dyn Ecosystem` is exactly how [`crate::ecosystem_registry`] stores every registered
1320 /// ecosystem, so this method mirrors [`Self::formatter`]/[`Self::registry`] instead.
1321 #[cfg(feature = "lsp-responses")]
1322 fn version_operator_chars(&self) -> &'static [char] {
1323 &[]
1324 }
1325
1326 /// Completion hook for a [`crate::completion::CompletionContext::Version`] context —
1327 /// called only by [`Self::generate_completions`]'s default dispatch.
1328 ///
1329 /// Required in spirit: every ecosystem serves version completion (the one property this
1330 /// method preserves from before #793 — requiredness is orthogonal to the wildcard-match
1331 /// bug class itself, which is fixed by the dispatch match's *location*, not by which hook
1332 /// is required). `package_name`/`prefix` come from the resolved context; several
1333 /// implementations ignore them and instead re-derive the dependency at
1334 /// `request.position` from `request.parse_result` (cursor-position-based routing, issue
1335 /// #593) — deliberate divergence between ecosystems, not a mistake to unify, since the
1336 /// two lookups can disagree (see `deps-cargo`'s position-based migration history).
1337 ///
1338 /// Default implementation delegates to
1339 /// [`crate::completion::complete_versions_at_position`] using `self.registry()`,
1340 /// `self.formatter()`, and [`Self::version_operator_chars`] — the pattern `deps-npm`,
1341 /// `deps-pypi`, `deps-go`, `deps-composer`, `deps-swift`, `deps-nuget`, `deps-cargo`,
1342 /// `deps-dart`, `deps-bundler`, and `deps-deno` all hand-implemented identically before
1343 /// this default existed (#1223). Only `deps-gradle` and `deps-maven` (both route by
1344 /// dependency *name* through [`crate::completion::complete_versions_generic_from`]
1345 /// directly, not by cursor position) and `deps-github-actions`/`deps-gitlab-ci` (each
1346 /// wraps [`crate::completion::complete_versions_at_position`] in ecosystem-specific
1347 /// guard logic — GitHub Actions withholds completion past a SHA pin's trailing comment;
1348 /// GitLab CI re-derives its own position-based dependency lookup with an extra
1349 /// literal-range check) have genuinely different version-completion behavior and
1350 /// override this method instead of `version_operator_chars`.
1351 #[cfg(feature = "lsp-responses")]
1352 fn complete_version<'a>(
1353 &'a self,
1354 request: crate::completion::CompletionRequest<'a>,
1355 _package_name: crate::PackageName,
1356 prefix: String,
1357 ) -> BoxFuture<'a, Completions> {
1358 Box::pin(async move {
1359 let registry = self.registry();
1360 crate::completion::complete_versions_at_position(
1361 registry.as_ref(),
1362 self.formatter(),
1363 request.parse_result,
1364 request.position,
1365 &prefix,
1366 self.version_operator_chars(),
1367 request.freshness,
1368 )
1369 .await
1370 .into()
1371 })
1372 }
1373
1374 /// Completion hook for a [`crate::completion::CompletionContext::Feature`] context —
1375 /// called only by [`Self::generate_completions`]'s default dispatch.
1376 ///
1377 /// Default: no feature completion (`Completions::default()`) — correct for every
1378 /// ecosystem without a feature-flag concept. Overridden today by `deps-cargo` and
1379 /// `deps-go`.
1380 #[cfg(feature = "lsp-responses")]
1381 fn complete_feature<'a>(
1382 &'a self,
1383 _request: crate::completion::CompletionRequest<'a>,
1384 _package_name: crate::PackageName,
1385 _prefix: String,
1386 ) -> BoxFuture<'a, Completions> {
1387 Box::pin(std::future::ready(Completions::default()))
1388 }
1389
1390 /// Raw-text search prefix at `position` for `deps-lsp`'s fallback (parse-failure)
1391 /// completion path, used when the manifest failed to parse (typically mid-edit) so
1392 /// [`Self::generate_completions`]'s parsed-AST path has no [`ParseResult`] to work
1393 /// from.
1394 ///
1395 /// `content` is the whole manifest text; the implementation locates the line at
1396 /// `position`, decides whether that line falls inside a dependencies-like section
1397 /// using its own raw-text heuristic (see `deps_core::fallback_completion` for the
1398 /// scanners shared across ecosystems, e.g. [`crate::fallback_completion::
1399 /// is_in_toml_dependencies`]), and — only when it does — extracts the text the user
1400 /// has typed up to the cursor, stripping any manifest-syntax wrapper (a JSON
1401 /// string's quotes, an XML tag or attribute value) so the returned prefix is a bare
1402 /// candidate package-name fragment. `None` means either the cursor is not at a
1403 /// completable position at all, or this ecosystem's manifest format has no raw-text
1404 /// section boundary cheap enough to detect this way (e.g. Gradle's five manifest
1405 /// formats, or Swift's/Bundler's whole-file-scoped dependency calls).
1406 ///
1407 /// The caller applies its own ecosystem-agnostic guards to the returned prefix
1408 /// (length 2-200 chars, no `=` character) before searching the registry — this method
1409 /// only answers "is there a prefix here, and what manifest-syntax wrapper does it
1410 /// need stripped", not "is this prefix worth searching for".
1411 ///
1412 /// Default `None`: correct for every ecosystem with no raw-text section boundary.
1413 /// Unlike [`Self::completion_insert_text`], deliberately *not* required — a missing
1414 /// override only disables fallback completion for a 15th ecosystem (a feature gap),
1415 /// never silently emits wrong manifest syntax the way an unhandled
1416 /// [`Self::completion_insert_text`] arm could (see issue #118's failure mode).
1417 fn fallback_completion_prefix<'a>(
1418 &self,
1419 _content: &'a str,
1420 _position: crate::position::Position,
1421 ) -> Option<&'a str> {
1422 None
1423 }
1424
1425 /// Manifest-syntax snippet to insert for a completed package, given its registry
1426 /// search `metadata` — the ecosystem-specific counterpart to
1427 /// [`Self::fallback_completion_prefix`]. Called only from `deps-lsp`'s raw-text
1428 /// fallback (parse-failure) completion path; the primary (parsed) completion path
1429 /// builds its own insert text via `deps_core::completion::build_package_completion`
1430 /// / `complete_package_names_generic` and never calls this method.
1431 ///
1432 /// Takes `&dyn Metadata` rather than separate `name`/`latest_version` parameters:
1433 /// an ecosystem's snippet may need more than those two fields (Swift's needs
1434 /// [`Metadata::repository`] to build a `.package(url:, from:)` call). Returns `None`
1435 /// to reject the completion entirely — e.g. a per-ecosystem gate on a structural
1436 /// character that would otherwise let a malicious/compromised registry response
1437 /// break out of the inserted snippet's syntax (Maven's coordinate-segment gate,
1438 /// Swift's registry-URL gate, GitHub Actions' `owner/repo` shape check). The caller
1439 /// (`deps-lsp`) does not log a rejection itself — an implementation that rejects a
1440 /// value is expected to log it via [`crate::lsp_helpers::warn_rejected_value`]
1441 /// first, the way Maven/Swift/GitHub Actions do inside their own overrides.
1442 ///
1443 /// Deliberately **required**, no default: a missing override here would silently
1444 /// insert wrong manifest syntax for whatever ecosystem forgot to implement it
1445 /// (exactly the failure mode issue #118 fixed for ecosystem-identity matching in
1446 /// general — this is the same principle applied to completion-insert syntax).
1447 ///
1448 /// The two upfront, ecosystem-agnostic gates — [`crate::lsp_helpers::
1449 /// is_safe_package_name`] on `metadata.name()` and [`crate::lsp_helpers::
1450 /// is_safe_version_string`] on `metadata.latest_version()` (whenever non-empty) —
1451 /// run in the caller before this method is invoked, not inside it: every ecosystem
1452 /// interpolates those two fields, so checking them once in `deps-lsp` avoids
1453 /// re-deriving the same two allowlist checks in every implementation.
1454 fn completion_insert_text(&self, metadata: &dyn Metadata) -> Option<String>;
1455
1456 /// Whether the prefix [`Self::fallback_completion_prefix`] just returned for this
1457 /// `content`/`position` sits inside manifest markup that can only safely hold the
1458 /// bare candidate text — an already-open XML tag/attribute value, JSON object key,
1459 /// or TOML quoted string — rather than [`Self::completion_insert_text`]'s normal
1460 /// full snippet.
1461 ///
1462 /// Inserting the full snippet where markup is already open would nest a duplicate
1463 /// copy of it (issue #724, the original NuGet report: `Include="Newt` accepting a
1464 /// completion produced `Include="Newt<PackageReference Include="..." .../>`).
1465 /// `deps-lsp`'s fallback-completion caller checks this once per call, alongside
1466 /// [`Self::fallback_completion_prefix`], and routes to
1467 /// [`Self::fallback_bare_insert_text`] instead of [`Self::completion_insert_text`]
1468 /// when it returns `true`.
1469 ///
1470 /// Default `false`: correct for every ecosystem whose manifest syntax has no
1471 /// concept of "already open" markup around a fallback-completion cursor. Overridden
1472 /// today by Maven/NuGet (XML tag/attribute), npm/Composer (JSON object key), and
1473 /// PyPI (TOML quoted string). Not required, like [`Self::fallback_completion_prefix`]:
1474 /// a missing override only means a future markup-shaped ecosystem always gets the
1475 /// full-snippet insert, a feature gap rather than #118's "silently wrong syntax"
1476 /// failure mode.
1477 fn fallback_completion_is_bare(
1478 &self,
1479 _content: &str,
1480 _position: crate::position::Position,
1481 ) -> bool {
1482 false
1483 }
1484
1485 /// Bare candidate-name text to insert when [`Self::fallback_completion_is_bare`]
1486 /// reports the cursor already sits inside open markup — the counterpart to
1487 /// [`Self::completion_insert_text`] for that case. Only called when
1488 /// [`Self::fallback_completion_is_bare`] returned `true` for the same
1489 /// `content`/`position`.
1490 ///
1491 /// Returns `None` to reject the completion entirely, the same rejection semantics
1492 /// as [`Self::completion_insert_text`] — e.g. Maven's `artifactId` half still needs
1493 /// its own [`crate::is_safe_maven_coordinate_segment`] gate here, since the two
1494 /// upfront ecosystem-agnostic gates the caller runs beforehand
1495 /// ([`crate::lsp_helpers::is_safe_package_name`] on `metadata.name()` and
1496 /// [`crate::lsp_helpers::is_safe_version_string`] on `metadata.latest_version()`)
1497 /// validate the *whole* `name`, not a substring split out of it.
1498 ///
1499 /// Default: `metadata.name()` verbatim — correct for NuGet (and any future
1500 /// attribute-valued ecosystem whose full snippet's name field is the bare name
1501 /// unmodified), since `metadata.name()` already passed the caller's
1502 /// `is_safe_package_name` gate before this method runs. Maven overrides this: its
1503 /// `name` is a `group:artifact` compound, and only the `artifact` half belongs in
1504 /// an already-open `<artifactId>` tag.
1505 fn fallback_bare_insert_text(&self, metadata: &dyn Metadata) -> Option<String> {
1506 Some(metadata.name().as_str().to_string())
1507 }
1508
1509 /// Whether this ecosystem's package-name search may return a truncated view of
1510 /// a larger candidate set (see e.g. `PypiRegistry::search`'s doc comment).
1511 ///
1512 /// [`generate_completions`](Ecosystem::generate_completions) already reports
1513 /// this precisely per call via [`Completions::is_incomplete`] whenever a real
1514 /// completion context is available. This method exists only for the two
1515 /// `deps-lsp` code paths that cannot compute that precise per-call signal
1516 /// because no context has been resolved yet:
1517 ///
1518 /// - the raw-text fallback search (`fallback_completion`), which always
1519 /// performs a package-name lookup via `<dyn Registry>::search` regardless
1520 /// of what completion context (or lack thereof) triggered it;
1521 /// - the document-not-loaded early return, before any `ParseResult` — and so
1522 /// any completion context — exists to call `generate_completions` with.
1523 ///
1524 /// Unlike the ecosystem-wide `completions_are_incomplete()` flag this method
1525 /// superseded (#419, removed in #427), it never gates the *primary*
1526 /// `generate_completions` response — only these two context-less fallbacks.
1527 /// Default `false` preserves existing behavior for every ecosystem whose
1528 /// package-name search is always exhaustive.
1529 fn package_search_is_incomplete(&self) -> bool {
1530 false
1531 }
1532
1533 /// Fetches `name`'s license at `version` from this ecosystem's own tier-3 license
1534 /// source (issue #660/#688/#697), via `deps-engine`'s shared
1535 /// `classify::license::prefetch_tier3_licenses`/`fetch_tier3_licenses` (issue #1133) —
1536 /// `deps-lsp`'s `document::osv_scan::run_license_prefetch` calls it as a background
1537 /// pre-fetch, and `deps-cli`'s `report::check_manifest` calls it directly before
1538 /// evaluating `license_policy`. Never called from `deps-lsp`'s hover critical path
1539 /// directly, since it may perform network I/O.
1540 ///
1541 /// Only ever called when
1542 /// <code>self.[license_source](Self::license_source)().[requires_dedicated_fetch](LicenseSource::requires_dedicated_fetch)()</code>
1543 /// is `true`; the default implementation returns an already-resolved empty result
1544 /// and is never actually awaited by a correctly gated caller. Overriding this
1545 /// without also overriding [`Self::license_source`] to a variant whose
1546 /// `requires_dedicated_fetch()` is `true` leaves the override dead code — nothing
1547 /// automated cross-checks the two methods against each other, so keeping them in
1548 /// sync for a new override is the implementor's responsibility.
1549 fn fetch_license<'a>(
1550 &'a self,
1551 _name: &'a str,
1552 _version: &'a str,
1553 ) -> BoxFuture<'a, Vec<String>> {
1554 Box::pin(std::future::ready(Vec::new()))
1555 }
1556
1557 /// How this ecosystem's license strings are sourced. See [`LicenseSource`].
1558 ///
1559 /// Default [`LicenseSource::RegistryDeclaredSpdx`] is correct for every ecosystem
1560 /// whose license is an author-declared SPDX identifier already present in the
1561 /// hot-path registry response. This is also the single answer to whether
1562 /// [`Self::fetch_license`] is ever called for this ecosystem — see
1563 /// [`LicenseSource::requires_dedicated_fetch`].
1564 fn license_source(&self) -> LicenseSource {
1565 LicenseSource::RegistryDeclaredSpdx
1566 }
1567
1568 /// Support for downcasting to concrete ecosystem type
1569 ///
1570 /// This allows ecosystem-specific operations when needed.
1571 fn as_any(&self) -> &dyn Any;
1572
1573 /// Builds the edits for this ecosystem's bulk "Pin N {noun} to commit SHA" code lens
1574 /// and `deps-lsp.pinAllToSha` command (issue #633, generalized cross-ecosystem in
1575 /// #640) — one [`TextEdit`] per mutable-ref pin resolvable to a commit SHA from
1576 /// already-in-hand data, mirroring [`crate::collect_update_all_edits`]'s
1577 /// recompute-at-click-time contract for the sibling "Update N outdated
1578 /// dependencies" lens.
1579 ///
1580 /// `versions` is threaded through for an ecosystem (e.g. `deps-gitlab-ci`) whose
1581 /// dynamic pin forms need the caller's already-fetched version data to resolve
1582 /// without an extra network round trip — a lens is push-based and must never block
1583 /// on, or trigger, a fetch. Empty by default: most ecosystems have no mutable-ref
1584 /// pin concept at all. The lens/command wiring itself lives in `deps-lsp`'s
1585 /// `handlers::code_lens` (not a trait default here), so no override of
1586 /// [`Self::generate_code_lenses`] can accidentally suppress it.
1587 #[cfg(feature = "lsp-responses")]
1588 fn collect_pin_all_to_sha_edits(
1589 &self,
1590 _parse_result: &dyn ParseResult,
1591 _versions: VersionData<'_>,
1592 ) -> Vec<TextEdit> {
1593 Vec::new()
1594 }
1595
1596 /// Singular/plural noun for this ecosystem's bulk "Pin N {noun} to commit SHA" lens
1597 /// title (e.g. `{ singular: "action", plural: "actions" }` for GitHub Actions),
1598 /// consulted only when [`Self::collect_pin_all_to_sha_edits`] returns at least one
1599 /// edit. Default `{ "ref", "refs" }` is a generic fallback; override to match the
1600 /// ecosystem's own vocabulary.
1601 #[cfg(feature = "lsp-responses")]
1602 fn pin_all_to_sha_noun(&self) -> crate::lsp_helpers::PinNoun {
1603 crate::lsp_helpers::PinNoun {
1604 singular: "ref",
1605 plural: "refs",
1606 }
1607 }
1608}
1609
1610#[cfg(test)]
1611mod tests {
1612 use super::*;
1613
1614 use std::assert_matches;
1615
1616 #[test]
1617 fn test_ecosystem_id_roundtrip() {
1618 const ALL: &[EcosystemId] = &[
1619 EcosystemId::Cargo,
1620 EcosystemId::Npm,
1621 EcosystemId::Pypi,
1622 EcosystemId::Go,
1623 EcosystemId::Bundler,
1624 EcosystemId::Dart,
1625 EcosystemId::Maven,
1626 EcosystemId::Composer,
1627 EcosystemId::Gradle,
1628 EcosystemId::Swift,
1629 EcosystemId::NuGet,
1630 EcosystemId::Deno,
1631 EcosystemId::GithubActions,
1632 EcosystemId::GitlabCi,
1633 ];
1634
1635 for id in ALL {
1636 let parsed: EcosystemId = id.id().parse().unwrap();
1637 assert_eq!(parsed, *id);
1638 assert_eq!(id.to_string(), id.id());
1639 }
1640 }
1641
1642 #[test]
1643 fn test_osv_ecosystem_mapping_pinned() {
1644 let expected: &[(EcosystemId, &str)] = &[
1645 (EcosystemId::Cargo, "crates.io"),
1646 (EcosystemId::Npm, "npm"),
1647 (EcosystemId::Pypi, "PyPI"),
1648 (EcosystemId::Go, "Go"),
1649 (EcosystemId::Bundler, "RubyGems"),
1650 (EcosystemId::Dart, "Pub"),
1651 (EcosystemId::Maven, "Maven"),
1652 (EcosystemId::Composer, "Packagist"),
1653 (EcosystemId::Gradle, "Maven"),
1654 (EcosystemId::Swift, "SwiftURL"),
1655 (EcosystemId::NuGet, "NuGet"),
1656 (EcosystemId::Deno, "npm"),
1657 (EcosystemId::GithubActions, "GitHub Actions"),
1658 ];
1659
1660 for (id, expected_str) in expected {
1661 assert_eq!(
1662 id.osv_ecosystem(),
1663 Some(*expected_str),
1664 "unexpected OSV ecosystem string for {id:?}"
1665 );
1666 }
1667
1668 // A git-tag/release pin has no OSV coordinate by name (see `osv_ecosystem`'s doc).
1669 assert_eq!(EcosystemId::GitlabCi.osv_ecosystem(), None);
1670 }
1671
1672 #[test]
1673 fn test_ecosystem_id_from_str_unknown() {
1674 let err = "unknown".parse::<EcosystemId>().unwrap_err();
1675 assert_matches!(err, crate::error::DepsError::UnsupportedEcosystem(s) if s == "unknown");
1676 }
1677
1678 #[test]
1679 fn test_ecosystem_config_default() {
1680 let config = EcosystemConfig::default();
1681 assert!(config.show_up_to_date_hints);
1682 assert_eq!(config.up_to_date_text, "✅");
1683 assert_eq!(config.needs_update_text, "❌ {}");
1684 }
1685
1686 #[test]
1687 fn test_ecosystem_config_custom() {
1688 let config = EcosystemConfig {
1689 show_up_to_date_hints: false,
1690 up_to_date_text: "OK".to_string(),
1691 needs_update_text: "Update to {}".to_string(),
1692 loading_text: "Loading...".to_string(),
1693 show_loading_hints: false,
1694 offline: false,
1695 };
1696 assert!(!config.show_up_to_date_hints);
1697 assert_eq!(config.up_to_date_text, "OK");
1698 assert_eq!(config.needs_update_text, "Update to {}");
1699 }
1700
1701 #[test]
1702 fn test_ecosystem_config_clone() {
1703 let config1 = EcosystemConfig::default();
1704 let config2 = config1.clone();
1705 assert_eq!(config1.up_to_date_text, config2.up_to_date_text);
1706 assert_eq!(config1.show_up_to_date_hints, config2.show_up_to_date_hints);
1707 assert_eq!(config1.needs_update_text, config2.needs_update_text);
1708 }
1709
1710 #[test]
1711 fn test_dependency_default_features() {
1712 struct MockDep;
1713 impl Dependency for MockDep {
1714 fn name(&self) -> &crate::PackageName {
1715 static NAME: std::sync::LazyLock<crate::PackageName> =
1716 std::sync::LazyLock::new(|| crate::PackageName::new("test"));
1717 &NAME
1718 }
1719 fn name_range(&self) -> Range {
1720 Range::default()
1721 }
1722 fn version_requirement(&self) -> Option<&crate::VersionReq> {
1723 None
1724 }
1725 fn version_range(&self) -> Option<Range> {
1726 None
1727 }
1728 fn source(&self) -> crate::parser::DependencySource {
1729 crate::parser::DependencySource::Registry
1730 }
1731 fn as_any(&self) -> &dyn std::any::Any {
1732 self
1733 }
1734 }
1735
1736 let dep = MockDep;
1737 assert_eq!(dep.features(), &[] as &[String]);
1738 }
1739
1740 /// Minimal [`Dependency`] repeated `dep_count` times by [`StubParseResult`] (#796
1741 /// `parse_manifest_blocking` capping test) — identical instances are fine here since
1742 /// only the *count* `parse_manifest_blocking` sees is under test, not per-dependency
1743 /// identity.
1744 struct StubDep;
1745
1746 impl Dependency for StubDep {
1747 fn name(&self) -> &crate::PackageName {
1748 static NAME: std::sync::LazyLock<crate::PackageName> =
1749 std::sync::LazyLock::new(|| crate::PackageName::new("stub-dep"));
1750 &NAME
1751 }
1752 fn name_range(&self) -> Range {
1753 Range::default()
1754 }
1755 fn version_requirement(&self) -> Option<&crate::VersionReq> {
1756 None
1757 }
1758 fn version_range(&self) -> Option<Range> {
1759 None
1760 }
1761 fn source(&self) -> crate::parser::DependencySource {
1762 crate::parser::DependencySource::Registry
1763 }
1764 fn as_any(&self) -> &dyn std::any::Any {
1765 self
1766 }
1767 }
1768
1769 /// Minimal [`ParseResult`] returned by [`StubEcosystem::parse_manifest`] below —
1770 /// only [`parse_manifest_blocking`] tests need it, so it carries nothing beyond a URI
1771 /// and a synthetic dependency count.
1772 struct StubParseResult {
1773 uri: url::Url,
1774 dep_count: usize,
1775 }
1776
1777 impl ParseResult for StubParseResult {
1778 fn dependencies(&self) -> Vec<&dyn Dependency> {
1779 static STUB_DEP: StubDep = StubDep;
1780 vec![&STUB_DEP as &dyn Dependency; self.dep_count]
1781 }
1782
1783 fn workspace_root(&self) -> Option<&std::path::Path> {
1784 None
1785 }
1786
1787 fn uri(&self) -> &url::Url {
1788 &self.uri
1789 }
1790
1791 fn as_any(&self) -> &dyn Any {
1792 self
1793 }
1794 }
1795
1796 /// Ecosystem stub for [`parse_manifest_blocking`] tests: `parse_manifest` either
1797 /// asserts it runs off `calling_thread` or panics, per `should_panic`.
1798 struct StubEcosystem {
1799 calling_thread: std::thread::ThreadId,
1800 should_panic: bool,
1801 /// Dependency count [`StubParseResult`] reports — `0` for every test except
1802 /// [`test_parse_manifest_blocking_caps_dependencies_over_the_ceiling`] (#796).
1803 dep_count: usize,
1804 }
1805
1806 impl private::Sealed for StubEcosystem {}
1807
1808 impl Ecosystem for StubEcosystem {
1809 fn id(&self) -> &'static str {
1810 "stub"
1811 }
1812
1813 fn ecosystem_id(&self) -> EcosystemId {
1814 EcosystemId::Cargo
1815 }
1816
1817 fn display_name(&self) -> &'static str {
1818 "Stub"
1819 }
1820
1821 fn manifest_filenames(&self) -> &[&'static str] {
1822 &[]
1823 }
1824
1825 fn parse_manifest<'a>(
1826 &'a self,
1827 _content: &'a str,
1828 uri: &'a url::Url,
1829 ) -> BoxFuture<'a, crate::error::Result<Box<dyn ParseResult>>> {
1830 Box::pin(async move {
1831 assert!(!self.should_panic, "boom");
1832 assert_ne!(
1833 std::thread::current().id(),
1834 self.calling_thread,
1835 "parse must run on the blocking pool, not the calling thread"
1836 );
1837 Ok(Box::new(StubParseResult {
1838 uri: uri.clone(),
1839 dep_count: self.dep_count,
1840 }) as Box<dyn ParseResult>)
1841 })
1842 }
1843
1844 fn registry(&self) -> Arc<dyn crate::Registry> {
1845 unimplemented!()
1846 }
1847
1848 fn formatter(&self) -> &dyn crate::lsp_helpers::EcosystemFormatter {
1849 unimplemented!()
1850 }
1851
1852 #[cfg(feature = "lsp-responses")]
1853 fn generate_completions<'a>(
1854 &'a self,
1855 _parse_result: &'a dyn ParseResult,
1856 _position: Position,
1857 _content: &'a str,
1858 _freshness: crate::FreshnessSettings,
1859 ) -> BoxFuture<'a, crate::completion::Completions> {
1860 unimplemented!()
1861 }
1862
1863 #[cfg(feature = "lsp-responses")]
1864 fn complete_version<'a>(
1865 &'a self,
1866 _request: crate::completion::CompletionRequest<'a>,
1867 _package_name: crate::PackageName,
1868 _prefix: String,
1869 ) -> BoxFuture<'a, crate::completion::Completions> {
1870 unimplemented!()
1871 }
1872
1873 fn completion_insert_text(&self, _metadata: &dyn crate::Metadata) -> Option<String> {
1874 unimplemented!()
1875 }
1876
1877 fn as_any(&self) -> &dyn Any {
1878 self
1879 }
1880 }
1881
1882 /// #1195: the default [`Ecosystem::generate_completions`] dispatch — not an ecosystem
1883 /// override — must stamp [`crate::completion::CompletionOrigin::PackageName`]/`Version`/
1884 /// `Feature` on whichever hook it dispatches to, and `Unresolved` when no
1885 /// [`crate::completion::CompletionContext`] resolves. This is the mapping every
1886 /// default-dispatch ecosystem crate (11 of 14) relies on for `deps-lsp`'s fallback gate
1887 /// without any code of its own.
1888 #[cfg(feature = "lsp-responses")]
1889 #[tokio::test]
1890 async fn test_generate_completions_default_dispatch_stamps_origin_per_context() {
1891 use crate::completion::CompletionOrigin;
1892
1893 struct OriginMockDep {
1894 name: Range,
1895 version: Range,
1896 features: Range,
1897 }
1898 impl Dependency for OriginMockDep {
1899 fn name(&self) -> &crate::PackageName {
1900 static NAME: std::sync::LazyLock<crate::PackageName> =
1901 std::sync::LazyLock::new(|| crate::PackageName::new("demo"));
1902 &NAME
1903 }
1904 fn name_range(&self) -> Range {
1905 self.name
1906 }
1907 fn version_requirement(&self) -> Option<&crate::VersionReq> {
1908 static REQ: std::sync::LazyLock<crate::VersionReq> =
1909 std::sync::LazyLock::new(|| crate::VersionReq::new("1.0"));
1910 Some(&REQ)
1911 }
1912 fn version_range(&self) -> Option<Range> {
1913 Some(self.version)
1914 }
1915 fn source(&self) -> crate::parser::DependencySource {
1916 crate::parser::DependencySource::Registry
1917 }
1918 fn features_range(&self) -> Option<Range> {
1919 Some(self.features)
1920 }
1921 fn as_any(&self) -> &dyn Any {
1922 self
1923 }
1924 }
1925
1926 struct OriginMockParseResult {
1927 dep: OriginMockDep,
1928 uri: url::Url,
1929 }
1930 impl ParseResult for OriginMockParseResult {
1931 fn dependencies(&self) -> Vec<&dyn Dependency> {
1932 vec![&self.dep]
1933 }
1934 fn workspace_root(&self) -> Option<&std::path::Path> {
1935 None
1936 }
1937 fn uri(&self) -> &url::Url {
1938 &self.uri
1939 }
1940 fn as_any(&self) -> &dyn Any {
1941 self
1942 }
1943 }
1944
1945 struct OriginMockEcosystem;
1946 impl private::Sealed for OriginMockEcosystem {}
1947 impl Ecosystem for OriginMockEcosystem {
1948 fn ecosystem_id(&self) -> EcosystemId {
1949 EcosystemId::Cargo
1950 }
1951 fn display_name(&self) -> &'static str {
1952 "origin-mock"
1953 }
1954 fn manifest_filenames(&self) -> &[&'static str] {
1955 &[]
1956 }
1957 fn parse_manifest<'a>(
1958 &'a self,
1959 _content: &'a str,
1960 _uri: &'a url::Url,
1961 ) -> BoxFuture<'a, crate::error::Result<Box<dyn ParseResult>>> {
1962 Box::pin(async move { unimplemented!() })
1963 }
1964 fn registry(&self) -> Arc<dyn crate::Registry> {
1965 unimplemented!()
1966 }
1967 fn formatter(&self) -> &dyn crate::lsp_helpers::EcosystemFormatter {
1968 unimplemented!()
1969 }
1970 fn complete_version<'a>(
1971 &'a self,
1972 _request: crate::completion::CompletionRequest<'a>,
1973 _package_name: crate::PackageName,
1974 _prefix: String,
1975 ) -> BoxFuture<'a, Completions> {
1976 Box::pin(std::future::ready(Completions::new(vec![])))
1977 }
1978 fn completion_insert_text(&self, _metadata: &dyn crate::Metadata) -> Option<String> {
1979 unimplemented!()
1980 }
1981 fn as_any(&self) -> &dyn Any {
1982 self
1983 }
1984 }
1985 // `complete_package_name`/`complete_feature` use the trait defaults
1986 // (`Completions::default()`) — this test only cares about the stamped `origin`.
1987
1988 let content = "demo\n1.0\nderive\n";
1989 let dep = OriginMockDep {
1990 name: Range::new(
1991 crate::position::Position::new(0, 0),
1992 crate::position::Position::new(0, 4),
1993 ),
1994 version: Range::new(
1995 crate::position::Position::new(1, 0),
1996 crate::position::Position::new(1, 3),
1997 ),
1998 features: Range::new(
1999 crate::position::Position::new(2, 0),
2000 crate::position::Position::new(2, 6),
2001 ),
2002 };
2003 let parse_result = OriginMockParseResult {
2004 dep,
2005 uri: crate::test_util::test_uri("/test/manifest.toml"),
2006 };
2007 let eco = OriginMockEcosystem;
2008 let freshness = crate::FreshnessSettings::default();
2009
2010 for (position, expected) in [
2011 (Position::new(0, 2), CompletionOrigin::PackageName),
2012 (Position::new(1, 1), CompletionOrigin::Version),
2013 (Position::new(2, 3), CompletionOrigin::Feature),
2014 (Position::new(3, 0), CompletionOrigin::Unresolved),
2015 ] {
2016 let result = eco
2017 .generate_completions(&parse_result, position, content, freshness)
2018 .await;
2019 assert_eq!(
2020 result.origin, expected,
2021 "position {position:?} expected origin {expected:?}, got {:?}",
2022 result.origin
2023 );
2024 }
2025 }
2026
2027 /// Proves `parse_manifest_blocking` actually runs the parse off the calling (async)
2028 /// thread via `spawn_blocking`, mirroring `lockfile.rs`'s
2029 /// `test_read_and_parse_lockfile_runs_parse_off_calling_thread`.
2030 #[tokio::test]
2031 async fn test_parse_manifest_blocking_runs_parse_off_calling_thread() {
2032 let ecosystem: Arc<dyn Ecosystem> = Arc::new(StubEcosystem {
2033 calling_thread: std::thread::current().id(),
2034 should_panic: false,
2035 dep_count: 0,
2036 });
2037 let uri = crate::test_util::test_uri("/test/manifest.toml");
2038
2039 let parsed = parse_manifest_blocking(&ecosystem, "content", &uri)
2040 .await
2041 .unwrap();
2042 assert_eq!(parsed.uri(), &uri);
2043 }
2044
2045 /// #796: `parse_manifest_blocking` is the single chokepoint every ecosystem's parse
2046 /// result flows through — proves it actually applies
2047 /// `dependency_cap::cap_dependencies`, not just that the wrapper type behaves
2048 /// correctly in isolation (covered by `dependency_cap`'s own tests).
2049 #[tokio::test]
2050 async fn test_parse_manifest_blocking_caps_dependencies_over_the_ceiling() {
2051 let ecosystem: Arc<dyn Ecosystem> = Arc::new(StubEcosystem {
2052 calling_thread: std::thread::current().id(),
2053 should_panic: false,
2054 dep_count: crate::dependency_cap::MAX_DEPENDENCIES_PER_DOCUMENT + 1,
2055 });
2056 let uri = crate::test_util::test_uri("/test/manifest.toml");
2057
2058 let parsed = parse_manifest_blocking(&ecosystem, "content", &uri)
2059 .await
2060 .unwrap();
2061
2062 assert_eq!(
2063 parsed.dependencies().len(),
2064 crate::dependency_cap::MAX_DEPENDENCIES_PER_DOCUMENT
2065 );
2066 assert_eq!(
2067 parsed.dependency_truncation(),
2068 Some((
2069 crate::dependency_cap::MAX_DEPENDENCIES_PER_DOCUMENT,
2070 crate::dependency_cap::MAX_DEPENDENCIES_PER_DOCUMENT + 1
2071 ))
2072 );
2073 }
2074
2075 /// A panicking `parse_manifest` must surface as `Err(DepsError::ParseError)` with the
2076 /// panic message preserved, mirroring `lockfile.rs`'s
2077 /// `test_read_and_parse_lockfile_panic_in_parse_becomes_parse_error`.
2078 #[tokio::test]
2079 async fn test_parse_manifest_blocking_panic_becomes_parse_error() {
2080 let ecosystem: Arc<dyn Ecosystem> = Arc::new(StubEcosystem {
2081 calling_thread: std::thread::current().id(),
2082 should_panic: true,
2083 dep_count: 0,
2084 });
2085 let uri = crate::test_util::test_uri("/test/manifest.toml");
2086
2087 let Err(err) = parse_manifest_blocking(&ecosystem, "content", &uri).await else {
2088 panic!("expected parse_manifest_blocking to return an error");
2089 };
2090
2091 match err {
2092 crate::error::DepsError::ParseError { file_type, source } => {
2093 assert!(file_type.contains("manifest at"));
2094 assert!(
2095 source.to_string().contains("boom"),
2096 "panic message should be preserved in the error source, got: {source}"
2097 );
2098 }
2099 other => panic!("Expected ParseError, got: {other:?}"),
2100 }
2101 }
2102
2103 crate::debug_redaction_conformance!(
2104 test_blocked_registry_occurrence_debug_redacts_credentials,
2105 2,
2106 BlockedRegistryOccurrence {
2107 range: Range::default(),
2108 class: crate::net_policy::HostClass::Loopback,
2109 raw_value: crate::conformance::CREDENTIAL_PROBE_URL.to_string(),
2110 declaration_key: crate::conformance::CREDENTIAL_PROBE_KEY.to_string(),
2111 },
2112 );
2113
2114 crate::debug_redaction_conformance!(
2115 test_blocked_source_class_debug_redacts_credentials,
2116 2,
2117 BlockedSourceClass {
2118 class: crate::net_policy::HostClass::Loopback,
2119 raw_value: crate::conformance::CREDENTIAL_PROBE_URL.to_string(),
2120 declaration_key: crate::conformance::CREDENTIAL_PROBE_KEY.to_string(),
2121 },
2122 );
2123}