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.
5
6use async_trait::async_trait;
7use std::any::Any;
8use std::collections::HashMap;
9use std::sync::Arc;
10use tower_lsp_server::ls_types::{
11    CodeAction, CompletionItem, Diagnostic, Hover, InlayHint, Position, Uri,
12};
13
14use deps_core::{
15    Ecosystem, EcosystemConfig, ParseResult as ParseResultTrait, Registry, Result, lsp_helpers,
16};
17
18use crate::formatter::PypiFormatter;
19use crate::parser::PypiParser;
20use crate::registry::PypiRegistry;
21
22/// PyPI ecosystem implementation.
23///
24/// Provides LSP functionality for pyproject.toml files, including:
25/// - Dependency parsing with position tracking
26/// - Version information from PyPI registry
27/// - Inlay hints for latest versions
28/// - Hover tooltips with package metadata
29/// - Code actions for version updates
30/// - Diagnostics for unknown/yanked packages
31pub struct PypiEcosystem {
32    registry: Arc<PypiRegistry>,
33    parser: PypiParser,
34    formatter: PypiFormatter,
35}
36
37impl PypiEcosystem {
38    /// Creates a new PyPI ecosystem with the given HTTP cache.
39    pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
40        Self {
41            registry: Arc::new(PypiRegistry::new(cache)),
42            parser: PypiParser::new(),
43            formatter: PypiFormatter,
44        }
45    }
46
47    /// Completes package names by searching the PyPI registry.
48    ///
49    /// Requires at least 2 characters for search. Returns up to 20 results.
50    async fn complete_package_names(&self, prefix: &str) -> Vec<CompletionItem> {
51        use deps_core::completion::build_package_completion;
52
53        // Security: reject too short or too long prefixes
54        if prefix.len() < 2 || prefix.len() > 100 {
55            return vec![];
56        }
57
58        // Search registry (limit to 20 results)
59        let results = match self.registry.search(prefix, 20).await {
60            Ok(r) => r,
61            Err(e) => {
62                tracing::warn!("Package search failed for '{}': {}", prefix, e);
63                return vec![];
64            }
65        };
66
67        // Use dummy range - completion will be inserted at cursor position
68        let insert_range = tower_lsp_server::ls_types::Range::default();
69
70        results
71            .into_iter()
72            .map(|metadata| {
73                let boxed: Box<dyn deps_core::Metadata> = Box::new(metadata);
74                build_package_completion(boxed.as_ref(), insert_range)
75            })
76            .collect()
77    }
78
79    async fn complete_versions(&self, package_name: &str, prefix: &str) -> Vec<CompletionItem> {
80        deps_core::completion::complete_versions_generic(
81            self.registry.as_ref(),
82            package_name,
83            prefix,
84            &['>', '<', '=', '~', '!'],
85        )
86        .await
87    }
88}
89
90#[async_trait]
91impl Ecosystem for PypiEcosystem {
92    fn id(&self) -> &'static str {
93        "pypi"
94    }
95
96    fn display_name(&self) -> &'static str {
97        "PyPI (Python)"
98    }
99
100    fn manifest_filenames(&self) -> &[&'static str] {
101        &["pyproject.toml"]
102    }
103
104    fn lockfile_filenames(&self) -> &[&'static str] {
105        &["poetry.lock", "uv.lock"]
106    }
107
108    async fn parse_manifest(&self, content: &str, uri: &Uri) -> Result<Box<dyn ParseResultTrait>> {
109        let result = self.parser.parse_content(content, uri).map_err(|e| {
110            deps_core::DepsError::ParseError {
111                file_type: "pyproject.toml".into(),
112                source: Box::new(e),
113            }
114        })?;
115        Ok(Box::new(result))
116    }
117
118    fn registry(&self) -> Arc<dyn Registry> {
119        self.registry.clone() as Arc<dyn Registry>
120    }
121
122    fn lockfile_provider(&self) -> Option<Arc<dyn deps_core::lockfile::LockFileProvider>> {
123        Some(Arc::new(crate::lockfile::PypiLockParser))
124    }
125
126    async fn generate_inlay_hints(
127        &self,
128        parse_result: &dyn ParseResultTrait,
129        cached_versions: &HashMap<String, String>,
130        resolved_versions: &HashMap<String, String>,
131        loading_state: deps_core::LoadingState,
132        config: &EcosystemConfig,
133    ) -> Vec<InlayHint> {
134        lsp_helpers::generate_inlay_hints(
135            parse_result,
136            cached_versions,
137            resolved_versions,
138            loading_state,
139            config,
140            &self.formatter,
141        )
142    }
143
144    async fn generate_hover(
145        &self,
146        parse_result: &dyn ParseResultTrait,
147        position: Position,
148        cached_versions: &HashMap<String, String>,
149        resolved_versions: &HashMap<String, String>,
150    ) -> Option<Hover> {
151        lsp_helpers::generate_hover(
152            parse_result,
153            position,
154            cached_versions,
155            resolved_versions,
156            self.registry.as_ref(),
157            &self.formatter,
158        )
159        .await
160    }
161
162    async fn generate_code_actions(
163        &self,
164        parse_result: &dyn ParseResultTrait,
165        position: Position,
166        _cached_versions: &HashMap<String, String>,
167        uri: &Uri,
168    ) -> Vec<CodeAction> {
169        lsp_helpers::generate_code_actions(
170            parse_result,
171            position,
172            uri,
173            self.registry.as_ref(),
174            &self.formatter,
175        )
176        .await
177    }
178
179    async fn generate_diagnostics(
180        &self,
181        parse_result: &dyn ParseResultTrait,
182        cached_versions: &HashMap<String, String>,
183        resolved_versions: &HashMap<String, String>,
184        _uri: &Uri,
185    ) -> Vec<Diagnostic> {
186        lsp_helpers::generate_diagnostics_from_cache(
187            parse_result,
188            cached_versions,
189            resolved_versions,
190            &self.formatter,
191        )
192    }
193
194    async fn generate_completions(
195        &self,
196        parse_result: &dyn ParseResultTrait,
197        position: Position,
198        content: &str,
199    ) -> Vec<CompletionItem> {
200        use deps_core::completion::{CompletionContext, detect_completion_context};
201
202        let context = detect_completion_context(parse_result, position, content);
203
204        match context {
205            CompletionContext::PackageName { prefix } => self.complete_package_names(&prefix).await,
206            CompletionContext::Version {
207                package_name,
208                prefix,
209            } => self.complete_versions(&package_name, &prefix).await,
210            // PyPI doesn't have features like Cargo
211            CompletionContext::Feature { .. } => vec![],
212            CompletionContext::None => vec![],
213        }
214    }
215
216    fn as_any(&self) -> &dyn Any {
217        self
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_ecosystem_id() {
227        let cache = Arc::new(deps_core::HttpCache::new());
228        let ecosystem = PypiEcosystem::new(cache);
229        assert_eq!(ecosystem.id(), "pypi");
230    }
231
232    #[test]
233    fn test_ecosystem_display_name() {
234        let cache = Arc::new(deps_core::HttpCache::new());
235        let ecosystem = PypiEcosystem::new(cache);
236        assert_eq!(ecosystem.display_name(), "PyPI (Python)");
237    }
238
239    #[test]
240    fn test_ecosystem_manifest_filenames() {
241        let cache = Arc::new(deps_core::HttpCache::new());
242        let ecosystem = PypiEcosystem::new(cache);
243        assert_eq!(ecosystem.manifest_filenames(), &["pyproject.toml"]);
244    }
245
246    #[test]
247    fn test_ecosystem_lockfile_filenames() {
248        let cache = Arc::new(deps_core::HttpCache::new());
249        let ecosystem = PypiEcosystem::new(cache);
250        assert_eq!(ecosystem.lockfile_filenames(), &["poetry.lock", "uv.lock"]);
251    }
252
253    #[test]
254    fn test_as_any() {
255        let cache = Arc::new(deps_core::HttpCache::new());
256        let ecosystem = PypiEcosystem::new(cache);
257
258        let any = ecosystem.as_any();
259        assert!(any.is::<PypiEcosystem>());
260    }
261
262    #[tokio::test]
263    async fn test_complete_package_names_minimum_prefix() {
264        let cache = Arc::new(deps_core::HttpCache::new());
265        let ecosystem = PypiEcosystem::new(cache);
266
267        // Less than 2 characters should return empty
268        let results = ecosystem.complete_package_names("d").await;
269        assert!(results.is_empty());
270
271        // Empty prefix should return empty
272        let results = ecosystem.complete_package_names("").await;
273        assert!(results.is_empty());
274    }
275
276    #[tokio::test]
277    #[ignore] // Requires network access
278    async fn test_complete_package_names_real_search() {
279        let cache = Arc::new(deps_core::HttpCache::new());
280        let ecosystem = PypiEcosystem::new(cache);
281
282        let results = ecosystem.complete_package_names("reque").await;
283        assert!(!results.is_empty());
284        assert!(results.iter().any(|r| r.label == "requests"));
285    }
286
287    #[tokio::test]
288    #[ignore] // Requires network access
289    async fn test_complete_versions_real() {
290        let cache = Arc::new(deps_core::HttpCache::new());
291        let ecosystem = PypiEcosystem::new(cache);
292
293        let results = ecosystem.complete_versions("requests", "2.").await;
294        assert!(!results.is_empty());
295        assert!(results.iter().all(|r| r.label.starts_with("2.")));
296    }
297
298    #[tokio::test]
299    #[ignore] // Requires network access
300    async fn test_complete_versions_with_operator() {
301        let cache = Arc::new(deps_core::HttpCache::new());
302        let ecosystem = PypiEcosystem::new(cache);
303
304        let results = ecosystem.complete_versions("requests", ">=2.").await;
305        assert!(!results.is_empty());
306        assert!(results.iter().all(|r| r.label.starts_with("2.")));
307    }
308
309    #[tokio::test]
310    async fn test_complete_versions_unknown_package() {
311        let cache = Arc::new(deps_core::HttpCache::new());
312        let ecosystem = PypiEcosystem::new(cache);
313
314        // Unknown package should return empty (graceful degradation)
315        let results = ecosystem
316            .complete_versions("this-package-does-not-exist-12345", "1.0")
317            .await;
318        assert!(results.is_empty());
319    }
320
321    #[tokio::test]
322    async fn test_complete_package_names_special_characters() {
323        let cache = Arc::new(deps_core::HttpCache::new());
324        let ecosystem = PypiEcosystem::new(cache);
325
326        // Package names with hyphens and underscores should work
327        let results = ecosystem.complete_package_names("scikit-le").await;
328        // Should not panic or error
329        assert!(results.is_empty() || !results.is_empty());
330    }
331
332    #[tokio::test]
333    async fn test_complete_package_names_max_length() {
334        let cache = Arc::new(deps_core::HttpCache::new());
335        let ecosystem = PypiEcosystem::new(cache);
336
337        // Prefix longer than 100 chars should return empty (security)
338        let long_prefix = "a".repeat(101);
339        let results = ecosystem.complete_package_names(&long_prefix).await;
340        assert!(results.is_empty());
341
342        // Exactly 100 chars should work
343        let max_prefix = "a".repeat(100);
344        let results = ecosystem.complete_package_names(&max_prefix).await;
345        // Should not panic, but may return empty (no matches)
346        assert!(results.is_empty() || !results.is_empty());
347    }
348
349    #[tokio::test]
350    #[ignore] // Requires network access
351    async fn test_complete_versions_limit_20() {
352        let cache = Arc::new(deps_core::HttpCache::new());
353        let ecosystem = PypiEcosystem::new(cache);
354
355        // Test that we respect the 20 result limit
356        let results = ecosystem.complete_versions("requests", "2").await;
357        assert!(results.len() <= 20);
358    }
359
360    #[tokio::test]
361    #[ignore] // Requires network access
362    async fn test_complete_package_names_special_chars_real() {
363        let cache = Arc::new(deps_core::HttpCache::new());
364        let ecosystem = PypiEcosystem::new(cache);
365
366        // Real packages with special characters
367        let results = ecosystem.complete_package_names("scikit-le").await;
368        assert!(!results.is_empty() || results.is_empty()); // May or may not have results
369    }
370
371    #[tokio::test]
372    async fn test_parse_manifest_valid_content() {
373        let cache = Arc::new(deps_core::HttpCache::new());
374        let ecosystem = PypiEcosystem::new(cache);
375        let uri = Uri::from_file_path("/test/pyproject.toml").unwrap();
376
377        let content = r#"[project]
378name = "test"
379dependencies = ["requests>=2.0.0"]
380"#;
381
382        let result = ecosystem.parse_manifest(content, &uri).await;
383        assert!(result.is_ok());
384
385        let parse_result = result.unwrap();
386        assert!(!parse_result.dependencies().is_empty());
387    }
388
389    #[tokio::test]
390    async fn test_parse_manifest_invalid_toml() {
391        let cache = Arc::new(deps_core::HttpCache::new());
392        let ecosystem = PypiEcosystem::new(cache);
393        let uri = Uri::from_file_path("/test/pyproject.toml").unwrap();
394
395        let invalid_content = "[project\nname = invalid";
396
397        let result = ecosystem.parse_manifest(invalid_content, &uri).await;
398        assert!(result.is_err());
399    }
400
401    #[tokio::test]
402    async fn test_parse_manifest_empty_dependencies() {
403        let cache = Arc::new(deps_core::HttpCache::new());
404        let ecosystem = PypiEcosystem::new(cache);
405        let uri = Uri::from_file_path("/test/pyproject.toml").unwrap();
406
407        let content = r#"[project]
408name = "test"
409dependencies = []
410"#;
411
412        let result = ecosystem.parse_manifest(content, &uri).await;
413        assert!(result.is_ok());
414
415        let parse_result = result.unwrap();
416        assert!(parse_result.dependencies().is_empty());
417    }
418
419    #[tokio::test]
420    async fn test_registry_returns_arc() {
421        let cache = Arc::new(deps_core::HttpCache::new());
422        let ecosystem = PypiEcosystem::new(cache);
423
424        let registry = ecosystem.registry();
425        assert!(Arc::strong_count(&registry) >= 1);
426    }
427
428    #[tokio::test]
429    async fn test_lockfile_provider_returns_some() {
430        let cache = Arc::new(deps_core::HttpCache::new());
431        let ecosystem = PypiEcosystem::new(cache);
432
433        let provider = ecosystem.lockfile_provider();
434        assert!(provider.is_some());
435    }
436
437    #[tokio::test]
438    async fn test_generate_inlay_hints_empty_dependencies() {
439        let cache = Arc::new(deps_core::HttpCache::new());
440        let ecosystem = PypiEcosystem::new(cache);
441        let uri = Uri::from_file_path("/test/pyproject.toml").unwrap();
442
443        let content = r"[project]
444dependencies = []
445";
446
447        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
448        let cached_versions = HashMap::new();
449        let resolved_versions = HashMap::new();
450        let config = EcosystemConfig::default();
451
452        let hints = ecosystem
453            .generate_inlay_hints(
454                parse_result.as_ref(),
455                &cached_versions,
456                &resolved_versions,
457                deps_core::LoadingState::Loaded,
458                &config,
459            )
460            .await;
461
462        assert!(hints.is_empty());
463    }
464
465    #[tokio::test]
466    async fn test_generate_completions_no_context() {
467        let cache = Arc::new(deps_core::HttpCache::new());
468        let ecosystem = PypiEcosystem::new(cache);
469        let uri = Uri::from_file_path("/test/pyproject.toml").unwrap();
470
471        let content = r#"[project]
472name = "test"
473"#;
474
475        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
476        let position = Position {
477            line: 0,
478            character: 0,
479        };
480
481        let completions = ecosystem
482            .generate_completions(parse_result.as_ref(), position, content)
483            .await;
484
485        assert!(completions.is_empty());
486    }
487
488    #[tokio::test]
489    async fn test_generate_completions_feature_context_returns_empty() {
490        let cache = Arc::new(deps_core::HttpCache::new());
491        let ecosystem = PypiEcosystem::new(cache);
492
493        // PyPI doesn't have features, so this should always return empty
494        // Even if we detect a feature context (which shouldn't happen for PyPI)
495        // This tests the Feature branch in generate_completions
496        let content = r#"[project]
497dependencies = ["requests"]
498"#;
499        let uri = Uri::from_file_path("/test/pyproject.toml").unwrap();
500        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
501
502        // Test with any position - feature context should return empty
503        let position = Position {
504            line: 1,
505            character: 20,
506        };
507
508        let completions = ecosystem
509            .generate_completions(parse_result.as_ref(), position, content)
510            .await;
511
512        // Should not crash, returns empty or package/version completions
513        assert!(completions.is_empty() || !completions.is_empty());
514    }
515
516    #[tokio::test]
517    async fn test_generate_hover_no_dependency_at_position() {
518        let cache = Arc::new(deps_core::HttpCache::new());
519        let ecosystem = PypiEcosystem::new(cache);
520        let uri = Uri::from_file_path("/test/pyproject.toml").unwrap();
521
522        let content = r#"[project]
523name = "test"
524"#;
525
526        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
527        let position = Position {
528            line: 0,
529            character: 0,
530        };
531        let cached_versions = HashMap::new();
532        let resolved_versions = HashMap::new();
533
534        let hover = ecosystem
535            .generate_hover(
536                parse_result.as_ref(),
537                position,
538                &cached_versions,
539                &resolved_versions,
540            )
541            .await;
542
543        assert!(hover.is_none());
544    }
545
546    #[tokio::test]
547    async fn test_generate_code_actions_no_actions() {
548        let cache = Arc::new(deps_core::HttpCache::new());
549        let ecosystem = PypiEcosystem::new(cache);
550        let uri = Uri::from_file_path("/test/pyproject.toml").unwrap();
551
552        let content = r#"[project]
553name = "test"
554"#;
555
556        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
557        let position = Position {
558            line: 0,
559            character: 0,
560        };
561        let cached_versions = HashMap::new();
562
563        let actions = ecosystem
564            .generate_code_actions(parse_result.as_ref(), position, &cached_versions, &uri)
565            .await;
566
567        assert!(actions.is_empty());
568    }
569
570    #[tokio::test]
571    async fn test_generate_diagnostics_no_dependencies() {
572        let cache = Arc::new(deps_core::HttpCache::new());
573        let ecosystem = PypiEcosystem::new(cache);
574        let uri = Uri::from_file_path("/test/pyproject.toml").unwrap();
575
576        let content = r#"[project]
577name = "test"
578dependencies = []
579"#;
580
581        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
582        let cached_versions = HashMap::new();
583        let resolved_versions = HashMap::new();
584
585        let diagnostics = ecosystem
586            .generate_diagnostics(
587                parse_result.as_ref(),
588                &cached_versions,
589                &resolved_versions,
590                &uri,
591            )
592            .await;
593
594        assert!(diagnostics.is_empty());
595    }
596
597    #[tokio::test]
598    async fn test_complete_versions_empty_prefix() {
599        let cache = Arc::new(deps_core::HttpCache::new());
600        let ecosystem = PypiEcosystem::new(cache);
601
602        // Empty prefix should show non-yanked versions (up to 20)
603        let results = ecosystem.complete_versions("nonexistent-package", "").await;
604        // Should not panic, returns empty for unknown package
605        assert!(results.is_empty());
606    }
607
608    #[tokio::test]
609    async fn test_complete_versions_with_tilde_operator() {
610        let cache = Arc::new(deps_core::HttpCache::new());
611        let ecosystem = PypiEcosystem::new(cache);
612
613        // Test PEP 440 operators are stripped correctly
614        let results = ecosystem
615            .complete_versions("nonexistent-pkg", "~=2.0")
616            .await;
617        assert!(results.is_empty());
618    }
619
620    #[tokio::test]
621    async fn test_complete_versions_with_not_equal_operator() {
622        let cache = Arc::new(deps_core::HttpCache::new());
623        let ecosystem = PypiEcosystem::new(cache);
624
625        // Test != operator stripping
626        let results = ecosystem
627            .complete_versions("nonexistent-pkg", "!=2.0")
628            .await;
629        assert!(results.is_empty());
630    }
631}