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::LessOverridenParametersWithVariadic
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
296            | Feature::ShortOpenTag => self.0 < 0x08_00_00,
297            Feature::InterfaceConstantImplicitlyFinal => self.0 < 0x08_01_00,
298            Feature::PassNoneEncodings => self.0 < 0x07_03_00,
299            _ => true,
300        }
301    }
302
303    /// Checks if a given [`Feature`] is deprecated in this PHP version.
304    ///
305    /// Returns `true` if the feature is *considered deprecated* at or above
306    /// certain version thresholds. The threshold logic is encoded within the `match`.
307    ///
308    /// # Examples
309    ///
310    /// ```
311    /// use mago_php_version::PHPVersion;
312    /// use mago_php_version::feature::Feature;
313    ///
314    /// let version = PHPVersion::new(8, 0, 0);
315    /// assert!(version.is_deprecated(Feature::RequiredParameterAfterOptional));
316    /// assert!(!version.is_deprecated(Feature::DynamicProperties)); // that is 8.2+
317    /// ```
318    pub const fn is_deprecated(&self, feature: Feature) -> bool {
319        match feature {
320            Feature::DynamicProperties | Feature::CallStaticMethodOnTrait => self.0 >= 0x08_02_00,
321            Feature::ImplicitlyNullableParameterTypes => self.0 >= 0x08_04_00,
322            Feature::RequiredParameterAfterOptionalUnionOrMixed => self.0 >= 0x08_03_00,
323            Feature::RequiredParameterAfterOptionalNullableAndDefaultNull => self.0 >= 0x08_01_00,
324            Feature::RequiredParameterAfterOptional => self.0 >= 0x08_00_00,
325            _ => false,
326        }
327    }
328
329    /// Converts this `PHPVersion` into a raw version ID (e.g. `80400` for `8.4.0`).
330    ///
331    /// This is the inverse of [`from_version_id`].
332    ///
333    /// # Examples
334    ///
335    /// ```
336    /// use mago_php_version::PHPVersion;
337    ///
338    /// let version = PHPVersion::new(8, 4, 0);
339    /// assert_eq!(version.to_version_id(), 0x080400);
340    /// ```
341    pub const fn to_version_id(&self) -> u32 {
342        self.0
343    }
344}
345
346impl std::fmt::Display for PHPVersion {
347    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
348        write!(f, "{}.{}.{}", self.major(), self.minor(), self.patch())
349    }
350}
351
352impl Serialize for PHPVersion {
353    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
354    where
355        S: Serializer,
356    {
357        serializer.serialize_str(&self.to_string())
358    }
359}
360
361impl<'de> Deserialize<'de> for PHPVersion {
362    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
363    where
364        D: Deserializer<'de>,
365    {
366        let s = String::deserialize(deserializer)?;
367
368        s.parse().map_err(serde::de::Error::custom)
369    }
370}
371
372impl FromStr for PHPVersion {
373    type Err = ParsingError;
374
375    fn from_str(s: &str) -> Result<Self, Self::Err> {
376        if s.is_empty() {
377            return Err(ParsingError::InvalidFormat);
378        }
379
380        let parts = s.split('.').collect::<Vec<_>>();
381        match parts.len() {
382            1 => {
383                let major = parts[0].parse()?;
384
385                Ok(Self::new(major, 0, 0))
386            }
387            2 => {
388                let major = parts[0].parse()?;
389                let minor = parts[1].parse()?;
390
391                Ok(Self::new(major, minor, 0))
392            }
393            3 => {
394                let major = parts[0].parse()?;
395                let minor = parts[1].parse()?;
396                let patch = parts[2].parse()?;
397
398                Ok(Self::new(major, minor, patch))
399            }
400            _ => Err(ParsingError::InvalidFormat),
401        }
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_version() {
411        let version = PHPVersion::new(7, 4, 0);
412        assert_eq!(version.major(), 7);
413        assert_eq!(version.minor(), 4);
414        assert_eq!(version.patch(), 0);
415    }
416
417    #[test]
418    fn test_display() {
419        let version = PHPVersion::new(7, 4, 0);
420        assert_eq!(version.to_string(), "7.4.0");
421    }
422
423    #[test]
424    fn test_from_str_single_segment() {
425        let v: PHPVersion = "7".parse().unwrap();
426        assert_eq!(v.major(), 7);
427        assert_eq!(v.minor(), 0);
428        assert_eq!(v.patch(), 0);
429        assert_eq!(v.to_string(), "7.0.0");
430    }
431
432    #[test]
433    fn test_from_str_two_segments() {
434        let v: PHPVersion = "7.4".parse().unwrap();
435        assert_eq!(v.major(), 7);
436        assert_eq!(v.minor(), 4);
437        assert_eq!(v.patch(), 0);
438        assert_eq!(v.to_string(), "7.4.0");
439    }
440
441    #[test]
442    fn test_from_str_three_segments() {
443        let v: PHPVersion = "8.1.2".parse().unwrap();
444        assert_eq!(v.major(), 8);
445        assert_eq!(v.minor(), 1);
446        assert_eq!(v.patch(), 2);
447        assert_eq!(v.to_string(), "8.1.2");
448    }
449
450    #[test]
451    fn test_from_str_invalid() {
452        let err = "7.4.0.1".parse::<PHPVersion>().unwrap_err();
453        assert_eq!(format!("{err}"), "Invalid version format, expected 'major.minor.patch'.");
454
455        let err = "".parse::<PHPVersion>().unwrap_err();
456        assert_eq!(format!("{err}"), "Invalid version format, expected 'major.minor.patch'.");
457
458        let err = "foo.4.0".parse::<PHPVersion>().unwrap_err();
459        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
460
461        let err = "7.foo.0".parse::<PHPVersion>().unwrap_err();
462        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
463
464        let err = "7.4.foo".parse::<PHPVersion>().unwrap_err();
465        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
466    }
467
468    #[test]
469    fn test_is_supported_features_before_8() {
470        let v_7_4_0 = PHPVersion::new(7, 4, 0);
471
472        assert!(v_7_4_0.is_supported(Feature::NullCoalesceAssign));
473        assert!(!v_7_4_0.is_supported(Feature::NamedArguments));
474
475        assert!(v_7_4_0.is_supported(Feature::CallableInstanceMethods));
476        assert!(v_7_4_0.is_supported(Feature::LegacyConstructor));
477    }
478
479    #[test]
480    fn test_is_supported_features_8_0_0() {
481        let v_8_0_0 = PHPVersion::new(8, 0, 0);
482
483        assert!(v_8_0_0.is_supported(Feature::NamedArguments));
484        assert!(!v_8_0_0.is_supported(Feature::CallableInstanceMethods));
485    }
486
487    #[test]
488    fn test_is_deprecated_features() {
489        let v_7_4_0 = PHPVersion::new(7, 4, 0);
490        assert!(!v_7_4_0.is_deprecated(Feature::DynamicProperties));
491        assert!(!v_7_4_0.is_deprecated(Feature::RequiredParameterAfterOptional));
492
493        let v_8_0_0 = PHPVersion::new(8, 0, 0);
494        assert!(v_8_0_0.is_deprecated(Feature::RequiredParameterAfterOptional));
495        assert!(!v_8_0_0.is_deprecated(Feature::DynamicProperties));
496
497        let v_8_2_0 = PHPVersion::new(8, 2, 0);
498        assert!(v_8_2_0.is_deprecated(Feature::DynamicProperties));
499    }
500
501    #[test]
502    fn test_serde_serialize() {
503        let v_7_4_0 = PHPVersion::new(7, 4, 0);
504        let json = serde_json::to_string(&v_7_4_0).unwrap();
505        assert_eq!(json, "\"7.4.0\"");
506    }
507
508    #[test]
509    fn test_serde_deserialize() {
510        let json = "\"7.4.0\"";
511        let v: PHPVersion = serde_json::from_str(json).unwrap();
512        assert_eq!(v.major(), 7);
513        assert_eq!(v.minor(), 4);
514        assert_eq!(v.patch(), 0);
515
516        let json = "\"7.4\"";
517        let v: PHPVersion = serde_json::from_str(json).unwrap();
518        assert_eq!(v.major(), 7);
519        assert_eq!(v.minor(), 4);
520        assert_eq!(v.patch(), 0);
521    }
522
523    #[test]
524    fn test_serde_round_trip() {
525        let original = PHPVersion::new(8, 1, 5);
526        let serialized = serde_json::to_string(&original).unwrap();
527        let deserialized: PHPVersion = serde_json::from_str(&serialized).unwrap();
528        assert_eq!(original, deserialized);
529        assert_eq!(serialized, "\"8.1.5\"");
530    }
531}