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