Skip to main content

mago_codex/metadata/
version_constraint.rs

1use mago_php_version::PHPVersion;
2use mago_php_version::PHPVersionRange;
3
4/// Tracks the PHP version intervals in which a symbol is available, derived
5/// from `Mago\AvailableSince` / `Mago\AvailableUntil` attributes during
6/// scanning.
7///
8/// Both attributes are repeatable, so a symbol can declare disjoint
9/// availability ranges (for example "available 8.1–8.3, removed in 8.4,
10/// brought back in 8.5"). An empty range list means "always available";
11/// otherwise a version is allowed when *some* range contains it.
12#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct VersionConstraint {
15    /// Disjoint availability intervals in source order. Empty = unconstrained.
16    pub ranges: Vec<PHPVersionRange>,
17}
18
19impl VersionConstraint {
20    #[inline]
21    #[must_use]
22    pub const fn unconstrained() -> Self {
23        Self { ranges: Vec::new() }
24    }
25
26    #[inline]
27    #[must_use]
28    pub const fn is_unconstrained(&self) -> bool {
29        self.ranges.is_empty()
30    }
31
32    /// Records a new `Mago\AvailableSince(version)` claim — opens a fresh
33    /// range that stays open on the right until a matching `AvailableUntil`
34    /// claim closes it.
35    #[inline]
36    pub fn push_since(&mut self, version: PHPVersion) {
37        self.ranges.push(PHPVersionRange::from(version));
38    }
39
40    /// Records a new `Mago\AvailableUntil(version)` claim. Closes the most
41    /// recent open range if there is one; otherwise opens a brand-new range
42    /// with no `min` bound.
43    #[inline]
44    pub fn push_until(&mut self, version: PHPVersion) {
45        if let Some(last) = self.ranges.last_mut()
46            && last.max.is_none()
47        {
48            last.max = Some(version);
49            return;
50        }
51
52        self.ranges.push(PHPVersionRange::until(version));
53    }
54
55    /// Folds `other` into `self` by unioning their availability ranges.
56    #[inline]
57    pub fn merge(&mut self, other: VersionConstraint) {
58        if self.is_unconstrained() || other.is_unconstrained() {
59            self.ranges.clear();
60            return;
61        }
62
63        self.ranges.extend(other.ranges);
64    }
65
66    /// Returns `true` when `version` is allowed by *any* range in this
67    /// constraint.
68    #[inline]
69    #[must_use]
70    pub fn allows_version(&self, version: PHPVersion) -> bool {
71        if self.ranges.is_empty() {
72            return true;
73        }
74
75        self.ranges.iter().any(|r| r.includes(version))
76    }
77
78    /// Returns `true` when *every* PHP version in `range` is covered by the
79    /// union of ranges in this constraint. An open `min`/`max` on `range` is
80    /// treated as the platform's known low/high bound.
81    #[inline]
82    #[must_use]
83    pub fn allows_version_range(&self, range: PHPVersionRange) -> bool {
84        if self.ranges.is_empty() {
85            return true;
86        }
87
88        let min = range.min.unwrap_or(PHPVersion::from_version_id(0));
89        let max = range.max.unwrap_or(PHPVersion::from_version_id(u32::MAX));
90
91        if min > max {
92            return true;
93        }
94
95        let mut sorted: Vec<&PHPVersionRange> = self.ranges.iter().collect();
96        sorted.sort_by_key(|r| r.min);
97
98        let mut next_required = min;
99        for r in sorted {
100            let r_min = r.min.unwrap_or(PHPVersion::from_version_id(0));
101            let r_max = r.max.unwrap_or(PHPVersion::from_version_id(u32::MAX));
102
103            // Skip ranges that end before what we still need to cover.
104            if r_max < next_required {
105                continue;
106            }
107
108            // If this range opens after the next-required version, there's a
109            // gap that no later range can close (ranges are sorted ascending
110            // by `min`).
111            if r_min > next_required {
112                return false;
113            }
114
115            if r_max >= max {
116                return true;
117            }
118
119            // LocalArena past this range. There's no `+1` notion on PHPVersion, so
120            // re-cast through the packed id; every range we'd be looking for
121            // next must start strictly after `r_max`.
122            next_required = PHPVersion::from_version_id(r_max.to_version_id().saturating_add(1));
123        }
124
125        false
126    }
127}
128
129#[cfg(test)]
130mod tests {
131
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}