Skip to main content

deps_maven/
range.rs

1//! Maven version range parsing and containment ([Maven versioning spec][spec]).
2//!
3//! # Why this is hand-rolled
4//!
5//! No maintained Rust crate implements Maven's interval-notation range grammar. Unlike
6//! NuGet (`deps-nuget`), Maven ranges may be a top-level comma-separated union of intervals
7//! (`(,1.0),(1.2,)`), so this module owns the union-splitting; each individual interval is
8//! parsed and matched by [`crate::interval`], shared with `deps-gradle`, which has the same
9//! bracket-interval grammar but no top-level union.
10//!
11//! A bare (non-bracketed) requirement such as `"1.0"` is Maven's "soft" recommended version,
12//! not a range, and is intentionally not handled here — see [`is_range`].
13//!
14//! [spec]: https://maven.apache.org/pom.html#dependency-version-requirement-specification
15
16use crate::interval::{BracketStyle, VersionRange, contains, parse_interval};
17
18/// Splits `s` on commas that are not nested inside a `[`/`(` ... `]`/`)` pair, so a
19/// union like `[1.0,2.0),[3.0,4.0)` yields two members while the inner min/max comma of
20/// a single member (handled by [`crate::interval::parse_interval`]) is left untouched.
21fn split_top_level(s: &str) -> Vec<&str> {
22    let mut parts = Vec::new();
23    let mut depth = 0i32;
24    let mut start = 0usize;
25    for (i, c) in s.char_indices() {
26        match c {
27            '[' | '(' => depth += 1,
28            ']' | ')' => depth -= 1,
29            ',' if depth == 0 => {
30                parts.push(&s[start..i]);
31                start = i + 1;
32            }
33            _ => {}
34        }
35    }
36    parts.push(&s[start..]);
37    parts
38}
39
40/// Whether `requirement` looks like a Maven range/union, as opposed to a bare "soft"
41/// recommended version (which is compared for plain equality by the caller).
42pub fn is_range(requirement: &str) -> bool {
43    requirement.trim_start().starts_with(['[', '('])
44}
45
46/// Parses a Maven range/union `requirement` into its union members, once.
47///
48/// `requirement` may be a single interval (`[1.0,2.0)`, `[1.0]`, `[1.5,)`, `(,2.0]`) or a
49/// top-level comma union of intervals (`(,1.0),(1.2,)`). Returns `None` if any member fails
50/// to parse — a malformed union member indicates the whole `requirement` string is not the
51/// range its author intended, so treating it as satisfied by the well-formed members alone
52/// would be misleading, not just a missing feature.
53///
54/// Used by `MavenFormatter::compile_requirement` to parse the requirement once per
55/// dependency; the resulting `Vec<VersionRange>` is then tested against each candidate
56/// version via `satisfies_ranges` with no re-parsing.
57pub(crate) fn parse_range(requirement: &str) -> Option<Vec<VersionRange>> {
58    split_top_level(requirement.trim())
59        .iter()
60        .map(|member| parse_interval(member, BracketStyle::Standard))
61        .collect()
62}
63
64/// Whether `version` falls inside any member of an already-parsed range union.
65pub(crate) fn satisfies_ranges(version: &str, ranges: &[VersionRange]) -> bool {
66    ranges.iter().any(|range| contains(version, range))
67}
68
69/// Checks whether `version` satisfies a Maven range `requirement`.
70///
71/// Convenience wrapper around `parse_range` + `satisfies_ranges` for callers that don't
72/// need to test more than one candidate against the same requirement (unlike
73/// `MavenFormatter::compile_requirement`, which parses once via `parse_range` and reuses it).
74pub fn satisfies(version: &str, requirement: &str) -> bool {
75    match parse_range(requirement) {
76        Some(ranges) => satisfies_ranges(version, &ranges),
77        None => false,
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn test_is_range_detects_brackets() {
87        assert!(is_range("[1.0,2.0)"));
88        assert!(is_range("(1.0,2.0]"));
89        assert!(is_range("  [1.0]"));
90        assert!(!is_range("1.0"));
91        assert!(!is_range("${property}"));
92    }
93
94    #[test]
95    fn test_satisfies_bound_with_fewer_segments_than_version() {
96        // #182: a bound with fewer segments than the version normalizes its
97        // missing trailing segments as zero rather than rejecting the match.
98        assert!(satisfies("4.1.0", "[4.0,4.1]"));
99        assert!(satisfies("2.0.0", "(,2.0]"));
100        assert!(satisfies("1.0.0", "[1.0]"));
101        assert!(!satisfies("4.1.0", "[4.0,4.1)"));
102        assert!(!satisfies("2.0.0", "(,2.0)"));
103    }
104
105    #[test]
106    fn test_satisfies_bound_with_more_segments_than_version() {
107        // The reverse case must also hold: a version with fewer segments
108        // than the bound still matches when the missing segments are zero.
109        assert!(satisfies("4.1", "[4.1.0,4.2]"));
110        assert!(satisfies("2.0", "(,2.0.0]"));
111        assert!(satisfies("1.0", "[1.0.0]"));
112        assert!(!satisfies("4.1", "(4.1.0,4.2]"));
113    }
114
115    #[test]
116    fn test_satisfies_three_way_union() {
117        let req = "[1.0,2.0),[3.0,4.0),[5.0,)";
118        assert!(satisfies("1.5", req));
119        assert!(!satisfies("2.5", req));
120        assert!(satisfies("3.5", req));
121        assert!(!satisfies("4.5", req));
122        assert!(satisfies("9.0", req));
123    }
124
125    #[test]
126    fn test_satisfies_malformed_union_member_rejects_whole_requirement() {
127        // A malformed member must not be silently dropped — the whole requirement is
128        // rejected (fail-closed), even though the well-formed member(s) would otherwise
129        // have matched.
130        assert!(!satisfies("1.5", "[1.0,2.0),[3.0"));
131        assert!(!satisfies("1.5", "[1.0,2.0),garbage"));
132        assert!(!satisfies("1.5", "[1.0,2.0),"));
133        assert!(!satisfies("1.5", ",[1.0,2.0)"));
134        // A reversed-bracket (Gradle-only) member must not sneak through Maven's
135        // union parsing via a style mixup.
136        assert!(!satisfies("1.3", "]1.2,1.5]"));
137    }
138}