Skip to main content

mir_analyzer/
php_version.rs

1//! Target PHP language version.
2//!
3//! Used by the analyzer and stub loader to make version-conditional decisions
4//! (e.g. filtering stub symbols by `@since`/`@removed` markers). The type is
5//! `Copy` and stores only major/minor — patch level is parsed but discarded,
6//! since language features track the minor release.
7use std::fmt;
8use std::str::FromStr;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct PhpVersion {
12    major: u8,
13    minor: u8,
14}
15
16impl PhpVersion {
17    pub const LATEST: PhpVersion = PhpVersion::new(8, 5);
18
19    pub const fn new(major: u8, minor: u8) -> Self {
20        Self { major, minor }
21    }
22
23    pub const fn major(self) -> u8 {
24        self.major
25    }
26
27    pub const fn minor(self) -> u8 {
28        self.minor
29    }
30
31    /// Parser target: 8.6 syntax is accepted only when targeting 8.6+; older
32    /// targets keep the parser's 8.5 default so syntax is not gated per version.
33    pub fn parser_version(self) -> php_rs_parser::PhpVersion {
34        if self >= PhpVersion::new(8, 6) {
35            php_rs_parser::PhpVersion::Php86
36        } else {
37            php_rs_parser::PhpVersion::Php85
38        }
39    }
40
41    /// Encode the version into the single byte the [`crate::stub_cache`]
42    /// header carries. Layout: `(major << 4) | (minor & 0x0F)`. PHP minor
43    /// versions stay well below 16 so they fit unambiguously in the low
44    /// nibble.
45    pub const fn cache_byte(self) -> u8 {
46        (self.major << 4) | (self.minor & 0x0F)
47    }
48
49    /// Return `true` if a stub symbol annotated with `@since`/`@removed` is
50    /// available at this target version.
51    ///
52    /// `@since X.Y` excludes targets `< X.Y`. `@removed X.Y` excludes
53    /// targets `>= X.Y` (the symbol is gone *as of* that release). Tags that
54    /// fail to parse, or whose major version is outside the plausible PHP
55    /// range, are ignored — some extension stubs (newrelic, mongodb) put
56    /// library versions there (`@since 9.12`, `@since 1.17`) which must not
57    /// drive PHP-version filtering.
58    pub fn includes_symbol(self, since: Option<&str>, removed: Option<&str>) -> bool {
59        let parse_php = |s: &str| -> Option<PhpVersion> {
60            let v = s.parse::<PhpVersion>().ok()?;
61            // PHP majors so far: 4, 5, 7, 8 (no 6). Accept LATEST.major + 1 as
62            // forward-compat headroom; reject everything else as a library
63            // version.
64            if v.major() >= 4 && v.major() <= PhpVersion::LATEST.major() {
65                Some(v)
66            } else {
67                None
68            }
69        };
70        if let Some(s) = since.and_then(parse_php) {
71            if self < s {
72                return false;
73            }
74        }
75        if let Some(r) = removed.and_then(parse_php) {
76            if self >= r {
77                return false;
78            }
79        }
80        true
81    }
82
83    /// Whether `self` falls within the **inclusive** `[from, to]` range used by
84    /// phpstorm-stubs' `#[PhpStormStubsElementAvailable($from, $to)]`. Both
85    /// bounds are inclusive (verified empirically: `Error::__clone` is declared
86    /// `from:"7.0", to:"8.0"` then `'8.1'`, and the only gap-free partition is
87    /// inclusive `to`). A `None` bound is open on that side.
88    ///
89    /// Unlike [`includes_symbol`](Self::includes_symbol) this is *not* the
90    /// `@since`/`@removed` semantics — `removed` there is exclusive. Version
91    /// strings that fail to parse are ignored (treated as absent), defensively;
92    /// `PhpVersion: FromStr` already truncates any `x.y.z` patch component.
93    pub fn in_range(self, from: Option<&str>, to_inclusive: Option<&str>) -> bool {
94        let parse = |s: &str| s.parse::<PhpVersion>().ok();
95        if let Some(f) = from.and_then(parse) {
96            if self < f {
97                return false;
98            }
99        }
100        if let Some(t) = to_inclusive.and_then(parse) {
101            if self > t {
102                return false;
103            }
104        }
105        true
106    }
107}
108
109impl Default for PhpVersion {
110    fn default() -> Self {
111        Self::LATEST
112    }
113}
114
115impl fmt::Display for PhpVersion {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        write!(f, "{}.{}", self.major, self.minor)
118    }
119}
120
121#[derive(Debug, thiserror::Error)]
122#[error("invalid PHP version `{0}`: expected `MAJOR.MINOR` (e.g. `8.2`)")]
123pub struct ParsePhpVersionError(pub String);
124
125impl FromStr for PhpVersion {
126    type Err = ParsePhpVersionError;
127
128    fn from_str(s: &str) -> Result<Self, Self::Err> {
129        let mut parts = s.trim().split('.');
130        let major = parts
131            .next()
132            .and_then(|p| p.parse::<u8>().ok())
133            .ok_or_else(|| ParsePhpVersionError(s.to_string()))?;
134        let minor = match parts.next() {
135            Some(p) => p
136                .parse::<u8>()
137                .map_err(|_| ParsePhpVersionError(s.to_string()))?,
138            None => 0,
139        };
140        // Ignore any patch component — language features track the minor release.
141        Ok(Self::new(major, minor))
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn parses_major_minor() {
151        assert_eq!("8.2".parse::<PhpVersion>().unwrap(), PhpVersion::new(8, 2));
152    }
153
154    #[test]
155    fn parses_major_minor_patch() {
156        assert_eq!(
157            "8.3.7".parse::<PhpVersion>().unwrap(),
158            PhpVersion::new(8, 3)
159        );
160    }
161
162    #[test]
163    fn parses_major_only() {
164        assert_eq!("7".parse::<PhpVersion>().unwrap(), PhpVersion::new(7, 0));
165    }
166
167    #[test]
168    fn rejects_garbage() {
169        assert!("x.y".parse::<PhpVersion>().is_err());
170        assert!("8.x".parse::<PhpVersion>().is_err());
171        assert!("".parse::<PhpVersion>().is_err());
172    }
173
174    #[test]
175    fn ordered_by_major_then_minor() {
176        assert!(PhpVersion::new(8, 1) < PhpVersion::new(8, 2));
177        assert!(PhpVersion::new(7, 4) < PhpVersion::new(8, 0));
178    }
179
180    #[test]
181    fn displays_as_major_dot_minor() {
182        assert_eq!(PhpVersion::new(8, 3).to_string(), "8.3");
183    }
184
185    #[test]
186    fn includes_symbol_respects_since() {
187        assert!(!PhpVersion::new(7, 4).includes_symbol(Some("8.0"), None));
188        assert!(PhpVersion::new(8, 0).includes_symbol(Some("8.0"), None));
189        assert!(PhpVersion::new(8, 5).includes_symbol(Some("8.0"), None));
190    }
191
192    #[test]
193    fn includes_symbol_respects_removed() {
194        assert!(PhpVersion::new(7, 4).includes_symbol(None, Some("8.0")));
195        assert!(!PhpVersion::new(8, 0).includes_symbol(None, Some("8.0")));
196        assert!(!PhpVersion::new(8, 5).includes_symbol(None, Some("8.0")));
197    }
198
199    #[test]
200    fn includes_symbol_ignores_library_versions() {
201        // newrelic uses `@since 9.12` for its own version; must not exclude on
202        // PHP 8.5.
203        assert!(PhpVersion::new(8, 5).includes_symbol(Some("9.12"), None));
204        // mongodb uses `@since 1.17` for its driver version; harmless on its
205        // own, but exercise the cap explicitly.
206        assert!(PhpVersion::new(8, 5).includes_symbol(Some("1.17"), None));
207        assert!(PhpVersion::new(8, 5).includes_symbol(Some("12.0"), None));
208    }
209
210    #[test]
211    fn includes_symbol_ignores_garbage() {
212        assert!(PhpVersion::new(8, 5).includes_symbol(Some("PECL"), None));
213        assert!(PhpVersion::new(8, 5).includes_symbol(Some(""), None));
214    }
215
216    #[test]
217    fn in_range_inclusive_both_bounds() {
218        let v = PhpVersion::new;
219        // [7.0, 8.0] inclusive on both ends.
220        assert!(v(7, 0).in_range(Some("7.0"), Some("8.0")));
221        assert!(v(8, 0).in_range(Some("7.0"), Some("8.0"))); // upper bound is inclusive
222        assert!(v(7, 4).in_range(Some("7.0"), Some("8.0")));
223        assert!(!v(8, 1).in_range(Some("7.0"), Some("8.0")));
224        assert!(!v(6, 4).in_range(Some("7.0"), Some("8.0")));
225    }
226
227    #[test]
228    fn in_range_open_bounds() {
229        let v = PhpVersion::new;
230        // from-only: available at and after 8.0.
231        assert!(!v(7, 4).in_range(Some("8.0"), None));
232        assert!(v(8, 0).in_range(Some("8.0"), None));
233        assert!(v(8, 5).in_range(Some("8.0"), None));
234        // to-only: available at and before 8.0.
235        assert!(v(7, 4).in_range(None, Some("8.0")));
236        assert!(v(8, 0).in_range(None, Some("8.0")));
237        assert!(!v(8, 1).in_range(None, Some("8.0")));
238        // no bounds: always available.
239        assert!(v(7, 4).in_range(None, None));
240    }
241
242    #[test]
243    fn in_range_across_the_7_4_to_8_0_jump() {
244        let v = PhpVersion::new;
245        // The 7.4 → 8.0 boundary: the gap-free partition for Error::__clone.
246        assert!(v(7, 4).in_range(Some("7.0"), Some("8.0")));
247        assert!(!v(7, 4).in_range(Some("8.1"), None));
248        assert!(!v(8, 1).in_range(Some("7.0"), Some("8.0")));
249        assert!(v(8, 1).in_range(Some("8.1"), None));
250    }
251
252    #[test]
253    fn in_range_ignores_unparseable_bounds() {
254        let v = PhpVersion::new(8, 2);
255        assert!(v.in_range(Some("garbage"), None));
256        assert!(v.in_range(None, Some("")));
257        // patch components are truncated by FromStr.
258        assert!(v.in_range(Some("8.0.1"), Some("8.3.9")));
259    }
260}