1use deps_core::ConcreteVersion;
2use deps_core::Dependency;
3use deps_core::InvalidPackageName;
4use deps_core::PackageName;
5use deps_core::VersionReq;
6use deps_core::lsp_helpers::{
7 DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
8 RequirementMatcher, RequirementResolution, SourcePolicy,
9};
10use pep440_rs::{Version, VersionSpecifiers};
11use std::str::FromStr;
12use tower_lsp_server::ls_types::Position;
13
14struct Pep440Matcher(VersionSpecifiers);
17
18impl RequirementMatcher for Pep440Matcher {
19 fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
20 let version = version.as_str();
21 Version::from_str(version).ok().map(|v| self.0.contains(&v))
22 }
23}
24
25pub struct PypiFormatter;
26
27impl PackageNaming for PypiFormatter {
28 fn normalize_package_name(&self, name: &PackageName) -> String {
29 crate::name::normalize(name.as_str())
30 }
31
32 fn validate_package_name(&self, name: &str) -> Result<(), InvalidPackageName> {
33 let valid = !name.is_empty()
35 && name
36 .chars()
37 .next()
38 .is_some_and(|c| c.is_ascii_alphanumeric())
39 && name
40 .chars()
41 .last()
42 .is_some_and(|c| c.is_ascii_alphanumeric())
43 && name
44 .chars()
45 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
46
47 if valid {
48 Ok(())
49 } else {
50 Err(InvalidPackageName::new(
51 "must match PEP 508 name pattern ^([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9])$",
52 ))
53 }
54 }
55}
56
57impl PackageRendering for PypiFormatter {
58 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
59 let version = version.as_str();
60 let next_major = version
61 .split('.')
62 .next()
63 .and_then(|s| s.parse::<u32>().ok())
64 .and_then(|v| v.checked_add(1))
65 .unwrap_or(1);
66
67 format!(">={version},<{next_major}")
68 }
69
70 fn format_version_replacing(&self, version: &ConcreteVersion, current: &str) -> String {
71 let version = version.as_str();
72 let terms: Vec<&str> = current.trim().split(',').map(str::trim).collect();
73
74 if terms.iter().any(|t| t.starts_with("===")) {
75 return format!("==={version}");
76 }
77
78 if let Some(term) = terms.iter().find(|t| t.starts_with("==")) {
79 return match term.strip_circumfix("==", ".*") {
80 Some(base) => {
81 let candidate = truncate_release_to_match(base, version)
82 .map(|truncated| format!("=={truncated}.*"))
83 .unwrap_or_else(|| format!("=={version}"));
84 if candidate == *term {
91 format!("=={version}")
92 } else {
93 candidate
94 }
95 }
96 None => format!("=={version}"),
97 };
98 }
99
100 if let Some(term) = terms.iter().find(|t| t.starts_with("~=")) {
101 let rest = term.strip_prefix("~=").unwrap_or_default().trim();
102 let release_len = Version::from_str(rest)
103 .map(|v| v.release().len())
104 .unwrap_or(0);
105 return if release_len >= 2 {
106 let candidate = truncate_release_to_match(rest, version)
107 .map(|truncated| format!("~={truncated}"))
108 .unwrap_or_else(|| format!("~={version}"));
109 if candidate == *term {
111 format!("~={version}")
112 } else {
113 candidate
114 }
115 } else {
116 self.format_version_for_text_edit(&ConcreteVersion::new(version))
119 };
120 }
121
122 self.format_version_for_text_edit(&ConcreteVersion::new(version))
123 }
124
125 fn package_url(&self, name: &PackageName) -> String {
126 crate::registry::package_url(name.as_str())
127 }
128
129 fn is_position_on_dependency(&self, dep: &dyn Dependency, position: Position) -> bool {
130 let name_range = dep.name_range();
131
132 if position.line != name_range.start.line {
133 return false;
134 }
135
136 let end_char = dep
137 .version_range()
138 .map_or(name_range.end.character, |r| r.end.character);
139
140 let start_char = name_range.start.character.saturating_sub(2);
141 let end_char = end_char.saturating_add(2);
142
143 position.character >= start_char && position.character <= end_char
144 }
145
146 fn suppress_package_url(&self, source: &deps_core::DependencySource) -> bool {
156 !self.source_is_public_registry_content(source)
157 }
158}
159
160impl RequirementResolution for PypiFormatter {
161 fn version_satisfies_requirement(&self, version: &ConcreteVersion, requirement: &str) -> bool {
162 let version = version.as_str();
163 let Ok(ver) = Version::from_str(version) else {
164 return false;
165 };
166
167 let Ok(specs) = VersionSpecifiers::from_str(requirement) else {
168 return false;
169 };
170
171 specs.contains(&ver)
172 }
173
174 fn compile_requirement(&self, requirement: &VersionReq) -> Option<Box<dyn RequirementMatcher>> {
188 let specs = VersionSpecifiers::from_str(requirement.as_str()).ok()?;
189 if specs.iter().any(|spec| spec.version().is_local()) {
190 return None;
191 }
192 Some(Box::new(Pep440Matcher(specs)))
193 }
194}
195
196impl DiagnosticMessages for PypiFormatter {}
197
198impl DiagnosticPolicy for PypiFormatter {}
199
200impl SourcePolicy for PypiFormatter {
201 fn can_resolve_source(&self, source: &deps_core::DependencySource) -> bool {
215 matches!(
216 source,
217 deps_core::DependencySource::Registry
218 | deps_core::DependencySource::AlternateRegistry { .. }
219 )
220 }
221}
222
223impl OsvNaming for PypiFormatter {}
224
225fn truncate_release_to_match(source_version: &str, latest: &str) -> Option<String> {
231 let source_release_len = Version::from_str(source_version).ok()?.release().len();
232 let latest_release = Version::from_str(latest).ok()?;
233 let latest_release = latest_release.release();
234 if latest_release.len() < source_release_len {
235 return None;
236 }
237 Some(
238 latest_release[..source_release_len]
239 .iter()
240 .map(u64::to_string)
241 .collect::<Vec<_>>()
242 .join("."),
243 )
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
253 fn test_can_resolve_source() {
254 let formatter = PypiFormatter;
255 assert!(formatter.can_resolve_source(&deps_core::DependencySource::Registry));
256 assert!(
257 formatter.can_resolve_source(&deps_core::DependencySource::AlternateRegistry {
258 index: "pypi-chain:deadbeef".to_string(),
259 mirrors_crates_io: false,
260 })
261 );
262 assert!(
263 !formatter.can_resolve_source(&deps_core::DependencySource::CustomRegistry {
264 url: "https://pypi.mycorp.example/simple".to_string(),
265 })
266 );
267 }
268
269 #[test]
273 fn test_suppress_package_url() {
274 let formatter = PypiFormatter;
275 assert!(!formatter.suppress_package_url(&deps_core::DependencySource::Registry));
276 assert!(
277 formatter.suppress_package_url(&deps_core::DependencySource::AlternateRegistry {
278 index: "pypi-chain:deadbeef".to_string(),
279 mirrors_crates_io: false,
280 })
281 );
282 assert!(
283 formatter.suppress_package_url(&deps_core::DependencySource::CustomRegistry {
284 url: "https://pypi.mycorp.example/simple".to_string(),
285 })
286 );
287 }
288
289 #[test]
290 fn test_normalize_package_name() {
291 let formatter = PypiFormatter;
292 assert_eq!(
293 formatter.normalize_package_name(&PackageName::new("requests")),
294 "requests"
295 );
296 assert_eq!(
297 formatter.normalize_package_name(&PackageName::new("Django-REST-Framework")),
298 "django-rest-framework"
299 );
300 assert_eq!(
301 formatter.normalize_package_name(&PackageName::new("My-Package")),
302 "my-package"
303 );
304 assert_eq!(
305 formatter.normalize_package_name(&PackageName::new("zope.interface")),
306 "zope-interface"
307 );
308 }
309
310 #[test]
311 fn test_format_version() {
312 let formatter = PypiFormatter;
313 assert_eq!(
314 formatter.format_version_for_text_edit(&ConcreteVersion::new("1.2.3")),
315 ">=1.2.3,<2"
316 );
317 assert_eq!(
318 formatter.format_version_for_text_edit(&ConcreteVersion::new("2.28.0")),
319 ">=2.28.0,<3"
320 );
321 assert_eq!(
322 formatter.format_version_for_text_edit(&ConcreteVersion::new("0.1.0")),
323 ">=0.1.0,<1"
324 );
325 }
326
327 #[test]
328 fn test_format_version_overflow_protection() {
329 let formatter = PypiFormatter;
330 assert_eq!(
332 formatter.format_version_for_text_edit(&ConcreteVersion::new("4294967295.0.0")),
333 ">=4294967295.0.0,<1"
334 );
335 }
336
337 #[test]
338 fn test_package_url() {
339 let formatter = PypiFormatter;
340 assert_eq!(
341 formatter.package_url(&PackageName::new("requests")),
342 "https://pypi.org/project/requests"
343 );
344 assert_eq!(
345 formatter.package_url(&PackageName::new("django")),
346 "https://pypi.org/project/django"
347 );
348 }
349
350 #[test]
351 fn test_version_satisfies_pep440() {
352 let formatter = PypiFormatter;
353
354 assert!(
355 formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), ">=1.0,<2")
356 );
357 assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("2.28.0"), ">=2.0"));
358 assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.0.0"), "==1.0.0"));
359 assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.0"), "~=1.2.0"));
360
361 assert!(
362 !formatter.version_satisfies_requirement(&ConcreteVersion::new("2.0.0"), ">=1.0,<2")
363 );
364 assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("0.9.0"), ">=1.0"));
365 }
366
367 #[test]
368 fn test_version_satisfies_invalid_version() {
369 let formatter = PypiFormatter;
370 assert!(
371 !formatter
372 .version_satisfies_requirement(&ConcreteVersion::new("not-a-version"), ">=1.0")
373 );
374 }
375
376 #[test]
377 fn test_version_satisfies_invalid_specifier() {
378 let formatter = PypiFormatter;
379 assert!(
380 !formatter
381 .version_satisfies_requirement(&ConcreteVersion::new("1.0.0"), "not-a-specifier")
382 );
383 }
384
385 #[test]
386 fn test_compile_requirement_satisfiable() {
387 let formatter = PypiFormatter;
388 let matcher = formatter
389 .compile_requirement(&VersionReq::new(">=1.0,<2.0"))
390 .expect("valid PEP 440 specifier must compile");
391 assert_eq!(matcher.matches(&ConcreteVersion::new("1.5.0")), Some(true));
392 assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
393 }
394
395 #[test]
396 fn test_compile_requirement_unparseable_requirement_returns_none() {
397 let formatter = PypiFormatter;
398 assert!(
399 formatter
400 .compile_requirement(&VersionReq::new("not-a-specifier"))
401 .is_none()
402 );
403 }
404
405 #[test]
406 fn test_compile_requirement_local_version_returns_none() {
407 let formatter = PypiFormatter;
408 assert!(
409 formatter
410 .compile_requirement(&VersionReq::new("==2.0.1+cu118"))
411 .is_none()
412 );
413 }
414
415 #[test]
416 fn test_compile_requirement_unparseable_candidate_is_skipped() {
417 let formatter = PypiFormatter;
418 let matcher = formatter
419 .compile_requirement(&VersionReq::new(">=1.0"))
420 .unwrap();
421 assert_eq!(matcher.matches(&ConcreteVersion::new("2011k")), None);
422 }
423
424 #[test]
425 fn test_default_yanked_message() {
426 let formatter = PypiFormatter;
427 assert_eq!(formatter.yanked_message(), "This version has been yanked");
428 assert_eq!(formatter.yanked_label(), "*(yanked)*");
429 }
430
431 #[test]
432 fn test_osv_version_to_native_round_trips_through_own_parser() {
433 let formatter = PypiFormatter;
440 let osv_version = "2.28.0";
441 let native = formatter.osv_version_to_native(osv_version);
442 assert_eq!(native, osv_version);
443 let native = ConcreteVersion::new(native);
444 let edit_text = formatter.format_version_for_text_edit(&native);
445 assert!(formatter.version_satisfies_requirement(&native, &edit_text));
446 }
447
448 #[test]
449 fn test_osv_version_to_native_round_trips_through_format_version_replacing() {
450 let formatter = PypiFormatter;
456 let osv_version = "2.28.0";
457 let native = formatter.osv_version_to_native(osv_version);
458 assert_eq!(native, osv_version);
459 let native = ConcreteVersion::new(native);
460
461 for current in ["==2.20.0", "==2.20.*", "~=2.20", "~=2.20.0", ">=2.20,<2.21"] {
462 let edit_text = formatter.format_version_replacing(&native, current);
463 assert!(
464 formatter.version_satisfies_requirement(&native, &edit_text),
465 "current={current:?} produced edit_text={edit_text:?}, which does not admit {native:?}"
466 );
467 }
468 }
469
470 #[test]
471 fn test_normalize_fast_path() {
472 let formatter = PypiFormatter;
473 assert_eq!(
475 formatter.normalize_package_name(&PackageName::new("requests")),
476 "requests"
477 );
478 assert_eq!(
479 formatter.normalize_package_name(&PackageName::new("flask")),
480 "flask"
481 );
482 assert_eq!(
483 formatter.normalize_package_name(&PackageName::new("numpy")),
484 "numpy"
485 );
486 }
487
488 #[test]
489 fn test_validate_package_name_accepts_valid_names() {
490 let formatter = PypiFormatter;
491 assert!(formatter.validate_package_name("zope.interface").is_ok());
492 assert!(formatter.validate_package_name("Django").is_ok());
493 assert!(formatter.validate_package_name("a").is_ok());
494 assert!(formatter.validate_package_name("my-package_1.0").is_ok());
495 }
496
497 #[test]
498 fn test_validate_package_name_rejects_invalid_names() {
499 let formatter = PypiFormatter;
500 assert!(formatter.validate_package_name("---").is_err());
501 assert!(formatter.validate_package_name("-x").is_err());
502 assert!(formatter.validate_package_name("x-").is_err());
503 assert!(formatter.validate_package_name("a b").is_err());
504 assert!(formatter.validate_package_name("").is_err());
505 }
506
507 #[test]
508 fn test_format_version_replacing_table() {
509 let formatter = PypiFormatter;
510
511 assert_eq!(
513 formatter.format_version_replacing(&ConcreteVersion::new("1.2"), "===1.0"),
514 "===1.2"
515 );
516
517 assert_eq!(
519 formatter.format_version_replacing(&ConcreteVersion::new("1.2"), "==1.0"),
520 "==1.2"
521 );
522
523 assert_eq!(
525 formatter.format_version_replacing(&ConcreteVersion::new("1.6.2"), "==1.4.*"),
526 "==1.6.*"
527 );
528
529 assert_eq!(
531 formatter.format_version_replacing(&ConcreteVersion::new("1.26.4"), "~=1.24"),
532 "~=1.26"
533 );
534
535 assert_eq!(
537 formatter.format_version_replacing(&ConcreteVersion::new("4.0.0"), "~=3"),
538 ">=4.0.0,<5"
539 );
540
541 assert_eq!(
545 formatter.format_version_replacing(&ConcreteVersion::new("1.2"), "==1.0, !=1.0.1"),
546 "==1.2"
547 );
548 assert_eq!(
549 formatter.format_version_replacing(&ConcreteVersion::new("1.2"), "!=0.9, ==1.0"),
550 "==1.2"
551 );
552
553 assert_eq!(
555 formatter
556 .format_version_replacing(&ConcreteVersion::new("2.0.0"), ">=1.0, !=1.5, <2.0"),
557 ">=2.0.0,<3"
558 );
559
560 assert_eq!(
562 formatter.format_version_replacing(&ConcreteVersion::new("2.0.0"), ">=1.0"),
563 ">=2.0.0,<3"
564 );
565 }
566
567 #[test]
568 fn test_format_version_replacing_wildcard_pin_no_op_falls_back_to_exact_fix() {
569 let formatter = PypiFormatter;
577
578 assert_eq!(
579 formatter.format_version_replacing(&ConcreteVersion::new("1.0.2"), "~=1.0"),
580 "~=1.0.2"
581 );
582 assert_eq!(
583 formatter.format_version_replacing(&ConcreteVersion::new("1.0.2"), "==1.0.*"),
584 "==1.0.2"
585 );
586
587 assert_eq!(
591 formatter.format_version_replacing(&ConcreteVersion::new("1.0.2"), "~=1.0.2"),
592 "~=1.0.2"
593 );
594 assert_eq!(
599 formatter.format_version_replacing(&ConcreteVersion::new("1.0.2"), "==1.0.2.*"),
600 "==1.0.2"
601 );
602 }
603
604 mod is_position_on_dependency_tests {
605 use super::*;
606 use deps_core::parser::DependencySource;
607 use std::any::Any;
608 use tower_lsp_server::ls_types::Range;
609
610 struct MockDep {
611 name_range: Range,
612 version_range: Option<Range>,
613 }
614
615 impl deps_core::Dependency for MockDep {
616 fn name(&self) -> &deps_core::PackageName {
617 static NAME: std::sync::LazyLock<deps_core::PackageName> =
618 std::sync::LazyLock::new(|| deps_core::PackageName::new("test-package"));
619 &NAME
620 }
621 fn name_range(&self) -> Range {
622 self.name_range
623 }
624 fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
625 static VERSION_REQ: std::sync::LazyLock<deps_core::VersionReq> =
626 std::sync::LazyLock::new(|| deps_core::VersionReq::new(">=1.0"));
627 Some(&VERSION_REQ)
628 }
629 fn version_range(&self) -> Option<Range> {
630 self.version_range
631 }
632 fn source(&self) -> DependencySource {
633 DependencySource::Registry
634 }
635 fn as_any(&self) -> &dyn Any {
636 self
637 }
638 }
639
640 #[test]
641 fn test_position_on_name() {
642 let formatter = PypiFormatter;
643 let dep = MockDep {
644 name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
645 version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
646 };
647 assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 15)));
649 }
650
651 #[test]
652 fn test_position_in_padding_before() {
653 let formatter = PypiFormatter;
654 let dep = MockDep {
655 name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
656 version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
657 };
658 assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 8)));
660 }
661
662 #[test]
663 fn test_position_after_version_padding() {
664 let formatter = PypiFormatter;
665 let dep = MockDep {
666 name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
667 version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
668 };
669 assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 37)));
671 }
672
673 #[test]
674 fn test_position_too_far_before() {
675 let formatter = PypiFormatter;
676 let dep = MockDep {
677 name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
678 version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
679 };
680 assert!(!formatter.is_position_on_dependency(&dep, Position::new(5, 5)));
682 }
683
684 #[test]
685 fn test_position_too_far_after() {
686 let formatter = PypiFormatter;
687 let dep = MockDep {
688 name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
689 version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
690 };
691 assert!(!formatter.is_position_on_dependency(&dep, Position::new(5, 40)));
693 }
694
695 #[test]
696 fn test_position_different_line() {
697 let formatter = PypiFormatter;
698 let dep = MockDep {
699 name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
700 version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
701 };
702 assert!(!formatter.is_position_on_dependency(&dep, Position::new(4, 15)));
704 assert!(!formatter.is_position_on_dependency(&dep, Position::new(6, 15)));
705 }
706
707 #[test]
708 fn test_position_without_version_range() {
709 let formatter = PypiFormatter;
710 let dep = MockDep {
711 name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
712 version_range: None,
713 };
714 assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 22)));
716 assert!(!formatter.is_position_on_dependency(&dep, Position::new(5, 25)));
717 }
718
719 #[test]
720 fn test_saturating_sub_at_column_zero() {
721 let formatter = PypiFormatter;
722 let dep = MockDep {
724 name_range: Range::new(Position::new(5, 0), Position::new(5, 10)),
725 version_range: None,
726 };
727 assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 0)));
729 }
730 }
731}