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 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 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 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 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 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 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 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 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 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 let dep = MockDep {
274 name_range: Range::new(Position::new(5, 0), Position::new(5, 10)),
275 version_range: None,
276 };
277 assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 0)));
279 }
280 }
281}