1use 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 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 pub const fn cache_byte(self) -> u8 {
46 (self.major << 4) | (self.minor & 0x0F)
47 }
48
49 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 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 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 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 assert!(PhpVersion::new(8, 5).includes_symbol(Some("9.12"), None));
204 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 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"))); 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 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 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 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 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 assert!(v.in_range(Some("8.0.1"), Some("8.3.9")));
259 }
260}