diff --git a/README.md b/README.md index 904cea3..37de927 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# XXX +# PhpSermVer -XXX \ No newline at end of file +Class representing a [semantic version](https://semver.org/). \ No newline at end of file diff --git a/composer.json b/composer.json index 982738d..2fda4c7 100644 --- a/composer.json +++ b/composer.json @@ -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" } } \ No newline at end of file diff --git a/src/SemVer/Version.php b/src/SemVer/Version.php new file mode 100644 index 0000000..efb9da9 --- /dev/null +++ b/src/SemVer/Version.php @@ -0,0 +1,396 @@ +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 short string (only version numbers) + * + * @param bool $showEmptyPatch Show the patch version if null/0 ? + * + * @return string The semantic version to a short 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?' + . '(?(?P>VersionNumber))(?:' . preg_quote(self::SEPARATOR_VERSION, '/') + . '(?(?P>VersionNumber))(?:' . preg_quote(self::SEPARATOR_VERSION, '/') + . '(?(?P>VersionNumber)))?)?' + . '(?:' . preg_quote(self::SEPARATOR_PRE_RELEASE, '/') . '(?(?P>ExtraPart)))?' + . '(?:' . preg_quote(self::SEPARATOR_BUILD_METADATA, '/') . '(?(?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 +} \ No newline at end of file diff --git a/src/SemVer/VersionExtraPart.php b/src/SemVer/VersionExtraPart.php new file mode 100644 index 0000000..7e035b4 --- /dev/null +++ b/src/SemVer/VersionExtraPart.php @@ -0,0 +1,181 @@ +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; + } +} \ No newline at end of file diff --git a/tests/test.php b/tests/test.php new file mode 100644 index 0000000..e0412d8 --- /dev/null +++ b/tests/test.php @@ -0,0 +1,17 @@ +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); \ No newline at end of file