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
+ . '((?:[-+]|(?=(?)' . 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+)*)|(?:[._]|(?)){$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);
+ }
}