1use deps_core::lsp_helpers::{
4 DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
5 RequirementMatcher, RequirementResolution, SourcePolicy, compile_requirement_unless,
6};
7use deps_core::{
8 ConcreteVersion, InvalidPackageName, PackageName, VersionReq, is_safe_maven_coordinate_segment,
9};
10
11pub struct MavenFormatter;
12
13fn is_unresolved(requirement: &str) -> bool {
15 requirement.contains("${")
16}
17
18fn is_latest_keyword(requirement: &str) -> bool {
25 matches!(requirement, "LATEST" | "RELEASE")
26}
27
28fn is_snapshot(requirement: &str) -> bool {
35 requirement.ends_with("-SNAPSHOT")
36}
37
38fn is_timestamped_snapshot(requirement: &str) -> bool {
44 let mut segments = requirement.rsplitn(3, '-');
45 let Some(build_number) = segments.next() else {
46 return false;
47 };
48 let Some(timestamp) = segments.next() else {
49 return false;
50 };
51 if segments.next().is_none() {
52 return false;
53 }
54 if build_number.is_empty() || !build_number.bytes().all(|b| b.is_ascii_digit()) {
55 return false;
56 }
57 let Some((date, time)) = timestamp.split_once('.') else {
58 return false;
59 };
60 date.len() == 8
61 && date.bytes().all(|b| b.is_ascii_digit())
62 && time.len() == 6
63 && time.bytes().all(|b| b.is_ascii_digit())
64}
65
66enum MavenMatcher {
76 AlwaysSatisfied,
79 Ranges(Vec<crate::interval::VersionRange>),
81 Exact(String),
83}
84
85impl RequirementMatcher for MavenMatcher {
86 fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
87 let version = version.as_str();
88 Some(match self {
89 Self::AlwaysSatisfied => true,
90 Self::Ranges(ranges) => crate::range::satisfies_ranges(version, ranges),
91 Self::Exact(target) => {
92 crate::version::compare_versions_for_range(version, target)
93 == std::cmp::Ordering::Equal
94 }
95 })
96 }
97}
98
99impl PackageNaming for MavenFormatter {
100 fn validate_package_name(&self, name: &str) -> Result<(), InvalidPackageName> {
126 if is_unresolved(name) {
127 return Ok(());
128 }
129 let Some((group_id, artifact_id)) = name.split_once(':') else {
130 return Err(InvalidPackageName::new(
131 "coordinate must be in 'groupId:artifactId' form",
132 ));
133 };
134 if !is_safe_maven_coordinate_segment(group_id) {
135 return Err(InvalidPackageName::new(
136 "groupId contains invalid characters",
137 ));
138 }
139 if !is_safe_maven_coordinate_segment(artifact_id) {
140 return Err(InvalidPackageName::new(
141 "artifactId contains invalid characters",
142 ));
143 }
144 Ok(())
145 }
146}
147
148impl PackageRendering for MavenFormatter {
149 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
150 let version = version.as_str();
151 version.to_string()
153 }
154
155 fn package_url(&self, name: &PackageName) -> String {
156 crate::registry::package_url(name.as_str())
157 }
158}
159
160impl RequirementResolution for MavenFormatter {
161 fn version_satisfies_requirement(&self, version: &ConcreteVersion, requirement: &str) -> bool {
167 let version = version.as_str();
168 if is_unresolved(requirement) {
170 return true;
171 }
172 if crate::range::is_range(requirement) {
173 return crate::range::satisfies(version, requirement);
174 }
175 version == requirement
176 }
177
178 fn requirement_is_unresolved(&self, requirement: &VersionReq) -> bool {
179 is_unresolved(requirement.as_str())
180 }
181
182 fn compile_requirement(&self, requirement: &VersionReq) -> Option<Box<dyn RequirementMatcher>> {
196 compile_requirement_unless(
197 requirement.as_str(),
198 |r| crate::range::is_range(r) && crate::range::parse_range(r).is_none(),
199 |r| {
200 if is_unresolved(&r)
201 || is_latest_keyword(&r)
202 || is_snapshot(&r)
203 || is_timestamped_snapshot(&r)
204 {
205 return MavenMatcher::AlwaysSatisfied;
206 }
207 if crate::range::is_range(&r)
209 && let Some(ranges) = crate::range::parse_range(&r)
210 {
211 return MavenMatcher::Ranges(ranges);
212 }
213 MavenMatcher::Exact(r)
214 },
215 )
216 }
217}
218
219impl DiagnosticMessages for MavenFormatter {}
220
221impl DiagnosticPolicy for MavenFormatter {}
222
223impl SourcePolicy for MavenFormatter {}
224
225impl OsvNaming for MavenFormatter {}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use deps_core::lsp_helpers::RequirementStatus;
231
232 #[test]
233 fn test_format_version() {
234 let f = MavenFormatter;
235 assert_eq!(
236 f.format_version_for_text_edit(&ConcreteVersion::new("3.14.0")),
237 "3.14.0"
238 );
239 assert_eq!(
240 f.format_version_for_text_edit(&ConcreteVersion::new("1.0.0-SNAPSHOT")),
241 "1.0.0-SNAPSHOT"
242 );
243 }
244
245 #[test]
246 fn test_package_url() {
247 let f = MavenFormatter;
248 assert_eq!(
249 f.package_url(&PackageName::new("org.apache.commons:commons-lang3")),
250 "https://central.sonatype.com/artifact/org.apache.commons/commons-lang3"
251 );
252 }
253
254 #[test]
255 fn test_version_satisfies() {
256 let f = MavenFormatter;
257 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("3.14.0"), "3.14.0"));
258 assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("3.14.0"), "3.13.0"));
259 assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("3.14.0"), "3.14.1"));
260 }
261
262 #[test]
263 fn test_version_satisfies_range() {
264 let f = MavenFormatter;
265 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.5.0"), "[1.0,2.0)"));
266 assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("2.0.0"), "[1.0,2.0)"));
267 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.0.0"), "[1.0.0]"));
268 assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("1.0.1"), "[1.0.0]"));
269 }
270
271 #[test]
272 fn test_version_satisfies_maven_property() {
273 let f = MavenFormatter;
274 assert!(
275 f.version_satisfies_requirement(&ConcreteVersion::new("7.1.1"), "${woodstoxVersion}")
276 );
277 assert!(
278 f.version_satisfies_requirement(&ConcreteVersion::new("2.0.17"), "${slf4j.version}")
279 );
280 assert!(
281 f.version_satisfies_requirement(&ConcreteVersion::new("1.0.0"), "${project.version}")
282 );
283 }
284
285 #[test]
286 fn test_validate_package_name_accepts_valid_coordinate() {
287 let f = MavenFormatter;
288 assert!(
289 f.validate_package_name("org.apache.commons:commons-lang3")
290 .is_ok()
291 );
292 }
293
294 #[test]
295 fn test_validate_package_name_rejects_invalid_group_id() {
296 let f = MavenFormatter;
297 assert!(
298 f.validate_package_name("commons</artifactId><parent>:commons-lang3")
299 .is_err()
300 );
301 }
302
303 #[test]
304 fn test_validate_package_name_rejects_invalid_artifact_id() {
305 let f = MavenFormatter;
306 assert!(f.validate_package_name("org.apache.commons:..").is_err());
307 }
308
309 #[test]
310 fn test_validate_package_name_rejects_missing_colon() {
311 let f = MavenFormatter;
312 assert!(f.validate_package_name("org.apache.commons").is_err());
313 }
314
315 #[test]
319 fn test_validate_package_name_accepts_unresolved_property() {
320 let f = MavenFormatter;
321 assert!(
322 f.validate_package_name("${project.groupId}:my-module")
323 .is_ok()
324 );
325 assert!(
326 f.validate_package_name("org.example:${artifact.name}")
327 .is_ok()
328 );
329 }
330
331 #[test]
332 fn test_normalize_is_identity() {
333 let f = MavenFormatter;
334 assert_eq!(
335 f.normalize_package_name(&PackageName::new("org.apache.commons:commons-lang3")),
336 "org.apache.commons:commons-lang3"
337 );
338 }
339
340 #[test]
341 fn test_requirement_status_unresolved_property() {
342 let f = MavenFormatter;
343 assert_eq!(
344 f.requirement_status(
345 &VersionReq::new("${woodstoxVersion}"),
346 &ConcreteVersion::new("7.1.1")
347 ),
348 RequirementStatus::Unresolved
349 );
350 assert_eq!(
351 f.requirement_status(
352 &VersionReq::new("${project.version}"),
353 &ConcreteVersion::new("1.0.0")
354 ),
355 RequirementStatus::Unresolved
356 );
357 }
358
359 #[test]
360 fn test_requirement_status_up_to_date() {
361 let f = MavenFormatter;
362 assert_eq!(
363 f.requirement_status(&VersionReq::new("3.14.0"), &ConcreteVersion::new("3.14.0")),
364 RequirementStatus::UpToDate
365 );
366 }
367
368 #[test]
369 fn test_requirement_status_outdated() {
370 let f = MavenFormatter;
371 assert_eq!(
372 f.requirement_status(&VersionReq::new("3.13.0"), &ConcreteVersion::new("3.14.0")),
373 RequirementStatus::Outdated
374 );
375 }
376
377 #[test]
378 fn test_osv_version_to_native_round_trips_through_own_parser() {
379 let f = MavenFormatter;
385 let osv_version = "1.2.3";
386 let native = f.osv_version_to_native(osv_version);
387 assert_eq!(native, osv_version);
388 let native = ConcreteVersion::new(native);
389 let edit_text = f.format_version_for_text_edit(&native);
390 assert!(f.version_satisfies_requirement(&native, &edit_text));
391 }
392
393 #[test]
394 fn test_compile_requirement_exact() {
395 let f = MavenFormatter;
396 let matcher = f
397 .compile_requirement(&VersionReq::new("3.14.0"))
398 .expect("Maven requirement always compiles");
399 assert_eq!(matcher.matches(&ConcreteVersion::new("3.14.0")), Some(true));
400 assert_eq!(
401 matcher.matches(&ConcreteVersion::new("3.13.0")),
402 Some(false)
403 );
404 }
405
406 #[test]
407 fn test_compile_requirement_range() {
408 let f = MavenFormatter;
409 let matcher = f
410 .compile_requirement(&VersionReq::new("[1.0,2.0)"))
411 .unwrap();
412 assert_eq!(matcher.matches(&ConcreteVersion::new("1.5.0")), Some(true));
413 assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
414 }
415
416 #[test]
417 fn test_compile_requirement_malformed_range_returns_none() {
418 let f = MavenFormatter;
419 assert!(
420 f.compile_requirement(&VersionReq::new("[1.0,2.0"))
421 .is_none()
422 );
423 }
424
425 #[test]
429 fn test_compile_requirement_trailing_zero_segments_are_equal() {
430 let f = MavenFormatter;
431 let matcher = f.compile_requirement(&VersionReq::new("1.0")).unwrap();
432 assert_eq!(matcher.matches(&ConcreteVersion::new("1.0.0")), Some(true));
433 assert_eq!(matcher.matches(&ConcreteVersion::new("1.1.0")), Some(false));
434 }
435
436 #[test]
440 fn test_compile_requirement_latest_keyword_always_satisfied() {
441 let f = MavenFormatter;
442 let matcher = f.compile_requirement(&VersionReq::new("LATEST")).unwrap();
443 assert_eq!(matcher.matches(&ConcreteVersion::new("3.14.0")), Some(true));
444
445 let matcher = f.compile_requirement(&VersionReq::new("RELEASE")).unwrap();
446 assert_eq!(matcher.matches(&ConcreteVersion::new("3.14.0")), Some(true));
447 }
448
449 #[test]
453 fn test_compile_requirement_snapshot_always_satisfied() {
454 let f = MavenFormatter;
455 let matcher = f
456 .compile_requirement(&VersionReq::new("7.0.0-SNAPSHOT"))
457 .unwrap();
458 assert_eq!(matcher.matches(&ConcreteVersion::new("6.9.0")), Some(true));
459 assert_eq!(matcher.matches(&ConcreteVersion::new("7.0.0")), Some(true));
460 }
461
462 #[test]
466 fn test_compile_requirement_malformed_range_rejected_even_with_snapshot_or_property_suffix() {
467 let f = MavenFormatter;
468 assert!(
469 f.compile_requirement(&VersionReq::new("[1.0,2.0-SNAPSHOT"))
470 .is_none()
471 );
472 assert!(
473 f.compile_requirement(&VersionReq::new("[1.0,${max}"))
474 .is_none()
475 );
476 }
477
478 #[test]
482 fn test_compile_requirement_timestamped_snapshot_always_satisfied() {
483 let f = MavenFormatter;
484 let matcher = f
485 .compile_requirement(&VersionReq::new("1.0-20260101.120000-1"))
486 .unwrap();
487 assert_eq!(matcher.matches(&ConcreteVersion::new("6.9.0")), Some(true));
488
489 let matcher = f.compile_requirement(&VersionReq::new("1.0-1-2")).unwrap();
492 assert_eq!(
493 matcher.matches(&ConcreteVersion::new("1.0-1-2")),
494 Some(true)
495 );
496 assert_eq!(
497 matcher.matches(&ConcreteVersion::new("1.0-1-3")),
498 Some(false)
499 );
500 }
501}