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'; + . '((?:[-+]|(?=(?![ab](?![a-z]|[._-]?\d))' . 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+)*)|(?:[._]|(?![ab](?![a-z]|[._-]?\\d))){$keyword}"; + } + + public function isSatisfiedBy(Version $version): bool + { + foreach ($this->groups as $comparisons) { + foreach ($comparisons as $comparison) { + if (!$comparison->isSatisfiedBy($version)) { + continue 2; + } + } + + return true; + } + + return false; + } + + /** + * @return list + */ + private static function parseGroup(string $group, string $constraint): array + { + $group === '' and throw new \InvalidArgumentException("Empty alternative in the version constraint `{$constraint}`."); + + $version = '(v?\d+(?:\.\d+)*(?:\+\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); + } }