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    /// The PHP 8.5 version.
65    pub const PHP85: PHPVersion = PHPVersion::new(8, 5, 0);
66
67    /// Represents the latest stable PHP version actively supported or targeted by this crate.
68    ///
69    /// **Warning:** The specific PHP version this constant points to (e.g., `PHPVersion::PHP84`)
70    /// is subject to change frequently, potentially even in **minor or patch releases**
71    /// of this crate, as new PHP versions are released and our support baseline updates.
72    ///
73    /// **Do NOT rely on this constant having a fixed value across different crate versions.**
74    /// It is intended for features that should target "the most current PHP we know of now."
75    pub const LATEST: PHPVersion = PHPVersion::PHP84;
76
77    /// Represents an upcoming, future, or "next" PHP version that this crate is
78    /// anticipating or for which experimental support might be in development.
79    ///
80    /// **Warning:** The specific PHP version this constant points to (e.g., `PHPVersion::PHP85`)
81    /// is highly volatile and **WILL CHANGE frequently**, potentially even in **minor or patch
82    /// releases** of this crate, reflecting shifts in PHP's release cycle or our development focus.
83    ///
84    /// **Do NOT rely on this constant having a fixed value across different crate versions.**
85    /// Use with caution, primarily for internal or forward-looking features.
86    pub const NEXT: PHPVersion = PHPVersion::PHP85;
87
88    /// Creates a new `PHPVersion` from the provided `major`, `minor`, and `patch` values.
89    ///
90    /// The internal representation packs these three components into a single `u32`
91    /// for efficient comparisons.
92    ///
93    /// # Examples
94    ///
95    /// ```
96    /// use mago_php_version::PHPVersion;
97    ///
98    /// let version = PHPVersion::new(8, 1, 3);
99    /// assert_eq!(version.major(), 8);
100    /// assert_eq!(version.minor(), 1);
101    /// assert_eq!(version.patch(), 3);
102    /// ```
103    #[inline]
104    pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
105        Self((major << 16) | (minor << 8) | patch)
106    }
107
108    /// Creates a `PHPVersion` directly from a raw version ID (e.g. `80400` for `8.4.0`).
109    ///
110    /// This can be useful if you already have the numeric form. The higher bits represent
111    /// the major version, the next bits represent minor, and the lowest bits represent patch.
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// use mago_php_version::PHPVersion;
117    ///
118    /// // "8.4.0" => 0x080400 in hex, which is 525312 in decimal
119    /// let version = PHPVersion::from_version_id(0x080400);
120    /// assert_eq!(version.to_string(), "8.4.0");
121    /// ```
122    #[inline]
123    pub const fn from_version_id(version_id: u32) -> Self {
124        Self(version_id)
125    }
126
127    /// Returns the **major** component of the PHP version.
128    ///
129    /// # Examples
130    ///
131    /// ```
132    /// use mago_php_version::PHPVersion;
133    ///
134    /// let version = PHPVersion::new(8, 2, 0);
135    /// assert_eq!(version.major(), 8);
136    /// ```
137    #[inline]
138    pub const fn major(&self) -> u32 {
139        self.0 >> 16
140    }
141
142    /// Returns the **minor** component of the PHP version.
143    ///
144    /// # Examples
145    ///
146    /// ```
147    /// use mago_php_version::PHPVersion;
148    ///
149    /// let version = PHPVersion::new(8, 2, 0);
150    /// assert_eq!(version.minor(), 2);
151    /// ```
152    #[inline]
153    pub const fn minor(&self) -> u32 {
154        (self.0 >> 8) & 0xff
155    }
156
157    /// Returns the **patch** component of the PHP version.
158    ///
159    /// # Examples
160    ///
161    /// ```
162    /// use mago_php_version::PHPVersion;
163    ///
164    /// let version = PHPVersion::new(8, 1, 13);
165    /// assert_eq!(version.patch(), 13);
166    /// ```
167    #[inline]
168    pub const fn patch(&self) -> u32 {
169        self.0 & 0xff
170    }
171
172    /// Determines if this version is **at least** `major.minor.patch`.
173    ///
174    /// Returns `true` if `self >= (major.minor.patch)`.
175    ///
176    /// # Examples
177    ///
178    /// ```
179    /// use mago_php_version::PHPVersion;
180    ///
181    /// let version = PHPVersion::new(8, 0, 0);
182    /// assert!(version.is_at_least(8, 0, 0));
183    /// assert!(version.is_at_least(7, 4, 30)); // 8.0.0 is newer than 7.4.30
184    /// assert!(!version.is_at_least(8, 1, 0));
185    /// ```
186    pub const fn is_at_least(&self, major: u32, minor: u32, patch: u32) -> bool {
187        self.0 >= ((major << 16) | (minor << 8) | patch)
188    }
189
190    /// Checks if a given [`Feature`] is supported by this PHP version.
191    ///
192    /// The logic is based on version thresholds (e.g. `>= 8.0.0` or `< 8.0.0`).
193    /// Each `Feature` variant corresponds to a behavior introduced, removed, or changed
194    /// at a particular version boundary.
195    ///
196    /// # Examples
197    ///
198    /// ```
199    /// use mago_php_version::PHPVersion;
200    /// use mago_php_version::feature::Feature;
201    ///
202    /// let version = PHPVersion::new(7, 4, 0);
203    /// assert!(version.is_supported(Feature::NullCoalesceAssign));
204    /// assert!(!version.is_supported(Feature::NamedArguments));
205    /// ```
206    pub const fn is_supported(&self, feature: Feature) -> bool {
207        match feature {
208            Feature::NullableTypeHint
209            | Feature::IterableTypeHint
210            | Feature::VoidTypeHint
211            | Feature::ClassLikeConstantVisibilityModifiers
212            | Feature::CatchUnionType => self.0 >= 0x07_01_00,
213            Feature::TrailingCommaInListSyntax
214            | Feature::ParameterTypeWidening
215            | Feature::AllUnicodeScalarCodePointsInMbSubstituteCharacter => self.0 >= 0x07_02_00,
216            Feature::ListReferenceAssignment | Feature::TrailingCommaInFunctionCalls => self.0 >= 0x07_03_00,
217            Feature::NullCoalesceAssign
218            | Feature::ParameterContravariance
219            | Feature::ReturnCovariance
220            | Feature::PregUnmatchedAsNull
221            | Feature::ArrowFunctions
222            | Feature::NumericLiteralSeparator
223            | Feature::TypedProperties => self.0 >= 0x070400,
224            Feature::NonCapturingCatches
225            | Feature::NativeUnionTypes
226            | Feature::LessOverriddenParametersWithVariadic
227            | Feature::ThrowExpression
228            | Feature::ClassConstantOnExpression
229            | Feature::PromotedProperties
230            | Feature::NamedArguments
231            | Feature::ThrowsTypeErrorForInternalFunctions
232            | Feature::ThrowsValueErrorForInternalFunctions
233            | Feature::HHPrintfSpecifier
234            | Feature::StricterRoundFunctions
235            | Feature::ThrowsOnInvalidMbStringEncoding
236            | Feature::WarnsAboutFinalPrivateMethods
237            | Feature::CastsNumbersToStringsOnLooseComparison
238            | Feature::NonNumericStringAndIntegerIsFalseOnLooseComparison
239            | Feature::AbstractTraitMethods
240            | Feature::StaticReturnTypeHint
241            | Feature::AccessClassOnObject
242            | Feature::Attributes
243            | Feature::MixedTypeHint
244            | Feature::MatchExpression
245            | Feature::NullSafeOperator
246            | Feature::TrailingCommaInClosureUseList
247            | Feature::FalseCompoundTypeHint
248            | Feature::NullCompoundTypeHint
249            | Feature::CatchOptionalVariable => self.0 >= 0x08_00_00,
250            Feature::FinalConstants
251            | Feature::ReadonlyProperties
252            | Feature::Enums
253            | Feature::PureIntersectionTypes
254            | Feature::TentativeReturnTypes
255            | Feature::NeverTypeHint
256            | Feature::ClosureCreation
257            | Feature::ArrayUnpackingWithStringKeys
258            | Feature::SerializableRequiresMagicMethods => self.0 >= 0x08_01_00,
259            Feature::ConstantsInTraits
260            | Feature::StrSplitReturnsEmptyArray
261            | Feature::DisjunctiveNormalForm
262            | Feature::ReadonlyClasses
263            | Feature::NeverReturnTypeInArrowFunction
264            | Feature::PregCaptureOnlyNamedGroups
265            | Feature::TrueTypeHint
266            | Feature::FalseTypeHint
267            | Feature::NullTypeHint => self.0 >= 0x08_02_00,
268            Feature::JsonValidate
269            | Feature::TypedClassLikeConstants
270            | Feature::DateTimeExceptions
271            | Feature::OverrideAttribute
272            | Feature::DynamicClassConstantAccess
273            | Feature::ReadonlyAnonymousClasses => self.0 >= 0x08_03_00,
274            Feature::AsymmetricVisibility
275            | Feature::LazyObjects
276            | Feature::HighlightStringDoesNotReturnFalse
277            | Feature::PropertyHooks
278            | Feature::NewWithoutParentheses
279            | Feature::DeprecatedAttribute => self.0 >= 0x08_04_00,
280            Feature::ClosureInConstantExpressions
281            | Feature::ConstantAttributes
282            | Feature::NoDiscardAttribute
283            | Feature::VoidCast
284            | Feature::AsymmetricVisibilityForStaticProperties
285            | Feature::ClosureCreationInConstantExpressions
286            | Feature::PipeOperator => self.0 >= 0x08_05_00,
287            Feature::CallableInstanceMethods
288            | Feature::LegacyConstructor
289            | Feature::UnsetCast
290            | Feature::CaseInsensitiveConstantNames
291            | Feature::ArrayFunctionsReturnNullWithNonArray
292            | Feature::SubstrReturnFalseInsteadOfEmptyString
293            | Feature::CurlUrlOptionCheckingFileSchemeWithOpenBasedir
294            | Feature::EmptyStringValidAliasForNoneInMbSubstituteCharacter
295            | Feature::NumericStringValidArgInMbSubstituteCharacter => self.0 < 0x08_00_00,
296            Feature::InterfaceConstantImplicitlyFinal => self.0 < 0x08_01_00,
297            Feature::PassNoneEncodings => self.0 < 0x07_03_00,
298            _ => true,
299        }
300    }
301
302    /// Checks if a given [`Feature`] is deprecated in this PHP version.
303    ///
304    /// Returns `true` if the feature is *considered deprecated* at or above
305    /// certain version thresholds. The threshold logic is encoded within the `match`.
306    ///
307    /// # Examples
308    ///
309    /// ```
310    /// use mago_php_version::PHPVersion;
311    /// use mago_php_version::feature::Feature;
312    ///
313    /// let version = PHPVersion::new(8, 0, 0);
314    /// assert!(version.is_deprecated(Feature::RequiredParameterAfterOptional));
315    /// assert!(!version.is_deprecated(Feature::DynamicProperties)); // that is 8.2+
316    /// ```
317    pub const fn is_deprecated(&self, feature: Feature) -> bool {
318        match feature {
319            Feature::DynamicProperties | Feature::CallStaticMethodOnTrait => self.0 >= 0x08_02_00,
320            Feature::ImplicitlyNullableParameterTypes => self.0 >= 0x08_04_00,
321            Feature::RequiredParameterAfterOptionalUnionOrMixed => self.0 >= 0x08_03_00,
322            Feature::RequiredParameterAfterOptionalNullableAndDefaultNull => self.0 >= 0x08_01_00,
323            Feature::RequiredParameterAfterOptional => self.0 >= 0x08_00_00,
324            _ => false,
325        }
326    }
327
328    /// Converts this `PHPVersion` into a raw version ID (e.g. `80400` for `8.4.0`).
329    ///
330    /// This is the inverse of [`from_version_id`].
331    ///
332    /// # Examples
333    ///
334    /// ```
335    /// use mago_php_version::PHPVersion;
336    ///
337    /// let version = PHPVersion::new(8, 4, 0);
338    /// assert_eq!(version.to_version_id(), 0x080400);
339    /// ```
340    pub const fn to_version_id(&self) -> u32 {
341        self.0
342    }
343}
344
345impl std::fmt::Display for PHPVersion {
346    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
347        write!(f, "{}.{}.{}", self.major(), self.minor(), self.patch())
348    }
349}
350
351impl Serialize for PHPVersion {
352    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
353    where
354        S: Serializer,
355    {
356        serializer.serialize_str(&self.to_string())
357    }
358}
359
360impl<'de> Deserialize<'de> for PHPVersion {
361    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
362    where
363        D: Deserializer<'de>,
364    {
365        let s = String::deserialize(deserializer)?;
366
367        s.parse().map_err(serde::de::Error::custom)
368    }
369}
370
371impl FromStr for PHPVersion {
372    type Err = ParsingError;
373
374    fn from_str(s: &str) -> Result<Self, Self::Err> {
375        if s.is_empty() {
376            return Err(ParsingError::InvalidFormat);
377        }
378
379        let parts = s.split('.').collect::<Vec<_>>();
380        match parts.len() {
381            1 => {
382                let major = parts[0].parse()?;
383
384                Ok(Self::new(major, 0, 0))
385            }
386            2 => {
387                let major = parts[0].parse()?;
388                let minor = parts[1].parse()?;
389
390                Ok(Self::new(major, minor, 0))
391            }
392            3 => {
393                let major = parts[0].parse()?;
394                let minor = parts[1].parse()?;
395                let patch = parts[2].parse()?;
396
397                Ok(Self::new(major, minor, patch))
398            }
399            _ => Err(ParsingError::InvalidFormat),
400        }
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn test_version() {
410        let version = PHPVersion::new(7, 4, 0);
411        assert_eq!(version.major(), 7);
412        assert_eq!(version.minor(), 4);
413        assert_eq!(version.patch(), 0);
414    }
415
416    #[test]
417    fn test_display() {
418        let version = PHPVersion::new(7, 4, 0);
419        assert_eq!(version.to_string(), "7.4.0");
420    }
421
422    #[test]
423    fn test_from_str_single_segment() {
424        let v: PHPVersion = "7".parse().unwrap();
425        assert_eq!(v.major(), 7);
426        assert_eq!(v.minor(), 0);
427        assert_eq!(v.patch(), 0);
428        assert_eq!(v.to_string(), "7.0.0");
429    }
430
431    #[test]
432    fn test_from_str_two_segments() {
433        let v: PHPVersion = "7.4".parse().unwrap();
434        assert_eq!(v.major(), 7);
435        assert_eq!(v.minor(), 4);
436        assert_eq!(v.patch(), 0);
437        assert_eq!(v.to_string(), "7.4.0");
438    }
439
440    #[test]
441    fn test_from_str_three_segments() {
442        let v: PHPVersion = "8.1.2".parse().unwrap();
443        assert_eq!(v.major(), 8);
444        assert_eq!(v.minor(), 1);
445        assert_eq!(v.patch(), 2);
446        assert_eq!(v.to_string(), "8.1.2");
447    }
448
449    #[test]
450    fn test_from_str_invalid() {
451        let err = "7.4.0.1".parse::<PHPVersion>().unwrap_err();
452        assert_eq!(format!("{err}"), "Invalid version format, expected 'major.minor.patch'.");
453
454        let err = "".parse::<PHPVersion>().unwrap_err();
455        assert_eq!(format!("{err}"), "Invalid version format, expected 'major.minor.patch'.");
456
457        let err = "foo.4.0".parse::<PHPVersion>().unwrap_err();
458        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
459
460        let err = "7.foo.0".parse::<PHPVersion>().unwrap_err();
461        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
462
463        let err = "7.4.foo".parse::<PHPVersion>().unwrap_err();
464        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
465    }
466
467    #[test]
468    fn test_is_supported_features_before_8() {
469        let v_7_4_0 = PHPVersion::new(7, 4, 0);
470
471        assert!(v_7_4_0.is_supported(Feature::NullCoalesceAssign));
472        assert!(!v_7_4_0.is_supported(Feature::NamedArguments));
473
474        assert!(v_7_4_0.is_supported(Feature::CallableInstanceMethods));
475        assert!(v_7_4_0.is_supported(Feature::LegacyConstructor));
476    }
477
478    #[test]
479    fn test_is_supported_features_8_0_0() {
480        let v_8_0_0 = PHPVersion::new(8, 0, 0);
481
482        assert!(v_8_0_0.is_supported(Feature::NamedArguments));
483        assert!(!v_8_0_0.is_supported(Feature::CallableInstanceMethods));
484    }
485
486    #[test]
487    fn test_is_deprecated_features() {
488        let v_7_4_0 = PHPVersion::new(7, 4, 0);
489        assert!(!v_7_4_0.is_deprecated(Feature::DynamicProperties));
490        assert!(!v_7_4_0.is_deprecated(Feature::RequiredParameterAfterOptional));
491
492        let v_8_0_0 = PHPVersion::new(8, 0, 0);
493        assert!(v_8_0_0.is_deprecated(Feature::RequiredParameterAfterOptional));
494        assert!(!v_8_0_0.is_deprecated(Feature::DynamicProperties));
495
496        let v_8_2_0 = PHPVersion::new(8, 2, 0);
497        assert!(v_8_2_0.is_deprecated(Feature::DynamicProperties));
498    }
499
500    #[test]
501    fn test_serde_serialize() {
502        let v_7_4_0 = PHPVersion::new(7, 4, 0);
503        let json = serde_json::to_string(&v_7_4_0).unwrap();
504        assert_eq!(json, "\"7.4.0\"");
505    }
506
507    #[test]
508    fn test_serde_deserialize() {
509        let json = "\"7.4.0\"";
510        let v: PHPVersion = serde_json::from_str(json).unwrap();
511        assert_eq!(v.major(), 7);
512        assert_eq!(v.minor(), 4);
513        assert_eq!(v.patch(), 0);
514
515        let json = "\"7.4\"";
516        let v: PHPVersion = serde_json::from_str(json).unwrap();
517        assert_eq!(v.major(), 7);
518        assert_eq!(v.minor(), 4);
519        assert_eq!(v.patch(), 0);
520    }
521
522    #[test]
523    fn test_serde_round_trip() {
524        let original = PHPVersion::new(8, 1, 5);
525        let serialized = serde_json::to_string(&original).unwrap();
526        let deserialized: PHPVersion = serde_json::from_str(&serialized).unwrap();
527        assert_eq!(original, deserialized);
528        assert_eq!(serialized, "\"8.1.5\"");
529    }
530}