mago_php_version/
lib.rs

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