@@ -98,6 +98,52 @@ public static function matchProperty($property, $propertyValues)
9898 }
9999 }
100100
101+ // Semver operators
102+ if (in_array ($ operator , ["semver_eq " , "semver_neq " , "semver_gt " , "semver_gte " , "semver_lt " , "semver_lte " ])) {
103+ $ overrideTuple = FeatureFlag::parseSemver ($ overrideValue );
104+ $ valueTuple = FeatureFlag::parseSemver ($ value );
105+
106+ $ comparison = FeatureFlag::compareSemverTuples ($ overrideTuple , $ valueTuple );
107+
108+ if ($ operator === "semver_eq " ) {
109+ return $ comparison === 0 ;
110+ } elseif ($ operator === "semver_neq " ) {
111+ return $ comparison !== 0 ;
112+ } elseif ($ operator === "semver_gt " ) {
113+ return $ comparison > 0 ;
114+ } elseif ($ operator === "semver_gte " ) {
115+ return $ comparison >= 0 ;
116+ } elseif ($ operator === "semver_lt " ) {
117+ return $ comparison < 0 ;
118+ } elseif ($ operator === "semver_lte " ) {
119+ return $ comparison <= 0 ;
120+ }
121+ }
122+
123+ if ($ operator === "semver_tilde " ) {
124+ $ overrideTuple = FeatureFlag::parseSemver ($ overrideValue );
125+ list ($ lower , $ upper ) = FeatureFlag::tildeBounds ($ value );
126+
127+ return FeatureFlag::compareSemverTuples ($ overrideTuple , $ lower ) >= 0
128+ && FeatureFlag::compareSemverTuples ($ overrideTuple , $ upper ) < 0 ;
129+ }
130+
131+ if ($ operator === "semver_caret " ) {
132+ $ overrideTuple = FeatureFlag::parseSemver ($ overrideValue );
133+ list ($ lower , $ upper ) = FeatureFlag::caretBounds ($ value );
134+
135+ return FeatureFlag::compareSemverTuples ($ overrideTuple , $ lower ) >= 0
136+ && FeatureFlag::compareSemverTuples ($ overrideTuple , $ upper ) < 0 ;
137+ }
138+
139+ if ($ operator === "semver_wildcard " ) {
140+ $ overrideTuple = FeatureFlag::parseSemver ($ overrideValue );
141+ list ($ lower , $ upper ) = FeatureFlag::wildcardBounds ($ value );
142+
143+ return FeatureFlag::compareSemverTuples ($ overrideTuple , $ lower ) >= 0
144+ && FeatureFlag::compareSemverTuples ($ overrideTuple , $ upper ) < 0 ;
145+ }
146+
101147 return false ;
102148 }
103149
@@ -245,6 +291,198 @@ public static function relativeDateParseForFeatureFlagMatching($value)
245291 }
246292 }
247293
294+ /**
295+ * Parse a semver string into a tuple of [major, minor, patch].
296+ *
297+ * Rules:
298+ * 1. Strip leading/trailing whitespace
299+ * 2. Strip `v` or `V` prefix (e.g., "v1.2.3" → "1.2.3")
300+ * 3. Strip pre-release and build metadata suffixes (split on `-` or `+`, take first part)
301+ * 4. Split on `.` and parse first 3 components as integers
302+ * 5. Default missing components to 0 (e.g., "1.2" → (1, 2, 0), "1" → (1, 0, 0))
303+ * 6. Ignore extra components beyond the third (e.g., "1.2.3.4" → (1, 2, 3))
304+ * 7. Throw InconclusiveMatchException for invalid input (empty string, non-numeric parts, leading dot)
305+ *
306+ * @param mixed $value The semver string to parse
307+ * @return array{int, int, int} The parsed tuple [major, minor, patch]
308+ * @throws InconclusiveMatchException If the value cannot be parsed as semver
309+ */
310+ public static function parseSemver ($ value ): array
311+ {
312+ if ($ value === null || $ value === "" ) {
313+ throw new InconclusiveMatchException ("Cannot parse empty or null value as semver " );
314+ }
315+
316+ $ text = trim (strval ($ value ));
317+
318+ if ($ text === "" ) {
319+ throw new InconclusiveMatchException ("Cannot parse empty value as semver " );
320+ }
321+
322+ // Strip v/V prefix
323+ $ text = ltrim ($ text , "vV " );
324+
325+ if ($ text === "" ) {
326+ throw new InconclusiveMatchException ("Cannot parse semver: only prefix found " );
327+ }
328+
329+ // Strip pre-release and build metadata (split on - or +, take first part)
330+ $ text = preg_split ('/[-+]/ ' , $ text , 2 )[0 ];
331+
332+ // Check for leading dot
333+ if (str_starts_with ($ text , ". " )) {
334+ throw new InconclusiveMatchException ("Cannot parse semver with leading dot: {$ value }" );
335+ }
336+
337+ // Split on dots
338+ $ parts = explode (". " , $ text );
339+
340+ // Parse major
341+ if (!isset ($ parts [0 ]) || $ parts [0 ] === "" || !ctype_digit (ltrim ($ parts [0 ], "0 " ) ?: "0 " )) {
342+ // Allow pure zeros or numeric strings
343+ if (isset ($ parts [0 ]) && preg_match ('/^[0-9]+$/ ' , $ parts [0 ])) {
344+ $ major = intval ($ parts [0 ]);
345+ } else {
346+ throw new InconclusiveMatchException ("Cannot parse semver: invalid major version in {$ value }" );
347+ }
348+ } else {
349+ $ major = intval ($ parts [0 ]);
350+ }
351+
352+ // Parse minor (default to 0 if not present or empty)
353+ $ minor = 0 ;
354+ if (isset ($ parts [1 ]) && $ parts [1 ] !== "" ) {
355+ if (!preg_match ('/^[0-9]+$/ ' , $ parts [1 ])) {
356+ throw new InconclusiveMatchException ("Cannot parse semver: invalid minor version in {$ value }" );
357+ }
358+ $ minor = intval ($ parts [1 ]);
359+ }
360+
361+ // Parse patch (default to 0 if not present or empty)
362+ $ patch = 0 ;
363+ if (isset ($ parts [2 ]) && $ parts [2 ] !== "" ) {
364+ if (!preg_match ('/^[0-9]+$/ ' , $ parts [2 ])) {
365+ throw new InconclusiveMatchException ("Cannot parse semver: invalid patch version in {$ value }" );
366+ }
367+ $ patch = intval ($ parts [2 ]);
368+ }
369+
370+ return [$ major , $ minor , $ patch ];
371+ }
372+
373+ /**
374+ * Compare two semver tuples.
375+ *
376+ * @param array{int, int, int} $a First tuple
377+ * @param array{int, int, int} $b Second tuple
378+ * @return int -1 if a < b, 0 if a == b, 1 if a > b
379+ */
380+ private static function compareSemverTuples (array $ a , array $ b ): int
381+ {
382+ if ($ a [0 ] !== $ b [0 ]) {
383+ return $ a [0 ] <=> $ b [0 ];
384+ }
385+ if ($ a [1 ] !== $ b [1 ]) {
386+ return $ a [1 ] <=> $ b [1 ];
387+ }
388+ return $ a [2 ] <=> $ b [2 ];
389+ }
390+
391+ /**
392+ * Calculate tilde bounds for semver matching.
393+ * ~X.Y.Z means >=X.Y.Z and <X.(Y+1).0
394+ *
395+ * @param mixed $value The semver pattern
396+ * @return array{array{int, int, int}, array{int, int, int}} [lower, upper] bounds
397+ */
398+ private static function tildeBounds ($ value ): array
399+ {
400+ $ tuple = FeatureFlag::parseSemver ($ value );
401+ $ lower = $ tuple ;
402+ $ upper = [$ tuple [0 ], $ tuple [1 ] + 1 , 0 ];
403+ return [$ lower , $ upper ];
404+ }
405+
406+ /**
407+ * Calculate caret bounds for semver matching.
408+ * ^X.Y.Z where:
409+ * - X > 0: >=X.Y.Z <(X+1).0.0
410+ * - X == 0, Y > 0: >=0.Y.Z <0.(Y+1).0
411+ * - X == 0, Y == 0: >=0.0.Z <0.0.(Z+1)
412+ *
413+ * @param mixed $value The semver pattern
414+ * @return array{array{int, int, int}, array{int, int, int}} [lower, upper] bounds
415+ */
416+ private static function caretBounds ($ value ): array
417+ {
418+ $ tuple = FeatureFlag::parseSemver ($ value );
419+ $ lower = $ tuple ;
420+
421+ if ($ tuple [0 ] > 0 ) {
422+ $ upper = [$ tuple [0 ] + 1 , 0 , 0 ];
423+ } elseif ($ tuple [1 ] > 0 ) {
424+ $ upper = [0 , $ tuple [1 ] + 1 , 0 ];
425+ } else {
426+ $ upper = [0 , 0 , $ tuple [2 ] + 1 ];
427+ }
428+
429+ return [$ lower , $ upper ];
430+ }
431+
432+ /**
433+ * Calculate wildcard bounds for semver matching.
434+ * X.Y.* means >=X.Y.0 <X.(Y+1).0
435+ * X.* means >=X.0.0 <(X+1).0.0
436+ *
437+ * @param mixed $value The semver pattern with wildcard
438+ * @return array{array{int, int, int}, array{int, int, int}} [lower, upper] bounds
439+ */
440+ private static function wildcardBounds ($ value ): array
441+ {
442+ if ($ value === null || $ value === "" ) {
443+ throw new InconclusiveMatchException ("Cannot parse empty or null value as semver wildcard " );
444+ }
445+
446+ $ text = trim (strval ($ value ));
447+
448+ // Strip v/V prefix
449+ $ text = ltrim ($ text , "vV " );
450+
451+ // Split on dots
452+ $ parts = explode (". " , $ text );
453+
454+ // Remove trailing wildcard parts and empty parts
455+ while (count ($ parts ) > 0 && (end ($ parts ) === "* " || end ($ parts ) === "x " || end ($ parts ) === "X " || end ($ parts ) === "" )) {
456+ array_pop ($ parts );
457+ }
458+
459+ if (count ($ parts ) === 0 ) {
460+ throw new InconclusiveMatchException ("Cannot parse semver wildcard: no version components found in {$ value }" );
461+ }
462+
463+ // Parse major
464+ if (!preg_match ('/^[0-9]+$/ ' , $ parts [0 ])) {
465+ throw new InconclusiveMatchException ("Cannot parse semver wildcard: invalid major version in {$ value }" );
466+ }
467+ $ major = intval ($ parts [0 ]);
468+
469+ if (count ($ parts ) === 1 ) {
470+ // X.* pattern
471+ $ lower = [$ major , 0 , 0 ];
472+ $ upper = [$ major + 1 , 0 , 0 ];
473+ } else {
474+ // X.Y.* pattern
475+ if (!preg_match ('/^[0-9]+$/ ' , $ parts [1 ])) {
476+ throw new InconclusiveMatchException ("Cannot parse semver wildcard: invalid minor version in {$ value }" );
477+ }
478+ $ minor = intval ($ parts [1 ]);
479+ $ lower = [$ major , $ minor , 0 ];
480+ $ upper = [$ major , $ minor + 1 , 0 ];
481+ }
482+
483+ return [$ lower , $ upper ];
484+ }
485+
248486 private static function convertToDateTime ($ value )
249487 {
250488 if ($ value instanceof \DateTime) {
0 commit comments