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    /// Folds `other` into `self` by unioning their availability ranges.
57    #[inline]
58    pub fn merge(&mut self, other: VersionConstraint) {
59        if self.is_unconstrained() || other.is_unconstrained() {
60            self.ranges.clear();
61            return;
62        }
63
64        self.ranges.extend(other.ranges);
65    }
66
67    /// Returns `true` when `version` is allowed by *any* range in this
68    /// constraint.
69    #[inline]
70    #[must_use]
71    pub fn allows_version(&self, version: PHPVersion) -> bool {
72        if self.ranges.is_empty() {
73            return true;
74        }
75
76        self.ranges.iter().any(|r| r.includes(version))
77    }
78
79    /// Returns `true` when *every* PHP version in `range` is covered by the
80    /// union of ranges in this constraint. An open `min`/`max` on `range` is
81    /// treated as the platform's known low/high bound.
82    #[inline]
83    #[must_use]
84    pub fn allows_version_range(&self, range: PHPVersionRange) -> bool {
85        if self.ranges.is_empty() {
86            return true;
87        }
88
89        let min = range.min.map_or(PHPVersion::from_version_id(0), |v| v);
90        let max = range.max.map_or(PHPVersion::from_version_id(u32::MAX), |v| v);
91
92        if min > max {
93            return true;
94        }
95
96        let mut sorted: Vec<&PHPVersionRange> = self.ranges.iter().collect();
97        sorted.sort_by_key(|r| r.min);
98
99        let mut next_required = min;
100        for r in sorted {
101            let r_min = r.min.unwrap_or(PHPVersion::from_version_id(0));
102            let r_max = r.max.unwrap_or(PHPVersion::from_version_id(u32::MAX));
103
104            // Skip ranges that end before what we still need to cover.
105            if r_max < next_required {
106                continue;
107            }
108
109            // If this range opens after the next-required version, there's a
110            // gap that no later range can close (ranges are sorted ascending
111            // by `min`).
112            if r_min > next_required {
113                return false;
114            }
115
116            if r_max >= max {
117                return true;
118            }
119
120            // Bump past this range. There's no `+1` notion on PHPVersion, so
121            // re-cast through the packed id; every range we'd be looking for
122            // next must start strictly after `r_max`.
123            next_required = PHPVersion::from_version_id(r_max.to_version_id().saturating_add(1));
124        }
125
126        false
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn v(major: u32, minor: u32, patch: u32) -> PHPVersion {
135        PHPVersion::new(major, minor, patch)
136    }
137
138    #[test]
139    fn unconstrained_allows_everything() {
140        let c = VersionConstraint::unconstrained();
141        assert!(c.allows_version(v(7, 0, 0)));
142        assert!(c.allows_version(v(8, 5, 0)));
143        assert!(c.allows_version_range(PHPVersionRange::between(v(7, 0, 0), v(9, 0, 0))));
144    }
145
146    #[test]
147    fn since_only_open_on_the_right() {
148        let mut c = VersionConstraint::unconstrained();
149        c.push_since(v(8, 1, 0));
150        assert!(!c.allows_version(v(8, 0, 0)));
151        assert!(c.allows_version(v(8, 1, 0)));
152        assert!(c.allows_version(v(8, 5, 0)));
153    }
154
155    #[test]
156    fn until_only_open_on_the_left() {
157        let mut c = VersionConstraint::unconstrained();
158        c.push_until(v(8, 3, 0));
159        assert!(c.allows_version(v(7, 0, 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 since_then_until_closes_the_range() {
166        let mut c = VersionConstraint::unconstrained();
167        c.push_since(v(8, 1, 0));
168        c.push_until(v(8, 3, 0));
169        assert!(!c.allows_version(v(8, 0, 0)));
170        assert!(c.allows_version(v(8, 1, 0)));
171        assert!(c.allows_version(v(8, 3, 0)));
172        assert!(!c.allows_version(v(8, 4, 0)));
173    }
174
175    #[test]
176    fn disjoint_ranges_compose() {
177        let mut c = VersionConstraint::unconstrained();
178        c.push_since(v(8, 1, 0));
179        c.push_until(v(8, 3, 0));
180        c.push_since(v(8, 5, 0));
181
182        assert!(!c.allows_version(v(8, 0, 0)));
183        assert!(c.allows_version(v(8, 1, 0)));
184        assert!(c.allows_version(v(8, 3, 0)));
185        assert!(!c.allows_version(v(8, 4, 0)));
186        assert!(c.allows_version(v(8, 5, 0)));
187        assert!(c.allows_version(v(9, 0, 0)));
188    }
189
190    #[test]
191    fn range_query_requires_full_coverage() {
192        let mut c = VersionConstraint::unconstrained();
193        c.push_since(v(8, 1, 0));
194        c.push_until(v(8, 3, 0));
195        c.push_since(v(8, 5, 0));
196
197        // 8.0–8.7 has a gap at 8.4, so this fails.
198        assert!(!c.allows_version_range(PHPVersionRange::between(v(8, 0, 0), v(8, 7, 0))));
199        // 8.5+ is fully inside the open right range.
200        assert!(c.allows_version_range(PHPVersionRange::between(v(8, 5, 0), v(8, 7, 0))));
201        // 8.1–8.3 sits inside the first range.
202        assert!(c.allows_version_range(PHPVersionRange::between(v(8, 1, 0), v(8, 3, 0))));
203    }
204}