diff --git a/README-es.md b/README-es.md
index 3e822ce..6d128ec 100644
--- a/README-es.md
+++ b/README-es.md
@@ -329,6 +329,10 @@ Usa restricciones de versión estilo Composer:
+
+
+
+
@@ -343,7 +347,7 @@ 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`.
+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
diff --git a/README-ru.md b/README-ru.md
index 19ecb54..73058e7 100644
--- a/README-ru.md
+++ b/README-ru.md
@@ -330,6 +330,10 @@ DLoad поддерживает три типа загрузки, которые
+
+
+
+
@@ -344,7 +348,7 @@ DLoad поддерживает три типа загрузки, которые
```
-Pre-release в ограничении задаёт минимальную стабильность при любом операторе, даже в `<3.5.0-beta.2`; понизить её можно через `@alpha` или `@dev`.
+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`.
### Расширенные настройки
diff --git a/README-zh.md b/README-zh.md
index 3d78d98..6390bce 100644
--- a/README-zh.md
+++ b/README-zh.md
@@ -329,6 +329,10 @@ DLoad 支持三种下载类型,它们决定了资源的处理方式:
+
+
+
+
@@ -343,7 +347,7 @@ DLoad 支持三种下载类型,它们决定了资源的处理方式:
```
-约束中的预发布版本会为任何运算符设定最低稳定性,即使是 `<3.5.0-beta.2`;可用 `@alpha` 或 `@dev` 降低。
+约束中的预发布版本会为任何运算符设定最低稳定性,即使是 `<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`。
### 高级配置选项
diff --git a/README.md b/README.md
index 5ab1c0a..e07d025 100644
--- a/README.md
+++ b/README.md
@@ -331,6 +331,10 @@ Use Composer-style version constraints:
+
+
+
+
@@ -345,7 +349,7 @@ 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 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
diff --git a/composer.json b/composer.json
index b6e3315..1c50040 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 2c62b61..e3ca601 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 3fa0168..3e86039 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 7331e68..5dec7ee 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`, 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 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`); trailing zero parts don't count (`1.2.3` = `1.2.3.0`). |
+| `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 don't count (`1.2.3` = `1.2.3.0`). 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/troubleshooting.md b/skills/dload-fetch-tool/references/troubleshooting.md
index 5be6851..5503f25 100644
--- a/skills/dload-fetch-tool/references/troubleshooting.md
+++ b/skills/dload-fetch-tool/references/troubleshooting.md
@@ -12,6 +12,8 @@ curl -s "https://api.github.com/repos///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/BinaryVersion.php b/src/Module/Binary/BinaryVersion.php
index d3dbe91..db64e34 100644
--- a/src/Module/Binary/BinaryVersion.php
+++ b/src/Module/Binary/BinaryVersion.php
@@ -39,12 +39,13 @@ public static function fromBinaryOutput(string $output): static
/**
* 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.
+ * 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::keywordPattern() . ')\d*\b))[\w.-]+)?/i';
+ . '((?:[-+]|(?=(?)' . PreRelease::wordPattern() . '))[\w.-]+)?/i';
}
/**
diff --git a/src/Module/Version/Comparison.php b/src/Module/Version/Comparison.php
new file mode 100644
index 0000000..79c079b
--- /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 2e1b2ab..e0b23c6 100644
--- a/src/Module/Version/Constraint.php
+++ b/src/Module/Version/Constraint.php
@@ -4,36 +4,32 @@
namespace Internal\DLoad\Module\Version;
-use Composer\Semver\Semver;
-use Composer\Semver\VersionParser;
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
- * - Numbered pre-releases: 3.5.0-beta.1, ^3.5.0-RC2, 1.0.0-nightly20250503
+ * - 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
*
- * 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
*/
final class Constraint implements \Stringable
{
- /** Operators a constraint bound to a pre-release may carry. */
- private const OPERATORS = ['', '=', '==', '^', '~', '>=', '>', '<', '<='];
-
/** @var non-empty-string $versionConstraint Base version constraint (e.g. "^2.12.0") */
public readonly string $versionConstraint;
/**
* @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;
@@ -42,16 +38,14 @@ final class Constraint implements \Stringable
public readonly Stability $minimumStability;
/**
- * @var PreRelease|null $preRelease Numbered pre-release the version constraint is bound to,
- * e.g. `beta.1` of `3.5.0-beta.1`. If null, pre-releases are matched by stability only.
+ * @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;
- /** Operator of a constraint bound to a pre-release, e.g. `^` of `^3.5.0-beta.1`. */
- private readonly string $operator;
-
- /** Version number of a constraint bound to a pre-release, e.g. `3.5.0` of `^3.5.0-beta.1`. */
- private readonly string $bound;
+ /** Versions the constraint accepts, the pre-release included. */
+ private readonly Range|Comparison $matcher;
/**
* @param non-empty-string $origin Original constraint string used for parsing.
@@ -74,27 +68,40 @@ private function __construct(
);
}
- # A numbered pre-release is a part of the version, not a feature suffix: "3.5.0-beta.1" names one
- # release, and "3.5.0-beta.2" or "3.5.0" must not satisfy it. A bare "-beta" stays a stability.
+ # 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;
- $operator = $bound = '';
- $pattern = '/^([~^>=<]*)(\d+(?:\.\d+)*)-((?:' . PreRelease::keywordPattern() . ')[._-]?\d+)$/i';
- if (\preg_match($pattern, $origin, $matches) === 1) {
- [, $operator, $bound, $preReleasePart] = $matches;
- \in_array($operator, self::OPERATORS, true) or throw new \InvalidArgumentException(
- "Unsupported operator `{$operator}` in a constraint with a pre-release.",
- );
-
- $preRelease = PreRelease::fromString($preReleasePart);
- \assert($preRelease !== null);
- $stability ??= $preRelease->stability;
- $origin = $operator . $bound;
+ if (\preg_match(
+ '/^(?:==?)?\s*v?(\d+(?:\.\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;
- $this->operator = $operator;
- $this->bound = $bound;
- [$version, $suffix] = \explode('-', $origin, 2) + [1 => ''];
+ # 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) {
@@ -118,17 +125,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;
@@ -138,18 +141,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
@@ -163,31 +165,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 ($this->preRelease !== null) {
- // A version with a number always has a pre-release
- \assert($version->preRelease !== null);
- if (!$this->satisfiesPreRelease($number, $version->preRelease, $version->suffix)) {
- return false;
- }
- } elseif (!self::satisfies(self::composerNumber($number), $this->versionConstraint)) {
+ 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;
}
}
@@ -206,62 +194,39 @@ public function __toString(): string
}
/**
- * Composer reads four number parts at most, so the rest goes to a patch: `1.2.3.4.5` is `1.2.3.4-p5`.
- * Trailing zero parts past the fourth are dropped, as Composer does for the shorter ones: `1.2.3.4.0` is `1.2.3.4`.
- */
- private static function composerNumber(string $number): string
- {
- $parts = \explode('.', $number);
- while (\count($parts) > 4 && \ltrim(\end($parts), '0') === '') {
- \array_pop($parts);
- }
-
- return \count($parts) > 4
- ? \implode('.', \array_slice($parts, 0, 4)) . '-p' . \implode('.', \array_slice($parts, 4))
- : \implode('.', $parts);
- }
-
- /**
- * A version Composer cannot read, like `20250101.1.2.3`, satisfies no constraint.
- * An unreadable constraint still throws.
+ * 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 satisfies(string $number, string $constraint): bool
+ private static function preReleaseBounds(string $constraint): array
{
- try {
- (new VersionParser())->normalize($number);
- } catch (\UnexpectedValueException) {
- return false;
- }
+ \preg_match_all(
+ '/(?:^|[\s,|])[<>=!~^]*v?\d+(?:\.\d+)*(?:\+\d+)?(' . Range::preReleasePattern() . ')(?=$|[\s,|])/i',
+ $constraint,
+ $matches,
+ );
- return Semver::satisfies($number, $constraint);
+ /** @var list */
+ return $matches[1];
}
/**
- * The pre-release decides only between versions with the number of the bound itself; versions
- * with another number are left to the operator, like for a constraint without a pre-release.
- * A tail after the pre-release orders after it, as in sorting: `3.5.0-RC1-linux` is above `3.5.0-RC1`,
- * so an exact constraint does not match it.
+ * A numeric tail, like `-1` of `1.0.0-1`, is a {@see Stability::Preview}, as {@see Version} reads it.
*
- * @param null|string $suffix Feature suffix of the version
+ * @param non-empty-list $preReleases
*/
- private function satisfiesPreRelease(string $number, PreRelease $preRelease, ?string $suffix): bool
+ private static function lowestStability(array $preReleases): Stability
{
- \assert($this->preRelease !== null);
- $number = self::composerNumber($number);
- if (!self::satisfies($number, '==' . $this->bound)) {
- return self::satisfies($number, $this->versionConstraint);
- }
-
- $order = $preRelease->compare($this->preRelease);
- $order === 0 && $suffix !== null and $order = 1;
+ $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 match ($this->operator) {
- '', '=', '==' => $order === 0,
- '^', '~', '>=' => $order >= 0,
- '>' => $order > 0,
- '<' => $order < 0,
- '<=' => $order <= 0,
- default => throw new \LogicException("Unsupported operator `{$this->operator}`."),
- };
+ return $stabilities[0];
}
}
diff --git a/src/Module/Version/Operator.php b/src/Module/Version/Operator.php
new file mode 100644
index 0000000..3d161d5
--- /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
index 52599d5..daaa084 100644
--- a/src/Module/Version/PreRelease.php
+++ b/src/Module/Version/PreRelease.php
@@ -65,6 +65,18 @@ public static function keywordPattern(): string
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.
*/
diff --git a/src/Module/Version/Range.php b/src/Module/Version/Range.php
new file mode 100644
index 0000000..f6d557e
--- /dev/null
+++ b/src/Module/Version/Range.php
@@ -0,0 +1,260 @@
+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`
+ *
+ * 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+)*(?:\+\d+)?(?:' . self::preReleasePattern() . ')?)';
+ 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+)*)(?:\+\d+)?(' . self::preReleasePattern() . ')?$/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 30d4985..1155a0f 100644
--- a/src/Module/Version/Version.php
+++ b/src/Module/Version/Version.php
@@ -16,7 +16,7 @@ class Version implements \Stringable
protected const VERSION_SEMVER_NUMBER_PATTERN = 'v?(\d+\.\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+(?:\.\d+(?:\.\d+)*(?:\+\d+)?)?)?)((?:[-+.]|(?=[a-z_]))[\w.-]+)?';
+ protected const VERSION_FALLBACK_PATTERN = 'v?(\d+(?:\.\d+)*(?:\+\d+)?)((?:[-+.]|(?=[a-z_]))[\w.-]+)?';
protected const VERSION_HASH_SUFFIX_PATTERN = '(?:#([a-f0-9]{6,40}))?';
@@ -58,7 +58,10 @@ public static function fromVersionString(string $string): static
\assert($number !== '');
$suffix = $parts[2] ?? '';
- $preRelease = $suffix === '' ? null : self::preReleaseFromSuffix($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, '-_.+');
@@ -80,22 +83,35 @@ public static function empty(): static
/**
* 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, a version without one first:
- * `1.0.0-RC1 < 1.0.0-RC1-priority.0 < 1.0.0-RC1-priority.1`.
+ * 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
{
- $byNumber = \version_compare($this->comparableNumber(), $other->comparableNumber());
+ $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);
+ }
- return $byPreRelease !== 0 ? $byPreRelease : \version_compare((string) $this->suffix, (string) $other->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.
+ *
+ * @return int<-1, 1>
+ */
+ public function compareNumber(self $other): int
+ {
+ return \version_compare($this->comparableNumber(), $other->comparableNumber());
}
public function __toString(): string
@@ -112,16 +128,13 @@ public function __toString(): string
*/
private static function preReleaseFromSuffix(string &$input): ?PreRelease
{
- $reg = '[._-]?(?:(' . PreRelease::keywordPattern() . ')([._-]?\d+)?)?';
+ $reg = '[._-]?' . PreRelease::wordPattern();
- /** @var list $parts */
+ /** @var array<0|1, non-empty-string> $parts */
$parts = [];
- \preg_match(('#' . $reg . '$#i'), $input, $match);
- isset($match[1]) and $parts[0] = $match[0];
-
- \preg_match(('#^' . $reg . '#i'), $input, $match);
- isset($match[1]) and $parts[1] = $match[0];
+ \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 => $fullPart) {
$preRelease = PreRelease::fromString(\ltrim($fullPart, '._-'));
@@ -137,14 +150,65 @@ private static function preReleaseFromSuffix(string &$input): ?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,
+ # Digit strings of any length: as integers, long ones would overflow
+ $isNumber => (\strlen($token) <=> \strlen($otherToken)) ?: \strcmp($token, $otherToken) <=> 0,
+ default => \strcmp($token, $otherToken) <=> 0,
+ };
+ if ($byToken !== 0) {
+ return $byToken;
+ }
+ }
+
+ return \count($tokens) <=> \count($otherTokens);
+ }
+
+ /**
+ * 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.
- * Trailing zero parts do not count: `1.2.3`, `1.2.3.0` and `1.2.3.0.0` are one number.
+ * Trailing zero parts do not count: `1.2.3`, `1.2.3.0` and `1.2.3.0.0` are one number,
+ * nor does build metadata, as in SemVer: `1.2.3+5` is `1.2.3`.
*/
private function comparableNumber(): string
{
- $number = (string) $this->number;
+ $number = \explode('+', (string) $this->number, 2)[0];
if ($this->withoutKeyword && $this->suffix !== null && \preg_match('/^\d+(?:\.\d+)*$/', $this->suffix) === 1) {
$number .= '.' . $this->suffix;
}
diff --git a/tests/Unit/Module/Binary/BinaryVersionTest.php b/tests/Unit/Module/Binary/BinaryVersionTest.php
index 06c6f62..6794bd8 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)]
@@ -75,6 +77,30 @@ public static function provideSemanticVersionOutputs(): \Generator
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
yield 'mixed case version string' => [
'VERSION: 5.1.2',
@@ -216,6 +242,44 @@ 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.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/Version/ConstraintTest.php b/tests/Unit/Module/Version/ConstraintTest.php
index 232c0fb..f4ae4da 100644
--- a/tests/Unit/Module/Version/ConstraintTest.php
+++ b/tests/Unit/Module/Version/ConstraintTest.php
@@ -235,6 +235,46 @@ 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'];
}
public static function provideInvalidConstraints(): \Generator
@@ -296,6 +336,14 @@ 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 '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
@@ -410,14 +458,210 @@ public static function provideComparableConstraints(): \Generator
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 version Composer cannot read
+ // 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', 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 bare stability keyword still matches every pre-release of that stability
- yield 'bare stability keyword' => ['3.5.0-beta', 'v3.5.0-beta.3', true];
+ // 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];
+
+ // 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 after build metadata on a numbered one' => ['1.2.3+5-hotfix', '1.2.3-hotfix.2', false];
+ yield 'exact feature build after build metadata' => ['1.2.3+5-hotfix', '1.2.3-hotfix', 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 after build metadata on a release candidate' => ['^1.2.3+5-beta@alpha', '1.2.3-RC1', true];
+
+ // 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
@@ -428,7 +672,18 @@ public static function providePreReleases(): \Generator
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', null, null, Stability::Beta];
+ 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 'build metadata before a pre-release' => ['1.2.3+5-beta.1', '1.2.3+5', Stability::Beta, 1, Stability::Beta];
+ 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')]
@@ -530,4 +785,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/RangeComposerTest.php b/tests/Unit/Module/Version/RangeComposerTest.php
new file mode 100644
index 0000000..64f51c3
--- /dev/null
+++ b/tests/Unit/Module/Version/RangeComposerTest.php
@@ -0,0 +1,88 @@
+', '>=', '<', '<=', '^', '~', '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',
+ ];
+ 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',
+ ];
+
+ /** 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',
+ ];
+
+ 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',
+ ];
+
+ #[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 0000000..59cac13
--- /dev/null
+++ b/tests/Unit/Module/Version/RangeTest.php
@@ -0,0 +1,437 @@
+', '>=', '<', '<='];
+
+ /** 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];
+ }
+
+ 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 '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 9a52577..ea66112 100644
--- a/tests/Unit/Module/Version/VersionTest.php
+++ b/tests/Unit/Module/Version/VersionTest.php
@@ -72,6 +72,17 @@ public static function provideOrderedVersions(): \Generator
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 '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
@@ -82,6 +93,17 @@ public static function provideEqualVersions(): \Generator
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 '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'];
}
/**
@@ -217,6 +239,37 @@ 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(
@@ -383,4 +436,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);
+ }
}