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 tower_lsp_server::ls_types::{Position, Range, TextEdit, Uri};
6
7use crate::osv::VulnerabilityMap;
8use crate::{
9    ConcreteVersion, Deprecation, DepsDevClient, EcosystemId, FetchFailure, PackageName,
10    RemovalStatus,
11};
12
13mod code_actions;
14mod code_lenses;
15mod diagnostics;
16mod formatter;
17mod git_ref;
18mod hover;
19mod in_use_version;
20mod inlay_hints;
21#[cfg(test)]
22mod test_support;
23
24pub use code_actions::generate_code_actions;
25pub use code_lenses::{collect_update_all_edits, generate_code_lenses};
26pub use diagnostics::{
27    DEPRECATED_DIAGNOSTIC_CODE, DiagnosticSeverities, UNSATISFIABLE_DIAGNOSTIC_CODE,
28    compile_requirement_unless, generate_diagnostics_from_cache, requirement_is_unsatisfiable,
29    truncate_for_diagnostic,
30};
31pub use formatter::{
32    DiagnosticMessages, DiagnosticPolicy, EcosystemFormatter, OsvNaming, PackageNaming,
33    PackageRendering, RequirementResolution, SourcePolicy,
34};
35pub use git_ref::{
36    CharOffsets, MAX_FALLBACK_SCAN_BYTES, is_full_sha, is_tag_shaped, locate_value_span,
37    match_v_prefix_style,
38};
39pub use hover::{CMD_DOT_FOOTER, generate_hover};
40pub use in_use_version::{concrete_pin_version, in_use_version, is_full_semver_shape};
41pub use inlay_hints::generate_inlay_hints;
42
43/// Maximum number of recent versions hover's "Recent versions" section renders.
44///
45/// Also the walk target for registries (NuGet, npm) that must fetch publish times for
46/// only the versions actually rendered, rather than the entire version history.
47pub const HOVER_RECENT_VERSIONS: usize = 8;
48
49/// Registry version data for one package, fetched together in a single round trip.
50///
51/// `latest` and `available` are deliberately asymmetric — this is load-bearing, not an
52/// oversight:
53/// - `latest` comes from this ecosystem's own `Registry::select_latest_matching(.., "*")`
54///   pick, which excludes yanked (and, for semver/node-semver `*`, prerelease) versions —
55///   the same value `get_latest_matching` returned before this type existed.
56/// - `available` is the **unfiltered** `get_versions` output: every published version,
57///   newest-first, yanked and prerelease entries included.
58///
59/// The unsatisfiable-requirement check (see `crate::lsp_helpers::requirement_is_unsatisfiable`)
60/// scans `available` and deliberately does not filter it: a requirement that only matches a
61/// yanked or prerelease version is still satisfied, so filtering `available` the same way
62/// `latest` is filtered would produce false "no published version satisfies" warnings.
63///
64/// `yanked` is the subset of `available` (same version-string encoding) that the registry
65/// reported as yanked/deprecated, paired with each entry's [`RemovalStatus`]. It exists
66/// because `Registry::get_latest_matching` — the call that used to populate this cache —
67/// filters yanked entries out by contract on every current registry implementation, so a
68/// per-version yanked flag threaded through *that* call would always read `false` (see
69/// #233). `available` now comes from the unfiltered `get_versions` instead, which does
70/// observe yanked entries, so `yanked` is derived from that same fetch rather than
71/// discarded.
72///
73/// The status rides alongside each version (rather than a bare membership list) so
74/// [`crate::lsp_helpers::generate_diagnostics_from_cache`]'s #247 "requirement satisfiable
75/// only by a yanked version" check can gate its own package-level-deprecation suppression
76/// on `AdvisoryDeprecated` specifically, never on a genuine `Yanked` finding — mirroring
77/// [`VersionData::outcomes`]'s D5 gate for the #263 in-use-version check (see #437).
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct PackageVersions {
80    /// Latest usable version for this package.
81    pub latest: ConcreteVersion,
82    /// Every published version, newest-first, unfiltered.
83    pub available: Arc<[ConcreteVersion]>,
84    /// Subset of `available` reported as yanked/deprecated by the registry, each paired
85    /// with its [`RemovalStatus`].
86    pub yanked: Arc<[(ConcreteVersion, RemovalStatus)]>,
87    /// When `latest` was published, if the registry exposes it. `None` when
88    /// the ecosystem doesn't wire [`crate::Version::published_at`] or the fetch
89    /// never ran.
90    ///
91    /// Deliberately a field on this single per-package struct rather than a
92    /// second parallel map keyed alongside `latest` — the earlier two-map
93    /// design (issue #227 critique C3) let `latest` and its age drift apart
94    /// silently whenever one map was updated (e.g. lockfile-resolved
95    /// overwrite) without the other. Bundling them here makes that
96    /// desync impossible: whoever sets `latest` sets `published_at` too.
97    pub published_at: Option<crate::freshness::PublishTime>,
98}
99
100impl PackageVersions {
101    /// Builds a `PackageVersions` from only the "latest" version string, with `available`
102    /// populated as the single-element list `[latest]`.
103    ///
104    /// **Test-only in intent.** The one-element `available` this produces is a real, if
105    /// small, version list — it is not "empty/unknown", so `requirement_is_unsatisfiable`
106    /// will evaluate a requirement against it. Do **not** use this for a lock-file-only
107    /// population path that has no real version list to offer (use
108    /// [`latest_without_list`](Self::latest_without_list) there instead, which leaves
109    /// `available` genuinely empty) — that exact substitution is the false-positive N5 was
110    /// written to prevent: it would let the unsatisfiable-requirement check produce a
111    /// verdict against a fabricated one-entry list before any registry fetch has run. Real
112    /// registry fetches always populate `available` from the full `get_versions` result
113    /// instead of using either constructor.
114    ///
115    /// # Examples
116    ///
117    /// ```
118    /// use deps_core::{ConcreteVersion, PackageVersions};
119    ///
120    /// let versions = PackageVersions::latest_only("1.0.214");
121    /// assert_eq!(versions.latest, "1.0.214");
122    /// assert_eq!(&*versions.available, &[ConcreteVersion::new("1.0.214")]);
123    /// ```
124    pub fn latest_only(latest: impl Into<ConcreteVersion>) -> Self {
125        let latest = latest.into();
126        let available = Arc::from(vec![latest.clone()]);
127        Self {
128            latest,
129            available,
130            yanked: Arc::from(Vec::new()),
131            published_at: None,
132        }
133    }
134
135    /// Builds a `PackageVersions` with no version list — used where only the "latest" value
136    /// is known and probing further would be misleading, notably the lock-file population
137    /// path (`crates/deps-lsp/src/document/lifecycle.rs`), which must not populate a
138    /// plausible-looking one-element `available` list before any registry fetch has run: the
139    /// unsatisfiable-requirement check treats an empty `available` as "still loading, skip".
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use deps_core::PackageVersions;
145    ///
146    /// let versions = PackageVersions::latest_without_list("1.0.195");
147    /// assert_eq!(versions.latest, "1.0.195");
148    /// assert!(versions.available.is_empty());
149    /// ```
150    pub fn latest_without_list(latest: impl Into<ConcreteVersion>) -> Self {
151        Self {
152            latest: latest.into(),
153            available: Arc::from(Vec::new()),
154            yanked: Arc::from(Vec::new()),
155            published_at: None,
156        }
157    }
158}
159
160/// Everything the lifecycle fetch learned about one package, keyed by its normalized name.
161///
162/// All three channels can hold simultaneously for the same package — this is load-bearing,
163/// not an incidental shape. The D5 status gate in
164/// [`generate_diagnostics_from_cache`] reads the [`Self::deprecation`] and [`Self::yanked`]
165/// entries for the same normalized name together and compares their [`RemovalStatus`], and on
166/// the didChange path a surviving deprecation can coexist with a later fetch failure. A single
167/// enum variant per package could not express that overlap.
168#[derive(Debug, Clone, Default, PartialEq)]
169pub struct DependencyOutcome {
170    /// The version found yanked (the in-use version, or `latest` when only `latest` itself is
171    /// yanked), paired with its [`RemovalStatus`]. See [`VersionData::outcomes`]'s prior
172    /// semantics (#233/#263).
173    pub yanked: Option<(ConcreteVersion, RemovalStatus)>,
174    /// Package-level deprecation finding (#205). See [`Deprecation`].
175    pub deprecation: Option<Deprecation>,
176    /// Registry fetch errored, timed out, or was never attempted (#267). See [`FetchFailure`].
177    pub fetch_failure: Option<FetchFailure>,
178    /// The registry fetch succeeded (no [`Self::fetch_failure`]) but produced zero
179    /// comparable versions — e.g. a real GitHub repository whose only tags don't parse
180    /// as full `major.minor.patch` semver (`dtolnay/rust-toolchain`'s sole tag `v1`,
181    /// issue #550). Distinct from both an absent [`DependencyOutcome`] entry ("never
182    /// fetched") and [`Self::fetch_failure`] ("couldn't be asked"): this package
183    /// demonstrably exists, so [R5](crate::lsp_helpers::generate_diagnostics_from_cache)
184    /// must not claim it is unknown.
185    pub no_comparable_versions: bool,
186}
187
188impl DependencyOutcome {
189    /// True when none of the four channels are set.
190    #[must_use]
191    pub const fn is_empty(&self) -> bool {
192        self.yanked.is_none()
193            && self.deprecation.is_none()
194            && self.fetch_failure.is_none()
195            && !self.no_comparable_versions
196    }
197}
198
199/// Normalized-package-name -> [`DependencyOutcome`] map.
200///
201/// A newtype rather than a bare `HashMap` so the empty-entry pruning invariant (an entry is
202/// removed once all three of its channels are cleared) lives in one place, and so test
203/// fixtures get chainable `with_*` constructors instead of building three ad-hoc `HashMap`s.
204/// Mirrors the existing [`crate::osv::VulnerabilityMap`] convention of a `String`-keyed map by
205/// normalized name.
206#[derive(Debug, Clone, Default, PartialEq)]
207pub struct DependencyOutcomes(HashMap<String, DependencyOutcome>);
208
209impl DependencyOutcomes {
210    /// Creates an empty map.
211    #[must_use]
212    pub fn new() -> Self {
213        Self::default()
214    }
215
216    /// Looks up the full outcome recorded for `name`.
217    #[must_use]
218    pub fn get(&self, name: &str) -> Option<&DependencyOutcome> {
219        self.0.get(name)
220    }
221
222    /// Looks up the yanked-version finding for `name`.
223    #[must_use]
224    pub fn yanked(&self, name: &str) -> Option<&(ConcreteVersion, RemovalStatus)> {
225        self.0.get(name)?.yanked.as_ref()
226    }
227
228    /// Looks up the package-level deprecation finding for `name`.
229    #[must_use]
230    pub fn deprecation(&self, name: &str) -> Option<&Deprecation> {
231        self.0.get(name)?.deprecation.as_ref()
232    }
233
234    /// Looks up the fetch-failure finding for `name`.
235    #[must_use]
236    pub fn fetch_failure(&self, name: &str) -> Option<&FetchFailure> {
237        self.0.get(name)?.fetch_failure.as_ref()
238    }
239
240    /// True when the registry fetch for `name` succeeded but produced zero comparable
241    /// versions (#550). See [`DependencyOutcome::no_comparable_versions`].
242    #[must_use]
243    pub fn no_comparable_versions(&self, name: &str) -> bool {
244        self.0.get(name).is_some_and(|o| o.no_comparable_versions)
245    }
246
247    /// Records a yanked-version finding for `name`, creating the entry if absent.
248    pub fn set_yanked(&mut self, name: String, yanked: (ConcreteVersion, RemovalStatus)) {
249        self.0.entry(name).or_default().yanked = Some(yanked);
250    }
251
252    /// Records a package-level deprecation finding for `name`, creating the entry if absent.
253    pub fn set_deprecation(&mut self, name: String, deprecation: Deprecation) {
254        self.0.entry(name).or_default().deprecation = Some(deprecation);
255    }
256
257    /// Records a fetch-failure finding for `name`, creating the entry if absent.
258    pub fn set_fetch_failure(&mut self, name: String, failure: FetchFailure) {
259        self.0.entry(name).or_default().fetch_failure = Some(failure);
260    }
261
262    /// Records a fetch-failure finding for `name` only if one is not already recorded,
263    /// creating the entry if absent.
264    pub fn set_fetch_failure_if_absent(&mut self, name: String, failure: FetchFailure) {
265        self.0
266            .entry(name)
267            .or_default()
268            .fetch_failure
269            .get_or_insert(failure);
270    }
271
272    /// Records that `name`'s registry fetch succeeded but produced zero comparable
273    /// versions (#550), creating the entry if absent.
274    pub fn set_no_comparable_versions(&mut self, name: String) {
275        self.0.entry(name).or_default().no_comparable_versions = true;
276    }
277
278    /// Clears the no-comparable-versions channel for `name`, pruning the entry if it
279    /// becomes empty.
280    pub fn clear_no_comparable_versions(&mut self, name: &str) {
281        if let Some(entry) = self.0.get_mut(name) {
282            entry.no_comparable_versions = false;
283            self.prune(name);
284        }
285    }
286
287    /// Clears the yanked-version channel for `name`, pruning the entry if it becomes empty.
288    pub fn clear_yanked(&mut self, name: &str) {
289        if let Some(entry) = self.0.get_mut(name) {
290            entry.yanked = None;
291            self.prune(name);
292        }
293    }
294
295    /// Clears the deprecation channel for `name`, pruning the entry if it becomes empty.
296    pub fn clear_deprecation(&mut self, name: &str) {
297        if let Some(entry) = self.0.get_mut(name) {
298            entry.deprecation = None;
299            self.prune(name);
300        }
301    }
302
303    /// Clears the fetch-failure channel for `name`, pruning the entry if it becomes empty.
304    pub fn clear_fetch_failure(&mut self, name: &str) {
305        if let Some(entry) = self.0.get_mut(name) {
306            entry.fetch_failure = None;
307            self.prune(name);
308        }
309    }
310
311    /// Removes the whole entry for `name`, regardless of which channels are set.
312    pub fn remove(&mut self, name: &str) {
313        self.0.remove(name);
314    }
315
316    /// Clears the fetch-failure channel for every entry, pruning any that become empty.
317    ///
318    /// Used when a forced re-fetch (e.g. a live-reloaded registry-routing setting, deps-lsp
319    /// issue #592) makes every previously recorded fetch-failure finding untrustworthy: the
320    /// routing itself changed, so a failure recorded under the old routing must not survive
321    /// to be merged with results fetched under the new one.
322    pub fn clear_all_fetch_failures(&mut self) {
323        let names: Vec<String> = self.0.keys().cloned().collect();
324        for name in names {
325            self.clear_fetch_failure(&name);
326        }
327    }
328
329    fn prune(&mut self, name: &str) {
330        if self.0.get(name).is_some_and(DependencyOutcome::is_empty) {
331            self.0.remove(name);
332        }
333    }
334
335    /// Number of entries currently stored.
336    #[must_use]
337    pub fn len(&self) -> usize {
338        self.0.len()
339    }
340
341    /// True when no entries are stored.
342    #[must_use]
343    pub fn is_empty(&self) -> bool {
344        self.0.is_empty()
345    }
346
347    /// Number of entries with a yanked-version finding, for logging/`Debug`.
348    #[must_use]
349    pub fn yanked_count(&self) -> usize {
350        self.0.values().filter(|o| o.yanked.is_some()).count()
351    }
352
353    /// Number of entries with a deprecation finding, for logging/`Debug`.
354    #[must_use]
355    pub fn deprecation_count(&self) -> usize {
356        self.0.values().filter(|o| o.deprecation.is_some()).count()
357    }
358
359    /// Number of entries with a fetch-failure finding, for logging/`Debug`.
360    #[must_use]
361    pub fn fetch_failure_count(&self) -> usize {
362        self.0
363            .values()
364            .filter(|o| o.fetch_failure.is_some())
365            .count()
366    }
367
368    /// Chainable builder recording a yanked-version finding. Test/fixture ergonomics.
369    #[must_use]
370    pub fn with_yanked(
371        mut self,
372        name: impl Into<String>,
373        yanked: (ConcreteVersion, RemovalStatus),
374    ) -> Self {
375        self.set_yanked(name.into(), yanked);
376        self
377    }
378
379    /// Chainable builder recording a package-level deprecation finding. Test/fixture
380    /// ergonomics.
381    #[must_use]
382    pub fn with_deprecation(mut self, name: impl Into<String>, deprecation: Deprecation) -> Self {
383        self.set_deprecation(name.into(), deprecation);
384        self
385    }
386
387    /// Chainable builder recording a fetch-failure finding. Test/fixture ergonomics.
388    #[must_use]
389    pub fn with_fetch_failure(mut self, name: impl Into<String>, failure: FetchFailure) -> Self {
390        self.set_fetch_failure(name.into(), failure);
391        self
392    }
393
394    /// Chainable builder recording a no-comparable-versions finding (#550). Test/fixture
395    /// ergonomics.
396    #[must_use]
397    pub fn with_no_comparable_versions(mut self, name: impl Into<String>) -> Self {
398        self.set_no_comparable_versions(name.into());
399        self
400    }
401}
402
403/// Bundles the two per-package version maps (`cached`, `resolved`) that LSP handlers pass
404/// together everywhere.
405///
406/// Grouping them prevents accidentally swapping the two map arguments at a call site, since
407/// the compiler can no longer typecheck them positionally.
408///
409/// # Examples
410///
411/// ```
412/// use deps_core::{ConcreteVersion, PackageName, PackageVersions, VersionData};
413/// use std::collections::HashMap;
414///
415/// let mut cached = HashMap::new();
416/// cached.insert(PackageName::new("serde"), PackageVersions::latest_only("1.0.214"));
417///
418/// let mut resolved = HashMap::new();
419/// resolved.insert(PackageName::new("serde"), ConcreteVersion::new("1.0.200"));
420///
421/// let versions = VersionData::new(&cached, &resolved);
422///
423/// assert_eq!(versions.cached.get("serde").map(|v| v.latest.as_str()), Some("1.0.214"));
424/// assert_eq!(versions.resolved.get("serde").map(ConcreteVersion::as_str), Some("1.0.200"));
425/// ```
426#[derive(Debug, Clone, Copy)]
427pub struct VersionData<'a> {
428    /// Latest known versions and full version lists from the registry, keyed by package name.
429    pub cached: &'a HashMap<PackageName, PackageVersions>,
430    /// Versions actually resolved in the lock file, keyed by package name.
431    pub resolved: &'a HashMap<PackageName, ConcreteVersion>,
432    /// OSV scan results, keyed by normalized package name. `None` when no
433    /// scan has run yet (e.g. the feature is disabled) — distinct from an
434    /// empty map, which would mean "scanned, nothing found".
435    pub vulnerabilities: Option<&'a VulnerabilityMap>,
436    /// Yanked, deprecation, and fetch-failure findings from the most recent lifecycle fetch,
437    /// keyed by normalized package name — see [`DependencyOutcome`] for what each channel
438    /// means and why they must stay readable together off one lookup (D5 in
439    /// [`generate_diagnostics_from_cache`], #233/#263/#205/#267). `None` when no fetch has run
440    /// yet — distinct from an empty map, which would mean "checked, nothing found".
441    pub outcomes: Option<&'a DependencyOutcomes>,
442    /// This document's ecosystem, when the caller has one to give. `None` in
443    /// most test fixtures and a handful of ecosystem-crate self-tests that
444    /// predate this field.
445    ///
446    /// Enables two occurrence-aware refinements added for #394 (duplicate
447    /// dependency names no longer collapsing into one shared finding):
448    /// [`generate_diagnostics_from_cache`] only emits a yanked-version
449    /// diagnostic on the occurrence whose own in-use version actually
450    /// matches the recorded finding (S1), and the vulnerability lookups in
451    /// `generate_diagnostics_from_cache`, [`generate_hover`], and
452    /// `generate_code_actions` prefer a version-qualified
453    /// [`crate::osv::VulnerabilityMap`] key over the plain name when more
454    /// than one occurrence of a name has a distinct in-use version (S2).
455    /// When `None`, both fall back to their pre-#394 name-only behavior.
456    pub ecosystem: Option<EcosystemId>,
457    /// Whether `network.offline` is set (issue #483). When `true`, [`generate_hover`]
458    /// appends a footer stating that version *and vulnerability* data were not checked —
459    /// deliberately more specific than a bare "showing cached data" notice, since
460    /// `hover.rs`'s `Some(ScanOutcome::Skipped(_)) | None` arm renders nothing for an
461    /// offline OSV skip, which would otherwise look identical to a scanned-and-clean
462    /// dependency.
463    pub offline: bool,
464    /// The deps.dev client to fetch a supply-chain trust signal through
465    /// (spec 037), when the caller wants hover to attempt one. `None` by
466    /// default and left `None` by every surface but `handlers/hover.rs`
467    /// (deps-lsp) — diagnostics, code actions, inlay hints, and code lenses
468    /// never set this, which is what makes FR-010's hover-only scope
469    /// structural rather than convention: those surfaces cannot reach
470    /// deps.dev because they are never handed a client. `&'a Arc<..>`, not
471    /// `&'a DepsDevClient`, so [`generate_hover`] can clone the `Arc` into a
472    /// detached background task.
473    pub trust: Option<&'a Arc<DepsDevClient>>,
474}
475
476impl<'a> VersionData<'a> {
477    /// Creates a new `VersionData` from the cached and resolved version maps.
478    ///
479    /// `vulnerabilities` starts `None`; chain [`Self::with_vulnerabilities`]
480    /// to attach a scan result.
481    ///
482    /// # Examples
483    ///
484    /// ```
485    /// use deps_core::VersionData;
486    /// use std::collections::HashMap;
487    ///
488    /// let cached = HashMap::new();
489    /// let resolved = HashMap::new();
490    /// let versions = VersionData::new(&cached, &resolved);
491    /// assert!(versions.cached.is_empty());
492    /// assert!(versions.vulnerabilities.is_none());
493    /// ```
494    pub fn new(
495        cached: &'a HashMap<PackageName, PackageVersions>,
496        resolved: &'a HashMap<PackageName, ConcreteVersion>,
497    ) -> Self {
498        Self {
499            cached,
500            resolved,
501            vulnerabilities: None,
502            outcomes: None,
503            ecosystem: None,
504            offline: false,
505            trust: None,
506        }
507    }
508
509    /// Attaches an OSV scan result to this `VersionData`.
510    ///
511    /// # Examples
512    ///
513    /// ```
514    /// use deps_core::VersionData;
515    /// use deps_core::osv::VulnerabilityMap;
516    /// use std::collections::HashMap;
517    ///
518    /// let cached = HashMap::new();
519    /// let resolved = HashMap::new();
520    /// let vulns = VulnerabilityMap::new();
521    /// let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulns);
522    /// assert!(versions.vulnerabilities.is_some());
523    /// ```
524    #[must_use]
525    pub fn with_vulnerabilities(mut self, vulnerabilities: &'a VulnerabilityMap) -> Self {
526        self.vulnerabilities = Some(vulnerabilities);
527        self
528    }
529
530    /// Attaches yanked, deprecation, and fetch-failure findings to this `VersionData`. See
531    /// [`Self::outcomes`].
532    ///
533    /// # Examples
534    ///
535    /// ```
536    /// use deps_core::VersionData;
537    /// use deps_core::lsp_helpers::DependencyOutcomes;
538    /// use std::collections::HashMap;
539    ///
540    /// let cached = HashMap::new();
541    /// let resolved = HashMap::new();
542    /// let outcomes = DependencyOutcomes::new();
543    /// let versions = VersionData::new(&cached, &resolved).with_outcomes(&outcomes);
544    /// assert!(versions.outcomes.is_some());
545    /// ```
546    #[must_use]
547    pub fn with_outcomes(mut self, outcomes: &'a DependencyOutcomes) -> Self {
548        self.outcomes = Some(outcomes);
549        self
550    }
551
552    /// Attaches this document's ecosystem, enabling the occurrence-aware
553    /// refinements described on [`Self::ecosystem`].
554    ///
555    /// # Examples
556    ///
557    /// ```
558    /// use deps_core::{EcosystemId, VersionData};
559    /// use std::collections::HashMap;
560    ///
561    /// let cached = HashMap::new();
562    /// let resolved = HashMap::new();
563    /// let versions = VersionData::new(&cached, &resolved).with_ecosystem(EcosystemId::Cargo);
564    /// assert_eq!(versions.ecosystem, Some(EcosystemId::Cargo));
565    /// ```
566    #[must_use]
567    pub const fn with_ecosystem(mut self, ecosystem: EcosystemId) -> Self {
568        self.ecosystem = Some(ecosystem);
569        self
570    }
571
572    /// Marks this `VersionData` as built while `network.offline` was set, so
573    /// [`generate_hover`] appends its offline footer.
574    ///
575    /// # Examples
576    ///
577    /// ```
578    /// use deps_core::VersionData;
579    /// use std::collections::HashMap;
580    ///
581    /// let cached = HashMap::new();
582    /// let resolved = HashMap::new();
583    /// let versions = VersionData::new(&cached, &resolved).with_offline(true);
584    /// assert!(versions.offline);
585    /// ```
586    #[must_use]
587    pub const fn with_offline(mut self, offline: bool) -> Self {
588        self.offline = offline;
589        self
590    }
591
592    /// Attaches a deps.dev client, enabling [`generate_hover`] to attempt a
593    /// supply-chain trust signal for the hovered dependency. See
594    /// [`Self::trust`].
595    ///
596    /// # Examples
597    ///
598    /// ```
599    /// use deps_core::{DepsDevClient, HttpCache, VersionData};
600    /// use std::collections::HashMap;
601    /// use std::sync::Arc;
602    ///
603    /// let cached = HashMap::new();
604    /// let resolved = HashMap::new();
605    /// let client = Arc::new(DepsDevClient::new(Arc::new(HttpCache::new())));
606    /// let versions = VersionData::new(&cached, &resolved).with_trust(&client);
607    /// assert!(versions.trust.is_some());
608    /// ```
609    #[must_use]
610    pub const fn with_trust(mut self, client: &'a Arc<DepsDevClient>) -> Self {
611        self.trust = Some(client);
612        self
613    }
614}
615
616/// Checks whether a cursor position falls within an LSP range (inclusive on both ends).
617pub fn position_in_range(pos: Position, range: Range) -> bool {
618    if pos.line < range.start.line || pos.line > range.end.line {
619        return false;
620    }
621    if pos.line == range.start.line && pos.character < range.start.character {
622        return false;
623    }
624    if pos.line == range.end.line && pos.character > range.end.character {
625        return false;
626    }
627    true
628}
629
630/// Converts byte offsets in source text to LSP `Position` values.
631///
632/// Precomputes line-start byte offsets once, then maps any byte offset to a
633/// `(line, character)` position. Characters are counted as UTF-16 code units
634/// as required by the LSP specification.
635pub struct LineOffsetTable {
636    line_starts: Vec<usize>,
637}
638
639impl LineOffsetTable {
640    /// Builds the table for `content`.
641    pub fn new(content: &str) -> Self {
642        let mut line_starts = vec![0];
643        for (i, c) in content.char_indices() {
644            if c == '\n' {
645                line_starts.push(i + 1);
646            }
647        }
648        Self { line_starts }
649    }
650
651    /// Absolute byte offset where `line` (0-indexed) starts, or `None` if
652    /// `line` is out of range.
653    ///
654    /// Prefer this over re-deriving a line's start via cursor arithmetic
655    /// (`cursor += line.len() + 1`): `str::lines()` strips a trailing `\r`,
656    /// so that approach under-counts by one byte per CRLF line and corrupts
657    /// every subsequent offset in the file. This table is built by scanning
658    /// `char_indices()` for `\n` (see [`new`](Self::new)), which counts the
659    /// `\r`, so it stays correct for LF, CRLF and mixed line endings alike.
660    ///
661    /// # Examples
662    ///
663    /// ```
664    /// use deps_core::lsp_helpers::LineOffsetTable;
665    ///
666    /// let table = LineOffsetTable::new("a\r\nb\r\nc");
667    /// assert_eq!(table.line_start(0), Some(0));
668    /// assert_eq!(table.line_start(1), Some(3));
669    /// assert_eq!(table.line_start(2), Some(6));
670    /// assert_eq!(table.line_start(3), None);
671    /// ```
672    pub fn line_start(&self, line: usize) -> Option<usize> {
673        self.line_starts.get(line).copied()
674    }
675
676    /// Converts a byte offset into an LSP `Position`.
677    pub fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position {
678        let offset = offset.min(content.len());
679        // `offset` is not always a toml-span offset (boundary-safe by
680        // construction) — the requirements.txt line parser derives offsets
681        // via hand-rolled byte arithmetic, which can land inside a
682        // multi-byte character (e.g. a non-ASCII comment or marker string
683        // combined with an off-by-a-byte cut). Clamp down to the nearest
684        // char boundary rather than panicking on the slice below.
685        let offset = content.floor_char_boundary(offset);
686        let line = self
687            .line_starts
688            .partition_point(|&start| start <= offset)
689            .saturating_sub(1);
690        let line_start = self.line_starts[line];
691        let character = content[line_start..offset]
692            .chars()
693            .map(|c| c.len_utf16() as u32)
694            .sum();
695        Position::new(line as u32, character)
696    }
697
698    /// Converts an LSP `Position` back into a byte offset — the inverse of
699    /// [`byte_offset_to_position`](Self::byte_offset_to_position). Out-of-range lines or
700    /// UTF-16 characters clamp to `content.len()` rather than panicking, matching the
701    /// forward conversion's `.min(content.len())` guard.
702    pub fn position_to_byte_offset(&self, content: &str, position: Position) -> usize {
703        let Some(&line_start) = self.line_starts.get(position.line as usize) else {
704            return content.len();
705        };
706        let line_end = self
707            .line_starts
708            .get(position.line as usize + 1)
709            .copied()
710            .unwrap_or(content.len());
711        let line = &content[line_start..line_end];
712        crate::completion::utf16_to_byte_offset(line, position.character)
713            .map_or(line_end, |offset| line_start + offset)
714            .min(content.len())
715    }
716}
717
718/// Escapes Markdown syntax characters so untrusted text cannot break out of the
719/// Markdown structure it is embedded in.
720///
721/// Applied to manifest-controlled text (dependency names) before it is written into
722/// hover markdown link labels, and to registry-controlled completion metadata
723/// (package name/version, description, repository/documentation URLs) before it is
724/// written into completion-item link labels and link destinations. Every ASCII
725/// punctuation character is backslash-escaped (CommonMark's full escapable set — not
726/// just brackets/parens, which would still leave e.g. `<https://evil.example>`
727/// autolinks live), and control characters (including newlines) are replaced with a
728/// space so the text cannot terminate the single-line block it is embedded in and
729/// splice in new content.
730///
731/// Backslash-escaping is valid in link destinations as well as regular text, so this
732/// also neutralizes `)`/`]` breakout attempts in a `[label](destination)` URL. It does
733/// *not* block dangerous URI schemes (e.g. `javascript:`) in a destination — that is a
734/// separate concern from breaking out of the surrounding Markdown structure.
735///
736/// Backslash-escaping does *not* work inside inline code spans (CommonMark §6.1) —
737/// use [`markdown_code_span`] for text embedded in `` `...` `` instead.
738///
739/// # Examples
740///
741/// ```
742/// use deps_core::lsp_helpers::escape_markdown;
743///
744/// assert_eq!(escape_markdown("pkg](evil)[pkg"), r"pkg\]\(evil\)\[pkg");
745/// assert_eq!(escape_markdown("a\nb"), "a b");
746/// ```
747pub fn escape_markdown(s: &str) -> String {
748    let mut escaped = String::with_capacity(s.len());
749    for c in s.chars() {
750        if c.is_control() {
751            escaped.push(' ');
752            continue;
753        }
754        if c.is_ascii_punctuation() {
755            escaped.push('\\');
756        }
757        escaped.push(c);
758    }
759    escaped
760}
761
762/// Wraps `content` in a Markdown inline code span (backticks included) that safely
763/// contains arbitrary untrusted text, regardless of embedded backticks.
764///
765/// Backslash-escaping does not work inside code spans (CommonMark §6.1), so instead
766/// this fences with one more backtick than the longest run found in `content`, and
767/// pads with a single space on each side when `content` starts or ends with a
768/// backtick or space (required by CommonMark to keep the fence unambiguous). Control
769/// characters (including newlines) are replaced with a space first, since the raw
770/// hover string is otherwise free to merge into an adjacent Markdown block.
771///
772/// # Examples
773///
774/// ```
775/// use deps_core::lsp_helpers::markdown_code_span;
776///
777/// assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
778/// assert_eq!(markdown_code_span("a`b"), "``a`b``");
779/// ```
780pub fn markdown_code_span(content: &str) -> String {
781    let sanitized: String = content
782        .chars()
783        .map(|c| if c.is_control() { ' ' } else { c })
784        .collect();
785
786    let max_backtick_run = sanitized
787        .split(|c| c != '`')
788        .map(str::len)
789        .max()
790        .unwrap_or(0);
791    let fence = "`".repeat(max_backtick_run + 1);
792
793    if sanitized.is_empty() {
794        format!("{fence} {fence}")
795    } else if sanitized.starts_with(['`', ' ']) || sanitized.ends_with(['`', ' ']) {
796        format!("{fence} {sanitized} {fence}")
797    } else {
798        format!("{fence}{sanitized}{fence}")
799    }
800}
801
802/// Checks if two version strings have the same major and minor version.
803pub fn is_same_major_minor(v1: &str, v2: &str) -> bool {
804    if v1.is_empty() || v2.is_empty() {
805        return false;
806    }
807
808    let mut parts1 = v1.split('.');
809    let mut parts2 = v2.split('.');
810
811    if parts1.next() != parts2.next() {
812        return false;
813    }
814
815    match (parts1.next(), parts2.next()) {
816        (Some(m1), Some(m2)) => m1 == m2,
817        _ => true,
818    }
819}
820
821/// Result of checking whether a dependency's declared requirement is already satisfied by
822/// the latest known version.
823///
824/// Diagnostics and inlay hints read this result differently: diagnostics only need to know
825/// whether it is safe to skip the "Newer version available" warning, so both `UpToDate` and
826/// `Unresolved` suppress it. Inlay hints additionally need to distinguish `Unresolved` from
827/// `UpToDate`, since an unresolved requirement (e.g. a dangling Gradle version-catalog
828/// `version.ref` alias, or an unexpanded Maven `${property}`) must not render an "up to
829/// date" badge that was never actually verified.
830#[derive(Debug, Clone, Copy, PartialEq, Eq)]
831pub enum RequirementStatus {
832    /// The latest version satisfies the declared requirement.
833    UpToDate,
834    /// The latest version does not satisfy the declared requirement — a newer version is
835    /// available.
836    Outdated,
837    /// The requirement could not be resolved to a concrete constraint, so no comparison
838    /// could be made.
839    Unresolved,
840}
841
842/// A `requirement` compiled by one ecosystem, ready to test candidate versions against.
843///
844/// Produced by [`formatter::RequirementResolution::compile_requirement`]. Kept as a separate object
845/// (rather than a single "does any version match" function) so the requirement is parsed
846/// once per dependency, and so the scanning loop — including the empty-list guard, the
847/// early-exit on first match, and the "skip an unparseable candidate" rule — lives once in
848/// [`requirement_is_unsatisfiable`] instead of being reimplemented by all eleven ecosystems.
849///
850/// # Examples
851///
852/// ```
853/// use deps_core::lsp_helpers::RequirementMatcher;
854/// use deps_core::ConcreteVersion;
855///
856/// struct ExactMatch(String);
857///
858/// impl RequirementMatcher for ExactMatch {
859///     fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
860///         Some(version.as_str() == self.0)
861///     }
862/// }
863///
864/// let matcher = ExactMatch("1.0.0".to_string());
865/// assert_eq!(matcher.matches(&ConcreteVersion::new("1.0.0")), Some(true));
866/// assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
867/// ```
868pub trait RequirementMatcher: Send + Sync {
869    /// Tests one candidate version string against the compiled requirement.
870    ///
871    /// `Some(true)` / `Some(false)`: this candidate provably does / does not satisfy the
872    /// requirement. `None`: this candidate *string* could not be parsed by this ecosystem's
873    /// version format (e.g. a PyPI legacy release identifier, a Maven timestamped snapshot
874    /// qualifier) — the caller skips it and keeps scanning the rest of the list. Never
875    /// return `None` to mean "the requirement itself is unusable"; that is
876    /// [`formatter::RequirementResolution::compile_requirement`]'s job, via returning `None` from that
877    /// method instead of constructing a matcher at all.
878    fn matches(&self, version: &ConcreteVersion) -> Option<bool>;
879}
880
881/// Whether `segment` is exactly `.` or `..`.
882///
883/// Unlike ordinary path characters, a literal `.`/`..` segment is not neutralized by
884/// percent-encoding: `.` is an unreserved character (RFC 3986), so `urlencoding::encode`
885/// leaves it untouched, and the URL parser's dot-segment removal (RFC 3986 §5.2.4) still
886/// collapses it after encoding — `%2E` decodes back to `.` before that normalization runs.
887/// A registry-fetch URL built as `{base}/{prefix}/{name}` (no fixed suffix after `name`)
888/// must reject a `name`/path segment satisfying this predicate rather than encode it,
889/// since encoding alone does not stop the collapse (#341, #349).
890///
891/// Shared by `deps-npm`'s scope/package segment guard and `deps-dart`'s package-name
892/// guard — both ecosystems' registry APIs key a fetch on a bare, suffix-less path segment.
893///
894/// **Scope**: this predicate (and the `#365` regression sweep built around it) guards
895/// registry-*fetch* URL builders — the sink is a request this process actually
896/// dereferences, so a retargeted URL can make it fetch attacker-chosen data. It
897/// deliberately does *not* extend to a "docs link"/`package_url`-style builder (the
898/// per-ecosystem hover/display link, e.g. `deps_cargo::crate_url`, `deps_go::package_url`):
899/// those interpolate the name into a link rendered in hover text and never fetched by
900/// this process, so an unrejected `.`/`..` name there produces at worst a misleading
901/// same-host link (the registry's package-listing root), not a traversal off-host (#379).
902///
903/// # Examples
904///
905/// ```
906/// use deps_core::lsp_helpers::is_dot_segment;
907///
908/// assert!(is_dot_segment(".."));
909/// assert!(is_dot_segment("."));
910/// assert!(!is_dot_segment("left-pad"));
911/// ```
912pub fn is_dot_segment(segment: &str) -> bool {
913    segment == "." || segment == ".."
914}
915
916/// Whether `version` is safe to embed in a manifest [`TextEdit`] or completion item.
917///
918/// Guards every call into
919/// [`formatter::PackageRendering::format_version_replacing`]/[`formatter::PackageRendering::format_version_for_text_edit`]
920/// and every completion item's `insert_text`/`text_edit`.
921///
922/// Must be applied to the raw version string *before* formatting, never to a
923/// formatter's output: some formatters legitimately produce structural
924/// characters in their output from an already-validated version plus fixed,
925/// trusted operators (e.g. PyPI's `>=1.2.3,<2`), so validating the output
926/// would wrongly reject those.
927///
928/// An allowlist, not a denylist: `version` must be non-empty, at most 64
929/// bytes, and contain only `[A-Za-z0-9.+_~:*^!-]` — the character set real
930/// version strings use across every ecosystem this workspace supports
931/// (SemVer, PEP 440 including epochs like `1!2.0`, Maven qualifiers, npm's
932/// `^`/`~`/`*` range tokens, Go's `+incompatible` suffix). A denylist here
933/// would need to anticipate every dangerous token a target manifest format
934/// (or a build tool evaluating it, e.g. Gradle's Kotlin/Groovy DSL
935/// interpolating `${...}` inside a version literal) could ever act on;
936/// failing closed on an unrecognized character is cheaper and safer.
937///
938/// This is the single validation chokepoint shared by every producer of a
939/// version-derived `TextEdit`/completion item in this workspace, including
940/// OSV advisory data (an `Advisory.fixed_versions` entry is exactly as
941/// untrusted as a registry-reported version).
942///
943/// # Examples
944///
945/// ```
946/// use deps_core::is_safe_version_string;
947///
948/// assert!(is_safe_version_string("1.2.3-alpha.1+build"));
949/// assert!(!is_safe_version_string("1.2.3\", git = \"https://evil"));
950/// ```
951pub fn is_safe_version_string(version: &str) -> bool {
952    !version.is_empty()
953        && version.len() <= 64
954        && version.chars().all(|c| {
955            c.is_ascii_alphanumeric()
956                || matches!(c, '.' | '+' | '_' | '~' | ':' | '*' | '^' | '!' | '-')
957        })
958}
959
960/// Whether `segment` is safe to embed as a Maven `groupId`/`artifactId` value in a
961/// pom.xml [`TextEdit`] or completion item.
962///
963/// Guards Maven's group/artifact completion producer, which builds a completion item's
964/// `insert_text`/`text_edit` from one field of a Maven Central search result — a value
965/// type distinct from a version string (see [`is_safe_version_string`]'s doc comment for
966/// why version-derived and non-version-derived sinks each get their own allowlist).
967///
968/// An allowlist, not a denylist: `segment` must be non-empty, at most 128 bytes, not
969/// exactly `.`/`..` (see [`is_dot_segment`]), and contain only `[A-Za-z0-9._-]` — the
970/// character set real Maven Central group ids (reverse-DNS style, e.g.
971/// `org.apache.commons`) and artifact ids (hyphen/underscore separated, e.g.
972/// `commons-lang3`) use. Deliberately excludes `:` — the `groupId:artifactId` separator —
973/// because this validates one already-split coordinate field at a time, never the joined
974/// pair. Failing closed on an unrecognized character (e.g. `<`, `"`, a newline) keeps a
975/// malicious/compromised search result from restructuring the pom.xml it's inserted into;
976/// the dedicated `.`/`..` rejection closes the same dot-segment URL-normalization gap
977/// [`is_dot_segment`] guards elsewhere (`artifactId` reaches a registry-fetch URL as a bare
978/// path segment in `deps-maven::registry::metadata_urls`, unlike `groupId`, whose `.`→`/`
979/// expansion can never itself produce a literal `..` component).
980///
981/// # Examples
982///
983/// ```
984/// use deps_core::is_safe_maven_coordinate_segment;
985///
986/// assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
987/// assert!(is_safe_maven_coordinate_segment("commons-lang3"));
988/// assert!(!is_safe_maven_coordinate_segment("commons</artifactId><parent>"));
989/// assert!(!is_safe_maven_coordinate_segment(".."));
990/// ```
991pub fn is_safe_maven_coordinate_segment(segment: &str) -> bool {
992    !segment.is_empty()
993        && segment.len() <= 128
994        && !is_dot_segment(segment)
995        && segment
996            .chars()
997            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
998}
999
1000/// Whether `url` is safe to embed as a Swift Package Manager repository URL in a
1001/// Package.swift [`TextEdit`] or completion item.
1002///
1003/// Guards Swift's URL-completion producer, which builds a `.package(url: "...")`
1004/// string-literal replacement from a package registry search result's URL — a value
1005/// type distinct from a version string (see [`is_safe_version_string`]'s doc comment for
1006/// why version-derived and non-version-derived sinks each get their own allowlist).
1007///
1008/// An allowlist, not a denylist: `url` must be non-empty, at most 2048 bytes, start with
1009/// `https://` — every real Swift package registry response is HTTPS (GitHub's `html_url`
1010/// never downgrades), so accepting plain `http://` would only hand a
1011/// compromised/malicious registry a transport-downgrade lever for zero legitimate
1012/// benefit — and otherwise contain only RFC 3986 URL characters (`A-Za-z0-9` plus
1013/// `` -._~:/?#[]@!$&'()*+,;=% ``). Deliberately excludes `"`, `\`, control characters,
1014/// and whitespace — none of those are valid unencoded URL characters, and any of them
1015/// could close the surrounding Swift string literal or otherwise corrupt the manifest.
1016/// Failing closed on an unrecognized character keeps a malicious/compromised search
1017/// result from breaking out of the string it's inserted into.
1018///
1019/// # Examples
1020///
1021/// ```
1022/// use deps_core::is_safe_registry_url;
1023///
1024/// assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
1025/// assert!(!is_safe_registry_url("https://evil.example\", .exact(\"1\")) // "));
1026/// ```
1027pub fn is_safe_registry_url(url: &str) -> bool {
1028    !url.is_empty()
1029        && url.len() <= 2048
1030        && url.starts_with("https://")
1031        && url.chars().all(|c| {
1032            c.is_ascii_alphanumeric()
1033                || matches!(
1034                    c,
1035                    '-' | '.'
1036                        | '_'
1037                        | '~'
1038                        | ':'
1039                        | '/'
1040                        | '?'
1041                        | '#'
1042                        | '['
1043                        | ']'
1044                        | '@'
1045                        | '!'
1046                        | '$'
1047                        | '&'
1048                        | '\''
1049                        | '('
1050                        | ')'
1051                        | '*'
1052                        | '+'
1053                        | ','
1054                        | ';'
1055                        | '='
1056                        | '%'
1057                )
1058        })
1059}
1060
1061/// Whether `name` is safe to embed as a package name in a manifest [`TextEdit`] or
1062/// completion item.
1063///
1064/// Guards every arm of `create_package_completion_item`
1065/// (`crates/deps-lsp/src/handlers/completion.rs`) as a single upfront check, applied
1066/// before the raw `name` reaches any ecosystem-specific snippet — including Maven and
1067/// Swift, which additionally validate a *derived* value on top of this gate
1068/// ([`is_safe_maven_coordinate_segment`] on each split coordinate segment,
1069/// [`is_safe_registry_url`] on the constructed URL) because a value type distinct from
1070/// the raw name needs its own allowlist — see [`is_safe_version_string`]'s doc comment
1071/// for why version-derived and non-version-derived sinks each get their own allowlist.
1072/// [`PackageName::new`](crate::PackageName::new) is documented as never validating or
1073/// modifying its input, so this predicate is the first gate a registry-reported name
1074/// passes through before reaching a manifest. Two sinks that key a bare TOML/YAML
1075/// entry by `name` (Cargo/PyPI, Dart) additionally quote that key in the snippet, since
1076/// `.` and `@` are legal here but would otherwise be read as TOML's dotted-key
1077/// separator or break a YAML plain scalar.
1078///
1079/// An allowlist, not a denylist: `name` must be non-empty, at most 256 bytes, and
1080/// contain only `[A-Za-z0-9._@:/~-]` — the character set real package names use across
1081/// every ecosystem this predicate guards: Cargo/PyPI/Dart/NuGet/Bundler
1082/// (alphanumeric, `-`, `_`, `.`), npm/Deno scoped names (`@scope/name`, adding `@` and
1083/// `/`), Composer (`vendor/package`, `/`), Go module paths (domain-qualified paths like
1084/// `github.com/org/repo`, `/`, `.`, and `~` — legal in a Go path element and already
1085/// allowed by [`is_safe_version_string`]/[`is_safe_registry_url`]), and Gradle's
1086/// colon-delimited `group:artifact` short form (`:`). A denylist here would need to
1087/// anticipate every dangerous token a target manifest format (TOML/JSON/YAML/XML
1088/// string literals, a live Kotlin/Groovy build-script DSL) could ever act on; failing
1089/// closed on an unrecognized character — notably `"`, `'`, `<`, `>`, `` ` ``, and all
1090/// control characters/newlines — is cheaper and safer.
1091///
1092/// # Examples
1093///
1094/// ```
1095/// use deps_core::is_safe_package_name;
1096///
1097/// assert!(is_safe_package_name("serde"));
1098/// assert!(is_safe_package_name("@scope/name"));
1099/// assert!(is_safe_package_name("org.apache.commons:commons-lang3"));
1100/// assert!(!is_safe_package_name("evil\"\nbackdoor = \"9.9.9"));
1101/// ```
1102pub fn is_safe_package_name(name: &str) -> bool {
1103    !name.is_empty()
1104        && name.len() <= 256
1105        && name.chars().all(|c| {
1106            c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '@' | ':' | '/' | '-' | '~')
1107        })
1108}
1109
1110/// Logs a `tracing::warn!` for a value rejected by an `is_safe_*` predicate (or an
1111/// equivalent value-rejecting gate) before it reaches a manifest edit or registry URL.
1112///
1113/// Deliberately logs only `value`'s byte length, never its content: `value` is
1114/// registry-controlled and, by construction, already failed an allowlist — logging it
1115/// verbatim at `warn` would let a malicious/compromised registry response inject arbitrary
1116/// content into this project's own log stream (a second-order log-injection concern),
1117/// mirroring why `deps-pypi`'s `truncate_for_log` bounds a logged excerpt instead of
1118/// logging a value verbatim. This is this helper's own contract, not a claim that every
1119/// `tracing` call site in the workspace avoids logging a raw value — e.g. `deps-lsp`'s
1120/// `deps-lsp.updateVersion` handler and an OSV malformed-`fixed`-version warning predate
1121/// this helper and log their rejected value directly; they are unrelated call sites, not a
1122/// place this helper is used.
1123///
1124/// `gate` names the predicate/guard that rejected `value` (e.g.
1125/// `"is_safe_maven_coordinate_segment"`); `context` is a short description of the call site
1126/// (e.g. `"maven groupId completion"`).
1127///
1128/// # Examples
1129///
1130/// ```
1131/// use deps_core::lsp_helpers::warn_rejected_value;
1132///
1133/// warn_rejected_value("is_safe_version_string", "code lens latest version", "1.0.0\"; evil");
1134/// ```
1135pub fn warn_rejected_value(gate: &str, context: &str, value: &str) {
1136    tracing::warn!(
1137        gate,
1138        context,
1139        len = value.len(),
1140        "rejected unsafe value before manifest/registry sink"
1141    );
1142}
1143
1144/// Builds a single-entry [`WorkspaceEdit::changes`] map replacing `range` in `uri`
1145/// with `new_text`.
1146///
1147/// Shared by every quickfix/refactor code action in `code_actions` that edits exactly
1148/// one span in the current document (`build_vulnerability_fix_action`,
1149/// `build_unsatisfiable_fix_action`, and the plain "update to `<version>`" loop in
1150/// [`generate_code_actions`]).
1151fn single_file_edit(uri: &Uri, range: Range, new_text: String) -> HashMap<Uri, Vec<TextEdit>> {
1152    let mut edits = HashMap::new();
1153    edits.insert(uri.clone(), vec![TextEdit { range, new_text }]);
1154    edits
1155}
1156
1157/// Strips every whitespace character from `s`, so two textually-equivalent strings that
1158/// differ only in spacing compare equal.
1159///
1160/// Shared by every no-op/literal-match guard across `code_actions` and `code_lenses`
1161/// (`build_vulnerability_fix_action`'s N1 guard, `generate_code_actions`'s REFACTOR-loop
1162/// guard, `literal_span_matches`, and `collect_update_all_edits`'s no-op guard), all of
1163/// which compare a declared requirement
1164/// string against a differently-normalized counterpart — e.g. pep508's `>=1.7, <2.0` vs. a
1165/// formatter's `>=1.7,<2.0`.
1166fn strip_whitespace(s: &str) -> String {
1167    s.chars().filter(|c| !c.is_whitespace()).collect()
1168}
1169
1170/// Slices `content` over an LSP `Range` using a pre-built `LineOffsetTable`, returning
1171/// `""` for an inverted or out-of-bounds range instead of panicking.
1172///
1173/// `table` is document-invariant — callers iterating over multiple dependencies in the
1174/// same document must build it once and reuse it, rather than rebuilding it (an O(n)
1175/// scan of `content`) per dependency.
1176fn slice_for_range<'a>(content: &'a str, table: &LineOffsetTable, range: Range) -> &'a str {
1177    let start = table.position_to_byte_offset(content, range.start);
1178    let end = table.position_to_byte_offset(content, range.end);
1179    if start > end {
1180        return "";
1181    }
1182    content.get(start..end).unwrap_or("")
1183}
1184
1185/// Checks whether `slice` — `content` sliced over a dependency's `version_range` — still
1186/// holds the literal version text declared by `requirement`.
1187///
1188/// Whitespace is stripped from both sides before comparison, since pep508's normalized
1189/// requirement string can diverge from the original source spacing (PyPI's `>=1.7,<2.0`
1190/// renders as `>=1.7, <2.0`) while `version_range` still spans the un-normalized source.
1191///
1192/// The second branch accepts `slice` wrapped in brackets matching `requirement` — the
1193/// exact inverse of NuGet's parser wrapping a bare source version as `format!("[{v}]")`
1194/// (`crates/deps-nuget/src/parser.rs`). This is deliberately **not** a symmetric bracket
1195/// strip: NuGet's `Version="1.0.0"` produces requirement `[1.0.0]` over a bare-literal
1196/// span, so a *symmetric* strip (stripping one bracket pair from both operands) would
1197/// compare `1.0.0` against `1.0.0` — coincidentally correct there, but the same strip
1198/// applied to `Version="[1.0.0]"` (requirement `[[1.0.0]]`, a spelling
1199/// `crates/deps-nuget/src/formatter.rs` explicitly supports) leaves `[1.0.0]` vs
1200/// `1.0.0` and **falsely rejects** an editable dependency. Wrapping only the slice side
1201/// handles both spellings without that false reject.
1202fn literal_span_matches(slice: &str, requirement: &str) -> bool {
1203    let norm_slice = strip_whitespace(slice);
1204    let norm_req = strip_whitespace(requirement);
1205    norm_slice == norm_req || format!("[{norm_slice}]") == norm_req
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210    use super::*;
1211    use crate::lsp_helpers::test_support::*;
1212    use crate::{PackageName, VersionReq};
1213
1214    /// The empty-entry pruning invariant: an entry is removed once its last set channel
1215    /// is cleared, one channel at a time, in every order — never left behind as a
1216    /// vacuous `Some(DependencyOutcome::default())` that would inflate `len()`.
1217    #[test]
1218    fn test_dependency_outcomes_prunes_entry_once_all_channels_cleared() {
1219        let mut outcomes = DependencyOutcomes::new()
1220            .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
1221            .with_deprecation(
1222                "pkg",
1223                Deprecation {
1224                    reason: None,
1225                    replacement: None,
1226                },
1227            )
1228            .with_fetch_failure("pkg", FetchFailure::Transient);
1229        assert_eq!(outcomes.len(), 1);
1230
1231        outcomes.clear_yanked("pkg");
1232        assert!(
1233            outcomes.get("pkg").is_some(),
1234            "entry must survive while other channels are still set"
1235        );
1236
1237        outcomes.clear_deprecation("pkg");
1238        assert!(
1239            outcomes.get("pkg").is_some(),
1240            "entry must survive while the fetch-failure channel is still set"
1241        );
1242
1243        outcomes.clear_fetch_failure("pkg");
1244        assert!(
1245            outcomes.is_empty(),
1246            "entry must be pruned once its last channel is cleared, not left as a vacuous Some"
1247        );
1248        assert_eq!(outcomes.len(), 0);
1249    }
1250
1251    /// Clearing a channel that was never set on an existing entry must not spuriously
1252    /// prune channels that ARE still set (each `clear_*` only nulls its own field).
1253    #[test]
1254    fn test_dependency_outcomes_clear_on_unset_channel_is_a_no_op_for_others() {
1255        let mut outcomes = DependencyOutcomes::new()
1256            .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::AdvisoryDeprecated));
1257
1258        outcomes.clear_deprecation("pkg");
1259        outcomes.clear_fetch_failure("pkg");
1260
1261        assert!(
1262            outcomes.yanked("pkg").is_some(),
1263            "clearing unset channels must not touch the still-set yanked channel"
1264        );
1265        assert_eq!(outcomes.len(), 1);
1266    }
1267
1268    /// `remove` drops the whole entry regardless of which channels are set, unlike the
1269    /// per-channel `clear_*` methods.
1270    #[test]
1271    fn test_dependency_outcomes_remove_drops_entry_with_multiple_channels_set() {
1272        let mut outcomes = DependencyOutcomes::new()
1273            .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
1274            .with_fetch_failure("pkg", FetchFailure::Transient);
1275
1276        outcomes.remove("pkg");
1277
1278        assert!(outcomes.get("pkg").is_none());
1279        assert!(outcomes.is_empty());
1280    }
1281
1282    /// `clear_all_fetch_failures` (deps-lsp issue #592) drops the fetch-failure channel for
1283    /// every entry, pruning an entry that becomes empty, while leaving other channels
1284    /// (yanked/deprecation/no-comparable-versions) on a still-mixed entry untouched.
1285    #[test]
1286    fn test_dependency_outcomes_clear_all_fetch_failures() {
1287        let mut outcomes = DependencyOutcomes::new()
1288            .with_fetch_failure("only-failure", FetchFailure::Transient)
1289            .with_yanked("mixed", ("1.0.0".into(), RemovalStatus::Yanked))
1290            .with_fetch_failure("mixed", FetchFailure::Transient);
1291
1292        outcomes.clear_all_fetch_failures();
1293
1294        assert!(
1295            outcomes.get("only-failure").is_none(),
1296            "an entry whose only channel was fetch-failure must be pruned"
1297        );
1298        assert!(outcomes.fetch_failure("mixed").is_none());
1299        assert!(
1300            outcomes.yanked("mixed").is_some(),
1301            "clearing fetch-failure must not touch a mixed entry's other channels"
1302        );
1303    }
1304
1305    /// `clear_all_fetch_failures` on an empty map is a no-op, not a panic.
1306    #[test]
1307    fn test_dependency_outcomes_clear_all_fetch_failures_on_empty_map() {
1308        let mut outcomes = DependencyOutcomes::new();
1309        outcomes.clear_all_fetch_failures();
1310        assert!(outcomes.is_empty());
1311    }
1312
1313    /// `set_fetch_failure_if_absent` (impl-critic M2) must never clobber a genuine
1314    /// `Actionable`/`Transient` failure already recorded for the name — only `set_fetch_failure`
1315    /// unconditionally overwrites.
1316    #[test]
1317    fn test_dependency_outcomes_set_fetch_failure_if_absent_does_not_clobber_existing() {
1318        let mut outcomes = DependencyOutcomes::new().with_fetch_failure(
1319            "pkg",
1320            FetchFailure::Actionable("set GITHUB_TOKEN".to_string()),
1321        );
1322
1323        outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
1324
1325        assert_eq!(
1326            outcomes.fetch_failure("pkg"),
1327            Some(&FetchFailure::Actionable("set GITHUB_TOKEN".to_string())),
1328            "an existing genuine failure must survive a collided-name NotAttempted marker"
1329        );
1330    }
1331
1332    /// The mirror case: when no failure is recorded yet, `set_fetch_failure_if_absent` does
1333    /// populate the entry.
1334    #[test]
1335    fn test_dependency_outcomes_set_fetch_failure_if_absent_populates_when_unset() {
1336        let mut outcomes = DependencyOutcomes::new();
1337
1338        outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
1339
1340        assert_eq!(
1341            outcomes.fetch_failure("pkg"),
1342            Some(&FetchFailure::NotAttempted)
1343        );
1344    }
1345
1346    #[test]
1347    fn test_line_offset_table_line_start_crlf() {
1348        let table = LineOffsetTable::new("a\r\nbb\r\nc");
1349        assert_eq!(table.line_start(0), Some(0));
1350        assert_eq!(table.line_start(1), Some(3));
1351        assert_eq!(table.line_start(2), Some(7));
1352        assert_eq!(table.line_start(3), None);
1353    }
1354
1355    #[test]
1356    fn test_byte_offset_to_position_clamps_to_char_boundary_instead_of_panicking() {
1357        // "é" is a 2-byte UTF-8 sequence; offset 1 lands inside it.
1358        let content = "é";
1359        let table = LineOffsetTable::new(content);
1360        // Must not panic; clamps down to the nearest boundary (offset 0).
1361        let pos = table.byte_offset_to_position(content, 1);
1362        assert_eq!(pos, Position::new(0, 0));
1363    }
1364
1365    #[test]
1366    fn test_byte_offset_to_position_multi_byte_boundary_in_longer_line() {
1367        let content = "ab é cd";
1368        let table = LineOffsetTable::new(content);
1369        // Byte 3 is 'é's leading byte (boundary); byte 4 is its continuation
1370        // byte (not a boundary) and must clamp back to 3 rather than panic.
1371        assert!(content.is_char_boundary(3));
1372        assert!(!content.is_char_boundary(4));
1373        let pos = table.byte_offset_to_position(content, 4);
1374        assert_eq!(pos, table.byte_offset_to_position(content, 3));
1375    }
1376
1377    #[test]
1378    fn test_position_in_range_inside() {
1379        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1380        let position = Position::new(5, 15);
1381        assert!(position_in_range(position, range));
1382    }
1383
1384    #[test]
1385    fn test_position_in_range_at_start() {
1386        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1387        let position = Position::new(5, 10);
1388        assert!(position_in_range(position, range));
1389    }
1390
1391    #[test]
1392    fn test_position_in_range_at_end() {
1393        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1394        let position = Position::new(5, 20);
1395        assert!(position_in_range(position, range));
1396    }
1397
1398    #[test]
1399    fn test_position_in_range_before() {
1400        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1401        let position = Position::new(5, 5);
1402        assert!(!position_in_range(position, range));
1403    }
1404
1405    #[test]
1406    fn test_position_in_range_after() {
1407        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1408        let position = Position::new(5, 25);
1409        assert!(!position_in_range(position, range));
1410    }
1411
1412    #[test]
1413    fn test_position_in_range_different_line_before() {
1414        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1415        let position = Position::new(4, 15);
1416        assert!(!position_in_range(position, range));
1417    }
1418
1419    #[test]
1420    fn test_position_in_range_different_line_after() {
1421        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1422        let position = Position::new(6, 15);
1423        assert!(!position_in_range(position, range));
1424    }
1425
1426    #[test]
1427    fn test_position_in_range_multiline() {
1428        let range = Range::new(Position::new(5, 10), Position::new(7, 5));
1429        let position = Position::new(6, 0);
1430        assert!(position_in_range(position, range));
1431    }
1432
1433    #[test]
1434    fn test_escape_markdown_link_breakout_payload() {
1435        let payload = "real-pkg](https://legit-looking-typosquat.example/download)[real-pkg";
1436        let escaped = escape_markdown(payload);
1437        assert_eq!(
1438            escaped,
1439            r"real\-pkg\]\(https\:\/\/legit\-looking\-typosquat\.example\/download\)\[real\-pkg"
1440        );
1441        assert!(!escaped.contains("]("));
1442    }
1443
1444    #[test]
1445    fn test_escape_markdown_backslash_and_backtick() {
1446        assert_eq!(escape_markdown(r"a\b`c"), r"a\\b\`c");
1447    }
1448
1449    #[test]
1450    fn test_escape_markdown_autolink_angle_brackets() {
1451        // `<...>` around a bare URL is a CommonMark autolink; `<`/`>` must be escaped
1452        // so it cannot render as a live link independent of the `[]`/`()` escaping.
1453        let escaped = escape_markdown("pkg <https://evil.example>");
1454        assert_eq!(escaped, r"pkg \<https\:\/\/evil\.example\>");
1455        assert!(!escaped.contains('<') || escaped.contains(r"\<"));
1456    }
1457
1458    #[test]
1459    fn test_escape_markdown_control_chars_become_spaces() {
1460        assert_eq!(escape_markdown("a\nb"), "a b");
1461        assert_eq!(escape_markdown("a\r\nb"), "a  b");
1462        assert_eq!(escape_markdown("a\tb"), "a b");
1463        assert_eq!(escape_markdown("a\0b"), "a b");
1464    }
1465
1466    #[test]
1467    fn test_escape_markdown_newline_cannot_break_out_of_heading() {
1468        // A raw newline used to terminate the ATX heading line early, letting the
1469        // rest of the name (potentially another "# [...](...)" sequence) render as
1470        // separate, unescaped Markdown blocks.
1471        let escaped = escape_markdown("react\n# [fake](https://evil.example)");
1472        assert!(!escaped.contains('\n'));
1473    }
1474
1475    #[test]
1476    fn test_escape_markdown_hyphenated_name_round_trips_visually() {
1477        // Escaping a hyphen (ASCII punctuation) is visually inert on render — CommonMark
1478        // renders `\-` as a literal `-` — so common package names are unaffected in
1479        // practice even though the raw Markdown source now escapes them.
1480        assert_eq!(escape_markdown("tokio-util"), r"tokio\-util");
1481    }
1482
1483    #[test]
1484    fn test_markdown_code_span_plain_content() {
1485        assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
1486    }
1487
1488    #[test]
1489    fn test_markdown_code_span_widens_fence_for_embedded_backticks() {
1490        assert_eq!(markdown_code_span("a`b"), "``a`b``");
1491        assert_eq!(markdown_code_span("``double``"), "``` ``double`` ```");
1492    }
1493
1494    #[test]
1495    fn test_markdown_code_span_pads_when_content_starts_or_ends_with_backtick() {
1496        let span = markdown_code_span("`leading");
1497        assert!(span.starts_with("`` `"));
1498    }
1499
1500    #[test]
1501    fn test_markdown_code_span_replaces_control_chars() {
1502        let span = markdown_code_span("1.0\n[evil](https://evil.example)");
1503        assert!(!span.contains('\n'));
1504    }
1505
1506    #[test]
1507    fn test_markdown_code_span_empty_content() {
1508        assert_eq!(markdown_code_span(""), "` `");
1509    }
1510
1511    #[test]
1512    fn test_markdown_code_span_backtick_payload_cannot_break_span() {
1513        // A payload attempting to close the code span early and splice in a live
1514        // link must not succeed regardless of backtick count in the content.
1515        let payload = "1.0` <https://evil.example>` more";
1516        let span = markdown_code_span(payload);
1517        // The fence must be strictly longer than any backtick run in the (sanitized)
1518        // content, so no substring of `span` after the opening fence can act as a
1519        // closing fence before the real one.
1520        let opening_fence_len = span.chars().take_while(|&c| c == '`').count();
1521        let inner = &span[opening_fence_len..span.len() - opening_fence_len];
1522        assert!(
1523            !inner.contains(&"`".repeat(opening_fence_len)),
1524            "content contains a run of backticks as long as the fence: {span}"
1525        );
1526    }
1527
1528    #[test]
1529    fn test_is_same_major_minor_full_match() {
1530        assert!(is_same_major_minor("1.2.3", "1.2.9"));
1531    }
1532
1533    #[test]
1534    fn test_is_same_major_minor_exact_match() {
1535        assert!(is_same_major_minor("1.2.3", "1.2.3"));
1536    }
1537
1538    #[test]
1539    fn test_is_same_major_minor_major_only_match() {
1540        assert!(is_same_major_minor("1", "1.2.3"));
1541        assert!(is_same_major_minor("1.2.3", "1"));
1542    }
1543
1544    #[test]
1545    fn test_is_same_major_minor_no_match_different_minor() {
1546        assert!(!is_same_major_minor("1.2.3", "1.3.0"));
1547    }
1548
1549    #[test]
1550    fn test_is_same_major_minor_no_match_different_major() {
1551        assert!(!is_same_major_minor("1.2.3", "2.2.3"));
1552    }
1553
1554    #[test]
1555    fn test_is_same_major_minor_empty_strings() {
1556        assert!(!is_same_major_minor("", ""));
1557        assert!(!is_same_major_minor("1.2.3", ""));
1558        assert!(!is_same_major_minor("", "1.2.3"));
1559    }
1560
1561    #[test]
1562    fn test_is_safe_version_string_accepts_ordinary_versions() {
1563        assert!(is_safe_version_string("1.2.3"));
1564        assert!(is_safe_version_string("1.2.3-beta.1+build"));
1565        assert!(is_safe_version_string("v1.2.3"));
1566    }
1567
1568    #[test]
1569    fn test_is_safe_version_string_rejects_empty_or_whitespace() {
1570        assert!(!is_safe_version_string(""));
1571        assert!(!is_safe_version_string("   "));
1572        assert!(!is_safe_version_string("\t\n"));
1573    }
1574
1575    #[test]
1576    fn test_is_safe_version_string_rejects_control_and_structural_characters() {
1577        for bad in [
1578            "1.2.3\n",
1579            "1.2.3\t",
1580            "1.2.3\"",
1581            "1.2.3'",
1582            "1.2.3<",
1583            "1.2.3>",
1584            "1.2.3&",
1585            "1.2.3\\",
1586            "1.0.0\", \"malicious\": \"true",
1587        ] {
1588            assert!(
1589                !is_safe_version_string(bad),
1590                "expected {bad:?} to be rejected"
1591            );
1592        }
1593    }
1594
1595    #[test]
1596    fn test_is_safe_version_string_rejects_gradle_interpolation_payload() {
1597        // Regression (critic S2): `$`/`{`/`}` are outside the allowlist, so a
1598        // Gradle Kotlin/Groovy `${...}` interpolation payload written into
1599        // build.gradle(.kts) can never reach a version literal via this gate.
1600        for bad in ["1.0${System.getenv(\"X\")}", "1.0$var", "${evil}"] {
1601            assert!(
1602                !is_safe_version_string(bad),
1603                "expected {bad:?} to be rejected"
1604            );
1605        }
1606    }
1607
1608    #[test]
1609    fn test_is_safe_version_string_rejects_invisible_unicode() {
1610        // Regression (critic M1): `char::is_control()` alone only covers
1611        // category Cc — format/separator characters like the bidi override
1612        // U+202E, zero-width space U+200B, and the JS/JSON5 line terminators
1613        // U+2028/U+2029 must also be rejected by the allowlist.
1614        for bad in ["1.2.3\u{202E}", "1.2.3\u{200B}", "1.2.3\u{2028}"] {
1615            assert!(
1616                !is_safe_version_string(bad),
1617                "expected {bad:?} to be rejected"
1618            );
1619        }
1620    }
1621
1622    #[test]
1623    fn test_is_safe_version_string_accepts_pep440_epoch() {
1624        // PEP 440 epochs (`1!2.0`) are legitimate PyPI versions.
1625        assert!(is_safe_version_string("1!2.0"));
1626    }
1627
1628    #[test]
1629    fn test_is_safe_version_string_length_cap() {
1630        assert!(is_safe_version_string(&"1".repeat(64)));
1631        assert!(!is_safe_version_string(&"1".repeat(65)));
1632    }
1633
1634    #[test]
1635    fn test_is_safe_maven_coordinate_segment_accepts_real_ids() {
1636        assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
1637        assert!(is_safe_maven_coordinate_segment("commons-lang3"));
1638        assert!(is_safe_maven_coordinate_segment("jackson-core_2.13"));
1639    }
1640
1641    #[test]
1642    fn test_is_safe_maven_coordinate_segment_rejects_empty() {
1643        assert!(!is_safe_maven_coordinate_segment(""));
1644    }
1645
1646    #[test]
1647    fn test_is_safe_maven_coordinate_segment_rejects_xml_structural_characters() {
1648        for bad in [
1649            "commons</artifactId><parent>",
1650            "commons\"",
1651            "commons'",
1652            "commons&amp;",
1653            "commons\nlang3",
1654            "commons\tlang3",
1655        ] {
1656            assert!(
1657                !is_safe_maven_coordinate_segment(bad),
1658                "expected {bad:?} to be rejected"
1659            );
1660        }
1661    }
1662
1663    #[test]
1664    fn test_is_safe_maven_coordinate_segment_rejects_group_artifact_colon() {
1665        assert!(!is_safe_maven_coordinate_segment(
1666            "org.apache.commons:commons-lang3"
1667        ));
1668    }
1669
1670    #[test]
1671    fn test_is_safe_maven_coordinate_segment_length_cap() {
1672        assert!(is_safe_maven_coordinate_segment(&"a".repeat(128)));
1673        assert!(!is_safe_maven_coordinate_segment(&"a".repeat(129)));
1674    }
1675
1676    #[test]
1677    fn test_is_safe_registry_url_accepts_real_urls() {
1678        assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
1679        assert!(is_safe_registry_url(
1680            "https://github.com/apple/swift-nio.git"
1681        ));
1682        assert!(is_safe_registry_url("https://github.com/apple/swift%2Dnio"));
1683    }
1684
1685    #[test]
1686    fn test_is_safe_registry_url_rejects_non_https_scheme() {
1687        // Every real Swift package registry response is HTTPS; accepting `http://` would
1688        // only hand a compromised registry a transport-downgrade lever.
1689        assert!(!is_safe_registry_url("http://example.com/repo"));
1690        assert!(!is_safe_registry_url("file:///etc/passwd"));
1691        assert!(!is_safe_registry_url("javascript:alert(1)"));
1692        assert!(!is_safe_registry_url("ftp://example.com/repo"));
1693        assert!(!is_safe_registry_url(""));
1694    }
1695
1696    #[test]
1697    fn test_is_safe_registry_url_rejects_swift_string_literal_breakout() {
1698        for bad in [
1699            "https://evil.example\", .exact(\"1.0.0\")), .package(url: \"https://real",
1700            "https://evil.example\\",
1701            "https://evil.example\nlet x = 1",
1702            "https://evil.example`echo`",
1703            "https://evil.example<script>",
1704        ] {
1705            assert!(
1706                !is_safe_registry_url(bad),
1707                "expected {bad:?} to be rejected"
1708            );
1709        }
1710    }
1711
1712    #[test]
1713    fn test_is_safe_registry_url_length_cap() {
1714        let prefix = "https://example.com/";
1715        let at_cap = format!("{prefix}{}", "a".repeat(2048 - prefix.len()));
1716        assert_eq!(at_cap.len(), 2048);
1717        assert!(is_safe_registry_url(&at_cap));
1718
1719        let over_cap = format!("{at_cap}a");
1720        assert_eq!(over_cap.len(), 2049);
1721        assert!(!is_safe_registry_url(&over_cap));
1722    }
1723
1724    #[test]
1725    fn test_is_safe_package_name_accepts_real_names_across_ecosystems() {
1726        for good in [
1727            "serde",                            // Cargo
1728            "requests",                         // PyPI
1729            "@scope/name",                      // npm/Deno scoped
1730            "monolog/monolog",                  // Composer vendor/package
1731            "github.com/org/repo",              // Go module path
1732            "path",                             // Dart
1733            "org.apache.commons:commons-lang3", // Gradle group:artifact
1734            "Newtonsoft.Json",                  // NuGet
1735            "rails",                            // Bundler
1736            "npm:react",                        // Deno npm-scheme specifier
1737            "jsr:@std/fs",                      // Deno jsr-scheme specifier
1738            "github.com/foo/bar~compat",        // Go path element with `~`
1739        ] {
1740            assert!(
1741                is_safe_package_name(good),
1742                "expected {good:?} to be accepted"
1743            );
1744        }
1745    }
1746
1747    #[test]
1748    fn test_is_safe_package_name_rejects_empty() {
1749        assert!(!is_safe_package_name(""));
1750    }
1751
1752    #[test]
1753    fn test_is_safe_package_name_rejects_non_ascii() {
1754        // Deliberately excluded: the allowlist is ASCII-only, so a legacy non-ASCII
1755        // npm package name (a handful exist, e.g. Unicode-normalized scopes) is
1756        // rejected rather than risking homograph/normalization tricks in a manifest.
1757        assert!(!is_safe_package_name("café"));
1758        assert!(!is_safe_package_name("пакет"));
1759    }
1760
1761    #[test]
1762    fn test_is_safe_package_name_accepts_dot_dot_shapes() {
1763        // `.`/`/` are individually legal (PyPI dotted names, npm/Composer scopes), so
1764        // `..`/`../..` pass the charset too. This is not a path-traversal risk: every
1765        // sink treats `name` as manifest text (a TOML/JSON/YAML/XML value or a
1766        // string-literal argument), never as a filesystem path.
1767        assert!(is_safe_package_name(".."));
1768        assert!(is_safe_package_name("../.."));
1769    }
1770
1771    #[test]
1772    fn test_is_safe_package_name_rejects_structural_breakout_characters() {
1773        for bad in [
1774            "evil\"\nbackdoor = \"9.9.9",
1775            "evil\", git = \"https://evil",
1776            "evil\\",
1777            "evil'",
1778            "evil<script>",
1779            "evil`echo`",
1780            "evil\ninjected = true",
1781            "evil\tname",
1782        ] {
1783            assert!(
1784                !is_safe_package_name(bad),
1785                "expected {bad:?} to be rejected"
1786            );
1787        }
1788    }
1789
1790    #[test]
1791    fn test_is_safe_package_name_length_cap() {
1792        assert!(is_safe_package_name(&"a".repeat(256)));
1793        assert!(!is_safe_package_name(&"a".repeat(257)));
1794    }
1795
1796    #[test]
1797    fn test_is_same_major_minor_partial_versions() {
1798        assert!(is_same_major_minor("1.2", "1.2.3"));
1799        assert!(is_same_major_minor("1.2.3", "1.2"));
1800    }
1801
1802    #[test]
1803    fn test_ecosystem_formatter_defaults() {
1804        let formatter = MockFormatter;
1805        assert_eq!(
1806            formatter.normalize_package_name(&pkg("test-pkg")),
1807            "test-pkg"
1808        );
1809        assert_eq!(formatter.yanked_message(), "This version has been yanked");
1810        assert_eq!(formatter.yanked_label(), "*(yanked)*");
1811    }
1812
1813    #[test]
1814    fn test_format_version_replacing_for_default_delegates_to_format_version_replacing() {
1815        let formatter = MockFormatter;
1816        let dep = MockDep {
1817            name: pkg("test-pkg"),
1818            version_req: VersionReq::new("1.0.0"),
1819            version_range: Range::default(),
1820            name_range: Range::default(),
1821        };
1822        assert_eq!(
1823            formatter.format_version_replacing_for(&dep, &ConcreteVersion::new("1.2.3"), "1.0.0"),
1824            formatter.format_version_replacing(&ConcreteVersion::new("1.2.3"), "1.0.0")
1825        );
1826    }
1827
1828    #[test]
1829    fn test_ecosystem_formatter_version_satisfies() {
1830        let formatter = MockFormatter;
1831
1832        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2.3"));
1833
1834        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "^1.2"));
1835        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "~1.2"));
1836
1837        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1"));
1838        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2"));
1839
1840        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "2.0.0"));
1841        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.3"));
1842    }
1843
1844    #[test]
1845    fn test_ecosystem_formatter_custom_normalize() {
1846        struct PyPIFormatter;
1847
1848        impl PackageNaming for PyPIFormatter {
1849            fn normalize_package_name(&self, name: &PackageName) -> String {
1850                name.as_str().to_lowercase().replace('-', "_")
1851            }
1852        }
1853
1854        impl PackageRendering for PyPIFormatter {
1855            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1856                format!(
1857                    ">={},<{}",
1858                    version,
1859                    version.as_str().split('.').next().unwrap_or("0")
1860                )
1861            }
1862
1863            fn package_url(&self, name: &PackageName) -> String {
1864                format!("https://pypi.org/project/{}", name)
1865            }
1866        }
1867
1868        impl RequirementResolution for PyPIFormatter {}
1869
1870        impl DiagnosticMessages for PyPIFormatter {}
1871
1872        impl DiagnosticPolicy for PyPIFormatter {}
1873
1874        impl SourcePolicy for PyPIFormatter {}
1875
1876        impl OsvNaming for PyPIFormatter {}
1877
1878        let formatter = PyPIFormatter;
1879        assert_eq!(
1880            formatter.normalize_package_name(&pkg("Test-Package")),
1881            "test_package"
1882        );
1883        assert_eq!(
1884            formatter.format_version_for_text_edit(&ConcreteVersion::new("1.2.3")),
1885            ">=1.2.3,<1"
1886        );
1887        assert_eq!(
1888            formatter.package_url(&pkg("requests")),
1889            "https://pypi.org/project/requests"
1890        );
1891    }
1892}