Skip to main content

deps_maven/
interval.rs

1//! Shared bracket-interval version range parsing, used by both `deps-maven` and
2//! `deps-gradle`.
3//!
4//! Maven and Gradle both express a single version range as a bracket interval
5//! (`[1.0,2.0)`, `[1.0]`, `[1.5,)`, `(,2.0]`). Gradle additionally accepts a
6//! reversed-bracket exclusive notation Maven does not have (`]1.2,1.5]` for an
7//! exclusive lower bound, `[1.1,2.0[` for an exclusive upper bound) — the only
8//! grammar difference between the two, selected via [`BracketStyle`]. What
9//! differs between the ecosystems is what wraps a single interval: Maven allows a
10//! top-level comma union of intervals (`(,1.0),(1.2,)`), handled by
11//! `deps_maven::range`; Gradle has no such union and a single interval is the
12//! whole requirement, handled by `deps_gradle::range`. Bounds are compared with
13//! `crate::version::compare_versions_for_range`, which understands Maven's qualifier
14//! precedence (`alpha < beta < milestone < rc < snapshot < release < sp`) — plain numeric
15//! parsing would misorder bounds like `[1.0-beta,2.0-rc)` — and normalizes a missing trailing
16//! segment as zero, so a bound and the version it is checked against need not share the same
17//! segment count (`[1.0]` matches `1.0.0`).
18
19use crate::version::compare_versions_for_range;
20use std::cmp::Ordering;
21
22/// A single parsed bracket interval, e.g. `[1.0,2.0)` or `[1.0]`.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum VersionRange {
25    /// `[1.0]` — matches only that exact version.
26    Exact(String),
27    /// `[1.5,)` / `(1.5,)` — an open-ended lower bound.
28    Minimum {
29        /// The lower bound version.
30        version: String,
31        /// Whether `version` itself is included in the range.
32        inclusive: bool,
33    },
34    /// `(,2.0]` / `(,2.0)` — an open-ended upper bound.
35    Maximum {
36        /// The upper bound version.
37        version: String,
38        /// Whether `version` itself is included in the range.
39        inclusive: bool,
40    },
41    /// `[1.0,2.0)` — both bounds present.
42    Bounded {
43        /// The lower bound version.
44        min: String,
45        /// Whether `min` itself is included in the range.
46        min_inclusive: bool,
47        /// The upper bound version.
48        max: String,
49        /// Whether `max` itself is included in the range.
50        max_inclusive: bool,
51    },
52}
53
54/// Selects the delimiter grammar [`parse_interval`] accepts.
55///
56/// `Standard` is Maven's grammar: `[`/`]` are inclusive, `(`/`)` are
57/// exclusive, and no character serves as both an opener and a closer.
58/// `AllowReversed` adds Gradle's reversed-bracket exclusive notation on top:
59/// a leading `]` or trailing `[` is also accepted as an exclusive bound
60/// (`]1.2,1.5]`, `[1.1,2.0[`).
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum BracketStyle {
63    /// Maven's grammar: `[`/`]` inclusive, `(`/`)` exclusive only.
64    Standard,
65    /// `Standard` plus Gradle's reversed-bracket exclusive notation (`]`/`[`).
66    AllowReversed,
67}
68
69/// Parses one bracketed interval under the given [`BracketStyle`].
70///
71/// Returns `None` for anything that isn't a well-formed `[`/`(`/`]` ... `]`/`)`/`[`
72/// interval: unbalanced delimiters, a single character that cannot serve as both
73/// delimiters, empty bounds on both sides, a stray bracket character nested inside
74/// the bounds (e.g. `[[1.0,2.0)`, `[1.0,2.0)]`), a third comma-separated component
75/// (`[1.0,2.0,3.0]`), or a no-comma body whose delimiters aren't the matching
76/// inclusive pair `[...]` (`[1.0)`, `(1.0]` — neither grammar has a reversed-bracket
77/// exact-pin form). Callers treat an unparseable interval as satisfying nothing
78/// rather than panicking.
79pub fn parse_interval(s: &str, style: BracketStyle) -> Option<VersionRange> {
80    let s = s.trim();
81    let first = s.chars().next()?;
82    let min_inclusive = match (first, style) {
83        ('[', _) => true,
84        ('(', _) => false,
85        (']', BracketStyle::AllowReversed) => false,
86        _ => return None,
87    };
88    let last = s.chars().next_back()?;
89    let max_inclusive = match (last, style) {
90        (']', _) => true,
91        (')', _) => false,
92        ('[', BracketStyle::AllowReversed) => false,
93        _ => return None,
94    };
95
96    // A single character cannot be both delimiters; without this the slice below
97    // would have start > end (AllowReversed makes `[` and `]` valid on both sides).
98    if s.len() < first.len_utf8() + last.len_utf8() {
99        return None;
100    }
101
102    let inner = &s[first.len_utf8()..s.len() - last.len_utf8()];
103
104    if inner.contains(['[', ']', '(', ')']) {
105        return None;
106    }
107
108    if let Some((lo, hi)) = inner.split_once(',') {
109        if hi.contains(',') {
110            return None;
111        }
112        let lo = lo.trim();
113        let hi = hi.trim();
114        let min = (!lo.is_empty()).then(|| lo.to_string());
115        let max = (!hi.is_empty()).then(|| hi.to_string());
116        match (min, max) {
117            (Some(min), Some(max)) => Some(VersionRange::Bounded {
118                min,
119                min_inclusive,
120                max,
121                max_inclusive,
122            }),
123            (Some(version), None) => Some(VersionRange::Minimum {
124                version,
125                inclusive: min_inclusive,
126            }),
127            (None, Some(version)) => Some(VersionRange::Maximum {
128                version,
129                inclusive: max_inclusive,
130            }),
131            (None, None) => None,
132        }
133    } else {
134        let inner = inner.trim();
135        (!inner.is_empty() && min_inclusive && max_inclusive)
136            .then(|| VersionRange::Exact(inner.to_string()))
137    }
138}
139
140fn satisfies_min(v: &str, min: &str, inclusive: bool) -> bool {
141    let ord = compare_versions_for_range(v, min);
142    if inclusive {
143        ord != Ordering::Less
144    } else {
145        ord == Ordering::Greater
146    }
147}
148
149fn satisfies_max(v: &str, max: &str, inclusive: bool) -> bool {
150    let ord = compare_versions_for_range(v, max);
151    if inclusive {
152        ord != Ordering::Greater
153    } else {
154        ord == Ordering::Less
155    }
156}
157
158/// Whether `version` falls inside the parsed interval `range`.
159pub fn contains(version: &str, range: &VersionRange) -> bool {
160    match range {
161        VersionRange::Exact(target) => {
162            compare_versions_for_range(version, target) == Ordering::Equal
163        }
164        VersionRange::Minimum {
165            version: min,
166            inclusive,
167        } => satisfies_min(version, min, *inclusive),
168        VersionRange::Maximum {
169            version: max,
170            inclusive,
171        } => satisfies_max(version, max, *inclusive),
172        VersionRange::Bounded {
173            min,
174            min_inclusive,
175            max,
176            max_inclusive,
177        } => {
178            satisfies_min(version, min, *min_inclusive)
179                && satisfies_max(version, max, *max_inclusive)
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn parses(s: &str, style: BracketStyle) -> bool {
189        parse_interval(s, style).is_some()
190    }
191
192    #[test]
193    fn test_satisfies_exact_pin() {
194        for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
195            let range = parse_interval("[1.0]", style).unwrap();
196            assert!(contains("1.0", &range));
197            assert!(!contains("1.0.1", &range));
198        }
199    }
200
201    #[test]
202    fn test_satisfies_bounded_no_comma_vs_with_comma() {
203        for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
204            let exact = parse_interval("[1.0]", style).unwrap();
205            let bounded = parse_interval("[1.0,1.0]", style).unwrap();
206            assert!(contains("1.0", &exact));
207            assert!(contains("1.0", &bounded));
208            assert!(!contains("1.0.1", &bounded));
209        }
210    }
211
212    #[test]
213    fn test_satisfies_open_ended_minimum() {
214        for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
215            let range = parse_interval("[1.5,)", style).unwrap();
216            assert!(contains("1.5", &range));
217            assert!(contains("2.0", &range));
218            assert!(!contains("1.4", &range));
219        }
220    }
221
222    #[test]
223    fn test_satisfies_open_ended_maximum() {
224        for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
225            let inclusive = parse_interval("(,2.0]", style).unwrap();
226            assert!(contains("2.0", &inclusive));
227            assert!(!contains("2.0.1", &inclusive));
228            let exclusive = parse_interval("(,2.0)", style).unwrap();
229            assert!(contains("1.9", &exclusive));
230            assert!(!contains("2.0", &exclusive));
231        }
232    }
233
234    #[test]
235    fn test_satisfies_bounded_exclusive_inclusive_mix() {
236        for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
237            let range = parse_interval("[1.0,2.0)", style).unwrap();
238            assert!(contains("1.5", &range));
239            assert!(!contains("2.0", &range));
240            let range = parse_interval("(1.0,2.0)", style).unwrap();
241            assert!(!contains("1.0", &range));
242            assert!(contains("1.0.1", &range));
243        }
244    }
245
246    #[test]
247    fn test_satisfies_whitespace_inside_brackets() {
248        for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
249            let range = parse_interval("[ 1.0 , 2.0 )", style).unwrap();
250            assert!(contains("1.5", &range));
251        }
252    }
253
254    #[test]
255    fn test_satisfies_malformed_brackets_return_false() {
256        for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
257            assert!(!parses("[1.0,2.0", style));
258            assert!(!parses("1.0,2.0)", style));
259            assert!(!parses("(,)", style));
260        }
261    }
262
263    #[test]
264    fn test_satisfies_rejects_mismatched_no_comma_brackets() {
265        // A no-comma body is only a valid exact pin when both delimiters are the
266        // matching inclusive pair `[...]`; neither grammar has a reversed-bracket
267        // exact-pin notation.
268        for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
269            assert!(!parses("[1.0)", style));
270            assert!(!parses("(1.0]", style));
271            assert!(!parses("(1.0)", style));
272        }
273    }
274
275    #[test]
276    fn test_satisfies_rejects_mismatched_no_comma_reversed_brackets() {
277        // M1: the no-comma exact-pin path must reject reversed-bracket delimiters
278        // under AllowReversed too — only the matching inclusive pair `[...]` is a
279        // valid exact pin.
280        assert!(!parses("]1.0[", BracketStyle::AllowReversed));
281        assert!(!parses("]1.0]", BracketStyle::AllowReversed));
282        assert!(!parses("[1.0[", BracketStyle::AllowReversed));
283    }
284
285    #[test]
286    fn test_satisfies_rejects_stray_nested_brackets() {
287        for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
288            assert!(!parses("[[1.0,2.0)", style));
289            assert!(!parses("[1.0,2.0)]", style));
290        }
291    }
292
293    #[test]
294    fn test_satisfies_rejects_extra_component() {
295        for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
296            assert!(!parses("[1.0,2.0,3.0]", style));
297        }
298    }
299
300    #[test]
301    fn test_satisfies_qualifier_bearing_bounds() {
302        for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
303            let range = parse_interval("[1.0-beta,2.0-rc)", style).unwrap();
304            assert!(contains("1.0-milestone", &range));
305            assert!(!contains("1.0-alpha", &range));
306            assert!(!contains("2.0-rc", &range));
307            assert!(contains("2.0-milestone", &range));
308        }
309    }
310
311    #[test]
312    fn test_reversed_bracket_accepted_under_allow_reversed() {
313        let lower = parse_interval("]1.2,1.5]", BracketStyle::AllowReversed).unwrap();
314        assert!(!contains("1.2", &lower));
315        assert!(contains("1.3", &lower));
316        assert!(contains("1.5", &lower));
317        assert!(!contains("1.6", &lower));
318
319        let upper = parse_interval("[1.1,2.0[", BracketStyle::AllowReversed).unwrap();
320        assert!(contains("1.1", &upper));
321        assert!(contains("1.5", &upper));
322        assert!(!contains("2.0", &upper));
323        assert!(!contains("1.0", &upper));
324    }
325
326    #[test]
327    fn test_reversed_bracket_rejected_under_standard() {
328        assert!(!parses("]1.2,1.5]", BracketStyle::Standard));
329        assert!(!parses("[1.1,2.0[", BracketStyle::Standard));
330    }
331
332    #[test]
333    fn test_single_delimiter_is_rejected_not_panicking() {
334        // #187: a single-character requirement is a char that would need to serve
335        // as both the opener and closer; AllowReversed makes `[` and `]` valid on
336        // both sides, so without the length guard this panics on a start > end
337        // slice instead of returning None.
338        for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
339            assert!(!parses("[", style));
340            assert!(!parses("]", style));
341        }
342    }
343}