deps_pypi/
formatter.rs

1use deps_core::Dependency;
2use deps_core::lsp_helpers::EcosystemFormatter;
3use pep440_rs::{Version, VersionSpecifiers};
4use std::str::FromStr;
5use tower_lsp_server::ls_types::Position;
6
7pub struct PypiFormatter;
8
9impl EcosystemFormatter for PypiFormatter {
10    fn normalize_package_name(&self, name: &str) -> String {
11        if !name.chars().any(|c| c.is_uppercase() || c == '-') {
12            return name.to_string();
13        }
14        name.to_lowercase().replace('-', "_")
15    }
16
17    fn format_version_for_code_action(&self, version: &str) -> String {
18        let next_major = version
19            .split('.')
20            .next()
21            .and_then(|s| s.parse::<u32>().ok())
22            .and_then(|v| v.checked_add(1))
23            .unwrap_or(1);
24
25        format!(">={version},<{next_major}")
26    }
27
28    fn version_satisfies_requirement(&self, version: &str, requirement: &str) -> bool {
29        let Ok(ver) = Version::from_str(version) else {
30            return false;
31        };
32
33        let Ok(specs) = VersionSpecifiers::from_str(requirement) else {
34            return false;
35        };
36
37        specs.contains(&ver)
38    }
39
40    fn package_url(&self, name: &str) -> String {
41        format!("https://pypi.org/project/{name}")
42    }
43
44    fn is_position_on_dependency(&self, dep: &dyn Dependency, position: Position) -> bool {
45        let name_range = dep.name_range();
46
47        if position.line != name_range.start.line {
48            return false;
49        }
50
51        let end_char = dep
52            .version_range()
53            .map_or(name_range.end.character, |r| r.end.character);
54
55        let start_char = name_range.start.character.saturating_sub(2);
56        let end_char = end_char.saturating_add(2);
57
58        position.character >= start_char && position.character <= end_char
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    #[test]
67    fn test_normalize_package_name() {
68        let formatter = PypiFormatter;
69        assert_eq!(formatter.normalize_package_name("requests"), "requests");
70        assert_eq!(
71            formatter.normalize_package_name("Django-REST-Framework"),
72            "django_rest_framework"
73        );
74        assert_eq!(formatter.normalize_package_name("My-Package"), "my_package");
75    }
76
77    #[test]
78    fn test_format_version() {
79        let formatter = PypiFormatter;
80        assert_eq!(
81            formatter.format_version_for_code_action("1.2.3"),
82            ">=1.2.3,<2"
83        );
84        assert_eq!(
85            formatter.format_version_for_code_action("2.28.0"),
86            ">=2.28.0,<3"
87        );
88        assert_eq!(
89            formatter.format_version_for_code_action("0.1.0"),
90            ">=0.1.0,<1"
91        );
92    }
93
94    #[test]
95    fn test_format_version_overflow_protection() {
96        let formatter = PypiFormatter;
97        // u32::MAX should not overflow, checked_add returns None
98        assert_eq!(
99            formatter.format_version_for_code_action("4294967295.0.0"),
100            ">=4294967295.0.0,<1"
101        );
102    }
103
104    #[test]
105    fn test_package_url() {
106        let formatter = PypiFormatter;
107        assert_eq!(
108            formatter.package_url("requests"),
109            "https://pypi.org/project/requests"
110        );
111        assert_eq!(
112            formatter.package_url("django"),
113            "https://pypi.org/project/django"
114        );
115    }
116
117    #[test]
118    fn test_version_satisfies_pep440() {
119        let formatter = PypiFormatter;
120
121        assert!(formatter.version_satisfies_requirement("1.2.3", ">=1.0,<2"));
122        assert!(formatter.version_satisfies_requirement("2.28.0", ">=2.0"));
123        assert!(formatter.version_satisfies_requirement("1.0.0", "==1.0.0"));
124        assert!(formatter.version_satisfies_requirement("1.2.0", "~=1.2.0"));
125
126        assert!(!formatter.version_satisfies_requirement("2.0.0", ">=1.0,<2"));
127        assert!(!formatter.version_satisfies_requirement("0.9.0", ">=1.0"));
128    }
129
130    #[test]
131    fn test_version_satisfies_invalid_version() {
132        let formatter = PypiFormatter;
133        assert!(!formatter.version_satisfies_requirement("not-a-version", ">=1.0"));
134    }
135
136    #[test]
137    fn test_version_satisfies_invalid_specifier() {
138        let formatter = PypiFormatter;
139        assert!(!formatter.version_satisfies_requirement("1.0.0", "not-a-specifier"));
140    }
141
142    #[test]
143    fn test_default_yanked_message() {
144        let formatter = PypiFormatter;
145        assert_eq!(formatter.yanked_message(), "This version has been yanked");
146        assert_eq!(formatter.yanked_label(), "*(yanked)*");
147    }
148
149    #[test]
150    fn test_normalize_fast_path() {
151        let formatter = PypiFormatter;
152        // Already lowercase, no hyphens - should hit fast path
153        assert_eq!(formatter.normalize_package_name("requests"), "requests");
154        assert_eq!(formatter.normalize_package_name("flask"), "flask");
155        assert_eq!(formatter.normalize_package_name("numpy"), "numpy");
156    }
157
158    mod is_position_on_dependency_tests {
159        use super::*;
160        use deps_core::parser::DependencySource;
161        use std::any::Any;
162        use tower_lsp_server::ls_types::Range;
163
164        struct MockDep {
165            name_range: Range,
166            version_range: Option<Range>,
167        }
168
169        impl deps_core::Dependency for MockDep {
170            fn name(&self) -> &'static str {
171                "test-package"
172            }
173            fn name_range(&self) -> Range {
174                self.name_range
175            }
176            fn version_requirement(&self) -> Option<&str> {
177                Some(">=1.0")
178            }
179            fn version_range(&self) -> Option<Range> {
180                self.version_range
181            }
182            fn source(&self) -> DependencySource {
183                DependencySource::Registry
184            }
185            fn as_any(&self) -> &dyn Any {
186                self
187            }
188        }
189
190        #[test]
191        fn test_position_on_name() {
192            let formatter = PypiFormatter;
193            let dep = MockDep {
194                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
195                version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
196            };
197            // Position on package name
198            assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 15)));
199        }
200
201        #[test]
202        fn test_position_in_padding_before() {
203            let formatter = PypiFormatter;
204            let dep = MockDep {
205                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
206                version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
207            };
208            // Position in padding before name (character - 2)
209            assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 8)));
210        }
211
212        #[test]
213        fn test_position_after_version_padding() {
214            let formatter = PypiFormatter;
215            let dep = MockDep {
216                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
217                version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
218            };
219            // Position after version range (character + 2)
220            assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 37)));
221        }
222
223        #[test]
224        fn test_position_too_far_before() {
225            let formatter = PypiFormatter;
226            let dep = MockDep {
227                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
228                version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
229            };
230            // Position too far before (outside padding)
231            assert!(!formatter.is_position_on_dependency(&dep, Position::new(5, 5)));
232        }
233
234        #[test]
235        fn test_position_too_far_after() {
236            let formatter = PypiFormatter;
237            let dep = MockDep {
238                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
239                version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
240            };
241            // Position too far after (outside padding)
242            assert!(!formatter.is_position_on_dependency(&dep, Position::new(5, 40)));
243        }
244
245        #[test]
246        fn test_position_different_line() {
247            let formatter = PypiFormatter;
248            let dep = MockDep {
249                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
250                version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
251            };
252            // Different line
253            assert!(!formatter.is_position_on_dependency(&dep, Position::new(4, 15)));
254            assert!(!formatter.is_position_on_dependency(&dep, Position::new(6, 15)));
255        }
256
257        #[test]
258        fn test_position_without_version_range() {
259            let formatter = PypiFormatter;
260            let dep = MockDep {
261                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
262                version_range: None,
263            };
264            // Should use name_range.end for calculation
265            assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 22)));
266            assert!(!formatter.is_position_on_dependency(&dep, Position::new(5, 25)));
267        }
268
269        #[test]
270        fn test_saturating_sub_at_column_zero() {
271            let formatter = PypiFormatter;
272            // Edge case: character 0 with saturating_sub(2)
273            let dep = MockDep {
274                name_range: Range::new(Position::new(5, 0), Position::new(5, 10)),
275                version_range: None,
276            };
277            // saturating_sub(2) should give 0, not underflow
278            assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 0)));
279        }
280    }
281}