Skip to main content

deps_pypi/
ecosystem.rs

1//! PyPI ecosystem implementation for deps-lsp.
2//!
3//! This module implements the `Ecosystem` trait for Python projects,
4//! providing LSP functionality for `pyproject.toml` files and for
5//! `requirements.txt`/`constraints.txt` files (pip's requirements file
6//! format).
7
8use std::any::Any;
9use std::sync::Arc;
10use tower_lsp_server::ls_types::{CompletionItem, DocumentLink, Position, Range, Uri};
11
12use deps_core::{
13    Ecosystem, ParseResult as ParseResultTrait, Registry, Result, completion::Completions,
14    lsp_helpers::EcosystemFormatter,
15};
16
17use crate::formatter::PypiFormatter;
18use crate::parser::PypiParser;
19use crate::registry::PypiRegistry;
20
21/// Which manifest shape a URI's basename identifies, so `parse_manifest` can
22/// dispatch to the right parser method and report the right `file_type` on
23/// error.
24#[derive(Clone, Copy)]
25enum PypiManifestKind {
26    PyProject,
27    Requirements,
28}
29
30impl PypiManifestKind {
31    fn from_uri(uri: &Uri) -> Self {
32        let basename = uri.path().as_str().rsplit('/').next().unwrap_or_default();
33        if basename == "pyproject.toml" {
34            Self::PyProject
35        } else {
36            Self::Requirements
37        }
38    }
39
40    const fn file_type(self) -> &'static str {
41        match self {
42            Self::PyProject => "pyproject.toml",
43            Self::Requirements => "requirements.txt",
44        }
45    }
46}
47
48/// PyPI ecosystem implementation.
49///
50/// Provides LSP functionality for pyproject.toml files, including:
51/// - Dependency parsing with position tracking
52/// - Version information from PyPI registry
53/// - Inlay hints for latest versions
54/// - Hover tooltips with package metadata
55/// - Code actions for version updates
56/// - Diagnostics for unknown/yanked packages
57pub struct PypiEcosystem {
58    registry: Arc<PypiRegistry>,
59    parser: PypiParser,
60    formatter: PypiFormatter,
61    /// The reachability policy every `parse_manifest` call threads through to
62    /// [`PypiParser::parse_content_with_policy`]/[`PypiParser::parse_requirements_with_policy`]
63    /// (spec FR-008). Defaulted to `RegistryAccessPolicy::default()` by [`Self::new`]; set
64    /// explicitly by [`Self::with_policy`] so `crate::lib::register_ecosystems`-equivalent
65    /// wiring in `deps-lsp` can share one process-wide `Arc<RegistryAccessPolicy>` handle
66    /// with `ServerState`, mirroring `deps_npm::ecosystem::NpmEcosystem`'s identical `context`
67    /// field.
68    policy: Arc<deps_core::net_policy::RegistryAccessPolicy>,
69}
70
71impl PypiEcosystem {
72    /// Creates a new PyPI ecosystem with the given HTTP cache, using a fresh, default
73    /// (`public_only`) [`deps_core::net_policy::RegistryAccessPolicy`] private to this
74    /// ecosystem instance. Production use goes through [`Self::with_policy`] instead.
75    pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
76        Self::with_policy(
77            Arc::new(PypiRegistry::new(cache)),
78            Arc::new(deps_core::net_policy::RegistryAccessPolicy::default()),
79        )
80    }
81
82    /// Creates a new PyPI ecosystem around an existing [`PypiRegistry`] instance, sharing
83    /// `policy`'s live reachability setting — the production constructor, used by
84    /// `deps-lsp`'s `register_ecosystems` so `initialize`/`workspace/didChangeConfiguration`
85    /// updating the same `Arc<RegistryAccessPolicy>` takes effect immediately, with no need
86    /// to reconstruct the ecosystem.
87    #[must_use]
88    pub fn with_policy(
89        registry: Arc<PypiRegistry>,
90        policy: Arc<deps_core::net_policy::RegistryAccessPolicy>,
91    ) -> Self {
92        Self {
93            registry,
94            parser: PypiParser::new(),
95            formatter: PypiFormatter,
96            policy,
97        }
98    }
99
100    async fn complete_package_names(&self, prefix: &str, range: Range) -> Vec<CompletionItem> {
101        let mut items = deps_core::completion::complete_package_names_generic(
102            self.registry.as_ref(),
103            prefix,
104            20,
105            range,
106        )
107        .await;
108
109        // #419 S2: the search index matches on the PEP 503 *normalized* name
110        // (`zope.int` typed -> normalized to `zope-int` -> `zope-interface`
111        // found), but `build_package_completion` sets `filter_text` to that same
112        // normalized name — and the LSP client re-filters every returned item
113        // against the RAW TEXT the user actually typed, independent of what the
114        // server matched on. `zope.int` is not a subsequence of `zope-interface`,
115        // so an editor like VS Code silently drops a result the server correctly
116        // found. Rewriting `filter_text` to the raw, as-typed `prefix` makes every
117        // returned item trivially self-matching against what's already on screen.
118        // Safe to do unconditionally (rather than something that must also match
119        // characters not yet typed): this method's caller always reports
120        // `is_incomplete: true` for the `PackageName` context (see
121        // `generate_completions`), so the client re-queries — and receives a
122        // fresh `filter_text` — on the very next keystroke rather than continuing
123        // to filter this same list locally.
124        for item in &mut items {
125            item.filter_text = Some(prefix.to_string());
126        }
127
128        items
129    }
130
131    /// True when `uri`'s basename matches neither an exact
132    /// [`Ecosystem::manifest_filenames`] entry nor a
133    /// [`Ecosystem::manifest_patterns`] glob — i.e. this file was routed to PyPI
134    /// purely via the [`Ecosystem::manifest_directory_patterns`] fallback
135    /// (`requirements/*.txt`, matched by `EcosystemRegistry::get_for_uri` on
136    /// directory name alone), not a primary basename match.
137    ///
138    /// Recomputes the same basename check `EcosystemRegistry::get_for_uri`
139    /// already performed, from the single source of truth (`self`'s own
140    /// `manifest_filenames`/`manifest_patterns`) rather than threading a
141    /// match-kind flag through `parse_manifest`'s signature — cheap, and
142    /// correct as long as this ecosystem's directory-pattern fallback is only
143    /// ever reached after both basename stages miss (true by construction in
144    /// [`deps_core::EcosystemRegistry::get_for_uri`]).
145    fn matched_only_via_directory_pattern(&self, uri: &Uri) -> bool {
146        let basename = uri.path().as_str().rsplit('/').next().unwrap_or_default();
147        if self.manifest_filenames().contains(&basename) {
148            return false;
149        }
150        !self.manifest_patterns().iter().any(|pattern| {
151            deps_core::ecosystem_registry::manifest_pattern_matches(basename, pattern)
152        })
153    }
154
155    /// Completes version requirements for the dependency at `position`, resolved by cursor
156    /// position rather than by name (issue #593) — delegates to
157    /// [`deps_core::completion::complete_versions_at_position`], which mirrors
158    /// `deps_gitlab_ci::ecosystem::GitLabCiEcosystem::generate_completions`'s reference
159    /// pattern. Position-based lookup also fixes a residual gap in the old name-based
160    /// routing (validator finding #1): two dependencies sharing one `PackageName` but
161    /// resolving to different sources used to collapse into an ambiguous, empty result for
162    /// both occurrences, even though the cursor position unambiguously identifies which one
163    /// the user is editing.
164    ///
165    /// An unresolvable source (`CustomRegistry`, or anything
166    /// [`SourcePolicy::can_resolve_source`](deps_core::lsp_helpers::SourcePolicy::can_resolve_source)
167    /// rejects) still offers no completions rather than risking a private package name lookup
168    /// against `pypi.org` — the shared helper's gate is what keeps `Registry::get_versions_from`'s
169    /// permissive routing of an unrecognized source to the default public client (matching
170    /// hover/diagnostics/code-actions' identical gate) from leaking one for completions too.
171    async fn complete_versions(
172        &self,
173        parse_result: &dyn ParseResultTrait,
174        position: Position,
175        prefix: &str,
176        freshness: deps_core::FreshnessSettings,
177    ) -> Vec<CompletionItem> {
178        deps_core::completion::complete_versions_at_position(
179            self.registry.as_ref(),
180            &self.formatter,
181            parse_result,
182            position,
183            prefix,
184            &['>', '<', '=', '~', '!'],
185            freshness,
186        )
187        .await
188    }
189}
190
191impl deps_core::ecosystem::private::Sealed for PypiEcosystem {}
192
193impl Ecosystem for PypiEcosystem {
194    fn id(&self) -> &'static str {
195        "pypi"
196    }
197
198    fn display_name(&self) -> &'static str {
199        "PyPI (Python)"
200    }
201
202    fn manifest_filenames(&self) -> &[&'static str] {
203        &["pyproject.toml"]
204    }
205
206    fn manifest_patterns(&self) -> &[&'static str] {
207        &[
208            "requirements*.txt",
209            "*-requirements.txt",
210            "*.requirements.txt",
211            "constraints*.txt",
212        ]
213    }
214
215    fn manifest_directory_patterns(&self) -> &[(&'static str, &'static str)] {
216        &[("requirements", ".txt")]
217    }
218
219    fn lockfile_filenames(&self) -> &[&'static str] {
220        &["poetry.lock", "uv.lock"]
221    }
222
223    fn parse_manifest<'a>(
224        &'a self,
225        content: &'a str,
226        uri: &'a Uri,
227    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
228        Box::pin(async move {
229            let kind = PypiManifestKind::from_uri(uri);
230            let result = match kind {
231                PypiManifestKind::PyProject => {
232                    self.parser
233                        .parse_content_with_policy(content, uri, &self.policy)
234                }
235                PypiManifestKind::Requirements => {
236                    let require_strong_signal = self.matched_only_via_directory_pattern(uri);
237                    self.parser.parse_requirements_with_policy(
238                        content,
239                        uri,
240                        require_strong_signal,
241                        &self.policy,
242                    )
243                }
244            }
245            .map_err(|e| deps_core::DepsError::ParseError {
246                file_type: kind.file_type().into(),
247                source: Box::new(e),
248            })?;
249            // Registers every chain this file's --index-url/--extra-index-url/Poetry-source/
250            // uv-index declarations imply (spec FR-002/003/005/007/013) into the shared
251            // root registry — the only point where a per-document resolution and the
252            // long-lived `PypiRegistry` this ecosystem shares across every document ever
253            // meet. A file with no such declaration contributes an empty `resolved_chains`
254            // (US-004), so this loop is a no-op for the overwhelming majority of projects.
255            // `register_chain` handles both shapes uniformly: a primary/extras chain and a
256            // single-hop named-source registration (Poetry `source =`/uv `index =`) are both
257            // just `ResolvedChain`s whose hop-tree construction only differs in length — a
258            // named source's `key` is already its own literal URL (`ResolvedChain::named_source`),
259            // so there is no separate `register_named_source` call needed here.
260            for chain in &result.resolved_chains {
261                PypiRegistry::register_chain(&self.registry, chain);
262            }
263            Ok(Box::new(result) as Box<dyn ParseResultTrait>)
264        })
265    }
266
267    fn registry(&self) -> Arc<dyn Registry> {
268        self.registry.clone() as Arc<dyn Registry>
269    }
270
271    fn lockfile_provider(&self) -> Option<Arc<dyn deps_core::lockfile::LockFileProvider>> {
272        Some(Arc::new(crate::lockfile::PypiLockParser))
273    }
274
275    fn formatter(&self) -> &dyn EcosystemFormatter {
276        &self.formatter
277    }
278
279    fn generate_completions<'a>(
280        &'a self,
281        parse_result: &'a dyn ParseResultTrait,
282        position: Position,
283        content: &'a str,
284        freshness: deps_core::FreshnessSettings,
285    ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
286        Box::pin(async move {
287            use deps_core::completion::{CompletionContext, detect_completion_context};
288
289            // Warms the package-name search index lazily on the first completion
290            // request in this manifest, not just on a package-name completion —
291            // so a version completion (or any other completion in the file)
292            // usually has the index ready before the user starts typing a new
293            // package name. Cheap to call unconditionally: a no-op once the
294            // index is ready or while a prior failed build is within backoff.
295            self.registry.warm_search_index();
296
297            let context = detect_completion_context(parse_result, position, content);
298
299            match context {
300                // Serves unranked, alphabetically-truncated prefix matches from
301                // `PypiRegistry::search`'s local index (issue #419): the client must
302                // re-query as the user keeps typing rather than filter its existing
303                // (possibly cold-start-empty) list — so this is the one context that
304                // reports `is_incomplete: true`, regardless of whether it currently
305                // has any items (#427).
306                CompletionContext::PackageName { prefix, range } => Completions {
307                    items: self.complete_package_names(&prefix, range).await,
308                    is_incomplete: true,
309                },
310                CompletionContext::Version { prefix, .. } => self
311                    .complete_versions(parse_result, position, &prefix, freshness)
312                    .await
313                    .into(),
314                CompletionContext::Feature { .. } | CompletionContext::None => {
315                    Completions::default()
316                }
317            }
318        })
319    }
320
321    fn package_search_is_incomplete(&self) -> bool {
322        // Same unranked, alphabetically-truncated index `PackageName`'s
323        // `is_incomplete: true` above covers for the primary path — see
324        // `Ecosystem::package_search_is_incomplete`'s doc for why this only
325        // matters for `deps-lsp`'s context-less fallback paths.
326        true
327    }
328
329    fn generate_document_links(
330        &self,
331        parse_result: &dyn ParseResultTrait,
332        uri: &Uri,
333    ) -> Vec<DocumentLink> {
334        let Some(result) = parse_result
335            .as_any()
336            .downcast_ref::<crate::parser::ParseResult>()
337        else {
338            return Vec::new();
339        };
340        if result.document_links.is_empty() {
341            return Vec::new();
342        }
343
344        let Some(base_dir) = uri
345            .to_file_path()
346            .and_then(|p| p.parent().map(std::path::Path::to_path_buf))
347        else {
348            return Vec::new();
349        };
350
351        result
352            .document_links
353            .iter()
354            .filter_map(|link| {
355                // Only local relative/absolute filesystem paths are resolved — an
356                // already-absolute URL (`http://...`) is left alone rather than
357                // mangled by joining it onto a filesystem directory.
358                if link.target.contains("://") {
359                    return None;
360                }
361                if !is_safe_document_link_target(&link.target) {
362                    deps_core::lsp_helpers::warn_rejected_value(
363                        "is_safe_document_link_target",
364                        "pypi requirements -r/-c document link target",
365                        &link.target,
366                    );
367                    return None;
368                }
369                let target_path = base_dir.join(&link.target);
370                let target_uri = Uri::from_file_path(&target_path)?;
371                // Tooltip is derived from the URI's own round-tripped path, not
372                // `target_path` directly: on Windows, `Uri::to_file_path` builds its
373                // string with forward slashes while `Path::join` inserts the native
374                // `\` separator, so the two disagree on separator style for the same
375                // path — always resolve through the URI to keep them in sync.
376                let tooltip = target_uri.to_file_path()?.display().to_string();
377                Some(DocumentLink {
378                    range: link.range,
379                    target: Some(target_uri),
380                    // Resolved absolute path, shown on hover — a bidi/format-character
381                    // trick in the rendered line (rejected above) or a merely confusing
382                    // relative path still leaves the user a way to verify the real
383                    // target before clicking.
384                    tooltip: Some(tooltip),
385                    data: None,
386                })
387            })
388            .collect()
389    }
390
391    fn as_any(&self) -> &dyn Any {
392        self
393    }
394}
395
396/// Whether `target` is safe to resolve into a clickable `DocumentLink`.
397///
398/// Rejects every ASCII control character (`char::is_control()`, the same gate
399/// [`deps_core::lsp_helpers::escape_markdown`] uses) plus the Unicode
400/// bidi/format characters that gate alone misses — RLO/LRO-family overrides
401/// (U+202A-U+202E, U+2066-U+2069), explicit directional marks (U+200E/U+200F),
402/// zero-width joiners/spaces (U+200B-U+200D, U+2060, U+FEFF), and the
403/// JS/JSON5 line terminators U+2028/U+2029. Without this, a target like
404/// `"safe.txt\u{202E}txt.evil"` renders right-to-left in the editor (reading
405/// as an innocuous `.txt` file) while the link actually opens `.evil` —
406/// link-target spoofing, not merely a cosmetic issue, since the resolved URI
407/// is exactly what the user's click opens.
408fn is_safe_document_link_target(target: &str) -> bool {
409    !target.is_empty()
410        && target.chars().all(|c| {
411            !c.is_control()
412                && !matches!(c,
413                    '\u{200B}'..='\u{200F}'
414                        | '\u{202A}'..='\u{202E}'
415                        | '\u{2060}'
416                        | '\u{2066}'..='\u{2069}'
417                        | '\u{2028}'
418                        | '\u{2029}'
419                        | '\u{FEFF}'
420                )
421        })
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use deps_core::{EcosystemConfig, VersionData, parser::DependencySource};
428    use std::assert_matches;
429    use std::collections::HashMap;
430
431    fn pkg(s: &str) -> deps_core::PackageName {
432        deps_core::PackageName::new(s)
433    }
434
435    #[test]
436    fn test_ecosystem_id() {
437        let cache = Arc::new(deps_core::HttpCache::new());
438        let ecosystem = PypiEcosystem::new(cache);
439        assert_eq!(ecosystem.id(), "pypi");
440    }
441
442    #[test]
443    fn test_ecosystem_manifest_patterns() {
444        let cache = Arc::new(deps_core::HttpCache::new());
445        let ecosystem = PypiEcosystem::new(cache);
446        assert_eq!(
447            ecosystem.manifest_patterns(),
448            &[
449                "requirements*.txt",
450                "*-requirements.txt",
451                "*.requirements.txt",
452                "constraints*.txt",
453            ]
454        );
455    }
456
457    #[test]
458    fn test_matched_only_via_directory_pattern() {
459        let cache = Arc::new(deps_core::HttpCache::new());
460        let ecosystem = PypiEcosystem::new(cache);
461
462        // Directory-pattern-only: no basename filename/pattern match.
463        let uri = deps_core::test_util::test_uri("/project/requirements/base.txt");
464        assert!(ecosystem.matched_only_via_directory_pattern(&uri));
465
466        // Basename matches (exact `requirements.txt` and a `*-requirements.txt`
467        // pattern respectively), even from inside a `requirements/` directory.
468        for path in [
469            "/project/requirements.txt",
470            "/project/requirements/dev-requirements.txt",
471        ] {
472            let uri = deps_core::test_util::test_uri(path);
473            assert!(
474                !ecosystem.matched_only_via_directory_pattern(&uri),
475                "{path} should be a basename match, not directory-pattern-only"
476            );
477        }
478    }
479
480    #[tokio::test]
481    async fn test_parse_manifest_directory_pattern_only_applies_strict_gate() {
482        // #452 S6 end-to-end: a `requirements/` docs file with only prose-shaped
483        // bare names must not survive the ratio gate through `parse_manifest`.
484        let cache = Arc::new(deps_core::HttpCache::new());
485        let ecosystem = PypiEcosystem::new(cache);
486        let uri = deps_core::test_util::test_uri("/project/requirements/base.txt");
487
488        let result = ecosystem
489            .parse_manifest(
490                "Introduction\n\nScope\n\nThis document defines the requirements.\n",
491                &uri,
492            )
493            .await
494            .unwrap();
495
496        assert!(result.dependencies().is_empty());
497    }
498
499    #[tokio::test]
500    async fn test_parse_manifest_requirements_txt_uri_yields_dependencies() {
501        let cache = Arc::new(deps_core::HttpCache::new());
502        let ecosystem = PypiEcosystem::new(cache);
503        let uri = deps_core::test_util::test_uri("/test/requirements.txt");
504
505        let result = ecosystem
506            .parse_manifest("requests==2.31.0\nflask>=3.0\n", &uri)
507            .await
508            .unwrap();
509
510        assert_eq!(result.dependencies().len(), 2);
511    }
512
513    #[tokio::test]
514    async fn test_generate_document_links_resolves_relative_target() {
515        let cache = Arc::new(deps_core::HttpCache::new());
516        let ecosystem = PypiEcosystem::new(cache);
517        let uri = deps_core::test_util::test_uri("/project/requirements.txt");
518
519        let parse_result = ecosystem
520            .parse_manifest("-r base.txt\n", &uri)
521            .await
522            .unwrap();
523
524        let links = ecosystem.generate_document_links(parse_result.as_ref(), &uri);
525        assert_eq!(links.len(), 1);
526        let target = links[0].target.as_ref().unwrap();
527        assert!(target.path().as_str().ends_with("/project/base.txt"));
528        assert_eq!(
529            links[0].tooltip.as_deref(),
530            target.to_file_path().unwrap().to_str()
531        );
532    }
533
534    #[tokio::test]
535    async fn test_generate_document_links_rejects_bidi_override_target() {
536        // #452 S2 (security): a bidi override in the target text could make the
537        // rendered requirements.txt line read as an innocuous filename while the
538        // link itself opens something else entirely — link-target spoofing.
539        let cache = Arc::new(deps_core::HttpCache::new());
540        let ecosystem = PypiEcosystem::new(cache);
541        let uri = deps_core::test_util::test_uri("/project/requirements.txt");
542
543        let parse_result = ecosystem
544            .parse_manifest("-r safe.txt\u{202E}txt.evil\n", &uri)
545            .await
546            .unwrap();
547
548        let links = ecosystem.generate_document_links(parse_result.as_ref(), &uri);
549        assert!(links.is_empty());
550    }
551
552    #[test]
553    fn test_is_safe_document_link_target_rejects_invisible_unicode() {
554        for bad in [
555            "safe.txt\u{202E}txt.evil",
556            "a\u{200B}b.txt",
557            "a\u{2028}b.txt",
558            "a\u{FEFF}b.txt",
559            "a\nb.txt",
560        ] {
561            assert!(
562                !is_safe_document_link_target(bad),
563                "expected {bad:?} to be rejected"
564            );
565        }
566    }
567
568    #[test]
569    fn test_is_safe_document_link_target_accepts_normal_paths() {
570        for good in [
571            "base.txt",
572            "../shared/constraints.txt",
573            "dev-requirements.txt",
574        ] {
575            assert!(is_safe_document_link_target(good));
576        }
577    }
578
579    #[tokio::test]
580    async fn test_parse_manifest_pyproject_toml_unchanged() {
581        let cache = Arc::new(deps_core::HttpCache::new());
582        let ecosystem = PypiEcosystem::new(cache);
583        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
584
585        let result = ecosystem
586            .parse_manifest("[project]\ndependencies = [\"requests>=2.0.0\"]\n", &uri)
587            .await
588            .unwrap();
589
590        assert_eq!(result.dependencies().len(), 1);
591    }
592
593    #[test]
594    fn test_manifest_kind_file_type_reflects_uri() {
595        let requirements_uri = deps_core::test_util::test_uri("/test/requirements.txt");
596        assert_eq!(
597            PypiManifestKind::from_uri(&requirements_uri).file_type(),
598            "requirements.txt"
599        );
600
601        let pyproject_uri = deps_core::test_util::test_uri("/test/pyproject.toml");
602        assert_eq!(
603            PypiManifestKind::from_uri(&pyproject_uri).file_type(),
604            "pyproject.toml"
605        );
606    }
607
608    #[tokio::test]
609    async fn test_parse_manifest_pyproject_toml_invalid_reports_pyproject_file_type() {
610        let cache = Arc::new(deps_core::HttpCache::new());
611        let ecosystem = PypiEcosystem::new(cache);
612        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
613
614        let result = ecosystem
615            .parse_manifest("[project\nname = invalid", &uri)
616            .await;
617
618        let Err(err) = result else {
619            panic!("expected a parse error");
620        };
621        assert_matches!(
622            err,
623            deps_core::DepsError::ParseError { file_type, .. } if file_type == "pyproject.toml"
624        );
625    }
626
627    #[test]
628    fn test_ecosystem_display_name() {
629        let cache = Arc::new(deps_core::HttpCache::new());
630        let ecosystem = PypiEcosystem::new(cache);
631        assert_eq!(ecosystem.display_name(), "PyPI (Python)");
632    }
633
634    #[test]
635    fn test_ecosystem_manifest_filenames() {
636        let cache = Arc::new(deps_core::HttpCache::new());
637        let ecosystem = PypiEcosystem::new(cache);
638        assert_eq!(ecosystem.manifest_filenames(), &["pyproject.toml"]);
639    }
640
641    #[test]
642    fn test_ecosystem_lockfile_filenames() {
643        let cache = Arc::new(deps_core::HttpCache::new());
644        let ecosystem = PypiEcosystem::new(cache);
645        assert_eq!(ecosystem.lockfile_filenames(), &["poetry.lock", "uv.lock"]);
646    }
647
648    #[test]
649    fn test_as_any() {
650        let cache = Arc::new(deps_core::HttpCache::new());
651        let ecosystem = PypiEcosystem::new(cache);
652
653        let any = ecosystem.as_any();
654        assert!(any.is::<PypiEcosystem>());
655    }
656
657    #[tokio::test]
658    async fn test_package_name_completion_context_has_real_range() {
659        // Regression test for #232: the textEdit range for a package-name completion
660        // must be the real name token span, not the (0,0)-(0,0) placeholder.
661        let cache = Arc::new(deps_core::HttpCache::new());
662        let ecosystem = PypiEcosystem::new(cache);
663        let content = "[dependency-groups]\ndev = [\"pytest>=8.0\", \"mypy>=1.0\"]\n";
664        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
665
666        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
667        let position = Position::new(1, 11); // cursor after "pyt" in "pytest"
668
669        let context = deps_core::completion::detect_completion_context(
670            parse_result.as_ref(),
671            position,
672            content,
673        );
674
675        match context {
676            deps_core::completion::CompletionContext::PackageName { prefix, range } => {
677                assert_eq!(prefix, "pyt");
678                assert_ne!(range, Range::default());
679                assert_eq!(range, Range::new(Position::new(1, 8), Position::new(1, 14)));
680            }
681            other => panic!("Expected PackageName context, got {other:?}"),
682        }
683    }
684
685    /// #427 coverage gap: the actual bugfix — `generate_completions`'s
686    /// `PackageName` arm reporting `is_incomplete: true` for the truncated
687    /// package-name search index — was previously only verified via a hand-rolled
688    /// mock `Ecosystem` in `deps-lsp`'s handler tests, never on the real
689    /// `PypiEcosystem` dispatch. Same fixture/cursor as
690    /// `test_package_name_completion_context_has_real_range`, but calling
691    /// `generate_completions` directly (not `detect_completion_context`) so a
692    /// reversed condition or wrong-arm bug in the real dispatch would be caught.
693    #[tokio::test]
694    async fn test_generate_completions_package_name_context_is_incomplete() {
695        let cache = Arc::new(deps_core::HttpCache::new());
696        let ecosystem = PypiEcosystem::new(cache);
697        let content = "[dependency-groups]\ndev = [\"pytest>=8.0\", \"mypy>=1.0\"]\n";
698        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
699
700        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
701        let position = Position::new(1, 11); // cursor after "pyt" in "pytest"
702
703        let completions = ecosystem
704            .generate_completions(
705                parse_result.as_ref(),
706                position,
707                content,
708                deps_core::FreshnessSettings::default(),
709            )
710            .await;
711
712        assert!(
713            completions.is_incomplete,
714            "PackageName context must report is_incomplete: true, even with zero \
715             items on a cold-start index"
716        );
717    }
718
719    #[tokio::test]
720    async fn test_complete_package_names_minimum_prefix() {
721        let cache = Arc::new(deps_core::HttpCache::new());
722        let ecosystem = PypiEcosystem::new(cache);
723
724        // Less than 2 characters should return empty
725        let results = ecosystem
726            .complete_package_names("d", Range::default())
727            .await;
728        assert!(results.is_empty());
729
730        // Empty prefix should return empty
731        let results = ecosystem.complete_package_names("", Range::default()).await;
732        assert!(results.is_empty());
733    }
734
735    /// Builds a `PypiEcosystem` whose registry's search index is pointed at a
736    /// mock server rather than the real `pypi.org/simple/`, so package-name
737    /// completion (issue #419) can be exercised network-free.
738    fn ecosystem_with_index_url(
739        cache: Arc<deps_core::HttpCache>,
740        index_url: String,
741    ) -> PypiEcosystem {
742        PypiEcosystem {
743            registry: Arc::new(PypiRegistry::with_index_url(cache, index_url)),
744            parser: PypiParser::new(),
745            formatter: PypiFormatter,
746            policy: Arc::new(deps_core::net_policy::RegistryAccessPolicy::default()),
747        }
748    }
749
750    /// Polls `probe` until it returns a non-empty result or `attempts` polls have
751    /// elapsed, returning the last (possibly still empty) result. Used to wait out
752    /// the background index build without a flaky fixed sleep.
753    async fn poll_until_nonempty<F, Fut>(mut probe: F, attempts: u32) -> Vec<CompletionItem>
754    where
755        F: FnMut() -> Fut,
756        Fut: std::future::Future<Output = Vec<CompletionItem>>,
757    {
758        for _ in 0..attempts {
759            let results = probe().await;
760            if !results.is_empty() {
761                return results;
762            }
763            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
764        }
765        probe().await
766    }
767
768    /// #419 regression: `test_complete_package_names_real_search` used to be
769    /// `#[ignore]`d (real network access, so never ran in CI). Rewritten
770    /// network-free against a mocked Simple API index: the first call is a cold
771    /// start (empty, index not built yet) and a later call — once the background
772    /// build finishes — finds `requests`.
773    #[tokio::test]
774    async fn test_complete_package_names_uses_index() {
775        let mut server = mockito::Server::new_async().await;
776        let _mock = server
777            .mock("GET", "/simple/")
778            .with_status(200)
779            .with_body(crate::search::sample_index_body(&["requests"]))
780            .create_async()
781            .await;
782
783        let cache = Arc::new(deps_core::HttpCache::new());
784        let index_url = format!("{}/simple/", server.url());
785        let ecosystem = ecosystem_with_index_url(cache, index_url);
786
787        let cold_start = ecosystem
788            .complete_package_names("reque", Range::default())
789            .await;
790        assert!(
791            cold_start.is_empty(),
792            "cold start must not block on the download"
793        );
794
795        let results = poll_until_nonempty(
796            || ecosystem.complete_package_names("reque", Range::default()),
797            100,
798        )
799        .await;
800        assert!(!results.is_empty());
801        assert!(results.iter().any(|r| r.label == "requests"));
802    }
803
804    /// #419 S2 regression: a query using a different separator than the index's
805    /// normalized form (`zope.int`, PEP 503-normalized to `zope-int` server-side)
806    /// must come back with `filter_text` set to the *raw typed* prefix, not the
807    /// normalized `label`/`insert_text` — otherwise an LSP client's local
808    /// re-filtering (`zope.int` is not a subsequence of `zope-interface`) would
809    /// silently drop a result the server correctly matched.
810    #[tokio::test]
811    async fn test_complete_package_names_filter_text_matches_raw_typed_prefix() {
812        let mut server = mockito::Server::new_async().await;
813        let _mock = server
814            .mock("GET", "/simple/")
815            .with_status(200)
816            .with_body(crate::search::sample_index_body(&["zope-interface"]))
817            .create_async()
818            .await;
819
820        let cache = Arc::new(deps_core::HttpCache::new());
821        let index_url = format!("{}/simple/", server.url());
822        let ecosystem = ecosystem_with_index_url(cache, index_url);
823
824        let results = poll_until_nonempty(
825            || ecosystem.complete_package_names("zope.int", Range::default()),
826            100,
827        )
828        .await;
829
830        let item = results
831            .iter()
832            .find(|r| r.label == "zope-interface")
833            .expect("zope-interface should be found via separator-normalized search");
834        assert_eq!(
835            item.filter_text,
836            Some("zope.int".to_string()),
837            "filter_text must be the raw typed prefix, not the normalized label"
838        );
839    }
840
841    /// #419 §4.6/Q2 regression: a *version* completion request (not a
842    /// package-name one) inside a Python manifest must warm the search index —
843    /// `PypiEcosystem::generate_completions` calls `warm_search_index` before
844    /// dispatching on completion context — and repeated requests must still
845    /// produce exactly one index-build fetch (single-flight + build-once).
846    #[tokio::test]
847    async fn test_version_completion_triggers_exactly_one_index_build_attempt() {
848        let mut server = mockito::Server::new_async().await;
849        let mock = server
850            .mock("GET", "/simple/")
851            .with_status(200)
852            .with_body(crate::search::sample_index_body(&["requests"]))
853            .expect(1)
854            .create_async()
855            .await;
856
857        let cache = Arc::new(deps_core::HttpCache::new());
858        let index_url = format!("{}/simple/", server.url());
859        let ecosystem = ecosystem_with_index_url(cache, index_url);
860
861        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
862        let content = "[project]\ndependencies = [\"requests>=2.0\"]\n";
863        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
864
865        // Locate a cursor position that `detect_completion_context` actually
866        // resolves to a Version context, rather than hand-computing a column
867        // offset that would silently drift if the fixture line changes.
868        let version_line = content.lines().nth(1).unwrap();
869        let version_position = (0..=version_line.len() as u32)
870            .map(|character| tower_lsp_server::ls_types::Position::new(1, character))
871            .find(|&position| {
872                matches!(
873                    deps_core::completion::detect_completion_context(
874                        parse_result.as_ref(),
875                        position,
876                        content,
877                    ),
878                    deps_core::completion::CompletionContext::Version { .. }
879                )
880            })
881            .expect("fixture line must contain a Version completion context");
882
883        let mut last_completions = None;
884        for _ in 0..3 {
885            last_completions = Some(
886                ecosystem
887                    .generate_completions(
888                        parse_result.as_ref(),
889                        version_position,
890                        content,
891                        deps_core::FreshnessSettings::default(),
892                    )
893                    .await,
894            );
895        }
896        assert!(
897            !last_completions
898                .expect("loop ran at least once")
899                .is_incomplete,
900            "a Version completion context is always exhaustive, unlike PackageName's \
901             truncated index search"
902        );
903
904        // Give the (single-flight) background build a chance to finish.
905        let ready = poll_until_nonempty(
906            || ecosystem.complete_package_names("reque", Range::default()),
907            100,
908        )
909        .await;
910        assert!(
911            ready.iter().any(|r| r.label == "requests"),
912            "index should be ready and contain requests after warming"
913        );
914        mock.assert_async().await;
915    }
916
917    #[tokio::test]
918    #[ignore] // Requires network access
919    async fn test_complete_versions_real() {
920        let cache = Arc::new(deps_core::HttpCache::new());
921        let ecosystem = PypiEcosystem::new(cache);
922        let parse_result = parse_result_with_dependency("requests", DependencySource::Registry);
923
924        let results = ecosystem
925            .complete_versions(
926                &parse_result,
927                DEP_POSITION,
928                "2.",
929                deps_core::FreshnessSettings::default(),
930            )
931            .await;
932        assert!(!results.is_empty());
933        assert!(results.iter().all(|r| r.label.starts_with("2.")));
934    }
935
936    #[tokio::test]
937    #[ignore] // Requires network access
938    async fn test_complete_versions_with_operator() {
939        let cache = Arc::new(deps_core::HttpCache::new());
940        let ecosystem = PypiEcosystem::new(cache);
941        let parse_result = parse_result_with_dependency("requests", DependencySource::Registry);
942
943        let results = ecosystem
944            .complete_versions(
945                &parse_result,
946                DEP_POSITION,
947                ">=2.",
948                deps_core::FreshnessSettings::default(),
949            )
950            .await;
951        assert!(!results.is_empty());
952        assert!(results.iter().all(|r| r.label.starts_with("2.")));
953    }
954
955    #[tokio::test]
956    async fn test_complete_versions_unknown_package() {
957        let cache = Arc::new(deps_core::HttpCache::new());
958        let ecosystem = PypiEcosystem::new(cache);
959        let parse_result = parse_result_with_dependency(
960            "this-package-does-not-exist-12345",
961            DependencySource::Registry,
962        );
963
964        // Unknown package should return empty (graceful degradation)
965        let results = ecosystem
966            .complete_versions(
967                &parse_result,
968                DEP_POSITION,
969                "1.0",
970                deps_core::FreshnessSettings::default(),
971            )
972            .await;
973        assert!(results.is_empty());
974    }
975
976    /// The single dependency `parse_result_with_dependency` constructs always has its
977    /// `version_range` start here — every call site below passes this as `complete_versions`'
978    /// `position` argument so the position-based lookup finds it.
979    const DEP_POSITION: Position = Position {
980        line: 0,
981        character: 0,
982    };
983
984    /// A minimal single-dependency `ParseResult`, used to exercise `complete_versions`'
985    /// per-source routing (issue #593) — the dependency's `version_range` starts at
986    /// [`DEP_POSITION`].
987    fn parse_result_with_dependency(
988        name: &str,
989        source: DependencySource,
990    ) -> crate::parser::ParseResult {
991        use tower_lsp_server::ls_types::Range;
992        crate::parser::ParseResult {
993            dependencies: vec![crate::types::PypiDependency {
994                name: pkg(name),
995                name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
996                version_req: None,
997                version_range: Some(Range::new(DEP_POSITION, Position::new(0, 10))),
998                extras: Vec::new(),
999                extras_range: None,
1000                markers: None,
1001                markers_range: None,
1002                section: crate::types::PypiDependencySection::Requirements,
1003                source,
1004            }],
1005            workspace_root: None,
1006            uri: deps_core::test_util::test_uri("/test/requirements.txt"),
1007            document_links: Vec::new(),
1008            resolved_chains: Vec::new(),
1009        }
1010    }
1011
1012    /// Validator finding #1 (security H1 + impl-critic C1): a version-completion request for
1013    /// an `AlternateRegistry`-sourced dependency must route through the resolved chain, never
1014    /// through the root `Public`-tier client — fetching from the root would send the
1015    /// dependency's name to `pypi.org` on every keystroke.
1016    #[tokio::test]
1017    async fn test_complete_versions_alternate_registry_routes_through_chain() {
1018        let mut alt_server = mockito::Server::new_async().await;
1019        let alt_mock = alt_server
1020            .mock("GET", "/simple/mypkg/")
1021            .with_status(200)
1022            .with_body(r#"{"versions": ["1.0.0", "2.0.0"], "files": []}"#)
1023            .create_async()
1024            .await;
1025
1026        let cache = Arc::new(deps_core::HttpCache::new());
1027        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
1028        let root = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
1029        let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
1030            deps_core::net_policy::WorkspaceRegistryAccess::All,
1031        ));
1032        let ecosystem = PypiEcosystem::with_policy(Arc::clone(&root), policy);
1033
1034        let base = crate::config::PypiIndexUrl::new(
1035            &format!("{}/simple", alt_server.url()),
1036            &deps_core::net_policy::RegistryAccessPolicy::new(
1037                deps_core::net_policy::WorkspaceRegistryAccess::All,
1038            ),
1039        )
1040        .unwrap();
1041        let chain = crate::config::ResolvedChain {
1042            key: "test-alt-chain".to_string(),
1043            hops: vec![base],
1044            implicit_public_fallback: false,
1045        };
1046        PypiRegistry::register_chain(&root, &chain);
1047
1048        let source = DependencySource::AlternateRegistry {
1049            index: chain.key.clone(),
1050            mirrors_crates_io: false,
1051        };
1052        let parse_result = parse_result_with_dependency("mypkg", source);
1053
1054        let results = ecosystem
1055            .complete_versions(
1056                &parse_result,
1057                DEP_POSITION,
1058                "",
1059                deps_core::FreshnessSettings::default(),
1060            )
1061            .await;
1062        assert!(
1063            !results.is_empty(),
1064            "expected version completions fetched from the alternate index"
1065        );
1066        alt_mock.assert_async().await;
1067    }
1068
1069    /// Validator finding #1: a `CustomRegistry`-sourced dependency (an invalid/blocked
1070    /// explicit index, US-005) must offer no version completions at all — never falling back
1071    /// to `pypi.org`, matching hover/diagnostics' existing fail-closed behavior for it
1072    /// (SC-004).
1073    #[tokio::test]
1074    async fn test_complete_versions_custom_registry_offers_nothing() {
1075        let cache = Arc::new(deps_core::HttpCache::new());
1076        let ecosystem = PypiEcosystem::new(cache);
1077
1078        let source = DependencySource::CustomRegistry {
1079            url: "not-a-valid-url".to_string(),
1080        };
1081        let parse_result = parse_result_with_dependency("mypkg", source);
1082
1083        let results = ecosystem
1084            .complete_versions(
1085                &parse_result,
1086                DEP_POSITION,
1087                "",
1088                deps_core::FreshnessSettings::default(),
1089            )
1090            .await;
1091        assert!(results.is_empty());
1092    }
1093
1094    /// Validator finding #1: an `AlternateRegistry` source whose chain was never registered
1095    /// (or whose registration is now stale) offers nothing rather than falling back to the
1096    /// root client.
1097    #[tokio::test]
1098    async fn test_complete_versions_unregistered_alternate_offers_nothing() {
1099        let cache = Arc::new(deps_core::HttpCache::new());
1100        let ecosystem = PypiEcosystem::new(cache);
1101
1102        let source = DependencySource::AlternateRegistry {
1103            index: "pypi-chain:never-registered".to_string(),
1104            mirrors_crates_io: false,
1105        };
1106        let parse_result = parse_result_with_dependency("mypkg", source);
1107
1108        let results = ecosystem
1109            .complete_versions(
1110                &parse_result,
1111                DEP_POSITION,
1112                "",
1113                deps_core::FreshnessSettings::default(),
1114            )
1115            .await;
1116        assert!(results.is_empty());
1117    }
1118
1119    /// Issue #593: two dependencies sharing one `PackageName` but resolving to different
1120    /// sources no longer collapse into the old name-based "offer nothing for either" result
1121    /// — cursor position now identifies exactly one dependency, so each occurrence routes
1122    /// independently through its own source.
1123    #[tokio::test]
1124    async fn test_complete_versions_same_name_different_sources_routes_by_position() {
1125        let cache = Arc::new(deps_core::HttpCache::new());
1126        let ecosystem = PypiEcosystem::new(cache);
1127
1128        let mut registry_dep =
1129            parse_result_with_dependency("shared-name", DependencySource::Registry)
1130                .dependencies
1131                .remove(0);
1132        registry_dep.name_range = Range::new(Position::new(0, 0), Position::new(0, 0));
1133        registry_dep.version_range = Some(Range::new(Position::new(0, 0), Position::new(0, 10)));
1134
1135        let mut alternate_dep = parse_result_with_dependency(
1136            "shared-name",
1137            DependencySource::AlternateRegistry {
1138                index: "pypi-chain:never-registered".to_string(),
1139                mirrors_crates_io: false,
1140            },
1141        )
1142        .dependencies
1143        .remove(0);
1144        alternate_dep.name_range = Range::new(Position::new(1, 0), Position::new(1, 0));
1145        alternate_dep.version_range = Some(Range::new(Position::new(1, 0), Position::new(1, 10)));
1146        let alternate_position = alternate_dep.version_range.unwrap().start;
1147
1148        let parse_result = crate::parser::ParseResult {
1149            dependencies: vec![registry_dep, alternate_dep],
1150            workspace_root: None,
1151            uri: deps_core::test_util::test_uri("/test/requirements.txt"),
1152            document_links: Vec::new(),
1153            resolved_chains: Vec::new(),
1154        };
1155
1156        // The alternate occurrence resolves deterministically without network: its chain was
1157        // never registered, so the fetch fails closed with `PackageNotFound` before any HTTP
1158        // call — proving its own source, not the co-occurring `Registry`-sourced entry, drove
1159        // the routing.
1160        let results = ecosystem
1161            .complete_versions(
1162                &parse_result,
1163                alternate_position,
1164                "1",
1165                deps_core::FreshnessSettings::default(),
1166            )
1167            .await;
1168        assert!(
1169            results.is_empty(),
1170            "unregistered alternate chain must offer no completions"
1171        );
1172    }
1173
1174    #[tokio::test]
1175    async fn test_complete_package_names_special_characters() {
1176        let cache = Arc::new(deps_core::HttpCache::new());
1177        let ecosystem = PypiEcosystem::new(cache);
1178
1179        // Package names with hyphens and underscores should work
1180        let results = ecosystem
1181            .complete_package_names("scikit-le", Range::default())
1182            .await;
1183        // Should not panic or error
1184        assert!(results.is_empty() || !results.is_empty());
1185    }
1186
1187    #[tokio::test]
1188    async fn test_complete_package_names_max_length() {
1189        let cache = Arc::new(deps_core::HttpCache::new());
1190        let ecosystem = PypiEcosystem::new(cache);
1191
1192        // Prefix longer than 200 chars should return empty (security)
1193        let long_prefix = "a".repeat(201);
1194        let results = ecosystem
1195            .complete_package_names(&long_prefix, Range::default())
1196            .await;
1197        assert!(results.is_empty());
1198
1199        // Exactly 100 chars should work
1200        let max_prefix = "a".repeat(100);
1201        let results = ecosystem
1202            .complete_package_names(&max_prefix, Range::default())
1203            .await;
1204        // Should not panic, but may return empty (no matches)
1205        assert!(results.is_empty() || !results.is_empty());
1206    }
1207
1208    #[tokio::test]
1209    #[ignore] // Requires network access
1210    async fn test_complete_versions_limit_20() {
1211        let cache = Arc::new(deps_core::HttpCache::new());
1212        let ecosystem = PypiEcosystem::new(cache);
1213
1214        // Test that we respect the 20 result limit
1215        let parse_result = parse_result_with_dependency("requests", DependencySource::Registry);
1216        let results = ecosystem
1217            .complete_versions(
1218                &parse_result,
1219                DEP_POSITION,
1220                "2",
1221                deps_core::FreshnessSettings::default(),
1222            )
1223            .await;
1224        assert!(results.len() <= 20);
1225    }
1226
1227    #[tokio::test]
1228    #[ignore] // Requires network access
1229    async fn test_complete_package_names_special_chars_real() {
1230        let cache = Arc::new(deps_core::HttpCache::new());
1231        let ecosystem = PypiEcosystem::new(cache);
1232
1233        // Real packages with special characters
1234        let results = ecosystem
1235            .complete_package_names("scikit-le", Range::default())
1236            .await;
1237        assert!(!results.is_empty() || results.is_empty()); // May or may not have results
1238    }
1239
1240    #[tokio::test]
1241    async fn test_parse_manifest_valid_content() {
1242        let cache = Arc::new(deps_core::HttpCache::new());
1243        let ecosystem = PypiEcosystem::new(cache);
1244        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1245
1246        let content = r#"[project]
1247name = "test"
1248dependencies = ["requests>=2.0.0"]
1249"#;
1250
1251        let result = ecosystem.parse_manifest(content, &uri).await;
1252        assert!(result.is_ok());
1253
1254        let parse_result = result.unwrap();
1255        assert!(!parse_result.dependencies().is_empty());
1256    }
1257
1258    #[tokio::test]
1259    async fn test_parse_manifest_invalid_toml() {
1260        let cache = Arc::new(deps_core::HttpCache::new());
1261        let ecosystem = PypiEcosystem::new(cache);
1262        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1263
1264        let invalid_content = "[project\nname = invalid";
1265
1266        let result = ecosystem.parse_manifest(invalid_content, &uri).await;
1267        assert!(result.is_err());
1268    }
1269
1270    #[tokio::test]
1271    async fn test_parse_manifest_empty_dependencies() {
1272        let cache = Arc::new(deps_core::HttpCache::new());
1273        let ecosystem = PypiEcosystem::new(cache);
1274        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1275
1276        let content = r#"[project]
1277name = "test"
1278dependencies = []
1279"#;
1280
1281        let result = ecosystem.parse_manifest(content, &uri).await;
1282        assert!(result.is_ok());
1283
1284        let parse_result = result.unwrap();
1285        assert!(parse_result.dependencies().is_empty());
1286    }
1287
1288    #[tokio::test]
1289    async fn test_registry_returns_arc() {
1290        let cache = Arc::new(deps_core::HttpCache::new());
1291        let ecosystem = PypiEcosystem::new(cache);
1292
1293        let registry = ecosystem.registry();
1294        assert!(Arc::strong_count(&registry) >= 1);
1295    }
1296
1297    #[tokio::test]
1298    async fn test_lockfile_provider_returns_some() {
1299        let cache = Arc::new(deps_core::HttpCache::new());
1300        let ecosystem = PypiEcosystem::new(cache);
1301
1302        let provider = ecosystem.lockfile_provider();
1303        assert!(provider.is_some());
1304    }
1305
1306    #[tokio::test]
1307    async fn test_generate_inlay_hints_empty_dependencies() {
1308        let cache = Arc::new(deps_core::HttpCache::new());
1309        let ecosystem = PypiEcosystem::new(cache);
1310        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1311
1312        let content = r"[project]
1313dependencies = []
1314";
1315
1316        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1317        let cached_versions = HashMap::new();
1318        let resolved_versions = HashMap::new();
1319        let config = EcosystemConfig::default();
1320
1321        let hints = ecosystem
1322            .generate_inlay_hints(
1323                parse_result.as_ref(),
1324                VersionData::new(&cached_versions, &resolved_versions),
1325                deps_core::LoadingState::Loaded,
1326                &config,
1327            )
1328            .await;
1329
1330        assert!(hints.is_empty());
1331    }
1332
1333    #[tokio::test]
1334    async fn test_generate_completions_no_context() {
1335        let cache = Arc::new(deps_core::HttpCache::new());
1336        let ecosystem = PypiEcosystem::new(cache);
1337        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1338
1339        let content = r#"[project]
1340name = "test"
1341"#;
1342
1343        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1344        let position = Position {
1345            line: 0,
1346            character: 0,
1347        };
1348
1349        let completions = ecosystem
1350            .generate_completions(
1351                parse_result.as_ref(),
1352                position,
1353                content,
1354                deps_core::FreshnessSettings::default(),
1355            )
1356            .await;
1357
1358        assert!(completions.items.is_empty());
1359        assert!(!completions.is_incomplete);
1360    }
1361
1362    #[tokio::test]
1363    async fn test_generate_completions_feature_context_returns_empty() {
1364        let cache = Arc::new(deps_core::HttpCache::new());
1365        let ecosystem = PypiEcosystem::new(cache);
1366
1367        // PyPI doesn't have features, so this should always return empty
1368        // Even if we detect a feature context (which shouldn't happen for PyPI)
1369        // This tests the Feature branch in generate_completions
1370        let content = r#"[project]
1371dependencies = ["requests"]
1372"#;
1373        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1374        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1375
1376        // Test with any position - feature context should return empty
1377        let position = Position {
1378            line: 1,
1379            character: 20,
1380        };
1381
1382        let completions = ecosystem
1383            .generate_completions(
1384                parse_result.as_ref(),
1385                position,
1386                content,
1387                deps_core::FreshnessSettings::default(),
1388            )
1389            .await;
1390
1391        // Should not crash, returns empty or package/version completions
1392        assert!(completions.items.is_empty() || !completions.items.is_empty());
1393    }
1394
1395    #[tokio::test]
1396    async fn test_generate_hover_no_dependency_at_position() {
1397        let cache = Arc::new(deps_core::HttpCache::new());
1398        let ecosystem = PypiEcosystem::new(cache);
1399        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1400
1401        let content = r#"[project]
1402name = "test"
1403"#;
1404
1405        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1406        let position = Position {
1407            line: 0,
1408            character: 0,
1409        };
1410        let cached_versions = HashMap::new();
1411        let resolved_versions = HashMap::new();
1412
1413        let hover = ecosystem
1414            .generate_hover(
1415                parse_result.as_ref(),
1416                position,
1417                VersionData::new(&cached_versions, &resolved_versions),
1418                deps_core::FreshnessSettings::default(),
1419            )
1420            .await;
1421
1422        assert!(hover.is_none());
1423    }
1424
1425    #[tokio::test]
1426    async fn test_generate_code_actions_no_actions() {
1427        let cache = Arc::new(deps_core::HttpCache::new());
1428        let ecosystem = PypiEcosystem::new(cache);
1429        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1430
1431        let content = r#"[project]
1432name = "test"
1433"#;
1434
1435        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1436        let position = Position {
1437            line: 0,
1438            character: 0,
1439        };
1440        let cached_versions = HashMap::new();
1441        let resolved_versions = HashMap::new();
1442        let actions = ecosystem
1443            .generate_code_actions(
1444                parse_result.as_ref(),
1445                position,
1446                &uri,
1447                VersionData::new(&cached_versions, &resolved_versions),
1448                content,
1449            )
1450            .await;
1451
1452        assert!(actions.is_empty());
1453    }
1454
1455    #[tokio::test]
1456    async fn test_generate_diagnostics_no_dependencies() {
1457        let cache = Arc::new(deps_core::HttpCache::new());
1458        let ecosystem = PypiEcosystem::new(cache);
1459        let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1460
1461        let content = r#"[project]
1462name = "test"
1463dependencies = []
1464"#;
1465
1466        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1467        let cached_versions = HashMap::new();
1468        let resolved_versions = HashMap::new();
1469
1470        let diagnostics = ecosystem
1471            .generate_diagnostics(
1472                parse_result.as_ref(),
1473                VersionData::new(&cached_versions, &resolved_versions),
1474                &uri,
1475                deps_core::FreshnessSettings::default(),
1476                deps_core::DiagnosticSeverities::default(),
1477            )
1478            .await;
1479
1480        assert!(diagnostics.is_empty());
1481    }
1482
1483    #[tokio::test]
1484    async fn test_complete_versions_empty_prefix() {
1485        let cache = Arc::new(deps_core::HttpCache::new());
1486        let ecosystem = PypiEcosystem::new(cache);
1487        let parse_result =
1488            parse_result_with_dependency("nonexistent-package", DependencySource::Registry);
1489
1490        // Empty prefix should show non-yanked versions (up to 20)
1491        let results = ecosystem
1492            .complete_versions(
1493                &parse_result,
1494                DEP_POSITION,
1495                "",
1496                deps_core::FreshnessSettings::default(),
1497            )
1498            .await;
1499        // Should not panic, returns empty for unknown package
1500        assert!(results.is_empty());
1501    }
1502
1503    #[tokio::test]
1504    async fn test_complete_versions_with_tilde_operator() {
1505        let cache = Arc::new(deps_core::HttpCache::new());
1506        let ecosystem = PypiEcosystem::new(cache);
1507        let parse_result =
1508            parse_result_with_dependency("nonexistent-pkg", DependencySource::Registry);
1509
1510        // Test PEP 440 operators are stripped correctly
1511        let results = ecosystem
1512            .complete_versions(
1513                &parse_result,
1514                DEP_POSITION,
1515                "~=2.0",
1516                deps_core::FreshnessSettings::default(),
1517            )
1518            .await;
1519        assert!(results.is_empty());
1520    }
1521
1522    #[tokio::test]
1523    async fn test_complete_versions_with_not_equal_operator() {
1524        let cache = Arc::new(deps_core::HttpCache::new());
1525        let ecosystem = PypiEcosystem::new(cache);
1526        let parse_result =
1527            parse_result_with_dependency("nonexistent-pkg", DependencySource::Registry);
1528
1529        // Test != operator stripping
1530        let results = ecosystem
1531            .complete_versions(
1532                &parse_result,
1533                DEP_POSITION,
1534                "!=2.0",
1535                deps_core::FreshnessSettings::default(),
1536            )
1537            .await;
1538        assert!(results.is_empty());
1539    }
1540
1541    /// End-to-end regression for #212: a dotted package name declared as a
1542    /// Poetry table key must resolve against its `poetry.lock` entry. Unlike
1543    /// a PEP 621 fixture (which already worked before the fix, since
1544    /// `pep508_rs::PackageName` normalizes at construction), the Poetry
1545    /// table-key path takes the name verbatim from the TOML key — this is
1546    /// the actual bug #212 fixes.
1547    mod poetry_lockfile_regression_tests {
1548        use super::*;
1549        use crate::lockfile::PypiLockParser;
1550        use deps_core::PackageName;
1551        use deps_core::lockfile::LockFileProvider;
1552
1553        /// A registry mock returning an empty (but `Ok`) version list —
1554        /// `generate_hover` requires a successful registry call before it
1555        /// reaches the `versions.resolved`-driven "Current" line, but the
1556        /// content of that call is irrelevant to this regression.
1557        struct EmptyOkRegistry;
1558
1559        impl deps_core::Registry for EmptyOkRegistry {
1560            fn get_versions<'a>(
1561                &'a self,
1562                _name: &'a PackageName,
1563            ) -> deps_core::ecosystem::BoxFuture<
1564                'a,
1565                deps_core::error::Result<Vec<Box<dyn deps_core::Version>>>,
1566            > {
1567                Box::pin(async move { Ok(Vec::new()) })
1568            }
1569
1570            fn get_latest_matching<'a>(
1571                &'a self,
1572                _name: &'a PackageName,
1573                _req: &'a deps_core::VersionReq,
1574            ) -> deps_core::ecosystem::BoxFuture<
1575                'a,
1576                deps_core::error::Result<Option<Box<dyn deps_core::Version>>>,
1577            > {
1578                Box::pin(async move { Ok(None) })
1579            }
1580
1581            fn search<'a>(
1582                &'a self,
1583                _query: &'a str,
1584                _limit: usize,
1585            ) -> deps_core::ecosystem::BoxFuture<
1586                'a,
1587                deps_core::error::Result<Vec<Box<dyn deps_core::Metadata>>>,
1588            > {
1589                Box::pin(async move { Ok(Vec::new()) })
1590            }
1591
1592            fn as_any(&self) -> &dyn std::any::Any {
1593                self
1594            }
1595        }
1596
1597        #[tokio::test]
1598        async fn test_poetry_table_key_dotted_name_resolves_against_lockfile() {
1599            let toml = "[tool.poetry.dependencies]\n\"zope.interface\" = \"^5.0\"\n";
1600            let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1601            let parser = PypiParser::new();
1602            let parse_result = parser.parse_content(toml, &uri).unwrap();
1603
1604            // The raw TOML key is taken verbatim — unnormalized — confirming
1605            // this fixture actually exercises the Poetry table-key path
1606            // rather than a PEP 508 string path (which already normalizes).
1607            assert_eq!(parse_result.dependencies[0].name, "zope.interface");
1608            let dep_position = parse_result.dependencies[0].name_range.start;
1609
1610            // Real poetry.lock/uv.lock files store the canonical hyphenated
1611            // form on write, never the dotted source name — a dotted lockfile
1612            // fixture here would make the headline assertions pass even
1613            // before the #212 fix (only an intermediate `contains_key`
1614            // mechanics check would fail), so this must be hyphenated to
1615            // actually discriminate pre/post fix.
1616            let lockfile_content = "[[package]]\nname = \"zope-interface\"\nversion = \"5.2.0\"\n";
1617            let temp_dir = tempfile::tempdir().unwrap();
1618            let lockfile_path = temp_dir.path().join("poetry.lock");
1619            std::fs::write(&lockfile_path, lockfile_content).unwrap();
1620
1621            let lock_parser = PypiLockParser;
1622            let resolved_packages = lock_parser.parse_lockfile(&lockfile_path).await.unwrap();
1623            let resolved_versions: HashMap<PackageName, deps_core::ConcreteVersion> =
1624                resolved_packages
1625                    .iter()
1626                    .map(|(name, pkg)| {
1627                        (PackageName::new(name.as_str()), pkg.version.clone().into())
1628                    })
1629                    .collect();
1630            // Canonical PEP 503 normalization: both the lockfile key and the
1631            // formatter-normalized manifest name land on "zope-interface".
1632            assert!(resolved_versions.contains_key("zope-interface"));
1633
1634            let cached_versions: HashMap<PackageName, deps_core::PackageVersions> = HashMap::new();
1635            let versions = VersionData::new(&cached_versions, &resolved_versions);
1636            let formatter = PypiFormatter;
1637
1638            let hover = deps_core::lsp_helpers::generate_hover(
1639                &parse_result,
1640                dep_position,
1641                versions,
1642                &EmptyOkRegistry,
1643                &formatter,
1644                deps_core::FreshnessSettings::default(),
1645                deps_core::PublishTime::now(),
1646            )
1647            .await
1648            .expect("hover should be produced for a dependency at its name position");
1649
1650            let markdown = match hover.contents {
1651                tower_lsp_server::ls_types::HoverContents::Markup(m) => m.value,
1652                _ => panic!("expected Markup hover contents"),
1653            };
1654            assert!(
1655                markdown.contains("**Current**") && markdown.contains("5.2.0"),
1656                "hover should render the resolved lock file version: {markdown}"
1657            );
1658
1659            let diagnostics = deps_core::lsp_helpers::generate_diagnostics_from_cache(
1660                &parse_result,
1661                versions,
1662                &formatter,
1663                parse_result.uri(),
1664                deps_core::FreshnessSettings::default(),
1665                deps_core::DiagnosticSeverities::default(),
1666                deps_core::PublishTime::now(),
1667            );
1668            assert!(
1669                diagnostics
1670                    .iter()
1671                    .all(|d| !d.message.contains("Unknown package")),
1672                "no 'Unknown package' diagnostic should be emitted: {diagnostics:?}"
1673            );
1674        }
1675    }
1676
1677    // --- T010: ecosystem wiring — parse_manifest registers resolved chains ---
1678
1679    fn parsed_dependencies(
1680        result: &dyn ParseResultTrait,
1681    ) -> Vec<(String, deps_core::parser::DependencySource)> {
1682        result
1683            .dependencies()
1684            .into_iter()
1685            .map(|d| (d.name().to_string(), d.source()))
1686            .collect()
1687    }
1688
1689    /// A file with no index declaration anywhere never constructs more than an empty
1690    /// `PypiIndexConfig` and never calls `register_chain` (US-004).
1691    #[tokio::test]
1692    async fn test_parse_manifest_no_declaration_registers_nothing() {
1693        let cache = Arc::new(deps_core::HttpCache::new());
1694        let ecosystem = PypiEcosystem::new(cache);
1695        let uri = deps_core::test_util::test_uri("/project/requirements.txt");
1696
1697        let result = ecosystem
1698            .parse_manifest("requests==2.31.0\n", &uri)
1699            .await
1700            .unwrap();
1701
1702        for (_, source) in parsed_dependencies(result.as_ref()) {
1703            assert_eq!(source, DependencySource::Registry);
1704        }
1705    }
1706
1707    /// **Test A (S6, mixed chain)**: a chain with one policy-blocked hop and one valid hop
1708    /// still resolves via the valid hop — a blocked extra must not break a chain that still
1709    /// has a working remaining hop. Uses `public_only` (Global primary, RFC1918 extra) rather
1710    /// than a literal `off` policy: `Off::allows` is unconditionally `false` for every host
1711    /// class, so under a real `off` policy the "explicit valid primary" in this scenario
1712    /// would *also* be blocked (there is no host class `off` ever allows) — `public_only`
1713    /// exercises the identical code path (one hop blocked by policy, one hop not) without
1714    /// that contradiction, and is the policy under which this mixed-chain scenario is
1715    /// actually reachable in production.
1716    #[tokio::test]
1717    async fn test_parse_manifest_blocked_extra_does_not_break_chain_with_valid_primary() {
1718        let cache = Arc::new(deps_core::HttpCache::new());
1719        let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
1720            deps_core::net_policy::WorkspaceRegistryAccess::PublicOnly,
1721        ));
1722        let registry = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
1723        let ecosystem = PypiEcosystem::with_policy(Arc::clone(&registry), policy);
1724        let uri = deps_core::test_util::test_uri("/project/requirements.txt");
1725
1726        let content = "--index-url https://pypi.mycorp.example/simple\n\
1727                        --extra-index-url https://10.0.0.5/simple\n\
1728                        requests==2.31.0\n";
1729        let result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1730
1731        let deps = parsed_dependencies(result.as_ref());
1732        let (_, source) = deps.iter().find(|(name, _)| name == "requests").unwrap();
1733        let DependencySource::AlternateRegistry { index, .. } = source else {
1734            panic!("expected AlternateRegistry, got {source:?}");
1735        };
1736        // The registered chain must actually be reachable through the root registry this
1737        // ecosystem shares — proving `parse_manifest` really called `register_chain`, not
1738        // just that `resolve_source_for` computed the right `DependencySource` in isolation.
1739        assert!(registry.alternate_client(index).is_some());
1740    }
1741
1742    /// **Test B (N5, zero-hop)**: `workspace_registries = off`, a file declaring only
1743    /// `--extra-index-url` entries (no explicit primary) — every extra is blocked, the chain
1744    /// has zero hops, and every plain dependency in the file degrades to plain
1745    /// `DependencySource::Registry` (not per-dependency fail-closed, not a structurally-broken
1746    /// empty `AlternateRegistry`).
1747    #[tokio::test]
1748    async fn test_parse_manifest_all_extras_blocked_degrades_to_plain_registry() {
1749        let cache = Arc::new(deps_core::HttpCache::new());
1750        let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
1751            deps_core::net_policy::WorkspaceRegistryAccess::Off,
1752        ));
1753        let registry = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
1754        let ecosystem = PypiEcosystem::with_policy(Arc::clone(&registry), policy);
1755        let uri = deps_core::test_util::test_uri("/project/requirements.txt");
1756
1757        let content = "--extra-index-url https://extra.example/simple\nrequests==2.31.0\n";
1758        let result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1759
1760        let deps = parsed_dependencies(result.as_ref());
1761        assert_eq!(deps.len(), 1);
1762        assert_eq!(deps[0].1, DependencySource::Registry);
1763
1764        // Nothing was registered — a zero-hop config has no chain to register at all.
1765        let downcast = result
1766            .as_any()
1767            .downcast_ref::<crate::parser::ParseResult>()
1768            .unwrap();
1769        assert!(downcast.resolved_chains.is_empty());
1770    }
1771}