Skip to main content

mago_codex/metadata/
version_constraint.rs

1use mago_php_version::PHPVersion;
2use mago_php_version::PHPVersionRange;
3use serde::Deserialize;
4use serde::Serialize;
5
6/// Tracks the PHP version intervals in which a symbol is available, derived
7/// from `Mago\AvailableSince` / `Mago\AvailableUntil` attributes during
8/// scanning.
9///
10/// Both attributes are repeatable, so a symbol can declare disjoint
11/// availability ranges (for example "available 8.1–8.3, removed in 8.4,
12/// brought back in 8.5"). An empty range list means "always available";
13/// otherwise a version is allowed when *some* range contains it.
14#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub struct VersionConstraint {
16    /// Disjoint availability intervals in source order. Empty = unconstrained.
17    pub ranges: Vec<PHPVersionRange>,
18}
19
20impl VersionConstraint {
21    #[inline]
22    #[must_use]
23    pub const fn unconstrained() -> Self {
24        Self { ranges: Vec::new() }
25    }
26
27    #[inline]
28    #[must_use]
29    pub const fn is_unconstrained(&self) -> bool {
30        self.ranges.is_empty()
31    }
32
33    /// Records a new `Mago\AvailableSince(version)` claim — opens a fresh
34    /// range that stays open on the right until a matching `AvailableUntil`
35    /// claim closes it.
36    #[inline]
37    pub fn push_since(&mut self, version: PHPVersion) {
38        self.ranges.push(PHPVersionRange::from(version));
39    }
40
41    /// Records a new `Mago\AvailableUntil(version)` claim. Closes the most
42    /// recent open range if there is one; otherwise opens a brand-new range
43    /// with no `min` bound.
44    #[inline]
45    pub fn push_until(&mut self, version: PHPVersion) {
46        if let Some(last) = self.ranges.last_mut()
47            && last.max.is_none()
48        {
49            last.max = Some(version);
50            return;
51        }
52
53        self.ranges.push(PHPVersionRange::until(version));
54    }
55
56    /// Returns `true` when `version` is allowed by *any* range in this
57    /// constraint.
58    #[inline]
59    #[must_use]
60    pub fn allows_version(&self, version: PHPVersion) -> bool {
61        if self.ranges.is_empty() {
62            return true;
63        }
64
65        self.ranges.iter().any(|r| r.includes(version))
66    }
67
68    /// Returns `true` when *every* PHP version in `range` is covered by the
69    /// union of ranges in this constraint. An open `min`/`max` on `range` is
70    /// treated as the platform's known low/high bound.
71    #[inline]
72    #[must_use]
73    pub fn allows_version_range(&self, range: PHPVersionRange) -> bool {
74        if self.ranges.is_empty() {
75            return true;
76        }
77
78        let min = range.min.map_or(PHPVersion::from_version_id(0), |v| v);
79        let max = range.max.map_or(PHPVersion::from_version_id(u32::MAX), |v| v);
80
81        if min > max {
82            return true;
83        }
84
85        let mut sorted: Vec<&PHPVersionRange> = self.ranges.iter().collect();
86        sorted.sort_by_key(|r| r.min);
87
88        let mut next_required = min;
89        for r in sorted {
90            let r_min = r.min.unwrap_or(PHPVersion::from_version_id(0));
91            let r_max = r.max.unwrap_or(PHPVersion::from_version_id(u32::MAX));
92
93            // Skip ranges that end before what we still need to cover.
94            if r_max < next_required {
95                continue;
96            }
97
98            // If this range opens after the next-required version, there's a
99            // gap that no later range can close (ranges are sorted ascending
100            // by `min`).
101            if r_min > next_required {
102                return false;
103            }
104
105            if r_max >= max {
106                return true;
107            }
108
109            // Bump past this range. There's no `+1` notion on PHPVersion, so
110            // re-cast through the packed id; every range we'd be looking for
111            // next must start strictly after `r_max`.
112            next_required = PHPVersion::from_version_id(r_max.to_version_id().saturating_add(1));
113        }
114
115        false
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn v(major: u32, minor: u32, patch: u32) -> PHPVersion {
124        PHPVersion::new(major, minor, patch)
125    }
126
127    #[test]
128    fn unconstrained_allows_everything() {
129        let c = VersionConstraint::unconstrained();
130        assert!(c.allows_version(v(7, 0, 0)));
131        assert!(c.allows_version(v(8, 5, 0)));
132        assert!(c.allows_version_range(PHPVersionRange::between(v(7, 0, 0), v(9, 0, 0))));
133    }
134
135    #[test]
136    fn since_only_open_on_the_right() {
137        let mut c = VersionConstraint::unconstrained();
138        c.push_since(v(8, 1, 0));
139        assert!(!c.allows_version(v(8, 0, 0)));
140        assert!(c.allows_version(v(8, 1, 0)));
141        assert!(c.allows_version(v(8, 5, 0)));
142    }
143
144    #[test]
145    fn until_only_open_on_the_left() {
146        let mut c = VersionConstraint::unconstrained();
147        c.push_until(v(8, 3, 0));
148        assert!(c.allows_version(v(7, 0, 0)));
149        assert!(c.allows_version(v(8, 3, 0)));
150        assert!(!c.allows_version(v(8, 4, 0)));
151    }
152
153    #[test]
154    fn since_then_until_closes_the_range() {
155        let mut c = VersionConstraint::unconstrained();
156        c.push_since(v(8, 1, 0));
157        c.push_until(v(8, 3, 0));
158        assert!(!c.allows_version(v(8, 0, 0)));
159        assert!(c.allows_version(v(8, 1, 0)));
160        assert!(c.allows_version(v(8, 3, 0)));
161        assert!(!c.allows_version(v(8, 4, 0)));
162    }
163
164    #[test]
165    fn disjoint_ranges_compose() {
166        let mut c = VersionConstraint::unconstrained();
167        c.push_since(v(8, 1, 0));
168        c.push_until(v(8, 3, 0));
169        c.push_since(v(8, 5, 0));
170
171        assert!(!c.allows_version(v(8, 0, 0)));
172        assert!(c.allows_version(v(8, 1, 0)));
173        assert!(c.allows_version(v(8, 3, 0)));
174        assert!(!c.allows_version(v(8, 4, 0)));
175        assert!(c.allows_version(v(8, 5, 0)));
176        assert!(c.allows_version(v(9, 0, 0)));
177    }
178
179    #[test]
180    fn range_query_requires_full_coverage() {
181        let mut c = VersionConstraint::unconstrained();
182        c.push_since(v(8, 1, 0));
183        c.push_until(v(8, 3, 0));
184        c.push_since(v(8, 5, 0));
185
186        // 8.0–8.7 has a gap at 8.4, so this fails.
187        assert!(!c.allows_version_range(PHPVersionRange::between(v(8, 0, 0), v(8, 7, 0))));
188        // 8.5+ is fully inside the open right range.
189        assert!(c.allows_version_range(PHPVersionRange::between(v(8, 5, 0), v(8, 7, 0))));
190        // 8.1–8.3 sits inside the first range.
191        assert!(c.allows_version_range(PHPVersionRange::between(v(8, 1, 0), v(8, 3, 0))));
192    }
193}