Skip to content

Commit 0afea85

Browse files
authored
feat(flags): add semver targeting to local evaluation (#107)
* add semver targeting to local evaluation * docs: add parsing rules to parseSemver docblock
1 parent 4cc1730 commit 0afea85

2 files changed

Lines changed: 726 additions & 0 deletions

File tree

lib/FeatureFlag.php

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)