1use 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
22pub struct PypiEcosystem {
32 registry: Arc<PypiRegistry>,
33 parser: PypiParser,
34 formatter: PypiFormatter,
35}
36
37impl PypiEcosystem {
38 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 async fn complete_package_names(&self, prefix: &str) -> Vec<CompletionItem> {
51 use deps_core::completion::build_package_completion;
52
53 if prefix.len() < 2 || prefix.len() > 100 {
55 return vec![];
56 }
57
58 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 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 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 let results = ecosystem.complete_package_names("d").await;
269 assert!(results.is_empty());
270
271 let results = ecosystem.complete_package_names("").await;
273 assert!(results.is_empty());
274 }
275
276 #[tokio::test]
277 #[ignore] 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] 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] 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 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 let results = ecosystem.complete_package_names("scikit-le").await;
328 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 let long_prefix = "a".repeat(101);
339 let results = ecosystem.complete_package_names(&long_prefix).await;
340 assert!(results.is_empty());
341
342 let max_prefix = "a".repeat(100);
344 let results = ecosystem.complete_package_names(&max_prefix).await;
345 assert!(results.is_empty() || !results.is_empty());
347 }
348
349 #[tokio::test]
350 #[ignore] async fn test_complete_versions_limit_20() {
352 let cache = Arc::new(deps_core::HttpCache::new());
353 let ecosystem = PypiEcosystem::new(cache);
354
355 let results = ecosystem.complete_versions("requests", "2").await;
357 assert!(results.len() <= 20);
358 }
359
360 #[tokio::test]
361 #[ignore] 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 let results = ecosystem.complete_package_names("scikit-le").await;
368 assert!(!results.is_empty() || results.is_empty()); }
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(®istry) >= 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 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 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 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 let results = ecosystem.complete_versions("nonexistent-package", "").await;
604 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 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 let results = ecosystem
627 .complete_versions("nonexistent-pkg", "!=2.0")
628 .await;
629 assert!(results.is_empty());
630 }
631}