Skip to main content

deps_core/lsp_helpers/
mod.rs

1//! Shared LSP response builders.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::Duration;
6
7use crate::error::DepsError;
8use crate::licenses::LicensePolicy;
9use crate::osv::VulnerabilityMap;
10use crate::position::{Position, Range};
11use crate::{
12    ConcreteVersion, Dependency, Deprecation, DepsDevClient, EcosystemId, FetchFailure,
13    LicenseSource, PackageName, RemovalStatus,
14};
15
16#[cfg(feature = "lsp-responses")]
17mod code_actions;
18#[cfg(feature = "lsp-responses")]
19mod code_lenses;
20mod diagnostics;
21mod formatter;
22mod git_ref;
23#[cfg(feature = "lsp-responses")]
24mod hover;
25mod in_use_version;
26#[cfg(feature = "lsp-responses")]
27mod inlay_hints;
28#[cfg(test)]
29pub(crate) mod test_support;
30
31#[cfg(feature = "lsp-responses")]
32pub use code_actions::generate_code_actions;
33#[cfg(feature = "lsp-responses")]
34pub use code_lenses::{
35    PIN_ALL_TO_SHA_COMMAND_ID, PinNoun, build_pin_all_to_sha_lens, collect_update_all_edits,
36    dedup_overlapping_edits, generate_code_lenses,
37};
38pub use diagnostics::{
39    DEPRECATED_DIAGNOSTIC_CODE, DiagnosticSeverities, LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE,
40    UNSATISFIABLE_DIAGNOSTIC_CODE, compile_requirement_unless, generate_diagnostics_from_cache,
41    redact_name_for_diagnostic, requirement_is_unsatisfiable,
42    sanitize_advisory_text_for_diagnostic, sanitize_and_truncate_for_diagnostic,
43    truncate_for_diagnostic,
44};
45pub use formatter::{
46    DiagnosticMessages, DiagnosticPolicy, EcosystemFormatter, OsvNaming, PackageNaming,
47    PackageRendering, RequirementResolution, SourcePolicy,
48};
49pub use git_ref::{
50    MAX_FALLBACK_SCAN_BYTES, MarkedScalar, byte_span_to_range, is_full_sha, is_null_tag,
51    is_partial_semver_shaped, is_plain_null, is_tag_shaped, locate_value_span, marker_byte_offset,
52    match_v_prefix_style,
53};
54#[cfg(feature = "lsp-responses")]
55pub use git_ref::{
56    ResolvedShaPin, ShaPinning, build_sha_pin_action, sha_pin_text_edit, splice_resolved_line,
57};
58#[cfg(feature = "lsp-responses")]
59pub use hover::{CMD_DOT_FOOTER, generate_hover};
60pub use in_use_version::{concrete_pin_version, is_full_semver_shape, resolve_in_use_version};
61#[cfg(feature = "lsp-responses")]
62pub use inlay_hints::generate_inlay_hints;
63
64/// Maximum number of recent versions hover's "Recent versions" section renders.
65///
66/// Also the walk target for registries (NuGet, npm) that must fetch publish times for
67/// only the versions actually rendered, rather than the entire version history.
68pub const HOVER_RECENT_VERSIONS: usize = 8;
69
70/// Registry version data for one package, fetched together in a single round trip.
71///
72/// `latest` and `available` are deliberately asymmetric — this is load-bearing, not an
73/// oversight:
74/// - `latest` comes from this ecosystem's own `Registry::select_latest_matching(.., "*")`
75///   pick, which excludes yanked (and, for semver/node-semver `*`, prerelease) versions —
76///   the same value `get_latest_matching` returned before this type existed.
77/// - `available` is the **unfiltered** `get_versions` output: every published version,
78///   newest-first, yanked and prerelease entries included.
79///
80/// The unsatisfiable-requirement check (see `crate::lsp_helpers::requirement_is_unsatisfiable`)
81/// scans `available` and deliberately does not filter it: a requirement that only matches a
82/// yanked or prerelease version is still satisfied, so filtering `available` the same way
83/// `latest` is filtered would produce false "no published version satisfies" warnings.
84///
85/// `yanked` is the subset of `available` (same version-string encoding) that the registry
86/// reported as yanked/deprecated, paired with each entry's [`RemovalStatus`]. It exists
87/// because `Registry::get_latest_matching` — the call that used to populate this cache —
88/// filters yanked entries out by contract on every current registry implementation, so a
89/// per-version yanked flag threaded through *that* call would always read `false` (see
90/// #233). `available` now comes from the unfiltered `get_versions` instead, which does
91/// observe yanked entries, so `yanked` is derived from that same fetch rather than
92/// discarded.
93///
94/// The status rides alongside each version (rather than a bare membership list) so
95/// [`crate::lsp_helpers::generate_diagnostics_from_cache`]'s #247 "requirement satisfiable
96/// only by a yanked version" check can gate its own package-level-deprecation suppression
97/// on `AdvisoryDeprecated` specifically, never on a genuine `Yanked` finding — mirroring
98/// [`VersionData::outcomes`]'s D5 gate for the #263 in-use-version check (see #437).
99#[non_exhaustive]
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct PackageVersions {
102    /// Latest usable version for this package.
103    pub latest: ConcreteVersion,
104    /// Every published version, newest-first, unfiltered.
105    pub available: Arc<[ConcreteVersion]>,
106    /// Subset of `available` reported as yanked/deprecated by the registry, each paired
107    /// with its [`RemovalStatus`].
108    pub yanked: Arc<[(ConcreteVersion, RemovalStatus)]>,
109    /// When `latest` was published, if the registry exposes it. `None` when
110    /// the ecosystem doesn't wire [`crate::Version::published_at`] or the fetch
111    /// never ran.
112    ///
113    /// Deliberately a field on this single per-package struct rather than a
114    /// second parallel map keyed alongside `latest` — the earlier two-map
115    /// design (issue #227 critique C3) let `latest` and its age drift apart
116    /// silently whenever one map was updated (e.g. lockfile-resolved
117    /// overwrite) without the other. Bundling them here makes that
118    /// desync impossible: whoever sets `latest` sets `published_at` too.
119    pub published_at: Option<crate::freshness::PublishTime>,
120}
121
122impl PackageVersions {
123    /// Constructs a `PackageVersions` from its two required fields, with [`Self::yanked`] and
124    /// [`Self::published_at`] left empty/`None` — chain [`Self::with_yanked`] and/or
125    /// [`Self::with_published_at`] to attach them.
126    ///
127    /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
128    /// this crate, so every other crate (including test code) must go through this
129    /// constructor instead.
130    ///
131    /// # Examples
132    ///
133    /// ```
134    /// use deps_core::{ConcreteVersion, PackageVersions};
135    /// use std::sync::Arc;
136    ///
137    /// let versions = PackageVersions::new(
138    ///     ConcreteVersion::new("2.0.0"),
139    ///     Arc::from(vec![ConcreteVersion::new("2.0.0"), ConcreteVersion::new("1.0.0")]),
140    /// );
141    /// assert_eq!(versions.latest, "2.0.0");
142    /// assert_eq!(versions.available.len(), 2);
143    /// assert!(versions.yanked.is_empty());
144    /// ```
145    #[must_use]
146    pub fn new(latest: ConcreteVersion, available: Arc<[ConcreteVersion]>) -> Self {
147        Self {
148            latest,
149            available,
150            yanked: Arc::from(Vec::new()),
151            published_at: None,
152        }
153    }
154
155    /// Attaches the registry's yanked/deprecated-version findings. See [`Self::yanked`].
156    #[must_use]
157    pub fn with_yanked(mut self, yanked: Arc<[(ConcreteVersion, RemovalStatus)]>) -> Self {
158        self.yanked = yanked;
159        self
160    }
161
162    /// Attaches when `latest` was published. See [`Self::published_at`].
163    #[must_use]
164    pub const fn with_published_at(mut self, published_at: crate::freshness::PublishTime) -> Self {
165        self.published_at = Some(published_at);
166        self
167    }
168
169    /// Builds a `PackageVersions` from only the "latest" version string, with `available`
170    /// populated as the single-element list `[latest]`.
171    ///
172    /// **Test-only in intent.** The one-element `available` this produces is a real, if
173    /// small, version list — it is not "empty/unknown", so `requirement_is_unsatisfiable`
174    /// will evaluate a requirement against it. Do **not** use this for a lock-file-only
175    /// population path that has no real version list to offer (use
176    /// [`latest_without_list`](Self::latest_without_list) there instead, which leaves
177    /// `available` genuinely empty) — that exact substitution is the false-positive N5 was
178    /// written to prevent: it would let the unsatisfiable-requirement check produce a
179    /// verdict against a fabricated one-entry list before any registry fetch has run. Real
180    /// registry fetches always populate `available` from the full `get_versions` result
181    /// instead of using either constructor.
182    ///
183    /// # Examples
184    ///
185    /// ```
186    /// use deps_core::{ConcreteVersion, PackageVersions};
187    ///
188    /// let versions = PackageVersions::latest_only("1.0.214");
189    /// assert_eq!(versions.latest, "1.0.214");
190    /// assert_eq!(&*versions.available, &[ConcreteVersion::new("1.0.214")]);
191    /// ```
192    pub fn latest_only(latest: impl Into<ConcreteVersion>) -> Self {
193        let latest = latest.into();
194        let available = Arc::from(vec![latest.clone()]);
195        Self {
196            latest,
197            available,
198            yanked: Arc::from(Vec::new()),
199            published_at: None,
200        }
201    }
202
203    /// Builds a `PackageVersions` with no version list — used where only the "latest" value
204    /// is known and probing further would be misleading, notably the lock-file population
205    /// path (`crates/deps-lsp/src/document/lifecycle.rs`), which must not populate a
206    /// plausible-looking one-element `available` list before any registry fetch has run: the
207    /// unsatisfiable-requirement check treats an empty `available` as "still loading, skip".
208    ///
209    /// # Examples
210    ///
211    /// ```
212    /// use deps_core::PackageVersions;
213    ///
214    /// let versions = PackageVersions::latest_without_list("1.0.195");
215    /// assert_eq!(versions.latest, "1.0.195");
216    /// assert!(versions.available.is_empty());
217    /// ```
218    pub fn latest_without_list(latest: impl Into<ConcreteVersion>) -> Self {
219        Self {
220            latest: latest.into(),
221            available: Arc::from(Vec::new()),
222            yanked: Arc::from(Vec::new()),
223            published_at: None,
224        }
225    }
226}
227
228/// Everything the lifecycle fetch learned about one package, keyed by its normalized name.
229///
230/// All three channels can hold simultaneously for the same package — this is load-bearing,
231/// not an incidental shape. The D5 status gate in
232/// [`generate_diagnostics_from_cache`] reads the [`Self::deprecation`] and [`Self::yanked`]
233/// entries for the same normalized name together and compares their [`RemovalStatus`], and on
234/// the didChange path a surviving deprecation can coexist with a later fetch failure. A single
235/// enum variant per package could not express that overlap.
236#[non_exhaustive]
237#[derive(Debug, Clone, Default, PartialEq)]
238pub struct DependencyOutcome {
239    /// The version found yanked (the in-use version, or `latest` when only `latest` itself is
240    /// yanked), paired with its [`RemovalStatus`]. See [`VersionData::outcomes`]'s prior
241    /// semantics (#233/#263).
242    pub yanked: Option<(ConcreteVersion, RemovalStatus)>,
243    /// Package-level deprecation finding (#205). See [`Deprecation`].
244    pub deprecation: Option<Deprecation>,
245    /// Registry fetch errored, timed out, or was never attempted (#267). See [`FetchFailure`].
246    pub fetch_failure: Option<FetchFailure>,
247    /// The registry fetch succeeded (no [`Self::fetch_failure`]) but produced zero
248    /// comparable versions — e.g. a real GitHub repository whose only tags don't parse
249    /// as full `major.minor.patch` semver (`dtolnay/rust-toolchain`'s sole tag `v1`,
250    /// issue #550). Distinct from both an absent [`DependencyOutcome`] entry ("never
251    /// fetched") and [`Self::fetch_failure`] ("couldn't be asked"): this package
252    /// demonstrably exists, so [R5](crate::lsp_helpers::generate_diagnostics_from_cache)
253    /// must not claim it is unknown.
254    pub no_comparable_versions: bool,
255}
256
257impl DependencyOutcome {
258    /// True when none of the four channels are set.
259    #[must_use]
260    pub const fn is_empty(&self) -> bool {
261        self.yanked.is_none()
262            && self.deprecation.is_none()
263            && self.fetch_failure.is_none()
264            && !self.no_comparable_versions
265    }
266}
267
268/// Normalized-package-name -> [`DependencyOutcome`] map.
269///
270/// A newtype rather than a bare `HashMap` so the empty-entry pruning invariant (an entry is
271/// removed once all three of its channels are cleared) lives in one place, and so test
272/// fixtures get chainable `with_*` constructors instead of building three ad-hoc `HashMap`s.
273/// Mirrors the existing [`crate::osv::VulnerabilityMap`] convention of a `String`-keyed map by
274/// normalized name.
275#[derive(Debug, Clone, Default, PartialEq)]
276pub struct DependencyOutcomes(HashMap<String, DependencyOutcome>);
277
278impl DependencyOutcomes {
279    /// Creates an empty map.
280    #[must_use]
281    pub fn new() -> Self {
282        Self::default()
283    }
284
285    /// Looks up the full outcome recorded for `name`.
286    #[must_use]
287    pub fn get(&self, name: &str) -> Option<&DependencyOutcome> {
288        self.0.get(name)
289    }
290
291    /// Looks up the yanked-version finding for `name`.
292    #[must_use]
293    pub fn yanked(&self, name: &str) -> Option<&(ConcreteVersion, RemovalStatus)> {
294        self.0.get(name)?.yanked.as_ref()
295    }
296
297    /// Looks up the package-level deprecation finding for `name`.
298    #[must_use]
299    pub fn deprecation(&self, name: &str) -> Option<&Deprecation> {
300        self.0.get(name)?.deprecation.as_ref()
301    }
302
303    /// Looks up the fetch-failure finding for `name`.
304    #[must_use]
305    pub fn fetch_failure(&self, name: &str) -> Option<&FetchFailure> {
306        self.0.get(name)?.fetch_failure.as_ref()
307    }
308
309    /// True when the registry fetch for `name` succeeded but produced zero comparable
310    /// versions (#550). See [`DependencyOutcome::no_comparable_versions`].
311    #[must_use]
312    pub fn no_comparable_versions(&self, name: &str) -> bool {
313        self.0.get(name).is_some_and(|o| o.no_comparable_versions)
314    }
315
316    /// Records a yanked-version finding for `name`, creating the entry if absent.
317    pub fn set_yanked(&mut self, name: String, yanked: (ConcreteVersion, RemovalStatus)) {
318        self.0.entry(name).or_default().yanked = Some(yanked);
319    }
320
321    /// Records a package-level deprecation finding for `name`, creating the entry if absent.
322    pub fn set_deprecation(&mut self, name: String, deprecation: Deprecation) {
323        self.0.entry(name).or_default().deprecation = Some(deprecation);
324    }
325
326    /// Records a fetch-failure finding for `name`, creating the entry if absent.
327    pub fn set_fetch_failure(&mut self, name: String, failure: FetchFailure) {
328        self.0.entry(name).or_default().fetch_failure = Some(failure);
329    }
330
331    /// Records a fetch-failure finding for `name` only if one is not already recorded,
332    /// creating the entry if absent.
333    pub fn set_fetch_failure_if_absent(&mut self, name: String, failure: FetchFailure) {
334        self.0
335            .entry(name)
336            .or_default()
337            .fetch_failure
338            .get_or_insert(failure);
339    }
340
341    /// Records that `name`'s registry fetch succeeded but produced zero comparable
342    /// versions (#550), creating the entry if absent.
343    pub fn set_no_comparable_versions(&mut self, name: String) {
344        self.0.entry(name).or_default().no_comparable_versions = true;
345    }
346
347    /// Clears the no-comparable-versions channel for `name`, pruning the entry if it
348    /// becomes empty.
349    pub fn clear_no_comparable_versions(&mut self, name: &str) {
350        if let Some(entry) = self.0.get_mut(name) {
351            entry.no_comparable_versions = false;
352            self.prune(name);
353        }
354    }
355
356    /// Clears the yanked-version channel for `name`, pruning the entry if it becomes empty.
357    pub fn clear_yanked(&mut self, name: &str) {
358        if let Some(entry) = self.0.get_mut(name) {
359            entry.yanked = None;
360            self.prune(name);
361        }
362    }
363
364    /// Clears the deprecation channel for `name`, pruning the entry if it becomes empty.
365    pub fn clear_deprecation(&mut self, name: &str) {
366        if let Some(entry) = self.0.get_mut(name) {
367            entry.deprecation = None;
368            self.prune(name);
369        }
370    }
371
372    /// Clears the fetch-failure channel for `name`, pruning the entry if it becomes empty.
373    pub fn clear_fetch_failure(&mut self, name: &str) {
374        if let Some(entry) = self.0.get_mut(name) {
375            entry.fetch_failure = None;
376            self.prune(name);
377        }
378    }
379
380    /// Removes the whole entry for `name`, regardless of which channels are set.
381    pub fn remove(&mut self, name: &str) {
382        self.0.remove(name);
383    }
384
385    /// Clears the fetch-failure channel for every entry, pruning any that become empty.
386    ///
387    /// Used when a forced re-fetch (e.g. a live-reloaded registry-routing setting, deps-lsp
388    /// issue #592) makes every previously recorded fetch-failure finding untrustworthy: the
389    /// routing itself changed, so a failure recorded under the old routing must not survive
390    /// to be merged with results fetched under the new one.
391    pub fn clear_all_fetch_failures(&mut self) {
392        let names: Vec<String> = self.0.keys().cloned().collect();
393        for name in names {
394            self.clear_fetch_failure(&name);
395        }
396    }
397
398    fn prune(&mut self, name: &str) {
399        if self.0.get(name).is_some_and(DependencyOutcome::is_empty) {
400            self.0.remove(name);
401        }
402    }
403
404    /// Number of entries currently stored.
405    #[must_use]
406    pub fn len(&self) -> usize {
407        self.0.len()
408    }
409
410    /// True when no entries are stored.
411    #[must_use]
412    pub fn is_empty(&self) -> bool {
413        self.0.is_empty()
414    }
415
416    /// Number of entries with a yanked-version finding, for logging/`Debug`.
417    #[must_use]
418    pub fn yanked_count(&self) -> usize {
419        self.0.values().filter(|o| o.yanked.is_some()).count()
420    }
421
422    /// Number of entries with a deprecation finding, for logging/`Debug`.
423    #[must_use]
424    pub fn deprecation_count(&self) -> usize {
425        self.0.values().filter(|o| o.deprecation.is_some()).count()
426    }
427
428    /// Number of entries with a fetch-failure finding, for logging/`Debug`.
429    #[must_use]
430    pub fn fetch_failure_count(&self) -> usize {
431        self.0
432            .values()
433            .filter(|o| o.fetch_failure.is_some())
434            .count()
435    }
436
437    /// Chainable builder recording a yanked-version finding. Test/fixture ergonomics.
438    #[must_use]
439    pub fn with_yanked(
440        mut self,
441        name: impl Into<String>,
442        yanked: (ConcreteVersion, RemovalStatus),
443    ) -> Self {
444        self.set_yanked(name.into(), yanked);
445        self
446    }
447
448    /// Chainable builder recording a package-level deprecation finding. Test/fixture
449    /// ergonomics.
450    #[must_use]
451    pub fn with_deprecation(mut self, name: impl Into<String>, deprecation: Deprecation) -> Self {
452        self.set_deprecation(name.into(), deprecation);
453        self
454    }
455
456    /// Chainable builder recording a fetch-failure finding. Test/fixture ergonomics.
457    #[must_use]
458    pub fn with_fetch_failure(mut self, name: impl Into<String>, failure: FetchFailure) -> Self {
459        self.set_fetch_failure(name.into(), failure);
460        self
461    }
462
463    /// Chainable builder recording a no-comparable-versions finding (#550). Test/fixture
464    /// ergonomics.
465    #[must_use]
466    pub fn with_no_comparable_versions(mut self, name: impl Into<String>) -> Self {
467        self.set_no_comparable_versions(name.into());
468        self
469    }
470}
471
472/// Bundles the two per-package version maps (`cached`, `resolved`) that LSP handlers pass
473/// together everywhere.
474///
475/// Grouping them prevents accidentally swapping the two map arguments at a call site, since
476/// the compiler can no longer typecheck them positionally.
477///
478/// # Examples
479///
480/// ```
481/// use deps_core::{ConcreteVersion, PackageName, PackageVersions, VersionData};
482/// use std::collections::HashMap;
483///
484/// let mut cached = HashMap::new();
485/// cached.insert(PackageName::new("serde"), PackageVersions::latest_only("1.0.214"));
486///
487/// let mut resolved = HashMap::new();
488/// resolved.insert(PackageName::new("serde"), ConcreteVersion::new("1.0.200"));
489///
490/// let versions = VersionData::new(&cached, &resolved);
491///
492/// assert_eq!(versions.cached.get("serde").map(|v| v.latest.as_str()), Some("1.0.214"));
493/// assert_eq!(versions.resolved.get("serde").map(ConcreteVersion::as_str), Some("1.0.200"));
494/// ```
495#[non_exhaustive]
496#[derive(Debug, Clone, Copy)]
497pub struct VersionData<'a> {
498    /// Latest known versions and full version lists from the registry, keyed by package name.
499    pub cached: &'a HashMap<PackageName, PackageVersions>,
500    /// Versions actually resolved in the lock file, keyed by package name.
501    pub resolved: &'a HashMap<PackageName, ConcreteVersion>,
502    /// Every lock-file-resolved version for a package name, when more than one is retained
503    /// (issue #649) — additive alongside [`Self::resolved`], which stays the single
504    /// collapsed value for the common case. `None` by default (most call sites have no such
505    /// map); [`crate::lsp_helpers::resolve_in_use_version`] and [`crate::osv::vulnerability_keys`]
506    /// consult it to disambiguate two manifest occurrences of one resolved name (e.g. a
507    /// Cargo `package = "..."` rename pinning an older major) by each occurrence's own
508    /// `version_requirement()`, falling back to [`Self::resolved`] when a name has at most
509    /// one candidate.
510    pub resolved_version_candidates: Option<&'a HashMap<PackageName, Vec<ConcreteVersion>>>,
511    /// OSV scan results, keyed by normalized package name. `None` when no
512    /// scan has run yet (e.g. the feature is disabled) — distinct from an
513    /// empty map, which would mean "scanned, nothing found".
514    pub vulnerabilities: Option<&'a VulnerabilityMap>,
515    /// Yanked, deprecation, and fetch-failure findings from the most recent lifecycle fetch,
516    /// keyed by normalized package name — see [`DependencyOutcome`] for what each channel
517    /// means and why they must stay readable together off one lookup (D5 in
518    /// [`generate_diagnostics_from_cache`], #233/#263/#205/#267). `None` when no fetch has run
519    /// yet — distinct from an empty map, which would mean "checked, nothing found".
520    pub outcomes: Option<&'a DependencyOutcomes>,
521    /// This document's ecosystem, when the caller has one to give. `None` in
522    /// most test fixtures and a handful of ecosystem-crate self-tests that
523    /// predate this field.
524    ///
525    /// Enables two occurrence-aware refinements added for #394 (duplicate
526    /// dependency names no longer collapsing into one shared finding):
527    /// [`generate_diagnostics_from_cache`] only emits a yanked-version
528    /// diagnostic on the occurrence whose own in-use version actually
529    /// matches the recorded finding (S1), and the vulnerability lookups in
530    /// `generate_diagnostics_from_cache`, [`generate_hover`], and
531    /// `generate_code_actions` prefer a version-qualified
532    /// [`crate::osv::VulnerabilityMap`] key over the plain name when more
533    /// than one occurrence of a name has a distinct in-use version (S2).
534    /// When `None`, both fall back to their pre-#394 name-only behavior.
535    pub ecosystem: Option<EcosystemId>,
536    /// Whether `network.offline` is set (issue #483). When `true`, [`generate_hover`]
537    /// appends a footer stating that version *and vulnerability* data were not checked —
538    /// deliberately more specific than a bare "showing cached data" notice, since
539    /// `hover.rs`'s `Some(ScanOutcome::Skipped(_)) | None` arm renders nothing for an
540    /// offline OSV skip, which would otherwise look identical to a scanned-and-clean
541    /// dependency.
542    pub offline: bool,
543    /// The deps.dev client to fetch a supply-chain trust signal through
544    /// (spec 037), when the caller wants hover to attempt one. `None` by
545    /// default and left `None` by every surface but `handlers/hover.rs`
546    /// (deps-lsp) — diagnostics, code actions, inlay hints, and code lenses
547    /// never set this, which is what makes FR-010's hover-only scope
548    /// structural rather than convention: those surfaces cannot reach
549    /// deps.dev because they are never handed a client. `&'a Arc<..>`, not
550    /// `&'a DepsDevClient`, so [`generate_hover`] can clone the `Arc` into a
551    /// detached background task.
552    pub trust: Option<&'a Arc<DepsDevClient>>,
553    /// Background-pre-fetched license data for ecosystems `deps_dev_system` doesn't
554    /// cover and whose hot-path registry response carries no license field — Dart,
555    /// Swift, Gradle, Deno (issue #660, spec 010 plan §1 tier 3), keyed by raw
556    /// (unnormalized) package name. `None` by default. Populated from `DocumentState`'s
557    /// per-document pre-fetch cache (`document::osv_scan::run_license_prefetch` on
558    /// document open/change — mirrors [`Self::vulnerabilities`]'s "populated by a
559    /// background task, read synchronously here" shape) by `handlers/hover.rs` (deps-lsp)
560    /// for hover, and by `handlers/diagnostics.rs`'s `textDocument/diagnostic` pull path
561    /// for [`generate_diagnostics_from_cache`]'s license-policy rule (issue #661) — see
562    /// [`Self::license_policy`].
563    ///
564    /// **What version this actually reflects is per-ecosystem, not uniformly the
565    /// resolved version** (critic S1 — corrects a previous blanket claim here that it
566    /// "only ever covers the resolved version"): Gradle's Maven Central POM fetch and
567    /// Deno's JSR per-version API are genuinely fetched at the dependency's resolved/
568    /// in-use version. Dart's pub.dev `/score` endpoint is per-*package*, not
569    /// per-version — it reflects pana's detection on whatever pub.dev most recently
570    /// scored, unrelated to which version is resolved. Swift's GitHub
571    /// `GET /repos/{owner}/{repo}` reflects the repository's default branch, not the
572    /// resolved version's tag. Every source here is still a background pre-fetch gated
573    /// on a version having been resolved at all (no in-use version means nothing to
574    /// look up), but only Gradle/Deno are actually version-*specific* in what they
575    /// return.
576    pub license_prefetch: Option<&'a HashMap<PackageName, Vec<String>>>,
577    /// How [`Self::license_prefetch`]'s (and, for a resolved-version match, the
578    /// registry-declared) license strings are sourced (issue #688), consulted by
579    /// [`generate_hover`] for the "(detected)" qualifier and by
580    /// [`generate_diagnostics_from_cache`]'s license-policy rule to decide whether
581    /// [`crate::licenses::resolve_license_entries`] must normalize free text before
582    /// evaluation. `None` in most test fixtures and any handler that doesn't go through
583    /// [`crate::Ecosystem::generate_hover`]/[`crate::Ecosystem::generate_diagnostics`]'s
584    /// default implementation, which is what actually attaches this field via
585    /// `self.license_source()` — falls back to
586    /// [`LicenseSource::RegistryDeclaredSpdx`] (`Default`) wherever consumed.
587    pub license_source: Option<LicenseSource>,
588    /// SPDX allow-list/deny-list license policy (issue #661, spec 010 Phase 2), when the
589    /// caller wants diagnostics evaluated against one. `None` by default.
590    ///
591    /// Unlike [`Self::trust`], this is **not** hover-only-scoped: `deps-lsp`'s
592    /// `handlers/diagnostics.rs::generate_diagnostics_internal` attaches the currently
593    /// configured policy (cached on `ServerState`, kept live-updated by
594    /// `Backend::initialize`/`did_change_configuration`) unconditionally, so every
595    /// diagnostics-generation call site — the `textDocument/diagnostic` pull path *and*
596    /// every push-path background refresh (fetch-completion, watched-config reparse,
597    /// lock-file change) — evaluates the same policy. An earlier revision scoped this
598    /// field to the pull path only, following `Self::trust`'s hover-only precedent; that
599    /// was a false analogy (issue #660/#661 critic C1) — `trust` is safe hover-only
600    /// because hover has exactly one producer per request, but diagnostics has multiple
601    /// producers all replacing the same client-visible `publish_diagnostics` set, so a
602    /// caller-scoped policy meant the license diagnostic flickered in and out on every
603    /// edit and was invisible to push-only clients.
604    /// [`generate_diagnostics_from_cache`]'s license-policy rule reads this alongside
605    /// [`Self::license_prefetch`] — so today it only actually fires for tier-3 ecosystems
606    /// (Dart/Swift/Deno; Gradle is explicitly excluded, see that rule's doc comment), the
607    /// only ones `license_prefetch` covers; this field and the rule are otherwise
608    /// ecosystem-agnostic and need no change as `license_prefetch`'s coverage widens.
609    pub license_policy: Option<&'a LicensePolicy>,
610}
611
612impl<'a> VersionData<'a> {
613    /// Creates a new `VersionData` from the cached and resolved version maps.
614    ///
615    /// `vulnerabilities` starts `None`; chain [`Self::with_vulnerabilities`]
616    /// to attach a scan result.
617    ///
618    /// # Examples
619    ///
620    /// ```
621    /// use deps_core::VersionData;
622    /// use std::collections::HashMap;
623    ///
624    /// let cached = HashMap::new();
625    /// let resolved = HashMap::new();
626    /// let versions = VersionData::new(&cached, &resolved);
627    /// assert!(versions.cached.is_empty());
628    /// assert!(versions.vulnerabilities.is_none());
629    /// ```
630    pub fn new(
631        cached: &'a HashMap<PackageName, PackageVersions>,
632        resolved: &'a HashMap<PackageName, ConcreteVersion>,
633    ) -> Self {
634        Self {
635            cached,
636            resolved,
637            resolved_version_candidates: None,
638            vulnerabilities: None,
639            outcomes: None,
640            ecosystem: None,
641            offline: false,
642            trust: None,
643            license_prefetch: None,
644            license_source: None,
645            license_policy: None,
646        }
647    }
648
649    /// Attaches the per-name lock-file candidates map, enabling the per-occurrence
650    /// disambiguation described on [`Self::resolved_version_candidates`].
651    ///
652    /// # Examples
653    ///
654    /// ```
655    /// use deps_core::VersionData;
656    /// use std::collections::HashMap;
657    ///
658    /// let cached = HashMap::new();
659    /// let resolved = HashMap::new();
660    /// let candidates = HashMap::new();
661    /// let versions =
662    ///     VersionData::new(&cached, &resolved).with_resolved_version_candidates(&candidates);
663    /// assert!(versions.resolved_version_candidates.is_some());
664    /// ```
665    #[must_use]
666    pub const fn with_resolved_version_candidates(
667        mut self,
668        candidates: &'a HashMap<PackageName, Vec<ConcreteVersion>>,
669    ) -> Self {
670        self.resolved_version_candidates = Some(candidates);
671        self
672    }
673
674    /// Attaches an OSV scan result to this `VersionData`.
675    ///
676    /// # Examples
677    ///
678    /// ```
679    /// use deps_core::VersionData;
680    /// use deps_core::osv::VulnerabilityMap;
681    /// use std::collections::HashMap;
682    ///
683    /// let cached = HashMap::new();
684    /// let resolved = HashMap::new();
685    /// let vulns = VulnerabilityMap::new();
686    /// let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulns);
687    /// assert!(versions.vulnerabilities.is_some());
688    /// ```
689    #[must_use]
690    pub fn with_vulnerabilities(mut self, vulnerabilities: &'a VulnerabilityMap) -> Self {
691        self.vulnerabilities = Some(vulnerabilities);
692        self
693    }
694
695    /// Attaches yanked, deprecation, and fetch-failure findings to this `VersionData`. See
696    /// [`Self::outcomes`].
697    ///
698    /// # Examples
699    ///
700    /// ```
701    /// use deps_core::VersionData;
702    /// use deps_core::lsp_helpers::DependencyOutcomes;
703    /// use std::collections::HashMap;
704    ///
705    /// let cached = HashMap::new();
706    /// let resolved = HashMap::new();
707    /// let outcomes = DependencyOutcomes::new();
708    /// let versions = VersionData::new(&cached, &resolved).with_outcomes(&outcomes);
709    /// assert!(versions.outcomes.is_some());
710    /// ```
711    #[must_use]
712    pub fn with_outcomes(mut self, outcomes: &'a DependencyOutcomes) -> Self {
713        self.outcomes = Some(outcomes);
714        self
715    }
716
717    /// Attaches this document's ecosystem, enabling the occurrence-aware
718    /// refinements described on [`Self::ecosystem`].
719    ///
720    /// # Examples
721    ///
722    /// ```
723    /// use deps_core::{EcosystemId, VersionData};
724    /// use std::collections::HashMap;
725    ///
726    /// let cached = HashMap::new();
727    /// let resolved = HashMap::new();
728    /// let versions = VersionData::new(&cached, &resolved).with_ecosystem(EcosystemId::Cargo);
729    /// assert_eq!(versions.ecosystem, Some(EcosystemId::Cargo));
730    /// ```
731    #[must_use]
732    pub const fn with_ecosystem(mut self, ecosystem: EcosystemId) -> Self {
733        self.ecosystem = Some(ecosystem);
734        self
735    }
736
737    /// Marks this `VersionData` as built while `network.offline` was set, so
738    /// [`generate_hover`] appends its offline footer.
739    ///
740    /// # Examples
741    ///
742    /// ```
743    /// use deps_core::VersionData;
744    /// use std::collections::HashMap;
745    ///
746    /// let cached = HashMap::new();
747    /// let resolved = HashMap::new();
748    /// let versions = VersionData::new(&cached, &resolved).with_offline(true);
749    /// assert!(versions.offline);
750    /// ```
751    #[must_use]
752    pub const fn with_offline(mut self, offline: bool) -> Self {
753        self.offline = offline;
754        self
755    }
756
757    /// Attaches a deps.dev client, enabling [`generate_hover`] to attempt a
758    /// supply-chain trust signal for the hovered dependency. See
759    /// [`Self::trust`].
760    ///
761    /// # Examples
762    ///
763    /// ```
764    /// use deps_core::{DepsDevClient, HttpCache, VersionData};
765    /// use std::collections::HashMap;
766    /// use std::sync::Arc;
767    ///
768    /// let cached = HashMap::new();
769    /// let resolved = HashMap::new();
770    /// let client = Arc::new(DepsDevClient::new(Arc::new(HttpCache::new())));
771    /// let versions = VersionData::new(&cached, &resolved).with_trust(&client);
772    /// assert!(versions.trust.is_some());
773    /// ```
774    #[must_use]
775    pub const fn with_trust(mut self, client: &'a Arc<DepsDevClient>) -> Self {
776        self.trust = Some(client);
777        self
778    }
779
780    /// Attaches tier-3 pre-fetched license data. See [`Self::license_prefetch`].
781    ///
782    /// # Examples
783    ///
784    /// ```
785    /// use deps_core::VersionData;
786    /// use std::collections::HashMap;
787    ///
788    /// let cached = HashMap::new();
789    /// let resolved = HashMap::new();
790    /// let licenses = HashMap::new();
791    /// let versions = VersionData::new(&cached, &resolved).with_license_prefetch(&licenses);
792    /// assert!(versions.license_prefetch.is_some());
793    /// ```
794    #[must_use]
795    pub const fn with_license_prefetch(
796        mut self,
797        licenses: &'a HashMap<PackageName, Vec<String>>,
798    ) -> Self {
799        self.license_prefetch = Some(licenses);
800        self
801    }
802
803    /// Attaches this ecosystem's license source. See [`Self::license_source`].
804    ///
805    /// # Examples
806    ///
807    /// ```
808    /// use deps_core::{LicenseSource, VersionData};
809    /// use std::collections::HashMap;
810    ///
811    /// let cached = HashMap::new();
812    /// let resolved = HashMap::new();
813    /// let versions =
814    ///     VersionData::new(&cached, &resolved).with_license_source(LicenseSource::PomFreeText);
815    /// assert_eq!(versions.license_source, Some(LicenseSource::PomFreeText));
816    /// ```
817    #[must_use]
818    pub const fn with_license_source(mut self, source: LicenseSource) -> Self {
819        self.license_source = Some(source);
820        self
821    }
822
823    /// Attaches a license policy for diagnostics evaluation. See [`Self::license_policy`].
824    ///
825    /// # Examples
826    ///
827    /// ```
828    /// use deps_core::{LicensePolicy, VersionData};
829    /// use std::collections::HashMap;
830    ///
831    /// let cached = HashMap::new();
832    /// let resolved = HashMap::new();
833    /// let policy = LicensePolicy::new(vec!["MIT".to_string()], vec![]);
834    /// let versions = VersionData::new(&cached, &resolved).with_license_policy(&policy);
835    /// assert!(versions.license_policy.is_some());
836    /// ```
837    #[must_use]
838    pub const fn with_license_policy(mut self, policy: &'a LicensePolicy) -> Self {
839        self.license_policy = Some(policy);
840        self
841    }
842}
843
844/// Wall-clock budget `deps-lsp`'s completion handler gives an ecosystem's
845/// `generate_completions` before treating it as a timeout.
846///
847/// Past this, the handler skips the fallback search rather than treating a
848/// fast-but-empty result as "genuinely no results"
849/// (`crates/deps-lsp/src/handlers/completion.rs`).
850///
851/// A registry-backed completion path that retries internally on failure (e.g.
852/// `deps-maven`'s `search`, #274) must size its own total retry budget to
853/// exceed this constant: finishing sooner with an empty/error result is
854/// indistinguishable, at the call site, from a query that legitimately has no
855/// matches, and triggers a wasted (and, for a struggling registry, likely to also
856/// fail) fallback search rather than the handler's existing skip-on-timeout path.
857///
858/// Lives here (ungated), not in [`crate::completion`] (`#[cfg(feature =
859/// "lsp-responses")]`), and is re-exported from there for that feature's consumers:
860/// a registry-backed completion path's own retry-budget constant (e.g. `deps-maven`'s
861/// `RECENT_FAILURE_TTL`) is genuine `Registry::search` behavior, not LSP-response-shaped,
862/// and must stay available under `--no-default-features` too (issue #1083 critic M1) —
863/// one definition, not a hand-copied duplicate per feature state.
864pub const COMPLETION_SEARCH_TIMEOUT: Duration = Duration::from_secs(2);
865
866/// Wall-clock budget hover and code-action generation give the primary
867/// `Registry::get_versions_from` fetch for the dependency under the cursor.
868///
869/// Unlike completion, code lens, inlay hints, and the background fetch task, hover
870/// and code actions previously awaited this call with no deadline at all — an
871/// ecosystem whose registry client retries across several candidate URLs
872/// sequentially (e.g. `deps-maven`'s Gradle Plugin Portal fallback) could multiply
873/// `HttpCache`'s own per-request timeout into tens of seconds of blocked, user-facing
874/// latency on a single hover or lightbulb request (issue #1204). This is the *only*
875/// deadline needed to fix that: it wraps the whole `get_versions_from` future,
876/// cancelling it — candidate-URL loop included — at the ecosystem-registry level, so
877/// no per-ecosystem inner budget is required on top of it. On elapse, hover falls back
878/// to its existing `.ok()`-based basic-card degradation (the same path a genuine fetch
879/// error takes); code actions instead drop the speculative fix/unsat actions outright
880/// (see `await_versions_fetch`'s `timed_out` flag) rather than reusing that same
881/// fail-open path, since an unverified yank check must not be treated as "not yanked".
882pub const REGISTRY_FETCH_BUDGET: Duration = Duration::from_secs(10);
883
884/// Awaits `fetch` bounded by [`REGISTRY_FETCH_BUDGET`], logging a `tracing::warn!` naming
885/// `package` and `context` if the budget elapses first.
886///
887/// Shared by hover and code-action generation to avoid duplicating this
888/// timeout/log/degrade shape at both call sites. Returns `(None, false)` for a
889/// fetch-level error exactly like the pre-#1204 `.ok()` degrade, and `(None, true)` on
890/// timeout — the `bool` lets a caller (code actions) tell the two apart when a fetch
891/// failure and a fetch timeout must not be treated the same way (impl-critic S1: a
892/// timeout is not a verified "not yanked" answer, so it must not fail open the way a
893/// genuine registry outage does).
894#[cfg(feature = "lsp-responses")]
895async fn await_versions_fetch<T, E>(
896    fetch: impl std::future::Future<Output = Result<T, E>>,
897    package: &PackageName,
898    context: &'static str,
899) -> (Option<T>, bool) {
900    match tokio::time::timeout(REGISTRY_FETCH_BUDGET, fetch).await {
901        Ok(result) => (result.ok(), false),
902        Err(_) => {
903            tracing::warn!(
904                package = %package.for_tracing(),
905                context,
906                timeout_secs = REGISTRY_FETCH_BUDGET.as_secs(),
907                "primary registry version fetch timed out"
908            );
909            (None, true)
910        }
911    }
912}
913
914/// Converts UTF-16 offset to byte offset in a string.
915///
916/// LSP uses UTF-16 code units for character positions (for compatibility with
917/// JavaScript and other languages). This function converts from UTF-16 offset
918/// to byte offset for Rust string indexing.
919///
920/// # Arguments
921///
922/// * `s` - The string to index into
923/// * `utf16_offset` - UTF-16 code unit offset (from LSP Position.character)
924///
925/// # Returns
926///
927/// Byte offset if valid, `None` if the UTF-16 offset is out of bounds.
928///
929/// # Examples
930///
931/// ```
932/// # use deps_core::lsp_helpers::utf16_to_byte_offset;
933/// // ASCII: UTF-16 offset equals byte offset
934/// assert_eq!(utf16_to_byte_offset("hello", 2), Some(2));
935///
936/// // Unicode: "日本語" - each char is 3 bytes but 1 UTF-16 code unit
937/// assert_eq!(utf16_to_byte_offset("日本語", 0), Some(0));
938/// assert_eq!(utf16_to_byte_offset("日本語", 1), Some(3));
939/// assert_eq!(utf16_to_byte_offset("日本語", 2), Some(6));
940///
941/// // Emoji: "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
942/// assert_eq!(utf16_to_byte_offset("😀test", 2), Some(4));
943/// ```
944pub fn utf16_to_byte_offset(s: &str, utf16_offset: u32) -> Option<usize> {
945    let mut utf16_count = 0u32;
946    for (byte_idx, ch) in s.char_indices() {
947        if utf16_count >= utf16_offset {
948            return Some(byte_idx);
949        }
950        #[expect(
951            clippy::cast_possible_truncation,
952            reason = "char::len_utf16 always returns 1 or 2, so this cast never truncates"
953        )]
954        {
955            utf16_count += ch.len_utf16() as u32;
956        }
957    }
958    if utf16_count == utf16_offset {
959        return Some(s.len());
960    }
961    None
962}
963
964/// Converts a byte offset within `s` to a UTF-16 code unit offset (LSP `Position.character`).
965///
966/// `byte_offset` may be an arbitrary caller-supplied value: it is clamped to `s.len()` and
967/// floored down to the nearest UTF-8 char boundary before use, so this never panics.
968///
969/// # Examples
970///
971/// ```
972/// # use deps_core::lsp_helpers::byte_to_utf16_offset;
973/// // ASCII: byte offset equals UTF-16 offset
974/// assert_eq!(byte_to_utf16_offset("hello", 2), 2);
975///
976/// // Unicode: "日本語" - each char is 3 bytes but 1 UTF-16 code unit
977/// assert_eq!(byte_to_utf16_offset("日本語", 0), 0);
978/// assert_eq!(byte_to_utf16_offset("日本語", 3), 1);
979/// assert_eq!(byte_to_utf16_offset("日本語", 6), 2);
980///
981/// // Emoji: "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
982/// assert_eq!(byte_to_utf16_offset("😀test", 4), 2);
983///
984/// // Never panics: an offset landing mid-character floors down to the start of that
985/// // character, and an offset past the end saturates to the string's length.
986/// assert_eq!(byte_to_utf16_offset("日本語", 1), 0); // inside the first character
987/// assert_eq!(byte_to_utf16_offset("日本語", 999), 3); // past the end
988/// ```
989#[expect(
990    clippy::string_slice,
991    reason = "end is floor_char_boundary-clamped just above, mirroring the already-hardened \
992              LineOffsetTable::byte_offset_to_position (lsp_helpers/mod.rs)"
993)]
994pub fn byte_to_utf16_offset(s: &str, byte_offset: usize) -> u32 {
995    // Saturate rather than silently wrap: an LSP `Position.character` past `u32::MAX` UTF-16
996    // units is already meaningless, but a wrapped value would be a wrong-but-plausible one
997    // (#673 — the exact offset-math bug class #244 shipped).
998    //
999    // `byte_offset` is not guaranteed to be in bounds or on a char boundary (a caller-supplied
1000    // offset can be past `s.len()` or land mid-character); `floor_char_boundary` clamps both
1001    // — past-the-end saturates to `s.len()` and any other in-bounds index floors to the
1002    // nearest char boundary — mirroring `LineOffsetTable::byte_offset_to_position`
1003    // (lsp_helpers/mod.rs).
1004    let end = s.floor_char_boundary(byte_offset);
1005    u32::try_from(s[..end].encode_utf16().count()).unwrap_or(u32::MAX)
1006}
1007
1008/// Checks whether a cursor position falls within an LSP range (inclusive on both ends).
1009pub fn position_in_range(pos: Position, range: Range) -> bool {
1010    if pos.line < range.start.line || pos.line > range.end.line {
1011        return false;
1012    }
1013    if pos.line == range.start.line && pos.character < range.start.character {
1014        return false;
1015    }
1016    if pos.line == range.end.line && pos.character > range.end.character {
1017        return false;
1018    }
1019    true
1020}
1021
1022/// Whether `dep`'s `version_range()` is a degenerate, completion-only position with no
1023/// requirement behind it (e.g. Maven's `<version></version>`/self-closing `<version/>`) rather
1024/// than a real, non-empty range that happens to have no requirement (e.g. Gradle's
1025/// `version.ref` pointing at a dangling or rich-version alias, which spans the real
1026/// alias-reference text). Zero-width (`start == end`) is the discriminating signal —
1027/// `version_requirement().is_none()` alone is not, since both shapes share it.
1028pub(crate) fn version_range_is_synthetic_empty(dep: &dyn Dependency) -> bool {
1029    dep.version_requirement().is_none() && dep.version_range().is_some_and(|r| r.start == r.end)
1030}
1031
1032/// Converts byte offsets in source text to LSP `Position` values.
1033///
1034/// Precomputes line-start byte offsets once, then maps any byte offset to a
1035/// `(line, character)` position. Characters are counted as UTF-16 code units
1036/// as required by the LSP specification.
1037///
1038/// Also precomputes, per line, whether that line is pure ASCII (`line_is_ascii`): for an
1039/// ASCII-only line, byte offset and UTF-16 code-unit
1040/// offset are always equal, so [`byte_offset_to_position`](Self::byte_offset_to_position)
1041/// can skip the `chars().map(char::len_utf16).sum()` scan entirely and compute the
1042/// character offset in O(1). This matters because a single minified manifest line (e.g. a
1043/// `package.json` with hundreds of dependencies on one line) turns per-offset lookups into
1044/// an O(n) scan each, and O(n) lookups across the line's length make the whole document
1045/// O(n^2) (#742) — the common case of an ASCII-only line now stays O(1) per lookup; a
1046/// non-ASCII line still takes an O(1) lookup too, via a lazily-built per-line index (#882) —
1047/// see `with_non_ascii_line_index`.
1048///
1049/// Interior mutability: this type is `Send` but not `Sync` (the non-ASCII line index cache
1050/// uses a `RefCell`). Build one per document parse and never share it across threads.
1051pub struct LineOffsetTable {
1052    line_starts: Vec<usize>,
1053    line_is_ascii: Vec<bool>,
1054    // Lazily populated, keyed by line number. A `HashMap` rather than a `Vec` sized to the
1055    // line count so cost is proportional to lines actually queried non-ASCII (#882).
1056    non_ascii_line_index_cache:
1057        std::cell::RefCell<std::collections::HashMap<usize, NonAsciiLineIndex>>,
1058}
1059
1060/// A non-ASCII line's char-boundary index, lazily built and cached by
1061/// `LineOffsetTable::with_non_ascii_line_index`.
1062///
1063/// `entries[i]` is `(byte_offset, utf16_units_before)` for the line's `i`-th char boundary
1064/// (`byte_offset` relative to the line's own start, `utf16_units_before` the cumulative UTF-16
1065/// code-unit count of every char *before* it). A final sentinel entry for the end of the line
1066/// (`byte_offset == line.len()`, `utf16_units_before` = the line's total UTF-16 length) makes
1067/// end-of-line byte offsets resolvable the same way as any other char boundary.
1068///
1069/// Serves both directions #882 needs on a non-ASCII line, from one build pass: char index ->
1070/// byte offset (`entries.get(col)`, what [`marker_byte_offset`] needs) and byte offset ->
1071/// UTF-16 units (`entries.binary_search_by_key`, what
1072/// [`byte_offset_to_position`](LineOffsetTable::byte_offset_to_position) needs) — a second,
1073/// independent cache would duplicate the exact `char_indices()` walk this one pass already
1074/// performs.
1075struct NonAsciiLineIndex {
1076    entries: Vec<(u32, u32)>,
1077}
1078
1079impl NonAsciiLineIndex {
1080    /// Builds the index for one line's text via a single `char_indices()` walk.
1081    fn build(line_text: &str) -> Self {
1082        let mut entries = Vec::with_capacity(line_text.len() + 1);
1083        let mut utf16_units = 0u32;
1084        for (byte_offset, c) in line_text.char_indices() {
1085            entries.push((u32::try_from(byte_offset).unwrap_or(u32::MAX), utf16_units));
1086            utf16_units = utf16_units.saturating_add(u32::try_from(c.len_utf16()).unwrap_or(2));
1087        }
1088        entries.push((
1089            u32::try_from(line_text.len()).unwrap_or(u32::MAX),
1090            utf16_units,
1091        ));
1092        // The initial `line_text.len() + 1` capacity over-reserves 3-4x on a multi-byte-heavy
1093        // line (CJK, Devanagari, ...): it reserves per byte but pushes one entry per char.
1094        entries.shrink_to_fit();
1095        Self { entries }
1096    }
1097}
1098
1099impl LineOffsetTable {
1100    /// Builds the table for `content`.
1101    pub fn new(content: &str) -> Self {
1102        let mut line_starts = vec![0];
1103        let mut line_is_ascii = Vec::new();
1104        let mut current_ascii = true;
1105        for (i, c) in content.char_indices() {
1106            if !c.is_ascii() {
1107                current_ascii = false;
1108            }
1109            if c == '\n' {
1110                line_starts.push(i + 1);
1111                line_is_ascii.push(current_ascii);
1112                current_ascii = true;
1113            }
1114        }
1115        line_is_ascii.push(current_ascii);
1116        debug_assert_eq!(
1117            line_starts.len(),
1118            line_is_ascii.len(),
1119            "line_starts and line_is_ascii must stay in lockstep — one entry per line"
1120        );
1121        Self {
1122            line_starts,
1123            line_is_ascii,
1124            non_ascii_line_index_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
1125        }
1126    }
1127
1128    /// Runs `f` against the cached (building it on first call) [`NonAsciiLineIndex`] for the
1129    /// non-ASCII line starting at `line0` (0-indexed), given that line's own text.
1130    ///
1131    /// Callers must pass the exact text of line `line0` as sliced from the same `content` this
1132    /// table was built from — the cache trusts its first-seen `line_text` for every later
1133    /// lookup of the same `line0` and never re-validates it. Building a line's index the first
1134    /// time it is looked up is still O(line length) (one `char_indices()` walk); only repeat
1135    /// lookups on an already-cached line become O(1) — a huge non-ASCII line's *first* lookup
1136    /// is not free, it is the same one-time cost `LineOffsetTable::new` always paid.
1137    ///
1138    /// Takes a closure rather than returning a `Ref` so the `RefCell` borrow never outlives one
1139    /// call — a caller cannot accidentally hold it open across an unrelated later borrow.
1140    #[expect(
1141        clippy::expect_used,
1142        reason = "borrow_mut() is dropped before borrow() runs, and the inserted entry is \
1143                  never removed, so the lookup below always succeeds"
1144    )]
1145    fn with_non_ascii_line_index<R>(
1146        &self,
1147        line0: usize,
1148        line_text: &str,
1149        f: impl FnOnce(&NonAsciiLineIndex) -> R,
1150    ) -> R {
1151        self.non_ascii_line_index_cache
1152            .borrow_mut()
1153            .entry(line0)
1154            .or_insert_with(|| NonAsciiLineIndex::build(line_text));
1155        let cache = self.non_ascii_line_index_cache.borrow();
1156        let index = cache
1157            .get(&line0)
1158            .expect("just inserted above if it was missing");
1159        f(index)
1160    }
1161
1162    /// Byte offset (relative to the line's own start) of the `col`-th char boundary within the
1163    /// non-ASCII line starting at `line0`, given that line's text. Clamps to `line_text.len()`
1164    /// if `col` is past the line's char count, matching a plain `char_indices().nth(col)` walk.
1165    fn non_ascii_char_byte_offset(&self, line0: usize, line_text: &str, col: usize) -> usize {
1166        self.with_non_ascii_line_index(line0, line_text, |index| {
1167            index
1168                .entries
1169                .get(col)
1170                .map_or(line_text.len(), |&(byte_offset, _)| byte_offset as usize)
1171        })
1172    }
1173
1174    /// UTF-16 code-unit count preceding `byte_offset` (relative to the line's own start) within
1175    /// the non-ASCII line starting at `line0`, given that line's text.
1176    ///
1177    /// `byte_offset` must be a char boundary within the line (guaranteed by
1178    /// [`byte_offset_to_position`](Self::byte_offset_to_position)'s `floor_char_boundary` clamp
1179    /// before calling this) — every char boundary, including end-of-line, has an exact entry in
1180    /// the index, so this never needs to interpolate between two boundaries.
1181    fn non_ascii_utf16_units_before(
1182        &self,
1183        line0: usize,
1184        line_text: &str,
1185        byte_offset: usize,
1186    ) -> u32 {
1187        self.with_non_ascii_line_index(line0, line_text, |index| {
1188            let byte_offset = u32::try_from(byte_offset).unwrap_or(u32::MAX);
1189            let found = index
1190                .entries
1191                .binary_search_by_key(&byte_offset, |&(b, _)| b);
1192            // `Err` shouldn't happen given the caller's `floor_char_boundary` guarantee, but
1193            // falls back to the nearest preceding entry rather than panicking.
1194            let i = found.unwrap_or_else(|i| i.saturating_sub(1));
1195            index.entries.get(i).map_or(0, |&(_, units)| units)
1196        })
1197    }
1198
1199    /// Absolute byte offset where `line` (0-indexed) starts, or `None` if
1200    /// `line` is out of range.
1201    ///
1202    /// Prefer this over re-deriving a line's start via cursor arithmetic
1203    /// (`cursor += line.len() + 1`): `str::lines()` strips a trailing `\r`,
1204    /// so that approach under-counts by one byte per CRLF line and corrupts
1205    /// every subsequent offset in the file. This table is built by scanning
1206    /// `char_indices()` for `\n` (see [`new`](Self::new)), which counts the
1207    /// `\r`, so it stays correct for LF, CRLF and mixed line endings alike.
1208    ///
1209    /// # Examples
1210    ///
1211    /// ```
1212    /// use deps_core::lsp_helpers::LineOffsetTable;
1213    ///
1214    /// let table = LineOffsetTable::new("a\r\nb\r\nc");
1215    /// assert_eq!(table.line_start(0), Some(0));
1216    /// assert_eq!(table.line_start(1), Some(3));
1217    /// assert_eq!(table.line_start(2), Some(6));
1218    /// assert_eq!(table.line_start(3), None);
1219    /// ```
1220    pub fn line_start(&self, line: usize) -> Option<usize> {
1221        self.line_starts.get(line).copied()
1222    }
1223
1224    /// Converts a byte offset into an LSP `Position`.
1225    pub fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position {
1226        let offset = offset.min(content.len());
1227        // The requirements.txt line parser derives offsets via hand-rolled byte arithmetic,
1228        // which can land inside a multi-byte character — clamp to the nearest char boundary
1229        // rather than panicking on the slice below.
1230        let offset = content.floor_char_boundary(offset);
1231        let line = self
1232            .line_starts
1233            .partition_point(|&start| start <= offset)
1234            .saturating_sub(1);
1235        #[expect(
1236            clippy::indexing_slicing,
1237            reason = "line_starts always has at least one element (vec![0] at construction), \
1238                      so partition_point().saturating_sub(1) is always a valid index into it"
1239        )]
1240        let line_start = self.line_starts[line];
1241        // #673 M1: `line`/`character` aren't bounded by any size cap for editor-sent
1242        // full-document-sync text, so this saturates rather than assuming an upstream cap
1243        // (mirrors `completion::byte_to_utf16_offset`'s identical fix).
1244        //
1245        // #742: an ASCII-only line has 1 UTF-16 unit per byte, so no need to walk `chars()`.
1246        let character = if self.line_is_ascii.get(line).copied().unwrap_or(false) {
1247            u32::try_from(offset - line_start).unwrap_or(u32::MAX)
1248        } else {
1249            // #882: a naive `chars().map(len_utf16).sum()` per call is O(line length); N
1250            // lookups on the same wide line made this O(N x line length). The cached
1251            // per-line index (`with_non_ascii_line_index`) makes repeat lookups O(1).
1252            let line_end = self
1253                .line_starts
1254                .get(line + 1)
1255                .copied()
1256                .unwrap_or(content.len());
1257            let line_text = content.get(line_start..line_end).unwrap_or_default();
1258            self.non_ascii_utf16_units_before(line, line_text, offset - line_start)
1259        };
1260        Position::new(u32::try_from(line).unwrap_or(u32::MAX), character)
1261    }
1262
1263    /// Converts an LSP `Position` back into a byte offset — the inverse of
1264    /// [`byte_offset_to_position`](Self::byte_offset_to_position). Out-of-range lines or
1265    /// UTF-16 characters clamp to `content.len()` rather than panicking, matching the
1266    /// forward conversion's `.min(content.len())` guard.
1267    #[expect(
1268        clippy::string_slice,
1269        reason = "line_start/line_end come from line_starts (post-newline offsets, always \
1270                  char boundaries) or content.len()"
1271    )]
1272    pub fn position_to_byte_offset(&self, content: &str, position: Position) -> usize {
1273        let Some(&line_start) = self.line_starts.get(position.line as usize) else {
1274            return content.len();
1275        };
1276        let line_end = self
1277            .line_starts
1278            .get(position.line as usize + 1)
1279            .copied()
1280            .unwrap_or(content.len());
1281        let line = &content[line_start..line_end];
1282        utf16_to_byte_offset(line, position.character)
1283            .map_or(line_end, |offset| line_start + offset)
1284            .min(content.len())
1285    }
1286}
1287
1288/// Escapes Markdown syntax characters so untrusted text cannot break out of the
1289/// Markdown structure it is embedded in.
1290///
1291/// Applied to manifest-controlled text (dependency names) before it is written into
1292/// hover markdown link labels, and to registry-controlled completion metadata
1293/// (package name/version, description, repository/documentation URLs) before it is
1294/// written into completion-item link labels and link destinations. Every ASCII
1295/// punctuation character is backslash-escaped (CommonMark's full escapable set — not
1296/// just brackets/parens, which would still leave e.g. `<https://evil.example>`
1297/// autolinks live), and control characters (including newlines) plus a narrow set of
1298/// invisible/bidi-override characters (see `is_markdown_unsafe`, #1248) are replaced
1299/// with a space so the text cannot terminate the single-line block it is embedded in,
1300/// splice in new content, or visually spoof the rendered name via a Trojan Source
1301/// (CVE-2021-42574) bidi override.
1302///
1303/// Backslash-escaping is valid in link destinations as well as regular text, so this
1304/// also neutralizes `)`/`]` breakout attempts in a `[label](destination)` URL. It does
1305/// *not* block dangerous URI schemes (e.g. `javascript:`) in a destination — that is a
1306/// separate concern from breaking out of the surrounding Markdown structure.
1307///
1308/// Backslash-escaping does *not* work inside inline code spans (CommonMark §6.1) —
1309/// use [`markdown_code_span`] for text embedded in `` `...` `` instead.
1310///
1311/// # Examples
1312///
1313/// ```
1314/// use deps_core::lsp_helpers::escape_markdown;
1315///
1316/// assert_eq!(escape_markdown("pkg](evil)[pkg"), r"pkg\]\(evil\)\[pkg");
1317/// assert_eq!(escape_markdown("a\nb"), "a b");
1318/// ```
1319pub fn escape_markdown(s: &str) -> String {
1320    let mut escaped = String::with_capacity(s.len());
1321    for c in s.chars() {
1322        if is_markdown_unsafe(c) {
1323            escaped.push(' ');
1324            continue;
1325        }
1326        if c.is_ascii_punctuation() {
1327            escaped.push('\\');
1328        }
1329        escaped.push(c);
1330    }
1331    escaped
1332}
1333
1334/// Narrow, explicit predicate for a character that must not survive [`escape_markdown`]
1335/// or [`markdown_code_span`] unescaped (#1248): ASCII/C1 control characters plus a
1336/// fixed set of invisible/bidi-override Unicode characters with a demonstrated Trojan
1337/// Source (CVE-2021-42574) or text-smuggling use, and no legitimate mid-string use in
1338/// registry-supplied free text.
1339///
1340/// Deliberately narrower than [`crate::net_policy::sanitize_invisible`]'s whole-category
1341/// (`Cf`/`Zl`/`Zp`) sweep: both `escape_markdown` and `markdown_code_span` also process
1342/// free text (registry descriptions) that can legitimately carry `Cf` marks such as
1343/// U+200F RIGHT-TO-LEFT MARK, U+061C ARABIC LETTER MARK, U+200E LEFT-TO-RIGHT MARK, or
1344/// U+200C/U+200D (ZWNJ/ZWJ, load-bearing in Persian/Arabic/Indic text and emoji
1345/// sequences) — a category-wide check would mangle genuine RTL text or emoji. This list
1346/// covers only the bidi-override/invisible characters with no such legitimate use:
1347/// explicit bidi overrides/isolates (U+202A-U+202E, U+2066-U+2069), the zero-width
1348/// space (U+200B) and word joiner (U+2060, functionally identical to — and Unicode's
1349/// recommended replacement for — U+FEFF used as a ZWNBSP rather than a byte-order mark),
1350/// the line/paragraph separators (U+2028/U+2029, `Zl`/`Zp` — not `Cf`, so a `Cf`-only
1351/// check would miss them), the byte-order mark (U+FEFF), interlinear annotation
1352/// characters (U+FFF9-U+FFFB), and the Unicode tag characters (U+E0000-U+E007F, the
1353/// canonical invisible "ASCII smuggling" vector).
1354fn is_markdown_unsafe(c: char) -> bool {
1355    c.is_control()
1356        || matches!(c,
1357            '\u{202A}'..='\u{202E}'   // LRE RLE PDF LRO RLO
1358          | '\u{2066}'..='\u{2069}'   // LRI RLI FSI PDI
1359          | '\u{200B}'               // ZWSP
1360          | '\u{2060}'               // WORD JOINER
1361          | '\u{2028}' | '\u{2029}'  // LS, PS
1362          | '\u{FEFF}'               // ZWNBSP / BOM
1363          | '\u{FFF9}'..='\u{FFFB}'  // interlinear annotation anchor/separator/terminator
1364          | '\u{E0000}'..='\u{E007F}' // Unicode tag characters
1365        )
1366}
1367
1368/// Replaces every [`is_markdown_unsafe`] character in `s` with a single space.
1369///
1370/// Shared by [`markdown_code_span`] and `diagnostics::sanitize_advisory_text_for_diagnostic`
1371/// (#1262 code-review follow-up) so the narrow bidi/invisible-character filter has exactly
1372/// one loop to keep in sync with [`is_markdown_unsafe`]'s policy, instead of two copies that
1373/// could silently drift. Also called from `crate::diagnostic::Diagnostic::new` and
1374/// `crate::diagnostic::RelatedInformation::new` as a defense-in-depth backstop on their
1375/// constructor path (#1276) — `pub(crate)` rather than private for that, but still not
1376/// reachable from outside `deps-core`.
1377///
1378/// Idempotent: `' '` is not itself [`is_markdown_unsafe`], so re-applying this to
1379/// already-sanitized input is a no-op — safe to double-apply through both a producer-side
1380/// call and the constructor-path backstop.
1381///
1382/// Only correct for a text/label sink or an inline code span, where a stray space is
1383/// harmless — **not** for a Markdown link *destination*: per CommonMark, an unbracketed
1384/// `[label](destination)` destination cannot contain a literal space at all, so
1385/// substituting one would turn a fired hazard into a broken (non-)link instead of a
1386/// sanitized one. [`strip_markdown_unsafe_chars`] is the destination-safe sibling.
1387pub(crate) fn replace_markdown_unsafe_chars(s: &str) -> String {
1388    s.chars()
1389        .map(|c| if is_markdown_unsafe(c) { ' ' } else { c })
1390        .collect()
1391}
1392
1393/// Removes every `is_markdown_unsafe` character from `s` outright, rather than
1394/// substituting a space (`replace_markdown_unsafe_chars`'s behavior).
1395///
1396/// Used for a Markdown link *destination* (#1259 critic S3): CommonMark forbids a
1397/// literal, unescaped space inside an unbracketed `[label](destination)` destination,
1398/// so replacing a stripped character with a space there would break the link (render it
1399/// as non-link literal text) instead of sanitizing it in place. Dropping the character
1400/// keeps the surrounding URL syntactically intact — the removed character carried no
1401/// meaningful display information to begin with (it is invisible/bidi-control by
1402/// definition), so there is nothing worth preserving a placeholder for.
1403///
1404/// # Examples
1405///
1406/// ```
1407/// use deps_core::lsp_helpers::strip_markdown_unsafe_chars;
1408///
1409/// assert_eq!(strip_markdown_unsafe_chars("https://example.com/pkg"), "https://example.com/pkg");
1410/// assert_eq!(
1411///     strip_markdown_unsafe_chars("https://example.com/real\u{202E}gnp.sj"),
1412///     "https://example.com/realgnp.sj"
1413/// );
1414/// ```
1415pub fn strip_markdown_unsafe_chars(s: &str) -> String {
1416    s.chars().filter(|c| !is_markdown_unsafe(*c)).collect()
1417}
1418
1419/// Wraps `content` in a Markdown inline code span (backticks included) that safely
1420/// contains arbitrary untrusted text, regardless of embedded backticks.
1421///
1422/// Backslash-escaping does not work inside code spans (CommonMark §6.1), so instead
1423/// this fences with one more backtick than the longest run found in `content`, and
1424/// pads with a single space on each side when `content` starts or ends with a
1425/// backtick or space (required by CommonMark to keep the fence unambiguous). Control
1426/// characters (including newlines) plus the same narrow invisible/bidi-override subset
1427/// `escape_markdown` neutralizes (see `is_markdown_unsafe`, #1248) are replaced with a
1428/// space first, since the raw hover string is otherwise free to merge into an adjacent
1429/// Markdown block or carry a Trojan Source bidi override into the rendered code span.
1430///
1431/// # Examples
1432///
1433/// ```
1434/// use deps_core::lsp_helpers::markdown_code_span;
1435///
1436/// assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
1437/// assert_eq!(markdown_code_span("a`b"), "``a`b``");
1438/// ```
1439pub fn markdown_code_span(content: &str) -> String {
1440    let sanitized = replace_markdown_unsafe_chars(content);
1441
1442    let max_backtick_run = sanitized
1443        .split(|c| c != '`')
1444        .map(str::len)
1445        .max()
1446        .unwrap_or(0);
1447    let fence = "`".repeat(max_backtick_run + 1);
1448
1449    if sanitized.is_empty() {
1450        format!("{fence} {fence}")
1451    } else if sanitized.starts_with(['`', ' ']) || sanitized.ends_with(['`', ' ']) {
1452        format!("{fence} {sanitized} {fence}")
1453    } else {
1454        format!("{fence}{sanitized}{fence}")
1455    }
1456}
1457
1458/// Checks if two version strings have the same major and minor version.
1459pub fn is_same_major_minor(v1: &str, v2: &str) -> bool {
1460    if v1.is_empty() || v2.is_empty() {
1461        return false;
1462    }
1463
1464    let mut parts1 = v1.split('.');
1465    let mut parts2 = v2.split('.');
1466
1467    if parts1.next() != parts2.next() {
1468        return false;
1469    }
1470
1471    match (parts1.next(), parts2.next()) {
1472        (Some(m1), Some(m2)) => m1 == m2,
1473        _ => true,
1474    }
1475}
1476
1477/// Result of checking whether a dependency's declared requirement is already satisfied by
1478/// the latest known version.
1479///
1480/// Diagnostics and inlay hints read this result differently: diagnostics only need to know
1481/// whether it is safe to skip the "Newer version available" warning, so both `UpToDate` and
1482/// `Unresolved` suppress it. Inlay hints additionally need to distinguish `Unresolved` from
1483/// `UpToDate`, since an unresolved requirement (e.g. a dangling Gradle version-catalog
1484/// `version.ref` alias, or an unexpanded Maven `${property}`) must not render an "up to
1485/// date" badge that was never actually verified.
1486// Exhaustive: a wildcard arm at any consuming match site would silently render the wrong
1487// label for a new variant instead of failing to compile (#769).
1488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1489pub enum RequirementStatus {
1490    /// The latest version satisfies the declared requirement.
1491    UpToDate,
1492    /// The latest version does not satisfy the declared requirement — a newer version is
1493    /// available.
1494    Outdated,
1495    /// The requirement could not be resolved to a concrete constraint, so no comparison
1496    /// could be made.
1497    Unresolved,
1498}
1499
1500/// A `requirement` compiled by one ecosystem, ready to test candidate versions against.
1501///
1502/// Produced by [`formatter::RequirementResolution::compile_requirement`]. Kept as a separate object
1503/// (rather than a single "does any version match" function) so the requirement is parsed
1504/// once per dependency, and so the scanning loop — including the empty-list guard, the
1505/// early-exit on first match, and the "skip an unparseable candidate" rule — lives once in
1506/// [`requirement_is_unsatisfiable`] instead of being reimplemented by all eleven ecosystems.
1507///
1508/// # Examples
1509///
1510/// ```
1511/// use deps_core::lsp_helpers::RequirementMatcher;
1512/// use deps_core::ConcreteVersion;
1513///
1514/// struct ExactMatch(String);
1515///
1516/// impl RequirementMatcher for ExactMatch {
1517///     fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
1518///         Some(version.as_str() == self.0)
1519///     }
1520/// }
1521///
1522/// let matcher = ExactMatch("1.0.0".to_string());
1523/// assert_eq!(matcher.matches(&ConcreteVersion::new("1.0.0")), Some(true));
1524/// assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
1525/// ```
1526pub trait RequirementMatcher: Send + Sync {
1527    /// Tests one candidate version string against the compiled requirement.
1528    ///
1529    /// `Some(true)` / `Some(false)`: this candidate provably does / does not satisfy the
1530    /// requirement. `None`: this candidate *string* could not be parsed by this ecosystem's
1531    /// version format (e.g. a PyPI legacy release identifier, a Maven timestamped snapshot
1532    /// qualifier) — the caller skips it and keeps scanning the rest of the list. Never
1533    /// return `None` to mean "the requirement itself is unusable"; that is
1534    /// [`formatter::RequirementResolution::compile_requirement`]'s job, via returning `None` from that
1535    /// method instead of constructing a matcher at all.
1536    fn matches(&self, version: &ConcreteVersion) -> Option<bool>;
1537}
1538
1539/// Whether `segment` is exactly `.` or `..`.
1540///
1541/// Unlike ordinary path characters, a literal `.`/`..` segment is not neutralized by
1542/// percent-encoding: `.` is an unreserved character (RFC 3986), so `urlencoding::encode`
1543/// leaves it untouched, and the URL parser's dot-segment removal (RFC 3986 §5.2.4) still
1544/// collapses it after encoding — `%2E` decodes back to `.` before that normalization runs.
1545/// A registry-fetch URL built as `{base}/{prefix}/{name}` (no fixed suffix after `name`)
1546/// must reject a `name`/path segment satisfying this predicate rather than encode it,
1547/// since encoding alone does not stop the collapse (#341, #349).
1548///
1549/// Shared by `deps-npm`'s scope/package segment guard and `deps-dart`'s package-name
1550/// guard — both ecosystems' registry APIs key a fetch on a bare, suffix-less path segment.
1551///
1552/// **Scope**: this predicate (and the `#365` regression sweep built around it) guards
1553/// registry-*fetch* URL builders — the sink is a request this process actually
1554/// dereferences, so a retargeted URL can make it fetch attacker-chosen data. It
1555/// deliberately does *not* extend to a "docs link"/`package_url`-style builder (the
1556/// per-ecosystem hover/display link, e.g. `deps_cargo::crate_url`, `deps_go::package_url`):
1557/// those interpolate the name into a link rendered in hover text and never fetched by
1558/// this process, so an unrejected `.`/`..` name there produces at worst a misleading
1559/// same-host link (the registry's package-listing root), not a traversal off-host (#379).
1560///
1561/// # Examples
1562///
1563/// ```
1564/// use deps_core::lsp_helpers::is_dot_segment;
1565///
1566/// assert!(is_dot_segment(".."));
1567/// assert!(is_dot_segment("."));
1568/// assert!(!is_dot_segment("left-pad"));
1569/// ```
1570pub fn is_dot_segment(segment: &str) -> bool {
1571    segment == "." || segment == ".."
1572}
1573
1574/// Whether `version` is safe to embed in a manifest `TextEdit` or completion item.
1575///
1576/// Guards every call into
1577/// [`formatter::PackageRendering::format_version_replacing`]/[`formatter::PackageRendering::format_version_for_text_edit`]
1578/// and every completion item's `insert_text`/`text_edit`.
1579///
1580/// Must be applied to the raw version string *before* formatting, never to a
1581/// formatter's output: some formatters legitimately produce structural
1582/// characters in their output from an already-validated version plus fixed,
1583/// trusted operators (e.g. PyPI's `>=1.2.3,<2`), so validating the output
1584/// would wrongly reject those.
1585///
1586/// An allowlist, not a denylist: `version` must be non-empty, at most 64
1587/// bytes, and contain only `[A-Za-z0-9.+_~:*^!-]` — the character set real
1588/// version strings use across every ecosystem this workspace supports
1589/// (SemVer, PEP 440 including epochs like `1!2.0`, Maven qualifiers, npm's
1590/// `^`/`~`/`*` range tokens, Go's `+incompatible` suffix). A denylist here
1591/// would need to anticipate every dangerous token a target manifest format
1592/// (or a build tool evaluating it, e.g. Gradle's Kotlin/Groovy DSL
1593/// interpolating `${...}` inside a version literal) could ever act on;
1594/// failing closed on an unrecognized character is cheaper and safer.
1595///
1596/// This is the single validation chokepoint shared by every producer of a
1597/// version-derived `TextEdit`/completion item in this workspace, including
1598/// OSV advisory data (an `Advisory.fixed_versions` entry is exactly as
1599/// untrusted as a registry-reported version).
1600///
1601/// # Examples
1602///
1603/// ```
1604/// use deps_core::is_safe_version_string;
1605///
1606/// assert!(is_safe_version_string("1.2.3-alpha.1+build"));
1607/// assert!(!is_safe_version_string("1.2.3\", git = \"https://evil"));
1608/// ```
1609pub fn is_safe_version_string(version: &str) -> bool {
1610    !version.is_empty()
1611        && version.len() <= 64
1612        && version.chars().all(|c| {
1613            c.is_ascii_alphanumeric()
1614                || matches!(c, '.' | '+' | '_' | '~' | ':' | '*' | '^' | '!' | '-')
1615        })
1616}
1617
1618/// Whether `segment` is safe to embed as a Maven `groupId`/`artifactId` value in a
1619/// pom.xml `TextEdit` or completion item.
1620///
1621/// Guards Maven's group/artifact completion producer, which builds a completion item's
1622/// `insert_text`/`text_edit` from one field of a Maven Central search result — a value
1623/// type distinct from a version string (see [`is_safe_version_string`]'s doc comment for
1624/// why version-derived and non-version-derived sinks each get their own allowlist).
1625///
1626/// An allowlist, not a denylist: `segment` must be non-empty, at most 128 bytes, not
1627/// exactly `.`/`..` (see [`is_dot_segment`]), and contain only `[A-Za-z0-9._-]` — the
1628/// character set real Maven Central group ids (reverse-DNS style, e.g.
1629/// `org.apache.commons`) and artifact ids (hyphen/underscore separated, e.g.
1630/// `commons-lang3`) use. Deliberately excludes `:` — the `groupId:artifactId` separator —
1631/// because this validates one already-split coordinate field at a time, never the joined
1632/// pair. Failing closed on an unrecognized character (e.g. `<`, `"`, a newline) keeps a
1633/// malicious/compromised search result from restructuring the pom.xml it's inserted into;
1634/// the dedicated `.`/`..` rejection closes the same dot-segment URL-normalization gap
1635/// [`is_dot_segment`] guards elsewhere (`artifactId` reaches a registry-fetch URL as a bare
1636/// path segment in `deps-maven::registry::metadata_urls`, unlike `groupId`, whose `.`→`/`
1637/// expansion can never itself produce a literal `..` component).
1638///
1639/// # Examples
1640///
1641/// ```
1642/// use deps_core::is_safe_maven_coordinate_segment;
1643///
1644/// assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
1645/// assert!(is_safe_maven_coordinate_segment("commons-lang3"));
1646/// assert!(!is_safe_maven_coordinate_segment("commons</artifactId><parent>"));
1647/// assert!(!is_safe_maven_coordinate_segment(".."));
1648/// ```
1649pub fn is_safe_maven_coordinate_segment(segment: &str) -> bool {
1650    !segment.is_empty()
1651        && segment.len() <= 128
1652        && !is_dot_segment(segment)
1653        && segment
1654            .chars()
1655            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
1656}
1657
1658/// Builds the Maven repository URL path (`"group/path/segments/artifact"`) for a
1659/// `groupId`/`artifactId` coordinate pair, or `None` if either fails validation.
1660///
1661/// The single shared home for `groupId:artifactId` -> URL-path construction (#702):
1662/// validates `group_id` and `artifact_id` as whole strings via
1663/// [`is_safe_maven_coordinate_segment`] (preserving its 128-byte total-length cap per
1664/// segment) *and* validates every `.`-separated component of `group_id` individually,
1665/// rejecting an empty component outright. The whole-string check alone is not enough —
1666/// it permits internal `.` characters, so a naive `group_id.replace('.', "/")` on a
1667/// coordinate like `"com..evil"` would produce the empty path segment `"com//evil"`; the
1668/// per-component check closes that gap without dropping the whole-string length bound a
1669/// pure per-segment `split('.')` validation would lose (a 10-segment, 128-byte-each group
1670/// would otherwise pass at 1280 bytes total).
1671///
1672/// # Examples
1673///
1674/// ```
1675/// use deps_core::maven_coordinate_path;
1676///
1677/// assert_eq!(
1678///     maven_coordinate_path("com.example", "artifact").as_deref(),
1679///     Some("com/example/artifact")
1680/// );
1681/// assert_eq!(
1682///     maven_coordinate_path("junit", "junit").as_deref(),
1683///     Some("junit/junit")
1684/// );
1685/// assert_eq!(maven_coordinate_path("com..evil", "artifact"), None);
1686/// ```
1687pub fn maven_coordinate_path(group_id: &str, artifact_id: &str) -> Option<String> {
1688    if !is_safe_maven_coordinate_segment(group_id) || !is_safe_maven_coordinate_segment(artifact_id)
1689    {
1690        return None;
1691    }
1692    let group_segments: Vec<&str> = group_id.split('.').collect();
1693    if group_segments
1694        .iter()
1695        .any(|segment| !is_safe_maven_coordinate_segment(segment))
1696    {
1697        return None;
1698    }
1699    Some(format!("{}/{artifact_id}", group_segments.join("/")))
1700}
1701
1702/// Whether `url` is safe to embed as a Swift Package Manager repository URL in a
1703/// Package.swift `TextEdit` or completion item.
1704///
1705/// Guards Swift's URL-completion producer, which builds a `.package(url: "...")`
1706/// string-literal replacement from a package registry search result's URL — a value
1707/// type distinct from a version string (see [`is_safe_version_string`]'s doc comment for
1708/// why version-derived and non-version-derived sinks each get their own allowlist).
1709///
1710/// An allowlist, not a denylist: `url` must be non-empty, at most 2048 bytes, start with
1711/// `https://` — every real Swift package registry response is HTTPS (GitHub's `html_url`
1712/// never downgrades), so accepting plain `http://` would only hand a
1713/// compromised/malicious registry a transport-downgrade lever for zero legitimate
1714/// benefit — and otherwise contain only RFC 3986 URL characters (`A-Za-z0-9` plus
1715/// `` -._~:/?#[]@!$&'()*+,;=% ``). Deliberately excludes `"`, `\`, control characters,
1716/// and whitespace — none of those are valid unencoded URL characters, and any of them
1717/// could close the surrounding Swift string literal or otherwise corrupt the manifest.
1718/// Failing closed on an unrecognized character keeps a malicious/compromised search
1719/// result from breaking out of the string it's inserted into.
1720///
1721/// # Examples
1722///
1723/// ```
1724/// use deps_core::is_safe_registry_url;
1725///
1726/// assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
1727/// assert!(!is_safe_registry_url("https://evil.example\", .exact(\"1\")) // "));
1728/// ```
1729pub fn is_safe_registry_url(url: &str) -> bool {
1730    !url.is_empty()
1731        && url.len() <= 2048
1732        && url.starts_with("https://")
1733        && url.chars().all(|c| {
1734            c.is_ascii_alphanumeric()
1735                || matches!(
1736                    c,
1737                    '-' | '.'
1738                        | '_'
1739                        | '~'
1740                        | ':'
1741                        | '/'
1742                        | '?'
1743                        | '#'
1744                        | '['
1745                        | ']'
1746                        | '@'
1747                        | '!'
1748                        | '$'
1749                        | '&'
1750                        | '\''
1751                        | '('
1752                        | ')'
1753                        | '*'
1754                        | '+'
1755                        | ','
1756                        | ';'
1757                        | '='
1758                        | '%'
1759                )
1760        })
1761}
1762
1763/// Whether `name` is safe to embed as a package name in a manifest `TextEdit` or
1764/// completion item.
1765///
1766/// Guards every arm of `create_package_completion_item`
1767/// (`crates/deps-lsp/src/handlers/completion.rs`) as a single upfront check, applied
1768/// before the raw `name` reaches any ecosystem-specific snippet — including Maven and
1769/// Swift, which additionally validate a *derived* value on top of this gate
1770/// ([`is_safe_maven_coordinate_segment`] on each split coordinate segment,
1771/// [`is_safe_registry_url`] on the constructed URL) because a value type distinct from
1772/// the raw name needs its own allowlist — see [`is_safe_version_string`]'s doc comment
1773/// for why version-derived and non-version-derived sinks each get their own allowlist.
1774/// [`PackageName::new`](crate::PackageName::new) is documented as never validating or
1775/// modifying its input, so this predicate is the first gate a registry-reported name
1776/// passes through before reaching a manifest. Two sinks that key a bare TOML/YAML
1777/// entry by `name` (Cargo/PyPI, Dart) additionally quote that key in the snippet, since
1778/// `.` and `@` are legal here but would otherwise be read as TOML's dotted-key
1779/// separator or break a YAML plain scalar.
1780///
1781/// An allowlist, not a denylist: `name` must be non-empty, at most 256 bytes, and
1782/// contain only `[A-Za-z0-9._@:/~-]` — the character set real package names use across
1783/// every ecosystem this predicate guards: Cargo/PyPI/Dart/NuGet/Bundler
1784/// (alphanumeric, `-`, `_`, `.`), npm/Deno scoped names (`@scope/name`, adding `@` and
1785/// `/`), Composer (`vendor/package`, `/`), Go module paths (domain-qualified paths like
1786/// `github.com/org/repo`, `/`, `.`, and `~` — legal in a Go path element and already
1787/// allowed by [`is_safe_version_string`]/[`is_safe_registry_url`]), and Gradle's
1788/// colon-delimited `group:artifact` short form (`:`). A denylist here would need to
1789/// anticipate every dangerous token a target manifest format (TOML/JSON/YAML/XML
1790/// string literals, a live Kotlin/Groovy build-script DSL) could ever act on; failing
1791/// closed on an unrecognized character — notably `"`, `'`, `<`, `>`, `` ` ``, and all
1792/// control characters/newlines — is cheaper and safer.
1793///
1794/// # Examples
1795///
1796/// ```
1797/// use deps_core::is_safe_package_name;
1798///
1799/// assert!(is_safe_package_name("serde"));
1800/// assert!(is_safe_package_name("@scope/name"));
1801/// assert!(is_safe_package_name("org.apache.commons:commons-lang3"));
1802/// assert!(!is_safe_package_name("evil\"\nbackdoor = \"9.9.9"));
1803/// ```
1804pub fn is_safe_package_name(name: &str) -> bool {
1805    !name.is_empty()
1806        && name.len() <= 256
1807        && name.chars().all(|c| {
1808            c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '@' | ':' | '/' | '-' | '~')
1809        })
1810}
1811
1812/// Logs a `tracing::warn!` for a value rejected by an `is_safe_*` predicate (or an
1813/// equivalent value-rejecting gate) before it reaches a manifest edit or registry URL.
1814///
1815/// Deliberately logs only `value`'s byte length, never its content: `value` is
1816/// registry-controlled and, by construction, already failed an allowlist — logging it
1817/// verbatim at `warn` would let a malicious/compromised registry response inject arbitrary
1818/// content into this project's own log stream (a second-order log-injection concern),
1819/// mirroring why `deps-pypi`'s `truncate_for_log` bounds a logged excerpt instead of
1820/// logging a value verbatim. This is this helper's own contract, not a claim that every
1821/// `tracing` call site in the workspace avoids logging a raw value — e.g. `deps-lsp`'s
1822/// `deps-lsp.updateVersion` handler and an OSV malformed-`fixed`-version warning predate
1823/// this helper and log their rejected value directly; they are unrelated call sites, not a
1824/// place this helper is used.
1825///
1826/// `gate` names the predicate/guard that rejected `value` (e.g.
1827/// `"is_safe_maven_coordinate_segment"`); `context` is a short description of the call site
1828/// (e.g. `"maven groupId completion"`).
1829///
1830/// # Examples
1831///
1832/// ```
1833/// use deps_core::lsp_helpers::warn_rejected_value;
1834///
1835/// warn_rejected_value("is_safe_version_string", "code lens latest version", "1.0.0\"; evil");
1836/// ```
1837pub fn warn_rejected_value(gate: &str, context: &str, value: &str) {
1838    tracing::warn!(
1839        gate,
1840        context,
1841        len = value.len(),
1842        "rejected unsafe value before manifest/registry sink"
1843    );
1844}
1845
1846/// Logs a [`warn_rejected_value`] warning and builds the [`DepsError::PackageNotFound`] a
1847/// caller returns after its own `is_dot_segment`/`has_dot_segment` predicate rejects `name`.
1848///
1849/// Extracted from four equivalent `warn_rejected_value` + `PackageNotFound`
1850/// constructions in `deps-dart`, `deps-composer`, `deps-nuget`, and `deps-npm`
1851/// (deps-lsp#929 item 2) — `deps-npm`'s `gate` label was `"npm_dot_segment_guard"` rather
1852/// than the other three's `"is_dot_segment"` before this extraction normalized it. Each
1853/// crate keeps its own dot-segment *predicate* and the call to it exactly where it is
1854/// today; only this trailing boilerplate is shared. Deliberately does **not** fold in
1855/// `deps-go`'s guard, which returns `DepsError::InvalidVersionReq`, not `PackageNotFound`.
1856///
1857/// # Examples
1858///
1859/// ```
1860/// use deps_core::lsp_helpers::dot_segment_rejection_error;
1861/// use deps_core::DepsError;
1862///
1863/// let err = dot_segment_rejection_error(
1864///     "is_dot_segment",
1865///     "example package metadata request URL",
1866///     "..",
1867///     "example",
1868/// );
1869/// assert!(matches!(err, DepsError::PackageNotFound { .. }));
1870/// ```
1871#[must_use]
1872pub fn dot_segment_rejection_error(
1873    gate: &str,
1874    context: &str,
1875    name: &str,
1876    registry: &'static str,
1877) -> DepsError {
1878    warn_rejected_value(gate, context, name);
1879    DepsError::PackageNotFound {
1880        package: name.into(),
1881        registry,
1882    }
1883}
1884
1885/// Converts a domain [`url::Url`] back into a `tower_lsp_server::ls_types::Uri` for
1886/// embedding into an actual LSP response object (`WorkspaceEdit`, `Location`, a code-lens
1887/// command argument, ...).
1888///
1889/// This is the one blessed conversion path from `deps-core`'s internal `url::Url` domain
1890/// type to `tower_lsp_server`'s `Uri` — ecosystem crates and `deps-core` itself must route
1891/// through it rather than re-deriving the conversion (e.g. `uri.as_str().parse()`) locally,
1892/// so a future change to how URLs round-trip through the LSP wire format only needs fixing
1893/// in one place. It mirrors `deps-lsp`'s own `lsp_types_interop::to_lsp_uri` (issue #1071):
1894/// `deps-core` cannot reuse that function (the dependency direction points the other way)
1895/// or express this as a `From` impl (neither `url::Url` nor `ls_types::Uri` is local to
1896/// `deps-core` — the same orphan-rule constraint that makes the `deps-lsp` boundary a pair
1897/// of free functions instead of a trait impl), so this is `deps-core`'s independent
1898/// counterpart, needed because `lsp_helpers` still builds real `ls_types` response objects
1899/// directly even though `Dependency`/`ParseResult` no longer carry an `ls_types::Uri` (see
1900/// `lib.rs`'s "LSP type stability" doc section).
1901///
1902/// # Panics
1903///
1904/// Panics if `url`'s string form does not round-trip into an `Uri`. In practice this
1905/// never happens: every `url::Url` reaching `lsp_helpers` originates from a real
1906/// editor-opened document (an `ls_types::Uri` converted to `Url` at the `deps-lsp`
1907/// boundary), so failing to convert it back indicates upstream corruption, not
1908/// attacker-controlled manifest content.
1909///
1910/// # Examples
1911///
1912/// ```
1913/// use deps_core::to_ls_uri;
1914/// use url::Url;
1915///
1916/// let url = Url::parse("file:///tmp/Cargo.toml").unwrap();
1917/// let uri = to_ls_uri(&url);
1918/// assert_eq!(uri.as_str(), "file:///tmp/Cargo.toml");
1919/// ```
1920#[cfg(feature = "lsp-responses")]
1921#[must_use]
1922pub fn to_ls_uri(url: &url::Url) -> tower_lsp_server::ls_types::Uri {
1923    url.as_str()
1924        .parse()
1925        .unwrap_or_else(|e| panic!("document URL {url} did not round-trip to an LSP Uri: {e}"))
1926}
1927
1928/// Builds a single-entry [`tower_lsp_server::ls_types::WorkspaceEdit::changes`] map replacing `range` in `uri`
1929/// with `new_text`.
1930///
1931/// Shared by every quickfix/refactor code action in `code_actions` that edits exactly
1932/// one span in the current document (`build_vulnerability_fix_action`,
1933/// `build_unsatisfiable_fix_action`, and the plain "update to `<version>`" loop in
1934/// [`generate_code_actions`]) — and by any ecosystem crate hand-building a
1935/// `WorkspaceEdit` for a single-span fix instead of reimplementing this map shape
1936/// locally.
1937///
1938/// # Panics
1939///
1940/// Panics if `uri` does not round-trip through [`to_ls_uri`] — see that function's own
1941/// `# Panics` section.
1942///
1943/// # Examples
1944///
1945/// ```
1946/// use deps_core::single_file_edit;
1947/// use deps_core::position::{Position, Range};
1948/// use url::Url;
1949///
1950/// let url = Url::parse("file:///tmp/Cargo.toml").unwrap();
1951/// let range = Range::new(Position::new(0, 0), Position::new(0, 5));
1952/// let edits = single_file_edit(&url, range, "1.2.3".to_string());
1953///
1954/// assert_eq!(edits.len(), 1);
1955/// ```
1956#[cfg(feature = "lsp-responses")]
1957#[must_use]
1958pub fn single_file_edit(
1959    uri: &url::Url,
1960    range: Range,
1961    new_text: String,
1962) -> HashMap<tower_lsp_server::ls_types::Uri, Vec<tower_lsp_server::ls_types::TextEdit>> {
1963    let mut edits = HashMap::new();
1964    edits.insert(
1965        to_ls_uri(uri),
1966        vec![tower_lsp_server::ls_types::TextEdit {
1967            range: range.into(),
1968            new_text,
1969        }],
1970    );
1971    edits
1972}
1973
1974/// Strips every whitespace character from `s`, so two textually-equivalent strings that
1975/// differ only in spacing compare equal.
1976///
1977/// Shared by every no-op/literal-match guard across `code_actions` and `code_lenses`
1978/// (`build_vulnerability_fix_action`'s N1 guard, `generate_code_actions`'s REFACTOR-loop
1979/// guard, `literal_span_matches`, and `collect_update_all_edits`'s no-op guard), all of
1980/// which compare a declared requirement
1981/// string against a differently-normalized counterpart — e.g. pep508's `>=1.7, <2.0` vs. a
1982/// formatter's `>=1.7,<2.0`.
1983fn strip_whitespace(s: &str) -> String {
1984    s.chars().filter(|c| !c.is_whitespace()).collect()
1985}
1986
1987/// Slices `content` over an LSP `Range` using a pre-built `LineOffsetTable`, returning
1988/// `""` for an inverted or out-of-bounds range instead of panicking.
1989///
1990/// `table` is document-invariant — callers iterating over multiple dependencies in the
1991/// same document must build it once and reuse it, rather than rebuilding it (an O(n)
1992/// scan of `content`) per dependency.
1993fn slice_for_range<'a>(content: &'a str, table: &LineOffsetTable, range: Range) -> &'a str {
1994    let start = table.position_to_byte_offset(content, range.start);
1995    let end = table.position_to_byte_offset(content, range.end);
1996    if start > end {
1997        return "";
1998    }
1999    content.get(start..end).unwrap_or("")
2000}
2001
2002/// Checks whether `slice` — `content` sliced over a dependency's `version_range` — still
2003/// holds the literal version text declared by `requirement`.
2004///
2005/// Whitespace is stripped from both sides before comparison, since pep508's normalized
2006/// requirement string can diverge from the original source spacing (PyPI's `>=1.7,<2.0`
2007/// renders as `>=1.7, <2.0`) while `version_range` still spans the un-normalized source.
2008///
2009/// The second branch accepts `slice` wrapped in brackets matching `requirement` — the
2010/// exact inverse of NuGet's parser wrapping a bare source version as `format!("[{v}]")`
2011/// (`crates/deps-nuget/src/parser.rs`). This is deliberately **not** a symmetric bracket
2012/// strip: NuGet's `Version="1.0.0"` produces requirement `[1.0.0]` over a bare-literal
2013/// span, so a *symmetric* strip (stripping one bracket pair from both operands) would
2014/// compare `1.0.0` against `1.0.0` — coincidentally correct there, but the same strip
2015/// applied to `Version="[1.0.0]"` (requirement `[[1.0.0]]`, a spelling
2016/// `crates/deps-nuget/src/formatter.rs` explicitly supports) leaves `[1.0.0]` vs
2017/// `1.0.0` and **falsely rejects** an editable dependency. Wrapping only the slice side
2018/// handles both spellings without that false reject.
2019fn literal_span_matches(slice: &str, requirement: &str) -> bool {
2020    let norm_slice = strip_whitespace(slice);
2021    let norm_req = strip_whitespace(requirement);
2022    norm_slice == norm_req || format!("[{norm_slice}]") == norm_req
2023}
2024
2025/// Whether `version_range`'s slice of `content` still holds `dep`'s own declared literal
2026/// version text.
2027///
2028/// The same literal-span discipline [`generate_code_actions`] and
2029/// `collect_update_all_edits` already apply before writing a `TextEdit` there, generalized
2030/// (issue #919) for any caller about to treat `version_range` as an editable literal: a
2031/// completion context, or a raw-text-scanning ecosystem's own completion dispatch (Maven,
2032/// Gradle) that never goes through [`crate::completion::detect_completion_context`] at all.
2033///
2034/// Returns `false` — not editable — when the slice is syntactically reference-shaped (see
2035/// below), or the slice doesn't textually match the declared literal. When `dep` has no
2036/// [`Dependency::version_requirement`] at all, an empty (or whitespace-only) slice is admitted
2037/// — e.g. Maven's `<version></version>`, which has no text for its parser to capture and thus
2038/// nothing that could be misread as a reference/wildcard (#1161) — but a *non-empty* slice is
2039/// still rejected, since there is then no requirement to validate it against.
2040///
2041/// The reference-shape check runs **independently** of the text comparison (#919 C1): an
2042/// *unresolved* Maven `${property}` or Gradle `$var`/`${var}` interpolation is left by its
2043/// parser exactly as-is in both `version_range`'s slice and `version_requirement` (there is
2044/// nothing else to put there), so the two would otherwise textually agree and wrongly pass —
2045/// this is precisely the corruption #919 was filed over, and the dominant real-world Maven
2046/// case (a version inherited from a parent/BOM POM this crate cannot resolve) always lands
2047/// here. A slice containing `$` anywhere is rejected up front, before ever comparing text —
2048/// this single check subsumes both a whole-value `$var`/`${var}` interpolation and a Gradle
2049/// GString with the reference embedded mid-string (`1.0.$patch`), which `resolve_variable_ref`
2050/// only ever resolves in its whole-value form, leaving the mid-string case byte-identical
2051/// between slice and requirement and otherwise invisible to a `starts_with`-only check. A
2052/// slice that starts with `*` is rejected the same way *unless* [`crate::is_existence_wildcard_str`]
2053/// recognizes it as the project-wide existence-wildcard spelling (synthesized by `deps-npm`,
2054/// `deps-composer`, ...) — that case must keep offering a full version list at the exact
2055/// moment a user wants to replace the wildcard with a pin, while a `*anchor` YAML alias (any
2056/// other leading-`*` shape) still gets rejected.
2057///
2058/// An empty `version_requirement` is deliberately **not** special-cased here (unlike
2059/// `generate_code_actions`'s "nothing to update" early return, which does not apply to
2060/// completion): `serde = ""` / `"lodash": ""` — what an editor's auto-closing quotes produce
2061/// the instant the opening quote is typed — must still offer the full version list.
2062/// `literal_span_matches` alone already handles this correctly: `("", "")` matches (empty
2063/// slice, empty requirement — admitted), while a non-empty non-literal slice against an empty
2064/// requirement still fails to match (rejected).
2065///
2066/// Compares against [`Dependency::version_literal`] when the ecosystem provides one, falling
2067/// back to `version_requirement` otherwise (see that method's doc for why).
2068///
2069/// Builds its own [`LineOffsetTable`] for `content` on every call rather than accepting a
2070/// caller-built one — unlike `slice_for_range`'s multi-dependency callers (`generate_code_actions`,
2071/// `code_lenses`), every current call site invokes this at most once per request (a single
2072/// matched dependency), so a table-accepting overload would add API surface with no caller to
2073/// exercise it.
2074///
2075/// # Examples
2076///
2077/// ```
2078/// use deps_core::lsp_helpers::dependency_version_range_is_literal;
2079/// use deps_core::{Dependency, PackageName, VersionReq};
2080/// use std::any::Any;
2081/// use tower_lsp_server::ls_types::{Position, Range};
2082///
2083/// struct MockDep {
2084///     name: PackageName,
2085///     version_req: VersionReq,
2086///     version_range: deps_core::position::Range,
2087/// }
2088/// impl Dependency for MockDep {
2089///     fn name(&self) -> &PackageName {
2090///         &self.name
2091///     }
2092///     fn name_range(&self) -> deps_core::position::Range {
2093///         deps_core::position::Range::default()
2094///     }
2095///     fn version_requirement(&self) -> Option<&VersionReq> {
2096///         Some(&self.version_req)
2097///     }
2098///     fn version_range(&self) -> Option<deps_core::position::Range> {
2099///         Some(self.version_range)
2100///     }
2101///     fn source(&self) -> deps_core::parser::DependencySource {
2102///         deps_core::parser::DependencySource::Registry
2103///     }
2104///     fn as_any(&self) -> &dyn Any {
2105///         self
2106///     }
2107/// }
2108///
2109/// // A plain literal version is admitted.
2110/// let content = r#"serde = "1.0.0""#;
2111/// let dep = MockDep {
2112///     name: PackageName::new("serde"),
2113///     version_req: VersionReq::new("1.0.0"),
2114///     version_range: Range::new(Position::new(0, 9), Position::new(0, 14)).into(),
2115/// };
2116/// assert!(dependency_version_range_is_literal(&dep, content, dep.version_range.into()));
2117///
2118/// // A Maven `${property}` interpolation is rejected even when unresolved — slice and
2119/// // requirement are byte-identical raw reference text, which the shape check catches
2120/// // independently of the (otherwise-matching) text comparison.
2121/// let content = r"<version>${slf4j.version}</version>";
2122/// let dep = MockDep {
2123///     name: PackageName::new("slf4j-api"),
2124///     version_req: VersionReq::new("${slf4j.version}"),
2125///     version_range: Range::new(Position::new(0, 9), Position::new(0, 25)).into(),
2126/// };
2127/// assert!(!dependency_version_range_is_literal(&dep, content, dep.version_range.into()));
2128/// ```
2129#[must_use]
2130pub fn dependency_version_range_is_literal(
2131    dep: &dyn Dependency,
2132    content: &str,
2133    version_range: Range,
2134) -> bool {
2135    if version_range == Range::default() {
2136        // `Range::default()` ((0,0)-(0,0)) is a purely syntactic "not a real position"
2137        // sentinel, never a genuine in-document location — `deps-gradle`'s catalog context
2138        // deliberately returns it for a `version.ref = "alias"` reference (#931), including a
2139        // dangling or still-being-typed one, so this function must always reject it
2140        // unconditionally, before the `version_requirement()` check below ever runs.
2141        return false;
2142    }
2143
2144    let table = LineOffsetTable::new(content);
2145    let slice = slice_for_range(content, &table, version_range);
2146    let trimmed = slice.trim();
2147
2148    let Some(version_req) = dep.version_requirement() else {
2149        // No parsed requirement at all — e.g. Maven's `<version></version>`, which has no
2150        // text for its parser to capture. An empty span here has no existing text that could
2151        // be misread as a reference/wildcard, so it is always safe to offer completion
2152        // (#1161) — now that the `Range::default()` sentinel above is rejected first, this can
2153        // only be a genuine in-document position.
2154        return trimmed.is_empty();
2155    };
2156    if trimmed.contains('$')
2157        || (trimmed.starts_with('*') && !crate::is_existence_wildcard_str(trimmed))
2158    {
2159        return false;
2160    }
2161    let literal_target = dep
2162        .version_literal()
2163        .unwrap_or_else(|| version_req.as_str());
2164    literal_span_matches(slice, literal_target)
2165}
2166
2167#[cfg(test)]
2168#[expect(
2169    clippy::string_slice,
2170    reason = "fixtures are single-line ASCII literals with hand-computed byte offsets"
2171)]
2172mod tests {
2173    use super::*;
2174    use crate::lsp_helpers::test_support::*;
2175    use crate::{PackageName, VersionReq};
2176
2177    /// #919: a plain literal version — `version_range`'s slice equals the declared
2178    /// requirement exactly — must be admitted as editable.
2179    #[test]
2180    fn test_dependency_version_range_is_literal_plain_literal_admitted() {
2181        let content = r#"serde = "1.0.0""#;
2182        let dep = MockDep {
2183            name: PackageName::new("serde"),
2184            version_req: VersionReq::new("1.0.0"),
2185            version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
2186            name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2187        };
2188
2189        assert!(dependency_version_range_is_literal(
2190            &dep,
2191            content,
2192            dep.version_range,
2193        ));
2194    }
2195
2196    /// #919: a Maven-style `${property}` interpolation — `version_range` slices to the raw
2197    /// placeholder text while the parser resolves `version_requirement` to the property's
2198    /// value (a different string) — must be rejected, since a completion/edit there would
2199    /// splice text into the interpolation instead of updating a version.
2200    #[test]
2201    fn test_dependency_version_range_is_literal_rejects_property_interpolation() {
2202        let content = r"<version>${slf4j.version}</version>";
2203        let dep = MockDep {
2204            name: PackageName::new("slf4j-api"),
2205            version_req: VersionReq::new("2.0.16"), // resolved property value
2206            version_range: Range::new(Position::new(0, 9), Position::new(0, 25)), // "${slf4j.version}"
2207            name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2208        };
2209
2210        assert!(!dependency_version_range_is_literal(
2211            &dep,
2212            content,
2213            dep.version_range,
2214        ));
2215    }
2216
2217    /// #919: a YAML alias-shaped value (`*anchor`, e.g. GitLab CI/GitHub Actions `ref: *pin`
2218    /// job reuse) never textually equals a declared literal requirement — rejected the same
2219    /// way as the property-interpolation case.
2220    #[test]
2221    fn test_dependency_version_range_is_literal_rejects_yaml_alias() {
2222        let content = "ref: *pin";
2223        let dep = MockDep {
2224            name: PackageName::new("job"),
2225            version_req: VersionReq::new("1.0.0"),
2226            version_range: Range::new(Position::new(0, 5), Position::new(0, 9)), // "*pin"
2227            name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2228        };
2229
2230        assert!(!dependency_version_range_is_literal(
2231            &dep,
2232            content,
2233            dep.version_range,
2234        ));
2235    }
2236
2237    /// S1 (critic follow-up): an empty `version_requirement` over an equally-empty slice —
2238    /// exactly what an editor's auto-closing quotes produce the instant `serde = "` is typed
2239    /// — must be **admitted**, not rejected. `generate_code_actions`'s "nothing to update"
2240    /// semantics for an empty requirement do not apply to completion, where an empty value is
2241    /// the single most common moment to offer the full version list.
2242    #[test]
2243    fn test_dependency_version_range_is_literal_admits_empty_requirement_with_empty_slice() {
2244        let content = r#"serde = """#;
2245        let dep = MockDep {
2246            name: PackageName::new("serde"),
2247            version_req: VersionReq::new(""),
2248            version_range: Range::new(Position::new(0, 9), Position::new(0, 9)),
2249            name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2250        };
2251
2252        assert!(dependency_version_range_is_literal(
2253            &dep,
2254            content,
2255            dep.version_range,
2256        ));
2257    }
2258
2259    /// S1 (critic follow-up): an empty `version_requirement` paired with a *non-empty*
2260    /// non-literal slice must still be rejected — dropping the old `is_empty()` early return
2261    /// must not also drop this case, which the plain text comparison already handles on its
2262    /// own (`"1.0" != ""`).
2263    #[test]
2264    fn test_dependency_version_range_is_literal_rejects_empty_requirement_with_mismatched_slice() {
2265        let content = r#"x = "1.0""#;
2266        let dep = MockDep {
2267            name: PackageName::new("x"),
2268            version_req: VersionReq::new(""),
2269            version_range: Range::new(Position::new(0, 5), Position::new(0, 8)), // "1.0"
2270            name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
2271        };
2272
2273        assert!(!dependency_version_range_is_literal(
2274            &dep,
2275            content,
2276            dep.version_range,
2277        ));
2278    }
2279
2280    /// C1 (critic finding): the headline #919 case — an *unresolved* Maven `${property}`
2281    /// reference. The parser leaves an unresolved property as-is in both `version_range`'s
2282    /// slice and `version_requirement` (there is nothing else to put there), so the two
2283    /// textually agree — `literal_span_matches` alone would wrongly admit this. The
2284    /// reference-shape check must reject it independently of that text comparison.
2285    #[test]
2286    fn test_dependency_version_range_is_literal_rejects_unresolved_maven_property() {
2287        let content = r"<version>${slf4j.version}</version>";
2288        let dep = MockDep {
2289            name: PackageName::new("slf4j-api"),
2290            version_req: VersionReq::new("${slf4j.version}"), // left unresolved by the parser
2291            version_range: Range::new(Position::new(0, 9), Position::new(0, 25)), // "${slf4j.version}"
2292            name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2293        };
2294
2295        assert!(!dependency_version_range_is_literal(
2296            &dep,
2297            content,
2298            dep.version_range,
2299        ));
2300    }
2301
2302    /// C1 (critic finding): the Gradle sibling of the unresolved-property case — a `$var`
2303    /// reference `resolve_variable_ref` could not resolve, left as raw text in both the slice
2304    /// and `version_requirement`.
2305    #[test]
2306    fn test_dependency_version_range_is_literal_rejects_unresolved_gradle_variable() {
2307        let content = "implementation 'com.example:lib:$libVersion'";
2308        let dep = MockDep {
2309            name: PackageName::new("com.example:lib"),
2310            version_req: VersionReq::new("$libVersion"), // left unresolved by the parser
2311            version_range: Range::new(Position::new(0, 32), Position::new(0, 43)), // "$libVersion"
2312            name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2313        };
2314
2315        assert!(!dependency_version_range_is_literal(
2316            &dep,
2317            content,
2318            dep.version_range,
2319        ));
2320    }
2321
2322    /// C1 (critic finding): a partial interpolation embedded mid-string (`1.0-${suffix}`)
2323    /// does not start with `$`, but must still be rejected — the `contains("${")` arm of the
2324    /// reference-shape check, not just the `starts_with` arms.
2325    #[test]
2326    fn test_dependency_version_range_is_literal_rejects_embedded_interpolation() {
2327        let content = r#"x = "1.0-${suffix}""#;
2328        let dep = MockDep {
2329            name: PackageName::new("x"),
2330            version_req: VersionReq::new("1.0-${suffix}"),
2331            version_range: Range::new(Position::new(0, 5), Position::new(0, 18)), // "1.0-${suffix}"
2332            name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
2333        };
2334
2335        assert!(!dependency_version_range_is_literal(
2336            &dep,
2337            content,
2338            dep.version_range,
2339        ));
2340    }
2341
2342    /// M2 (critic follow-up): `"*"` — the project-wide existence-wildcard spelling
2343    /// (`deps_core::registry::is_existence_wildcard`, synthesized by `deps-npm`,
2344    /// `deps-composer`, ...) — must be **admitted**, not caught by the `*`-prefix arm meant
2345    /// for a `*anchor` YAML alias. Rejecting it would silently disable version completion at
2346    /// the exact moment a user wants to replace `"lodash": "*"` with a pinned version.
2347    #[test]
2348    fn test_dependency_version_range_is_literal_admits_existence_wildcard() {
2349        let content = r#"x = "*""#;
2350        let dep = MockDep {
2351            name: PackageName::new("x"),
2352            version_req: VersionReq::new("*"),
2353            version_range: Range::new(Position::new(0, 5), Position::new(0, 6)), // "*"
2354            name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
2355        };
2356
2357        assert!(dependency_version_range_is_literal(
2358            &dep,
2359            content,
2360            dep.version_range,
2361        ));
2362    }
2363
2364    /// M3 (critic follow-up): a `*anchor`-shaped slice that is *not exactly* `"*"` — e.g. a
2365    /// multi-char alias name — must still be rejected. Distinguishes the wildcard exemption
2366    /// above from a genuine leading-`*` reference.
2367    #[test]
2368    fn test_dependency_version_range_is_literal_rejects_multi_char_alias_despite_wildcard_exemption()
2369     {
2370        let content = "ref: *pinned-anchor";
2371        let dep = MockDep {
2372            name: PackageName::new("job"),
2373            version_req: VersionReq::new("1.0.0"),
2374            version_range: Range::new(Position::new(0, 5), Position::new(0, 19)), // "*pinned-anchor"
2375            name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2376        };
2377
2378        assert!(!dependency_version_range_is_literal(
2379            &dep,
2380            content,
2381            dep.version_range,
2382        ));
2383    }
2384
2385    /// M3 (critic follow-up): a Gradle GString with a `$var` reference embedded mid-string
2386    /// (`1.0.$patch`, no braces, not at the start) is not resolved by
2387    /// `resolve_variable_ref` (whole-value only) — the slice stays byte-identical to the raw
2388    /// `version_requirement`, so only the shape check (not the text comparison) can reject
2389    /// it. This is #919 C1 in a narrower spelling than the whole-value `$var`/`${var}` case.
2390    #[test]
2391    fn test_dependency_version_range_is_literal_rejects_gradle_mid_string_variable() {
2392        let content = r#"implementation "com.example:lib:1.0.$patch""#;
2393        let dep = MockDep {
2394            name: PackageName::new("com.example:lib"),
2395            version_req: VersionReq::new("1.0.$patch"), // left unresolved by the parser
2396            version_range: Range::new(Position::new(0, 32), Position::new(0, 42)), // "1.0.$patch"
2397            name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2398        };
2399
2400        assert!(!dependency_version_range_is_literal(
2401            &dep,
2402            content,
2403            dep.version_range,
2404        ));
2405    }
2406
2407    /// #1161: a dependency with no `version_requirement()` at all — Maven's parser never
2408    /// sets one for a genuinely empty `<version></version>` tag, since there is no text to
2409    /// capture — must still be admitted when `version_range`'s slice is empty. There is no
2410    /// existing text at that position that could be misread as a reference/wildcard, so
2411    /// offering completion is always safe regardless of whether a requirement was parsed.
2412    #[test]
2413    fn test_dependency_version_range_is_literal_admits_empty_slice_with_no_requirement_at_all() {
2414        let content = "<version></version>";
2415        let dep = MockSyntheticRangeDep {
2416            name: PackageName::new("com.example:foo"),
2417        };
2418        let empty_range = Range::new(Position::new(0, 9), Position::new(0, 9));
2419
2420        assert!(dep.version_requirement().is_none());
2421        assert!(dependency_version_range_is_literal(
2422            &dep,
2423            content,
2424            empty_range
2425        ));
2426    }
2427
2428    /// Critic follow-up (C1, second round) to #1161: a DANGLING or still-being-typed
2429    /// `version.ref` alias (e.g. `version.ref = "gu"` while typing "guava") has
2430    /// `version_requirement() == None` — the exact same shape as Maven's genuinely empty
2431    /// `<version></version>` tag — but its range is `deps-gradle`'s `Range::default()`
2432    /// sentinel, not a real in-document position. This is the *normal* interactive state on
2433    /// every keystroke while typing an alias, not an edge case: admitting it here would offer
2434    /// the full unfiltered version list and splice a version literal into the alias name.
2435    /// Must be rejected regardless of `version_requirement()` being absent.
2436    #[test]
2437    fn test_dependency_version_range_is_literal_rejects_default_range_with_no_requirement_at_all() {
2438        let content = r#"version.ref = "gu""#;
2439        let dep = MockSyntheticRangeDep {
2440            name: PackageName::new("com.example:guava"),
2441        };
2442
2443        assert!(dep.version_requirement().is_none());
2444        assert!(!dependency_version_range_is_literal(
2445            &dep,
2446            content,
2447            Range::default(),
2448        ));
2449    }
2450
2451    /// Critic follow-up (C1) to #1161: an empty slice must NOT be admitted merely because it
2452    /// is empty — only when `version_requirement()` is also absent. `deps-gradle`'s catalog
2453    /// context deliberately returns `Range::default()` (which slices to `""`) for a
2454    /// `version.ref = "alias"` reference while `version_requirement()` still holds the
2455    /// alias's real, non-empty resolved value (#931) — the "always reject" guarantee that
2456    /// sentinel relies on comes from comparing that non-empty value against the empty slice,
2457    /// not from any special-casing of the empty slice itself. A version that checks
2458    /// `trimmed.is_empty()` before/independent of `version_requirement()` would invert this
2459    /// into an always-accept and let a full completion list splice a version literal into
2460    /// the alias name.
2461    #[test]
2462    fn test_dependency_version_range_is_literal_rejects_default_range_with_nonempty_requirement() {
2463        let content = r#"version.ref = "guavaVersion""#;
2464        let dep = MockDep {
2465            name: PackageName::new("com.example:guava"),
2466            version_req: VersionReq::new("32.0.1"), // the alias's resolved value
2467            version_range: Range::default(), // deps-gradle's deliberate always-reject sentinel
2468            name_range: Range::default(),
2469        };
2470
2471        assert!(!dependency_version_range_is_literal(
2472            &dep,
2473            content,
2474            dep.version_range,
2475        ));
2476    }
2477
2478    /// The empty-entry pruning invariant: an entry is removed once its last set channel
2479    /// is cleared, one channel at a time, in every order — never left behind as a
2480    /// vacuous `Some(DependencyOutcome::default())` that would inflate `len()`.
2481    #[test]
2482    fn test_dependency_outcomes_prunes_entry_once_all_channels_cleared() {
2483        let mut outcomes = DependencyOutcomes::new()
2484            .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
2485            .with_deprecation(
2486                "pkg",
2487                Deprecation {
2488                    reason: None,
2489                    replacement: None,
2490                },
2491            )
2492            .with_fetch_failure("pkg", FetchFailure::Transient);
2493        assert_eq!(outcomes.len(), 1);
2494
2495        outcomes.clear_yanked("pkg");
2496        assert!(
2497            outcomes.get("pkg").is_some(),
2498            "entry must survive while other channels are still set"
2499        );
2500
2501        outcomes.clear_deprecation("pkg");
2502        assert!(
2503            outcomes.get("pkg").is_some(),
2504            "entry must survive while the fetch-failure channel is still set"
2505        );
2506
2507        outcomes.clear_fetch_failure("pkg");
2508        assert!(
2509            outcomes.is_empty(),
2510            "entry must be pruned once its last channel is cleared, not left as a vacuous Some"
2511        );
2512        assert_eq!(outcomes.len(), 0);
2513    }
2514
2515    /// Clearing a channel that was never set on an existing entry must not spuriously
2516    /// prune channels that ARE still set (each `clear_*` only nulls its own field).
2517    #[test]
2518    fn test_dependency_outcomes_clear_on_unset_channel_is_a_no_op_for_others() {
2519        let mut outcomes = DependencyOutcomes::new()
2520            .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::AdvisoryDeprecated));
2521
2522        outcomes.clear_deprecation("pkg");
2523        outcomes.clear_fetch_failure("pkg");
2524
2525        assert!(
2526            outcomes.yanked("pkg").is_some(),
2527            "clearing unset channels must not touch the still-set yanked channel"
2528        );
2529        assert_eq!(outcomes.len(), 1);
2530    }
2531
2532    /// `remove` drops the whole entry regardless of which channels are set, unlike the
2533    /// per-channel `clear_*` methods.
2534    #[test]
2535    fn test_dependency_outcomes_remove_drops_entry_with_multiple_channels_set() {
2536        let mut outcomes = DependencyOutcomes::new()
2537            .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
2538            .with_fetch_failure("pkg", FetchFailure::Transient);
2539
2540        outcomes.remove("pkg");
2541
2542        assert!(outcomes.get("pkg").is_none());
2543        assert!(outcomes.is_empty());
2544    }
2545
2546    /// `clear_all_fetch_failures` (deps-lsp issue #592) drops the fetch-failure channel for
2547    /// every entry, pruning an entry that becomes empty, while leaving other channels
2548    /// (yanked/deprecation/no-comparable-versions) on a still-mixed entry untouched.
2549    #[test]
2550    fn test_dependency_outcomes_clear_all_fetch_failures() {
2551        let mut outcomes = DependencyOutcomes::new()
2552            .with_fetch_failure("only-failure", FetchFailure::Transient)
2553            .with_yanked("mixed", ("1.0.0".into(), RemovalStatus::Yanked))
2554            .with_fetch_failure("mixed", FetchFailure::Transient);
2555
2556        outcomes.clear_all_fetch_failures();
2557
2558        assert!(
2559            outcomes.get("only-failure").is_none(),
2560            "an entry whose only channel was fetch-failure must be pruned"
2561        );
2562        assert!(outcomes.fetch_failure("mixed").is_none());
2563        assert!(
2564            outcomes.yanked("mixed").is_some(),
2565            "clearing fetch-failure must not touch a mixed entry's other channels"
2566        );
2567    }
2568
2569    /// `clear_all_fetch_failures` on an empty map is a no-op, not a panic.
2570    #[test]
2571    fn test_dependency_outcomes_clear_all_fetch_failures_on_empty_map() {
2572        let mut outcomes = DependencyOutcomes::new();
2573        outcomes.clear_all_fetch_failures();
2574        assert!(outcomes.is_empty());
2575    }
2576
2577    /// `set_fetch_failure_if_absent` (impl-critic M2) must never clobber a genuine
2578    /// `Actionable`/`Transient` failure already recorded for the name — only `set_fetch_failure`
2579    /// unconditionally overwrites.
2580    #[test]
2581    fn test_dependency_outcomes_set_fetch_failure_if_absent_does_not_clobber_existing() {
2582        let mut outcomes = DependencyOutcomes::new().with_fetch_failure(
2583            "pkg",
2584            FetchFailure::Actionable("set GITHUB_TOKEN".to_string()),
2585        );
2586
2587        outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
2588
2589        assert_eq!(
2590            outcomes.fetch_failure("pkg"),
2591            Some(&FetchFailure::Actionable("set GITHUB_TOKEN".to_string())),
2592            "an existing genuine failure must survive a collided-name NotAttempted marker"
2593        );
2594    }
2595
2596    /// The mirror case: when no failure is recorded yet, `set_fetch_failure_if_absent` does
2597    /// populate the entry.
2598    #[test]
2599    fn test_dependency_outcomes_set_fetch_failure_if_absent_populates_when_unset() {
2600        let mut outcomes = DependencyOutcomes::new();
2601
2602        outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
2603
2604        assert_eq!(
2605            outcomes.fetch_failure("pkg"),
2606            Some(&FetchFailure::NotAttempted)
2607        );
2608    }
2609
2610    #[test]
2611    fn test_line_offset_table_line_start_crlf() {
2612        let table = LineOffsetTable::new("a\r\nbb\r\nc");
2613        assert_eq!(table.line_start(0), Some(0));
2614        assert_eq!(table.line_start(1), Some(3));
2615        assert_eq!(table.line_start(2), Some(7));
2616        assert_eq!(table.line_start(3), None);
2617    }
2618
2619    #[test]
2620    fn test_byte_offset_to_position_clamps_to_char_boundary_instead_of_panicking() {
2621        // "é" is a 2-byte UTF-8 sequence; offset 1 lands inside it.
2622        let content = "é";
2623        let table = LineOffsetTable::new(content);
2624        // Must not panic; clamps down to the nearest boundary (offset 0).
2625        let pos = table.byte_offset_to_position(content, 1);
2626        assert_eq!(pos, Position::new(0, 0));
2627    }
2628
2629    #[test]
2630    fn test_byte_offset_to_position_multi_byte_boundary_in_longer_line() {
2631        let content = "ab é cd";
2632        let table = LineOffsetTable::new(content);
2633        // Byte 3 is 'é's leading byte (boundary); byte 4 is its continuation
2634        // byte (not a boundary) and must clamp back to 3 rather than panic.
2635        assert!(content.is_char_boundary(3));
2636        assert!(!content.is_char_boundary(4));
2637        let pos = table.byte_offset_to_position(content, 4);
2638        assert_eq!(pos, table.byte_offset_to_position(content, 3));
2639    }
2640
2641    /// Covers the ASCII/non-ASCII fast-path split introduced for #742: an ASCII-only line
2642    /// (line 0), a non-ASCII line mixing a BMP accented character with a surrogate-pair
2643    /// emoji (line 1), and a mixed line (line 2) must all still resolve to the same
2644    /// `Position`s as the original `chars().map(char::len_utf16).sum()` scan would produce.
2645    #[test]
2646    fn test_byte_offset_to_position_ascii_and_non_ascii_lines_agree() {
2647        let content = "abcde\nhéllo 😀\nab café end";
2648        let table = LineOffsetTable::new(content);
2649
2650        // Line 0 ("abcde"): ASCII fast path, byte offset == UTF-16 character offset.
2651        assert_eq!(
2652            table.byte_offset_to_position(content, 3),
2653            Position::new(0, 3)
2654        );
2655        assert_eq!(
2656            table.byte_offset_to_position(content, 5),
2657            Position::new(0, 5)
2658        );
2659
2660        // Line 1 ("héllo 😀"): non-ASCII scan path.
2661        // Offset after "h\u{e9}" (1 + 2 bytes into the line): 'h' + 'é' = 2 UTF-16 units.
2662        assert_eq!(
2663            table.byte_offset_to_position(content, 6 + 3),
2664            Position::new(1, 2)
2665        );
2666        // Offset at the end of the line: "héllo 😀" = 8 UTF-16 units (emoji is a surrogate pair).
2667        assert_eq!(
2668            table.byte_offset_to_position(content, 6 + 11),
2669            Position::new(1, 8)
2670        );
2671
2672        // Line 2 ("ab café end"): mixed line, still takes the non-ASCII scan path.
2673        // Offset after "ab café" (8 bytes into the line): 7 UTF-16 units ('é' is 1 BMP unit).
2674        assert_eq!(
2675            table.byte_offset_to_position(content, 18 + 8),
2676            Position::new(2, 7)
2677        );
2678    }
2679
2680    /// Regression guard for #742: `byte_offset_to_position` on a large single-line
2681    /// (minified) manifest must stay near-instant. The pre-fix implementation rescanned
2682    /// the line from its start on every call, making repeated lookups over such a line
2683    /// O(n^2) in the line's length (~350ms for 8000 calls on an ~180KB line on the
2684    /// reporter's machine); the ASCII fast path makes each call O(1), so this generous
2685    /// wall-clock bound leaves wide margin without being flaky.
2686    #[test]
2687    fn test_byte_offset_to_position_minified_line_stays_fast() {
2688        let mut content = String::from("{\"dependencies\":{");
2689        for i in 0..8000 {
2690            content.push_str(&format!("\"dep{i}\":\"1.0.{i}\","));
2691        }
2692        content.push_str("}}");
2693
2694        let table = LineOffsetTable::new(&content);
2695        let step = (content.len() / 8000).max(1);
2696        let start = std::time::Instant::now();
2697        for offset in (0..content.len()).step_by(step) {
2698            std::hint::black_box(table.byte_offset_to_position(&content, offset));
2699        }
2700        let elapsed = start.elapsed();
2701
2702        assert!(
2703            elapsed < std::time::Duration::from_millis(100),
2704            "8000 byte_offset_to_position calls on a minified line took {elapsed:?}, expected < 100ms"
2705        );
2706    }
2707
2708    /// Regression guard for #882 (widened scope): `byte_offset_to_position`'s non-ASCII branch
2709    /// used to walk `chars().map(len_utf16).sum()` from the line's start on every call, so N
2710    /// lookups on the same wide non-ASCII line cost O(N x line length) — the same shape #882
2711    /// reported for `marker_byte_offset`, and, per the #882 review, ~300x its cost since
2712    /// `make_range` calls this twice per dependency. The shared per-line index makes every
2713    /// lookup after the first on a given line O(1).
2714    #[test]
2715    fn test_byte_offset_to_position_non_ascii_line_cache_stays_fast_on_a_huge_single_line() {
2716        let filler = "x".repeat(200 * 1024);
2717        let content = format!("{filler}\u{2014}{filler}");
2718        let table = LineOffsetTable::new(&content);
2719        let step = (content.len() / 5000).max(1);
2720        let start = std::time::Instant::now();
2721        for offset in (0..content.len()).step_by(step) {
2722            std::hint::black_box(table.byte_offset_to_position(&content, offset));
2723        }
2724        assert!(
2725            start.elapsed() < std::time::Duration::from_secs(1),
2726            "byte_offset_to_position took {:?} for ~5000 lookups on a huge non-ASCII line, \
2727             expected well under 1s",
2728            start.elapsed()
2729        );
2730    }
2731
2732    /// Differential test: the cached non-ASCII path must agree with a plain reimplemented
2733    /// `chars().map(len_utf16).sum()` walk for every char-boundary offset on a non-ASCII line,
2734    /// including offsets on more than one non-ASCII line in the same document (cache isolation
2735    /// across lines, sharing the same underlying index `marker_byte_offset` populates).
2736    #[test]
2737    fn test_byte_offset_to_position_non_ascii_cache_matches_char_sum_result_across_lines() {
2738        fn slow_path(content: &str, line_start: usize, offset: usize) -> u32 {
2739            u32::try_from(
2740                content[line_start..offset]
2741                    .chars()
2742                    .map(char::len_utf16)
2743                    .sum::<usize>(),
2744            )
2745            .unwrap_or(u32::MAX)
2746        }
2747
2748        let content = "\u{1f680}rocket \u{2014} dash\nascii line\n\u{3000}ideographic \u{e9}nd";
2749        let table = LineOffsetTable::new(content);
2750        let line_starts = [
2751            table.line_start(0).unwrap(),
2752            table.line_start(1).unwrap(),
2753            table.line_start(2).unwrap(),
2754        ];
2755        let line_ends = [line_starts[1] - 1, line_starts[2] - 1, content.len()];
2756
2757        for (line, (&line_start, &line_end)) in line_starts.iter().zip(&line_ends).enumerate() {
2758            let line_text = &content[line_start..line_end];
2759            for (byte_offset, _) in line_text
2760                .char_indices()
2761                .chain(std::iter::once((line_text.len(), '\0')))
2762            {
2763                let offset = line_start + byte_offset;
2764                assert_eq!(
2765                    table.byte_offset_to_position(content, offset).character,
2766                    slow_path(content, line_start, offset),
2767                    "line {line} byte offset {byte_offset} desynced from the char-sum walk"
2768                );
2769            }
2770        }
2771    }
2772
2773    #[test]
2774    fn test_position_in_range_inside() {
2775        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2776        let position = Position::new(5, 15);
2777        assert!(position_in_range(position, range));
2778    }
2779
2780    #[test]
2781    fn test_position_in_range_at_start() {
2782        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2783        let position = Position::new(5, 10);
2784        assert!(position_in_range(position, range));
2785    }
2786
2787    #[test]
2788    fn test_position_in_range_at_end() {
2789        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2790        let position = Position::new(5, 20);
2791        assert!(position_in_range(position, range));
2792    }
2793
2794    #[test]
2795    fn test_position_in_range_before() {
2796        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2797        let position = Position::new(5, 5);
2798        assert!(!position_in_range(position, range));
2799    }
2800
2801    #[test]
2802    fn test_position_in_range_after() {
2803        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2804        let position = Position::new(5, 25);
2805        assert!(!position_in_range(position, range));
2806    }
2807
2808    #[test]
2809    fn test_position_in_range_different_line_before() {
2810        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2811        let position = Position::new(4, 15);
2812        assert!(!position_in_range(position, range));
2813    }
2814
2815    #[test]
2816    fn test_position_in_range_different_line_after() {
2817        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2818        let position = Position::new(6, 15);
2819        assert!(!position_in_range(position, range));
2820    }
2821
2822    #[test]
2823    fn test_position_in_range_multiline() {
2824        let range = Range::new(Position::new(5, 10), Position::new(7, 5));
2825        let position = Position::new(6, 0);
2826        assert!(position_in_range(position, range));
2827    }
2828
2829    #[test]
2830    fn test_escape_markdown_link_breakout_payload() {
2831        let payload = "real-pkg](https://legit-looking-typosquat.example/download)[real-pkg";
2832        let escaped = escape_markdown(payload);
2833        assert_eq!(
2834            escaped,
2835            r"real\-pkg\]\(https\:\/\/legit\-looking\-typosquat\.example\/download\)\[real\-pkg"
2836        );
2837        assert!(!escaped.contains("]("));
2838    }
2839
2840    #[test]
2841    fn test_escape_markdown_backslash_and_backtick() {
2842        assert_eq!(escape_markdown(r"a\b`c"), r"a\\b\`c");
2843    }
2844
2845    #[test]
2846    fn test_escape_markdown_autolink_angle_brackets() {
2847        // `<...>` around a bare URL is a CommonMark autolink; `<`/`>` must be escaped
2848        // so it cannot render as a live link independent of the `[]`/`()` escaping.
2849        let escaped = escape_markdown("pkg <https://evil.example>");
2850        assert_eq!(escaped, r"pkg \<https\:\/\/evil\.example\>");
2851        assert!(!escaped.contains('<') || escaped.contains(r"\<"));
2852    }
2853
2854    #[test]
2855    fn test_escape_markdown_control_chars_become_spaces() {
2856        assert_eq!(escape_markdown("a\nb"), "a b");
2857        assert_eq!(escape_markdown("a\r\nb"), "a  b");
2858        assert_eq!(escape_markdown("a\tb"), "a b");
2859        assert_eq!(escape_markdown("a\0b"), "a b");
2860    }
2861
2862    #[test]
2863    fn test_escape_markdown_newline_cannot_break_out_of_heading() {
2864        // A raw newline used to terminate the ATX heading line early, letting the
2865        // rest of the name (potentially another "# [...](...)" sequence) render as
2866        // separate, unescaped Markdown blocks.
2867        let escaped = escape_markdown("react\n# [fake](https://evil.example)");
2868        assert!(!escaped.contains('\n'));
2869    }
2870
2871    #[test]
2872    fn test_escape_markdown_hyphenated_name_round_trips_visually() {
2873        // Escaping a hyphen (ASCII punctuation) is visually inert on render — CommonMark
2874        // renders `\-` as a literal `-` — so common package names are unaffected in
2875        // practice even though the raw Markdown source now escapes them.
2876        assert_eq!(escape_markdown("tokio-util"), r"tokio\-util");
2877    }
2878
2879    #[test]
2880    fn test_markdown_code_span_plain_content() {
2881        assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
2882    }
2883
2884    #[test]
2885    fn test_markdown_code_span_widens_fence_for_embedded_backticks() {
2886        assert_eq!(markdown_code_span("a`b"), "``a`b``");
2887        assert_eq!(markdown_code_span("``double``"), "``` ``double`` ```");
2888    }
2889
2890    #[test]
2891    fn test_markdown_code_span_pads_when_content_starts_or_ends_with_backtick() {
2892        let span = markdown_code_span("`leading");
2893        assert!(span.starts_with("`` `"));
2894    }
2895
2896    #[test]
2897    fn test_markdown_code_span_replaces_control_chars() {
2898        let span = markdown_code_span("1.0\n[evil](https://evil.example)");
2899        assert!(!span.contains('\n'));
2900    }
2901
2902    #[test]
2903    fn test_markdown_code_span_empty_content() {
2904        assert_eq!(markdown_code_span(""), "` `");
2905    }
2906
2907    #[test]
2908    fn test_markdown_code_span_backtick_payload_cannot_break_span() {
2909        // A payload closing the code span early to splice in a live link must not succeed.
2910        let payload = "1.0` <https://evil.example>` more";
2911        let span = markdown_code_span(payload);
2912        // The fence must be strictly longer than any backtick run in the content, so no
2913        // substring after the opening fence can act as a closing fence before the real one.
2914        let opening_fence_len = span.chars().take_while(|&c| c == '`').count();
2915        let inner = &span[opening_fence_len..span.len() - opening_fence_len];
2916        assert!(
2917            !inner.contains(&"`".repeat(opening_fence_len)),
2918            "content contains a run of backticks as long as the fence: {span}"
2919        );
2920    }
2921
2922    #[test]
2923    fn test_escape_markdown_replaces_bidi_and_invisible_characters() {
2924        for c in [
2925            '\u{202E}',  // RLO — the Trojan Source vector
2926            '\u{2066}',  // LRI
2927            '\u{200B}',  // ZWSP
2928            '\u{2060}',  // WORD JOINER
2929            '\u{2028}',  // LS
2930            '\u{FEFF}',  // BOM
2931            '\u{FFFA}',  // interlinear annotation separator
2932            '\u{E0041}', // Unicode tag character ("A")
2933        ] {
2934            let escaped = escape_markdown(&format!("a{c}b"));
2935            assert_eq!(escaped, "a b", "{c:?} must be replaced with a space");
2936        }
2937    }
2938
2939    /// #1276: `Diagnostic::new`/`RelatedInformation::new` apply `replace_markdown_unsafe_chars`
2940    /// on top of producer-side sanitization that may have already called it —
2941    /// double-application must be a no-op, since `' '` (the substitution) is not itself
2942    /// `is_markdown_unsafe`.
2943    #[test]
2944    fn test_replace_markdown_unsafe_chars_is_idempotent() {
2945        for c in [
2946            '\u{202E}',
2947            '\u{2066}',
2948            '\u{200B}',
2949            '\u{2060}',
2950            '\u{2028}',
2951            '\u{2029}',
2952            '\u{FEFF}',
2953            '\u{FFFA}',
2954            '\u{E0041}',
2955            '\n',
2956            '\t',
2957        ] {
2958            let input = format!("a{c}b");
2959            let once = replace_markdown_unsafe_chars(&input);
2960            assert_eq!(once, "a b", "{c:?} must be replaced with a single space");
2961            let twice = replace_markdown_unsafe_chars(&once);
2962            assert_eq!(twice, once, "{c:?} must not change on re-application");
2963        }
2964        assert!(
2965            !is_markdown_unsafe(' '),
2966            "substituted space must be stable under re-application"
2967        );
2968    }
2969
2970    #[test]
2971    fn test_escape_markdown_preserves_legitimate_bidi_marks() {
2972        for c in [
2973            '\u{200F}', // RLM
2974            '\u{061C}', // ALM
2975            '\u{200E}', // LRM
2976            '\u{200D}', // ZWJ
2977        ] {
2978            let escaped = escape_markdown(&format!("a{c}b"));
2979            assert_eq!(
2980                escaped,
2981                format!("a{c}b"),
2982                "{c:?} must survive verbatim (legitimate RTL/emoji use)"
2983            );
2984        }
2985    }
2986
2987    #[test]
2988    fn test_markdown_code_span_replaces_bidi_and_invisible_characters() {
2989        for c in [
2990            '\u{202E}',
2991            '\u{2066}',
2992            '\u{200B}',
2993            '\u{2060}',
2994            '\u{2029}',
2995            '\u{FEFF}',
2996            '\u{FFFA}',
2997            '\u{E0041}',
2998        ] {
2999            let span = markdown_code_span(&format!("a{c}b"));
3000            assert_eq!(span, "`a b`", "{c:?} must be replaced with a space");
3001        }
3002    }
3003
3004    #[test]
3005    fn test_markdown_code_span_preserves_legitimate_bidi_marks() {
3006        for c in ['\u{200F}', '\u{061C}', '\u{200E}', '\u{200D}'] {
3007            let span = markdown_code_span(&format!("a{c}b"));
3008            assert_eq!(
3009                span,
3010                format!("`a{c}b`"),
3011                "{c:?} must survive verbatim (legitimate RTL/emoji use)"
3012            );
3013        }
3014    }
3015
3016    #[test]
3017    fn test_is_same_major_minor_full_match() {
3018        assert!(is_same_major_minor("1.2.3", "1.2.9"));
3019    }
3020
3021    #[test]
3022    fn test_is_same_major_minor_exact_match() {
3023        assert!(is_same_major_minor("1.2.3", "1.2.3"));
3024    }
3025
3026    #[test]
3027    fn test_is_same_major_minor_major_only_match() {
3028        assert!(is_same_major_minor("1", "1.2.3"));
3029        assert!(is_same_major_minor("1.2.3", "1"));
3030    }
3031
3032    #[test]
3033    fn test_is_same_major_minor_no_match_different_minor() {
3034        assert!(!is_same_major_minor("1.2.3", "1.3.0"));
3035    }
3036
3037    #[test]
3038    fn test_is_same_major_minor_no_match_different_major() {
3039        assert!(!is_same_major_minor("1.2.3", "2.2.3"));
3040    }
3041
3042    #[test]
3043    fn test_is_same_major_minor_empty_strings() {
3044        assert!(!is_same_major_minor("", ""));
3045        assert!(!is_same_major_minor("1.2.3", ""));
3046        assert!(!is_same_major_minor("", "1.2.3"));
3047    }
3048
3049    #[test]
3050    fn test_is_safe_version_string_accepts_ordinary_versions() {
3051        assert!(is_safe_version_string("1.2.3"));
3052        assert!(is_safe_version_string("1.2.3-beta.1+build"));
3053        assert!(is_safe_version_string("v1.2.3"));
3054    }
3055
3056    #[test]
3057    fn test_is_safe_version_string_rejects_empty_or_whitespace() {
3058        assert!(!is_safe_version_string(""));
3059        assert!(!is_safe_version_string("   "));
3060        assert!(!is_safe_version_string("\t\n"));
3061    }
3062
3063    #[test]
3064    fn test_is_safe_version_string_rejects_control_and_structural_characters() {
3065        for bad in [
3066            "1.2.3\n",
3067            "1.2.3\t",
3068            "1.2.3\"",
3069            "1.2.3'",
3070            "1.2.3<",
3071            "1.2.3>",
3072            "1.2.3&",
3073            "1.2.3\\",
3074            "1.0.0\", \"malicious\": \"true",
3075        ] {
3076            assert!(
3077                !is_safe_version_string(bad),
3078                "expected {bad:?} to be rejected"
3079            );
3080        }
3081    }
3082
3083    #[test]
3084    fn test_is_safe_version_string_rejects_gradle_interpolation_payload() {
3085        // Regression (critic S2): `$`/`{`/`}` are outside the allowlist, so a Gradle
3086        // `${...}` interpolation payload can never reach a version literal via this gate.
3087        for bad in ["1.0${System.getenv(\"X\")}", "1.0$var", "${evil}"] {
3088            assert!(
3089                !is_safe_version_string(bad),
3090                "expected {bad:?} to be rejected"
3091            );
3092        }
3093    }
3094
3095    #[test]
3096    fn test_is_safe_version_string_rejects_invisible_unicode() {
3097        // Regression (critic M1): `char::is_control()` alone only covers category Cc — the
3098        // bidi override U+202E, zero-width space U+200B, and JS/JSON5 line terminators
3099        // U+2028/U+2029 must also be rejected.
3100        for bad in ["1.2.3\u{202E}", "1.2.3\u{200B}", "1.2.3\u{2028}"] {
3101            assert!(
3102                !is_safe_version_string(bad),
3103                "expected {bad:?} to be rejected"
3104            );
3105        }
3106    }
3107
3108    #[test]
3109    fn test_is_safe_version_string_accepts_pep440_epoch() {
3110        // PEP 440 epochs (`1!2.0`) are legitimate PyPI versions.
3111        assert!(is_safe_version_string("1!2.0"));
3112    }
3113
3114    #[test]
3115    fn test_is_safe_version_string_length_cap() {
3116        assert!(is_safe_version_string(&"1".repeat(64)));
3117        assert!(!is_safe_version_string(&"1".repeat(65)));
3118    }
3119
3120    #[test]
3121    fn test_is_safe_maven_coordinate_segment_accepts_real_ids() {
3122        assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
3123        assert!(is_safe_maven_coordinate_segment("commons-lang3"));
3124        assert!(is_safe_maven_coordinate_segment("jackson-core_2.13"));
3125    }
3126
3127    #[test]
3128    fn test_is_safe_maven_coordinate_segment_rejects_empty() {
3129        assert!(!is_safe_maven_coordinate_segment(""));
3130    }
3131
3132    #[test]
3133    fn test_is_safe_maven_coordinate_segment_rejects_xml_structural_characters() {
3134        for bad in [
3135            "commons</artifactId><parent>",
3136            "commons\"",
3137            "commons'",
3138            "commons&amp;",
3139            "commons\nlang3",
3140            "commons\tlang3",
3141        ] {
3142            assert!(
3143                !is_safe_maven_coordinate_segment(bad),
3144                "expected {bad:?} to be rejected"
3145            );
3146        }
3147    }
3148
3149    #[test]
3150    fn test_is_safe_maven_coordinate_segment_rejects_group_artifact_colon() {
3151        assert!(!is_safe_maven_coordinate_segment(
3152            "org.apache.commons:commons-lang3"
3153        ));
3154    }
3155
3156    #[test]
3157    fn test_is_safe_maven_coordinate_segment_length_cap() {
3158        assert!(is_safe_maven_coordinate_segment(&"a".repeat(128)));
3159        assert!(!is_safe_maven_coordinate_segment(&"a".repeat(129)));
3160    }
3161
3162    #[test]
3163    fn test_is_safe_registry_url_accepts_real_urls() {
3164        assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
3165        assert!(is_safe_registry_url(
3166            "https://github.com/apple/swift-nio.git"
3167        ));
3168        assert!(is_safe_registry_url("https://github.com/apple/swift%2Dnio"));
3169    }
3170
3171    #[test]
3172    fn test_is_safe_registry_url_rejects_non_https_scheme() {
3173        // Every real Swift package registry response is HTTPS; accepting `http://` would
3174        // only hand a compromised registry a transport-downgrade lever.
3175        assert!(!is_safe_registry_url("http://example.com/repo"));
3176        assert!(!is_safe_registry_url("file:///etc/passwd"));
3177        assert!(!is_safe_registry_url("javascript:alert(1)"));
3178        assert!(!is_safe_registry_url("ftp://example.com/repo"));
3179        assert!(!is_safe_registry_url(""));
3180    }
3181
3182    #[test]
3183    fn test_is_safe_registry_url_rejects_swift_string_literal_breakout() {
3184        for bad in [
3185            "https://evil.example\", .exact(\"1.0.0\")), .package(url: \"https://real",
3186            "https://evil.example\\",
3187            "https://evil.example\nlet x = 1",
3188            "https://evil.example`echo`",
3189            "https://evil.example<script>",
3190        ] {
3191            assert!(
3192                !is_safe_registry_url(bad),
3193                "expected {bad:?} to be rejected"
3194            );
3195        }
3196    }
3197
3198    #[test]
3199    fn test_is_safe_registry_url_length_cap() {
3200        let prefix = "https://example.com/";
3201        let at_cap = format!("{prefix}{}", "a".repeat(2048 - prefix.len()));
3202        assert_eq!(at_cap.len(), 2048);
3203        assert!(is_safe_registry_url(&at_cap));
3204
3205        let over_cap = format!("{at_cap}a");
3206        assert_eq!(over_cap.len(), 2049);
3207        assert!(!is_safe_registry_url(&over_cap));
3208    }
3209
3210    #[test]
3211    fn test_is_safe_package_name_accepts_real_names_across_ecosystems() {
3212        for good in [
3213            "serde",                            // Cargo
3214            "requests",                         // PyPI
3215            "@scope/name",                      // npm/Deno scoped
3216            "monolog/monolog",                  // Composer vendor/package
3217            "github.com/org/repo",              // Go module path
3218            "path",                             // Dart
3219            "org.apache.commons:commons-lang3", // Gradle group:artifact
3220            "Newtonsoft.Json",                  // NuGet
3221            "rails",                            // Bundler
3222            "npm:react",                        // Deno npm-scheme specifier
3223            "jsr:@std/fs",                      // Deno jsr-scheme specifier
3224            "github.com/foo/bar~compat",        // Go path element with `~`
3225        ] {
3226            assert!(
3227                is_safe_package_name(good),
3228                "expected {good:?} to be accepted"
3229            );
3230        }
3231    }
3232
3233    #[test]
3234    fn test_is_safe_package_name_rejects_empty() {
3235        assert!(!is_safe_package_name(""));
3236    }
3237
3238    #[test]
3239    fn test_is_safe_package_name_rejects_non_ascii() {
3240        // Deliberately excluded: the allowlist is ASCII-only, so a legacy non-ASCII
3241        // npm package name (a handful exist, e.g. Unicode-normalized scopes) is
3242        // rejected rather than risking homograph/normalization tricks in a manifest.
3243        assert!(!is_safe_package_name("café"));
3244        assert!(!is_safe_package_name("пакет"));
3245    }
3246
3247    #[test]
3248    fn test_is_safe_package_name_accepts_dot_dot_shapes() {
3249        // `.`/`/` are individually legal (PyPI dotted names, npm/Composer scopes), so
3250        // `..`/`../..` pass the charset too. This is not a path-traversal risk: every
3251        // sink treats `name` as manifest text (a TOML/JSON/YAML/XML value or a
3252        // string-literal argument), never as a filesystem path.
3253        assert!(is_safe_package_name(".."));
3254        assert!(is_safe_package_name("../.."));
3255    }
3256
3257    #[test]
3258    fn test_is_safe_package_name_rejects_structural_breakout_characters() {
3259        for bad in [
3260            "evil\"\nbackdoor = \"9.9.9",
3261            "evil\", git = \"https://evil",
3262            "evil\\",
3263            "evil'",
3264            "evil<script>",
3265            "evil`echo`",
3266            "evil\ninjected = true",
3267            "evil\tname",
3268        ] {
3269            assert!(
3270                !is_safe_package_name(bad),
3271                "expected {bad:?} to be rejected"
3272            );
3273        }
3274    }
3275
3276    #[test]
3277    fn test_is_safe_package_name_length_cap() {
3278        assert!(is_safe_package_name(&"a".repeat(256)));
3279        assert!(!is_safe_package_name(&"a".repeat(257)));
3280    }
3281
3282    #[test]
3283    fn test_is_same_major_minor_partial_versions() {
3284        assert!(is_same_major_minor("1.2", "1.2.3"));
3285        assert!(is_same_major_minor("1.2.3", "1.2"));
3286    }
3287
3288    #[test]
3289    fn test_ecosystem_formatter_defaults() {
3290        let formatter = MockFormatter;
3291        assert_eq!(
3292            formatter.normalize_package_name(&pkg("test-pkg")),
3293            "test-pkg"
3294        );
3295        assert_eq!(formatter.yanked_message(), "This version has been yanked");
3296        assert_eq!(formatter.yanked_label(), "*(yanked)*");
3297    }
3298
3299    #[test]
3300    fn test_format_version_replacing_for_default_delegates_to_format_version_replacing() {
3301        let formatter = MockFormatter;
3302        let dep = MockDep {
3303            name: pkg("test-pkg"),
3304            version_req: VersionReq::new("1.0.0"),
3305            version_range: Range::default(),
3306            name_range: Range::default(),
3307        };
3308        assert_eq!(
3309            formatter.format_version_replacing_for(&dep, &ConcreteVersion::new("1.2.3"), "1.0.0"),
3310            formatter.format_version_replacing(&ConcreteVersion::new("1.2.3"), "1.0.0")
3311        );
3312    }
3313
3314    #[test]
3315    fn test_ecosystem_formatter_version_satisfies() {
3316        let formatter = MockFormatter;
3317
3318        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2.3"));
3319
3320        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "^1.2"));
3321        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "~1.2"));
3322
3323        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1"));
3324        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2"));
3325
3326        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "2.0.0"));
3327        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.3"));
3328    }
3329
3330    #[test]
3331    fn test_ecosystem_formatter_custom_normalize() {
3332        struct PyPIFormatter;
3333
3334        impl PackageNaming for PyPIFormatter {
3335            fn normalize_package_name(&self, name: &PackageName) -> String {
3336                name.as_str().to_lowercase().replace('-', "_")
3337            }
3338        }
3339
3340        impl PackageRendering for PyPIFormatter {
3341            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
3342                format!(
3343                    ">={},<{}",
3344                    version,
3345                    version.as_str().split('.').next().unwrap_or("0")
3346                )
3347            }
3348
3349            fn package_url(&self, name: &PackageName) -> String {
3350                format!("https://pypi.org/project/{}", name.as_str())
3351            }
3352        }
3353
3354        impl RequirementResolution for PyPIFormatter {}
3355
3356        impl DiagnosticMessages for PyPIFormatter {}
3357
3358        impl DiagnosticPolicy for PyPIFormatter {}
3359
3360        impl SourcePolicy for PyPIFormatter {}
3361
3362        impl OsvNaming for PyPIFormatter {}
3363
3364        let formatter = PyPIFormatter;
3365        assert_eq!(
3366            formatter.normalize_package_name(&pkg("Test-Package")),
3367            "test_package"
3368        );
3369        assert_eq!(
3370            formatter.format_version_for_text_edit(&ConcreteVersion::new("1.2.3")),
3371            ">=1.2.3,<1"
3372        );
3373        assert_eq!(
3374            formatter.package_url(&pkg("requests")),
3375            "https://pypi.org/project/requests"
3376        );
3377    }
3378}