diff --git a/README-es.md b/README-es.md index 98bb8a54..6d128ec3 100644 --- a/README-es.md +++ b/README-es.md @@ -329,15 +329,26 @@ Usa restricciones de versión estilo Composer: + + + + + + + + + ``` +Un pre-release en una restricción fija la estabilidad mínima con cualquier operador, incluso en `<3.5.0-beta.2`; redúcela con `@alpha` o `@dev`. Una palabra clave sin número también es un límite, como en Composer: `>2.0-beta` acepta `2.0.0-RC1` y `2.0.0`. Una versión exacta con pre-release u otro sufijo solo coincide con esa etiqueta: `2.0-beta` no acepta `2.0.0-beta.3` ni `2.0.0`, y `2.12.0-hotfix` no acepta `2.12.0-hotfix.2`. Las mayúsculas no importan: `1.0.0-arm64` coincide con la etiqueta `1.0.0-ARM64`, y `^1.0-Linux` acepta `1.0.0-linux`. Un pre-release puede ir pegado al número, como en las etiquetas: `^2.0.0rc1`. + ### Opciones de Configuración Avanzadas ```xml diff --git a/README-ru.md b/README-ru.md index 0db71419..73058e71 100644 --- a/README-ru.md +++ b/README-ru.md @@ -330,15 +330,26 @@ DLoad поддерживает три типа загрузки, которые + + + + + + + + + ``` +Pre-release в ограничении задаёт минимальную стабильность при любом операторе, даже в `<3.5.0-beta.2`; понизить её можно через `@alpha` или `@dev`. Ключевое слово без номера тоже служит границей, как в Composer: `>2.0-beta` берёт `2.0.0-RC1` и `2.0.0`. Точная версия с pre-release или другим суффиксом совпадает только с этим тегом: `2.0-beta` не берёт ни `2.0.0-beta.3`, ни `2.0.0`, а `2.12.0-hotfix` не берёт `2.12.0-hotfix.2`. Регистр не важен: `1.0.0-arm64` совпадает с тегом `1.0.0-ARM64`, а `^1.0-Linux` берёт `1.0.0-linux`. Pre-release может идти сразу за номером, как в тегах: `^2.0.0rc1`. + ### Расширенные настройки ```xml diff --git a/README-zh.md b/README-zh.md index c8071243..6390bcea 100644 --- a/README-zh.md +++ b/README-zh.md @@ -329,15 +329,26 @@ DLoad 支持三种下载类型,它们决定了资源的处理方式: + + + + + + + + + ``` +约束中的预发布版本会为任何运算符设定最低稳定性,即使是 `<3.5.0-beta.2`;可用 `@alpha` 或 `@dev` 降低。不带编号的关键字也是边界,与 Composer 一致:`>2.0-beta` 会选中 `2.0.0-RC1` 和 `2.0.0`。带预发布或其他后缀的精确版本只匹配该标签:`2.0-beta` 不会选中 `2.0.0-beta.3` 或 `2.0.0`,`2.12.0-hotfix` 也不会选中 `2.12.0-hotfix.2`。大小写无关:`1.0.0-arm64` 匹配标签 `1.0.0-ARM64`,`^1.0-Linux` 会选中 `1.0.0-linux`。预发布版本可以像标签那样紧跟在版本号后面:`^2.0.0rc1`。 + ### 高级配置选项 ```xml diff --git a/README.md b/README.md index 7c328f74..e07d025d 100644 --- a/README.md +++ b/README.md @@ -331,15 +331,26 @@ Use Composer-style version constraints: + + + + + + + + + ``` +A pre-release in a constraint sets the minimum stability for any operator, even for `<3.5.0-beta.2`; lower it with `@alpha` or `@dev`. A keyword without a number is a bound too, as in Composer: `>2.0-beta` takes `2.0.0-RC1` and `2.0.0`. An exact version with a pre-release or another suffix matches only that tag: `2.0-beta` takes neither `2.0.0-beta.3` nor `2.0.0`, and `2.12.0-hotfix` not `2.12.0-hotfix.2`. Case does not matter: `1.0.0-arm64` matches the tag `1.0.0-ARM64`, and `^1.0-Linux` takes `1.0.0-linux`. A pre-release may follow the number right away, as in tags: `^2.0.0rc1`. + ### Advanced Configuration Options ```xml diff --git a/composer.json b/composer.json index b6e33157..1c50040e 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,6 @@ ], "require": { "php": ">=8.2", - "composer/semver": "^3.4", "internal/container": "^1.0", "internal/destroy": "^1.0", "internal/path": "^1.2", @@ -36,6 +35,7 @@ }, "require-dev": { "buggregator/trap": "^1.15", + "composer/semver": "^3.4", "dereuromark/composer-prefer-lowest": "^0.1.10", "llm/skills": "^1.9", "roxblnfk/unpoly": "^1.8.1", diff --git a/composer.lock b/composer.lock index 2c62b610..e3ca6011 100644 --- a/composer.lock +++ b/composer.lock @@ -4,85 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7377bb5713e1c82dfcaad8e8ccb451bc", + "content-hash": "f04fa5e31f0a6538da2ab1cf1e81de59", "packages": [ - { - "name": "composer/semver", - "version": "3.4.4", - "source": { - "type": "git", - "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "shasum": "" - }, - "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.11", - "symfony/phpunit-bridge": "^3 || ^7" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Semver\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "http://www.naderman.de" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - }, - { - "name": "Rob Bast", - "email": "rob.bast@gmail.com", - "homepage": "http://robbast.nl" - } - ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", - "keywords": [ - "semantic", - "semver", - "validation", - "versioning" - ], - "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.4" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - } - ], - "time": "2025-08-20T19:15:30+00:00" - }, { "name": "internal/container", "version": "1.1.0", @@ -2984,6 +2907,83 @@ ], "time": "2026-06-07T11:47:49+00:00" }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "composer/xdebug-handler", "version": "3.0.5", diff --git a/dload.xsd b/dload.xsd index 3fa0168a..3e86039d 100644 --- a/dload.xsd +++ b/dload.xsd @@ -35,7 +35,10 @@ • "~1.0.0" - Tilde constraint • "^2.12.0-feature" - With feature suffix • "^2.12.0@beta" - With explicit stability - • "^2.12.0-hotfix@rc" - Combined feature and stability + • "^2.12.0-hotfix@alpha" - Feature builds, alpha ones included + • "2025.1.0-rc.2" - One pre-release + • "^2025.1.0-rc.1" - Range from a pre-release on + • ">=1.0 <2.0 || ^3.0" - Range and alternatives diff --git a/skills/dload-fetch-tool/SKILL.md b/skills/dload-fetch-tool/SKILL.md index f71944db..10e70d74 100644 --- a/skills/dload-fetch-tool/SKILL.md +++ b/skills/dload-fetch-tool/SKILL.md @@ -45,7 +45,7 @@ Done when the block has a `` whose `asset-pattern` matches every OS/ | Attribute | Purpose | |---|---| | `software` | Alias or name of the tool (built-in or inline). Required. | -| `version` | Composer-style constraint: `^2025.1`, `~1.0.0`, `^2.12.0@beta`, `^2.12.0-hotfix@rc`. Omit for latest stable. Stability order follows Composer, `stable` by default. | +| `version` | Composer-style constraint: `^2025.1`, `~1.0.0`, `^2.12.0@beta`, `^2.12.0-hotfix@alpha` (feature builds, alpha ones included), one pre-release like `2025.1.0-rc.2`, or a range from one like `^2025.1.0-rc.1`. Omit for latest stable. Pre-releases order by stability (`nightly < snapshot < dev < unstable < alpha < preview < beta < pre < RC < stable`), then by number; `stable` by default. A pre-release in a constraint sets the minimum stability for any operator, even `<3.5.0-beta.2`; lower it with `@alpha`/`@dev`. A keyword without a number is a bound too: `>2.0-beta` takes `2.0.0-RC1` and `2.0.0`. An exact version with a pre-release or another suffix is that one tag: `2.0-beta` skips `2.0.0-beta.3`, `2.12.0-hotfix` skips `2.12.0-hotfix.2`; after `^`, `~` or a comparison a non-stability suffix is a feature the tag must contain. A tagged build like `2025.1.0-rc.2-linux` orders right after its pre-release: an exact `2025.1.0-rc.2` skips it, `>2025.1.0-rc.2` takes it. Numbers may have more than three parts (`1.2.3.4`), in constraints too (`>=1.2.3.4.5`); trailing zero parts and build metadata don't count (`1.2.3` = `1.2.3.0` = `1.2.3+build.5`). Case doesn't matter (`-ARM64` = `-arm64`, `^1.0-Linux` takes `1.0.0-linux`), and a pre-release may be glued to the number as in tags (`^2.0.0rc1`). | | `extract-path` | Target folder (default: project root). | | `type` | `binary` (default), `phar` (required for PHAR — skips extraction), `archive` (unpack the whole asset keeping its folder layout). | diff --git a/skills/dload-fetch-tool/references/registry-entry.md b/skills/dload-fetch-tool/references/registry-entry.md index 9ca81dd7..f67c339d 100644 --- a/skills/dload-fetch-tool/references/registry-entry.md +++ b/skills/dload-fetch-tool/references/registry-entry.md @@ -12,7 +12,7 @@ How to describe a tool dload does not ship, as a `` block inside `//releases?per_page=20" \ ``` - `prerelease: true` while `version` demands stable → lower the stability (`@beta`/`@RC`/`@alpha`). +- Tag has a word after the number (`1.2.0-linux`, `1.2.0-arm64`) → a feature build of preview stability, skipped while `version` demands stable. Only a whole stability word marks a pre-release (`-beta2`, `rc1`, `-a1`): `-arm64` or `-build.5` is not alpha or beta. +- `version` is an exact version with a suffix (`2.12.0-hotfix`, `2025.1.0-rc.2`) → it matches only that tag, not `2.12.0-hotfix.2` or `2025.1.0-rc.2-linux`; use `^` or `>=` to take later builds. - `draft: true` → invisible to dload until published. - Tags carry text before the version (`bun-v1.4.2`, `cli-v2.0.0`) → set `tag-prefix` on the ``; without it the tags do not parse and every release is skipped. See [`registry-entry.md`](registry-entry.md#tag-prefix). - Tag is on GitHub but dload picks an older one → the version registry still holds a fresh check; rerun with `dload get --refresh`. A release inserted below the top of the listing (e.g. a GitLab release with a backdated date) needs `dload cache:clear `. See [`version-registry.md`](version-registry.md). diff --git a/src/Module/Binary/Binary.php b/src/Module/Binary/Binary.php index 2bc52375..baca96c0 100644 --- a/src/Module/Binary/Binary.php +++ b/src/Module/Binary/Binary.php @@ -27,7 +27,7 @@ public function getPath(): Path; public function exists(): bool; /** - * Gets the binary version. + * Gets the binary version as printed, with its pre-release and build metadata, e.g. `2.3.4+20230415`. * * @return non-empty-string|null Version string or null if not available */ diff --git a/src/Module/Binary/BinaryVersion.php b/src/Module/Binary/BinaryVersion.php index e4e44e3c..1278d83a 100644 --- a/src/Module/Binary/BinaryVersion.php +++ b/src/Module/Binary/BinaryVersion.php @@ -4,6 +4,7 @@ namespace Internal\DLoad\Module\Binary; +use Internal\DLoad\Module\Version\PreRelease; use Internal\DLoad\Module\Version\Version; /** @@ -13,11 +14,6 @@ */ final class BinaryVersion extends Version { - /** - * Pattern to extract semantic version (X.Y.Z) from text. - */ - private const OUTPUT_VERSION_PATTERN = '/(?:version:?\s*)?(?:v(?:er(?:sion)?)?\.?\s*)?' . parent::VERSION_SEMVER_PATTERN . '/i'; - /** * Resolves the version from binary command output. * @@ -27,8 +23,8 @@ final class BinaryVersion extends Version public static function fromBinaryOutput(string $output): static { // Try to extract version using semantic version pattern - $version = \preg_match(self::OUTPUT_VERSION_PATTERN, $output, $matches) - ? $matches[1] . ($matches[2] ?? '') + $version = \preg_match(self::outputVersionPattern(), $output, $matches) + ? $matches[1] . ($matches[2] ?? '') . ($matches[3] ?? '') : self::extractVersionWithFallbacks($output); if ($version === null) { @@ -40,6 +36,19 @@ public static function fromBinaryOutput(string $output): static return self::fromVersionString($version); } + /** + * Pattern to extract semantic version (X.Y.Z, or more parts like X.Y.Z.W) from text. + * Only a pre-release may follow the number right away, like `2.0.0rc1`: another word glued to it, + * like `1.2.3_amd64`, is not a part of the version. Nor is a lone `a` or `b`: like the `c` of + * OpenSSL's `1.1.1c`, it is a patch letter, not an alpha or a beta. + */ + private static function outputVersionPattern(): string + { + return '/(?:version:?\s*)?(?:v(?:er(?:sion)?)?\.?\s*)?' . parent::VERSION_SEMVER_NUMBER_PATTERN + . '((?:[-+]|(?=(?![ab](?![a-z]|[._-]?\d))' . PreRelease::wordPattern() . '))[\w.-]+)?' + . '(' . parent::buildPattern() . ')?/i'; + } + /** * Attempts to extract version using various fallback patterns. * diff --git a/src/Module/Binary/Internal/AbstractBinary.php b/src/Module/Binary/Internal/AbstractBinary.php index 937dc91e..94848d2c 100644 --- a/src/Module/Binary/Internal/AbstractBinary.php +++ b/src/Module/Binary/Internal/AbstractBinary.php @@ -89,7 +89,9 @@ public function getVersion(): ?BinaryVersion public function getVersionString(): ?string { - return $this->getVersion()?->number; + // The whole version as printed, with its pre-release and build metadata + $version = $this->getVersion()?->string; + return $version === '' ? null : $version; } abstract public function getPath(): Path; diff --git a/src/Module/Repository/Collection/ReleasesCollection.php b/src/Module/Repository/Collection/ReleasesCollection.php index 97461d12..bba54439 100644 --- a/src/Module/Repository/Collection/ReleasesCollection.php +++ b/src/Module/Repository/Collection/ReleasesCollection.php @@ -77,11 +77,10 @@ public function sortByVersion(): self { $result = \iterator_to_array($this->getIterator()); - $sort = function (ReleaseInterface $a, ReleaseInterface $b): int { - return \version_compare($this->comparisonVersionString($b), $this->comparisonVersionString($a)); - }; - - \uasort($result, $sort); + \uasort( + $result, + static fn(ReleaseInterface $a, ReleaseInterface $b): int => $b->getVersion()->compare($a->getVersion()), + ); return new self($result); } @@ -120,22 +119,4 @@ public function minimumStability(Stability $stability): self static fn(ReleaseInterface $release): bool => $release->getVersion()->stability->getWeight() >= $weight, ); } - - /** - * Converts version string to a format suitable for comparison. - * - * Normalizes version strings to handle stability suffixes properly. - * - * @return non-empty-string Normalized version string - */ - private function comparisonVersionString(ReleaseInterface $release): string - { - $stability = $release->getVersion()->stability; - - return \ltrim(\str_replace( - '-' . $stability->value, - '.' . $stability->getWeight() . '.', - $release->getVersion()->string, - ), 'v'); - } } diff --git a/src/Module/Version/Comparison.php b/src/Module/Version/Comparison.php new file mode 100644 index 00000000..79c079bd --- /dev/null +++ b/src/Module/Version/Comparison.php @@ -0,0 +1,40 @@ +=1.2.3` or `<3.5.0-beta.2`. + * + * A bound with a pre-release or another tail is a single release and compares as in sorting, tails included. + * A bound without one names a version number: every build of that number counts as the number, + * so `>=1.2` accepts `1.2.0-beta` and `<1.2` rejects it, as Composer does with `-dev` bounds. + * + * @internal + */ +final class Comparison +{ + private function __construct( + public readonly Operator $operator, + public readonly Version $bound, + private readonly bool $withPreRelease, + ) {} + + /** + * @param non-empty-string $number Version number, e.g. `1.2.3` + * @param string $tail Pre-release or another tail of the bound with its separator, e.g. `-beta.1`, `rc1` + * or `-hotfix`, read as {@see Version} reads it; empty for none + */ + public static function create(Operator $operator, string $number, string $tail = ''): self + { + return new self($operator, Version::fromVersionString($number . $tail), $tail !== ''); + } + + public function isSatisfiedBy(Version $version): bool + { + return $this->operator->accepts( + $this->withPreRelease ? $version->compare($this->bound) : $version->compareNumber($this->bound), + ); + } +} diff --git a/src/Module/Version/Constraint.php b/src/Module/Version/Constraint.php index 55ca5575..160b9813 100644 --- a/src/Module/Version/Constraint.php +++ b/src/Module/Version/Constraint.php @@ -4,21 +4,22 @@ namespace Internal\DLoad\Module\Version; -use Composer\Semver\Semver; use Internal\DLoad\Module\Common\Stability; /** * Version constraint DTO for parsing and handling complex version requirements. * * Encapsulates version constraint logic supporting: - * - Feature suffixes: ^2.12.0-feature, ~1.20.0-hotfix, ^1.0.0-my-feature - * - Stability constraints with two equivalent syntaxes: - * * Explicit: ^2.12.0@beta, ~1.20.0@stable - * * Implicit: ^2.12.0-beta, ~1.20.0-stable + * - Version ranges in the Composer syntax, see {@see Range}: ^2.12.0, ~1.20, >=1.0 <2.0 || ^3.0 + * - Feature suffixes of ranges: ^2.12.0-feature, ~1.20.0-hotfix, ^1.0.0-my-feature + * - Minimum stability, explicit or taken from the pre-releases of the constraint: ^2.12.0@beta, ^2.12.0-beta * - Combined constraints: ^2.12.0-feature@beta + * - Exact releases, a pre-release or a tail included: 3.5.0-beta.1, 2.0-beta, 1.0.0-1, 2.12.0-hotfix, 2.0.0rc1 + * - Pre-release bounds of ranges and comparisons: >=3.5.0-beta.1 <3.5.0-RC1, ^1.0 || ^2.0-beta.1, >2.0-beta, ^2.0.0rc1 + * - Build metadata, which does not count: >=1.2.3+build.5, 3.5.0-beta.1+5 * - * Stability keywords (from Stability enum) as suffixes are automatically - * converted to stability constraints. + * A stability keyword at the start or the end of a feature suffix sets the minimum stability + * unless an explicit one is given: ^2.12.0-my-feature-beta. * * @internal */ @@ -29,6 +30,7 @@ final class Constraint implements \Stringable /** * @var non-empty-string|null $featureSuffix Optional feature suffix (e.g. "feature", "my-feature") + * the version suffix must contain, in any case. * If null, no feature suffix is specified. */ public readonly ?string $featureSuffix; @@ -36,6 +38,16 @@ final class Constraint implements \Stringable /** @var Stability $minimumStability Minimum stability level for this constraint */ public readonly Stability $minimumStability; + /** + * @var PreRelease|null $preRelease Pre-release of a single version the constraint is bound to, + * e.g. `beta.1` of `3.5.0-beta.1`. Null when there is none, when a tail follows it, or when + * the constraint has several terms: their pre-releases stay in {@see $versionConstraint}. + */ + public readonly ?PreRelease $preRelease; + + /** Versions the constraint accepts, the pre-release included. */ + private readonly Range|Comparison $matcher; + /** * @param non-empty-string $origin Original constraint string used for parsing. */ @@ -57,7 +69,43 @@ private function __construct( ); } - [$version, $suffix] = \explode('-', $origin, 2) + [1 => '']; + # Build metadata does not count, and a hyphen within it, as in "1.2.3+build-5", starts no feature suffix + $origin = (string) \preg_replace('/(?<=[a-z\d])' . Version::buildPattern() . '(?=$|[\s,|])/i', '', $origin); + + # A single version with a pre-release or any other tail names one release: "2.0-beta" accepts neither + # "2.0.0-beta.3" nor "2.0.0", and "2.12.0-hotfix" not "2.12.0-hotfix.2"; a bare number covers every build + # of that number, see Comparison. In a range, a pre-release bound, a bare "-beta" included, is a part of + # the version as in Composer, ">2.0-beta" accepts "2.0.0-RC1", and any other suffix is a feature. + # Without a hyphen, a tail starts with a letter, as in "2.0.0rc1" or "1.0.0.Final"; ".x" is a wildcard, a glued "x" a patch letter. + $matcher = null; + $tail = ''; + $preRelease = null; + if (\preg_match( + '/^(?:==?)?\s*v?(\d+(?:\.\d+)*)(-[\w.-]+|[._]?(?=[a-z])(?!(?<=\.)x(?![a-z\d]))[\w.-]+)?$/i', + $origin, + $exact, + ) === 1) { + /** @var non-empty-string $number */ + $number = $exact[1]; + $tail = $exact[2] ?? ''; + $matcher = Comparison::create(Operator::Equal, $number, $tail); + $stability ??= $matcher->bound->stability; + $tail !== '' && $matcher->bound->suffix === null and $preRelease = $matcher->bound->preRelease; + } elseif (($bounds = self::preReleaseBounds($origin)) !== []) { + $matcher = Range::fromString($origin); + $stability ??= self::lowestStability($bounds); + $singleTerm = \preg_match('/[\s,|]/', (string) \preg_replace('/^[<>=!~^]*\s*/', '', $origin)) !== 1; + $singleTerm and $tail = $bounds[0]; + $preRelease = PreRelease::fromString(\ltrim($tail, '-._')); + } + + $preRelease === null or $origin = \substr($origin, 0, -\strlen($tail)); + $this->preRelease = $preRelease; + + # The hyphen of a range like "1.0 - 2.0" stands between spaces and does not start a suffix + [$version, $suffix] = $matcher !== null || \preg_match('/\s-\s/', $origin) === 1 + ? [$origin, ''] + : \explode('-', $origin, 2) + [1 => '']; if ($suffix !== '') { // Check if suffix is a stability keyword (only if no explicit stability provided) if ($stability === null) { @@ -81,17 +129,13 @@ private function __construct( throw new \InvalidArgumentException("Invalid feature suffix format: {$suffix}."); } } - - // Validate base version format for non-empty suffixes - $suffix === '' and !\preg_match('/^[~^>=<]*\d+(\.\d+)*/', $version) and throw new \InvalidArgumentException( - "Invalid base version format: {$version}.", - ); $suffix === '' and $suffix = null; // Determine final stability (explicit takes precedence over implicit) $stability ??= $suffix === null ? Stability::Stable : Stability::Preview; $version === '' and throw new \InvalidArgumentException('Base version cannot be empty.'); + $this->matcher = $matcher ?? Range::fromString($version); $this->versionConstraint = $version; $this->featureSuffix = $suffix; @@ -101,17 +145,17 @@ private function __construct( /** * Parse version constraint string into DTO. * - * Handles both @stability and -stability syntax equivalence. - * Auto-converts stability keywords in suffixes to stability constraints. - * * Examples: * - "^2.12.0" -> baseVersion: "^2.12.0", featureSuffix: null, minimumStability: Stable - * - "^2.12.0-feature" -> baseVersion: "^2.12.0", featureSuffix: "feature", minimumStability: Stable - * - "^2.12.0-my-feature" -> baseVersion: "^2.12.0", featureSuffix: "my-feature", minimumStability: Stable + * - "^2.12.0-feature" -> baseVersion: "^2.12.0", featureSuffix: "feature", minimumStability: Preview + * - "^2.12.0-my-feature" -> baseVersion: "^2.12.0", featureSuffix: "my-feature", minimumStability: Preview * - "^2.12.0@beta" -> baseVersion: "^2.12.0", featureSuffix: null, minimumStability: Beta - * - "^2.12.0-beta" -> baseVersion: "^2.12.0", featureSuffix: null, minimumStability: Beta (auto-converted) + * - "^2.12.0-beta" -> baseVersion: "^2.12.0", preRelease: beta, minimumStability: Beta + * - "^2.12.0-beta@alpha" -> baseVersion: "^2.12.0", preRelease: beta, minimumStability: Alpha * - "^2.12.0-feature@beta" -> baseVersion: "^2.12.0", featureSuffix: "feature", minimumStability: Beta - * - "^2.12.0-my-beta-feature@stable" -> baseVersion: "^2.12.0-my-beta", featureSuffix: "feature", minimumStability: Stable + * - "^2.12.0-my-beta-feature@stable" -> baseVersion: "^2.12.0", featureSuffix: "my-beta-feature", minimumStability: Stable + * - "3.5.0-beta.1" -> baseVersion: "3.5.0", preRelease: beta.1, minimumStability: Beta + * - "2.12.0-hotfix" -> baseVersion: "2.12.0-hotfix", featureSuffix: null, minimumStability: Preview * * @param string $constraint Version constraint string * @return self Parsed version constraint @@ -125,25 +169,17 @@ public static function fromConstraintString(string $constraint): self /** * Checks if the given version satisfies this constraint. * - * @param Version $version Version to check against this constraint - * @return null|bool True if the version satisfies the constraint, false if it does not, - * null if the version is invalid or not applicable. + * A version without a number satisfies no constraint. */ - public function isSatisfiedBy(Version $version): ?bool + public function isSatisfiedBy(Version $version): bool { - $number = $version->number; - if ($number === null) { - return false; - } - - // Check if a version satisfies the base version constraint - if (Semver::satisfies($number, $this->versionConstraint) === false) { + if ($version->number === null || !$this->matcher->isSatisfiedBy($version)) { return false; } // Check if the version satisfies the feature suffix constraint if ($this->featureSuffix !== null) { - if (!\str_contains((string) $version->suffix, $this->featureSuffix)) { + if (\stripos((string) $version->suffix, $this->featureSuffix) === false) { return false; } } @@ -160,4 +196,41 @@ public function __toString(): string { return $this->origin; } + + /** + * Pre-releases of the version bounds with their separators, like `-1`, `-beta.1` and `rc2` + * of `>=1.0.0-1 <2.0.0-beta.1 || ^2.0rc2`, see {@see Range::preReleasePattern()}. + * A bound follows the version number right away: neither a suffix that runs on past a pre-release, + * like `-beta.1-feature`, nor a pre-release within a suffix, like `-1` of `1.0.0-x64-1`, is a bound. + * + * @return list + */ + private static function preReleaseBounds(string $constraint): array + { + \preg_match_all( + '/(?:^|[\s,|])[<>=!~^]*v?\d+(?:\.\d+)*(' . Range::preReleasePattern() . ')(?=$|[\s,|])/i', + $constraint, + $matches, + ); + + /** @var list */ + return $matches[1]; + } + + /** + * A numeric tail, like `-1` of `1.0.0-1`, is a {@see Stability::Preview}, as {@see Version} reads it. + * + * @param non-empty-list $preReleases + */ + private static function lowestStability(array $preReleases): Stability + { + $stabilities = \array_map( + static fn(string $preRelease): Stability => PreRelease::fromString(\ltrim($preRelease, '-._'))?->stability + ?? Stability::Preview, + $preReleases, + ); + \usort($stabilities, static fn(Stability $a, Stability $b): int => $a->getWeight() <=> $b->getWeight()); + + return $stabilities[0]; + } } diff --git a/src/Module/Version/Operator.php b/src/Module/Version/Operator.php new file mode 100644 index 00000000..3d161d5f --- /dev/null +++ b/src/Module/Version/Operator.php @@ -0,0 +1,49 @@ +=` of `>=1.2.3`. + * + * @internal + */ +enum Operator: string +{ + case Equal = '='; + case NotEqual = '!='; + case Greater = '>'; + case GreaterOrEqual = '>='; + case Less = '<'; + case LessOrEqual = '<='; + + /** + * Resolves an operator of the constraint syntax: none and `==` are {@see Equal}, `<>` is {@see NotEqual}. + * + * @return self|null Null when the text is not an operator. + */ + public static function fromString(string $operator): ?self + { + return match ($operator) { + '', '==' => self::Equal, + '<>' => self::NotEqual, + default => self::tryFrom($operator), + }; + } + + /** + * @param int $order Order of a version against the bound: negative when the version is lower. + */ + public function accepts(int $order): bool + { + return match ($this) { + self::Equal => $order === 0, + self::NotEqual => $order !== 0, + self::Greater => $order > 0, + self::GreaterOrEqual => $order >= 0, + self::Less => $order < 0, + self::LessOrEqual => $order <= 0, + }; + } +} diff --git a/src/Module/Version/PreRelease.php b/src/Module/Version/PreRelease.php new file mode 100644 index 00000000..daaa0843 --- /dev/null +++ b/src/Module/Version/PreRelease.php @@ -0,0 +1,109 @@ +compare(PreRelease::fromString('RC1')); // -1 + * ``` + * + * @internal + */ +final class PreRelease implements \Stringable +{ + /** + * @param int<0, max> $number Zero when the stability carries no number, like `beta`. + */ + public function __construct( + public readonly Stability $stability, + public readonly int $number = 0, + ) {} + + /** + * Parses a stability keyword with an optional number: `beta.1`, `RC2`, `rc-3`, `b4`, `nightly20250503`. + * + * @return self|null Null when the text is not a stability keyword. + */ + public static function fromString(string $value): ?self + { + if (\preg_match('/^([a-z]+)[._-]?(\d*)$/i', $value, $matches) !== 1) { + return null; + } + + $stability = self::stability($matches[1]); + if ($stability === null) { + return null; + } + + /** @var int<0, max> $number */ + $number = (int) $matches[2]; + + return new self($stability, $number); + } + + /** + * Regular expression alternation of every keyword {@see stability()} accepts. + * + * @return non-empty-string + */ + public static function keywordPattern(): string + { + $keywords = [...\array_column(Stability::cases(), 'value'), 'a', 'b']; + // Longer keywords first, so `preview` is not read as `pre` followed by garbage + \usort($keywords, static fn(string $a, string $b): int => \strlen($b) <=> \strlen($a)); + + return \implode('|', \array_map(static fn(string $keyword): string => \preg_quote($keyword, '/'), $keywords)); + } + + /** + * Regular expression of a stability keyword with an optional number, read as a whole word: letters that + * run together are one word, so `a1`, `beta.2` and the `rc1` of `2.0.0rc1` or `rc1_linux` are keywords, + * while the `a` of `arm64` or `ga` and the `b` of `build.5` are not. + * + * @return non-empty-string + */ + public static function wordPattern(): string + { + return '(? Negative when this pre-release is less stable or older than the other one. + */ + public function compare(self $other): int + { + return [$this->stability->getWeight(), $this->number] <=> [$other->stability->getWeight(), $other->number]; + } + + public function equals(self $other): bool + { + return $this->compare($other) === 0; + } + + public function __toString(): string + { + return $this->number === 0 ? $this->stability->value : $this->stability->value . '.' . $this->number; + } + + /** + * Resolves a stability keyword, including the `a` and `b` abbreviations of alpha and beta. + */ + private static function stability(string $keyword): ?Stability + { + return Stability::fromString($keyword) ?? match (\strtolower($keyword)) { + 'a' => Stability::Alpha, + 'b' => Stability::Beta, + default => null, + }; + } +} diff --git a/src/Module/Version/Range.php b/src/Module/Version/Range.php new file mode 100644 index 00000000..a9e4b1fd --- /dev/null +++ b/src/Module/Version/Range.php @@ -0,0 +1,261 @@ +1.2.3`, `>1.2`, `>=1.2`, `<2`, `<=2.0` + * - Caret and tilde ranges: `^1.2.3`, `^0.3`, `~1.2`, `~1.2.3` + * - Wildcards: `*`, `1.*`, `1.2.x` + * - Hyphen ranges: `1.0 - 2.0` + * - AND by a space or a comma, OR by `||` or `|`: `>=1.0 <2.0 || ^3.0` + * - A `v` prefix, pre-releases and numeric tails in bounds: `v1.2.3`, `^3.5.0-beta.1`, `^2.0.0rc1`, `>=1.0.0-1` + * - Build metadata, which does not count: `>=1.2.3+build.5`, `^1.2.3-beta.1+5` + * + * Any count of number parts is supported on both sides, and trailing zero parts do not count. + * + * ```php + * Range::fromString('^1.2 || ^2.0')->isSatisfiedBy(Version::fromVersionString('2.3.4')); // true + * ``` + * + * @internal + */ +final class Range +{ + /** + * @param list> $groups Alternatives of comparisons that must all hold; + * an empty group matches every version. + */ + private function __construct( + private readonly array $groups, + ) {} + + /** + * @throws \InvalidArgumentException If the constraint syntax is invalid + */ + public static function fromString(string $constraint): self + { + $constraint = \trim($constraint); + $constraint === '' and throw new \InvalidArgumentException('Version constraint cannot be empty.'); + + $groups = []; + foreach (\preg_split('/\s*\|\|?\s*/', $constraint) ?: [] as $group) { + $groups[] = self::parseGroup($group, $constraint); + } + + return new self($groups); + } + + /** + * Regular expression of a bound pre-release with its separator, to follow the version number: after a hyphen, + * a stability keyword with an optional number, like `-beta.1`, or a numeric tail, like `-1` of `1.0.0-1`, + * that orders as a part of the number; a keyword may also follow a dot, an underscore or the number itself, + * like `rc1` of `2.0.0rc1`. As {@see Version} reads tags, a lone `a` or `b` glued to the number is a patch + * letter, like the `b` of OpenSSL's `1.1.1b`, not a pre-release. The pattern ends at the keyword or its number: + * the caller makes sure no letter follows. + * + * @return non-empty-string + */ + public static function preReleasePattern(): string + { + $keyword = '(?:' . PreRelease::keywordPattern() . ')(?:[._-]?\d+)?'; + + return "-(?:{$keyword}|\\d+(?:\\.\\d+)*)|(?:[._]|(?![ab](?![a-z]|[._-]?\\d))){$keyword}"; + } + + public function isSatisfiedBy(Version $version): bool + { + foreach ($this->groups as $comparisons) { + foreach ($comparisons as $comparison) { + if (!$comparison->isSatisfiedBy($version)) { + continue 2; + } + } + + return true; + } + + return false; + } + + /** + * @return list + */ + private static function parseGroup(string $group, string $constraint): array + { + $group === '' and throw new \InvalidArgumentException("Empty alternative in the version constraint `{$constraint}`."); + + $version = '(v?\d+(?:\.\d+)*(?:' . self::preReleasePattern() . ')?(?:' . Version::buildPattern() . ')?)'; + if (\preg_match("/^{$version}\\s+-\\s+{$version}$/i", $group, $matches) === 1) { + return self::parseHyphenRange($matches[1], $matches[2]); + } + + # Composer allows a space after an operator: `>= 1.0` is one term, not two, while `> =1.0` is no term + $group = (string) \preg_replace('/(?<=^|[\s,])([<>=!~^]+)\s+(?=[v\d])/i', '$1', $group); + + $comparisons = []; + foreach (\preg_split('/\s*,\s*|\s+/', $group) ?: [] as $term) { + $term === '' and throw new \InvalidArgumentException("Empty term in the version constraint `{$constraint}`."); + $comparisons = [...$comparisons, ...self::parseTerm($term)]; + } + + return $comparisons; + } + + /** + * @return list + */ + private static function parseTerm(string $term): array + { + \preg_match('/^([<>=!~^]*)(.*)$/s', $term, $matches); + [, $operator, $version] = $matches; + + if (\preg_match('/^v?(?:\d+(?:\.\d+)*\.)?[x*](?:\.[x*])*$/i', $version) === 1) { + $operator === '' or throw new \InvalidArgumentException( + "A wildcard version takes no operator: `{$term}`.", + ); + + return self::parseWildcard($version); + } + + [$number, $preRelease] = self::parseVersion($version) ?? throw new \InvalidArgumentException( + "Invalid base version format: {$term}.", + ); + + return match ($operator) { + '^' => [ + Comparison::create(Operator::GreaterOrEqual, $number, $preRelease), + Comparison::create(Operator::Less, self::increment($number, self::caretPosition($number))), + ], + '~' => [ + Comparison::create(Operator::GreaterOrEqual, $number, $preRelease), + Comparison::create(Operator::Less, self::increment($number, \max(1, self::countParts($number) - 1))), + ], + default => [ + Comparison::create( + Operator::fromString($operator) ?? throw new \InvalidArgumentException( + "Unsupported operator `{$operator}` in the version constraint `{$term}`.", + ), + $number, + $preRelease, + ), + ], + }; + } + + /** + * `1.2.*` is `>=1.2 <1.3`; `*` matches every version. + * + * @return list + */ + private static function parseWildcard(string $version): array + { + \preg_match('/^v?((?:\d+\.)*)/i', $version, $matches); + $number = \rtrim($matches[1], '.'); + if ($number === '') { + return []; + } + + return [ + Comparison::create(Operator::GreaterOrEqual, $number), + Comparison::create(Operator::Less, self::increment($number, self::countParts($number))), + ]; + } + + /** + * A partial upper version covers all its builds, as in Composer: `1.0 - 2` is `>=1.0 <3`, + * while `1.0 - 2.0.0` is `>=1.0 <=2.0.0`. + * + * @return list + */ + private static function parseHyphenRange(string $from, string $to): array + { + $lower = self::parseVersion($from); + $upper = self::parseVersion($to); + \assert($lower !== null && $upper !== null); + + $parts = self::countParts($upper[0]); + return [ + Comparison::create(Operator::GreaterOrEqual, ...$lower), + $parts >= 3 || $upper[1] !== '' + ? Comparison::create(Operator::LessOrEqual, ...$upper) + : Comparison::create(Operator::Less, self::increment($upper[0], $parts)), + ]; + } + + /** + * @return array{non-empty-string, string}|null Version number and pre-release with its separator, + * or null if the text is not a version. + */ + private static function parseVersion(string $version): ?array + { + # Build metadata does not count, so the number goes without it + $pattern = '/^v?(\d+(?:\.\d+)*)(' . self::preReleasePattern() . ')?(?:' . Version::buildPattern() . ')?$/i'; + if (\preg_match($pattern, $version, $matches) !== 1) { + return null; + } + + /** @var non-empty-string $number */ + $number = $matches[1]; + return [$number, $matches[2] ?? '']; + } + + /** + * The caret keeps the first non-zero of the three leading parts: `^1.2` is `<2`, `^0.3` is `<0.4`, + * `^0.0.3` is `<0.0.4`; `^0` is `<1` and `^0.0` is `<0.1`. + * + * @return int<1, 3> + */ + private static function caretPosition(string $number): int + { + $parts = self::parts($number); + return match (true) { + (int) $parts[0] !== 0 || \count($parts) === 1 => 1, + (int) $parts[1] !== 0 || \count($parts) === 2 => 2, + default => 3, + }; + } + + /** + * Cuts the number after the given part and increments that part: `1.2.3` at 2 is `1.3`. + * Parts are digit strings of any length, so `^99999999999999999999` does not overflow an integer. + * + * @param int<1, max> $position Not past the last part of the number + * @return non-empty-string + */ + private static function increment(string $number, int $position): string + { + $parts = \array_map( + static fn(string $part): string => \ltrim($part, '0') ?: '0', + \array_slice(self::parts($number), 0, $position), + ); + + $last = $parts[$position - 1]; + $nines = \strlen($last) - \strlen(\rtrim($last, '9')); + $parts[$position - 1] = $nines === \strlen($last) + ? '1' . \str_repeat('0', $nines) + : \substr($last, 0, -$nines - 1) . ((int) $last[-$nines - 1] + 1) . \str_repeat('0', $nines); + + return \implode('.', $parts); + } + + /** + * @return int<1, max> + */ + private static function countParts(string $number): int + { + return \count(self::parts($number)); + } + + /** + * @return non-empty-list + */ + private static function parts(string $number): array + { + return \explode('.', $number); + } +} diff --git a/src/Module/Version/Version.php b/src/Module/Version/Version.php index f30c9dc9..3b8dc793 100644 --- a/src/Module/Version/Version.php +++ b/src/Module/Version/Version.php @@ -13,14 +13,20 @@ */ class Version implements \Stringable { - protected const VERSION_SEMVER_PATTERN = 'v?(\d+\.\d+\.\d+(?:\+\d+)?)([-+][\w.-]+)?'; - protected const VERSION_FALLBACK_PATTERN = 'v?(\d+(?:\.\d+(?:\.\d+(?:\+\d+)?)?)?)([-+.][\w.-]+)?'; + protected const VERSION_SEMVER_NUMBER_PATTERN = 'v?(\d+\.\d+\.\d+(?:\.\d+)*)'; + + /** A suffix starting with a letter may follow the number right away: `2.0.0rc1`, `1.2.3_beta2`. */ + protected const VERSION_FALLBACK_PATTERN = 'v?(\d+(?:\.\d+)*)((?:[-+.]|(?=[a-z_]))[\w.-]+)?'; + protected const VERSION_HASH_SUFFIX_PATTERN = '(?:#([a-f0-9]{6,40}))?'; /** * @param string $string Source of the version string (e.g., 1.2.3-beta-feature) * @param null|non-empty-string $number Parsed version number - * @param null|non-empty-string $suffix Stability and feature suffix + * @param null|non-empty-string $suffix Feature suffix: the suffix without the stability part + * @param null|PreRelease $preRelease Stability with its number, e.g. `beta.1` of `1.2.3-beta.1`; + * numbered 0 when the version carries none. Null only for an empty version. + * @param bool $withoutKeyword The suffix has no stability keyword, like `1` of `1.0.0-1`. */ final protected function __construct( public readonly string $string, @@ -28,19 +34,43 @@ final protected function __construct( public readonly ?string $suffix = null, public readonly ?Stability $stability = null, public readonly ?string $hash = null, + public readonly ?PreRelease $preRelease = null, + private readonly bool $withoutKeyword = false, ) {} + /** + * Regular expression of SemVer build metadata: a plus and dot-separated identifiers of letters, digits + * and hyphens, like `+build.5` or `+5-beta.1`. + * + * @return non-empty-string + */ + public static function buildPattern(): string + { + return '\+[0-9a-z-]+(?:\.[0-9a-z-]+)*'; + } + /** * Parses a version string into its components. * + * Build metadata at the end, see {@see buildPattern()}, is dropped as in SemVer: it means nothing for + * the order, the stability or the suffix, so `1.2.3-linux+build.5` is `1.2.3-linux`. It stays only + * in {@see $string}. A plus that starts no metadata, as in `1.2.3+linux_x64`, starts a suffix. + * * @param non-empty-string $string Version string to parse */ public static function fromVersionString(string $string): static { + $version = (string) \preg_replace( + '/' . self::buildPattern() . '(?=' . self::VERSION_HASH_SUFFIX_PATTERN . '$)/i', + '', + $string, + 1, + ); + // Parse the version number \preg_match( '/^' . self::VERSION_FALLBACK_PATTERN . self::VERSION_HASH_SUFFIX_PATTERN . '$/i', - $string, + $version, $parts, ) or throw new \InvalidArgumentException( "Failed version string: {$string}.", @@ -50,20 +80,22 @@ public static function fromVersionString(string $string): static \assert($number !== ''); $suffix = $parts[2] ?? ''; - $stability = null; - if ($suffix !== '') { - $stability = self::stabilityFromSuffix($suffix); - } + // A lone letter glued to the number is a patch letter, like OpenSSL's `1.1.1b`, not a pre-release + $preRelease = $suffix === '' || \preg_match('/^[a-z]$/i', $suffix) === 1 + ? null + : self::preReleaseFromSuffix($suffix); + $withoutKeyword = $preRelease === null; $suffix = \trim($suffix, '-_.+'); $suffix === '' and $suffix = null; - $stability ??= ($suffix === null ? Stability::Stable : Stability::Preview); + $stability = $preRelease?->stability ?? ($suffix === null ? Stability::Stable : Stability::Preview); + $preRelease ??= new PreRelease($stability); $hash = $parts[3] ?? null; $hash === '' and $hash = null; - return new static($string, $number, $suffix, $stability, $hash); + return new static($string, $number, $suffix, $stability, $hash, $preRelease, $withoutKeyword); } public static function empty(): static @@ -71,61 +103,170 @@ public static function empty(): static return new static(''); } - public function __toString(): string + /** + * Orders versions by the version number, then by the pre-release: `1.0.0-beta.2 < 1.0.0-RC1 < 1.0.0`, + * then by the rest of the suffix, see {@see compareTail()}: `1.0.0-RC1 < 1.0.0-RC1-priority.0`. + * A version without a number comes first. + * + * @return int<-1, 1> + */ + public function compare(self $other): int { - return $this->string; + $byNumber = $this->compareNumber($other); + if ($byNumber !== 0 || $this->preRelease === null || $other->preRelease === null) { + return $byNumber; + } + + $byPreRelease = $this->preRelease->compare($other->preRelease); + if ($byPreRelease !== 0) { + return $byPreRelease; + } + + return self::compareTail($this->suffix, $other->suffix); } /** - * Extracts the stability from the version suffix. + * Orders versions by the version number only, the first step of {@see compare()}: + * `1.0.0-beta` and `1.0.0` are equal, `1.0.0-1` is above both. * - * @param non-empty-string $input Version string with suffix - * @return null|Stability Stability level or null if not found + * @return int<-1, 1> */ - private static function stabilityFromSuffix(string &$input): ?Stability + public function compareNumber(self $other): int { - if (\str_starts_with('x-dev', \strtolower($input))) { - $input = \substr($input, \strlen('x-dev')); - return Stability::Dev; - } + $parts = $this->comparableParts(); + $otherParts = $other->comparableParts(); + foreach ($parts as $i => $part) { + if (!isset($otherParts[$i])) { + return 1; + } - // if (\preg_match('{^dev[-_.]}', $input) || \preg_match('{[-_.]dev$}', $input)) { - // return Stability::Dev; - // } + $byPart = self::compareDigits($part, $otherParts[$i]); + if ($byPart !== 0) { + return $byPart; + } + } - $mods = \implode('|', \array_column(Stability::cases(), 'value')) . '|b|a'; - $reg = "[._-]?(?:($mods)([.-]?\d+)?)?"; + return \count($parts) <=> \count($otherParts); + } - /** @var list $parts */ - $parts = []; + public function __toString(): string + { + return $this->string; + } - \preg_match(('#' . $reg . '$#i'), $input, $match); - isset($match[1]) and $parts[0] = [$match[1], $match[0]]; + /** + * Cuts the stability part off the version suffix. + * + * @param non-empty-string $input Version suffix; the stability part is removed from it + * @param-out string $input + * @return null|PreRelease Stability with its number, or null if the suffix has no stability part + */ + private static function preReleaseFromSuffix(string &$input): ?PreRelease + { + $reg = '[._-]?' . PreRelease::wordPattern(); - \preg_match(('#^' . $reg . '#i'), $input, $match); - isset($match[1]) and $parts[1] = [$match[1], $match[0]]; + /** @var array<0|1, non-empty-string> $parts */ + $parts = []; - if ($parts === []) { - return null; - } + \preg_match('#' . $reg . '$#i', $input, $match) === 1 and $parts[0] = $match[0]; + \preg_match('#^' . $reg . '#i', $input, $match) === 1 and $parts[1] = $match[0]; - foreach ($parts as $k => [$part, $fullPart]) { - $isPrefix = $k === 1; - $part = \strtolower($part); - $stability = Stability::fromString($part) ?? match ($part) { - 'a' => Stability::Alpha, - 'b' => Stability::Beta, - default => null, - }; - if ($stability !== null) { - $input = $isPrefix + foreach ($parts as $k => $fullPart) { + $preRelease = PreRelease::fromString(\ltrim($fullPart, '._-')); + if ($preRelease !== null) { + $input = $k === 1 ? \substr($input, \strlen($fullPart)) : \substr($input, 0, -\strlen($fullPart)); - return $stability; + return $preRelease; } } return null; } + + /** + * Orders the suffixes left after the pre-release token by token, a token being a run of digits or of letters. + * A suffix that ends where the other goes on comes first: `RC1 < RC1-priority.0`. A word comes before + * a number, as a word suffix sorts below the final release and a numeric one above it: + * `beta.1-linux < beta.1.2`. Numbers compare by value, words alphabetically. Stability keywords mean + * nothing here, so `arm64` is no alpha, and neither do case, separators and leading zeros: + * `Linux_x64.01` equals `linux-x64.1`. + * + * @return int<-1, 1> + */ + private static function compareTail(?string $suffix, ?string $other): int + { + $tokens = self::tokens($suffix); + $otherTokens = self::tokens($other); + foreach ($tokens as $i => $token) { + if (!isset($otherTokens[$i])) { + return 1; + } + + $otherToken = $otherTokens[$i]; + $isNumber = \ctype_digit($token); + $byToken = match (true) { + $isNumber !== \ctype_digit($otherToken) => $isNumber ? 1 : -1, + $isNumber => self::compareDigits($token, $otherToken), + default => \strcmp($token, $otherToken) <=> 0, + }; + if ($byToken !== 0) { + return $byToken; + } + } + + return \count($tokens) <=> \count($otherTokens); + } + + /** + * Compares digit strings of any length without leading zeros: as integers, long ones would overflow. + * + * @return int<-1, 1> + */ + private static function compareDigits(string $digits, string $other): int + { + return (\strlen($digits) <=> \strlen($other)) ?: \strcmp($digits, $other) <=> 0; + } + + /** + * Words in lower case and numbers without leading zeros, like `linux`, `x`, `64` and `1` of `Linux_x64.01`. + * + * @return list + */ + private static function tokens(?string $suffix): array + { + \preg_match_all('/\d+|[a-z]+/i', (string) $suffix, $matches); + + return \array_map( + static fn(string $token): string => \ctype_digit($token) ? (\ltrim($token, '0') ?: '0') : \strtolower($token), + $matches[0], + ); + } + + /** + * A numeric suffix without a stability keyword orders as a part of the number: `1.0.0-1 > 1.0.0`. + * With a keyword it does not: the `.2` of `2.0.0-beta.1.2` belongs to the pre-release. + * Leading zeros and trailing zero parts do not count: `1.2.3`, `01.2.3.0` and `1.2.3.0.0` are one number. + * + * @return list Parts without leading zeros, empty for a version without a number + */ + private function comparableParts(): array + { + if ($this->number === null) { + return []; + } + + $number = $this->number; + if ($this->withoutKeyword && $this->suffix !== null && \preg_match('/^\d+(?:\.\d+)*$/', $this->suffix) === 1) { + $number .= '.' . $this->suffix; + } + + $parts = \array_map(static fn(string $part): string => \ltrim($part, '0') ?: '0', \explode('.', $number)); + while (\count($parts) > 1 && \end($parts) === '0') { + \array_pop($parts); + } + + return $parts; + } } diff --git a/tests/Unit/Module/Binary/BinaryVersionTest.php b/tests/Unit/Module/Binary/BinaryVersionTest.php index 17e15834..4adbcd14 100644 --- a/tests/Unit/Module/Binary/BinaryVersionTest.php +++ b/tests/Unit/Module/Binary/BinaryVersionTest.php @@ -5,9 +5,11 @@ namespace Internal\DLoad\Tests\Unit\Module\Binary; use Internal\DLoad\Module\Binary\BinaryVersion; +use Internal\DLoad\Module\Common\Stability; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Data\DataProvider; +use Testo\Data\DataSet; use Testo\Test; #[Covers(BinaryVersion::class)] @@ -60,7 +62,61 @@ public static function provideSemanticVersionOutputs(): \Generator yield 'semver with build metadata' => [ 'App version 2.3.4+20230415', '2.3.4+20230415', - '2.3.4+20230415', + '2.3.4', + ]; + + yield 'build metadata with words' => [ + 'tool 1.2.3+build.5', + '1.2.3+build.5', + '1.2.3', + ]; + + yield 'build metadata before other text' => [ + 'tool v1.2.3-rc.1+exp.sha.5114f85, built today', + '1.2.3-rc.1+exp.sha.5114f85', + '1.2.3', + ]; + + yield 'plus apart from the version' => [ + 'tool 1.2.3 +build.5', + '1.2.3', + null, + ]; + + yield 'pre-release without a separator' => [ + 'tool 2.0.0rc1 (linux)', + '2.0.0rc1', + '2.0.0', + ]; + + yield 'word glued to the number' => [ + 'tool 1.2.3_amd64', + '1.2.3', + null, + ]; + + yield 'word starting with a stability letter glued to the number' => [ + 'tool 1.2.3arm64', + '1.2.3', + null, + ]; + + yield 'platform after a pre-release glued to the number' => [ + 'tool 2.0.0rc1_linux', + '2.0.0rc1_linux', + '2.0.0', + ]; + + yield 'letter release glued to the number' => [ + 'OpenSSL 1.1.1b 26 Feb 2019', + '1.1.1', + null, + ]; + + yield 'beta abbreviation glued to the number' => [ + 'tool 1.0.0b2', + '1.0.0b2', + '1.0.0', ]; // Case insensitivity @@ -97,6 +153,68 @@ public static function provideSemanticVersionOutputs(): \Generator '1.51.1', null, ]; + + // More than three parts + yield 'four-part version' => [ + 'tool version 1.2.3.4 (build 2025-01-01)', + '1.2.3.4', + null, + ]; + + yield 'five-part version with pre-release' => [ + 'Version: v1.2.3.4.5-beta.1', + '1.2.3.4.5-beta.1', + '1.2.3.4.5', + ]; + + yield 'four-part version after a name' => [ + 'libtool 30.2.1.4', + '30.2.1.4', + null, + ]; + + // Other numbers around the version + yield 'build number after the version' => [ + 'tool 1.2.3 build 4567', + '1.2.3', + null, + ]; + + yield 'date after the version' => [ + 'tool 1.2.3 20250101', + '1.2.3', + null, + ]; + + yield 'date joined to the version' => [ + 'tool 1.2.3-20250101', + '1.2.3-20250101', + '1.2.3', + ]; + + yield 'build metadata after a pre-release' => [ + 'v1.2.3-rc.1+build.5', + '1.2.3-rc.1+build.5', + '1.2.3', + ]; + + yield 'number before the version' => [ + 'protocol 2 tool 1.2.3', + '1.2.3', + null, + ]; + + yield 'Go toolchain before the version' => [ + 'built with go1.21 tool 1.2.3', + '1.2.3', + null, + ]; + + yield 'Go toolchain after the version' => [ + 'tool 1.2.3 (go1.22.1)', + '1.2.3', + null, + ]; } /** @@ -142,6 +260,52 @@ public function resolveVersionExtractsSemanticVersions(string $output, string $s Assert::same($result->number, $number ?? $string); } + #[DataSet(['OpenSSL 1.1.1a 20 Nov 2018'], 'patch letter a')] + #[DataSet(['OpenSSL 1.1.1b 26 Feb 2019'], 'patch letter b')] + #[DataSet(['OpenSSL 1.1.1c 28 May 2019'], 'patch letter c')] + #[Test] + public function patchLetterIsAStableRelease(string $output): void + { + $result = BinaryVersion::fromBinaryOutput($output); + + Assert::same($result->string, '1.1.1'); + Assert::same($result->stability, Stability::Stable); + } + + #[DataSet(['tool 1.0.0b2', Stability::Beta], 'beta abbreviation')] + #[DataSet(['tool 1.2.3a1', Stability::Alpha], 'alpha abbreviation')] + #[DataSet(['tool 2.0.0rc1', Stability::RC], 'release candidate')] + #[Test] + public function preReleaseGluedToTheNumberKeepsItsStability(string $output, Stability $stability): void + { + Assert::same(BinaryVersion::fromBinaryOutput($output)->stability, $stability); + } + + #[DataSet(['tool 1.2.3+beta.2', Stability::Stable], 'build metadata like a beta')] + #[DataSet(['tool 1.2.3-rc.1+build.5', Stability::RC], 'release candidate before build metadata')] + #[Test] + public function buildMetadataDoesNotChangeTheStability(string $output, Stability $stability): void + { + Assert::same(BinaryVersion::fromBinaryOutput($output)->stability, $stability); + } + + #[DataSet(['tool 1.2.3beta2', '1.2.3', Stability::Beta, 2], 'beta word with a number')] + #[DataSet(['tool 2.0.0alpha', '2.0.0', Stability::Alpha, 0], 'alpha word')] + #[Test] + public function preReleaseWordGluedToTheNumberIsNotAPatchLetter( + string $output, + string $number, + Stability $stability, + int $preReleaseNumber, + ): void { + $result = BinaryVersion::fromBinaryOutput($output); + + Assert::same($result->number, $number); + Assert::same($result->stability, $stability); + Assert::same($result->preRelease?->stability, $stability); + Assert::same($result->preRelease?->number, $preReleaseNumber); + } + /** * Tests that the resolver correctly extracts versions using fallback patterns. */ diff --git a/tests/Unit/Module/Binary/Internal/AbstractBinaryTest.php b/tests/Unit/Module/Binary/Internal/AbstractBinaryTest.php new file mode 100644 index 00000000..1ddec3b1 --- /dev/null +++ b/tests/Unit/Module/Binary/Internal/AbstractBinaryTest.php @@ -0,0 +1,56 @@ + ['App version 2.3.4+20230415', '2.3.4+20230415']; + yield 'pre-release with build metadata' => ['tool v1.2.3-rc.1+build.5', '1.2.3-rc.1+build.5']; + yield 'plain version' => ['tool 1.2.3', '1.2.3']; + yield 'no version' => ['no version here', null]; + } + + #[DataProvider('provideVersionOutputs')] + #[Test] + public function versionStringShowsTheWholeVersion(string $output, ?string $expected): void + { + $binary = new class('tool', new BinaryConfig(), new BinaryExecutor(new Logger()), $output) extends AbstractBinary { + public function __construct( + string $name, + BinaryConfig $config, + BinaryExecutor $executor, + private readonly string $output, + ) { + parent::__construct($name, $config, $executor); + } + + public function getVersion(): BinaryVersion + { + return BinaryVersion::fromBinaryOutput($this->output); + } + + public function getPath(): Path + { + return Path::create('tool'); + } + }; + + Assert::same($binary->getVersionString(), $expected); + } +} diff --git a/tests/Unit/Module/Repository/ReleasesCollectionTest.php b/tests/Unit/Module/Repository/ReleasesCollectionTest.php index dbaa534b..beebfa4d 100644 --- a/tests/Unit/Module/Repository/ReleasesCollectionTest.php +++ b/tests/Unit/Module/Repository/ReleasesCollectionTest.php @@ -111,6 +111,64 @@ public function sortByVersionSortsReleasesByVersionDescending(): void ]); } + #[Test] + public function sortByVersionOrdersPreReleasesOfOneVersion(): void + { + $names = ['2.0.0-beta.2', '2.0.0-rc.1', '1.9.0', '2.0.0', '2.0.0-nightly5', '2.0.0-beta.10', '2.0.0-RC2', '2.0.0-preview1']; + $collection = new ReleasesCollection(\array_map( + fn(string $name): ReleaseStub => new ReleaseStub($this->repository, $name, Version::fromVersionString($name)), + $names, + )); + + $versions = $this->getVersionsFromCollection($collection->sortByVersion()); + + Assert::same(\array_values($versions), [ + '2.0.0', + '2.0.0-RC2', + '2.0.0-rc.1', + '2.0.0-beta.10', + '2.0.0-beta.2', + '2.0.0-preview1', + '2.0.0-nightly5', + '1.9.0', + ]); + } + + #[Test] + public function sortByVersionOrdersWhatFollowsThePreRelease(): void + { + $names = ['1.3.1-RC1-priority.0', '1.2.3.4', '1.3.1-RC1-priority.1', '1.2.3', '1.2.3.10', '1.2.3.9']; + $collection = new ReleasesCollection(\array_map( + fn(string $name): ReleaseStub => new ReleaseStub($this->repository, $name, Version::fromVersionString($name)), + $names, + )); + + $versions = $this->getVersionsFromCollection($collection->sortByVersion()); + + Assert::same(\array_values($versions), [ + '1.3.1-RC1-priority.1', + '1.3.1-RC1-priority.0', + '1.2.3.10', + '1.2.3.9', + '1.2.3.4', + '1.2.3', + ]); + } + + #[Test] + public function exactPreReleaseConstraintPicksTheTagWithoutATail(): void + { + $names = ['3.5.0-RC1-linux', '3.5.0-RC1.2', '3.5.0-RC1', '3.5.0-RC2', '3.5.0-RC1-priority.0']; + $collection = new ReleasesCollection(\array_map( + fn(string $name): ReleaseStub => new ReleaseStub($this->repository, $name, Version::fromVersionString($name)), + $names, + )); + + $result = $collection->satisfies(Constraint::fromConstraintString('3.5.0-RC1'))->sortByVersion(); + + Assert::same(\array_values($this->getVersionsFromCollection($result)), ['3.5.0-RC1']); + } + #[Test] public function chainedFiltersWorkCorrectly(): void { diff --git a/tests/Unit/Module/Version/ConstraintTest.php b/tests/Unit/Module/Version/ConstraintTest.php index e4d45025..5ce3da85 100644 --- a/tests/Unit/Module/Version/ConstraintTest.php +++ b/tests/Unit/Module/Version/ConstraintTest.php @@ -235,6 +235,53 @@ public static function provideValidConstraints(): \Generator Stability::Preview, 'feature suffix with multiple dashes', ]; + + yield 'hyphen range' => ['1.0 - 2.0@beta', '1.0 - 2.0', null, Stability::Beta, 'Hyphen range with a stability']; + yield 'alternatives' => ['^1.2 || ^2.0', '^1.2 || ^2.0', null, Stability::Stable, 'Alternatives']; + yield 'range' => ['>=1.0 <2.0', '>=1.0 <2.0', null, Stability::Stable, 'Range']; + yield 'feature suffix of a single word' => ['^1.0.0-experimental', '^1.0.0', 'experimental', Stability::Preview, 'Feature']; + yield 'feature suffix with a stability' => ['^2.12.0-hotfix@rc', '^2.12.0', 'hotfix', Stability::RC, 'Feature with a stability']; + yield 'feature suffix of alternatives' => ['^1.2 || ^2.0-feature', '^1.2 || ^2.0', 'feature', Stability::Preview, 'Feature of alternatives']; + yield 'numeric tail' => ['1.0.0-1', '1.0.0-1', null, Stability::Preview, 'Numeric tail']; + yield 'pre-release lower bound of a range' => ['>=3.5.0-beta.1 <3.5.0', '>=3.5.0-beta.1 <3.5.0', null, Stability::Beta, 'Pre-release lower bound']; + yield 'pre-release upper bound of a range' => ['>=1.0 <2.0-RC1', '>=1.0 <2.0-RC1', null, Stability::RC, 'Pre-release upper bound']; + yield 'pre-release in an alternative' => ['^1.0 || ^2.0-beta.1', '^1.0 || ^2.0-beta.1', null, Stability::Beta, 'Pre-release alternative']; + yield 'pre-release lower bound of a hyphen range' => ['1.0.0-beta.1 - 2.0.0', '1.0.0-beta.1 - 2.0.0', null, Stability::Beta, 'Pre-release hyphen range']; + yield 'least stable of pre-release bounds' => ['>=1.0.0-alpha.1 <2.0.0-RC1', '>=1.0.0-alpha.1 <2.0.0-RC1', null, Stability::Alpha, 'Least stable bound']; + yield 'bare stability keyword in a range' => ['>=1.0-beta <2.0', '>=1.0-beta <2.0', null, Stability::Beta, 'Bare keyword bound']; + yield 'bare stability keyword before alternatives without spaces' => ['^1.0-beta||^2.0', '^1.0-beta||^2.0', null, Stability::Beta, 'Bare keyword before ||']; + yield 'pre-release after alternatives without spaces' => ['^1.0||^2.0-beta.1', '^1.0||^2.0-beta.1', null, Stability::Beta, 'Pre-release after ||']; + yield 'least stable bound last' => ['>=1.0-RC1 || ^2.0-alpha.1', '>=1.0-RC1 || ^2.0-alpha.1', null, Stability::Alpha, 'Least stable bound last']; + yield 'explicit stability over pre-release bounds' => ['>=3.5.0-beta.1 <3.5.0@alpha', '>=3.5.0-beta.1 <3.5.0', null, Stability::Alpha, 'Explicit stability']; + yield 'pre-release bound with a space after the operator' => ['>= 1.0-beta.1 <2.0', '>= 1.0-beta.1 <2.0', null, Stability::Beta, 'Spaced bound']; + yield 'bare stability keywords of a hyphen range' => ['1.0-beta - 2.0-RC1', '1.0-beta - 2.0-RC1', null, Stability::Beta, 'Hyphen range bounds']; + yield 'bare stability keyword of a caret with an explicit stability is a bound' => ['^2.0-beta@alpha', '^2.0', null, Stability::Alpha, 'Keyword bound of a caret']; + yield 'bare stability keyword at least with an explicit stability is a bound' => ['>=2.0-beta@alpha', '>=2.0', null, Stability::Alpha, 'Keyword bound of at least']; + yield 'bare stability keyword of a tilde with an explicit stability is a bound' => ['~2.0-beta@alpha', '~2.0', null, Stability::Alpha, 'Keyword bound of a tilde']; + yield 'pre-release bound after a comma without a space' => ['>=1.0,<2.0-RC1', '>=1.0,<2.0-RC1', null, Stability::RC, 'Comma before a bound']; + + // A digit within a feature suffix is no pre-release bound + yield 'numeric tail of a feature suffix' => ['^1.0.0-x64-1', '^1.0.0', 'x64-1', Stability::Preview, 'Numeric tail of a feature']; + yield 'numbered pre-release after a feature' => ['^1.0-arm64-beta.1', '^1.0', 'arm64-beta.1', Stability::Preview, 'Pre-release after a feature']; + yield 'glued pre-release after a feature' => ['^1.0.0-x64-rc1', '^1.0.0', 'x64-rc1', Stability::Preview, 'Glued pre-release after a feature']; + yield 'numeric tail of a feature with a digit' => ['^2.0-php8-1', '^2.0', 'php8-1', Stability::Preview, 'Feature with a digit']; + + // A single version with a tail is one release, its stability taken from that release + yield 'exact tail after a numbered pre-release' => ['1.0.0-beta.1-2', '1.0.0-beta.1-2', null, Stability::Beta, 'Tail after a pre-release']; + yield 'exact feature build' => ['2.12.0-hotfix', '2.12.0-hotfix', null, Stability::Preview, 'Exact feature build']; + yield 'exact pre-release after a feature' => ['=1.0-arm64-beta.1', '=1.0-arm64-beta.1', null, Stability::Beta, 'Exact pre-release after a feature']; + yield 'exact feature build with an explicit stability' => ['2.12.0-hotfix@rc', '2.12.0-hotfix', null, Stability::RC, 'Explicit stability of an exact build']; + yield 'exact patch letter' => ['1.1.1w', '1.1.1w', null, Stability::Preview, 'Patch letter']; + yield 'exact patch letter of an alpha abbreviation' => ['1.1.1a', '1.1.1a', null, Stability::Preview, 'Patch letter a']; + yield 'exact word after a dot' => ['1.0.0.Final', '1.0.0.Final', null, Stability::Preview, 'Word after a dot']; + yield 'glued release candidate with an explicit stability' => ['^2.0.0rc1@beta', '^2.0.0', null, Stability::Beta, 'Glued bound']; + + // Build metadata counts for nothing + yield 'build metadata with words' => ['>=1.2.3+build.5', '>=1.2.3', null, Stability::Stable, 'Build metadata']; + yield 'build metadata like a stability' => ['1.2.3+beta', '1.2.3', null, Stability::Stable, 'Build metadata like a stability']; + yield 'build metadata with a hyphen' => ['^1.2.3+build-5', '^1.2.3', null, Stability::Stable, 'No feature in build metadata']; + yield 'feature before build metadata' => ['^1.2.3-feature+build.5', '^1.2.3', 'feature', Stability::Preview, 'Feature before build metadata']; + yield 'build metadata in a range' => ['>=1.0+5 <2.0+build.1 || ^3.0+x', '>=1.0 <2.0 || ^3.0', null, Stability::Stable, 'Build metadata of every term']; } public static function provideInvalidConstraints(): \Generator @@ -296,6 +343,17 @@ public static function provideInvalidConstraints(): \Generator 'Invalid stability level: @beta@alpha', 'Multiple @ symbols in constraint', ]; + + yield 'base version of a feature build' => ['abc-feature', 'Invalid base version format: abc.', 'Base version of a feature build']; + yield 'wildcard with an operator' => ['>=1.*', 'A wildcard version takes no operator', 'Wildcard with an operator']; + yield 'reversed operator' => ['=>1.0', 'Unsupported operator `=>`', 'Reversed operator']; + yield 'branch' => ['dev-master', 'Invalid base version format: dev.', 'Branch name']; + yield 'empty alternative' => ['^1.0 ||', 'Empty alternative', 'Empty alternative']; + yield 'empty term' => ['1.2,', 'Empty term in the version constraint `1.2,`.', 'Empty term']; + yield 'plus is no operator' => ['+1.2.3', 'Invalid base version format: +1.2.3.', 'Plus before the version']; + yield 'plus without build metadata' => ['>=1.2.3+', 'Invalid base version format: >=1.2.3+.', 'Empty build metadata']; + yield 'underscore in build metadata' => ['1.2.3+5_beta', 'Invalid base version format: 1.2.3+5_beta.', 'Underscore in build metadata']; + yield 'feature suffix after a pre-release bound of a range' => ['>=3.5.0-beta.1 <3.5.0-custom', 'Invalid base version format: <3.5.0-custom.', 'Feature in a range']; } public static function provideComparableConstraints(): \Generator @@ -305,6 +363,360 @@ public static function provideComparableConstraints(): \Generator yield ['^1.0-priority@rc', '1.3.1-RC1-priority.0', true]; yield ['^1.0-priority@RC', '1.3.1-RC1-priority.0', true]; yield ['^1.0-priority@RC', '1.3.1-priority.0', false]; + + // A numbered pre-release names one release + yield 'exact pre-release with explicit stability' => ['3.5.0-beta.1@beta', 'v3.5.0-beta.1', true]; + yield 'exact pre-release with implicit stability' => ['3.5.0-beta.1', 'v3.5.0-beta.1', true]; + yield 'exact release candidate' => ['3.5.0-RC1', 'v3.5.0-RC1', true]; + yield 'exact dotted release candidate' => ['2025.1.0-rc.2@rc', 'v2025.1.0-rc.2', true]; + yield 'another spelling of the release' => ['3.5.0-rc.1', 'v3.5.0-RC1', true]; + yield 'beta abbreviation' => ['1.0.0-b2', 'v1.0.0-beta.2', true]; + yield 'exact pre' => ['2.0.0-pre.3', 'v2.0.0-pre3', true]; + yield 'exact preview' => ['2.0.0-preview5', 'v2.0.0-preview5', true]; + yield 'exact unstable' => ['2.0.0-unstable2', 'v2.0.0-unstable.2', true]; + yield 'exact snapshot' => ['2.0.0-snapshot1', 'v2.0.0-snapshot1', true]; + yield 'exact nightly' => ['1.0.0-nightly20250503', 'v1.0.0-nightly20250503', true]; + yield 'another nightly' => ['1.0.0-nightly20250503', 'v1.0.0-nightly20250504', false]; + yield 'another pre-release number' => ['3.5.0-beta.1@beta', 'v3.5.0-beta.2', false]; + yield 'another stability with the same number' => ['3.5.0-beta.1', 'v3.5.0-alpha.1', false]; + yield 'final release of a pre-release' => ['3.5.0-beta.1@beta', 'v3.5.0', false]; + yield 'another version number' => ['3.5.0-beta.1', 'v3.5.1-beta.1', false]; + yield 'pre-release below the minimum stability' => ['3.5.0-beta.1@stable', 'v3.5.0-beta.1', false]; + yield 'feature build of the version' => ['3.5.0-beta.1', 'v3.5.0-custom', false]; + yield 'underscore spelling in the tag' => ['1.0.0-alpha_2', 'v1.0.0-alpha_2', true]; + yield 'explicit stability looser than the pre-release' => ['3.5.0-RC1@beta', 'v3.5.0-RC1', true]; + yield 'explicit stability stricter than the pre-release' => ['3.5.0-beta.1@rc', 'v3.5.0-beta.1', false]; + yield 'feature build after the pre-release' => ['1.3.1-RC1', 'v1.3.1-RC1-priority.0', false]; + yield 'number after the pre-release' => ['=3.5.0-beta.1', 'v3.5.0-beta.1.2', false]; + yield 'platform after the pre-release' => ['3.5.0-RC1', 'v3.5.0-RC1-linux', false]; + yield 'explicit equality with a tail after the pre-release' => ['==3.5.0-RC1', 'v3.5.0-RC1-linux', false]; + + // A pre-release bound of a range + yield 'caret above the bound' => ['^3.5.0-beta.1@beta', 'v3.5.0-rc.1', true]; + yield 'caret on the final release' => ['^3.5.0-beta.1@beta', 'v3.5.0', true]; + yield 'caret up to the next major' => ['^3.5.0-beta.1@beta', 'v3.6.0', true]; + yield 'caret below the bound' => ['^3.5.0-beta.1@beta', 'v3.5.0-alpha.9', false]; + yield 'caret past the next major' => ['^3.5.0-beta.1@beta', 'v4.0.0', false]; + yield 'caret on an older version' => ['^3.5.0-beta.1@beta', 'v3.4.9', false]; + yield 'tilde on a later patch' => ['~3.5.0-rc.1', 'v3.5.4', true]; + yield 'tilde past the minor' => ['~3.5.0-rc.1', 'v3.6.0', false]; + yield 'at least the bound' => ['>=3.5.0-beta.2@beta', 'v3.5.0-beta.2', true]; + yield 'below at least the bound' => ['>=3.5.0-beta.2@beta', 'v3.5.0-beta.1', false]; + yield 'above the bound' => ['>3.5.0-beta.1@beta', 'v3.5.0-beta.2', true]; + yield 'equal to above the bound' => ['>3.5.0-beta.1@beta', 'v3.5.0-beta.1', false]; + yield 'below the bound' => ['<3.5.0-beta.2@beta', 'v3.5.0-beta.1', true]; + yield 'equal to below the bound' => ['<3.5.0-beta.2@beta', 'v3.5.0-beta.2', false]; + yield 'older version below the bound' => ['<3.5.0-beta.2@beta', 'v3.4.0', true]; + yield 'final release below its pre-release' => ['<3.5.0-beta.2@beta', 'v3.5.0', false]; + yield 'at most the bound' => ['<=3.5.0-beta.2@beta', 'v3.5.0-beta.2', true]; + yield 'explicit equality' => ['==3.5.0-rc.1', 'v3.5.0-RC1', true]; + yield 'nightly range' => ['>=1.0.0-nightly20250503@nightly', 'v1.0.0-nightly20250601', true]; + yield 'caret on a number after the pre-release' => ['^3.5.0-beta.1', 'v3.5.0-beta.1.2', true]; + yield 'at least the bound with a tail after it' => ['>=3.5.0-RC1', 'v3.5.0-RC1-linux', true]; + yield 'above the bound with a tail after it' => ['>3.5.0-RC1', 'v3.5.0-RC1-linux', true]; + yield 'above the bound with a number after it' => ['>3.5.0-RC1', 'v3.5.0-RC1.2', true]; + yield 'caret on the bound with a tail after it' => ['^3.5.0-RC1', 'v3.5.0-RC1-linux', true]; + yield 'tilde on the bound with a tail after it' => ['~3.5.0-RC1', 'v3.5.0-RC1-linux', true]; + yield 'below the bound with a tail after it' => ['<3.5.0-RC1', 'v3.5.0-RC1-linux', false]; + yield 'at most the bound with a tail after it' => ['<=3.5.0-RC1', 'v3.5.0-RC1-linux', false]; + yield 'below the next pre-release with a tail after the previous one' => ['<3.5.0-RC2', 'v3.5.0-RC1-linux', true]; + yield 'above the bound on a next version below the stability' => ['>3.5.0-beta.2', 'v3.6.0-alpha', false]; + yield 'above the bound on a next version' => ['>3.5.0-beta.2', 'v3.6.0', true]; + yield 'tilde from a pre-release on a later patch' => ['~3.5.0-beta.1', 'v3.5.4', true]; + yield 'tilde from a pre-release past the minor' => ['~3.5.0-beta.1', 'v3.6.0', false]; + + // A pre-release in the constraint sets the minimum stability for an upper bound too + yield 'below the bound on an earlier pre-release' => ['<3.5.0-beta.2', 'v3.5.0-beta.1', true]; + yield 'below the bound on a less stable pre-release' => ['<3.5.0-beta.2', 'v3.5.0-alpha.9', false]; + yield 'below the bound on an older dev build' => ['<3.5.0-beta.2', 'v3.4.0-dev', false]; + yield 'below the bound on an older release' => ['<3.5.0-beta.2', 'v3.4.0', true]; + yield 'below the bound on the bound' => ['<3.5.0-beta.2', 'v3.5.0-beta.2', false]; + yield 'below the bound with alpha on an alpha' => ['<3.5.0-beta.2@alpha', 'v3.5.0-alpha.9', true]; + yield 'below the bound with alpha on a dev build' => ['<3.5.0-beta.2@alpha', 'v3.4.0-dev', false]; + yield 'below the bound with dev on an earlier pre-release' => ['<3.5.0-beta.2@dev', 'v3.5.0-beta.1', true]; + yield 'below the bound with dev on an alpha' => ['<3.5.0-beta.2@dev', 'v3.5.0-alpha.9', true]; + yield 'below the bound with dev on a dev build' => ['<3.5.0-beta.2@dev', 'v3.4.0-dev', true]; + yield 'below the bound with dev on an older release' => ['<3.5.0-beta.2@dev', 'v3.4.0', true]; + yield 'below the bound with dev on the bound' => ['<3.5.0-beta.2@dev', 'v3.5.0-beta.2', false]; + + // Versions with more than three number parts + yield 'four parts exact' => ['1.2.3.4', 'v1.2.3.4', true]; + yield 'four parts exact pre-release' => ['1.2.3.4-beta.1', 'v1.2.3.4-beta.1', true]; + yield 'four parts on another fourth part' => ['1.2.3.4', 'v1.2.3.5', false]; + yield 'five parts on an exact four' => ['1.2.3.4', 'v1.2.3.4.5', false]; + yield 'five parts on an explicit equality' => ['==1.2.3.4', 'v1.2.3.4.5', false]; + yield 'five parts above four' => ['>1.2.3.4', 'v1.2.3.4.5', true]; + yield 'five parts at least four' => ['>=1.2.3.4', 'v1.2.3.4.5', true]; + yield 'five parts below four' => ['<1.2.3.4', 'v1.2.3.4.5', false]; + yield 'five parts at most four' => ['<=1.2.3.4', 'v1.2.3.4.5', false]; + yield 'five parts below the next fourth part' => ['<1.2.3.5', 'v1.2.3.4.5', true]; + yield 'five parts in a caret' => ['^1.2', 'v1.2.3.4.5', true]; + yield 'five parts in a tilde' => ['~1.2.3.4', 'v1.2.3.4.5', true]; + yield 'six parts above four' => ['>1.2.3.4', 'v1.2.3.4.5.6', true]; + yield 'trailing zero after four parts on an explicit equality' => ['==1.2.3.4', 'v1.2.3.4.0', true]; + yield 'trailing zeros after four parts' => ['1.2.3.4', 'v1.2.3.4.0.0', true]; + yield 'trailing zeros after three parts' => ['1.2.3', 'v1.2.3.0.0', true]; + yield 'trailing zero after four parts above four' => ['>1.2.3.4', 'v1.2.3.4.0', false]; + yield 'trailing zero after four parts below four' => ['<1.2.3.4', 'v1.2.3.4.0', false]; + yield 'zero part before a meaningful one' => ['1.2.3.4', 'v1.2.3.4.0.5', false]; + yield 'zero parts before a meaningful one' => ['1.0', 'v1.0.0.0.1', false]; + yield 'trailing zeros before a pre-release' => ['3.5.0-RC1', 'v3.5.0.0.0-RC1', true]; + yield 'five parts above a four parts pre-release' => ['^1.2.3.4-beta.1', 'v1.2.3.4.5', true]; + yield 'five parts on an exact four parts pre-release' => ['1.2.3.4-beta.1', 'v1.2.3.4.5', false]; + + // A pre-release glued to the number + yield 'exact release candidate without a separator in the tag' => ['2.0.0-rc1', 'v2.0.0rc1', true]; + yield 'exact beta abbreviation without a separator in the tag' => ['1.0.0-b2', '1.0.0b2', true]; + + // A long major part is an ordinary number + yield 'date major part with four parts' => ['^1.0', '20250101.1.2.3', false]; + yield 'date major part in a caret of its own' => ['^20250101.1', '20250101.1.2.3', true]; + yield 'six digit major part with four parts' => ['^1.0', '123456.1.2.3', false]; + yield 'six digit major part at least a bound' => ['>=1.0', '100000.0.0.1', true]; + yield 'six digit major part with five parts on a pre-release bound' => ['^1.0.0-beta.1', '123456.0.0.0.1', false]; + + // A single version with a pre-release or a tail is one release + yield 'exact bare keyword on its release' => ['2.0-beta', 'v2.0.0-beta', true]; + yield 'exact bare keyword on a zero numbered spelling' => ['2.0-beta', 'v2.0.0-beta.0', true]; + yield 'exact bare keyword on a numbered pre-release' => ['3.5.0-beta', 'v3.5.0-beta.3', false]; + yield 'exact bare keyword after an equal sign on a numbered pre-release' => ['=3.5.0-beta', 'v3.5.0-beta.3', false]; + yield 'exact bare keyword after a double equal sign on a numbered pre-release' => ['==3.5.0-beta', 'v3.5.0-beta.3', false]; + yield 'exact bare keyword on a release candidate' => ['2.0-beta', 'v2.0.0-RC1', false]; + yield 'exact bare keyword on the final release' => ['2.0-beta', 'v2.0.0', false]; + yield 'exact bare keyword after an equal sign on its release' => ['=2.0-beta', '2.0.0-beta', true]; + yield 'exact bare keyword after a double equal sign on its release' => ['==2.0-beta', 'v2.0.0-beta', true]; + yield 'exact pre-release after a feature' => ['1.0-arm64-beta.1', 'v1.0-arm64-beta.1', true]; + yield 'exact pre-release after a feature on another pre-release' => ['1.0-arm64-beta.1', 'v1.0-arm64-beta.2', false]; + yield 'exact pre-release after a feature on another feature' => ['1.0-arm64-beta.1', 'v1.0-amd64-beta.1', false]; + yield 'exact feature build' => ['2.12.0-hotfix', 'v2.12.0-hotfix', true]; + yield 'exact feature build on a numbered one' => ['2.12.0-hotfix', 'v2.12.0-hotfix.2', false]; + yield 'exact feature build on a platform build of it' => ['2.12.0-hotfix', 'v2.12.0-hotfix-linux', false]; + yield 'exact feature build on another feature' => ['=2.12.0-hotfix', 'v2.12.0-musl', false]; + yield 'exact feature build on its final release' => ['==2.12.0-hotfix', 'v2.12.0', false]; + yield 'exact feature build with a stricter explicit stability' => ['2.12.0-hotfix@stable', 'v2.12.0-hotfix', false]; + yield 'exact tail after a pre-release on itself' => ['1.0.0-beta.1-2', 'v1.0.0-beta.1-2', true]; + yield 'exact tail after a release candidate on itself' => ['3.5.0-RC1-linux', 'v3.5.0-RC1-linux', true]; + yield 'exact pre-release before a tail on itself' => ['1.2.3-rc1-arm64', 'v1.2.3-rc1-arm64', true]; + + // A bare number covers every build of that number the stability allows + yield 'bare number on a platform build' => ['1.2.3', 'v1.2.3-linux', false]; + yield 'bare number with a looser stability on a platform build' => ['1.2.3@preview', 'v1.2.3-linux', true]; + yield 'bare number with a looser stability on a pre-release' => ['=1.2.3@beta', 'v1.2.3-beta.1', true]; + yield 'bare number on build metadata' => ['1.2.3', 'v1.2.3+5', true]; + yield 'bare number on build metadata with words' => ['1.2.3', 'v1.2.3+build.5', true]; + + // Build metadata counts for nothing on either side + yield 'at least with build metadata on its number' => ['>=1.2.3+build.5', 'v1.2.3', true]; + yield 'above with build metadata on other build metadata' => ['>1.2.3+build.5', 'v1.2.3+build.6', false]; + yield 'exact pre-release with build metadata on itself' => ['1.2.3-beta.1+build.5', 'v1.2.3-beta.1', true]; + yield 'exact pre-release with build metadata on the next one' => ['1.2.3-beta.1+build.5', 'v1.2.3-beta.2', false]; + yield 'exact pre-release on itself with build metadata' => ['1.2.3-beta.1', 'v1.2.3-beta.1+build.5', true]; + yield 'above a pre-release with build metadata on the next one' => ['>1.2.3-beta.1+5', 'v1.2.3-beta.2', true]; + yield 'build metadata like a stability on its release' => ['1.2.3+beta', 'v1.2.3', true]; + yield 'build metadata like a stability on a beta' => ['1.2.3+beta', 'v1.2.3-beta', false]; + yield 'caret with build metadata with a hyphen' => ['^1.2.3+build-5', 'v1.4.0', true]; + yield 'feature before build metadata' => ['^1.2.3-feature+build.5', 'v1.4.0-feature', true]; + yield 'hyphen range with build metadata' => ['1.0+build.1 - 2.0+build.2', 'v2.0.0', true]; + + // Number parts past the integer range + yield 'above a long number part on the next one' => ['>1.99999999999999999998', 'v1.99999999999999999999', true]; + yield 'below a long number part on the previous one' => ['<1.99999999999999999999', 'v1.99999999999999999998', true]; + yield 'exact long number part on the next one' => ['1.99999999999999999998', 'v1.99999999999999999999', false]; + + // A bare stability keyword with an explicit stability is a bound too + yield 'caret from a bare keyword with a looser stability on its bound' => ['^2.0-beta@alpha', 'v2.0.0-beta', true]; + yield 'caret from a bare keyword with a looser stability on a release candidate' => ['^2.0-beta@alpha', 'v2.0.0-RC1', true]; + yield 'caret from a bare keyword with a looser stability on a later alpha' => ['^2.0-beta@alpha', 'v2.1.0-alpha', true]; + yield 'caret from a bare keyword with a looser stability below the bound' => ['^2.0-beta@alpha', 'v2.0.0-alpha', false]; + + // A feature suffix of a range is still a part the version must contain + yield 'tilde feature' => ['~1.2-feature', 'v1.2.5-feature-linux', true]; + yield 'tilde feature on a release' => ['~1.2-feature', 'v1.2.5', false]; + + // A numeric tail orders as a part of the number, in matching as in sorting + yield 'numeric tail below a pre-release bound' => ['<3.5.0-preview.3', 'v3.5.0-1', false]; + yield 'numeric tail at least a pre-release bound' => ['>=3.5.0-preview.3', 'v3.5.0-1', true]; + yield 'numeric tail at most a preview' => ['<=1.0.0@preview', 'v1.0.0-1', false]; + yield 'numeric tail above its number' => ['>1.0.0@preview', 'v1.0.0-1', true]; + yield 'numeric tail on an exact number' => ['1.0-dev', 'v1.0.0-1', false]; + + // Five and more number parts in the constraint + yield 'five parts at least a five parts bound' => ['>=1.2.3.4.5', 'v1.2.3.4.5', true]; + yield 'four parts at least a five parts bound' => ['>=1.2.3.4.5', 'v1.2.3.4', false]; + yield 'five parts pre-release' => ['1.2.3.4.5-beta.1', 'v1.2.3.4.5-beta.1', true]; + yield 'caret on a five parts pre-release' => ['^1.2.3.4.5-beta.1', 'v1.2.3.4.6', true]; + + // Composer syntax around the stability and the feature suffix + yield 'range with a stability' => ['>=1.0 <2.0@beta', 'v1.5.0-beta.1', true]; + yield 'range with a stability on the upper bound' => ['>=1.0 <2.0@beta', 'v2.0.0-beta.1', false]; + yield 'alternatives with a stability' => ['^1.2 || ^2.0@beta', 'v2.1.0-beta', true]; + yield 'hyphen range' => ['1.0 - 2.0', 'v2.0.5', true]; + yield 'hyphen range past the upper bound' => ['1.0 - 2.0', 'v2.1', false]; + yield 'hyphen range with a stability' => ['1.0 - 2.0@beta', 'v1.5.0-beta', true]; + yield 'wildcard with a stability' => ['1.*@beta', 'v1.5.0-beta', true]; + yield 'match all' => ['*', 'v20250101.1.2.3', true]; + yield 'match all keeps the stability' => ['*', 'v1.0.0-beta', false]; + yield 'not equal' => ['!=1.2.3', 'v1.2.4', true]; + yield 'not equal on the version' => ['!=1.2.3@beta', 'v1.2.3-beta.1', false]; + yield 'not equal to a pre-release' => ['!=3.5.0-beta.1', 'v3.5.0-beta.2', true]; + yield 'prefixed version' => ['v1.2.3', 'v1.2.3', true]; + yield 'prefixed pre-release' => ['v3.5.0-beta.1', 'v3.5.0-beta.1', true]; + yield 'space after the operator' => ['>= 1.2', 'v1.3', true]; + yield 'space after the operator of a pre-release' => ['>= 3.5.0-beta.1', 'v3.5.0-beta.2', true]; + yield 'feature on a range' => ['^1.0-priority', 'v2.0.0-priority.0', false]; + yield 'exact feature build on a numbered one of the same feature' => ['1.3.1-priority', 'v1.3.1-priority.0', false]; + yield 'not equal in the Composer spelling' => ['<>1.2.3', 'v1.2.4', true]; + + // A numeric tail is a part of the version number + yield 'exact numeric tail' => ['1.0.0-1', 'v1.0.0-1', true]; + yield 'exact numeric tail on its number' => ['1.0.0-1', 'v1.0.0', false]; + yield 'exact numeric tail on another one' => ['1.0.0-1', 'v1.0.0-2', false]; + yield 'at least a numeric tail on a later one' => ['>=1.0.0-1', 'v1.0.0-2', true]; + yield 'caret on a numeric tail' => ['^1.0.0-1', 'v1.9.0', true]; + + // Pre-release bounds of ranges set the minimum stability + yield 'pre-release lower bound on a later pre-release' => ['>=3.5.0-beta.1 <3.5.0-RC1', 'v3.5.0-beta.2', true]; + yield 'pre-release lower bound on an earlier stability' => ['>=3.5.0-beta.1 <3.5.0-RC1', 'v3.5.0-alpha.1', false]; + yield 'pre-release upper bound with a looser explicit stability' => ['>=3.4.0 <3.5.0-RC1@alpha', 'v3.4.5-alpha', true]; + yield 'pre-release upper bound without an explicit stability' => ['>=3.4.0 <3.5.0-RC1', 'v3.4.5-alpha', false]; + yield 'pre-release bounds with a stricter explicit stability' => ['>=3.5.0-beta.1 <3.5.0-RC1@stable', 'v3.5.0-beta.2', false]; + yield 'upper bound without a pre-release below every build of its number' => ['>=3.5.0-beta.1 <3.5.0', 'v3.5.0-beta.2', false]; + yield 'pre-release alternative on its bound' => ['^1.0 || ^2.0-beta.1', 'v2.0.0-beta.1', true]; + yield 'pre-release alternative on another alternative' => ['^1.0 || ^2.0-beta.1', 'v1.5.0', true]; + yield 'bare keyword before alternatives without spaces on its bound' => ['^1.0-beta||^2.0', 'v1.0.0-beta', true]; + yield 'bare keyword before alternatives without spaces below its bound' => ['^1.0-beta||^2.0', 'v1.0.0-alpha', false]; + yield 'bare keyword before alternatives without spaces on another alternative' => ['^1.0-beta||^2.0', 'v2.5.0', true]; + yield 'bare keyword before alternatives without spaces past them' => ['^1.0-beta||^2.0', 'v3.0.0', false]; + yield 'pre-release after alternatives without spaces on its bound' => ['^1.0||^2.0-beta.1', 'v2.0.0-beta.1', true]; + yield 'pre-release after alternatives without spaces below its bound' => ['^1.0||^2.0-beta.1', 'v2.0.0-alpha.1', false]; + yield 'pre-release after alternatives without spaces on another alternative' => ['^1.0||^2.0-beta.1', 'v1.5.0', true]; + yield 'pre-release after alternatives without spaces past them' => ['^1.0||^2.0-beta.1', 'v3.0.0', false]; + yield 'least stable bound last on its bound' => ['>=1.0-RC1 || ^2.0-alpha.1', 'v2.0.0-alpha.1', true]; + yield 'least stable bound last on a later alpha' => ['>=1.0-RC1 || ^2.0-alpha.1', 'v2.1.0-alpha', true]; + yield 'least stable bound last below the first bound' => ['>=1.0-RC1 || ^2.0-alpha.1', 'v1.0.0-beta', false]; + yield 'pre-release upper bound on a release below it' => ['>=1.0 <2.0-RC1', 'v1.5.0', true]; + yield 'pre-release upper bound on its bound' => ['>=1.0 <2.0-RC1', 'v2.0.0-RC1', false]; + yield 'pre-release upper bound below the stability' => ['>=1.0 <2.0-RC1', 'v2.0.0-beta', false]; + yield 'hyphen range from a pre-release on its bound' => ['1.0.0-beta.1 - 2.0.0', 'v1.0.0-beta.1', true]; + yield 'hyphen range from a pre-release on an earlier stability' => ['1.0.0-beta.1 - 2.0.0', 'v1.0.0-alpha', false]; + yield 'hyphen range to a short pre-release on its bound' => ['1.0 - 2.0-beta.1', 'v2.0.0-beta.1', true]; + yield 'hyphen range to a short pre-release past its bound' => ['1.0 - 2.0-beta.1', 'v2.0.0-beta.2', false]; + yield 'least stable of pre-release bounds' => ['>=1.0.0-alpha.1 <2.0.0-RC1', 'v1.5.0-beta', true]; + + // A feature suffix keeps filtering every alternative + yield 'feature of alternatives' => ['^1.2 || ^2.0-feature', 'v2.1.0-feature', true]; + yield 'feature of alternatives on a release' => ['^1.2 || ^2.0-feature', 'v1.3.0', false]; + yield 'feature with a stability' => ['^2.12.0-hotfix@rc', 'v2.13.0-RC1-hotfix', true]; + yield 'feature with a stability on a feature build' => ['^2.12.0-hotfix@rc', 'v2.12.0-hotfix', false]; + yield 'single word feature' => ['^2.12.0-experimental', 'v2.12.0-experimental', true]; + yield 'single word feature on a release' => ['^2.12.0-experimental', 'v2.13.0', false]; + + // A digit within a feature suffix is no pre-release bound + yield 'numeric tail of a feature suffix' => ['^1.0.0-x64-1', 'v1.0.0-x64-1', true]; + yield 'numeric tail of a feature with a digit' => ['^2.0-php8-1', 'v2.0.0-php8-1', true]; + + // A bare stability keyword after a comparison is a bound, as in Composer + yield 'above a bare keyword on a later pre-release' => ['>2.0-beta', 'v2.0.0-beta.1', true]; + yield 'above a bare keyword on a release candidate' => ['>2.0-beta', 'v2.0.0-RC1', true]; + yield 'above a bare keyword on its release' => ['>2.0-beta', 'v2.0.0', true]; + yield 'above a bare keyword on a later release' => ['>2.0-beta', 'v2.1.0', true]; + yield 'above a bare keyword on its bound' => ['>2.0-beta', 'v2.0.0-beta', false]; + yield 'above a bare keyword on an earlier stability' => ['>2.0-beta', 'v2.0.0-alpha', false]; + yield 'above a bare keyword with a looser stability' => ['>2.0-beta@alpha', 'v2.0.0-alpha', false]; + yield 'above a zero numbered pre-release on a release candidate' => ['>2.0-beta.0', 'v2.0.0-RC1', true]; + yield 'above a zero numbered pre-release on the bare keyword' => ['>2.0-beta.0', 'v2.0.0-beta', false]; + yield 'at most a bare keyword on its release' => ['<=2.0-beta', 'v2.0.0', false]; + yield 'at most a bare keyword on a later pre-release' => ['<=2.0-beta', 'v2.0.0-beta.1', false]; + yield 'at most a bare keyword on its bound' => ['<=2.0-beta', 'v2.0.0-beta', true]; + yield 'at most a bare keyword on an earlier release' => ['<=2.0-beta', 'v1.9.0', true]; + yield 'at most a bare keyword with a looser stability' => ['<=2.0-beta@alpha', 'v2.0.0-alpha', true]; + yield 'below a bare keyword on an earlier stability' => ['<2.0-beta', 'v2.0.0-alpha', false]; + yield 'not equal to a bare keyword on its bound' => ['!=2.0-beta', 'v2.0.0-beta', false]; + yield 'not equal to a bare keyword on a zero numbered spelling' => ['!=2.0-beta', 'v2.0.0-beta.0', false]; + yield 'not equal to a bare keyword on a later pre-release' => ['!=2.0-beta', 'v2.0.0-beta.1', true]; + yield 'not equal to a bare keyword on its release' => ['!=2.0-beta', 'v2.0.0', true]; + yield 'not equal to a bare keyword on an earlier stability' => ['!=2.0-beta', 'v2.0.0-alpha', false]; + yield 'not equal to a bare keyword in the Composer spelling' => ['<>2.0-beta', 'v2.0.0-RC1', true]; + + // Case does not matter, neither in a tail nor in a feature + yield 'exact platform build in another case' => ['1.0.0-arm64', 'v1.0.0-ARM64', true]; + yield 'exact platform build in mixed case' => ['1.0.0-arm64', 'v1.0.0-Arm64', true]; + yield 'exact patch build in another case' => ['2.12.0-patch1', 'v2.12.0-Patch1', true]; + yield 'exact platform build of a pre-release in another case' => ['1.0.0-beta-amd64', 'v1.0.0-beta-AMD64', true]; + yield 'exact patch abbreviation in another case' => ['1.0.0-p1', 'v1.0.0-P1', true]; + yield 'exact release candidate in another case' => ['2.0.0-RC', 'v2.0.0-rc', true]; + yield 'feature in another case' => ['^1.0-Linux', 'v1.0.0-linux', true]; + yield 'feature in another case on another feature' => ['^1.0-Linux', 'v1.0.0-darwin', false]; + + // A `v` prefix or build metadata before a tail + yield 'prefixed exact feature build on a numbered one' => ['v2.12.0-hotfix', '2.12.0-hotfix.2', false]; + yield 'prefixed exact feature build' => ['v2.12.0-hotfix', '2.12.0-hotfix', true]; + yield 'exact feature build before build metadata on a numbered one' => ['1.2.3-hotfix+5', '1.2.3-hotfix.2', false]; + yield 'exact feature build before build metadata' => ['1.2.3-hotfix+5', '1.2.3-hotfix', true]; + yield 'build metadata with a hyphen is no feature build' => ['1.2.3+5-hotfix', '1.2.3-hotfix', false]; + yield 'build metadata with a hyphen on its number' => ['1.2.3+5-hotfix', '1.2.3', true]; + yield 'above a prefixed bare keyword on a release candidate' => ['>v2.0-beta', '2.0.0-RC1', true]; + yield 'caret from a bare keyword before build metadata on a release candidate' => ['^1.2.3-beta+5', '1.2.3-RC1', true]; + yield 'caret with build metadata like a bare keyword on a release candidate' => ['^1.2.3+5-beta', '1.2.3-RC1', false]; + + // A pre-release glued to the number + yield 'caret from a glued release candidate on itself' => ['^2.0.0rc1', '2.0.0rc1', true]; + yield 'caret from a glued release candidate on the next one' => ['^2.0.0rc1', 'v2.0.0-RC2', true]; + yield 'caret from a glued release candidate on its release' => ['^2.0.0rc1', '2.0.0', true]; + yield 'caret from a glued release candidate on a later release' => ['^2.0.0rc1', '2.1.0', true]; + yield 'caret from a glued release candidate on a beta' => ['^2.0.0rc1@beta', '2.0.0-beta', false]; + yield 'at least a glued release candidate on its release' => ['>=1.21rc1', '1.21.0', true]; + yield 'at least a glued release candidate on a later beta' => ['>=1.21rc1', '1.22.0-beta', false]; + yield 'exact glued release candidate' => ['2.0.0rc1', 'v2.0.0-RC1', true]; + yield 'exact glued release candidate on its release' => ['2.0.0rc1', '2.0.0', false]; + yield 'exact glued release candidate on a platform build' => ['2.0.0rc1', '2.0.0rc1-linux', false]; + yield 'exact patch letter' => ['1.1.1w', '1.1.1w', true]; + yield 'exact patch letter on its number' => ['1.1.1w', '1.1.1', false]; + yield 'exact patch letter x' => ['0.9.8x', '0.9.8x', true]; + yield 'exact patch letter x on another letter' => ['0.9.8x', '0.9.8w', false]; + yield 'wildcard x after a dot' => ['1.x', '1.5.0', true]; + yield 'wildcard x of a dev branch after a dot' => ['1.0.x-dev', '1.0.3-dev', true]; + yield 'exact patch letter a' => ['1.1.1a', '1.1.1a', true]; + yield 'exact patch letter a on an alpha' => ['1.1.1a@alpha', '1.1.1-alpha', false]; + yield 'exact word after a dot' => ['1.0.0.Final', '1.0.0-final', true]; + yield 'dev branch is no exact word after a dot' => ['1.0.x-dev', '1.0.5-dev', true]; + } + + /** + * Bare stability keywords that match alone as in a range, after every operator. + */ + public static function provideBareKeywordConstraints(): \Generator + { + foreach (['', '=', '==', '^', '~', '>=', '>', '<', '<=', '!=', '<>'] as $operator) { + foreach (['dev', 'alpha', 'beta', 'RC'] as $keyword) { + foreach (['', '@alpha'] as $stability) { + yield "{$operator}2.0-{$keyword}{$stability}" => ["{$operator}2.0-{$keyword}", $stability]; + } + } + } + } + + public static function providePreReleases(): \Generator + { + yield 'dotted beta' => ['3.5.0-beta.1', '3.5.0', Stability::Beta, 1, Stability::Beta]; + yield 'release candidate' => ['3.5.0-RC1', '3.5.0', Stability::RC, 1, Stability::RC]; + yield 'beta abbreviation' => ['3.5.0-b2', '3.5.0', Stability::Beta, 2, Stability::Beta]; + yield 'nightly' => ['1.0.0-nightly20250503', '1.0.0', Stability::Nightly, 20250503, Stability::Nightly]; + yield 'explicit stability wins' => ['3.5.0-alpha-2@dev', '3.5.0', Stability::Alpha, 2, Stability::Dev]; + yield 'range' => ['^3.5.0-rc.2', '^3.5.0', Stability::RC, 2, Stability::RC]; + yield 'bare stability keyword' => ['3.5.0-beta', '3.5.0', Stability::Beta, 0, Stability::Beta]; + yield 'bare stability keyword after an equal sign' => ['=3.5.0-beta', '=3.5.0', Stability::Beta, 0, Stability::Beta]; + yield 'bare stability keyword with an explicit stability' => ['^3.5.0-beta@alpha', '^3.5.0', Stability::Beta, 0, Stability::Alpha]; + yield 'prefixed version' => ['v3.5.0-beta.1', 'v3.5.0', Stability::Beta, 1, Stability::Beta]; + yield 'not equal' => ['!=3.5.0-beta.1', '!=3.5.0', Stability::Beta, 1, Stability::Beta]; + yield 'bare stability keyword after a comparison' => ['>3.5.0-beta', '>3.5.0', Stability::Beta, 0, Stability::Beta]; + yield 'five number parts' => ['^1.2.3.4.5-rc.1', '^1.2.3.4.5', Stability::RC, 1, Stability::RC]; + yield 'pre-release before build metadata' => ['1.2.3-beta.1+build.5', '1.2.3', Stability::Beta, 1, Stability::Beta]; + yield 'pre-release bound before build metadata' => ['>=1.2.3-rc.2+5', '>=1.2.3', Stability::RC, 2, Stability::RC]; + yield 'release candidate glued to the number of a caret' => ['^2.0.0rc1', '^2.0.0', Stability::RC, 1, Stability::RC]; + yield 'release candidate glued to an exact number' => ['2.0.0rc1', '2.0.0', Stability::RC, 1, Stability::RC]; + yield 'release candidate after a dot' => ['=2.0.0.RC2', '=2.0.0', Stability::RC, 2, Stability::RC]; + yield 'beta abbreviation glued to the number of at least' => ['>=1.21b3', '>=1.21', Stability::Beta, 3, Stability::Beta]; } #[DataProvider('provideValidConstraints')] @@ -363,6 +775,39 @@ public function toStringWithFeatureSuffixAndCustomStability(): void Assert::same($result, '^2.12.0-feature@beta'); } + #[DataProvider('providePreReleases')] + #[Test] + public function numberedPreReleaseIsAPartOfTheVersion( + string $constraint, + string $expectedBaseVersion, + ?Stability $expectedPreReleaseStability, + ?int $expectedPreReleaseNumber, + Stability $expectedStability, + ): void { + $result = Constraint::fromConstraintString($constraint); + + Assert::same($result->versionConstraint, $expectedBaseVersion); + Assert::same($result->preRelease?->stability, $expectedPreReleaseStability); + Assert::same($result->preRelease?->number, $expectedPreReleaseNumber); + Assert::same($result->minimumStability, $expectedStability); + Assert::null($result->featureSuffix); + } + + #[Test] + public function rejectsAnUnknownOperatorWithAPreRelease(): never + { + Expect::exception(\InvalidArgumentException::class)->withMessageContaining('Unsupported operator `=>`'); + + Constraint::fromConstraintString('=>3.5.0-beta.1'); + } + + #[Test] + public function toStringKeepsThePreRelease(): void + { + Assert::same((string) Constraint::fromConstraintString('^3.5.0-beta.1@beta'), '^3.5.0-beta.1@beta'); + Assert::same((string) Constraint::fromConstraintString('>=1.2.3-beta.1+build.5'), '>=1.2.3-beta.1+build.5'); + } + #[DataProvider('provideComparableConstraints')] #[Test] public function isSatisfiedBy(string $constraint, string $version, bool $expected): void @@ -374,4 +819,30 @@ public function isSatisfiedBy(string $constraint, string $version, bool $expecte Assert::same($result, $expected, "Constraint: {$constraint}, Version: {$version}"); } + + #[DataProvider('provideBareKeywordConstraints')] + #[Test] + public function bareKeywordMatchesAloneAsInRanges(string $constraint, string $stability): void + { + $alone = Constraint::fromConstraintString($constraint . $stability); + $ranges = [ + Constraint::fromConstraintString("{$constraint} >=0{$stability}"), + Constraint::fromConstraintString("{$constraint} || <0{$stability}"), + ]; + + foreach (['1.9.0', '2.0.0-dev', '2.0.0-alpha', '2.0.0-alpha.2', '2.0.0-beta', '2.0.0-beta.0', '2.0.0-beta.1', + '2.0.0-beta-linux', '2.0.0-RC', '2.0.0-RC1', '2.0.0-linux', '2.0.0-1', '2.0.0', '2.0.1-beta', '2.0.1', + '2.1.0', '3.0.0-beta', '3.0.0'] as $string) { + $version = Version::fromVersionString($string); + foreach ($ranges as $range) { + Assert::same( + $alone->isSatisfiedBy($version), + $range->isSatisfiedBy($version), + "{$alone} and {$range} on {$string}", + ); + } + } + + Assert::same($alone->minimumStability, $ranges[0]->minimumStability); + } } diff --git a/tests/Unit/Module/Version/PreReleaseTest.php b/tests/Unit/Module/Version/PreReleaseTest.php new file mode 100644 index 00000000..c4495245 --- /dev/null +++ b/tests/Unit/Module/Version/PreReleaseTest.php @@ -0,0 +1,103 @@ + ['beta.1', Stability::Beta, 1]; + yield 'upper case release candidate' => ['RC1', Stability::RC, 1]; + yield 'dashed release candidate' => ['rc-3', Stability::RC, 3]; + yield 'underscored alpha' => ['alpha_2', Stability::Alpha, 2]; + yield 'beta abbreviation' => ['b4', Stability::Beta, 4]; + yield 'alpha abbreviation' => ['A5', Stability::Alpha, 5]; + yield 'pre' => ['pre.3', Stability::Pre, 3]; + yield 'preview' => ['preview5', Stability::Preview, 5]; + yield 'unstable' => ['unstable7', Stability::Unstable, 7]; + yield 'dev' => ['dev.2', Stability::Dev, 2]; + yield 'snapshot' => ['snapshot1', Stability::Snapshot, 1]; + yield 'nightly date' => ['nightly20250503', Stability::Nightly, 20250503]; + yield 'no number' => ['beta', Stability::Beta, 0]; + } + + public static function provideInvalidPreReleases(): \Generator + { + yield 'empty' => ['']; + yield 'feature' => ['feature']; + yield 'number first' => ['1beta']; + yield 'two numbers' => ['beta.1.2']; + yield 'letters after the number' => ['beta1x']; + yield 'two separators' => ['beta..1']; + } + + public static function provideSpellings(): \Generator + { + yield 'dot and none' => ['rc.1', 'RC1']; + yield 'dash and dot' => ['beta-2', 'beta.2']; + yield 'abbreviation and keyword' => ['b3', 'beta.3']; + yield 'leading zero' => ['alpha.01', 'alpha1']; + yield 'no number and zero' => ['beta', 'beta.0']; + } + + #[DataProvider('provideValidPreReleases')] + #[Test] + public function fromStringReadsTheStabilityAndTheNumber(string $value, Stability $stability, int $number): void + { + $preRelease = PreRelease::fromString($value); + + Assert::same($preRelease?->stability, $stability); + Assert::same($preRelease?->number, $number); + } + + #[DataProvider('provideInvalidPreReleases')] + #[Test] + public function fromStringRejectsAnythingButAStabilityWithANumber(string $value): void + { + Assert::null(PreRelease::fromString($value)); + } + + #[DataProvider('provideSpellings')] + #[Test] + public function spellingDoesNotMatter(string $a, string $b): void + { + Assert::true(PreRelease::fromString($a)?->equals(PreRelease::fromString($b)) ?? false); + } + + #[Test] + public function ordersByStabilityWeightThenByNumber(): void + { + $ordered = [ + 'nightly20250503', 'snapshot1', 'dev.1', 'unstable1', 'alpha.1', 'alpha.10', + 'preview1', 'beta', 'beta.1', 'beta.2', 'beta.10', 'pre.1', 'RC1', 'rc.2', 'stable', + ]; + $shuffled = [ + 'beta.10', 'stable', 'alpha.1', 'nightly20250503', 'RC1', 'beta', 'dev.1', 'pre.1', + 'beta.2', 'snapshot1', 'rc.2', 'preview1', 'alpha.10', 'unstable1', 'beta.1', + ]; + + $preReleases = \array_map(static fn(string $value): PreRelease => PreRelease::fromString($value), $shuffled); + \usort($preReleases, static fn(PreRelease $a, PreRelease $b): int => $a->compare($b)); + + $expected = \array_map(static fn(string $value): string => (string) PreRelease::fromString($value), $ordered); + Assert::same(\array_map(\strval(...), $preReleases), $expected); + } + + #[Test] + public function stringsToTheCanonicalSpelling(): void + { + Assert::same((string) PreRelease::fromString('RC-1'), 'RC.1'); + Assert::same((string) PreRelease::fromString('b2'), 'beta.2'); + Assert::same((string) PreRelease::fromString('beta'), 'beta'); + } +} diff --git a/tests/Unit/Module/Version/RangeComposerTest.php b/tests/Unit/Module/Version/RangeComposerTest.php new file mode 100644 index 00000000..2da09a11 --- /dev/null +++ b/tests/Unit/Module/Version/RangeComposerTest.php @@ -0,0 +1,90 @@ +', '>=', '<', '<=', '^', '~', 'v', '>= ']; + private const CONSTRAINTS = [ + '*', '1.*', '1.x', '1.2.*', '1.2.3.*', '0.*', '1.*.*', 'v1.*', + '1.0 - 2.0', '1 - 2', '1.2.3 - 2.3', '1.0.0 - 2.0.0', '1.2 - 2.3.4', '0.3 - 1.2.3.4', + '^1.2 || ^2.0', '1.0|2.0', '~0.3|^1', '>=1.0 <2.0', '>=1.0,<2.0', '>=1.0, <2.0', '>1.0 <=1.2.3', + '>=1.0 <2.0 || >=3.0', '^1.0 !=1.2.3', '>= 1.0 < 2.0', '1.2.3+5', '^1.2.3+5', + '>=1.2.3+build.5', '^1.2+exp.sha.5114f85', '1.0+a - 2.0+b-1', + ]; + private const VERSIONS = [ + '0', '0.0.0', '0.0.1', '0.0.3', '0.0.3.4', '0.0.3.9', '0.0.4', '0.1', '0.2.9', '0.3', '0.3.1', '0.3.2', '0.3.9', + '0.4', '0.9.9', '1', '1.0.1', '1.1', '1.2', '1.2.2', '1.2.3', '1.2.3.0', '1.2.3.1', '1.2.3.4', '1.2.3.5', + '1.2.4', '1.2.10', '1.3', '1.9.9', '1.99.99.99', '2', '2.0.1', '2.3', '2.3.4', '2.3.5', '2.4', '3', '3.1', + '10.0.0', '99999.0.0', '1.2.3+5', '1.2.4+1', '1.2.3+build.5', '2.0+exp.sha.5114f85', + ]; + + /** Pre-release bounds and the versions they decide between. */ + private const PRE_RELEASE_CONSTRAINTS = [ + '1.2.3-beta.1', '=1.2.3-RC1', '!=1.2.3-beta.1', '>=1.2.3-beta.1', '>1.2.3-beta.1', '<1.2.3-beta.2', + '<=1.2.3-rc.1', '^1.2.3-RC1', '~1.2.3-alpha.2', '^0.3.2-beta.1', '1.0 - 1.2.3-beta.2', + '>=1.2.3-beta.1+build.5', '<1.2.3-RC1+5', + ]; + + private const PRE_RELEASE_VERSIONS = [ + '1.2.2', '1.2.2-RC1', '1.2.3-alpha.1', '1.2.3-alpha.2', '1.2.3-beta.1', '1.2.3-beta.2', '1.2.3-RC1', + '1.2.3-RC2', '1.2.3', '1.2.4-alpha.1', '1.2.4', '1.3.0-beta.1', '2.0.0-beta.1', '2.0.0', '0.3.2-beta.1', + '0.3.2', '0.3.9', '0.4.0-beta.1', '1.2.3-beta.2+build.7', '1.2.3-RC1+5', + ]; + + #[Test] + public function agreesWithComposerOnReleases(): void + { + $constraints = self::CONSTRAINTS; + foreach (self::BASES as $base) { + foreach (self::OPERATORS as $operator) { + $constraints[] = $operator . $base; + } + } + + $this->assertAgreement($constraints, self::VERSIONS); + } + + #[Test] + public function agreesWithComposerOnPreReleaseBounds(): void + { + $this->assertAgreement(self::PRE_RELEASE_CONSTRAINTS, self::PRE_RELEASE_VERSIONS); + } + + /** + * @param list $constraints + * @param list $versions + */ + private function assertAgreement(array $constraints, array $versions): void + { + $disagreements = []; + foreach ($constraints as $constraint) { + $range = Range::fromString($constraint); + foreach ($versions as $version) { + $expected = Semver::satisfies($version, $constraint); + $range->isSatisfiedBy(Version::fromVersionString($version)) === $expected + or $disagreements[] = "{$constraint} on {$version}: Composer says " . \var_export($expected, true); + } + } + + Assert::same($disagreements, []); + } +} diff --git a/tests/Unit/Module/Version/RangeTest.php b/tests/Unit/Module/Version/RangeTest.php new file mode 100644 index 00000000..fff44a7f --- /dev/null +++ b/tests/Unit/Module/Version/RangeTest.php @@ -0,0 +1,454 @@ +', '>=', '<', '<=']; + + /** Versions in ascending order, a group of equal versions in one row. */ + private const ORDERED_VERSIONS = [ + ['0.0.1'], + ['0.9.9-1'], + ['1.0.0-nightly20250101'], + ['1.0.0-alpha.1', '1.0.0-a1'], + ['1.0.0-alpha.2'], + ['1.0.0-preview'], + ['1.0.0-custom'], + ['1.0.0-beta.1', '1.0.0.0-beta.1', '1.0.0b1'], + ['1.0.0-beta.1-linux'], + ['1.0.0-beta.1.2'], + ['1.0.0-beta.2'], + ['1.0.0-RC1', '1.0.0-rc.1'], + ['1.0.0-RC1-linux'], + ['1.0.0', '1.0', '1', '1.0.0.0.0'], + ['1.0.0-1'], + ['1.0.0.1-beta.1'], + ['1.0.0.1'], + ['1.0.0.1.5'], + ['1.0.0-2'], + ['1.0.1-RC1'], + ['1.0.1'], + ['1.2.3.4.5'], + ['2.0.0-beta.1'], + ['2.0.0'], + ['20250101.1.2.3'], + ]; + + /** Bounds of the property tests, with and without a pre-release. */ + private const BOUNDS = [ + '0.0.1', '1', '1.0', '1.0.0', '1.0.0.1', '1.0.1', '1.2.3.4.5', '2', '20250101.1', + '1.0.0-alpha.2', '1.0.0-beta.1', '1.0.0-beta.2', '1.0.0-rc.1', '1.0.0.1-beta.1', '2.0.0-beta.1', '1.0.0-preview', + ]; + + public static function provideMatches(): \Generator + { + // Comparisons against a version number: every build of the number counts as the number + yield ['1.2.3', '1.2.3', true]; + yield ['1.2.3', '1.2.3.0.0', true]; + yield ['1.2.3', 'v1.2.3', true]; + yield ['1.2.3', '1.2.4', false]; + yield ['1.2.3', '1.2.3.1', false]; + yield ['1.2.3', '1.2.3-beta.1', true]; + yield ['1.2.3', '1.2.3-feature', true]; + yield ['1.2', '1.2.0', true]; + yield ['1.2', '1.2.1', false]; + yield ['=1.2.3', '1.2.3', true]; + yield ['==1.2.3', '1.2.3', true]; + yield ['==1.2.3', '1.2.2', false]; + yield ['!=1.2.3', '1.2.3', false]; + yield ['!=1.2.3', '1.2.3-beta', false]; + yield ['!=1.2.3', '1.2.4', true]; + yield ['<>1.2.3', '1.2.3', false]; + yield ['<>1.2.3', '1.2.4', true]; + yield ['>1.2', '1.2.0.1', true]; + yield ['>1.2', '1.2.0', false]; + yield ['>1.2', '1.2.0-beta', false]; + yield ['>=1.2', '1.2.0-rc1', true]; + yield ['>=1.2', '1.1.99', false]; + yield ['<2', '1.99', true]; + yield ['<2', '2.0.0-beta', false]; + yield ['<2', '2.0.0', false]; + yield ['<=2', '2.0.0', true]; + yield ['<=2', '2.0.0-beta', true]; + yield ['<=2', '2.0.0.1', false]; + + // Caret + yield ['^1.2.3', '1.2.3', true]; + yield ['^1.2.3', '1.9.9', true]; + yield ['^1.2.3', '1.2.2', false]; + yield ['^1.2.3', '2.0.0', false]; + yield ['^1.2.3', '2.0.0-beta', false]; + yield ['^1', '1.9', true]; + yield ['^1', '2', false]; + yield ['^0.3', '0.3.9', true]; + yield ['^0.3', '0.4.0', false]; + yield ['^0.3.2', '0.3.1', false]; + yield ['^0.3.2', '0.3.9', true]; + yield ['^0.3.2', '0.4', false]; + yield ['^0.0.3', '0.0.3.1', true]; + yield ['^0.0.3', '0.0.4', false]; + yield ['^0', '0.9', true]; + yield ['^0', '1.0', false]; + yield ['^0.0', '0.0.9', true]; + yield ['^0.0', '0.1', false]; + yield ['^0.0.0', '0.0.0', true]; + yield ['^0.0.0', '0.0.1', false]; + yield ['^0.0.3.4', '0.0.3.9', true]; + yield ['^0.0.3.4', '0.0.4', false]; + yield ['^1.2.3.4', '1.2.3.3', false]; + yield ['^1.2.3.4', '1.9', true]; + yield ['^01.2', '1.3', true]; + + // Tilde + yield ['~1', '1.9', true]; + yield ['~1', '2.0', false]; + yield ['~1.2', '1.9.9', true]; + yield ['~1.2', '1.1', false]; + yield ['~1.2', '2.0', false]; + yield ['~1.2.3', '1.2.9', true]; + yield ['~1.2.3', '1.3.0', false]; + yield ['~1.2.3', '1.2.2', false]; + yield ['~1.2.3.4', '1.2.3.9', true]; + yield ['~1.2.3.4', '1.2.4', false]; + yield ['~1.2.3.4.5', '1.2.3.4.9', true]; + yield ['~1.2.3.4.5', '1.2.3.5', false]; + yield ['~0.3', '0.9', true]; + yield ['~0.3', '1.0', false]; + yield ['~0', '0.9', true]; + + // Wildcards + yield ['*', '0.0.1', true]; + yield ['*', '20250101.1.2.3', true]; + yield ['x', '1.0', true]; + yield ['*.*', '1.0', true]; + yield ['1.*', '1.0', true]; + yield ['1.*', '1.99.99', true]; + yield ['1.*', '1.0.0-beta', true]; + yield ['1.*', '0.9', false]; + yield ['1.*', '2.0', false]; + yield ['1.x', '1.5', true]; + yield ['1.X', '1.5', true]; + yield ['v1.*', '1.5', true]; + yield ['1.*.*', '1.5', true]; + yield ['1.2.*', '1.2.9', true]; + yield ['1.2.*', '1.3', false]; + yield ['1.2.x', '1.1', false]; + yield ['1.2.3.4.*', '1.2.3.4.7', true]; + yield ['1.2.3.4.*', '1.2.3.5', false]; + yield ['0.*', '0.0.1', true]; + yield ['0.*', '1.0', false]; + + // Hyphen ranges + yield ['1.0 - 2.0', '1.0', true]; + yield ['1.0 - 2.0', '0.9', false]; + yield ['1.0 - 2.0', '2.0.9', true]; + yield ['1.0 - 2.0', '2.1', false]; + yield ['1 - 2', '2.9', true]; + yield ['1 - 2', '3.0', false]; + yield ['1.2.3 - 2.3.4', '2.3.4', true]; + yield ['1.2.3 - 2.3.4', '2.3.4.1', false]; + yield ['1.2.3 - 2.3.4', '1.2.2', false]; + yield ['1.0 - 2.0.0.0', '2.0.1', false]; + yield ['v1.0 - v2.0', '2.0.5', true]; + yield ['1.0 - 2.0', '1.5', true]; + yield ['1.0.0-beta.1 - 2.0', '1.0.0-beta.1', true]; + yield ['1.0.0-beta.2 - 2.0', '1.0.0-beta.1', false]; + yield ['1.0 - 2.0.0-beta.1', '2.0.0-beta.1', true]; + yield ['1.0 - 2.0.0-beta.1', '2.0.0-beta.2', false]; + yield ['1.0 - 2.0-beta.1', '2.0.0-beta.1', true]; + yield ['1.0 - 2.0-beta.1', '2.0.0-beta.2', false]; + yield ['1.0.0-1 - 2.0', '1.0.0', false]; + + // OR and AND + yield ['^1.2 || ^2.0', '1.5', true]; + yield ['^1.2 || ^2.0', '2.5', true]; + yield ['^1.2 || ^2.0', '3.0', false]; + yield ['1.0|2.0', '2.0', true]; + yield ['1.0|2.0', '1.5', false]; + yield ['1.0 | 2.0', '1.0', true]; + yield ['>=1.0 <2.0', '1.5', true]; + yield ['>=1.0 <2.0', '2.0', false]; + yield ['>=1.0 <2.0', '0.9', false]; + yield ['>=1.0,<2.0', '1.5', true]; + yield ['>=1.0, <2.0', '2.0', false]; + yield ['>=1.0 , <2.0', '1.5', true]; + yield ['<2.0 >=1.0', '1.5', true]; + yield ['>= 1.0 < 2.0', '1.5', true]; + yield ['>= 1.0 < 2.0', '2.5', false]; + yield ['>=1.0 <2.0 || >=3.0', '2.5', false]; + yield ['>=1.0 <2.0 || >=3.0', '3.5', true]; + yield ['^1.0 !=1.2.3', '1.2.3', false]; + yield ['^1.0 !=1.2.3', '1.2.4', true]; + yield ['1.0 - 2.0 || 3.*', '3.1', true]; + + // A `v` prefix and a space after the operator + yield ['v1.2.3', '1.2.3', true]; + yield ['>=v1.2', '1.3', true]; + yield ['^ 1.2', '1.3', true]; + yield ['!= 1.2', '1.2', false]; + + // Any count of number parts + yield ['>=1.2.3.4.5', '1.2.3.4.5', true]; + yield ['>=1.2.3.4.5', '1.2.3.4.4', false]; + yield ['>=1.2.3.4.5', '1.2.3.5', true]; + yield ['1.2.3.4.5', '1.2.3.4.5.0', true]; + yield ['<1.2.3.4.5', '1.2.3.4', true]; + yield ['^1.2.3.4.5', '1.9', true]; + yield ['1.0.0.0.0', '1', true]; + yield ['>=1.0', '20250101.1.2.3', true]; + yield ['^20250101.1', '20250101.1.2.3', true]; + yield ['>=1.0', '123456.1.2.3', true]; + + // A bound with a pre-release is one release, ordered as in sorting + yield ['3.5.0-beta.1', '3.5.0-beta.1', true]; + yield ['3.5.0-beta.1', '3.5.0-b1', true]; + yield ['3.5.0-beta.1', '3.5.0.0-beta.1', true]; + yield ['3.5.0-beta.1', '3.5.0-beta.1-linux', false]; + yield ['3.5.0-beta.1', '3.5.0-beta.1.2', false]; + yield ['3.5.0-beta.1', '3.5.0', false]; + yield ['!=3.5.0-beta.1', '3.5.0-beta.1', false]; + yield ['!=3.5.0-beta.1', '3.5.0-beta.1-linux', true]; + yield ['>=3.5.0-beta.1', '3.5.0-alpha.9', false]; + yield ['>=3.5.0-beta.1', '3.5.0-beta.1-linux', true]; + yield ['>3.5.0-RC1', '3.5.0-RC1-linux', true]; + yield ['>3.5.0-RC1', '3.5.0-RC1', false]; + yield ['<3.5.0-RC1', '3.5.0-RC1-linux', false]; + yield ['<3.5.0-RC1', '3.5.0-beta.9', true]; + yield ['<=3.5.0-RC1', '3.5.0-RC1', true]; + yield ['<=3.5.0-RC1', '3.5.0-RC1-linux', false]; + yield ['^3.5.0-beta.1', '3.5.0-beta.2', true]; + yield ['^3.5.0-beta.1', '3.9', true]; + yield ['^3.5.0-beta.1', '4.0.0-beta', false]; + yield ['~3.5.0-rc.1', '3.5.4', true]; + yield ['~3.5.0-rc.1', '3.6.0', false]; + yield ['^2025.1.0-rc.1', '2025.1.0-rc.2', true]; + yield ['^1.2.3.4.5-beta.1', '1.2.3.4.5-beta.2', true]; + yield ['^1.2.3.4.5-beta.1', '1.2.3.4.5-alpha', false]; + yield ['>=1.0.0-beta', '1.0.0-beta.3', true]; + + // A pre-release glued to the number, as tags spell it + yield ['^2.0.0rc1', '2.0.0rc1', true]; + yield ['^2.0.0rc1', '2.0.0-RC2', true]; + yield ['^2.0.0rc1', '2.0.0-beta', false]; + yield ['>=1.21rc1', '1.21.0-RC1', true]; + yield ['>=1.21rc1', '1.21.0-beta.9', false]; + yield ['2.0.0RC1', '2.0.0-rc.1', true]; + yield ['^2.0.0.rc1', '2.0.0-RC2', true]; + yield ['^2.0.0_rc1', '2.0.0-RC1', true]; + yield ['^1.0b2', '1.0.0-beta.2', true]; + yield ['^1.0b2', '1.0.0-beta.1', false]; + yield ['1.2a.3', '1.2.0-alpha.3', true]; + yield ['1.0 - 2.0rc1', '2.0.0-RC1', true]; + yield ['1.0 - 2.0rc1', '2.0.0', false]; + + // A numeric tail is a part of the number, in matching as in sorting + yield ['<3.5.0-preview.3', '3.5.0-1', false]; + yield ['>=3.5.0-preview.3', '3.5.0-1', true]; + yield ['<=1.0.0', '1.0.0-1', false]; + yield ['>1.0.0', '1.0.0-1', true]; + yield ['1.0.0', '1.0.0-1', false]; + yield ['<1.0.0.1', '1.0.0-1', false]; + yield ['1.0.0.1', '1.0.0-1', true]; + yield ['1.0.0-1', '1.0.0-1', true]; + yield ['1.0.0-1', '1.0.0', false]; + yield ['>=1.0.0-1', '1.0.0-2', true]; + yield ['^1.0.0-1', '1.9', true]; + yield ['~1.0.0-1', '1.0.9', true]; + yield ['~1.0.0-1', '1.1', false]; + + // Incrementing a part that ends in nines carries over + yield ['9.*', '9.5', true]; + yield ['9.*', '10.0', false]; + yield ['~1.9', '1.10', true]; + yield ['~9.9', '9.10', true]; + yield ['~9.9', '10.0', false]; + yield ['^0.99', '0.99.5', true]; + yield ['^0.99', '0.100', false]; + yield ['1.99.*', '1.99.5', true]; + yield ['1.99.*', '1.100.0', false]; + yield ['1.19.*', '1.19.9', true]; + yield ['1.19.*', '1.25', false]; + yield ['^0.199', '0.200', false]; + + // Number parts past the integer range + yield ['^99999999999999999999', '1.0', false]; + yield ['~9223372036854775807', '1.0', false]; + yield ['9223372036854775807.*', '1.0', false]; + yield ['^0.99999999999999999999', '1.0', false]; + + // Build metadata does not count + yield ['1.2.3', '1.2.3+5', true]; + yield ['>1.2.3', '1.2.3+5', false]; + yield ['1.2.3.5', '1.2.3+5', false]; + yield ['1.2.3+5', '1.2.3', true]; + yield ['^1.2.3+5', '1.9', true]; + yield ['~1.2.3+5', '1.3', false]; + yield ['1.0 - 2+5', '2.5', true]; + yield ['>=1.2.3+build.5', '1.2.3', true]; + yield ['>1.2.3+build.5', '1.2.3+build.6', false]; + yield ['1.2.3', '1.2.3+exp.sha.5114f85', true]; + yield ['1.2.3-beta.1+build.5', '1.2.3-beta.1', true]; + yield ['1.2.3-beta.1+build.5', '1.2.3-beta.2', false]; + yield ['^1.2.3-beta.1+5', '1.2.3-beta.2', true]; + yield ['1.0+build.1 - 2.0+build-2', '2.0.0', true]; + yield ['1.0-beta+5 - 2.0', '1.0.0-alpha', false]; + + // Long number parts compare by value + yield ['>1.99999999999999999998', '1.99999999999999999999', true]; + yield ['<=1.99999999999999999998', '1.99999999999999999999', false]; + yield ['1.99999999999999999999', '1.0099999999999999999999', true]; + yield ['>=1.99999999999999999999.0.0', '1.99999999999999999999', true]; + } + + public static function provideInvalidConstraints(): \Generator + { + yield 'empty' => ['', 'Version constraint cannot be empty.']; + yield 'spaces' => [' ', 'Version constraint cannot be empty.']; + yield 'word' => ['abc', 'Invalid base version format: abc.']; + yield 'branch' => ['dev-master', 'Invalid base version format: dev-master.']; + yield 'feature suffix' => ['1.2.3-feature', 'Invalid base version format: 1.2.3-feature.']; + yield 'plus as an operator' => ['+1.2.3', 'Invalid base version format: +1.2.3.']; + yield 'plus without build metadata' => ['1.2.3+', 'Invalid base version format: 1.2.3+.']; + yield 'underscore in build metadata' => ['1.2.3+5_beta', 'Invalid base version format: 1.2.3+5_beta.']; + yield 'letter in the number' => ['1.2x.3', 'Invalid base version format: 1.2x.3.']; + yield 'lone patch letter' => ['^1.1.1b', 'Invalid base version format: ^1.1.1b.']; + yield 'word glued to a pre-release' => ['^2.0.0rc1x', 'Invalid base version format: ^2.0.0rc1x.']; + yield 'word starting with a pre-release' => ['^2.0.0prerelease', 'Invalid base version format: ^2.0.0prerelease.']; + yield 'wildcard inside the number' => ['1.*.2', 'Invalid base version format: 1.*.2.']; + yield 'operator without a version' => ['>=', 'Invalid base version format: >=.']; + yield 'reversed operator' => ['=>1.0', 'Unsupported operator `=>` in the version constraint `=>1.0`.']; + yield 'doubled operator' => ['>>1.0', 'Unsupported operator `>>`']; + yield 'ruby tilde' => ['~>1.0', 'Unsupported operator `~>`']; + yield 'doubled caret' => ['^^1.0', 'Unsupported operator `^^`']; + yield 'operator with a pre-release' => ['=>3.5.0-beta.1', 'Unsupported operator `=>`']; + yield 'wildcard with an operator' => ['>=1.*', 'A wildcard version takes no operator: `>=1.*`.']; + yield 'wildcard with a caret' => ['^1.*', 'A wildcard version takes no operator: `^1.*`.']; + yield 'empty last alternative' => ['1.0 ||', 'Empty alternative in the version constraint `1.0 ||`.']; + yield 'empty first alternative' => ['|| 1.0', 'Empty alternative']; + yield 'open hyphen range' => ['1.0 -', 'Invalid base version format: -.']; + yield 'hyphen range of a wildcard' => ['1.* - 2.0', 'Invalid base version format: -.']; + yield 'empty last term' => ['1.2,', 'Empty term in the version constraint `1.2,`.']; + yield 'empty first term' => [',1.2', 'Empty term']; + yield 'empty middle term' => ['^1.0,,^2', 'Empty term']; + yield 'space inside an operator' => ['> =1.0', 'Invalid base version format: >.']; + yield 'feature after a numeric tail' => ['1.0.0-1-feature', 'Invalid base version format: 1.0.0-1-feature.']; + } + + #[DataProvider('provideMatches')] + #[Test] + public function isSatisfiedBy(string $constraint, string $version, bool $expected): void + { + $result = Range::fromString($constraint)->isSatisfiedBy(Version::fromVersionString($version)); + + Assert::same($result, $expected, "Constraint: {$constraint}, Version: {$version}"); + } + + #[DataProvider('provideInvalidConstraints')] + #[Test] + public function rejectsAnInvalidConstraint(string $constraint, string $message): void + { + Expect::exception(\InvalidArgumentException::class)->withMessageContaining($message); + + Range::fromString($constraint); + } + + /** + * A bound with a pre-release is a version: each operator accepts what {@see Version::compare()} says. + */ + #[Test] + public function preReleaseBoundAgreesWithSorting(): void + { + foreach (self::BOUNDS as $bound) { + if (!\str_contains($bound, '-')) { + continue; + } + + $boundVersion = Version::fromVersionString($bound); + foreach (self::OPERATORS as $operator) { + $range = Range::fromString($operator . $bound); + foreach (self::versions() as $version) { + $expected = Operator::from($operator)->accepts($version->compare($boundVersion)); + Assert::same( + $range->isSatisfiedBy($version), + $expected, + "Constraint: {$operator}{$bound}, Version: {$version}", + ); + } + } + } + } + + /** + * Every operator accepts an interval of the sorting order: a version between two accepted versions + * is accepted too, and equal versions are accepted together. + */ + #[Test] + public function everyBoundAcceptsAnIntervalOfTheSortingOrder(): void + { + $constraints = []; + foreach (self::BOUNDS as $bound) { + foreach ([...self::OPERATORS, '^', '~'] as $operator) { + $constraints[] = $operator . $bound; + } + } + \array_push($constraints, '1.*', '1.0.*', '1.0 - 1.0.0.1', '1 - 2.0.0-beta.1'); + + foreach ($constraints as $constraint) { + $range = Range::fromString($constraint); + $accepted = []; + foreach (self::ORDERED_VERSIONS as $row => $equal) { + $results = \array_map( + static fn(string $version): bool => $range->isSatisfiedBy(Version::fromVersionString($version)), + $equal, + ); + Assert::same(\count(\array_unique($results)), 1, "Constraint {$constraint} splits " . \implode(', ', $equal)); + $results[0] and $accepted[] = $row; + } + + $accepted === [] or \str_starts_with($constraint, '!=') or Assert::same( + \count($accepted), + \max($accepted) - \min($accepted) + 1, + "Constraint {$constraint} accepts a gap", + ); + } + } + + #[Test] + public function orderedVersionsAreSorted(): void + { + foreach (self::ORDERED_VERSIONS as $row => $equal) { + foreach ($equal as $version) { + Assert::same(Version::fromVersionString($version)->compare(Version::fromVersionString($equal[0])), 0); + $row === 0 or Assert::same( + Version::fromVersionString($version)->compare(Version::fromVersionString(self::ORDERED_VERSIONS[$row - 1][0])), + 1, + "{$version} sorts above " . self::ORDERED_VERSIONS[$row - 1][0], + ); + } + } + } + + /** + * @return list + */ + private static function versions(): array + { + return \array_map(Version::fromVersionString(...), \array_merge(...self::ORDERED_VERSIONS)); + } +} diff --git a/tests/Unit/Module/Version/VersionTest.php b/tests/Unit/Module/Version/VersionTest.php index 23f50c57..bbf37f5d 100644 --- a/tests/Unit/Module/Version/VersionTest.php +++ b/tests/Unit/Module/Version/VersionTest.php @@ -15,6 +15,110 @@ #[Covers(Version::class)] final class VersionTest { + public static function providePreReleases(): \Generator + { + yield 'dotted beta' => ['v3.5.0-beta.1', '3.5.0', Stability::Beta, 1]; + yield 'release candidate' => ['v3.5.0-RC1', '3.5.0', Stability::RC, 1]; + yield 'dotted release candidate' => ['2025.1.0-rc.2', '2025.1.0', Stability::RC, 2]; + yield 'nightly date' => ['1.0.0-nightly20250503', '1.0.0', Stability::Nightly, 20250503]; + yield 'beta abbreviation' => ['1.0.0b2', '1.0.0', Stability::Beta, 2]; + yield 'no separator' => ['2.0.0rc1', '2.0.0', Stability::RC, 1]; + yield 'no separator after a prefixed number' => ['v1.0.0rc1', '1.0.0', Stability::RC, 1]; + yield 'no separator after four number parts' => ['1.2.3.4rc1', '1.2.3.4', Stability::RC, 1]; + yield 'alpha abbreviation' => ['1.2.3a1', '1.2.3', Stability::Alpha, 1]; + yield 'underscore before the stability' => ['1.2.3_beta2', '1.2.3', Stability::Beta, 2]; + yield 'underscore separator' => ['1.0.0-rc_1', '1.0.0', Stability::RC, 1]; + yield 'bare stability' => ['1.2.3-beta', '1.2.3', Stability::Beta, 0]; + yield 'stability before a feature' => ['1.2.3-beta.3-feature', '1.2.3', Stability::Beta, 3]; + yield 'dev branch' => ['v2.0.x-dev', '2.0', Stability::Dev, 0]; + yield 'stable release' => ['v3.5.0', '3.5.0', Stability::Stable, 0]; + yield 'feature only' => ['1.2.3-custom', '1.2.3', Stability::Preview, 0]; + yield 'four number parts' => ['1.2.3.4-beta.1', '1.2.3.4', Stability::Beta, 1]; + yield 'five number parts' => ['v1.2.3.4.5-rc.2', '1.2.3.4.5', Stability::RC, 2]; + yield 'pre-release before build metadata' => ['1.2.3-rc.2+build.5', '1.2.3', Stability::RC, 2]; + } + + public static function provideOrderedVersions(): \Generator + { + yield 'pre-release numbers' => ['1.0.0-beta.2', '1.0.0-beta.10']; + yield 'stability weight' => ['1.0.0-beta.10', '1.0.0-RC1']; + yield 'final after its release candidate' => ['1.0.0-rc.9', '1.0.0']; + yield 'nightly first' => ['1.0.0-nightly20250503', '1.0.0-alpha.1']; + yield 'version number first' => ['1.0.0', '1.0.1-alpha.1']; + yield 'feature build before the final' => ['1.0.0-custom', '1.0.0']; + yield 'upper case release candidate before the final' => ['1.0.0-RC1', '1.0.0']; + yield 'underscore spelling' => ['1.0.0-rc_1', '1.0.0-rc_2']; + yield 'fourth number part' => ['1.2.3.4', '1.2.3.5']; + yield 'fourth number part over ten' => ['1.2.3.9', '1.2.3.10']; + yield 'three parts before four' => ['1.2.3', '1.2.3.4']; + yield 'four parts before the next patch' => ['1.2.3.4', '1.2.4']; + yield 'feature build number' => ['1.3.1-RC1-priority.0', '1.3.1-RC1-priority.1']; + yield 'number after the pre-release' => ['2.0.0-beta.1.2', '2.0.0-beta.1.3']; + yield 'number after the pre-release before the final' => ['2.0.0-beta.1.2', '2.0.0']; + yield 'number after a bare stability before the final' => ['1.0.0-preview.0.4', '1.0.0']; + yield 'number before the stability before the final' => ['1.0.0-5-preview', '1.0.0']; + yield 'four parts pre-release after the previous fourth part' => ['1.2.3.3', '1.2.3.4-beta.1']; + yield 'four parts pre-release after three parts' => ['1.2.3', '1.2.3.4-beta.1']; + yield 'five parts after four' => ['1.2.3.4', '1.2.3.4.5']; + yield 'five parts before the next fourth part' => ['1.2.3.4.5', '1.2.3.5']; + yield 'numeric suffix after the number' => ['1.0.0', '1.0.0-1']; + yield 'numeric suffixes' => ['1.0.0-1', '1.0.0-2']; + yield 'numeric suffix over ten' => ['1.0.0-2', '1.0.0-10']; + yield 'date suffix before the next patch' => ['1.0.0-20250101', '1.0.1']; + yield 'release candidate without a separator after an alpha' => ['2.0.0-alpha', '2.0.0rc1']; + yield 'beta abbreviation without a separator after an earlier beta' => ['1.0.0-beta.3', '1.0.0b4']; + yield 'tail after the pre-release' => ['3.5.0-RC1', '3.5.0-RC1-linux']; + yield 'number after the pre-release after the pre-release itself' => ['3.5.0-RC1', '3.5.0-RC1.2']; + yield 'zero parts before a meaningful one' => ['1.0', '1.0.0.1']; + yield 'platform build before the final' => ['1.0.0-x86_64', '1.0.0']; + yield 'number with a letter before the final' => ['1.0.0-1x', '1.0.0']; + yield 'number with an alpha abbreviation before the final' => ['1.0.0-1a', '1.0.0']; + yield 'words of the suffix' => ['1.0.0-amd64', '1.0.0-arm64']; + yield 'words after the pre-release' => ['1.0.0-RC1-darwin', '1.0.0-RC1-linux']; + yield 'patch abbreviation and the whole word' => ['1.0.0-p1', '1.0.0-patch1']; + yield 'word before a number' => ['2.0.0-beta.1-linux', '2.0.0-beta.1.2']; + yield 'word before a number at the start of the tail' => ['1.0.0-x.1', '1.0.0-1.x']; + yield 'long number parts' => ['1.99999999999999999998', '1.99999999999999999999']; + yield 'long number part after a short one' => ['1.9', '1.10000000000000000000']; + yield 'long first number parts' => ['99999999999999999998.1', '99999999999999999999.0']; + yield 'part after a long number part' => ['1.99999999999999999999', '1.99999999999999999999.1']; + yield 'long numeric suffixes' => ['1.0.0-99999999999999999998', '1.0.0-99999999999999999999']; + yield 'pre-release before build metadata like a stability' => ['1.2.3-beta', '1.2.3+beta']; + yield 'platform word is no alpha' => ['1.0.0-arm64', '1.0.0-linux']; + yield 'patch abbreviation is no patch level' => ['1.0.0-p1', '1.0.0-x1']; + yield 'number after a word' => ['1.0.0-build.9', '1.0.0-build.10']; + yield 'number glued to a word' => ['1.0.0-build9', '1.0.0-build10']; + yield 'long numbers' => ['1.0.0-build.99999999999999999998', '1.0.0-build.99999999999999999999']; + yield 'words alphabetically in any case' => ['1.0.0-Darwin', '1.0.0-linux']; + } + + public static function provideEqualVersions(): \Generator + { + yield 'spellings of one pre-release' => ['v3.5.0-rc.1', '3.5.0-RC1']; + yield 'trailing zero part' => ['1.2.3', '1.2.3.0']; + yield 'trailing zero parts' => ['1.2.3', 'v1.2.3.0.0']; + yield 'trailing zero after four parts' => ['1.2.3.4', '1.2.3.4.0']; + yield 'trailing zero before a pre-release' => ['1.2.3-RC1', '1.2.3.0-RC1']; + yield 'two parts' => ['1.0', '1.0.0']; + yield 'build metadata' => ['1.2.3', '1.2.3+5']; + yield 'build metadata after two parts' => ['1.2', '1.2+5']; + yield 'build metadata after one part' => ['1', '1+2']; + yield 'build metadata with words' => ['1.2.3', 'v1.2.3+build.5']; + yield 'other build metadata' => ['1.2.3+build.5', '1.2.3+build.6']; + yield 'build metadata after a pre-release' => ['1.2.3-beta.1', '1.2.3-beta.1+build.5']; + yield 'build metadata after a tail' => ['1.2.3-linux', '1.2.3-linux+exp.sha.5114f85']; + yield 'leading zeros of number parts' => ['1.2.3', '01.002.3']; + yield 'leading zeros of a long number part' => ['1.99999999999999999999', '1.0099999999999999999999']; + yield 'case, separators and leading zeros of the suffix' => ['1.0.0-Linux_x64.01', '1.0.0-linux-x64.1']; + yield 'case of a platform word' => ['1.0.0-arm64', '1.0.0-ARM64']; + yield 'case of a patch word' => ['2.12.0-patch1', '2.12.0-Patch1']; + yield 'case of a platform word after a pre-release' => ['1.0.0-beta-amd64', '1.0.0-beta-AMD64']; + yield 'case of a patch abbreviation' => ['1.0.0-p1', '1.0.0-P1']; + yield 'case of a release candidate' => ['1.0.0-RC', '1.0.0-rc']; + yield 'case and separator of a word' => ['1.0.0-final', '1.0.0.Final']; + yield 'pre-release before or after a platform' => ['1.0.0-rc1-arm64', '1.0.0-arm64-rc1']; + } + /** * Provides test cases for successful version string parsing. */ @@ -23,7 +127,7 @@ public static function provideValidVersionStrings(): \Generator // Semantic versions yield 'basic semantic version' => ['1.2.3', '1.2.3', '1.2.3', null, Stability::Stable]; yield 'semantic version with patch number' => ['2.12.5', '2.12.5', '2.12.5', null, Stability::Stable]; - yield 'semantic version with build number' => ['1.0.0+123', '1.0.0+123', '1.0.0+123', null, Stability::Stable]; + yield 'semantic version with build number' => ['1.0.0+123', '1.0.0+123', '1.0.0', null, Stability::Stable]; // Versions with stability suffixes yield 'version with beta suffix' => ['1.2.3-beta', '1.2.3-beta', '1.2.3', null, Stability::Beta]; @@ -35,15 +139,42 @@ public static function provideValidVersionStrings(): \Generator // Versions with feature suffixes yield 'version with feature suffix after stability' => ['1.2.3-beta-feature', '1.2.3-beta-feature', '1.2.3', 'feature', Stability::Beta]; yield 'version with stability at end' => ['1.2.3-feature-beta', '1.2.3-feature-beta', '1.2.3', 'feature', Stability::Beta]; + yield 'preview is not read as pre' => ['1.0.0-preview-foo', '1.0.0-preview-foo', '1.0.0', 'foo', Stability::Preview]; yield 'version with multiple features and stability' => ['2.0.0-feature1-feature2-alpha', '2.0.0-feature1-feature2-alpha', '2.0.0', 'feature1-feature2', Stability::Alpha]; - // Versions with plus prefix - yield 'version with plus prefix stability' => ['1.2.3+beta', '1.2.3+beta', '1.2.3', null, Stability::Beta]; - yield 'version with plus prefix feature' => ['1.2.3+feature-alpha', '1.2.3+feature-alpha', '1.2.3', 'feature', Stability::Alpha]; + // Build metadata counts for nothing, whatever it looks like + yield 'build metadata with words' => ['1.2.3+build.5', '1.2.3+build.5', '1.2.3', null, Stability::Stable]; + yield 'build metadata like a stability' => ['1.2.3+beta', '1.2.3+beta', '1.2.3', null, Stability::Stable]; + yield 'build metadata like a feature with a stability' => ['1.2.3+feature-alpha', '1.2.3+feature-alpha', '1.2.3', null, Stability::Stable]; + yield 'build metadata after a pre-release' => ['1.2.3-beta.1+build.5', '1.2.3-beta.1+build.5', '1.2.3', null, Stability::Beta]; + yield 'numeric build metadata after a pre-release' => ['1.2.3-beta.1+5', '1.2.3-beta.1+5', '1.2.3', null, Stability::Beta]; + yield 'build metadata after a tail' => ['1.2.3-linux+build.5', '1.2.3-linux+build.5', '1.2.3', 'linux', Stability::Preview]; + yield 'build metadata with hyphens' => ['1.0.0-alpha11+cs-1.1.0', '1.0.0-alpha11+cs-1.1.0', '1.0.0', null, Stability::Alpha]; + yield 'build metadata after two parts' => ['v1.2+exp.sha.5114f85', 'v1.2+exp.sha.5114f85', '1.2', null, Stability::Stable]; + yield 'build metadata before a hash' => ['1.2.3+build.5#abcdef1', '1.2.3+build.5#abcdef1', '1.2.3', null, Stability::Stable]; + yield 'plus before a suffix that is no build metadata' => ['1.2.3+linux_x64', '1.2.3+linux_x64', '1.2.3', 'linux_x64', Stability::Preview]; // Partial semantic versions (fallback pattern) yield 'two-part version' => ['1.2', '1.2', '1.2', null, Stability::Stable]; yield 'single digit version' => ['5', '5', '5', null, Stability::Stable]; + + // Longer numbers + yield 'four-part version' => ['1.2.3.4', '1.2.3.4', '1.2.3.4', null, Stability::Stable]; + yield 'prefixed four-part version' => ['v1.2.3.4', 'v1.2.3.4', '1.2.3.4', null, Stability::Stable]; + yield 'five-part version' => ['v1.2.3.4.5', 'v1.2.3.4.5', '1.2.3.4.5', null, Stability::Stable]; + yield 'four-part version with feature' => ['1.2.3.4-custom', '1.2.3.4-custom', '1.2.3.4', 'custom', Stability::Preview]; + // A suffix glued to the number + yield 'release candidate without a separator' => ['2.0.0rc1', '2.0.0rc1', '2.0.0', null, Stability::RC]; + yield 'feature after a release candidate without a separator' => ['2.0.0rc1-linux', '2.0.0rc1-linux', '2.0.0', 'linux', Stability::RC]; + yield 'feature without a separator' => ['1.2.3_linux', '1.2.3_linux', '1.2.3', 'linux', Stability::Preview]; + yield 'letter without a separator' => ['1.0.0x', '1.0.0x', '1.0.0', 'x', Stability::Preview]; + yield 'dev branch after a letter without a separator' => ['1.0.0x-dev', '1.0.0x-dev', '1.0.0', 'x', Stability::Dev]; + + // Dev branches + yield 'dev branch of a minor' => ['1.0.x-dev', '1.0.x-dev', '1.0', 'x', Stability::Dev]; + yield 'dev branch of a major' => ['2.x-dev', '2.x-dev', '2', 'x', Stability::Dev]; + yield 'dev branch after a full number' => ['1.0.0-x-dev', '1.0.0-x-dev', '1.0.0', 'x', Stability::Dev]; + yield 'version with suffix but no recognized stability' => ['1.2.3-custom', '1.2.3-custom', '1.2.3', 'custom', Stability::Preview]; // Case insensitive stability @@ -63,7 +194,6 @@ public static function provideInvalidVersionStrings(): \Generator yield 'version starting with text' => ['version1.2.3']; yield 'dev-master' => ['dev-master']; yield 'dev-feature+issue-1' => ['dev-feature+issue-1']; - yield '1.0.0-alpha11+cs-1.1.0' => ['1.0.0-alpha11+cs-1.1.0']; yield '1.0.0-beta#comment-part' => ['1.0.0-beta#comment-part']; } @@ -129,6 +259,81 @@ public static function provideVersionsAndExpectedStability(): \Generator yield 'real case 3' => ['v1.3.0', Stability::Stable, 'Stable version']; } + /** + * A stability keyword is a whole word: letters glued to it make another word. + */ + public static function provideWordsStartingWithAStabilityLetter(): \Generator + { + yield 'arm64 is not alpha' => ['1.0.0-arm64', 'arm64']; + yield 'amd64 is not alpha' => ['1.0.0-amd64', 'amd64']; + yield 'abc is not alpha' => ['1.0.0-abc', 'abc']; + yield 'word glued to four number parts' => ['1.2.3.4abc', 'abc']; + yield 'bugfix is not beta' => ['1.0.0-bugfix', 'bugfix']; + yield 'build is not beta' => ['1.0.0-build.5', 'build.5']; + yield 'ga is not alpha' => ['1.0.0-ga', 'ga']; + yield 'word ending with a stability letter' => ['1.0.0-gamma', 'gamma']; + yield 'platform after the os' => ['1.0.0-linux-amd64', 'linux-amd64']; + yield 'letters after the pre-release number' => ['1.0.0-beta2x', 'beta2x']; + yield 'lone patch letter a' => ['1.1.1a', 'a']; + yield 'lone patch letter b' => ['1.1.1b', 'b']; + yield 'lone patch letter w' => ['1.1.1w', 'w']; + } + + public static function provideStabilityWords(): \Generator + { + yield 'alpha abbreviation with a number' => ['1.0.0-a1', Stability::Alpha, 1, null]; + yield 'beta with a number' => ['1.0.0-beta2', Stability::Beta, 2, null]; + yield 'release candidate glued to the number' => ['2.0.0rc1', Stability::RC, 1, null]; + yield 'beta after an underscore' => ['1.2.3_beta2', Stability::Beta, 2, null]; + yield 'beta abbreviation glued to the number' => ['1.0.0b2', Stability::Beta, 2, null]; + yield 'platform after the pre-release' => ['1.0.0-rc.1-arm64', Stability::RC, 1, 'arm64']; + yield 'platform after an underscore' => ['2.0.0rc1_linux', Stability::RC, 1, 'linux']; + } + + #[DataProvider('providePreReleases')] + #[Test] + public function preReleaseKeepsTheStabilityWithItsNumber( + string $input, + string $versionNumber, + Stability $stability, + int $number, + ): void { + $version = Version::fromVersionString($input); + + Assert::same($version->number, $versionNumber); + Assert::same($version->preRelease?->stability, $stability); + Assert::same($version->preRelease?->number, $number); + Assert::same($version->stability, $stability); + } + + #[DataProvider('provideOrderedVersions')] + #[Test] + public function compareOrdersByNumberThenByPreRelease(string $older, string $newer): void + { + $a = Version::fromVersionString($older); + $b = Version::fromVersionString($newer); + + Assert::same($a->compare($b), -1); + Assert::same($b->compare($a), 1); + } + + #[DataProvider('provideEqualVersions')] + #[Test] + public function compareTreatsVersionsAsEqual(string $a, string $b): void + { + Assert::same(Version::fromVersionString($a)->compare(Version::fromVersionString($b)), 0); + Assert::same(Version::fromVersionString($b)->compare(Version::fromVersionString($a)), 0); + } + + #[Test] + public function emptyVersionComesFirst(): void + { + Assert::same(Version::empty()->compare(Version::fromVersionString('0.0.1')), -1); + Assert::same(Version::empty()->compare(Version::fromVersionString('0.0')), -1); + Assert::same(Version::fromVersionString('0')->compare(Version::empty()), 1); + Assert::same(Version::empty()->compare(Version::empty()), 0); + } + /** * Tests that valid version strings are parsed correctly. */ @@ -254,4 +459,36 @@ public function parseStabilityInvalidCases(string $version): void $version = Version::fromVersionString($version); } + + #[DataProvider('provideWordsStartingWithAStabilityLetter')] + #[Test] + public function wordStartingWithAStabilityLetterIsAFeature(string $input, string $suffix): void + { + $version = Version::fromVersionString($input); + + Assert::same($version->stability, Stability::Preview); + Assert::same($version->suffix, $suffix); + } + + #[DataProvider('provideStabilityWords')] + #[Test] + public function wholeStabilityWordIsAPreRelease(string $input, Stability $stability, int $number, ?string $suffix): void + { + $version = Version::fromVersionString($input); + + Assert::same($version->preRelease?->stability, $stability); + Assert::same($version->preRelease?->number, $number); + Assert::same($version->suffix, $suffix); + } + + #[Test] + public function compareNumberIgnoresThePreRelease(): void + { + $final = Version::fromVersionString('1.0.0'); + + Assert::same(Version::fromVersionString('1.0.0-beta.1-linux')->compareNumber($final), 0); + Assert::same(Version::fromVersionString('1.0.0.0-rc1')->compareNumber($final), 0); + Assert::same(Version::fromVersionString('1.0.0-1')->compareNumber($final), 1); + Assert::same(Version::fromVersionString('0.9.9-1')->compareNumber($final), -1); + } }