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