Skip to main content

rer_version/
range.rs

1//! Faithful port of rez's `VersionRange` semantics.
2//!
3//! rez's solver relies on a specific contract that `version_ranges::Ranges`
4//! does not express directly — chiefly that `intersection`, the `-` operator
5//! and `inverse` return `None` to signal "empty / no result", and that there
6//! is a distinguished "any" range. This module wraps `Ranges<RerVersion>` and
7//! re-exposes rez's API (`rez/src/rez/version/_version.py`, class
8//! `VersionRange`) so the rest of the solver port can mirror the Python code
9//! closely.
10
11use crate::requirement::parser::parse_version_range;
12use crate::version::RerVersion;
13use core::fmt;
14use std::ops::Bound;
15use std::rc::Rc;
16use version_ranges::Ranges;
17
18/// A set of one or more contiguous version ranges, with rez's semantics.
19///
20/// Construct from a rez range string with [`VersionRange::parse`], from a
21/// single version with [`VersionRange::from_version`], or from an existing
22/// [`Ranges`] with [`VersionRange::from_ranges`].
23///
24/// The inner [`Ranges`] is wrapped in `Rc` so that a `VersionRange::clone` is
25/// a refcount bump rather than a `SmallVec` allocation. The solver clones
26/// requirements (and the ranges inside them) constantly during a solve;
27/// callgrind put `SmallVec::extend` + `Drop` at ~4 % of cycles after #66/
28/// /#67/#68/#70/#71/#72, almost entirely from these clones.
29///
30/// `PartialEq`, `Eq` and `Hash` on `Rc<T>` defer to the inner `T`, so the
31/// semantics are unchanged from the previous `Ranges<RerVersion>` field.
32#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33pub struct VersionRange(Rc<Ranges<RerVersion>>);
34
35impl VersionRange {
36    /// The "any" range — contains every version. Mirrors rez's empty-string range.
37    pub fn any() -> Self {
38        VersionRange(Rc::new(Ranges::full()))
39    }
40
41    /// The empty range — contains no version. rez represents "empty" as `None`
42    /// at the API boundary, but an empty `VersionRange` is still useful
43    /// internally (e.g. as a `from_versions` accumulator).
44    pub fn empty() -> Self {
45        VersionRange(Rc::new(Ranges::empty()))
46    }
47
48    /// Parse a rez range string such as `"3"`, `"1+<2"`, `"==1.0"`, `"2|6+"`.
49    ///
50    /// `|`-separated alternatives are unioned together. The empty string is the
51    /// "any" range.
52    ///
53    /// # Panics
54    ///
55    /// Panics on a syntactically invalid range string, matching the existing
56    /// `parse_version_range` behaviour.
57    pub fn parse(s: &str) -> Self {
58        let mut range: Option<Ranges<RerVersion>> = None;
59        for part in s.split('|') {
60            let part_range = parse_version_range(part);
61            range = Some(match range {
62                None => part_range,
63                Some(acc) => acc.union(&part_range),
64            });
65        }
66        VersionRange(Rc::new(range.unwrap_or_else(Ranges::full)))
67    }
68
69    /// Wrap a raw [`Ranges`] (already produced by the requirement parser).
70    pub fn from_ranges(ranges: Ranges<RerVersion>) -> Self {
71        VersionRange(Rc::new(ranges))
72    }
73
74    /// Borrow the underlying [`Ranges`].
75    pub fn as_ranges(&self) -> &Ranges<RerVersion> {
76        &self.0
77    }
78
79    /// Consume into the underlying [`Ranges`]. Clones the inner if the `Rc` is
80    /// shared.
81    pub fn into_ranges(self) -> Ranges<RerVersion> {
82        Rc::unwrap_or_clone(self.0)
83    }
84
85    /// rez `VersionRange.from_version` with `op=None`: the "superset" range
86    /// `[version, version.next())`, e.g. `3` contains `3`, `3.0`, `3.1.4`.
87    pub fn from_version(version: &RerVersion) -> Self {
88        VersionRange(Rc::new(Ranges::between(version.clone(), version.bump())))
89    }
90
91    /// rez `VersionRange.from_versions`: a range containing exactly the given
92    /// versions and nothing else (e.g. `==3|==4|==5.1`).
93    ///
94    /// Built in a single pass via `Ranges`'s `FromIterator` rather than folding
95    /// `union` over singletons (which reallocated and was O(n²)) — this is hot,
96    /// it backs `_PackageScope._update`.
97    pub fn from_versions<I: IntoIterator<Item = RerVersion>>(versions: I) -> Self {
98        VersionRange(Rc::new(
99            versions
100                .into_iter()
101                .map(|v| (Bound::Included(v.clone()), Bound::Included(v)))
102                .collect(),
103        ))
104    }
105
106    /// rez `VersionRange.is_any`: true for the range that contains all versions.
107    pub fn is_any(&self) -> bool {
108        *self.0 == Ranges::full()
109    }
110
111    /// True if this range contains no version.
112    pub fn is_empty(&self) -> bool {
113        self.0.is_empty()
114    }
115
116    /// rez `VersionRange.intersection` (`&`): `None` if the ranges are disjoint.
117    pub fn intersection(&self, other: &Self) -> Option<Self> {
118        let result = self.0.intersection(&other.0);
119        if result.is_empty() {
120            None
121        } else {
122            Some(VersionRange(Rc::new(result)))
123        }
124    }
125
126    /// rez `VersionRange.union` (`|`): always succeeds.
127    pub fn union(&self, other: &Self) -> Self {
128        VersionRange(Rc::new(self.0.union(&other.0)))
129    }
130
131    /// rez `VersionRange.inverse` (`~`): `None` if and only if this is the
132    /// "any" range (whose inverse would be empty).
133    pub fn inverse(&self) -> Option<Self> {
134        if self.is_any() {
135            None
136        } else {
137            Some(VersionRange(Rc::new(self.0.complement())))
138        }
139    }
140
141    /// rez `VersionRange.__sub__` (`-`): `self & ~other`. `None` if `other` is
142    /// the "any" range, or if the result is empty.
143    pub fn difference(&self, other: &Self) -> Option<Self> {
144        match other.inverse() {
145            None => None,
146            Some(inverse) => self.intersection(&inverse),
147        }
148    }
149
150    /// rez `VersionRange.issuperset`: true if `other` is fully contained here.
151    pub fn issuperset(&self, other: &Self) -> bool {
152        other.0.subset_of(&self.0)
153    }
154
155    /// rez `VersionRange.issubset`: true if `self` is fully contained in `other`.
156    pub fn issubset(&self, other: &Self) -> bool {
157        other.issuperset(self)
158    }
159
160    /// rez `VersionRange.intersects`: true if the ranges share any version.
161    pub fn intersects(&self, other: &Self) -> bool {
162        !self.0.intersection(&other.0).is_empty()
163    }
164
165    /// rez `VersionRange.contains_version` / `version in range`.
166    pub fn contains(&self, version: &RerVersion) -> bool {
167        self.0.contains(version)
168    }
169
170    /// rez `VersionRange.to_versions`: the exact (single-version) bounds present
171    /// in this range, or `None` if there are none. Non-exact bounds are ignored,
172    /// matching rez.
173    pub fn to_versions(&self) -> Option<Vec<RerVersion>> {
174        let mut versions = Vec::new();
175        for (lower, upper) in self.0.iter() {
176            if let (Bound::Included(low), Bound::Included(high)) = (lower, upper) {
177                if low == high {
178                    versions.push(low.clone());
179                }
180            }
181        }
182        if versions.is_empty() {
183            None
184        } else {
185            Some(versions)
186        }
187    }
188
189    /// rez `VersionRange.span`: the smallest contiguous range that is a superset
190    /// of this range (e.g. the span of `2+<4|6+<8` is `2+<8`).
191    pub fn span(&self) -> Self {
192        match self.0.bounding_range() {
193            None => VersionRange::empty(),
194            Some((lower, upper)) => VersionRange(Rc::new(Ranges::from_range_bounds((
195                clone_bound(lower),
196                clone_bound(upper),
197            )))),
198        }
199    }
200
201    /// rez `VersionRange.split`: one [`VersionRange`] per contiguous sub-range
202    /// (e.g. `3|5+` splits into `["3", "5+"]`).
203    pub fn split(&self) -> Vec<Self> {
204        self.0
205            .iter()
206            .map(|(lower, upper)| {
207                VersionRange(Rc::new(Ranges::from_range_bounds((
208                    lower.clone(),
209                    upper.clone(),
210                ))))
211            })
212            .collect()
213    }
214}
215
216/// Clone a `Bound<&V>` into an owned `Bound<V>`.
217fn clone_bound(bound: Bound<&RerVersion>) -> Bound<RerVersion> {
218    match bound {
219        Bound::Included(v) => Bound::Included(v.clone()),
220        Bound::Excluded(v) => Bound::Excluded(v.clone()),
221        Bound::Unbounded => Bound::Unbounded,
222    }
223}
224
225/// Format one contiguous segment in rez's range syntax, mirroring rez's
226/// `_Bound.__str__` (`rez/src/rez/version/_version.py:506`).
227fn format_segment(lower: &Bound<RerVersion>, upper: &Bound<RerVersion>) -> String {
228    // 1. Infinite upper bound -> just the lower bound string.
229    if matches!(upper, Bound::Unbounded) {
230        return match lower {
231            Bound::Unbounded => String::new(),
232            Bound::Included(v) => format!("{v}+"),
233            Bound::Excluded(v) => format!(">{v}"),
234        };
235    }
236    let (upper_version, upper_inclusive) = match upper {
237        Bound::Included(v) => (v, true),
238        Bound::Excluded(v) => (v, false),
239        Bound::Unbounded => unreachable!(),
240    };
241    let lower_version = match lower {
242        Bound::Included(v) | Bound::Excluded(v) => Some(v),
243        Bound::Unbounded => None,
244    };
245    let lower_inclusive = !matches!(lower, Bound::Excluded(_));
246
247    // 2. Single exact version -> "==v".
248    if lower_version == Some(upper_version) {
249        return format!("=={upper_version}");
250    }
251    // 3. Both bounds inclusive.
252    if lower_inclusive && upper_inclusive {
253        return match lower_version {
254            Some(lv) => format!("{lv}..{upper_version}"),
255            None => format!("<={upper_version}"),
256        };
257    }
258    // 4. The "superset" form: [v, v.next()) prints as just "v".
259    if lower_inclusive && !upper_inclusive {
260        if let Some(lv) = lower_version {
261            if &lv.bump() == upper_version {
262                return lv.to_string();
263            }
264        }
265    }
266    // 5. General case: lower string followed by upper string.
267    let lower_str = match lower {
268        Bound::Unbounded => String::new(),
269        Bound::Included(v) => format!("{v}+"),
270        Bound::Excluded(v) => format!(">{v}"),
271    };
272    let upper_str = if upper_inclusive {
273        format!("<={upper_version}")
274    } else {
275        format!("<{upper_version}")
276    };
277    format!("{lower_str}{upper_str}")
278}
279
280impl fmt::Display for VersionRange {
281    /// Renders in rez's compact range syntax (`3`, `3+<5`, `==1.0`, `2|6+`).
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        let mut first = true;
284        for (lower, upper) in self.0.iter() {
285            if !first {
286                write!(f, "|")?;
287            }
288            first = false;
289            write!(f, "{}", format_segment(lower, upper))?;
290        }
291        Ok(())
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn v(s: &str) -> RerVersion {
300        RerVersion::try_from(s).unwrap()
301    }
302
303    #[test]
304    fn test_any_and_empty() {
305        assert!(VersionRange::any().is_any());
306        assert!(!VersionRange::any().is_empty());
307        assert!(VersionRange::empty().is_empty());
308        assert!(!VersionRange::empty().is_any());
309        // The parsed empty-string range is the "any" range, per rez.
310        assert!(VersionRange::parse("").is_any());
311    }
312
313    #[test]
314    fn test_intersection_none_when_disjoint() {
315        let a = VersionRange::parse("1+<2");
316        let b = VersionRange::parse("3+<4");
317        assert!(a.intersection(&b).is_none());
318        let c = VersionRange::parse("1.5+<3");
319        let hit = a.intersection(&c).unwrap();
320        assert!(hit.contains(&v("1.6")));
321        assert!(!hit.contains(&v("2.0")));
322    }
323
324    #[test]
325    fn test_inverse_none_only_for_any() {
326        assert!(VersionRange::any().inverse().is_none());
327        let inv = VersionRange::parse("2+<3").inverse().unwrap();
328        assert!(inv.contains(&v("1.0")));
329        assert!(inv.contains(&v("4.0")));
330        assert!(!inv.contains(&v("2.5")));
331    }
332
333    #[test]
334    fn test_difference() {
335        // a - b = a & ~b
336        let a = VersionRange::parse("1+<5");
337        let b = VersionRange::parse("2+<3");
338        let diff = a.difference(&b).unwrap();
339        assert!(diff.contains(&v("1.5")));
340        assert!(!diff.contains(&v("2.5")));
341        assert!(diff.contains(&v("3.0")));
342        // subtracting "any" yields None
343        assert!(a.difference(&VersionRange::any()).is_none());
344        // subtracting a superset empties the result -> None
345        assert!(b.difference(&a).is_none());
346    }
347
348    #[test]
349    fn test_superset_subset_intersects() {
350        let wide = VersionRange::parse("1+<10");
351        let narrow = VersionRange::parse("2+<3");
352        assert!(wide.issuperset(&narrow));
353        assert!(!narrow.issuperset(&wide));
354        assert!(narrow.issubset(&wide));
355        assert!(wide.intersects(&narrow));
356        assert!(!narrow.intersects(&VersionRange::parse("5+<6")));
357    }
358
359    #[test]
360    fn test_from_version_superset() {
361        // rez: the range `3` is the superset of any `3[.X.X...]`.
362        let r = VersionRange::from_version(&v("3"));
363        assert!(r.contains(&v("3")));
364        assert!(r.contains(&v("3.0")));
365        assert!(r.contains(&v("3.1.4")));
366        assert!(!r.contains(&v("4")));
367    }
368
369    #[test]
370    fn test_from_versions_and_to_versions() {
371        let r = VersionRange::from_versions([v("3"), v("5.1"), v("4")]);
372        let mut versions = r.to_versions().unwrap();
373        versions.sort();
374        assert_eq!(versions, vec![v("3"), v("4"), v("5.1")]);
375        // a non-exact range has no exact versions
376        assert!(VersionRange::parse("1+<2").to_versions().is_none());
377    }
378
379    #[test]
380    fn test_display_rez_format() {
381        // Compact rez range syntax, not the underlying Ranges debug format.
382        assert_eq!(VersionRange::parse("4").to_string(), "4");
383        assert_eq!(VersionRange::parse("1.0").to_string(), "1.0");
384        assert_eq!(VersionRange::parse("3+<5").to_string(), "3+<5");
385        assert_eq!(VersionRange::parse("3+").to_string(), "3+");
386        assert_eq!(VersionRange::parse("<3").to_string(), "<3");
387        assert_eq!(VersionRange::parse("==1.0.1").to_string(), "==1.0.1");
388        assert_eq!(VersionRange::parse("2|5").to_string(), "2|5");
389        assert_eq!(VersionRange::any().to_string(), "");
390    }
391
392    #[test]
393    fn test_span_and_split() {
394        let r = VersionRange::parse("2+<4|6+<8");
395        let span = r.span();
396        assert!(span.contains(&v("5")));
397        assert!(span.contains(&v("3")));
398        assert!(!span.contains(&v("8")));
399        let parts = r.split();
400        assert_eq!(parts.len(), 2);
401        assert!(parts[0].contains(&v("3")));
402        assert!(!parts[0].contains(&v("6")));
403        assert!(parts[1].contains(&v("6")));
404    }
405}