Skip to main content

deps_maven/
formatter.rs

1//! Version formatting for Maven ecosystem.
2
3use 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
13/// Unexpanded property (missing from `<properties>`).
14fn is_unresolved(requirement: &str) -> bool {
15    requirement.contains("${")
16}
17
18/// Maven's `LATEST`/`RELEASE` metadata keywords (case-sensitive per Maven's own grammar):
19/// "resolve to whatever `<latest>`/`<release>` in maven-metadata.xml currently designates".
20/// That designation is a side channel [`MavenMatcher`] has no access to (the same
21/// `<release>`-side-channel limitation `MavenCentralRegistry::select_latest_matching`
22/// documents), so — like an unresolved `${property}` — the requirement can't be checked
23/// literally against `available` and must be treated as always satisfied.
24fn is_latest_keyword(requirement: &str) -> bool {
25    matches!(requirement, "LATEST" | "RELEASE")
26}
27
28/// A `-SNAPSHOT` pin (e.g. `7.0.0-SNAPSHOT`) is a normal, common requirement in real dev
29/// manifests, but `MavenCentralRegistry` only ever fetches the release-repo
30/// `maven-metadata.xml` — which never lists snapshot versions, those live in a separate
31/// snapshot repository this registry client doesn't query. `available` can therefore never
32/// contain one, so — like `LATEST`/`RELEASE` and an unresolved `${property}` — it must be
33/// treated as always satisfied rather than scanned.
34fn is_snapshot(requirement: &str) -> bool {
35    requirement.ends_with("-SNAPSHOT")
36}
37
38/// A resolved timestamped-snapshot deployment (e.g. `1.0-20260101.120000-1`) — the form a
39/// `-SNAPSHOT` version takes once actually deployed to the snapshot repository, replacing
40/// the `-SNAPSHOT` suffix with a `-<yyyyMMdd>.<HHmmss>-<buildNumber>` unique-version stamp.
41/// Same undecidable case as [`is_snapshot`]: this registry client never queries the
42/// snapshot repository, so `available` can never contain one.
43fn 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
66/// Precise Maven version/range matcher, compiled once per dependency by
67/// [`MavenFormatter::compile_requirement`] — the range union (if any) is parsed once into
68/// [`crate::interval::VersionRange`]s here rather than being re-parsed for every candidate
69/// version scanned. Deliberately more precise than the loose `version_satisfies_requirement`
70/// in two ways it does not need for its own "treat as up to date" question: it recognizes the
71/// `LATEST`/`RELEASE` keywords, and its exact-match branch uses qualifier-aware
72/// `compare_versions_for_range` instead of raw string equality, so `1.0` correctly matches a
73/// published `1.0.0` (equal under Maven's own `ComparableVersion`) rather than reporting a
74/// false WARNING.
75enum MavenMatcher {
76    /// Unresolved `${property}`, `LATEST`/`RELEASE`, or a `-SNAPSHOT` pin — see
77    /// [`is_unresolved`], [`is_latest_keyword`], [`is_snapshot`].
78    AlwaysSatisfied,
79    /// A range/union, pre-parsed by [`crate::range::parse_range`].
80    Ranges(Vec<crate::interval::VersionRange>),
81    /// A bare "soft" recommended version, compared with qualifier-aware equality.
82    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    /// Validates a Maven coordinate's `groupId:artifactId` shape and character set.
101    ///
102    /// Mirrors the gate `crate::registry::metadata_urls` applies before building a
103    /// registry request URL ([`is_safe_maven_coordinate_segment`] on each split
104    /// coordinate segment), so a coordinate rejected here would also be rejected there —
105    /// letting the "Invalid package name" diagnostic (deps-core's
106    /// `formatter.validate_package_name` gate) surface the accurate reason instead of the
107    /// generic "Unknown package" a registry-side rejection produces (#369).
108    ///
109    /// An unresolved `${property}` groupId/artifactId (e.g. a multi-module POM's
110    /// `<groupId>${project.groupId}</groupId>`, see `is_unresolved`) is valid Maven,
111    /// not a malformed coordinate — checked first and always accepted, the same
112    /// undecidable treatment `is_unresolved` already gets in
113    /// [`version_satisfies_requirement`](Self::version_satisfies_requirement) and
114    /// [`compile_requirement`](Self::compile_requirement).
115    ///
116    /// The missing-`:` branch is defensive: `crate::parser` always builds a
117    /// dependency's name as `format!("{group_id}:{artifact_id}")`, so a real coordinate
118    /// reaching this method already contains exactly one `:`.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`InvalidPackageName`] if `name` has no `:` separator, or if either the
123    /// `groupId` or `artifactId` segment fails [`is_safe_maven_coordinate_segment`] — but
124    /// never when `name` contains an unresolved `${property}`, which is accepted instead.
125    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        // Maven uses exact versions, no prefix
152        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    // #249 review (M4): this branch order (unresolved → range → exact) is a separate copy
162    // from `compile_requirement`'s below — kept apart deliberately (see `MavenMatcher`'s
163    // docs for the two precision differences), but any reordering here must be checked
164    // against `compile_requirement`'s malformed-range guard placement too, since S1/S2
165    // happened in `deps-gradle` from exactly this kind of drift between two copies.
166    fn version_satisfies_requirement(&self, version: &ConcreteVersion, requirement: &str) -> bool {
167        let version = version.as_str();
168        // Unresolved properties (missing from <properties>) — skip comparison
169        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    /// Uses [`compile_requirement_unless`] (see that function and
183    /// [`deps_core::lsp_helpers::RequirementResolution::compile_requirement`] for the shared "undecidable" contract).
184    ///
185    /// The undecidable predicate rejects a malformed range (`is_range` true but
186    /// `crate::range::parse_range` fails) — checked unconditionally, first, before any other
187    /// branch: without this guard ahead of the `AlwaysSatisfied` short-circuits below, a
188    /// range that happens to also end in `-SNAPSHOT` or contain `${` would be misclassified
189    /// as always-satisfied instead of rejected, and a fail-closed `false` on every candidate
190    /// would otherwise produce a false "unsatisfiable" verdict for a typo instead of
191    /// correctly suppressing the check.
192    ///
193    /// #249 review (M4): this is a separate branch-order copy from `version_satisfies_requirement`
194    /// above — see the note on that method before reordering either one.
195    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                // The undecidable guard above already ensures `parse_range` succeeds here.
208                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    /// Impl-critic S2: an unresolved `${property}` groupId/artifactId (e.g. a
316    /// multi-module POM's `<groupId>${project.groupId}</groupId>`) is valid Maven, not a
317    /// malformed coordinate — must be treated as undecidable (`Ok`), not rejected.
318    #[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        // Critic S2 gate: `osv_version_to_native` is identity for Maven (OSV
380        // records use Maven's own version syntax verbatim), so the version
381        // it hands to `format_version_for_text_edit` must itself satisfy the
382        // requirement text that edit produces — proving the default hook is
383        // safe for this ecosystem rather than merely assumed so.
384        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    /// M2: `<version>1.0</version>` and a published `1.0.0` are equal under Maven's own
426    /// `ComparableVersion` (trailing zero segments don't matter) — the exact-match branch
427    /// must not fall back to raw string equality and report a false WARNING.
428    #[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    /// M2: `LATEST`/`RELEASE` resolve against maven-metadata.xml's `<latest>`/`<release>`
437    /// elements, a side channel this matcher has no access to — must be treated as always
438    /// satisfied, like an unresolved property, not compared literally against `available`.
439    #[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    /// S6: a `-SNAPSHOT` pin resolves against the snapshot repository, which this registry
450    /// never queries — release-repo metadata never lists snapshot versions, so this must be
451    /// treated as always satisfied rather than reported unsatisfiable.
452    #[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    /// #249 review regression: a malformed range that also happens to end in `-SNAPSHOT` or
463    /// contain `${` must still be rejected (`None`), not misclassified as always-satisfied by
464    /// checking the `AlwaysSatisfied` short-circuits before the malformed-range guard.
465    #[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    /// A resolved timestamped-snapshot deployment (the form `-SNAPSHOT` takes once
479    /// actually published to the snapshot repository) is subject to the same
480    /// never-queried-repository limitation as the plain `-SNAPSHOT` pin above.
481    #[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        // A version with a trailing numeric qualifier that merely looks similar but isn't
490        // a `yyyyMMdd.HHmmss-N` stamp must still be compared normally.
491        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}