Skip to content

Instantly share code, notes, and snippets.

@simonhamp
Created January 18, 2018 12:16
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save simonhamp/80c781322e2cd2fb1573e738749a10ed to your computer and use it in GitHub Desktop.
Save simonhamp/80c781322e2cd2fb1573e738749a10ed to your computer and use it in GitHub Desktop.
A simple SemVer parser/matcher
<?php
class VersionMatcher
{
protected $specificity = 'major';
protected $originals = [];
protected $current;
protected $target;
protected static $padDigits;
/**
* Match a target version to a single or multiple available versions. Matching can be strict, holding to
* the specificity level of $target or non-strict (default). Returns true if the target is supported by
* any of the available versions.
*
* @param string $target The target version
* @param mixed $available A string or array of strings of available versions for matching
* @param bool $strict Whether or not matching should be strict
* @return bool
*/
public static function supported($target, $available, $strict = false)
{
if (! is_bool($strict)) {
$strict = false;
}
if (! is_array($available)) {
$available = [$available];
}
foreach ($available as $current) {
$matcher = new static($current, $target);
if ($strict ? $matcher->matches() : $matcher->exceeds()) {
return true;
}
}
return false;
}
/**
* Set the number of digits to pad the version segments by for logical matching. Default is 3.
*
* @param int $digits
*/
public static function setPadDigits($digits)
{
static::padDigits = (int) $digits;
}
/**
* @param string $current The version number to match against
* @param string $target The requested version number
* @param int $padDigits The number of digits to pad segments with for logical matching
*/
public function __construct($current, $target, $padDigits = 3)
{
$this->originals = compact('current', 'target');
static::setPadDigits($padDigits);
$this->setCurrent($current);
$this->setTarget($target);
}
/**
* Determine if the requested version number is equal to and greater than the current version number, and less
* than the next semantic version.
*
* @return bool
*/
public function matches()
{
// Calculate the next appropriate version number as a maximum for comparison
$max = $this->getNextKeyVersionNumber($this->originals['current']);
// Equal/higher than target, current less than max;
return $this->exceeds() && $this->target < $max;
}
/**
* Determine if the requested version number is equal to and greater than the current version number.
*
* @return bool
*/
public function exceeds()
{
return $this->target >= $this->current;
}
/**
* Return the specificity used for the this match: 'major', 'minor' or 'patch'.
*
* @return string
*/
public function getSpecificity()
{
return $this->specificity;
}
/**
* Parse the segment values from a semantic version number string.
*
* @param string $str The semantic version string to parse
* @param bool $determineSpecificity Should the provided version be used to determine comparison specificity
*
* @throws \Exception
*
* @return array A named-index array of SemVer segments
*/
public function getVersionSegments($str, $determineSpecificity = false) {
$valid = preg_match('/^(?P<major>\d+)\.?(?P<minor>\d+|\*)?(?<!\*)\.?(?P<patch>\d+|\*)?$/', $str, $segments);
if ($valid === false || $valid === 0) {
throw new \Exception("Invalid version number: {$str}");
}
$final_segments = [];
foreach (['major', 'minor', 'patch'] as $spec) {
if (! isset($segments[$spec]) || $segments[$spec] === '*') {
$final_segments[$spec] = str_repeat('9', $this->padDigits);
} else {
if ($determineSpecificity) {
$this->specificity = $spec;
}
$final_segments[$spec] = $segments[$spec];
}
}
return $final_segments;
}
/**
* Set the target version number.
*
* @param str $str The target SemVer string
* @return $this
*/
public function setTarget($str) {
$segments = $this->getTargetVersionSegments($str);
$this->target = $this->compileLogicalVersionNumber($segments);
return $this;
}
/**
* Set the current version number.
*
* @param str $str The current SemVer string
* @return $this
*/
public function setCurrent($str) {
$segments = $this->getVersionSegments($str);
$this->current = $this->compileLogicalVersionNumber($segments);
return $this;
}
/**
* Determine the next major/minor/patch version number from a given starting point.
*
* @param str $from The origin SemVer string
* @return $this
*/
protected function getNextKeyVersionNumber($from) {
$segments = $this->getVersionSegments($from);
$segments[$this->specificity] = (int) $segments[$this->specificity] + 1;
if ($this->specificity === 'major') {
$segments['minor'] = 0;
}
if ($this->specificity !== 'patch') {
$segments['patch'] = 0;
}
return $this->compileLogicalVersionNumber($segments);
}
/**
* Get the target version number segments, calculating the specificity to use for matching.
*
* @param str $str The target SemVer string
* @return array A named-index array of SemVer segments
*/
protected function getTargetVersionSegments($str) {
return $this->getVersionSegments($str, true);
}
/**
* Get the target version number segments, calculating the specificity to use for matching.
*
* @param str $str The target SemVer string
* @return array A named-index array of SemVer segments
*/
protected function compileLogicalVersionNumber(array $segments) {
return (int) $segments['major'] . str_pad($segments['minor'], $this->padDigits, '0', STR_PAD_LEFT) . str_pad($segments['patch'], $this->padDigits, '0', STR_PAD_LEFT);
}
}
@simonhamp
Copy link
Author

simonhamp commented Jan 18, 2018

Basic usage:

$requested = '1.0.1';
$current = '1.0.0';
VersionMatcher::supported($requested, $current);
// => true

The result of this check will be true as the requested version is higher than the current version. Using the * wildcard for (or omitting) the patch or minor segment (e.g. 1.*/1: minor-level match; 1.0/1.0.*: patch-level match) in your requested version will use wider matching.

There is no wildcard option at major level specificity as major changes are most likely to be breaking and so requesting them should be explicit.

If you want, you can make the match strict, which effectively adds a less-than check and calculates the next appropriate SemVer above $current according to the specificity used in $requested. E.g.:

$requested = '1.0.1'; // uses patch-level matching
$current = '1.0.0';
VersionMatcher::supported($requested, $current, true);
// => false

This will return false as 1.0.1 !== 1.0.0. However, the following will match:

$requested = '1.0'; // or '1.0.*' - will use minor-level matching
$current = '1.0.0';
VersionMatcher::supported($requested, $current, true);
// => true

$current can also be an array of version strings, which allows you to test the $requested version against a bunch of version numbers and will pass if any of them matches. Your $current version strings should always be complete SemVer strings - don't use wildcards.

$requested = '4.0';
$current = [
    '1.0.0',
    '2.0.3',
    '3.2.1',
]
VersionMatcher::supported($requested, $current, true);
// => false

No matching version for 4.0 is supported by the version strings provided.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment