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); } /**