mago_php_version/
lib.rs

1use std::str::FromStr;
2
3use serde::Deserialize;
4use serde::Deserializer;
5use serde::Serialize;
6use serde::Serializer;
7
8use crate::error::ParsingError;
9use crate::feature::Feature;
10
11pub mod error;
12pub mod feature;
13
14/// Represents a PHP version in `(major, minor, patch)` format,
15/// packed internally into a single `u32` for easy comparison.
16///
17/// # Examples
18///
19/// ```
20/// use mago_php_version::PHPVersion;
21///
22/// let version = PHPVersion::new(8, 4, 0);
23/// assert_eq!(version.major(), 8);
24/// assert_eq!(version.minor(), 4);
25/// assert_eq!(version.patch(), 0);
26/// assert_eq!(version.to_version_id(), 0x08_04_00);
27/// assert_eq!(version.to_string(), "8.4.0");
28/// ```
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
30#[repr(transparent)]
31pub struct PHPVersion(u32);
32
33impl PHPVersion {
34    /// The PHP 7.0 version.
35    pub const PHP70: PHPVersion = PHPVersion::new(7, 0, 0);
36
37    /// The PHP 7.1 version.
38    pub const PHP71: PHPVersion = PHPVersion::new(7, 1, 0);
39
40    /// The PHP 7.2 version.
41    pub const PHP72: PHPVersion = PHPVersion::new(7, 2, 0);
42
43    /// The PHP 7.3 version.
44    pub const PHP73: PHPVersion = PHPVersion::new(7, 3, 0);
45
46    /// The PHP 7.4 version.
47    pub const PHP74: PHPVersion = PHPVersion::new(7, 4, 0);
48
49    /// The PHP 8.0 version.
50    pub const PHP80: PHPVersion = PHPVersion::new(8, 0, 0);
51
52    /// The PHP 8.1 version.
53    pub const PHP81: PHPVersion = PHPVersion::new(8, 1, 0);
54
55    /// The PHP 8.2 version.
56    pub const PHP82: PHPVersion = PHPVersion::new(8, 2, 0);
57
58    /// The PHP 8.3 version.
59    pub const PHP83: PHPVersion = PHPVersion::new(8, 3, 0);
60
61    /// The PHP 8.4 version.
62    pub const PHP84: PHPVersion = PHPVersion::new(8, 4, 0);
63
64    /// Creates a new `PHPVersion` from the provided `major`, `minor`, and `patch` values.
65    ///
66    /// The internal representation packs these three components into a single `u32`
67    /// for efficient comparisons.
68    ///
69    /// # Examples
70    ///
71    /// ```
72    /// use mago_php_version::PHPVersion;
73    ///
74    /// let version = PHPVersion::new(8, 1, 3);
75    /// assert_eq!(version.major(), 8);
76    /// assert_eq!(version.minor(), 1);
77    /// assert_eq!(version.patch(), 3);
78    /// ```
79    #[inline]
80    pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
81        Self((major << 16) | (minor << 8) | patch)
82    }
83
84    /// Creates a `PHPVersion` directly from a raw version ID (e.g. `80400` for `8.4.0`).
85    ///
86    /// This can be useful if you already have the numeric form. The higher bits represent
87    /// the major version, the next bits represent minor, and the lowest bits represent patch.
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// use mago_php_version::PHPVersion;
93    ///
94    /// // "8.4.0" => 0x080400 in hex, which is 525312 in decimal
95    /// let version = PHPVersion::from_version_id(0x080400);
96    /// assert_eq!(version.to_string(), "8.4.0");
97    /// ```
98    #[inline]
99    pub const fn from_version_id(version_id: u32) -> Self {
100        Self(version_id)
101    }
102
103    /// Returns the **major** component of the PHP version.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use mago_php_version::PHPVersion;
109    ///
110    /// let version = PHPVersion::new(8, 2, 0);
111    /// assert_eq!(version.major(), 8);
112    /// ```
113    #[inline]
114    pub const fn major(&self) -> u32 {
115        self.0 >> 16
116    }
117
118    /// Returns the **minor** component of the PHP version.
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use mago_php_version::PHPVersion;
124    ///
125    /// let version = PHPVersion::new(8, 2, 0);
126    /// assert_eq!(version.minor(), 2);
127    /// ```
128    #[inline]
129    pub const fn minor(&self) -> u32 {
130        (self.0 >> 8) & 0xff
131    }
132
133    /// Returns the **patch** component of the PHP version.
134    ///
135    /// # Examples
136    ///
137    /// ```
138    /// use mago_php_version::PHPVersion;
139    ///
140    /// let version = PHPVersion::new(8, 1, 13);
141    /// assert_eq!(version.patch(), 13);
142    /// ```
143    #[inline]
144    pub const fn patch(&self) -> u32 {
145        self.0 & 0xff
146    }
147
148    /// Determines if this version is **at least** `major.minor.patch`.
149    ///
150    /// Returns `true` if `self >= (major.minor.patch)`.
151    ///
152    /// # Examples
153    ///
154    /// ```
155    /// use mago_php_version::PHPVersion;
156    ///
157    /// let version = PHPVersion::new(8, 0, 0);
158    /// assert!(version.is_at_least(8, 0, 0));
159    /// assert!(version.is_at_least(7, 4, 30)); // 8.0.0 is newer than 7.4.30
160    /// assert!(!version.is_at_least(8, 1, 0));
161    /// ```
162    pub const fn is_at_least(&self, major: u32, minor: u32, patch: u32) -> bool {
163        self.0 >= ((major << 16) | (minor << 8) | patch)
164    }
165
166    /// Checks if a given [`Feature`] is supported by this PHP version.
167    ///
168    /// The logic is based on version thresholds (e.g. `>= 8.0.0` or `< 8.0.0`).
169    /// Each `Feature` variant corresponds to a behavior introduced, removed, or changed
170    /// at a particular version boundary.
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// use mago_php_version::PHPVersion;
176    /// use mago_php_version::feature::Feature;
177    ///
178    /// let version = PHPVersion::new(7, 4, 0);
179    /// assert!(version.is_supported(Feature::NullCoalesceAssign));
180    /// assert!(!version.is_supported(Feature::NamedArguments));
181    /// ```
182    pub const fn is_supported(&self, feature: Feature) -> bool {
183        match feature {
184            Feature::NullableTypeHint
185            | Feature::IterableTypeHint
186            | Feature::VoidTypeHint
187            | Feature::ClassLikeConstantVisibilityModifiers
188            | Feature::CatchUnionType => self.0 >= 0x07_01_00,
189            Feature::TrailingCommaInListSyntax
190            | Feature::ParameterTypeWidening
191            | Feature::AllUnicodeScalarCodePointsInMbSubstituteCharacter => self.0 >= 0x07_02_00,
192            Feature::ListReferenceAssignment | Feature::TrailingCommaInFunctionCalls => self.0 >= 0x07_03_00,
193            Feature::NullCoalesceAssign
194            | Feature::ParameterContravariance
195            | Feature::ReturnCovariance
196            | Feature::PregUnmatchedAsNull
197            | Feature::ArrowFunctions
198            | Feature::NumericLiteralSeparator
199            | Feature::TypedProperties => self.0 >= 0x070400,
200            Feature::NonCapturingCatches
201            | Feature::NativeUnionTypes
202            | Feature::LessOverridenParametersWithVariadic
203            | Feature::ThrowExpression
204            | Feature::ClassConstantOnExpression
205            | Feature::PromotedProperties
206            | Feature::NamedArguments
207            | Feature::ThrowsTypeErrorForInternalFunctions
208            | Feature::ThrowsValueErrorForInternalFunctions
209            | Feature::HHPrintfSpecifier
210            | Feature::StricterRoundFunctions
211            | Feature::ThrowsOnInvalidMbStringEncoding
212            | Feature::WarnsAboutFinalPrivateMethods
213            | Feature::CastsNumbersToStringsOnLooseComparison
214            | Feature::NonNumericStringAndIntegerIsFalseOnLooseComparison
215            | Feature::AbstractTraitMethods
216            | Feature::StaticReturnTypeHint
217            | Feature::AccessClassOnObject
218            | Feature::Attribute
219            | Feature::MixedTypeHint
220            | Feature::MatchExpression
221            | Feature::NullSafeOperator
222            | Feature::TrailingCommaInClosureUseList
223            | Feature::FalseCompoundTypeHint
224            | Feature::NullCompoundTypeHint
225            | Feature::CatchOptionalVariable => self.0 >= 0x08_00_00,
226            Feature::FinalConstants
227            | Feature::ReadonlyProperties
228            | Feature::Enums
229            | Feature::PureIntersectionTypes
230            | Feature::TentativeReturnTypes
231            | Feature::NeverTypeHint
232            | Feature::ClosureCreation
233            | Feature::ArrayUnpackingWithStringKeys
234            | Feature::SerializableRequiresMagicMethods => self.0 >= 0x08_01_00,
235            Feature::ConstantsInTraits
236            | Feature::StrSplitReturnsEmptyArray
237            | Feature::DisjunctiveNormalForm
238            | Feature::ReadonlyClasses
239            | Feature::NeverReturnTypeInArrowFunction
240            | Feature::PregCaptureOnlyNamedGroups
241            | Feature::TrueTypeHint
242            | Feature::FalseTypeHint
243            | Feature::NullTypeHint => self.0 >= 0x08_02_00,
244            Feature::JsonValidate
245            | Feature::TypedClassLikeConstants
246            | Feature::DateTimeExceptions
247            | Feature::OverrideAttribute
248            | Feature::DynamicClassConstantAccess
249            | Feature::ReadonlyAnonymousClasses => self.0 >= 0x08_03_00,
250            Feature::AsymmetricVisibility
251            | Feature::LazyObjects
252            | Feature::HighlightStringDoesNotReturnFalse
253            | Feature::PropertyHooks
254            | Feature::NewWithoutParentheses => self.0 >= 0x08_04_00,
255            Feature::ClosureInConstantExpressions | Feature::ConstantAttribute => self.0 >= 0x08_05_00,
256            Feature::CallableInstanceMethods
257            | Feature::LegacyConstructor
258            | Feature::UnsetCast
259            | Feature::CaseInsensitiveConstantNames
260            | Feature::ArrayFunctionsReturnNullWithNonArray
261            | Feature::SubstrReturnFalseInsteadOfEmptyString
262            | Feature::CurlUrlOptionCheckingFileSchemeWithOpenBasedir
263            | Feature::EmptyStringValidAliasForNoneInMbSubstituteCharacter
264            | Feature::NumericStringValidArgInMbSubstituteCharacter
265            | Feature::ShortOpenTag => self.0 < 0x08_00_00,
266            Feature::InterfaceConstantImplicitlyFinal => self.0 < 0x08_01_00,
267            Feature::PassNoneEncodings => self.0 < 0x07_03_00,
268            _ => true,
269        }
270    }
271
272    /// Checks if a given [`Feature`] is deprecated in this PHP version.
273    ///
274    /// Returns `true` if the feature is *considered deprecated* at or above
275    /// certain version thresholds. The threshold logic is encoded within the `match`.
276    ///
277    /// # Examples
278    ///
279    /// ```
280    /// use mago_php_version::PHPVersion;
281    /// use mago_php_version::feature::Feature;
282    ///
283    /// let version = PHPVersion::new(8, 0, 0);
284    /// assert!(version.is_deprecated(Feature::RequiredParameterAfterOptional));
285    /// assert!(!version.is_deprecated(Feature::DynamicProperties)); // that is 8.2+
286    /// ```
287    pub const fn is_deprecated(&self, feature: Feature) -> bool {
288        match feature {
289            Feature::DynamicProperties | Feature::CallStaticMethodOnTrait => self.0 >= 0x08_02_00,
290            Feature::ImplicitlyNullableParameterTypes => self.0 >= 0x08_04_00,
291            Feature::RequiredParameterAfterOptionalUnionOrMixed => self.0 >= 0x08_03_00,
292            Feature::RequiredParameterAfterOptionalNullableAndDefaultNull => self.0 >= 0x08_01_00,
293            Feature::RequiredParameterAfterOptional => self.0 >= 0x08_00_00,
294            _ => false,
295        }
296    }
297
298    /// Converts this `PHPVersion` into a raw version ID (e.g. `80400` for `8.4.0`).
299    ///
300    /// This is the inverse of [`from_version_id`].
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// use mago_php_version::PHPVersion;
306    ///
307    /// let version = PHPVersion::new(8, 4, 0);
308    /// assert_eq!(version.to_version_id(), 0x080400);
309    /// ```
310    pub const fn to_version_id(&self) -> u32 {
311        self.0
312    }
313}
314
315impl std::fmt::Display for PHPVersion {
316    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
317        write!(f, "{}.{}.{}", self.major(), self.minor(), self.patch())
318    }
319}
320
321impl Serialize for PHPVersion {
322    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
323    where
324        S: Serializer,
325    {
326        serializer.serialize_str(&self.to_string())
327    }
328}
329
330impl<'de> Deserialize<'de> for PHPVersion {
331    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
332    where
333        D: Deserializer<'de>,
334    {
335        let s = String::deserialize(deserializer)?;
336
337        s.parse().map_err(serde::de::Error::custom)
338    }
339}
340
341impl FromStr for PHPVersion {
342    type Err = ParsingError;
343
344    fn from_str(s: &str) -> Result<Self, Self::Err> {
345        if s.is_empty() {
346            return Err(ParsingError::InvalidFormat);
347        }
348
349        let parts = s.split('.').collect::<Vec<_>>();
350        match parts.len() {
351            1 => {
352                let major = parts[0].parse()?;
353
354                Ok(Self::new(major, 0, 0))
355            }
356            2 => {
357                let major = parts[0].parse()?;
358                let minor = parts[1].parse()?;
359
360                Ok(Self::new(major, minor, 0))
361            }
362            3 => {
363                let major = parts[0].parse()?;
364                let minor = parts[1].parse()?;
365                let patch = parts[2].parse()?;
366
367                Ok(Self::new(major, minor, patch))
368            }
369            _ => Err(ParsingError::InvalidFormat),
370        }
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn test_version() {
380        let version = PHPVersion::new(7, 4, 0);
381        assert_eq!(version.major(), 7);
382        assert_eq!(version.minor(), 4);
383        assert_eq!(version.patch(), 0);
384    }
385
386    #[test]
387    fn test_display() {
388        let version = PHPVersion::new(7, 4, 0);
389        assert_eq!(version.to_string(), "7.4.0");
390    }
391
392    #[test]
393    fn test_from_str_single_segment() {
394        let v: PHPVersion = "7".parse().unwrap();
395        assert_eq!(v.major(), 7);
396        assert_eq!(v.minor(), 0);
397        assert_eq!(v.patch(), 0);
398        assert_eq!(v.to_string(), "7.0.0");
399    }
400
401    #[test]
402    fn test_from_str_two_segments() {
403        let v: PHPVersion = "7.4".parse().unwrap();
404        assert_eq!(v.major(), 7);
405        assert_eq!(v.minor(), 4);
406        assert_eq!(v.patch(), 0);
407        assert_eq!(v.to_string(), "7.4.0");
408    }
409
410    #[test]
411    fn test_from_str_three_segments() {
412        let v: PHPVersion = "8.1.2".parse().unwrap();
413        assert_eq!(v.major(), 8);
414        assert_eq!(v.minor(), 1);
415        assert_eq!(v.patch(), 2);
416        assert_eq!(v.to_string(), "8.1.2");
417    }
418
419    #[test]
420    fn test_from_str_invalid() {
421        let err = "7.4.0.1".parse::<PHPVersion>().unwrap_err();
422        assert_eq!(format!("{}", err), "Invalid version format, expected 'major.minor.patch'.");
423
424        let err = "".parse::<PHPVersion>().unwrap_err();
425        assert_eq!(format!("{}", err), "Invalid version format, expected 'major.minor.patch'.");
426
427        let err = "foo.4.0".parse::<PHPVersion>().unwrap_err();
428        assert_eq!(format!("{}", err), "Failed to parse integer component of version: invalid digit found in string.");
429
430        let err = "7.foo.0".parse::<PHPVersion>().unwrap_err();
431        assert_eq!(format!("{}", err), "Failed to parse integer component of version: invalid digit found in string.");
432
433        let err = "7.4.foo".parse::<PHPVersion>().unwrap_err();
434        assert_eq!(format!("{}", err), "Failed to parse integer component of version: invalid digit found in string.");
435    }
436
437    #[test]
438    fn test_is_supported_features_before_8() {
439        let v_7_4_0 = PHPVersion::new(7, 4, 0);
440
441        assert!(v_7_4_0.is_supported(Feature::NullCoalesceAssign));
442        assert!(!v_7_4_0.is_supported(Feature::NamedArguments));
443
444        assert!(v_7_4_0.is_supported(Feature::CallableInstanceMethods));
445        assert!(v_7_4_0.is_supported(Feature::LegacyConstructor));
446    }
447
448    #[test]
449    fn test_is_supported_features_8_0_0() {
450        let v_8_0_0 = PHPVersion::new(8, 0, 0);
451
452        assert!(v_8_0_0.is_supported(Feature::NamedArguments));
453        assert!(!v_8_0_0.is_supported(Feature::CallableInstanceMethods));
454    }
455
456    #[test]
457    fn test_is_deprecated_features() {
458        let v_7_4_0 = PHPVersion::new(7, 4, 0);
459        assert!(!v_7_4_0.is_deprecated(Feature::DynamicProperties));
460        assert!(!v_7_4_0.is_deprecated(Feature::RequiredParameterAfterOptional));
461
462        let v_8_0_0 = PHPVersion::new(8, 0, 0);
463        assert!(v_8_0_0.is_deprecated(Feature::RequiredParameterAfterOptional));
464        assert!(!v_8_0_0.is_deprecated(Feature::DynamicProperties));
465
466        let v_8_2_0 = PHPVersion::new(8, 2, 0);
467        assert!(v_8_2_0.is_deprecated(Feature::DynamicProperties));
468    }
469
470    #[test]
471    fn test_serde_serialize() {
472        let v_7_4_0 = PHPVersion::new(7, 4, 0);
473        let json = serde_json::to_string(&v_7_4_0).unwrap();
474        assert_eq!(json, "\"7.4.0\"");
475    }
476
477    #[test]
478    fn test_serde_deserialize() {
479        let json = "\"7.4.0\"";
480        let v: PHPVersion = serde_json::from_str(json).unwrap();
481        assert_eq!(v.major(), 7);
482        assert_eq!(v.minor(), 4);
483        assert_eq!(v.patch(), 0);
484
485        let json = "\"7.4\"";
486        let v: PHPVersion = serde_json::from_str(json).unwrap();
487        assert_eq!(v.major(), 7);
488        assert_eq!(v.minor(), 4);
489        assert_eq!(v.patch(), 0);
490    }
491
492    #[test]
493    fn test_serde_round_trip() {
494        let original = PHPVersion::new(8, 1, 5);
495        let serialized = serde_json::to_string(&original).unwrap();
496        let deserialized: PHPVersion = serde_json::from_str(&serialized).unwrap();
497        assert_eq!(original, deserialized);
498        assert_eq!(serialized, "\"8.1.5\"");
499    }
500}