Skip to main content

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}