From c2a54945bdb0fae42704655736bcc882b3594217 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 19:30:01 +0400 Subject: [PATCH 1/3] fix(version): match a constraint with a numbered pre-release fix(repository): sort pre-releases by stability weight and number docs: show pre-release constraints A constraint like `3.5.0-beta.1` took `beta.1` for a feature suffix, which no parsed version carries, so it matched nothing. A pre-release is now a stability with its number, ordered by the same `Stability` weights the minimum-stability filter uses, so every stability keyword works, `beta.1` matches neither `beta.2` nor the final release, and ranges like `^3.5.0-beta.1` work. Sorting releases uses the same order, which also fixes spellings like `rc.1` that the old string replacement missed. Assisted-By: Claude Opus 5.5 --- README-es.md | 5 + README-ru.md | 5 + README-zh.md | 5 + README.md | 5 + skills/dload-fetch-tool/SKILL.md | 2 +- .../Collection/ReleasesCollection.php | 24 +--- src/Module/Version/Constraint.php | 65 ++++++++++- src/Module/Version/PreRelease.php | 97 +++++++++++++++++ src/Module/Version/Version.php | 95 ++++++++++------ .../Repository/ReleasesCollectionTest.php | 44 ++++++++ tests/Unit/Module/Version/ConstraintTest.php | 90 +++++++++++++++ tests/Unit/Module/Version/PreReleaseTest.php | 103 ++++++++++++++++++ tests/Unit/Module/Version/VersionTest.php | 70 ++++++++++++ 13 files changed, 549 insertions(+), 61 deletions(-) create mode 100644 src/Module/Version/PreRelease.php create mode 100644 tests/Unit/Module/Version/PreReleaseTest.php diff --git a/README-es.md b/README-es.md index 98bb8a54..0cf5108d 100644 --- a/README-es.md +++ b/README-es.md @@ -332,6 +332,11 @@ Usa restricciones de versión estilo Composer: + + + + + diff --git a/README-ru.md b/README-ru.md index 0db71419..717960d3 100644 --- a/README-ru.md +++ b/README-ru.md @@ -333,6 +333,11 @@ DLoad поддерживает три типа загрузки, которые + + + + + diff --git a/README-zh.md b/README-zh.md index c8071243..898e7feb 100644 --- a/README-zh.md +++ b/README-zh.md @@ -332,6 +332,11 @@ DLoad 支持三种下载类型,它们决定了资源的处理方式: + + + + + diff --git a/README.md b/README.md index 7c328f74..13dd8560 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,11 @@ Use Composer-style version constraints: + + + + + diff --git a/skills/dload-fetch-tool/SKILL.md b/skills/dload-fetch-tool/SKILL.md index f71944db..c6bb80f5 100644 --- a/skills/dload-fetch-tool/SKILL.md +++ b/skills/dload-fetch-tool/SKILL.md @@ -45,7 +45,7 @@ Done when the block has a `` whose `asset-pattern` matches every OS/ | Attribute | Purpose | |---|---| | `software` | Alias or name of the tool (built-in or inline). Required. | -| `version` | Composer-style constraint: `^2025.1`, `~1.0.0`, `^2.12.0@beta`, `^2.12.0-hotfix@rc`. Omit for latest stable. Stability order follows Composer, `stable` by default. | +| `version` | Composer-style constraint: `^2025.1`, `~1.0.0`, `^2.12.0@beta`, `^2.12.0-hotfix@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. | | `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/src/Module/Repository/Collection/ReleasesCollection.php b/src/Module/Repository/Collection/ReleasesCollection.php index 97461d12..c86bd578 100644 --- a/src/Module/Repository/Collection/ReleasesCollection.php +++ b/src/Module/Repository/Collection/ReleasesCollection.php @@ -77,11 +77,7 @@ public function sortByVersion(): self { $result = \iterator_to_array($this->getIterator()); - $sort = function (ReleaseInterface $a, ReleaseInterface $b): int { - return \version_compare($this->comparisonVersionString($b), $this->comparisonVersionString($a)); - }; - - \uasort($result, $sort); + \uasort($result, static fn(ReleaseInterface $a, ReleaseInterface $b): int => $b->getVersion()->compare($a->getVersion())); return new self($result); } @@ -120,22 +116,4 @@ public function minimumStability(Stability $stability): self static fn(ReleaseInterface $release): bool => $release->getVersion()->stability->getWeight() >= $weight, ); } - - /** - * Converts version string to a format suitable for comparison. - * - * Normalizes version strings to handle stability suffixes properly. - * - * @return non-empty-string Normalized version string - */ - private function comparisonVersionString(ReleaseInterface $release): string - { - $stability = $release->getVersion()->stability; - - return \ltrim(\str_replace( - '-' . $stability->value, - '.' . $stability->getWeight() . '.', - $release->getVersion()->string, - ), 'v'); - } } diff --git a/src/Module/Version/Constraint.php b/src/Module/Version/Constraint.php index 55ca5575..f9588db0 100644 --- a/src/Module/Version/Constraint.php +++ b/src/Module/Version/Constraint.php @@ -16,6 +16,7 @@ * * Explicit: ^2.12.0@beta, ~1.20.0@stable * * Implicit: ^2.12.0-beta, ~1.20.0-stable * - Combined constraints: ^2.12.0-feature@beta + * - Numbered pre-releases: 3.5.0-beta.1, ^3.5.0-RC2, 1.0.0-nightly20250503 * * Stability keywords (from Stability enum) as suffixes are automatically * converted to stability constraints. @@ -24,6 +25,9 @@ */ 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; @@ -36,6 +40,18 @@ final class Constraint implements \Stringable /** @var Stability $minimumStability Minimum stability level for this constraint */ 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. + */ + 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; + /** * @param non-empty-string $origin Original constraint string used for parsing. */ @@ -57,6 +73,26 @@ 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. + $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; + } + $this->preRelease = $preRelease; + $this->operator = $operator; + $this->bound = $bound; + [$version, $suffix] = \explode('-', $origin, 2) + [1 => '']; if ($suffix !== '') { // Check if suffix is a stability keyword (only if no explicit stability provided) @@ -112,6 +148,7 @@ private function __construct( * - "^2.12.0-beta" -> baseVersion: "^2.12.0", featureSuffix: null, minimumStability: Beta (auto-converted) * - "^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 + * - "3.5.0-beta.1" -> baseVersion: "3.5.0", preRelease: beta.1, minimumStability: Beta * * @param string $constraint Version constraint string * @return self Parsed version constraint @@ -137,7 +174,11 @@ public function isSatisfiedBy(Version $version): ?bool } // Check if a version satisfies the base version constraint - if (Semver::satisfies($number, $this->versionConstraint) === false) { + if ($this->preRelease !== null) { + if ($version->preRelease === null || !$this->satisfiesPreRelease($this->preRelease, $number, $version->preRelease)) { + return false; + } + } elseif (Semver::satisfies($number, $this->versionConstraint) === false) { return false; } @@ -160,4 +201,26 @@ public function __toString(): string { return $this->origin; } + + /** + * 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. + */ + private function satisfiesPreRelease(PreRelease $required, string $number, PreRelease $preRelease): bool + { + if (!Semver::satisfies($number, '==' . $this->bound)) { + return Semver::satisfies($number, $this->versionConstraint); + } + + $order = $preRelease->compare($required); + + return match ($this->operator) { + '', '=', '==' => $order === 0, + '^', '~', '>=' => $order >= 0, + '>' => $order > 0, + '<' => $order < 0, + '<=' => $order <= 0, + default => throw new \LogicException("Unsupported operator `{$this->operator}`."), + }; + } } diff --git a/src/Module/Version/PreRelease.php b/src/Module/Version/PreRelease.php new file mode 100644 index 00000000..0382cd70 --- /dev/null +++ b/src/Module/Version/PreRelease.php @@ -0,0 +1,97 @@ +compare(PreRelease::fromString('RC1')); // -1 + * ``` + * + * @internal + */ +final class PreRelease implements \Stringable +{ + /** + * @param int<0, max> $number Zero when the stability carries no number, like `beta`. + */ + public function __construct( + public readonly Stability $stability, + public readonly int $number = 0, + ) {} + + /** + * Parses a stability keyword with an optional number: `beta.1`, `RC2`, `rc-3`, `b4`, `nightly20250503`. + * + * @return self|null Null when the text is not a stability keyword. + */ + public static function fromString(string $value): ?self + { + if (\preg_match('/^([a-z]+)[._-]?(\d*)$/i', $value, $matches) !== 1) { + return null; + } + + $stability = self::stability($matches[1]); + if ($stability === null) { + return null; + } + + /** @var int<0, max> $number */ + $number = (int) $matches[2]; + + return new self($stability, $number); + } + + /** + * Resolves a stability keyword, including the `a` and `b` abbreviations of alpha and beta. + */ + public static function stability(string $keyword): ?Stability + { + return Stability::fromString($keyword) ?? match (\strtolower($keyword)) { + 'a' => Stability::Alpha, + 'b' => Stability::Beta, + default => null, + }; + } + + /** + * Regular expression alternation of every keyword {@see stability()} accepts. + * + * @return non-empty-string + */ + public static function keywordPattern(): string + { + $keywords = [...\array_column(Stability::cases(), 'value'), 'a', 'b']; + // Longer keywords first, so `preview` is not read as `pre` followed by garbage + \usort($keywords, static fn(string $a, string $b): int => \strlen($b) <=> \strlen($a)); + + return \implode('|', \array_map(static fn(string $keyword): string => \preg_quote($keyword, '/'), $keywords)); + } + + /** + * @return int<-1, 1> Negative when this pre-release is less stable or older than the other one. + */ + public function compare(self $other): int + { + return [$this->stability->getWeight(), $this->number] <=> [$other->stability->getWeight(), $other->number]; + } + + public function equals(self $other): bool + { + return $this->compare($other) === 0; + } + + public function __toString(): string + { + return $this->number === 0 ? $this->stability->value : $this->stability->value . '.' . $this->number; + } +} diff --git a/src/Module/Version/Version.php b/src/Module/Version/Version.php index f30c9dc9..8c61623e 100644 --- a/src/Module/Version/Version.php +++ b/src/Module/Version/Version.php @@ -20,7 +20,9 @@ class Version implements \Stringable /** * @param string $string Source of the version string (e.g., 1.2.3-beta-feature) * @param null|non-empty-string $number Parsed version number - * @param null|non-empty-string $suffix Stability and feature suffix + * @param null|non-empty-string $suffix Feature suffix: the suffix without the stability part + * @param null|PreRelease $preRelease Stability with its number, e.g. `beta.1` of `1.2.3-beta.1`; + * numbered 0 when the version carries none. Null only for an empty version. */ final protected function __construct( public readonly string $string, @@ -28,6 +30,7 @@ final protected function __construct( public readonly ?string $suffix = null, public readonly ?Stability $stability = null, public readonly ?string $hash = null, + public readonly ?PreRelease $preRelease = null, ) {} /** @@ -50,20 +53,18 @@ public static function fromVersionString(string $string): static \assert($number !== ''); $suffix = $parts[2] ?? ''; - $stability = null; - if ($suffix !== '') { - $stability = self::stabilityFromSuffix($suffix); - } + $preRelease = $suffix === '' ? null : self::preReleaseFromSuffix($suffix); $suffix = \trim($suffix, '-_.+'); $suffix === '' and $suffix = null; - $stability ??= ($suffix === null ? Stability::Stable : Stability::Preview); + $stability = $preRelease?->stability ?? ($suffix === null ? Stability::Stable : Stability::Preview); + $preRelease ??= new PreRelease($stability); $hash = $parts[3] ?? null; $hash === '' and $hash = null; - return new static($string, $number, $suffix, $stability, $hash); + return new static($string, $number, $suffix, $stability, $hash, $preRelease); } public static function empty(): static @@ -71,61 +72,83 @@ public static function empty(): static return new 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: `1.0.0-RC1-priority.0 < 1.0.0-RC1-priority.1`. + * A version without a number comes first. + * + * @return int<-1, 1> + */ + public function compare(self $other): int + { + $byNumber = \version_compare($this->comparableNumber(), $other->comparableNumber()); + if ($byNumber !== 0 || $this->preRelease === null || $other->preRelease === null) { + return $byNumber; + } + + $byPreRelease = $this->preRelease->compare($other->preRelease); + + return $byPreRelease !== 0 ? $byPreRelease : \version_compare((string) $this->suffix, (string) $other->suffix); + } + public function __toString(): string { return $this->string; } /** - * Extracts the stability from the version suffix. + * Cuts the stability part off the version suffix. * - * @param non-empty-string $input Version string with suffix - * @return null|Stability Stability level or null if not found + * @param non-empty-string $input Version suffix; the stability part is removed from it + * @param-out string $input + * @return null|PreRelease Stability with its number, or null if the suffix has no stability part */ - private static function stabilityFromSuffix(string &$input): ?Stability + private static function preReleaseFromSuffix(string &$input): ?PreRelease { if (\str_starts_with('x-dev', \strtolower($input))) { $input = \substr($input, \strlen('x-dev')); - return Stability::Dev; + return new PreRelease(Stability::Dev); } - // if (\preg_match('{^dev[-_.]}', $input) || \preg_match('{[-_.]dev$}', $input)) { - // return Stability::Dev; - // } - - $mods = \implode('|', \array_column(Stability::cases(), 'value')) . '|b|a'; - $reg = "[._-]?(?:($mods)([.-]?\d+)?)?"; + $reg = '[._-]?(?:(' . PreRelease::keywordPattern() . ')([._-]?\d+)?)?'; - /** @var list $parts */ + /** @var list $parts */ $parts = []; \preg_match(('#' . $reg . '$#i'), $input, $match); - isset($match[1]) and $parts[0] = [$match[1], $match[0]]; + isset($match[1]) and $parts[0] = $match[0]; \preg_match(('#^' . $reg . '#i'), $input, $match); - isset($match[1]) and $parts[1] = [$match[1], $match[0]]; + isset($match[1]) and $parts[1] = $match[0]; - if ($parts === []) { - return null; - } - - foreach ($parts as $k => [$part, $fullPart]) { - $isPrefix = $k === 1; - $part = \strtolower($part); - $stability = Stability::fromString($part) ?? match ($part) { - 'a' => Stability::Alpha, - 'b' => Stability::Beta, - default => null, - }; - if ($stability !== null) { - $input = $isPrefix + foreach ($parts as $k => $fullPart) { + $preRelease = PreRelease::fromString(\ltrim($fullPart, '._-')); + if ($preRelease !== null) { + $input = $k === 1 ? \substr($input, \strlen($fullPart)) : \substr($input, 0, -\strlen($fullPart)); - return $stability; + return $preRelease; } } return null; } + + /** + * The number keeps three parts at most, so the rest of a longer one, like `.4` of `1.2.3.4`, + * lands in the suffix; for ordering it is a part of the number again. Only without a stability + * keyword: the `.2` of `2.0.0-beta.1.2` belongs to the pre-release. + */ + private function comparableNumber(): string + { + $number = (string) $this->number; + $withoutKeyword = $this->preRelease !== null + && $this->preRelease->stability === Stability::Preview + && $this->preRelease->number === 0; + + return $withoutKeyword && $this->suffix !== null && \preg_match('/^\d+(?:\.\d+)*$/', $this->suffix) === 1 + ? $number . '.' . $this->suffix + : $number; + } } diff --git a/tests/Unit/Module/Repository/ReleasesCollectionTest.php b/tests/Unit/Module/Repository/ReleasesCollectionTest.php index dbaa534b..579afd7d 100644 --- a/tests/Unit/Module/Repository/ReleasesCollectionTest.php +++ b/tests/Unit/Module/Repository/ReleasesCollectionTest.php @@ -111,6 +111,50 @@ public function sortByVersionSortsReleasesByVersionDescending(): void ]); } + #[Test] + public function sortByVersionOrdersPreReleasesOfOneVersion(): void + { + $names = ['2.0.0-beta.2', '2.0.0-rc.1', '1.9.0', '2.0.0', '2.0.0-nightly5', '2.0.0-beta.10', '2.0.0-RC2', '2.0.0-preview1']; + $collection = new ReleasesCollection(\array_map( + fn(string $name): ReleaseStub => new ReleaseStub($this->repository, $name, Version::fromVersionString($name)), + $names, + )); + + $versions = $this->getVersionsFromCollection($collection->sortByVersion()); + + Assert::same(\array_values($versions), [ + '2.0.0', + '2.0.0-RC2', + '2.0.0-rc.1', + '2.0.0-beta.10', + '2.0.0-beta.2', + '2.0.0-preview1', + '2.0.0-nightly5', + '1.9.0', + ]); + } + + #[Test] + public function sortByVersionOrdersWhatFollowsThePreRelease(): void + { + $names = ['1.3.1-RC1-priority.0', '1.2.3.4', '1.3.1-RC1-priority.1', '1.2.3', '1.2.3.10', '1.2.3.9']; + $collection = new ReleasesCollection(\array_map( + fn(string $name): ReleaseStub => new ReleaseStub($this->repository, $name, Version::fromVersionString($name)), + $names, + )); + + $versions = $this->getVersionsFromCollection($collection->sortByVersion()); + + Assert::same(\array_values($versions), [ + '1.3.1-RC1-priority.1', + '1.3.1-RC1-priority.0', + '1.2.3.10', + '1.2.3.9', + '1.2.3.4', + '1.2.3', + ]); + } + #[Test] public function chainedFiltersWorkCorrectly(): void { diff --git a/tests/Unit/Module/Version/ConstraintTest.php b/tests/Unit/Module/Version/ConstraintTest.php index e4d45025..f2505318 100644 --- a/tests/Unit/Module/Version/ConstraintTest.php +++ b/tests/Unit/Module/Version/ConstraintTest.php @@ -305,6 +305,64 @@ public static function provideComparableConstraints(): \Generator yield ['^1.0-priority@rc', '1.3.1-RC1-priority.0', true]; yield ['^1.0-priority@RC', '1.3.1-RC1-priority.0', true]; yield ['^1.0-priority@RC', '1.3.1-priority.0', false]; + + // A numbered pre-release names one release + yield 'exact pre-release with explicit stability' => ['3.5.0-beta.1@beta', 'v3.5.0-beta.1', true]; + yield 'exact pre-release with implicit stability' => ['3.5.0-beta.1', 'v3.5.0-beta.1', true]; + yield 'exact release candidate' => ['3.5.0-RC1', 'v3.5.0-RC1', true]; + yield 'exact dotted release candidate' => ['2025.1.0-rc.2@rc', 'v2025.1.0-rc.2', true]; + yield 'another spelling of the release' => ['3.5.0-rc.1', 'v3.5.0-RC1', true]; + yield 'beta abbreviation' => ['1.0.0-b2', 'v1.0.0-beta.2', true]; + yield 'exact pre' => ['2.0.0-pre.3', 'v2.0.0-pre3', true]; + yield 'exact preview' => ['2.0.0-preview5', 'v2.0.0-preview5', true]; + yield 'exact unstable' => ['2.0.0-unstable2', 'v2.0.0-unstable.2', true]; + yield 'exact snapshot' => ['2.0.0-snapshot1', 'v2.0.0-snapshot1', true]; + yield 'exact nightly' => ['1.0.0-nightly20250503', 'v1.0.0-nightly20250503', true]; + yield 'another nightly' => ['1.0.0-nightly20250503', 'v1.0.0-nightly20250504', false]; + yield 'another pre-release number' => ['3.5.0-beta.1@beta', 'v3.5.0-beta.2', false]; + yield 'another stability with the same number' => ['3.5.0-beta.1', 'v3.5.0-alpha.1', false]; + yield 'final release of a pre-release' => ['3.5.0-beta.1@beta', 'v3.5.0', false]; + yield 'another version number' => ['3.5.0-beta.1', 'v3.5.1-beta.1', false]; + yield 'pre-release below the minimum stability' => ['3.5.0-beta.1@stable', 'v3.5.0-beta.1', false]; + yield 'feature build of the version' => ['3.5.0-beta.1', 'v3.5.0-custom', false]; + yield 'underscore spelling in the tag' => ['1.0.0-alpha_2', 'v1.0.0-alpha_2', true]; + yield 'explicit stability looser than the pre-release' => ['3.5.0-RC1@beta', 'v3.5.0-RC1', true]; + yield 'explicit stability stricter than the pre-release' => ['3.5.0-beta.1@rc', 'v3.5.0-beta.1', false]; + + // A pre-release bound of a range + yield 'caret above the bound' => ['^3.5.0-beta.1@beta', 'v3.5.0-rc.1', true]; + yield 'caret on the final release' => ['^3.5.0-beta.1@beta', 'v3.5.0', true]; + yield 'caret up to the next major' => ['^3.5.0-beta.1@beta', 'v3.6.0', true]; + yield 'caret below the bound' => ['^3.5.0-beta.1@beta', 'v3.5.0-alpha.9', false]; + yield 'caret past the next major' => ['^3.5.0-beta.1@beta', 'v4.0.0', false]; + yield 'caret on an older version' => ['^3.5.0-beta.1@beta', 'v3.4.9', false]; + yield 'tilde on a later patch' => ['~3.5.0-rc.1', 'v3.5.4', true]; + yield 'tilde past the minor' => ['~3.5.0-rc.1', 'v3.6.0', false]; + yield 'at least the bound' => ['>=3.5.0-beta.2@beta', 'v3.5.0-beta.2', true]; + yield 'below at least the bound' => ['>=3.5.0-beta.2@beta', 'v3.5.0-beta.1', false]; + yield 'above the bound' => ['>3.5.0-beta.1@beta', 'v3.5.0-beta.2', true]; + yield 'equal to above the bound' => ['>3.5.0-beta.1@beta', 'v3.5.0-beta.1', false]; + yield 'below the bound' => ['<3.5.0-beta.2@beta', 'v3.5.0-beta.1', true]; + yield 'equal to below the bound' => ['<3.5.0-beta.2@beta', 'v3.5.0-beta.2', false]; + yield 'older version below the bound' => ['<3.5.0-beta.2@beta', 'v3.4.0', true]; + yield 'final release below its pre-release' => ['<3.5.0-beta.2@beta', 'v3.5.0', false]; + yield 'at most the bound' => ['<=3.5.0-beta.2@beta', 'v3.5.0-beta.2', true]; + yield 'explicit equality' => ['==3.5.0-rc.1', 'v3.5.0-RC1', true]; + yield 'nightly range' => ['>=1.0.0-nightly20250503@nightly', 'v1.0.0-nightly20250601', true]; + + // 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]; + } + + public static function providePreReleases(): \Generator + { + yield 'dotted beta' => ['3.5.0-beta.1', '3.5.0', Stability::Beta, 1, Stability::Beta]; + yield 'release candidate' => ['3.5.0-RC1', '3.5.0', Stability::RC, 1, Stability::RC]; + yield 'beta abbreviation' => ['3.5.0-b2', '3.5.0', Stability::Beta, 2, Stability::Beta]; + yield 'nightly' => ['1.0.0-nightly20250503', '1.0.0', Stability::Nightly, 20250503, Stability::Nightly]; + yield 'explicit stability wins' => ['3.5.0-alpha-2@dev', '3.5.0', Stability::Alpha, 2, Stability::Dev]; + yield 'range' => ['^3.5.0-rc.2', '^3.5.0', Stability::RC, 2, Stability::RC]; + yield 'bare stability keyword' => ['3.5.0-beta', '3.5.0', null, null, Stability::Beta]; } #[DataProvider('provideValidConstraints')] @@ -363,6 +421,38 @@ public function toStringWithFeatureSuffixAndCustomStability(): void Assert::same($result, '^2.12.0-feature@beta'); } + #[DataProvider('providePreReleases')] + #[Test] + public function numberedPreReleaseIsAPartOfTheVersion( + string $constraint, + string $expectedBaseVersion, + ?Stability $expectedPreReleaseStability, + ?int $expectedPreReleaseNumber, + Stability $expectedStability, + ): void { + $result = Constraint::fromConstraintString($constraint); + + Assert::same($result->versionConstraint, $expectedBaseVersion); + Assert::same($result->preRelease?->stability, $expectedPreReleaseStability); + Assert::same($result->preRelease?->number, $expectedPreReleaseNumber); + Assert::same($result->minimumStability, $expectedStability); + $expectedPreReleaseStability === null or Assert::null($result->featureSuffix); + } + + #[Test] + public function rejectsAnUnknownOperatorWithAPreRelease(): never + { + Expect::exception(\InvalidArgumentException::class)->withMessageContaining('Unsupported operator `=>`'); + + Constraint::fromConstraintString('=>3.5.0-beta.1'); + } + + #[Test] + public function toStringKeepsThePreRelease(): void + { + Assert::same((string) Constraint::fromConstraintString('^3.5.0-beta.1@beta'), '^3.5.0-beta.1@beta'); + } + #[DataProvider('provideComparableConstraints')] #[Test] public function isSatisfiedBy(string $constraint, string $version, bool $expected): void diff --git a/tests/Unit/Module/Version/PreReleaseTest.php b/tests/Unit/Module/Version/PreReleaseTest.php new file mode 100644 index 00000000..c4495245 --- /dev/null +++ b/tests/Unit/Module/Version/PreReleaseTest.php @@ -0,0 +1,103 @@ + ['beta.1', Stability::Beta, 1]; + yield 'upper case release candidate' => ['RC1', Stability::RC, 1]; + yield 'dashed release candidate' => ['rc-3', Stability::RC, 3]; + yield 'underscored alpha' => ['alpha_2', Stability::Alpha, 2]; + yield 'beta abbreviation' => ['b4', Stability::Beta, 4]; + yield 'alpha abbreviation' => ['A5', Stability::Alpha, 5]; + yield 'pre' => ['pre.3', Stability::Pre, 3]; + yield 'preview' => ['preview5', Stability::Preview, 5]; + yield 'unstable' => ['unstable7', Stability::Unstable, 7]; + yield 'dev' => ['dev.2', Stability::Dev, 2]; + yield 'snapshot' => ['snapshot1', Stability::Snapshot, 1]; + yield 'nightly date' => ['nightly20250503', Stability::Nightly, 20250503]; + yield 'no number' => ['beta', Stability::Beta, 0]; + } + + public static function provideInvalidPreReleases(): \Generator + { + yield 'empty' => ['']; + yield 'feature' => ['feature']; + yield 'number first' => ['1beta']; + yield 'two numbers' => ['beta.1.2']; + yield 'letters after the number' => ['beta1x']; + yield 'two separators' => ['beta..1']; + } + + public static function provideSpellings(): \Generator + { + yield 'dot and none' => ['rc.1', 'RC1']; + yield 'dash and dot' => ['beta-2', 'beta.2']; + yield 'abbreviation and keyword' => ['b3', 'beta.3']; + yield 'leading zero' => ['alpha.01', 'alpha1']; + yield 'no number and zero' => ['beta', 'beta.0']; + } + + #[DataProvider('provideValidPreReleases')] + #[Test] + public function fromStringReadsTheStabilityAndTheNumber(string $value, Stability $stability, int $number): void + { + $preRelease = PreRelease::fromString($value); + + Assert::same($preRelease?->stability, $stability); + Assert::same($preRelease?->number, $number); + } + + #[DataProvider('provideInvalidPreReleases')] + #[Test] + public function fromStringRejectsAnythingButAStabilityWithANumber(string $value): void + { + Assert::null(PreRelease::fromString($value)); + } + + #[DataProvider('provideSpellings')] + #[Test] + public function spellingDoesNotMatter(string $a, string $b): void + { + Assert::true(PreRelease::fromString($a)?->equals(PreRelease::fromString($b)) ?? false); + } + + #[Test] + public function ordersByStabilityWeightThenByNumber(): void + { + $ordered = [ + 'nightly20250503', 'snapshot1', 'dev.1', 'unstable1', 'alpha.1', 'alpha.10', + 'preview1', 'beta', 'beta.1', 'beta.2', 'beta.10', 'pre.1', 'RC1', 'rc.2', 'stable', + ]; + $shuffled = [ + 'beta.10', 'stable', 'alpha.1', 'nightly20250503', 'RC1', 'beta', 'dev.1', 'pre.1', + 'beta.2', 'snapshot1', 'rc.2', 'preview1', 'alpha.10', 'unstable1', 'beta.1', + ]; + + $preReleases = \array_map(static fn(string $value): PreRelease => PreRelease::fromString($value), $shuffled); + \usort($preReleases, static fn(PreRelease $a, PreRelease $b): int => $a->compare($b)); + + $expected = \array_map(static fn(string $value): string => (string) PreRelease::fromString($value), $ordered); + Assert::same(\array_map(\strval(...), $preReleases), $expected); + } + + #[Test] + public function stringsToTheCanonicalSpelling(): void + { + Assert::same((string) PreRelease::fromString('RC-1'), 'RC.1'); + Assert::same((string) PreRelease::fromString('b2'), 'beta.2'); + Assert::same((string) PreRelease::fromString('beta'), 'beta'); + } +} diff --git a/tests/Unit/Module/Version/VersionTest.php b/tests/Unit/Module/Version/VersionTest.php index 23f50c57..64fb31d9 100644 --- a/tests/Unit/Module/Version/VersionTest.php +++ b/tests/Unit/Module/Version/VersionTest.php @@ -15,6 +15,41 @@ #[Covers(Version::class)] final class VersionTest { + public static function providePreReleases(): \Generator + { + yield 'dotted beta' => ['v3.5.0-beta.1', Stability::Beta, 1]; + yield 'release candidate' => ['v3.5.0-RC1', Stability::RC, 1]; + yield 'dotted release candidate' => ['2025.1.0-rc.2', Stability::RC, 2]; + yield 'nightly date' => ['1.0.0-nightly20250503', Stability::Nightly, 20250503]; + yield 'beta abbreviation' => ['1.0.0b2', Stability::Beta, 2]; + yield 'no separator' => ['2.0.0rc1', Stability::RC, 1]; + yield 'underscore separator' => ['1.0.0-rc_1', Stability::RC, 1]; + yield 'bare stability' => ['1.2.3-beta', Stability::Beta, 0]; + yield 'stability before a feature' => ['1.2.3-beta.3-feature', Stability::Beta, 3]; + yield 'dev branch' => ['v2.0.x-dev', Stability::Dev, 0]; + yield 'stable release' => ['v3.5.0', Stability::Stable, 0]; + yield 'feature only' => ['1.2.3-custom', Stability::Preview, 0]; + } + + public static function provideOrderedVersions(): \Generator + { + yield 'pre-release numbers' => ['1.0.0-beta.2', '1.0.0-beta.10']; + yield 'stability weight' => ['1.0.0-beta.10', '1.0.0-RC1']; + yield 'final after its release candidate' => ['1.0.0-rc.9', '1.0.0']; + yield 'nightly first' => ['1.0.0-nightly20250503', '1.0.0-alpha.1']; + yield 'version number first' => ['1.0.0', '1.0.1-alpha.1']; + yield 'feature build before the final' => ['1.0.0-custom', '1.0.0']; + yield 'upper case release candidate before the final' => ['1.0.0-RC1', '1.0.0']; + yield 'underscore spelling' => ['1.0.0-rc_1', '1.0.0-rc_2']; + yield 'fourth number part' => ['1.2.3.4', '1.2.3.5']; + yield 'fourth number part over ten' => ['1.2.3.9', '1.2.3.10']; + yield 'three parts before four' => ['1.2.3', '1.2.3.4']; + yield 'four parts before the next patch' => ['1.2.3.4', '1.2.4']; + yield 'feature build number' => ['1.3.1-RC1-priority.0', '1.3.1-RC1-priority.1']; + yield 'number after the pre-release' => ['2.0.0-beta.1.2', '2.0.0-beta.1.3']; + yield 'number after the pre-release before the final' => ['2.0.0-beta.1.2', '2.0.0']; + } + /** * Provides test cases for successful version string parsing. */ @@ -35,6 +70,7 @@ public static function provideValidVersionStrings(): \Generator // Versions with feature suffixes yield 'version with feature suffix after stability' => ['1.2.3-beta-feature', '1.2.3-beta-feature', '1.2.3', 'feature', Stability::Beta]; yield 'version with stability at end' => ['1.2.3-feature-beta', '1.2.3-feature-beta', '1.2.3', 'feature', Stability::Beta]; + yield 'preview is not read as pre' => ['1.0.0-preview-foo', '1.0.0-preview-foo', '1.0.0', 'foo', Stability::Preview]; yield 'version with multiple features and stability' => ['2.0.0-feature1-feature2-alpha', '2.0.0-feature1-feature2-alpha', '2.0.0', 'feature1-feature2', Stability::Alpha]; // Versions with plus prefix @@ -129,6 +165,40 @@ public static function provideVersionsAndExpectedStability(): \Generator yield 'real case 3' => ['v1.3.0', Stability::Stable, 'Stable version']; } + #[DataProvider('providePreReleases')] + #[Test] + public function preReleaseKeepsTheStabilityWithItsNumber(string $input, Stability $stability, int $number): void + { + $version = Version::fromVersionString($input); + + Assert::same($version->preRelease?->stability, $stability); + Assert::same($version->preRelease?->number, $number); + Assert::same($version->stability, $stability); + } + + #[DataProvider('provideOrderedVersions')] + #[Test] + public function compareOrdersByNumberThenByPreRelease(string $older, string $newer): void + { + $a = Version::fromVersionString($older); + $b = Version::fromVersionString($newer); + + Assert::same($a->compare($b), -1); + Assert::same($b->compare($a), 1); + } + + #[Test] + public function compareTreatsSpellingsOfOnePreReleaseAsEqual(): void + { + Assert::same(Version::fromVersionString('v3.5.0-rc.1')->compare(Version::fromVersionString('3.5.0-RC1')), 0); + } + + #[Test] + public function emptyVersionComesFirst(): void + { + Assert::same(Version::empty()->compare(Version::fromVersionString('0.0.1')), -1); + } + /** * Tests that valid version strings are parsed correctly. */ From 8905861703acb259d539f8ebb23eaf59445c52e2 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 11:19:20 +0400 Subject: [PATCH 2/3] feat(version): match constraints with DLoad's own version ordering (#155) feat(version): support hyphen ranges, `!=`/`<>`, a `v` prefix and constraints with five or more number parts fix(version): read a version number with every dotted part, so `1.2.3.4` is a stable release, not a preview of `1.2.3` fix(version): ignore trailing zero parts when comparing versions fix(version): read a stability keyword only as a whole word, so `-arm64` or `-build.5` is not alpha or beta, and `1.1.1b` is a patch letter fix(version): match an exact version with a pre-release or suffix as that one tag fix(version): read a bare stability keyword in a range as a bound, so `>2.0-beta` takes `2.0.0` fix(version): read a pre-release glued to the number of a constraint, like `^2.0.0rc1`, as a pre-release fix(version): match a feature suffix in any case fix(binary): read every dotted part of the version a binary prints Constraints are matched by DLoad's own code instead of composer/semver, so matching and release sorting share one order. The Composer syntax keeps its meaning; a malformed constraint now fails when parsed. A pre-release in a constraint sets the minimum stability for any operator, and a tail after it orders right after it. composer/semver moves to require-dev. Closes #153 Closes #154 Assisted-By: Claude Opus 5.5 --- README-es.md | 6 + README-ru.md | 6 + README-zh.md | 6 + README.md | 6 + composer.json | 2 +- composer.lock | 156 +++---- dload.xsd | 5 +- skills/dload-fetch-tool/SKILL.md | 2 +- .../references/registry-entry.md | 2 +- .../references/troubleshooting.md | 2 + src/Module/Binary/BinaryVersion.php | 20 +- .../Collection/ReleasesCollection.php | 5 +- src/Module/Version/Comparison.php | 40 ++ src/Module/Version/Constraint.php | 162 +++---- src/Module/Version/Operator.php | 49 ++ src/Module/Version/PreRelease.php | 36 +- src/Module/Version/Range.php | 260 +++++++++++ src/Module/Version/Version.php | 124 +++-- .../Unit/Module/Binary/BinaryVersionTest.php | 138 ++++++ .../Repository/ReleasesCollectionTest.php | 14 + tests/Unit/Module/Version/ConstraintTest.php | 355 +++++++++++++- .../Unit/Module/Version/RangeComposerTest.php | 88 ++++ tests/Unit/Module/Version/RangeTest.php | 437 ++++++++++++++++++ tests/Unit/Module/Version/VersionTest.php | 176 ++++++- 24 files changed, 1868 insertions(+), 229 deletions(-) create mode 100644 src/Module/Version/Comparison.php create mode 100644 src/Module/Version/Operator.php create mode 100644 src/Module/Version/Range.php create mode 100644 tests/Unit/Module/Version/RangeComposerTest.php create mode 100644 tests/Unit/Module/Version/RangeTest.php diff --git a/README-es.md b/README-es.md index 0cf5108d..6d128ec3 100644 --- a/README-es.md +++ b/README-es.md @@ -329,6 +329,10 @@ Usa restricciones de versión estilo Composer: + + + + @@ -343,6 +347,8 @@ Usa restricciones de versión estilo Composer: ``` +Un pre-release en una restricción fija la estabilidad mínima con cualquier operador, incluso en `<3.5.0-beta.2`; redúcela con `@alpha` o `@dev`. Una palabra clave sin número también es un límite, como en Composer: `>2.0-beta` acepta `2.0.0-RC1` y `2.0.0`. Una versión exacta con pre-release u otro sufijo solo coincide con esa etiqueta: `2.0-beta` no acepta `2.0.0-beta.3` ni `2.0.0`, y `2.12.0-hotfix` no acepta `2.12.0-hotfix.2`. Las mayúsculas no importan: `1.0.0-arm64` coincide con la etiqueta `1.0.0-ARM64`, y `^1.0-Linux` acepta `1.0.0-linux`. Un pre-release puede ir pegado al número, como en las etiquetas: `^2.0.0rc1`. + ### Opciones de Configuración Avanzadas ```xml diff --git a/README-ru.md b/README-ru.md index 717960d3..73058e71 100644 --- a/README-ru.md +++ b/README-ru.md @@ -330,6 +330,10 @@ DLoad поддерживает три типа загрузки, которые + + + + @@ -344,6 +348,8 @@ DLoad поддерживает три типа загрузки, которые ``` +Pre-release в ограничении задаёт минимальную стабильность при любом операторе, даже в `<3.5.0-beta.2`; понизить её можно через `@alpha` или `@dev`. Ключевое слово без номера тоже служит границей, как в Composer: `>2.0-beta` берёт `2.0.0-RC1` и `2.0.0`. Точная версия с pre-release или другим суффиксом совпадает только с этим тегом: `2.0-beta` не берёт ни `2.0.0-beta.3`, ни `2.0.0`, а `2.12.0-hotfix` не берёт `2.12.0-hotfix.2`. Регистр не важен: `1.0.0-arm64` совпадает с тегом `1.0.0-ARM64`, а `^1.0-Linux` берёт `1.0.0-linux`. Pre-release может идти сразу за номером, как в тегах: `^2.0.0rc1`. + ### Расширенные настройки ```xml diff --git a/README-zh.md b/README-zh.md index 898e7feb..6390bcea 100644 --- a/README-zh.md +++ b/README-zh.md @@ -329,6 +329,10 @@ DLoad 支持三种下载类型,它们决定了资源的处理方式: + + + + @@ -343,6 +347,8 @@ DLoad 支持三种下载类型,它们决定了资源的处理方式: ``` +约束中的预发布版本会为任何运算符设定最低稳定性,即使是 `<3.5.0-beta.2`;可用 `@alpha` 或 `@dev` 降低。不带编号的关键字也是边界,与 Composer 一致:`>2.0-beta` 会选中 `2.0.0-RC1` 和 `2.0.0`。带预发布或其他后缀的精确版本只匹配该标签:`2.0-beta` 不会选中 `2.0.0-beta.3` 或 `2.0.0`,`2.12.0-hotfix` 也不会选中 `2.12.0-hotfix.2`。大小写无关:`1.0.0-arm64` 匹配标签 `1.0.0-ARM64`,`^1.0-Linux` 会选中 `1.0.0-linux`。预发布版本可以像标签那样紧跟在版本号后面:`^2.0.0rc1`。 + ### 高级配置选项 ```xml diff --git a/README.md b/README.md index 13dd8560..e07d025d 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,10 @@ Use Composer-style version constraints: + + + + @@ -345,6 +349,8 @@ Use Composer-style version constraints: ``` +A pre-release in a constraint sets the minimum stability for any operator, even for `<3.5.0-beta.2`; lower it with `@alpha` or `@dev`. A keyword without a number is a bound too, as in Composer: `>2.0-beta` takes `2.0.0-RC1` and `2.0.0`. An exact version with a pre-release or another suffix matches only that tag: `2.0-beta` takes neither `2.0.0-beta.3` nor `2.0.0`, and `2.12.0-hotfix` not `2.12.0-hotfix.2`. Case does not matter: `1.0.0-arm64` matches the tag `1.0.0-ARM64`, and `^1.0-Linux` takes `1.0.0-linux`. A pre-release may follow the number right away, as in tags: `^2.0.0rc1`. + ### Advanced Configuration Options ```xml diff --git a/composer.json b/composer.json index b6e33157..1c50040e 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,6 @@ ], "require": { "php": ">=8.2", - "composer/semver": "^3.4", "internal/container": "^1.0", "internal/destroy": "^1.0", "internal/path": "^1.2", @@ -36,6 +35,7 @@ }, "require-dev": { "buggregator/trap": "^1.15", + "composer/semver": "^3.4", "dereuromark/composer-prefer-lowest": "^0.1.10", "llm/skills": "^1.9", "roxblnfk/unpoly": "^1.8.1", diff --git a/composer.lock b/composer.lock index 2c62b610..e3ca6011 100644 --- a/composer.lock +++ b/composer.lock @@ -4,85 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7377bb5713e1c82dfcaad8e8ccb451bc", + "content-hash": "f04fa5e31f0a6538da2ab1cf1e81de59", "packages": [ - { - "name": "composer/semver", - "version": "3.4.4", - "source": { - "type": "git", - "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "shasum": "" - }, - "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.11", - "symfony/phpunit-bridge": "^3 || ^7" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Semver\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "http://www.naderman.de" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - }, - { - "name": "Rob Bast", - "email": "rob.bast@gmail.com", - "homepage": "http://robbast.nl" - } - ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", - "keywords": [ - "semantic", - "semver", - "validation", - "versioning" - ], - "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.4" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - } - ], - "time": "2025-08-20T19:15:30+00:00" - }, { "name": "internal/container", "version": "1.1.0", @@ -2984,6 +2907,83 @@ ], "time": "2026-06-07T11:47:49+00:00" }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "composer/xdebug-handler", "version": "3.0.5", diff --git a/dload.xsd b/dload.xsd index 3fa0168a..3e86039d 100644 --- a/dload.xsd +++ b/dload.xsd @@ -35,7 +35,10 @@ • "~1.0.0" - Tilde constraint • "^2.12.0-feature" - With feature suffix • "^2.12.0@beta" - With explicit stability - • "^2.12.0-hotfix@rc" - Combined feature and stability + • "^2.12.0-hotfix@alpha" - Feature builds, alpha ones included + • "2025.1.0-rc.2" - One pre-release + • "^2025.1.0-rc.1" - Range from a pre-release on + • ">=1.0 <2.0 || ^3.0" - Range and alternatives diff --git a/skills/dload-fetch-tool/SKILL.md b/skills/dload-fetch-tool/SKILL.md index c6bb80f5..5dec7eed 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. | +| `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/registry-entry.md b/skills/dload-fetch-tool/references/registry-entry.md index 9ca81dd7..f67c339d 100644 --- a/skills/dload-fetch-tool/references/registry-entry.md +++ b/skills/dload-fetch-tool/references/registry-entry.md @@ -12,7 +12,7 @@ How to describe a tool dload does not ship, as a `` block inside `//releases?per_page=20" \ ``` - `prerelease: true` while `version` demands stable → lower the stability (`@beta`/`@RC`/`@alpha`). +- Tag has a word after the number (`1.2.0-linux`, `1.2.0-arm64`) → a feature build of preview stability, skipped while `version` demands stable. Only a whole stability word marks a pre-release (`-beta2`, `rc1`, `-a1`): `-arm64` or `-build.5` is not alpha or beta. +- `version` is an exact version with a suffix (`2.12.0-hotfix`, `2025.1.0-rc.2`) → it matches only that tag, not `2.12.0-hotfix.2` or `2025.1.0-rc.2-linux`; use `^` or `>=` to take later builds. - `draft: true` → invisible to dload until published. - Tags carry text before the version (`bun-v1.4.2`, `cli-v2.0.0`) → set `tag-prefix` on the ``; without it the tags do not parse and every release is skipped. See [`registry-entry.md`](registry-entry.md#tag-prefix). - Tag is on GitHub but dload picks an older one → the version registry still holds a fresh check; rerun with `dload get --refresh`. A release inserted below the top of the listing (e.g. a GitLab release with a backdated date) needs `dload cache:clear `. See [`version-registry.md`](version-registry.md). diff --git a/src/Module/Binary/BinaryVersion.php b/src/Module/Binary/BinaryVersion.php index e4e44e3c..db64e348 100644 --- a/src/Module/Binary/BinaryVersion.php +++ b/src/Module/Binary/BinaryVersion.php @@ -4,6 +4,7 @@ namespace Internal\DLoad\Module\Binary; +use Internal\DLoad\Module\Version\PreRelease; use Internal\DLoad\Module\Version\Version; /** @@ -13,11 +14,6 @@ */ final class BinaryVersion extends Version { - /** - * Pattern to extract semantic version (X.Y.Z) from text. - */ - private const OUTPUT_VERSION_PATTERN = '/(?:version:?\s*)?(?:v(?:er(?:sion)?)?\.?\s*)?' . parent::VERSION_SEMVER_PATTERN . '/i'; - /** * Resolves the version from binary command output. * @@ -27,7 +23,7 @@ final class BinaryVersion extends Version public static function fromBinaryOutput(string $output): static { // Try to extract version using semantic version pattern - $version = \preg_match(self::OUTPUT_VERSION_PATTERN, $output, $matches) + $version = \preg_match(self::outputVersionPattern(), $output, $matches) ? $matches[1] . ($matches[2] ?? '') : self::extractVersionWithFallbacks($output); @@ -40,6 +36,18 @@ public static function fromBinaryOutput(string $output): static return self::fromVersionString($version); } + /** + * Pattern to extract semantic version (X.Y.Z, or more parts like X.Y.Z.W) from text. + * Only a pre-release may follow the number right away, like `2.0.0rc1`: another word glued to it, + * like `1.2.3_amd64`, is not a part of the version. Nor is a lone `a` or `b`: like the `c` of + * OpenSSL's `1.1.1c`, it is a patch letter, not an alpha or a beta. + */ + private static function outputVersionPattern(): string + { + return '/(?:version:?\s*)?(?:v(?:er(?:sion)?)?\.?\s*)?' . parent::VERSION_SEMVER_NUMBER_PATTERN + . '((?:[-+]|(?=(?![ab](?![a-z]|[._-]?\d))' . PreRelease::wordPattern() . '))[\w.-]+)?/i'; + } + /** * Attempts to extract version using various fallback patterns. * diff --git a/src/Module/Repository/Collection/ReleasesCollection.php b/src/Module/Repository/Collection/ReleasesCollection.php index c86bd578..bba54439 100644 --- a/src/Module/Repository/Collection/ReleasesCollection.php +++ b/src/Module/Repository/Collection/ReleasesCollection.php @@ -77,7 +77,10 @@ public function sortByVersion(): self { $result = \iterator_to_array($this->getIterator()); - \uasort($result, static fn(ReleaseInterface $a, ReleaseInterface $b): int => $b->getVersion()->compare($a->getVersion())); + \uasort( + $result, + static fn(ReleaseInterface $a, ReleaseInterface $b): int => $b->getVersion()->compare($a->getVersion()), + ); return new self($result); } diff --git a/src/Module/Version/Comparison.php b/src/Module/Version/Comparison.php new file mode 100644 index 00000000..79c079bd --- /dev/null +++ b/src/Module/Version/Comparison.php @@ -0,0 +1,40 @@ +=1.2.3` or `<3.5.0-beta.2`. + * + * A bound with a pre-release or another tail is a single release and compares as in sorting, tails included. + * A bound without one names a version number: every build of that number counts as the number, + * so `>=1.2` accepts `1.2.0-beta` and `<1.2` rejects it, as Composer does with `-dev` bounds. + * + * @internal + */ +final class Comparison +{ + private function __construct( + public readonly Operator $operator, + public readonly Version $bound, + private readonly bool $withPreRelease, + ) {} + + /** + * @param non-empty-string $number Version number, e.g. `1.2.3` + * @param string $tail Pre-release or another tail of the bound with its separator, e.g. `-beta.1`, `rc1` + * or `-hotfix`, read as {@see Version} reads it; empty for none + */ + public static function create(Operator $operator, string $number, string $tail = ''): self + { + return new self($operator, Version::fromVersionString($number . $tail), $tail !== ''); + } + + public function isSatisfiedBy(Version $version): bool + { + return $this->operator->accepts( + $this->withPreRelease ? $version->compare($this->bound) : $version->compareNumber($this->bound), + ); + } +} diff --git a/src/Module/Version/Constraint.php b/src/Module/Version/Constraint.php index f9588db0..e0b23c6f 100644 --- a/src/Module/Version/Constraint.php +++ b/src/Module/Version/Constraint.php @@ -4,35 +4,32 @@ namespace Internal\DLoad\Module\Version; -use Composer\Semver\Semver; use Internal\DLoad\Module\Common\Stability; /** * Version constraint DTO for parsing and handling complex version requirements. * * Encapsulates version constraint logic supporting: - * - Feature suffixes: ^2.12.0-feature, ~1.20.0-hotfix, ^1.0.0-my-feature - * - Stability constraints with two equivalent syntaxes: - * * Explicit: ^2.12.0@beta, ~1.20.0@stable - * * Implicit: ^2.12.0-beta, ~1.20.0-stable + * - Version ranges in the Composer syntax, see {@see Range}: ^2.12.0, ~1.20, >=1.0 <2.0 || ^3.0 + * - Feature suffixes of ranges: ^2.12.0-feature, ~1.20.0-hotfix, ^1.0.0-my-feature + * - Minimum stability, explicit or taken from the pre-releases of the constraint: ^2.12.0@beta, ^2.12.0-beta * - Combined constraints: ^2.12.0-feature@beta - * - 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; @@ -41,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. @@ -73,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) { @@ -117,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; @@ -137,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 @@ -162,29 +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) { - if ($version->preRelease === null || !$this->satisfiesPreRelease($this->preRelease, $number, $version->preRelease)) { - return false; - } - } elseif (Semver::satisfies($number, $this->versionConstraint) === false) { + if ($version->number === null || !$this->matcher->isSatisfiedBy($version)) { return false; } // Check if the version satisfies the feature suffix constraint if ($this->featureSuffix !== null) { - if (!\str_contains((string) $version->suffix, $this->featureSuffix)) { + if (\stripos((string) $version->suffix, $this->featureSuffix) === false) { return false; } } @@ -203,24 +194,39 @@ public function __toString(): string } /** - * 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. + * 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 function satisfiesPreRelease(PreRelease $required, string $number, PreRelease $preRelease): bool + private static function preReleaseBounds(string $constraint): array { - if (!Semver::satisfies($number, '==' . $this->bound)) { - return Semver::satisfies($number, $this->versionConstraint); - } + \preg_match_all( + '/(?:^|[\s,|])[<>=!~^]*v?\d+(?:\.\d+)*(?:\+\d+)?(' . Range::preReleasePattern() . ')(?=$|[\s,|])/i', + $constraint, + $matches, + ); + + /** @var list */ + return $matches[1]; + } - $order = $preRelease->compare($required); + /** + * A numeric tail, like `-1` of `1.0.0-1`, is a {@see Stability::Preview}, as {@see Version} reads it. + * + * @param non-empty-list $preReleases + */ + private static function lowestStability(array $preReleases): Stability + { + $stabilities = \array_map( + static fn(string $preRelease): Stability => PreRelease::fromString(\ltrim($preRelease, '-._'))?->stability + ?? Stability::Preview, + $preReleases, + ); + \usort($stabilities, static fn(Stability $a, Stability $b): int => $a->getWeight() <=> $b->getWeight()); - return 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 00000000..3d161d5f --- /dev/null +++ b/src/Module/Version/Operator.php @@ -0,0 +1,49 @@ +=` of `>=1.2.3`. + * + * @internal + */ +enum Operator: string +{ + case Equal = '='; + case NotEqual = '!='; + case Greater = '>'; + case GreaterOrEqual = '>='; + case Less = '<'; + case LessOrEqual = '<='; + + /** + * Resolves an operator of the constraint syntax: none and `==` are {@see Equal}, `<>` is {@see NotEqual}. + * + * @return self|null Null when the text is not an operator. + */ + public static function fromString(string $operator): ?self + { + return match ($operator) { + '', '==' => self::Equal, + '<>' => self::NotEqual, + default => self::tryFrom($operator), + }; + } + + /** + * @param int $order Order of a version against the bound: negative when the version is lower. + */ + public function accepts(int $order): bool + { + return match ($this) { + self::Equal => $order === 0, + self::NotEqual => $order !== 0, + self::Greater => $order > 0, + self::GreaterOrEqual => $order >= 0, + self::Less => $order < 0, + self::LessOrEqual => $order <= 0, + }; + } +} diff --git a/src/Module/Version/PreRelease.php b/src/Module/Version/PreRelease.php index 0382cd70..daaa0843 100644 --- a/src/Module/Version/PreRelease.php +++ b/src/Module/Version/PreRelease.php @@ -51,18 +51,6 @@ public static function fromString(string $value): ?self return new self($stability, $number); } - /** - * Resolves a stability keyword, including the `a` and `b` abbreviations of alpha and beta. - */ - public static function stability(string $keyword): ?Stability - { - return Stability::fromString($keyword) ?? match (\strtolower($keyword)) { - 'a' => Stability::Alpha, - 'b' => Stability::Beta, - default => null, - }; - } - /** * Regular expression alternation of every keyword {@see stability()} accepts. * @@ -77,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. */ @@ -94,4 +94,16 @@ public function __toString(): string { return $this->number === 0 ? $this->stability->value : $this->stability->value . '.' . $this->number; } + + /** + * Resolves a stability keyword, including the `a` and `b` abbreviations of alpha and beta. + */ + private static function stability(string $keyword): ?Stability + { + return Stability::fromString($keyword) ?? match (\strtolower($keyword)) { + 'a' => Stability::Alpha, + 'b' => Stability::Beta, + default => null, + }; + } } diff --git a/src/Module/Version/Range.php b/src/Module/Version/Range.php new file mode 100644 index 00000000..f6d557e2 --- /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 8c61623e..1155a0fe 100644 --- a/src/Module/Version/Version.php +++ b/src/Module/Version/Version.php @@ -13,8 +13,11 @@ */ class Version implements \Stringable { - protected const VERSION_SEMVER_PATTERN = 'v?(\d+\.\d+\.\d+(?:\+\d+)?)([-+][\w.-]+)?'; - protected const VERSION_FALLBACK_PATTERN = 'v?(\d+(?:\.\d+(?:\.\d+(?:\+\d+)?)?)?)([-+.][\w.-]+)?'; + protected const VERSION_SEMVER_NUMBER_PATTERN = 'v?(\d+\.\d+\.\d+(?:\.\d+)*(?:\+\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+)?)((?:[-+.]|(?=[a-z_]))[\w.-]+)?'; + protected const VERSION_HASH_SUFFIX_PATTERN = '(?:#([a-f0-9]{6,40}))?'; /** @@ -23,6 +26,7 @@ class Version implements \Stringable * @param null|non-empty-string $suffix Feature suffix: the suffix without the stability part * @param null|PreRelease $preRelease Stability with its number, e.g. `beta.1` of `1.2.3-beta.1`; * numbered 0 when the version carries none. Null only for an empty version. + * @param bool $withoutKeyword The suffix has no stability keyword, like `1` of `1.0.0-1`. */ final protected function __construct( public readonly string $string, @@ -31,6 +35,7 @@ final protected function __construct( public readonly ?Stability $stability = null, public readonly ?string $hash = null, public readonly ?PreRelease $preRelease = null, + private readonly bool $withoutKeyword = false, ) {} /** @@ -53,7 +58,11 @@ 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, '-_.+'); $suffix === '' and $suffix = null; @@ -64,7 +73,7 @@ public static function fromVersionString(string $string): static $hash = $parts[3] ?? null; $hash === '' and $hash = null; - return new static($string, $number, $suffix, $stability, $hash, $preRelease); + return new static($string, $number, $suffix, $stability, $hash, $preRelease, $withoutKeyword); } public static function empty(): static @@ -74,21 +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: `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 @@ -105,21 +128,13 @@ public function __toString(): string */ private static function preReleaseFromSuffix(string &$input): ?PreRelease { - if (\str_starts_with('x-dev', \strtolower($input))) { - $input = \substr($input, \strlen('x-dev')); - return new PreRelease(Stability::Dev); - } - - $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, '._-')); @@ -136,19 +151,68 @@ private static function preReleaseFromSuffix(string &$input): ?PreRelease } /** - * The number keeps three parts at most, so the rest of a longer one, like `.4` of `1.2.3.4`, - * lands in the suffix; for ordering it is a part of the number again. Only without a stability - * keyword: the `.2` of `2.0.0-beta.1.2` belongs to the pre-release. + * 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, + * nor does build metadata, as in SemVer: `1.2.3+5` is `1.2.3`. */ private function comparableNumber(): string { - $number = (string) $this->number; - $withoutKeyword = $this->preRelease !== null - && $this->preRelease->stability === Stability::Preview - && $this->preRelease->number === 0; - - return $withoutKeyword && $this->suffix !== null && \preg_match('/^\d+(?:\.\d+)*$/', $this->suffix) === 1 - ? $number . '.' . $this->suffix - : $number; + $number = \explode('+', (string) $this->number, 2)[0]; + if ($this->withoutKeyword && $this->suffix !== null && \preg_match('/^\d+(?:\.\d+)*$/', $this->suffix) === 1) { + $number .= '.' . $this->suffix; + } + + return (string) \preg_replace('/(?:\.0+)+$/', '', $number); } } diff --git a/tests/Unit/Module/Binary/BinaryVersionTest.php b/tests/Unit/Module/Binary/BinaryVersionTest.php index 17e15834..6794bd8a 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)] @@ -63,6 +65,42 @@ public static function provideSemanticVersionOutputs(): \Generator '2.3.4+20230415', ]; + yield 'pre-release without a separator' => [ + 'tool 2.0.0rc1 (linux)', + '2.0.0rc1', + '2.0.0', + ]; + + yield 'word glued to the number' => [ + 'tool 1.2.3_amd64', + '1.2.3', + null, + ]; + + yield 'word starting with a stability letter glued to the number' => [ + 'tool 1.2.3arm64', + '1.2.3', + null, + ]; + + yield 'platform after a pre-release glued to the number' => [ + 'tool 2.0.0rc1_linux', + '2.0.0rc1_linux', + '2.0.0', + ]; + + yield 'letter release glued to the number' => [ + 'OpenSSL 1.1.1b 26 Feb 2019', + '1.1.1', + null, + ]; + + yield 'beta abbreviation glued to the number' => [ + 'tool 1.0.0b2', + '1.0.0b2', + '1.0.0', + ]; + // Case insensitivity yield 'mixed case version string' => [ 'VERSION: 5.1.2', @@ -97,6 +135,68 @@ public static function provideSemanticVersionOutputs(): \Generator '1.51.1', null, ]; + + // More than three parts + yield 'four-part version' => [ + 'tool version 1.2.3.4 (build 2025-01-01)', + '1.2.3.4', + null, + ]; + + yield 'five-part version with pre-release' => [ + 'Version: v1.2.3.4.5-beta.1', + '1.2.3.4.5-beta.1', + '1.2.3.4.5', + ]; + + yield 'four-part version after a name' => [ + 'libtool 30.2.1.4', + '30.2.1.4', + null, + ]; + + // Other numbers around the version + yield 'build number after the version' => [ + 'tool 1.2.3 build 4567', + '1.2.3', + null, + ]; + + yield 'date after the version' => [ + 'tool 1.2.3 20250101', + '1.2.3', + null, + ]; + + yield 'date joined to the version' => [ + 'tool 1.2.3-20250101', + '1.2.3-20250101', + '1.2.3', + ]; + + yield 'build metadata after a pre-release' => [ + 'v1.2.3-rc.1+build.5', + '1.2.3-rc.1', + '1.2.3', + ]; + + yield 'number before the version' => [ + 'protocol 2 tool 1.2.3', + '1.2.3', + null, + ]; + + yield 'Go toolchain before the version' => [ + 'built with go1.21 tool 1.2.3', + '1.2.3', + null, + ]; + + yield 'Go toolchain after the version' => [ + 'tool 1.2.3 (go1.22.1)', + '1.2.3', + null, + ]; } /** @@ -142,6 +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/Repository/ReleasesCollectionTest.php b/tests/Unit/Module/Repository/ReleasesCollectionTest.php index 579afd7d..beebfa4d 100644 --- a/tests/Unit/Module/Repository/ReleasesCollectionTest.php +++ b/tests/Unit/Module/Repository/ReleasesCollectionTest.php @@ -155,6 +155,20 @@ public function sortByVersionOrdersWhatFollowsThePreRelease(): void ]); } + #[Test] + public function exactPreReleaseConstraintPicksTheTagWithoutATail(): void + { + $names = ['3.5.0-RC1-linux', '3.5.0-RC1.2', '3.5.0-RC1', '3.5.0-RC2', '3.5.0-RC1-priority.0']; + $collection = new ReleasesCollection(\array_map( + fn(string $name): ReleaseStub => new ReleaseStub($this->repository, $name, Version::fromVersionString($name)), + $names, + )); + + $result = $collection->satisfies(Constraint::fromConstraintString('3.5.0-RC1'))->sortByVersion(); + + Assert::same(\array_values($this->getVersionsFromCollection($result)), ['3.5.0-RC1']); + } + #[Test] public function chainedFiltersWorkCorrectly(): void { diff --git a/tests/Unit/Module/Version/ConstraintTest.php b/tests/Unit/Module/Version/ConstraintTest.php index f2505318..f4ae4da1 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 @@ -328,6 +376,10 @@ public static function provideComparableConstraints(): \Generator yield 'underscore spelling in the tag' => ['1.0.0-alpha_2', 'v1.0.0-alpha_2', true]; yield 'explicit stability looser than the pre-release' => ['3.5.0-RC1@beta', 'v3.5.0-RC1', true]; yield 'explicit stability stricter than the pre-release' => ['3.5.0-beta.1@rc', 'v3.5.0-beta.1', false]; + yield 'feature build after the pre-release' => ['1.3.1-RC1', 'v1.3.1-RC1-priority.0', false]; + yield 'number after the pre-release' => ['=3.5.0-beta.1', 'v3.5.0-beta.1.2', false]; + yield 'platform after the pre-release' => ['3.5.0-RC1', 'v3.5.0-RC1-linux', false]; + yield 'explicit equality with a tail after the pre-release' => ['==3.5.0-RC1', 'v3.5.0-RC1-linux', false]; // A pre-release bound of a range yield 'caret above the bound' => ['^3.5.0-beta.1@beta', 'v3.5.0-rc.1', true]; @@ -349,9 +401,267 @@ public static function provideComparableConstraints(): \Generator yield 'at most the bound' => ['<=3.5.0-beta.2@beta', 'v3.5.0-beta.2', true]; yield 'explicit equality' => ['==3.5.0-rc.1', 'v3.5.0-RC1', true]; yield 'nightly range' => ['>=1.0.0-nightly20250503@nightly', 'v1.0.0-nightly20250601', true]; + yield 'caret on a number after the pre-release' => ['^3.5.0-beta.1', 'v3.5.0-beta.1.2', true]; + yield 'at least the bound with a tail after it' => ['>=3.5.0-RC1', 'v3.5.0-RC1-linux', true]; + yield 'above the bound with a tail after it' => ['>3.5.0-RC1', 'v3.5.0-RC1-linux', true]; + yield 'above the bound with a number after it' => ['>3.5.0-RC1', 'v3.5.0-RC1.2', true]; + yield 'caret on the bound with a tail after it' => ['^3.5.0-RC1', 'v3.5.0-RC1-linux', true]; + yield 'tilde on the bound with a tail after it' => ['~3.5.0-RC1', 'v3.5.0-RC1-linux', true]; + yield 'below the bound with a tail after it' => ['<3.5.0-RC1', 'v3.5.0-RC1-linux', false]; + yield 'at most the bound with a tail after it' => ['<=3.5.0-RC1', 'v3.5.0-RC1-linux', false]; + yield 'below the next pre-release with a tail after the previous one' => ['<3.5.0-RC2', 'v3.5.0-RC1-linux', true]; + yield 'above the bound on a next version below the stability' => ['>3.5.0-beta.2', 'v3.6.0-alpha', false]; + yield 'above the bound on a next version' => ['>3.5.0-beta.2', 'v3.6.0', true]; + yield 'tilde from a pre-release on a later patch' => ['~3.5.0-beta.1', 'v3.5.4', true]; + yield 'tilde from a pre-release past the minor' => ['~3.5.0-beta.1', 'v3.6.0', false]; + + // A pre-release in the constraint sets the minimum stability for an upper bound too + yield 'below the bound on an earlier pre-release' => ['<3.5.0-beta.2', 'v3.5.0-beta.1', true]; + yield 'below the bound on a less stable pre-release' => ['<3.5.0-beta.2', 'v3.5.0-alpha.9', false]; + yield 'below the bound on an older dev build' => ['<3.5.0-beta.2', 'v3.4.0-dev', false]; + yield 'below the bound on an older release' => ['<3.5.0-beta.2', 'v3.4.0', true]; + yield 'below the bound on the bound' => ['<3.5.0-beta.2', 'v3.5.0-beta.2', false]; + yield 'below the bound with alpha on an alpha' => ['<3.5.0-beta.2@alpha', 'v3.5.0-alpha.9', true]; + yield 'below the bound with alpha on a dev build' => ['<3.5.0-beta.2@alpha', 'v3.4.0-dev', false]; + yield 'below the bound with dev on an earlier pre-release' => ['<3.5.0-beta.2@dev', 'v3.5.0-beta.1', true]; + yield 'below the bound with dev on an alpha' => ['<3.5.0-beta.2@dev', 'v3.5.0-alpha.9', true]; + yield 'below the bound with dev on a dev build' => ['<3.5.0-beta.2@dev', 'v3.4.0-dev', true]; + yield 'below the bound with dev on an older release' => ['<3.5.0-beta.2@dev', 'v3.4.0', true]; + yield 'below the bound with dev on the bound' => ['<3.5.0-beta.2@dev', 'v3.5.0-beta.2', false]; + + // Versions with more than three number parts + yield 'four parts exact' => ['1.2.3.4', 'v1.2.3.4', true]; + yield 'four parts exact pre-release' => ['1.2.3.4-beta.1', 'v1.2.3.4-beta.1', true]; + yield 'four parts on another fourth part' => ['1.2.3.4', 'v1.2.3.5', false]; + yield 'five parts on an exact four' => ['1.2.3.4', 'v1.2.3.4.5', false]; + yield 'five parts on an explicit equality' => ['==1.2.3.4', 'v1.2.3.4.5', false]; + yield 'five parts above four' => ['>1.2.3.4', 'v1.2.3.4.5', true]; + yield 'five parts at least four' => ['>=1.2.3.4', 'v1.2.3.4.5', true]; + yield 'five parts below four' => ['<1.2.3.4', 'v1.2.3.4.5', false]; + yield 'five parts at most four' => ['<=1.2.3.4', 'v1.2.3.4.5', false]; + yield 'five parts below the next fourth part' => ['<1.2.3.5', 'v1.2.3.4.5', true]; + yield 'five parts in a caret' => ['^1.2', 'v1.2.3.4.5', true]; + yield 'five parts in a tilde' => ['~1.2.3.4', 'v1.2.3.4.5', true]; + yield 'six parts above four' => ['>1.2.3.4', 'v1.2.3.4.5.6', true]; + yield 'trailing zero after four parts on an explicit equality' => ['==1.2.3.4', 'v1.2.3.4.0', true]; + yield 'trailing zeros after four parts' => ['1.2.3.4', 'v1.2.3.4.0.0', true]; + yield 'trailing zeros after three parts' => ['1.2.3', 'v1.2.3.0.0', true]; + yield 'trailing zero after four parts above four' => ['>1.2.3.4', 'v1.2.3.4.0', false]; + yield 'trailing zero after four parts below four' => ['<1.2.3.4', 'v1.2.3.4.0', false]; + yield 'zero part before a meaningful one' => ['1.2.3.4', 'v1.2.3.4.0.5', false]; + yield 'zero parts before a meaningful one' => ['1.0', 'v1.0.0.0.1', false]; + yield 'trailing zeros before a pre-release' => ['3.5.0-RC1', 'v3.5.0.0.0-RC1', true]; + yield 'five parts above a four parts pre-release' => ['^1.2.3.4-beta.1', 'v1.2.3.4.5', true]; + yield 'five parts on an exact four parts pre-release' => ['1.2.3.4-beta.1', 'v1.2.3.4.5', false]; + + // A pre-release glued to the number + yield 'exact release candidate without a separator in the tag' => ['2.0.0-rc1', 'v2.0.0rc1', true]; + yield 'exact beta abbreviation without a separator in the tag' => ['1.0.0-b2', '1.0.0b2', true]; + + // A long major part is an ordinary number + yield 'date major part with four parts' => ['^1.0', '20250101.1.2.3', false]; + yield 'date major part in a caret of its own' => ['^20250101.1', '20250101.1.2.3', true]; + yield 'six digit major part with four parts' => ['^1.0', '123456.1.2.3', false]; + yield 'six digit major part at least a bound' => ['>=1.0', '100000.0.0.1', true]; + yield 'six digit major part with five parts on a pre-release bound' => ['^1.0.0-beta.1', '123456.0.0.0.1', false]; + + // A single version with a pre-release or a tail is one release + yield 'exact bare keyword on its release' => ['2.0-beta', 'v2.0.0-beta', true]; + yield 'exact bare keyword on a zero numbered spelling' => ['2.0-beta', 'v2.0.0-beta.0', true]; + yield 'exact bare keyword on a numbered pre-release' => ['3.5.0-beta', 'v3.5.0-beta.3', false]; + yield 'exact bare keyword after an equal sign on a numbered pre-release' => ['=3.5.0-beta', 'v3.5.0-beta.3', false]; + yield 'exact bare keyword after a double equal sign on a numbered pre-release' => ['==3.5.0-beta', 'v3.5.0-beta.3', false]; + yield 'exact bare keyword on a release candidate' => ['2.0-beta', 'v2.0.0-RC1', false]; + yield 'exact bare keyword on the final release' => ['2.0-beta', 'v2.0.0', false]; + yield 'exact bare keyword after an equal sign on its release' => ['=2.0-beta', '2.0.0-beta', true]; + yield 'exact bare keyword after a double equal sign on its release' => ['==2.0-beta', 'v2.0.0-beta', true]; + yield 'exact pre-release after a feature' => ['1.0-arm64-beta.1', 'v1.0-arm64-beta.1', true]; + yield 'exact pre-release after a feature on another pre-release' => ['1.0-arm64-beta.1', 'v1.0-arm64-beta.2', false]; + yield 'exact pre-release after a feature on another feature' => ['1.0-arm64-beta.1', 'v1.0-amd64-beta.1', false]; + yield 'exact feature build' => ['2.12.0-hotfix', 'v2.12.0-hotfix', true]; + yield 'exact feature build on a numbered one' => ['2.12.0-hotfix', 'v2.12.0-hotfix.2', false]; + yield 'exact feature build on a platform build of it' => ['2.12.0-hotfix', 'v2.12.0-hotfix-linux', false]; + yield 'exact feature build on another feature' => ['=2.12.0-hotfix', 'v2.12.0-musl', false]; + yield 'exact feature build on its final release' => ['==2.12.0-hotfix', 'v2.12.0', false]; + yield 'exact feature build with a stricter explicit stability' => ['2.12.0-hotfix@stable', 'v2.12.0-hotfix', false]; + yield 'exact tail after a pre-release on itself' => ['1.0.0-beta.1-2', 'v1.0.0-beta.1-2', true]; + yield 'exact tail after a release candidate on itself' => ['3.5.0-RC1-linux', 'v3.5.0-RC1-linux', true]; + yield 'exact pre-release before a tail on itself' => ['1.2.3-rc1-arm64', 'v1.2.3-rc1-arm64', true]; + + // A bare number covers every build of that number the stability allows + yield 'bare number on a platform build' => ['1.2.3', 'v1.2.3-linux', false]; + yield 'bare number with a looser stability on a platform build' => ['1.2.3@preview', 'v1.2.3-linux', true]; + yield 'bare number with a looser stability on a pre-release' => ['=1.2.3@beta', 'v1.2.3-beta.1', true]; + yield 'bare number on build metadata' => ['1.2.3', 'v1.2.3+5', true]; + + // 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]; + } - // 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]; + /** + * 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 @@ -362,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')] @@ -436,7 +757,7 @@ public function numberedPreReleaseIsAPartOfTheVersion( Assert::same($result->preRelease?->stability, $expectedPreReleaseStability); Assert::same($result->preRelease?->number, $expectedPreReleaseNumber); Assert::same($result->minimumStability, $expectedStability); - $expectedPreReleaseStability === null or Assert::null($result->featureSuffix); + Assert::null($result->featureSuffix); } #[Test] @@ -464,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 00000000..64f51c3d --- /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 00000000..59cac134 --- /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 64fb31d9..ea66112e 100644 --- a/tests/Unit/Module/Version/VersionTest.php +++ b/tests/Unit/Module/Version/VersionTest.php @@ -17,18 +17,24 @@ final class VersionTest { public static function providePreReleases(): \Generator { - yield 'dotted beta' => ['v3.5.0-beta.1', Stability::Beta, 1]; - yield 'release candidate' => ['v3.5.0-RC1', Stability::RC, 1]; - yield 'dotted release candidate' => ['2025.1.0-rc.2', Stability::RC, 2]; - yield 'nightly date' => ['1.0.0-nightly20250503', Stability::Nightly, 20250503]; - yield 'beta abbreviation' => ['1.0.0b2', Stability::Beta, 2]; - yield 'no separator' => ['2.0.0rc1', Stability::RC, 1]; - yield 'underscore separator' => ['1.0.0-rc_1', Stability::RC, 1]; - yield 'bare stability' => ['1.2.3-beta', Stability::Beta, 0]; - yield 'stability before a feature' => ['1.2.3-beta.3-feature', Stability::Beta, 3]; - yield 'dev branch' => ['v2.0.x-dev', Stability::Dev, 0]; - yield 'stable release' => ['v3.5.0', Stability::Stable, 0]; - yield 'feature only' => ['1.2.3-custom', Stability::Preview, 0]; + yield 'dotted beta' => ['v3.5.0-beta.1', '3.5.0', Stability::Beta, 1]; + yield 'release candidate' => ['v3.5.0-RC1', '3.5.0', Stability::RC, 1]; + yield 'dotted release candidate' => ['2025.1.0-rc.2', '2025.1.0', Stability::RC, 2]; + yield 'nightly date' => ['1.0.0-nightly20250503', '1.0.0', Stability::Nightly, 20250503]; + yield 'beta abbreviation' => ['1.0.0b2', '1.0.0', Stability::Beta, 2]; + yield 'no separator' => ['2.0.0rc1', '2.0.0', Stability::RC, 1]; + yield 'no separator after a prefixed number' => ['v1.0.0rc1', '1.0.0', Stability::RC, 1]; + yield 'no separator after four number parts' => ['1.2.3.4rc1', '1.2.3.4', Stability::RC, 1]; + yield 'alpha abbreviation' => ['1.2.3a1', '1.2.3', Stability::Alpha, 1]; + yield 'underscore before the stability' => ['1.2.3_beta2', '1.2.3', Stability::Beta, 2]; + yield 'underscore separator' => ['1.0.0-rc_1', '1.0.0', Stability::RC, 1]; + yield 'bare stability' => ['1.2.3-beta', '1.2.3', Stability::Beta, 0]; + yield 'stability before a feature' => ['1.2.3-beta.3-feature', '1.2.3', Stability::Beta, 3]; + yield 'dev branch' => ['v2.0.x-dev', '2.0', Stability::Dev, 0]; + yield 'stable release' => ['v3.5.0', '3.5.0', Stability::Stable, 0]; + yield 'feature only' => ['1.2.3-custom', '1.2.3', Stability::Preview, 0]; + yield 'four number parts' => ['1.2.3.4-beta.1', '1.2.3.4', Stability::Beta, 1]; + yield 'five number parts' => ['v1.2.3.4.5-rc.2', '1.2.3.4.5', Stability::RC, 2]; } public static function provideOrderedVersions(): \Generator @@ -48,6 +54,56 @@ public static function provideOrderedVersions(): \Generator yield 'feature build number' => ['1.3.1-RC1-priority.0', '1.3.1-RC1-priority.1']; yield 'number after the pre-release' => ['2.0.0-beta.1.2', '2.0.0-beta.1.3']; yield 'number after the pre-release before the final' => ['2.0.0-beta.1.2', '2.0.0']; + yield 'number after a bare stability before the final' => ['1.0.0-preview.0.4', '1.0.0']; + yield 'number before the stability before the final' => ['1.0.0-5-preview', '1.0.0']; + yield 'four parts pre-release after the previous fourth part' => ['1.2.3.3', '1.2.3.4-beta.1']; + yield 'four parts pre-release after three parts' => ['1.2.3', '1.2.3.4-beta.1']; + yield 'five parts after four' => ['1.2.3.4', '1.2.3.4.5']; + yield 'five parts before the next fourth part' => ['1.2.3.4.5', '1.2.3.5']; + yield 'numeric suffix after the number' => ['1.0.0', '1.0.0-1']; + yield 'numeric suffixes' => ['1.0.0-1', '1.0.0-2']; + yield 'numeric suffix over ten' => ['1.0.0-2', '1.0.0-10']; + yield 'date suffix before the next patch' => ['1.0.0-20250101', '1.0.1']; + yield 'release candidate without a separator after an alpha' => ['2.0.0-alpha', '2.0.0rc1']; + yield 'beta abbreviation without a separator after an earlier beta' => ['1.0.0-beta.3', '1.0.0b4']; + yield 'tail after the pre-release' => ['3.5.0-RC1', '3.5.0-RC1-linux']; + yield 'number after the pre-release after the pre-release itself' => ['3.5.0-RC1', '3.5.0-RC1.2']; + yield 'zero parts before a meaningful one' => ['1.0', '1.0.0.1']; + yield 'platform build before the final' => ['1.0.0-x86_64', '1.0.0']; + yield 'number with a letter before the final' => ['1.0.0-1x', '1.0.0']; + yield 'number with an alpha abbreviation before the final' => ['1.0.0-1a', '1.0.0']; + yield 'words of the suffix' => ['1.0.0-amd64', '1.0.0-arm64']; + yield 'words after the pre-release' => ['1.0.0-RC1-darwin', '1.0.0-RC1-linux']; + yield 'patch abbreviation and the whole word' => ['1.0.0-p1', '1.0.0-patch1']; + yield 'word before a number' => ['2.0.0-beta.1-linux', '2.0.0-beta.1.2']; + yield 'word before a number at the start of the tail' => ['1.0.0-x.1', '1.0.0-1.x']; + yield 'platform word is no alpha' => ['1.0.0-arm64', '1.0.0-linux']; + yield 'patch abbreviation is no patch level' => ['1.0.0-p1', '1.0.0-x1']; + yield 'number after a word' => ['1.0.0-build.9', '1.0.0-build.10']; + yield 'number glued to a word' => ['1.0.0-build9', '1.0.0-build10']; + yield 'long numbers' => ['1.0.0-build.99999999999999999998', '1.0.0-build.99999999999999999999']; + yield 'words alphabetically in any case' => ['1.0.0-Darwin', '1.0.0-linux']; + } + + public static function provideEqualVersions(): \Generator + { + yield 'spellings of one pre-release' => ['v3.5.0-rc.1', '3.5.0-RC1']; + yield 'trailing zero part' => ['1.2.3', '1.2.3.0']; + yield 'trailing zero parts' => ['1.2.3', 'v1.2.3.0.0']; + yield 'trailing zero after four parts' => ['1.2.3.4', '1.2.3.4.0']; + yield 'trailing zero before a pre-release' => ['1.2.3-RC1', '1.2.3.0-RC1']; + yield 'two parts' => ['1.0', '1.0.0']; + yield 'build metadata' => ['1.2.3', '1.2.3+5']; + yield 'build metadata after two parts' => ['1.2', '1.2+5']; + yield 'build metadata after one part' => ['1', '1+2']; + yield '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']; } /** @@ -80,6 +136,24 @@ public static function provideValidVersionStrings(): \Generator // Partial semantic versions (fallback pattern) yield 'two-part version' => ['1.2', '1.2', '1.2', null, Stability::Stable]; yield 'single digit version' => ['5', '5', '5', null, Stability::Stable]; + + // Longer numbers + yield 'four-part version' => ['1.2.3.4', '1.2.3.4', '1.2.3.4', null, Stability::Stable]; + yield 'prefixed four-part version' => ['v1.2.3.4', 'v1.2.3.4', '1.2.3.4', null, Stability::Stable]; + yield 'five-part version' => ['v1.2.3.4.5', 'v1.2.3.4.5', '1.2.3.4.5', null, Stability::Stable]; + yield 'four-part version with feature' => ['1.2.3.4-custom', '1.2.3.4-custom', '1.2.3.4', 'custom', Stability::Preview]; + // A suffix glued to the number + yield 'release candidate without a separator' => ['2.0.0rc1', '2.0.0rc1', '2.0.0', null, Stability::RC]; + yield 'feature after a release candidate without a separator' => ['2.0.0rc1-linux', '2.0.0rc1-linux', '2.0.0', 'linux', Stability::RC]; + yield 'feature without a separator' => ['1.2.3_linux', '1.2.3_linux', '1.2.3', 'linux', Stability::Preview]; + yield 'letter without a separator' => ['1.0.0x', '1.0.0x', '1.0.0', 'x', Stability::Preview]; + yield 'dev branch after a letter without a separator' => ['1.0.0x-dev', '1.0.0x-dev', '1.0.0', 'x', Stability::Dev]; + + // Dev branches + yield 'dev branch of a minor' => ['1.0.x-dev', '1.0.x-dev', '1.0', 'x', Stability::Dev]; + yield 'dev branch of a major' => ['2.x-dev', '2.x-dev', '2', 'x', Stability::Dev]; + yield 'dev branch after a full number' => ['1.0.0-x-dev', '1.0.0-x-dev', '1.0.0', 'x', Stability::Dev]; + yield 'version with suffix but no recognized stability' => ['1.2.3-custom', '1.2.3-custom', '1.2.3', 'custom', Stability::Preview]; // Case insensitive stability @@ -165,12 +239,48 @@ public static function provideVersionsAndExpectedStability(): \Generator yield 'real case 3' => ['v1.3.0', Stability::Stable, 'Stable version']; } + /** + * A stability keyword is a whole word: letters glued to it make another word. + */ + public static function provideWordsStartingWithAStabilityLetter(): \Generator + { + yield 'arm64 is not alpha' => ['1.0.0-arm64', 'arm64']; + yield 'amd64 is not alpha' => ['1.0.0-amd64', 'amd64']; + yield 'abc is not alpha' => ['1.0.0-abc', 'abc']; + yield 'word glued to four number parts' => ['1.2.3.4abc', 'abc']; + yield 'bugfix is not beta' => ['1.0.0-bugfix', 'bugfix']; + yield 'build is not beta' => ['1.0.0-build.5', 'build.5']; + yield 'ga is not alpha' => ['1.0.0-ga', 'ga']; + yield 'word ending with a stability letter' => ['1.0.0-gamma', 'gamma']; + yield 'platform after the os' => ['1.0.0-linux-amd64', 'linux-amd64']; + yield 'letters after the pre-release number' => ['1.0.0-beta2x', 'beta2x']; + yield 'lone patch letter a' => ['1.1.1a', 'a']; + yield 'lone patch letter b' => ['1.1.1b', 'b']; + yield 'lone patch letter w' => ['1.1.1w', 'w']; + } + + public static function provideStabilityWords(): \Generator + { + yield 'alpha abbreviation with a number' => ['1.0.0-a1', Stability::Alpha, 1, null]; + yield 'beta with a number' => ['1.0.0-beta2', Stability::Beta, 2, null]; + yield 'release candidate glued to the number' => ['2.0.0rc1', Stability::RC, 1, null]; + yield 'beta after an underscore' => ['1.2.3_beta2', Stability::Beta, 2, null]; + yield 'beta abbreviation glued to the number' => ['1.0.0b2', Stability::Beta, 2, null]; + yield 'platform after the pre-release' => ['1.0.0-rc.1-arm64', Stability::RC, 1, 'arm64']; + yield 'platform after an underscore' => ['2.0.0rc1_linux', Stability::RC, 1, 'linux']; + } + #[DataProvider('providePreReleases')] #[Test] - public function preReleaseKeepsTheStabilityWithItsNumber(string $input, Stability $stability, int $number): void - { + public function preReleaseKeepsTheStabilityWithItsNumber( + string $input, + string $versionNumber, + Stability $stability, + int $number, + ): void { $version = Version::fromVersionString($input); + Assert::same($version->number, $versionNumber); Assert::same($version->preRelease?->stability, $stability); Assert::same($version->preRelease?->number, $number); Assert::same($version->stability, $stability); @@ -187,10 +297,12 @@ public function compareOrdersByNumberThenByPreRelease(string $older, string $new Assert::same($b->compare($a), 1); } + #[DataProvider('provideEqualVersions')] #[Test] - public function compareTreatsSpellingsOfOnePreReleaseAsEqual(): void + public function compareTreatsVersionsAsEqual(string $a, string $b): void { - Assert::same(Version::fromVersionString('v3.5.0-rc.1')->compare(Version::fromVersionString('3.5.0-RC1')), 0); + Assert::same(Version::fromVersionString($a)->compare(Version::fromVersionString($b)), 0); + Assert::same(Version::fromVersionString($b)->compare(Version::fromVersionString($a)), 0); } #[Test] @@ -324,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); + } } From aefa83454f9432e41af02c2c37312968a3c48d5a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 11:19:21 +0400 Subject: [PATCH 3/3] fix(version): ignore SemVer build metadata and compare long numbers (#158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(version): accept build metadata like `+build.5` in constraints again, as 1.18.0 did, and after a pre-release fix(version): read `+…` on a tag as build metadata, so `1.2.3+beta` is a stable `1.2.3` fix(binary): show the whole version a binary prints, with its pre-release and build metadata Build metadata follows the SemVer grammar on tags, binary output and constraints, and never changes order, stability or matching. Version numbers compare part by part as digit strings, so parts too long for an integer no longer compare equal. Assisted-By: Claude Opus 5.5 --- skills/dload-fetch-tool/SKILL.md | 2 +- src/Module/Binary/Binary.php | 2 +- src/Module/Binary/BinaryVersion.php | 5 +- src/Module/Binary/Internal/AbstractBinary.php | 4 +- src/Module/Version/Constraint.php | 8 +- src/Module/Version/Range.php | 5 +- src/Module/Version/Version.php | 76 ++++++++++++++++--- .../Unit/Module/Binary/BinaryVersionTest.php | 30 +++++++- .../Binary/Internal/AbstractBinaryTest.php | 56 ++++++++++++++ tests/Unit/Module/Version/ConstraintTest.php | 42 +++++++++- .../Unit/Module/Version/RangeComposerTest.php | 6 +- tests/Unit/Module/Version/RangeTest.php | 17 +++++ tests/Unit/Module/Version/VersionTest.php | 33 ++++++-- 13 files changed, 253 insertions(+), 33 deletions(-) create mode 100644 tests/Unit/Module/Binary/Internal/AbstractBinaryTest.php diff --git a/skills/dload-fetch-tool/SKILL.md b/skills/dload-fetch-tool/SKILL.md index 5dec7eed..10e70d74 100644 --- a/skills/dload-fetch-tool/SKILL.md +++ b/skills/dload-fetch-tool/SKILL.md @@ -45,7 +45,7 @@ Done when the block has a `` whose `asset-pattern` matches every OS/ | Attribute | Purpose | |---|---| | `software` | Alias or name of the tool (built-in or inline). Required. | -| `version` | Composer-style constraint: `^2025.1`, `~1.0.0`, `^2.12.0@beta`, `^2.12.0-hotfix@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`). | +| `version` | Composer-style constraint: `^2025.1`, `~1.0.0`, `^2.12.0@beta`, `^2.12.0-hotfix@alpha` (feature builds, alpha ones included), one pre-release like `2025.1.0-rc.2`, or a range from one like `^2025.1.0-rc.1`. Omit for latest stable. Pre-releases order by stability (`nightly < snapshot < dev < unstable < alpha < preview < beta < pre < RC < stable`), then by number; `stable` by default. A pre-release in a constraint sets the minimum stability for any operator, even `<3.5.0-beta.2`; lower it with `@alpha`/`@dev`. A keyword without a number is a bound too: `>2.0-beta` takes `2.0.0-RC1` and `2.0.0`. An exact version with a pre-release or another suffix is that one tag: `2.0-beta` skips `2.0.0-beta.3`, `2.12.0-hotfix` skips `2.12.0-hotfix.2`; after `^`, `~` or a comparison a non-stability suffix is a feature the tag must contain. A tagged build like `2025.1.0-rc.2-linux` orders right after its pre-release: an exact `2025.1.0-rc.2` skips it, `>2025.1.0-rc.2` takes it. Numbers may have more than three parts (`1.2.3.4`), in constraints too (`>=1.2.3.4.5`); trailing zero parts and build metadata don't count (`1.2.3` = `1.2.3.0` = `1.2.3+build.5`). Case doesn't matter (`-ARM64` = `-arm64`, `^1.0-Linux` takes `1.0.0-linux`), and a pre-release may be glued to the number as in tags (`^2.0.0rc1`). | | `extract-path` | Target folder (default: project root). | | `type` | `binary` (default), `phar` (required for PHAR — skips extraction), `archive` (unpack the whole asset keeping its folder layout). | diff --git a/src/Module/Binary/Binary.php b/src/Module/Binary/Binary.php index 2bc52375..baca96c0 100644 --- a/src/Module/Binary/Binary.php +++ b/src/Module/Binary/Binary.php @@ -27,7 +27,7 @@ public function getPath(): Path; public function exists(): bool; /** - * Gets the binary version. + * Gets the binary version as printed, with its pre-release and build metadata, e.g. `2.3.4+20230415`. * * @return non-empty-string|null Version string or null if not available */ diff --git a/src/Module/Binary/BinaryVersion.php b/src/Module/Binary/BinaryVersion.php index db64e348..1278d83a 100644 --- a/src/Module/Binary/BinaryVersion.php +++ b/src/Module/Binary/BinaryVersion.php @@ -24,7 +24,7 @@ public static function fromBinaryOutput(string $output): static { // Try to extract version using semantic version pattern $version = \preg_match(self::outputVersionPattern(), $output, $matches) - ? $matches[1] . ($matches[2] ?? '') + ? $matches[1] . ($matches[2] ?? '') . ($matches[3] ?? '') : self::extractVersionWithFallbacks($output); if ($version === null) { @@ -45,7 +45,8 @@ public static function fromBinaryOutput(string $output): static private static function outputVersionPattern(): string { return '/(?:version:?\s*)?(?:v(?:er(?:sion)?)?\.?\s*)?' . parent::VERSION_SEMVER_NUMBER_PATTERN - . '((?:[-+]|(?=(?![ab](?![a-z]|[._-]?\d))' . PreRelease::wordPattern() . '))[\w.-]+)?/i'; + . '((?:[-+]|(?=(?![ab](?![a-z]|[._-]?\d))' . PreRelease::wordPattern() . '))[\w.-]+)?' + . '(' . parent::buildPattern() . ')?/i'; } /** diff --git a/src/Module/Binary/Internal/AbstractBinary.php b/src/Module/Binary/Internal/AbstractBinary.php index 937dc91e..94848d2c 100644 --- a/src/Module/Binary/Internal/AbstractBinary.php +++ b/src/Module/Binary/Internal/AbstractBinary.php @@ -89,7 +89,9 @@ public function getVersion(): ?BinaryVersion public function getVersionString(): ?string { - return $this->getVersion()?->number; + // The whole version as printed, with its pre-release and build metadata + $version = $this->getVersion()?->string; + return $version === '' ? null : $version; } abstract public function getPath(): Path; diff --git a/src/Module/Version/Constraint.php b/src/Module/Version/Constraint.php index e0b23c6f..160b9813 100644 --- a/src/Module/Version/Constraint.php +++ b/src/Module/Version/Constraint.php @@ -16,6 +16,7 @@ * - Combined constraints: ^2.12.0-feature@beta * - Exact releases, a pre-release or a tail included: 3.5.0-beta.1, 2.0-beta, 1.0.0-1, 2.12.0-hotfix, 2.0.0rc1 * - Pre-release bounds of ranges and comparisons: >=3.5.0-beta.1 <3.5.0-RC1, ^1.0 || ^2.0-beta.1, >2.0-beta, ^2.0.0rc1 + * - Build metadata, which does not count: >=1.2.3+build.5, 3.5.0-beta.1+5 * * 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. @@ -68,6 +69,9 @@ private function __construct( ); } + # Build metadata does not count, and a hyphen within it, as in "1.2.3+build-5", starts no feature suffix + $origin = (string) \preg_replace('/(?<=[a-z\d])' . Version::buildPattern() . '(?=$|[\s,|])/i', '', $origin); + # A single version with a pre-release or any other tail names one release: "2.0-beta" accepts neither # "2.0.0-beta.3" nor "2.0.0", and "2.12.0-hotfix" not "2.12.0-hotfix.2"; a bare number covers every build # of that number, see Comparison. In a range, a pre-release bound, a bare "-beta" included, is a part of @@ -77,7 +81,7 @@ private function __construct( $tail = ''; $preRelease = null; if (\preg_match( - '/^(?:==?)?\s*v?(\d+(?:\.\d+)*(?:\+\d+)?)(-[\w.-]+|[._]?(?=[a-z])(?!(?<=\.)x(?![a-z\d]))[\w.-]+)?$/i', + '/^(?:==?)?\s*v?(\d+(?:\.\d+)*)(-[\w.-]+|[._]?(?=[a-z])(?!(?<=\.)x(?![a-z\d]))[\w.-]+)?$/i', $origin, $exact, ) === 1) { @@ -204,7 +208,7 @@ public function __toString(): string private static function preReleaseBounds(string $constraint): array { \preg_match_all( - '/(?:^|[\s,|])[<>=!~^]*v?\d+(?:\.\d+)*(?:\+\d+)?(' . Range::preReleasePattern() . ')(?=$|[\s,|])/i', + '/(?:^|[\s,|])[<>=!~^]*v?\d+(?:\.\d+)*(' . Range::preReleasePattern() . ')(?=$|[\s,|])/i', $constraint, $matches, ); diff --git a/src/Module/Version/Range.php b/src/Module/Version/Range.php index f6d557e2..a9e4b1fd 100644 --- a/src/Module/Version/Range.php +++ b/src/Module/Version/Range.php @@ -14,6 +14,7 @@ * - Hyphen ranges: `1.0 - 2.0` * - AND by a space or a comma, OR by `||` or `|`: `>=1.0 <2.0 || ^3.0` * - A `v` prefix, pre-releases and numeric tails in bounds: `v1.2.3`, `^3.5.0-beta.1`, `^2.0.0rc1`, `>=1.0.0-1` + * - Build metadata, which does not count: `>=1.2.3+build.5`, `^1.2.3-beta.1+5` * * Any count of number parts is supported on both sides, and trailing zero parts do not count. * @@ -88,7 +89,7 @@ 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() . ')?)'; + $version = '(v?\d+(?:\.\d+)*(?:' . self::preReleasePattern() . ')?(?:' . Version::buildPattern() . ')?)'; if (\preg_match("/^{$version}\\s+-\\s+{$version}$/i", $group, $matches) === 1) { return self::parseHyphenRange($matches[1], $matches[2]); } @@ -193,7 +194,7 @@ private static function parseHyphenRange(string $from, string $to): array 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'; + $pattern = '/^v?(\d+(?:\.\d+)*)(' . self::preReleasePattern() . ')?(?:' . Version::buildPattern() . ')?$/i'; if (\preg_match($pattern, $version, $matches) !== 1) { return null; } diff --git a/src/Module/Version/Version.php b/src/Module/Version/Version.php index 1155a0fe..3b8dc793 100644 --- a/src/Module/Version/Version.php +++ b/src/Module/Version/Version.php @@ -13,10 +13,10 @@ */ class Version implements \Stringable { - protected const VERSION_SEMVER_NUMBER_PATTERN = 'v?(\d+\.\d+\.\d+(?:\.\d+)*(?:\+\d+)?)'; + protected const VERSION_SEMVER_NUMBER_PATTERN = 'v?(\d+\.\d+\.\d+(?:\.\d+)*)'; /** A suffix starting with a letter may follow the number right away: `2.0.0rc1`, `1.2.3_beta2`. */ - protected const VERSION_FALLBACK_PATTERN = 'v?(\d+(?:\.\d+)*(?:\+\d+)?)((?:[-+.]|(?=[a-z_]))[\w.-]+)?'; + protected const VERSION_FALLBACK_PATTERN = 'v?(\d+(?:\.\d+)*)((?:[-+.]|(?=[a-z_]))[\w.-]+)?'; protected const VERSION_HASH_SUFFIX_PATTERN = '(?:#([a-f0-9]{6,40}))?'; @@ -38,17 +38,39 @@ final protected function __construct( private readonly bool $withoutKeyword = false, ) {} + /** + * Regular expression of SemVer build metadata: a plus and dot-separated identifiers of letters, digits + * and hyphens, like `+build.5` or `+5-beta.1`. + * + * @return non-empty-string + */ + public static function buildPattern(): string + { + return '\+[0-9a-z-]+(?:\.[0-9a-z-]+)*'; + } + /** * Parses a version string into its components. * + * Build metadata at the end, see {@see buildPattern()}, is dropped as in SemVer: it means nothing for + * the order, the stability or the suffix, so `1.2.3-linux+build.5` is `1.2.3-linux`. It stays only + * in {@see $string}. A plus that starts no metadata, as in `1.2.3+linux_x64`, starts a suffix. + * * @param non-empty-string $string Version string to parse */ public static function fromVersionString(string $string): static { + $version = (string) \preg_replace( + '/' . self::buildPattern() . '(?=' . self::VERSION_HASH_SUFFIX_PATTERN . '$)/i', + '', + $string, + 1, + ); + // Parse the version number \preg_match( '/^' . self::VERSION_FALLBACK_PATTERN . self::VERSION_HASH_SUFFIX_PATTERN . '$/i', - $string, + $version, $parts, ) or throw new \InvalidArgumentException( "Failed version string: {$string}.", @@ -111,7 +133,20 @@ public function compare(self $other): int */ public function compareNumber(self $other): int { - return \version_compare($this->comparableNumber(), $other->comparableNumber()); + $parts = $this->comparableParts(); + $otherParts = $other->comparableParts(); + foreach ($parts as $i => $part) { + if (!isset($otherParts[$i])) { + return 1; + } + + $byPart = self::compareDigits($part, $otherParts[$i]); + if ($byPart !== 0) { + return $byPart; + } + } + + return \count($parts) <=> \count($otherParts); } public function __toString(): string @@ -173,8 +208,7 @@ private static function compareTail(?string $suffix, ?string $other): int $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, + $isNumber => self::compareDigits($token, $otherToken), default => \strcmp($token, $otherToken) <=> 0, }; if ($byToken !== 0) { @@ -185,6 +219,16 @@ private static function compareTail(?string $suffix, ?string $other): int return \count($tokens) <=> \count($otherTokens); } + /** + * Compares digit strings of any length without leading zeros: as integers, long ones would overflow. + * + * @return int<-1, 1> + */ + private static function compareDigits(string $digits, string $other): int + { + return (\strlen($digits) <=> \strlen($other)) ?: \strcmp($digits, $other) <=> 0; + } + /** * Words in lower case and numbers without leading zeros, like `linux`, `x`, `64` and `1` of `Linux_x64.01`. * @@ -203,16 +247,26 @@ private static function tokens(?string $suffix): array /** * 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, - * nor does build metadata, as in SemVer: `1.2.3+5` is `1.2.3`. + * Leading zeros and trailing zero parts do not count: `1.2.3`, `01.2.3.0` and `1.2.3.0.0` are one number. + * + * @return list Parts without leading zeros, empty for a version without a number */ - private function comparableNumber(): string + private function comparableParts(): array { - $number = \explode('+', (string) $this->number, 2)[0]; + if ($this->number === null) { + return []; + } + + $number = $this->number; if ($this->withoutKeyword && $this->suffix !== null && \preg_match('/^\d+(?:\.\d+)*$/', $this->suffix) === 1) { $number .= '.' . $this->suffix; } - return (string) \preg_replace('/(?:\.0+)+$/', '', $number); + $parts = \array_map(static fn(string $part): string => \ltrim($part, '0') ?: '0', \explode('.', $number)); + while (\count($parts) > 1 && \end($parts) === '0') { + \array_pop($parts); + } + + return $parts; } } diff --git a/tests/Unit/Module/Binary/BinaryVersionTest.php b/tests/Unit/Module/Binary/BinaryVersionTest.php index 6794bd8a..4adbcd14 100644 --- a/tests/Unit/Module/Binary/BinaryVersionTest.php +++ b/tests/Unit/Module/Binary/BinaryVersionTest.php @@ -62,7 +62,25 @@ public static function provideSemanticVersionOutputs(): \Generator yield 'semver with build metadata' => [ 'App version 2.3.4+20230415', '2.3.4+20230415', - '2.3.4+20230415', + '2.3.4', + ]; + + yield 'build metadata with words' => [ + 'tool 1.2.3+build.5', + '1.2.3+build.5', + '1.2.3', + ]; + + yield 'build metadata before other text' => [ + 'tool v1.2.3-rc.1+exp.sha.5114f85, built today', + '1.2.3-rc.1+exp.sha.5114f85', + '1.2.3', + ]; + + yield 'plus apart from the version' => [ + 'tool 1.2.3 +build.5', + '1.2.3', + null, ]; yield 'pre-release without a separator' => [ @@ -176,7 +194,7 @@ public static function provideSemanticVersionOutputs(): \Generator yield 'build metadata after a pre-release' => [ 'v1.2.3-rc.1+build.5', - '1.2.3-rc.1', + '1.2.3-rc.1+build.5', '1.2.3', ]; @@ -263,6 +281,14 @@ public function preReleaseGluedToTheNumberKeepsItsStability(string $output, Stab Assert::same(BinaryVersion::fromBinaryOutput($output)->stability, $stability); } + #[DataSet(['tool 1.2.3+beta.2', Stability::Stable], 'build metadata like a beta')] + #[DataSet(['tool 1.2.3-rc.1+build.5', Stability::RC], 'release candidate before build metadata')] + #[Test] + public function buildMetadataDoesNotChangeTheStability(string $output, Stability $stability): void + { + Assert::same(BinaryVersion::fromBinaryOutput($output)->stability, $stability); + } + #[DataSet(['tool 1.2.3beta2', '1.2.3', Stability::Beta, 2], 'beta word with a number')] #[DataSet(['tool 2.0.0alpha', '2.0.0', Stability::Alpha, 0], 'alpha word')] #[Test] diff --git a/tests/Unit/Module/Binary/Internal/AbstractBinaryTest.php b/tests/Unit/Module/Binary/Internal/AbstractBinaryTest.php new file mode 100644 index 00000000..1ddec3b1 --- /dev/null +++ b/tests/Unit/Module/Binary/Internal/AbstractBinaryTest.php @@ -0,0 +1,56 @@ + ['App version 2.3.4+20230415', '2.3.4+20230415']; + yield 'pre-release with build metadata' => ['tool v1.2.3-rc.1+build.5', '1.2.3-rc.1+build.5']; + yield 'plain version' => ['tool 1.2.3', '1.2.3']; + yield 'no version' => ['no version here', null]; + } + + #[DataProvider('provideVersionOutputs')] + #[Test] + public function versionStringShowsTheWholeVersion(string $output, ?string $expected): void + { + $binary = new class('tool', new BinaryConfig(), new BinaryExecutor(new Logger()), $output) extends AbstractBinary { + public function __construct( + string $name, + BinaryConfig $config, + BinaryExecutor $executor, + private readonly string $output, + ) { + parent::__construct($name, $config, $executor); + } + + public function getVersion(): BinaryVersion + { + return BinaryVersion::fromBinaryOutput($this->output); + } + + public function getPath(): Path + { + return Path::create('tool'); + } + }; + + Assert::same($binary->getVersionString(), $expected); + } +} diff --git a/tests/Unit/Module/Version/ConstraintTest.php b/tests/Unit/Module/Version/ConstraintTest.php index f4ae4da1..5ce3da85 100644 --- a/tests/Unit/Module/Version/ConstraintTest.php +++ b/tests/Unit/Module/Version/ConstraintTest.php @@ -275,6 +275,13 @@ public static function provideValidConstraints(): \Generator yield 'exact patch letter of an alpha abbreviation' => ['1.1.1a', '1.1.1a', null, Stability::Preview, 'Patch letter a']; yield 'exact word after a dot' => ['1.0.0.Final', '1.0.0.Final', null, Stability::Preview, 'Word after a dot']; yield 'glued release candidate with an explicit stability' => ['^2.0.0rc1@beta', '^2.0.0', null, Stability::Beta, 'Glued bound']; + + // Build metadata counts for nothing + yield 'build metadata with words' => ['>=1.2.3+build.5', '>=1.2.3', null, Stability::Stable, 'Build metadata']; + yield 'build metadata like a stability' => ['1.2.3+beta', '1.2.3', null, Stability::Stable, 'Build metadata like a stability']; + yield 'build metadata with a hyphen' => ['^1.2.3+build-5', '^1.2.3', null, Stability::Stable, 'No feature in build metadata']; + yield 'feature before build metadata' => ['^1.2.3-feature+build.5', '^1.2.3', 'feature', Stability::Preview, 'Feature before build metadata']; + yield 'build metadata in a range' => ['>=1.0+5 <2.0+build.1 || ^3.0+x', '>=1.0 <2.0 || ^3.0', null, Stability::Stable, 'Build metadata of every term']; } public static function provideInvalidConstraints(): \Generator @@ -343,6 +350,9 @@ public static function provideInvalidConstraints(): \Generator yield 'branch' => ['dev-master', 'Invalid base version format: dev.', 'Branch name']; yield 'empty alternative' => ['^1.0 ||', 'Empty alternative', 'Empty alternative']; yield 'empty term' => ['1.2,', 'Empty term in the version constraint `1.2,`.', 'Empty term']; + yield 'plus is no operator' => ['+1.2.3', 'Invalid base version format: +1.2.3.', 'Plus before the version']; + yield 'plus without build metadata' => ['>=1.2.3+', 'Invalid base version format: >=1.2.3+.', 'Empty build metadata']; + yield 'underscore in build metadata' => ['1.2.3+5_beta', 'Invalid base version format: 1.2.3+5_beta.', 'Underscore in build metadata']; yield 'feature suffix after a pre-release bound of a range' => ['>=3.5.0-beta.1 <3.5.0-custom', 'Invalid base version format: <3.5.0-custom.', 'Feature in a range']; } @@ -493,6 +503,25 @@ public static function provideComparableConstraints(): \Generator yield 'bare number with a looser stability on a platform build' => ['1.2.3@preview', 'v1.2.3-linux', true]; yield 'bare number with a looser stability on a pre-release' => ['=1.2.3@beta', 'v1.2.3-beta.1', true]; yield 'bare number on build metadata' => ['1.2.3', 'v1.2.3+5', true]; + yield 'bare number on build metadata with words' => ['1.2.3', 'v1.2.3+build.5', true]; + + // Build metadata counts for nothing on either side + yield 'at least with build metadata on its number' => ['>=1.2.3+build.5', 'v1.2.3', true]; + yield 'above with build metadata on other build metadata' => ['>1.2.3+build.5', 'v1.2.3+build.6', false]; + yield 'exact pre-release with build metadata on itself' => ['1.2.3-beta.1+build.5', 'v1.2.3-beta.1', true]; + yield 'exact pre-release with build metadata on the next one' => ['1.2.3-beta.1+build.5', 'v1.2.3-beta.2', false]; + yield 'exact pre-release on itself with build metadata' => ['1.2.3-beta.1', 'v1.2.3-beta.1+build.5', true]; + yield 'above a pre-release with build metadata on the next one' => ['>1.2.3-beta.1+5', 'v1.2.3-beta.2', true]; + yield 'build metadata like a stability on its release' => ['1.2.3+beta', 'v1.2.3', true]; + yield 'build metadata like a stability on a beta' => ['1.2.3+beta', 'v1.2.3-beta', false]; + yield 'caret with build metadata with a hyphen' => ['^1.2.3+build-5', 'v1.4.0', true]; + yield 'feature before build metadata' => ['^1.2.3-feature+build.5', 'v1.4.0-feature', true]; + yield 'hyphen range with build metadata' => ['1.0+build.1 - 2.0+build.2', 'v2.0.0', true]; + + // Number parts past the integer range + yield 'above a long number part on the next one' => ['>1.99999999999999999998', 'v1.99999999999999999999', true]; + yield 'below a long number part on the previous one' => ['<1.99999999999999999999', 'v1.99999999999999999998', true]; + yield 'exact long number part on the next one' => ['1.99999999999999999998', 'v1.99999999999999999999', false]; // A bare stability keyword with an explicit stability is a bound too yield 'caret from a bare keyword with a looser stability on its bound' => ['^2.0-beta@alpha', 'v2.0.0-beta', true]; @@ -622,10 +651,13 @@ public static function provideComparableConstraints(): \Generator // 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 'exact feature build before build metadata on a numbered one' => ['1.2.3-hotfix+5', '1.2.3-hotfix.2', false]; + yield 'exact feature build before build metadata' => ['1.2.3-hotfix+5', '1.2.3-hotfix', true]; + yield 'build metadata with a hyphen is no feature build' => ['1.2.3+5-hotfix', '1.2.3-hotfix', false]; + yield 'build metadata with a hyphen on its number' => ['1.2.3+5-hotfix', '1.2.3', true]; yield 'above a prefixed bare keyword on a release candidate' => ['>v2.0-beta', '2.0.0-RC1', true]; - yield 'caret from a bare keyword after build metadata on a release candidate' => ['^1.2.3+5-beta@alpha', '1.2.3-RC1', true]; + yield 'caret from a bare keyword before build metadata on a release candidate' => ['^1.2.3-beta+5', '1.2.3-RC1', true]; + yield 'caret with build metadata like a bare keyword on a release candidate' => ['^1.2.3+5-beta', '1.2.3-RC1', false]; // A pre-release glued to the number yield 'caret from a glued release candidate on itself' => ['^2.0.0rc1', '2.0.0rc1', true]; @@ -679,7 +711,8 @@ public static function providePreReleases(): \Generator 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 'pre-release before build metadata' => ['1.2.3-beta.1+build.5', '1.2.3', Stability::Beta, 1, Stability::Beta]; + yield 'pre-release bound before build metadata' => ['>=1.2.3-rc.2+5', '>=1.2.3', Stability::RC, 2, Stability::RC]; yield 'release candidate glued to the number of a caret' => ['^2.0.0rc1', '^2.0.0', Stability::RC, 1, Stability::RC]; yield 'release candidate glued to an exact number' => ['2.0.0rc1', '2.0.0', Stability::RC, 1, Stability::RC]; yield 'release candidate after a dot' => ['=2.0.0.RC2', '=2.0.0', Stability::RC, 2, Stability::RC]; @@ -772,6 +805,7 @@ public function rejectsAnUnknownOperatorWithAPreRelease(): never public function toStringKeepsThePreRelease(): void { Assert::same((string) Constraint::fromConstraintString('^3.5.0-beta.1@beta'), '^3.5.0-beta.1@beta'); + Assert::same((string) Constraint::fromConstraintString('>=1.2.3-beta.1+build.5'), '>=1.2.3-beta.1+build.5'); } #[DataProvider('provideComparableConstraints')] diff --git a/tests/Unit/Module/Version/RangeComposerTest.php b/tests/Unit/Module/Version/RangeComposerTest.php index 64f51c3d..2da09a11 100644 --- a/tests/Unit/Module/Version/RangeComposerTest.php +++ b/tests/Unit/Module/Version/RangeComposerTest.php @@ -28,24 +28,26 @@ final class RangeComposerTest '1.0 - 2.0', '1 - 2', '1.2.3 - 2.3', '1.0.0 - 2.0.0', '1.2 - 2.3.4', '0.3 - 1.2.3.4', '^1.2 || ^2.0', '1.0|2.0', '~0.3|^1', '>=1.0 <2.0', '>=1.0,<2.0', '>=1.0, <2.0', '>1.0 <=1.2.3', '>=1.0 <2.0 || >=3.0', '^1.0 !=1.2.3', '>= 1.0 < 2.0', '1.2.3+5', '^1.2.3+5', + '>=1.2.3+build.5', '^1.2+exp.sha.5114f85', '1.0+a - 2.0+b-1', ]; private const VERSIONS = [ '0', '0.0.0', '0.0.1', '0.0.3', '0.0.3.4', '0.0.3.9', '0.0.4', '0.1', '0.2.9', '0.3', '0.3.1', '0.3.2', '0.3.9', '0.4', '0.9.9', '1', '1.0.1', '1.1', '1.2', '1.2.2', '1.2.3', '1.2.3.0', '1.2.3.1', '1.2.3.4', '1.2.3.5', '1.2.4', '1.2.10', '1.3', '1.9.9', '1.99.99.99', '2', '2.0.1', '2.3', '2.3.4', '2.3.5', '2.4', '3', '3.1', - '10.0.0', '99999.0.0', '1.2.3+5', '1.2.4+1', + '10.0.0', '99999.0.0', '1.2.3+5', '1.2.4+1', '1.2.3+build.5', '2.0+exp.sha.5114f85', ]; /** Pre-release bounds and the versions they decide between. */ private const PRE_RELEASE_CONSTRAINTS = [ '1.2.3-beta.1', '=1.2.3-RC1', '!=1.2.3-beta.1', '>=1.2.3-beta.1', '>1.2.3-beta.1', '<1.2.3-beta.2', '<=1.2.3-rc.1', '^1.2.3-RC1', '~1.2.3-alpha.2', '^0.3.2-beta.1', '1.0 - 1.2.3-beta.2', + '>=1.2.3-beta.1+build.5', '<1.2.3-RC1+5', ]; private const PRE_RELEASE_VERSIONS = [ '1.2.2', '1.2.2-RC1', '1.2.3-alpha.1', '1.2.3-alpha.2', '1.2.3-beta.1', '1.2.3-beta.2', '1.2.3-RC1', '1.2.3-RC2', '1.2.3', '1.2.4-alpha.1', '1.2.4', '1.3.0-beta.1', '2.0.0-beta.1', '2.0.0', '0.3.2-beta.1', - '0.3.2', '0.3.9', '0.4.0-beta.1', + '0.3.2', '0.3.9', '0.4.0-beta.1', '1.2.3-beta.2+build.7', '1.2.3-RC1+5', ]; #[Test] diff --git a/tests/Unit/Module/Version/RangeTest.php b/tests/Unit/Module/Version/RangeTest.php index 59cac134..fff44a7f 100644 --- a/tests/Unit/Module/Version/RangeTest.php +++ b/tests/Unit/Module/Version/RangeTest.php @@ -300,6 +300,20 @@ public static function provideMatches(): \Generator yield ['^1.2.3+5', '1.9', true]; yield ['~1.2.3+5', '1.3', false]; yield ['1.0 - 2+5', '2.5', true]; + yield ['>=1.2.3+build.5', '1.2.3', true]; + yield ['>1.2.3+build.5', '1.2.3+build.6', false]; + yield ['1.2.3', '1.2.3+exp.sha.5114f85', true]; + yield ['1.2.3-beta.1+build.5', '1.2.3-beta.1', true]; + yield ['1.2.3-beta.1+build.5', '1.2.3-beta.2', false]; + yield ['^1.2.3-beta.1+5', '1.2.3-beta.2', true]; + yield ['1.0+build.1 - 2.0+build-2', '2.0.0', true]; + yield ['1.0-beta+5 - 2.0', '1.0.0-alpha', false]; + + // Long number parts compare by value + yield ['>1.99999999999999999998', '1.99999999999999999999', true]; + yield ['<=1.99999999999999999998', '1.99999999999999999999', false]; + yield ['1.99999999999999999999', '1.0099999999999999999999', true]; + yield ['>=1.99999999999999999999.0.0', '1.99999999999999999999', true]; } public static function provideInvalidConstraints(): \Generator @@ -309,6 +323,9 @@ public static function provideInvalidConstraints(): \Generator yield 'word' => ['abc', 'Invalid base version format: abc.']; yield 'branch' => ['dev-master', 'Invalid base version format: dev-master.']; yield 'feature suffix' => ['1.2.3-feature', 'Invalid base version format: 1.2.3-feature.']; + yield 'plus as an operator' => ['+1.2.3', 'Invalid base version format: +1.2.3.']; + yield 'plus without build metadata' => ['1.2.3+', 'Invalid base version format: 1.2.3+.']; + yield 'underscore in build metadata' => ['1.2.3+5_beta', 'Invalid base version format: 1.2.3+5_beta.']; yield 'letter in the number' => ['1.2x.3', 'Invalid base version format: 1.2x.3.']; yield 'lone patch letter' => ['^1.1.1b', 'Invalid base version format: ^1.1.1b.']; yield 'word glued to a pre-release' => ['^2.0.0rc1x', 'Invalid base version format: ^2.0.0rc1x.']; diff --git a/tests/Unit/Module/Version/VersionTest.php b/tests/Unit/Module/Version/VersionTest.php index ea66112e..bbf37f5d 100644 --- a/tests/Unit/Module/Version/VersionTest.php +++ b/tests/Unit/Module/Version/VersionTest.php @@ -35,6 +35,7 @@ public static function providePreReleases(): \Generator yield 'feature only' => ['1.2.3-custom', '1.2.3', Stability::Preview, 0]; yield 'four number parts' => ['1.2.3.4-beta.1', '1.2.3.4', Stability::Beta, 1]; yield 'five number parts' => ['v1.2.3.4.5-rc.2', '1.2.3.4.5', Stability::RC, 2]; + yield 'pre-release before build metadata' => ['1.2.3-rc.2+build.5', '1.2.3', Stability::RC, 2]; } public static function provideOrderedVersions(): \Generator @@ -77,6 +78,12 @@ public static function provideOrderedVersions(): \Generator yield 'patch abbreviation and the whole word' => ['1.0.0-p1', '1.0.0-patch1']; yield 'word before a number' => ['2.0.0-beta.1-linux', '2.0.0-beta.1.2']; yield 'word before a number at the start of the tail' => ['1.0.0-x.1', '1.0.0-1.x']; + yield 'long number parts' => ['1.99999999999999999998', '1.99999999999999999999']; + yield 'long number part after a short one' => ['1.9', '1.10000000000000000000']; + yield 'long first number parts' => ['99999999999999999998.1', '99999999999999999999.0']; + yield 'part after a long number part' => ['1.99999999999999999999', '1.99999999999999999999.1']; + yield 'long numeric suffixes' => ['1.0.0-99999999999999999998', '1.0.0-99999999999999999999']; + yield 'pre-release before build metadata like a stability' => ['1.2.3-beta', '1.2.3+beta']; yield 'platform word is no alpha' => ['1.0.0-arm64', '1.0.0-linux']; yield 'patch abbreviation is no patch level' => ['1.0.0-p1', '1.0.0-x1']; yield 'number after a word' => ['1.0.0-build.9', '1.0.0-build.10']; @@ -96,6 +103,12 @@ public static function provideEqualVersions(): \Generator yield 'build metadata' => ['1.2.3', '1.2.3+5']; yield 'build metadata after two parts' => ['1.2', '1.2+5']; yield 'build metadata after one part' => ['1', '1+2']; + yield 'build metadata with words' => ['1.2.3', 'v1.2.3+build.5']; + yield 'other build metadata' => ['1.2.3+build.5', '1.2.3+build.6']; + yield 'build metadata after a pre-release' => ['1.2.3-beta.1', '1.2.3-beta.1+build.5']; + yield 'build metadata after a tail' => ['1.2.3-linux', '1.2.3-linux+exp.sha.5114f85']; + yield 'leading zeros of number parts' => ['1.2.3', '01.002.3']; + yield 'leading zeros of a long number part' => ['1.99999999999999999999', '1.0099999999999999999999']; yield 'case, separators and leading zeros of the suffix' => ['1.0.0-Linux_x64.01', '1.0.0-linux-x64.1']; yield 'case of a platform word' => ['1.0.0-arm64', '1.0.0-ARM64']; yield 'case of a patch word' => ['2.12.0-patch1', '2.12.0-Patch1']; @@ -114,7 +127,7 @@ public static function provideValidVersionStrings(): \Generator // Semantic versions yield 'basic semantic version' => ['1.2.3', '1.2.3', '1.2.3', null, Stability::Stable]; yield 'semantic version with patch number' => ['2.12.5', '2.12.5', '2.12.5', null, Stability::Stable]; - yield 'semantic version with build number' => ['1.0.0+123', '1.0.0+123', '1.0.0+123', null, Stability::Stable]; + yield 'semantic version with build number' => ['1.0.0+123', '1.0.0+123', '1.0.0', null, Stability::Stable]; // Versions with stability suffixes yield 'version with beta suffix' => ['1.2.3-beta', '1.2.3-beta', '1.2.3', null, Stability::Beta]; @@ -129,9 +142,17 @@ public static function provideValidVersionStrings(): \Generator yield 'preview is not read as pre' => ['1.0.0-preview-foo', '1.0.0-preview-foo', '1.0.0', 'foo', Stability::Preview]; yield 'version with multiple features and stability' => ['2.0.0-feature1-feature2-alpha', '2.0.0-feature1-feature2-alpha', '2.0.0', 'feature1-feature2', Stability::Alpha]; - // Versions with plus prefix - yield 'version with plus prefix stability' => ['1.2.3+beta', '1.2.3+beta', '1.2.3', null, Stability::Beta]; - yield 'version with plus prefix feature' => ['1.2.3+feature-alpha', '1.2.3+feature-alpha', '1.2.3', 'feature', Stability::Alpha]; + // Build metadata counts for nothing, whatever it looks like + yield 'build metadata with words' => ['1.2.3+build.5', '1.2.3+build.5', '1.2.3', null, Stability::Stable]; + yield 'build metadata like a stability' => ['1.2.3+beta', '1.2.3+beta', '1.2.3', null, Stability::Stable]; + yield 'build metadata like a feature with a stability' => ['1.2.3+feature-alpha', '1.2.3+feature-alpha', '1.2.3', null, Stability::Stable]; + yield 'build metadata after a pre-release' => ['1.2.3-beta.1+build.5', '1.2.3-beta.1+build.5', '1.2.3', null, Stability::Beta]; + yield 'numeric build metadata after a pre-release' => ['1.2.3-beta.1+5', '1.2.3-beta.1+5', '1.2.3', null, Stability::Beta]; + yield 'build metadata after a tail' => ['1.2.3-linux+build.5', '1.2.3-linux+build.5', '1.2.3', 'linux', Stability::Preview]; + yield 'build metadata with hyphens' => ['1.0.0-alpha11+cs-1.1.0', '1.0.0-alpha11+cs-1.1.0', '1.0.0', null, Stability::Alpha]; + yield 'build metadata after two parts' => ['v1.2+exp.sha.5114f85', 'v1.2+exp.sha.5114f85', '1.2', null, Stability::Stable]; + yield 'build metadata before a hash' => ['1.2.3+build.5#abcdef1', '1.2.3+build.5#abcdef1', '1.2.3', null, Stability::Stable]; + yield 'plus before a suffix that is no build metadata' => ['1.2.3+linux_x64', '1.2.3+linux_x64', '1.2.3', 'linux_x64', Stability::Preview]; // Partial semantic versions (fallback pattern) yield 'two-part version' => ['1.2', '1.2', '1.2', null, Stability::Stable]; @@ -173,7 +194,6 @@ public static function provideInvalidVersionStrings(): \Generator yield 'version starting with text' => ['version1.2.3']; yield 'dev-master' => ['dev-master']; yield 'dev-feature+issue-1' => ['dev-feature+issue-1']; - yield '1.0.0-alpha11+cs-1.1.0' => ['1.0.0-alpha11+cs-1.1.0']; yield '1.0.0-beta#comment-part' => ['1.0.0-beta#comment-part']; } @@ -309,6 +329,9 @@ public function compareTreatsVersionsAsEqual(string $a, string $b): void public function emptyVersionComesFirst(): void { Assert::same(Version::empty()->compare(Version::fromVersionString('0.0.1')), -1); + Assert::same(Version::empty()->compare(Version::fromVersionString('0.0')), -1); + Assert::same(Version::fromVersionString('0')->compare(Version::empty()), 1); + Assert::same(Version::empty()->compare(Version::empty()), 0); } /**