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    /// Return `true` if a stub symbol annotated with `@since`/`@removed` is
32    /// available at this target version.
33    ///
34    /// `@since X.Y` excludes targets `< X.Y`. `@removed X.Y` excludes
35    /// targets `>= X.Y` (the symbol is gone *as of* that release). Tags that
36    /// fail to parse, or whose major version is outside the plausible PHP
37    /// range, are ignored — some extension stubs (newrelic, mongodb) put
38    /// library versions there (`@since 9.12`, `@since 1.17`) which must not
39    /// drive PHP-version filtering.
40    pub fn includes_symbol(self, since: Option<&str>, removed: Option<&str>) -> bool {
41        let parse_php = |s: &str| -> Option<PhpVersion> {
42            let v = s.parse::<PhpVersion>().ok()?;
43            // PHP majors so far: 4, 5, 7, 8 (no 6). Accept LATEST.major + 1 as
44            // forward-compat headroom; reject everything else as a library
45            // version.
46            if v.major() >= 4 && v.major() <= PhpVersion::LATEST.major() {
47                Some(v)
48            } else {
49                None
50            }
51        };
52        if let Some(s) = since.and_then(parse_php) {
53            if self < s {
54                return false;
55            }
56        }
57        if let Some(r) = removed.and_then(parse_php) {
58            if self >= r {
59                return false;
60            }
61        }
62        true
63    }
64}
65
66impl Default for PhpVersion {
67    fn default() -> Self {
68        Self::LATEST
69    }
70}
71
72impl fmt::Display for PhpVersion {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        write!(f, "{}.{}", self.major, self.minor)
75    }
76}
77
78#[derive(Debug, thiserror::Error)]
79#[error("invalid PHP version `{0}`: expected `MAJOR.MINOR` (e.g. `8.2`)")]
80pub struct ParsePhpVersionError(pub String);
81
82impl FromStr for PhpVersion {
83    type Err = ParsePhpVersionError;
84
85    fn from_str(s: &str) -> Result<Self, Self::Err> {
86        let mut parts = s.trim().split('.');
87        let major = parts
88            .next()
89            .and_then(|p| p.parse::<u8>().ok())
90            .ok_or_else(|| ParsePhpVersionError(s.to_string()))?;
91        let minor = match parts.next() {
92            Some(p) => p
93                .parse::<u8>()
94                .map_err(|_| ParsePhpVersionError(s.to_string()))?,
95            None => 0,
96        };
97        // Ignore any patch component — language features track the minor release.
98        Ok(Self::new(major, minor))
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn parses_major_minor() {
108        assert_eq!("8.2".parse::<PhpVersion>().unwrap(), PhpVersion::new(8, 2));
109    }
110
111    #[test]
112    fn parses_major_minor_patch() {
113        assert_eq!(
114            "8.3.7".parse::<PhpVersion>().unwrap(),
115            PhpVersion::new(8, 3)
116        );
117    }
118
119    #[test]
120    fn parses_major_only() {
121        assert_eq!("7".parse::<PhpVersion>().unwrap(), PhpVersion::new(7, 0));
122    }
123
124    #[test]
125    fn rejects_garbage() {
126        assert!("x.y".parse::<PhpVersion>().is_err());
127        assert!("8.x".parse::<PhpVersion>().is_err());
128        assert!("".parse::<PhpVersion>().is_err());
129    }
130
131    #[test]
132    fn ordered_by_major_then_minor() {
133        assert!(PhpVersion::new(8, 1) < PhpVersion::new(8, 2));
134        assert!(PhpVersion::new(7, 4) < PhpVersion::new(8, 0));
135    }
136
137    #[test]
138    fn displays_as_major_dot_minor() {
139        assert_eq!(PhpVersion::new(8, 3).to_string(), "8.3");
140    }
141
142    #[test]
143    fn includes_symbol_respects_since() {
144        assert!(!PhpVersion::new(7, 4).includes_symbol(Some("8.0"), None));
145        assert!(PhpVersion::new(8, 0).includes_symbol(Some("8.0"), None));
146        assert!(PhpVersion::new(8, 5).includes_symbol(Some("8.0"), None));
147    }
148
149    #[test]
150    fn includes_symbol_respects_removed() {
151        assert!(PhpVersion::new(7, 4).includes_symbol(None, Some("8.0")));
152        assert!(!PhpVersion::new(8, 0).includes_symbol(None, Some("8.0")));
153        assert!(!PhpVersion::new(8, 5).includes_symbol(None, Some("8.0")));
154    }
155
156    #[test]
157    fn includes_symbol_ignores_library_versions() {
158        // newrelic uses `@since 9.12` for its own version; must not exclude on
159        // PHP 8.5.
160        assert!(PhpVersion::new(8, 5).includes_symbol(Some("9.12"), None));
161        // mongodb uses `@since 1.17` for its driver version; harmless on its
162        // own, but exercise the cap explicitly.
163        assert!(PhpVersion::new(8, 5).includes_symbol(Some("1.17"), None));
164        assert!(PhpVersion::new(8, 5).includes_symbol(Some("12.0"), None));
165    }
166
167    #[test]
168    fn includes_symbol_ignores_garbage() {
169        assert!(PhpVersion::new(8, 5).includes_symbol(Some("PECL"), None));
170        assert!(PhpVersion::new(8, 5).includes_symbol(Some(""), None));
171    }
172}