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