Initial code

master 1.0.0
Julien Rosset 2 years ago
parent 7e1f044d72
commit a85d7d5fc6

@ -1,3 +1,3 @@
# XXX
# PhpSermVer
XXX
Class representing a [semantic version](https://semver.org/).

@ -1,7 +1,17 @@
{
"name": "jrosset/xxx",
"description": "XXX",
"name": "jrosset/semver",
"description": "Class representing a semantic version",
"keywords": [ ],
"type": "library",
"config": {
"sort-packages": true
},
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"minimum-stability": "stable",
"require": {
@ -15,7 +25,7 @@
},
"readme": "README.md",
"homepage": "https://git.jrosset.ovh/jrosset/XXX",
"homepage": "https://git.jrosset.ovh/jrosset/PhpSemVer",
"license": "CC-BY-4.0",
"authors": [
{
@ -25,9 +35,9 @@
],
"support": {
"email": "jul.rosset@gmail.com",
"issues": "https://git.jrosset.ovh/jrosset/XXX/issues",
"wiki": "https://git.jrosset.ovh/jrosset/XXX/wiki",
"docs": "https://git.jrosset.ovh/jrosset/XXX/wiki",
"source": "https://git.jrosset.ovh/jrosset/XXX"
"issues": "https://git.jrosset.ovh/jrosset/PhpSemVer/issues",
"wiki": "https://git.jrosset.ovh/jrosset/PhpSemVer/wiki",
"docs": "https://git.jrosset.ovh/jrosset/PhpSemVer/wiki",
"source": "https://git.jrosset.ovh/jrosset/PhpSemVer"
}
}

@ -0,0 +1,396 @@
<?php
namespace jrosset\SemVer;
use DirectoryIterator;
use Exception;
use InvalidArgumentException;
use SplFileInfo;
/**
* A semantic version
*
* @link https://semver.org/ The semantic versionning (2.0.0)
*/
final class Version {
/**
* The separator between version number
*/
private const SEPARATOR_VERSION = '.';
/**
* The separator for pre-release
*/
private const SEPARATOR_PRE_RELEASE = '-';
/**
* The separator for build metadata
*/
private const SEPARATOR_BUILD_METADATA = '+';
/**
* @var string The regular expression's pattern for the semantic version parsing
*
* @link https://regex101.com/r/gDKcBH/1
*/
private static string $constRegexPattern = '';
/**
* @var int The major version
*/
private int $major;
/**
* @var int The minor version
*/
private int $minor;
/**
* @var int The patch version
*/
private int $patch;
/**
* @var VersionExtraPart The pre-release
*/
private VersionExtraPart $preRelease;
/**
* @var VersionExtraPart The build metadata
*/
private VersionExtraPart $buildMetadata;
/**
* Create a semantic version
*
* @param int|null $major The major version
* @param int|null $minor The minor version
* @param int|null $patch The patch version
* @param VersionExtraPart|string|null $preRelease The pre-release
* @param VersionExtraPart|string|null $buildMetadata The build metadata
*
* @throws InvalidArgumentException If the semantic version is not valid
*/
public function __construct (?int $major = null, ?int $minor = null, ?int $patch = null, $preRelease = null, $buildMetadata = null) {
$this->setMajor($major);
$this->setMinor($minor);
$this->setPatch($patch);
if (!$this->isEmpty()) {
$this->setPreRelease($preRelease);
$this->setBuildMetadata($buildMetadata);
}
}
/**
* Export the semantic version to a string
*
* @return string The semantic version to a string
*/
public function __toString (): string {
if ($this->isEmpty()) {
return '';
}
$string = $this->getMajor() . self::SEPARATOR_VERSION . $this->getMinor() . self::SEPARATOR_VERSION . $this->getPatch();
if (!$this->getPreRelease()->isNull()) {
$string .= self::SEPARATOR_PRE_RELEASE . $this->getPreRelease();
}
if (!$this->getBuildMetadata()->isNull()) {
$string .= self::SEPARATOR_BUILD_METADATA . $this->getBuildMetadata();
}
return $string;
}
/**
* Export the semantic version to a <u>short</u> string (only version numbers)
*
* @param bool $showEmptyPatch Show the patch version if null/0 ?
*
* @return string The semantic version to a <u>short</u> string
*/
public function toShortString (bool $showEmptyPatch = false): string {
if ($this->isEmpty()) {
return '';
}
return $this->getMajor() . self::SEPARATOR_VERSION . $this->getMinor() . (!$showEmptyPatch && $this->getPatch() === 0 ? '' : self::SEPARATOR_VERSION . $this->getPatch());
}
/**
* Parse a semantic version from a string
*
* @param string|null $semanticVersion The string to parse
*
* @return self The corresponding semantic version
*
* @throws InvalidArgumentException If the semantic version is not valid
*/
public static function parse (?string $semanticVersion): self {
//region Construction pattern regex
if (self::$constRegexPattern === '') {
/** @noinspection RegExpSuspiciousBackref */
self::$constRegexPattern = /** @lang PhpRegExp */
'/'
. '(?(DEFINE)(?\'VersionNumber\'0|[1-9][0-9]*))'
. '(?(DEFINE)(?\'Identifier\'' . VersionExtraPart::REGEX_IDENTIFIER . '))'
. '(?(DEFINE)(?\'ExtraPart\'(?P>Identifier)(?:' . preg_quote(VersionExtraPart::SEPARATOR_IDENTIFIERS, '/') . '(?P>Identifier))*))'
. '^v?'
. '(?<major>(?P>VersionNumber))(?:' . preg_quote(self::SEPARATOR_VERSION, '/')
. '(?<minor>(?P>VersionNumber))(?:' . preg_quote(self::SEPARATOR_VERSION, '/')
. '(?<patch>(?P>VersionNumber)))?)?'
. '(?:' . preg_quote(self::SEPARATOR_PRE_RELEASE, '/') . '(?<preRelease>(?P>ExtraPart)))?'
. '(?:' . preg_quote(self::SEPARATOR_BUILD_METADATA, '/') . '(?<buildMetadata>(?P>ExtraPart)))?'
. '$/';
}
//endregion
if (preg_match(self::$constRegexPattern, $semanticVersion, $match) !== 1) {
$match = [];
}
return new self (
$match['major'] ?? 0,
$match['minor'] ?? 0,
$match['patch'] ?? 0,
$match['preRelease'] ?? new VersionExtraPart(),
$match['buildMetadata'] ?? new VersionExtraPart(),
);
}
/**
* Compare with another semantic version
*
* @param self $other The other semantic version
*
* @return int 1 if current version is greater, 0 if they are equal, -1 if current version is lower
*/
public function compare (self $other): int {
if ($this->getMajor() > $other->getMajor()) {
return 1;
}
elseif ($this->getMajor() < $other->getMajor()) {
return -1;
}
if ($this->getMinor() > $other->getMinor()) {
return 1;
}
elseif ($this->getMinor() < $other->getMinor()) {
return -1;
}
if ($this->getPatch() > $other->getPatch()) {
return 1;
}
elseif ($this->getPatch() < $other->getPatch()) {
return -1;
}
if (($compare = $this->getPreRelease()->compare($other->getPreRelease())) !== 0) {
return $compare;
}
if (($compare = $this->getBuildMetadata()->compare($other->getBuildMetadata())) !== 0) {
return $compare;
}
return 0;
}
/**
* Is the semantic version empty ?
*
* @return bool Is the semantic version empty ?
*/
public function isEmpty (): bool {
return $this->getMajor() === 0
&& $this->getMinor() === 0
&& $this->getPatch() === 0;
}
/**
* Alias for {@see Version::isNull()}
*/
public function isNull (): bool {
return $this->isEmpty();
}
/**
* The major version
*
* @param bool $asNull Return null if 0
*
* @return int|null The major version
*/
public function getMajor (bool $asNull = false): ?int {
return $asNull && $this->major === 0 ? null : $this->major;
}
/**
* Set the major version
*
* @param int|null $major The new major version
*
* @return $this
*
* @throws InvalidArgumentException If the major version is not valid
*/
public function setMajor (?int $major): self {
if ($major < 0) {
throw new InvalidArgumentException('The major version is not valid');
}
$this->major = $major ?? 0;
return $this;
}
/**
* The minor version
*
* @param bool $asNull Return null if 0
*
* @return int|null The minor version
*/
public function getMinor (bool $asNull = false): ?int {
return $asNull && $this->minor === 0 ? null : $this->minor;
}
/**
* Set the minor version
*
* @param int|null $minor The new minor version
*
* @return $this
*
* @throws InvalidArgumentException If the minor version is not valid
*/
public function setMinor (?int $minor): self {
if ($minor < 0) {
throw new InvalidArgumentException('The minor version is not valid');
}
$this->minor = $minor ?? 0;
return $this;
}
/**
* The patch version
*
* @param bool $asNull Return null if 0
*
* @return int|null The patch version
*/
public function getPatch (bool $asNull = false): ?int {
return $asNull && $this->patch === 0 ? null : $this->patch;
}
/**
* Set the patch version
*
* @param int|null $patch The new patch version
*
* @return $this
*
* @throws InvalidArgumentException If the path version is not valid
*/
public function setPatch (?int $patch): self {
if ($patch < 0) {
throw new InvalidArgumentException('The path version is not valid');
}
$this->patch = $patch ?? 0;
return $this;
}
/**
* The pre-release
*
* @param bool $asNull Return null if no pre-release
*
* @return VersionExtraPart|null The pre-release
*/
public function getPreRelease (bool $asNull = false): ?VersionExtraPart {
return $asNull && $this->preRelease->isNull() ? null : $this->preRelease;
}
/**
* Set the pre-release
*
* @param VersionExtraPart|string|null $preRelease The new pre-release
*
* @return $this
*
* @throws InvalidArgumentException If the pre-release is not valid
*/
public function setPreRelease ($preRelease): self {
try {
$this->preRelease = is_string($preRelease)
? VersionExtraPart::parse($preRelease)
: $preRelease ?? new VersionExtraPart();
}
catch (Exception $exception) {
throw new InvalidArgumentException('The pre-release is not valid', 0, $exception);
}
return $this;
}
/**
* The build metadata
*
* @param bool $asNull Return null if no build metadata
*
* @return VersionExtraPart|null The build metadata
*/
public function getBuildMetadata (bool $asNull = false): ?VersionExtraPart {
return $asNull && $this->buildMetadata->isNull() ? null : $this->buildMetadata;
}
/**
* Set the build metadata
*
* @param VersionExtraPart|string|null $buildMetadata The new build metadata
*
* @return $this
*
* @throws InvalidArgumentException If the build metadata is not valid
*/
public function setBuildMetadata ($buildMetadata): self {
try {
$this->buildMetadata = is_string($buildMetadata)
? VersionExtraPart::parse($buildMetadata)
: $buildMetadata ?? new VersionExtraPart();
}
catch (Exception $exception) {
throw new InvalidArgumentException('The pre-release is not valid', 0, $exception);
}
return $this;
}
//region Extraction depuis Git
/**
* Subdirectory of git for tags
*/
protected const PATH_GIT_TAGS = '/.git/refs/tags';
/**
* Get last version from git (scan in tags)
*
* @param string $repositoryRootDir The git repository root directory (without the ".git")
*
* @return self The last version or an empty semantic version if none
*/
public static function getGitLastTag (string $repositoryRootDir): self {
$versions = [];
/** @var SplFileInfo $file */
$directoryIterator = new DirectoryIterator($repositoryRootDir . self::PATH_GIT_TAGS);
foreach ($directoryIterator as $file) {
if (!$file->isFile()) {
continue;
}
if (($version = self::parse($file->getFilename()))->isEmpty()) {
continue;
}
$versions[] = $version;
}
if (count($versions) === 0) {
return new self();
}
usort(
$versions,
function (self $versionNumber1, self $versionNumber2): int {
return $versionNumber1->compare($versionNumber2);
}
);
return array_pop($versions);
}
//endregion
}

@ -0,0 +1,181 @@
<?php
namespace jrosset\SemVer;
use InvalidArgumentException;
/**
* An extra part to version : pre-release or build metadata
*/
class VersionExtraPart {
/**
* The separator between identifiers
*/
public const SEPARATOR_IDENTIFIERS = '.';
/**
* The regular expression's pattern for a valid identifier
*/
public const REGEX_IDENTIFIER = '[1-9a-zA-Z-][0-9a-zA-Z-]*';
/**
* The regular expression's pattern for a valid identifier
*/
public const REGEX_IDENTIFIER_PHP = /** @lang PhpRegExp */
'/^' . self::REGEX_IDENTIFIER . '$/';
/**
* @var string[] The identifiers list
*/
private array $identifiers;
/**
* Create an extra part
*
* @param string[]|null $identifiers The identifiers list
*
* @throws InvalidArgumentException If one of the identifier is not valid (cf. {@see VersionExtraPart::REGEX_IDENTIFIER_PHP})
*/
public function __construct (?array $identifiers = null) {
$this->setIdentifiers($identifiers);
}
/**
* Export the extra part to a string
*
* @return string The extra part to a string
*/
public function __toString (): string {
return implode(self::SEPARATOR_IDENTIFIERS, $this->getIdentifiers());
}
/**
* Parse an extra part from a string
*
* @param string|null $extraPart The string to parse
*
* @return self The corresponding extra part
*
* @throws InvalidArgumentException If one of the identifier is not valid (cf. {@see VersionExtraPart::REGEX_IDENTIFIER_PHP})
*/
public static function parse (?string $extraPart): self {
return new self(explode(self::SEPARATOR_IDENTIFIERS, $extraPart ?? ''));
}
/**
* Is the extra part empty ?
*
* @return bool Is the extra part empty ?
*/
public function isEmpty (): bool {
return count($this->identifiers) === 0;
}
/**
* Alias for {@see VersionExtraPart::isNull()}
*/
public function isNull (): bool {
return $this->isEmpty();
}
/**
* Compare with another extra part
*
* @param VersionExtraPart $other The other extra part
*
* @return int 1 if greater than, 0 is equal or -1 if lesser than
*/
public function compare (VersionExtraPart $other): int {
$identifiers1 = $this->getIdentifiers();
$identifiers2 = $other->getIdentifiers();
$maxSize = max(count($identifiers1), count($identifiers2));
$identifiers1 = array_pad($identifiers1, $maxSize, null);
$identifiers2 = array_pad($identifiers2, $maxSize, null);
for ($iterator = 0; $iterator < $maxSize; $iterator++) {
if (($compare = self::compareIdentifier($identifiers1[$iterator], $identifiers2[$iterator])) === 0) {
continue;
}
return $compare;
}
return 0;
}
/**
* Compare one identifier with another one
*
* @param string|null $identifier1 The first identifier
* @param string|null $identifier2 The second identifier
*
* @return int 1 if the first is greater than the second, 0 is they are equals or -1 if the first is lesser than the second
*/
protected static function compareIdentifier (?string $identifier1, ?string $identifier2): int {
//region Check set size (null if missing)
if ($identifier1 === null && $identifier2 === null) {
return 0;
}
elseif ($identifier1 !== null && $identifier2 === null) {
return 1;
}
elseif ($identifier1 === null && $identifier2 !== null) {
return -1;
}
//endregion
//region Check digit-only vs string
if (!self::isOnlyDigits($identifier1) && self::isOnlyDigits($identifier2)) {
return 1;
}
elseif (self::isOnlyDigits($identifier1) && !self::isOnlyDigits($identifier2)) {
return -1;
}
//endregion
//region The comparison it-self
if ($identifier1 == $identifier2) {
return 0;
}
else {
return $identifier1 > $identifier2 ? 1 : -1;
}
//endregion
}
/**
* Check if a string contains only digits
*
* @param string $string The string to check
*
* @return bool Is the string contains only digits ?
*/
protected static function isOnlyDigits (string $string): bool {
return preg_match('/^[0-9]+$/', $string) === 1;
}
/**
* The identifiers list
*
* @param bool $asNull Return null if empty
*
* @return string[]|null The identifiers list
*/
public function getIdentifiers (bool $asNull = false): ?array {
return $asNull && $this->isNull() ? null : $this->identifiers;
}
/**
* Set the identifiers list
*
* @param array|null $identifiers The new identifiers list
*
* @return $this
*
* @throws InvalidArgumentException If one of the identifier is not valid (cf. {@see VersionExtraPart::REGEX_IDENTIFIER_PHP})
*/
protected function setIdentifiers (?array $identifiers): self {
if ($identifiers !== null) {
foreach ($identifiers as $identifier) {
if (preg_match(self::REGEX_IDENTIFIER_PHP, $identifier) !== 1) {
throw new InvalidArgumentException('The identifiant is not valid: ' . $identifier);
}
}
}
$this->identifiers = $identifiers ?? [];
return $this;
}
}

@ -0,0 +1,17 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use jrosset\SemVer\Version;
echo '======= Empty version =======' . PHP_EOL;
$version = new Version();
var_dump((string)$version, $version->toShortString(), $version);
echo '======= Version "complète" =======' . PHP_EOL;
$version = Version::parse('1.0.0-alpha.1-0+12587-6');
var_dump((string)$version, $version->toShortString(), $version);
echo '======= Last git version =======' . PHP_EOL;
$version = Version::getGitLastTag(__DIR__ . '/../');
var_dump((string)$version, $version->toShortString(), $version);
Loading…
Cancel
Save