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