diff --git a/src/Module/Version/Constraint.php b/src/Module/Version/Constraint.php index 160b981..c1ee0b3 100644 --- a/src/Module/Version/Constraint.php +++ b/src/Module/Version/Constraint.php @@ -13,7 +13,7 @@ * - 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 + * - Combined constraints: ^2.12.0-feature@beta, ^2.12.0-beta.1-feature * - 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 @@ -40,8 +40,9 @@ final class Constraint implements \Stringable /** * @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}. + * e.g. `beta.1` of `3.5.0-beta.1` or of `^3.5.0-beta.1-feature`. Null when there is none, when a tail + * follows it in an exact version, or when the constraint has several terms: their pre-releases stay + * in {@see $versionConstraint}. */ public readonly ?PreRelease $preRelease; @@ -79,6 +80,7 @@ private function __construct( # 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 = ''; + $feature = ''; $preRelease = null; if (\preg_match( '/^(?:==?)?\s*v?(\d+(?:\.\d+)*)(-[\w.-]+|[._]?(?=[a-z])(?!(?<=\.)x(?![a-z\d]))[\w.-]+)?$/i', @@ -97,6 +99,19 @@ private function __construct( $singleTerm = \preg_match('/[\s,|]/', (string) \preg_replace('/^[<>=!~^]*\s*/', '', $origin)) !== 1; $singleTerm and $tail = $bounds[0]; $preRelease = PreRelease::fromString(\ltrim($tail, '-._')); + } elseif (\preg_match( + '/^([<>=!~^]*\s*v?\d+(?:\.\d+)*)(' . Range::preReleasePattern() . ')-([\w.-]+)$/i', + $origin, + $bound, + ) === 1) { + # A suffix after the pre-release bound of a single range is a feature: "^3.5.0-beta.1-feature" + /** @var non-empty-string $tail */ + $tail = $bound[2]; + $origin = $bound[1] . $tail; + $feature = $bound[3]; + $matcher = Range::fromString($origin); + $stability ??= self::lowestStability([$tail]); + $preRelease = PreRelease::fromString(\ltrim($tail, '-._')); } $preRelease === null or $origin = \substr($origin, 0, -\strlen($tail)); @@ -104,7 +119,7 @@ private function __construct( # 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, ''] + ? [$origin, $feature] : \explode('-', $origin, 2) + [1 => '']; if ($suffix !== '') { // Check if suffix is a stability keyword (only if no explicit stability provided) diff --git a/tests/Unit/Module/Version/ConstraintTest.php b/tests/Unit/Module/Version/ConstraintTest.php index 5ce3da8..ed81c3f 100644 --- a/tests/Unit/Module/Version/ConstraintTest.php +++ b/tests/Unit/Module/Version/ConstraintTest.php @@ -215,8 +215,8 @@ public static function provideValidConstraints(): \Generator yield 'base with multiple dashes' => [ '^2.12.0-alpha.1-feature', '^2.12.0', - 'alpha.1-feature', - Stability::Preview, + 'feature', + Stability::Alpha, 'Base version with multiple dashes and feature suffix', ]; @@ -260,6 +260,17 @@ public static function provideValidConstraints(): \Generator 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 feature after the pre-release bound of a single range + yield 'feature after a numbered pre-release of a caret' => ['^3.5.0-beta.1-feature', '^3.5.0', 'feature', Stability::Beta, 'Feature after a caret bound']; + yield 'feature after a numbered pre-release of a tilde' => ['~3.5.0-beta.1-feature', '~3.5.0', 'feature', Stability::Beta, 'Feature after a tilde bound']; + yield 'feature after a numbered pre-release of at least' => ['>=3.5.0-beta.1-feature', '>=3.5.0', 'feature', Stability::Beta, 'Feature after an at least bound']; + yield 'feature after a glued pre-release' => ['^3.5.0-rc1-feature', '^3.5.0', 'feature', Stability::RC, 'Feature after a glued bound']; + yield 'feature after a bare stability keyword' => ['^3.5.0-beta-feature', '^3.5.0', 'feature', Stability::Beta, 'Feature after a bare keyword']; + yield 'hyphenated feature after a pre-release' => ['^3.5.0-beta.1-my-feature', '^3.5.0', 'my-feature', Stability::Beta, 'Hyphenated feature after a bound']; + yield 'feature after a pre-release with an explicit stability' => ['^3.5.0-beta.1-feature@alpha', '^3.5.0', 'feature', Stability::Alpha, 'Explicit stability over a bound']; + yield 'feature after an uppercase pre-release' => ['^3.5.0-BETA.1-Feature', '^3.5.0', 'Feature', Stability::Beta, 'Uppercase bound']; + yield 'exact pre-release before a feature' => ['3.5.0-beta.1-feature', '3.5.0-beta.1-feature', null, Stability::Beta, 'Exact release']; + // 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']; @@ -659,6 +670,35 @@ public static function provideComparableConstraints(): \Generator 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 followed by a feature: exact names one release, a range filters by the feature + yield 'exact pre-release with a feature on itself' => ['3.5.0-beta.1-feature', 'v3.5.0-beta.1-feature', true]; + yield 'exact pre-release with a feature in another case' => ['3.5.0-BETA.1-FEATURE', 'v3.5.0-beta.1-feature', true]; + yield 'exact pre-release with a feature in another spelling' => ['3.5.0-beta1-feature', 'v3.5.0-beta.1-feature', true]; + yield 'exact pre-release with a feature on the next pre-release' => ['3.5.0-beta.1-feature', 'v3.5.0-beta.2-feature', false]; + yield 'exact pre-release with a feature on the pre-release alone' => ['3.5.0-beta.1-feature', 'v3.5.0-beta.1', false]; + yield 'exact pre-release with a feature on the feature alone' => ['3.5.0-beta.1-feature', 'v3.5.0-feature', false]; + yield 'exact pre-release with a feature on another feature' => ['3.5.0-beta.1-feature', 'v3.5.0-beta.1-other', false]; + yield 'exact pre-release with a feature and an explicit stability' => ['3.5.0-beta.1-feature@beta', 'v3.5.0-beta.1-feature', true]; + yield 'caret from a pre-release with a feature on itself' => ['^3.5.0-beta.1-feature', 'v3.5.0-beta.1-feature', true]; + yield 'caret from an alpha with a feature on itself' => ['^2.12.0-alpha.1-feature', '2.12.0-alpha.1-feature', true]; + yield 'caret from a pre-release with a feature on the next pre-release' => ['^3.5.0-beta.1-feature', 'v3.5.0-rc.1-feature', true]; + yield 'caret from a pre-release with a feature on a later version' => ['^3.5.0-beta.1-feature', 'v3.5.1-beta.1-feature', true]; + yield 'caret from a pre-release with a feature in another case' => ['^3.5.0-beta.1-feature', 'v3.5.0-BETA.1-Feature', true]; + yield 'caret from a pre-release with a feature below the bound' => ['^3.5.0-beta.1-feature', 'v3.5.0-alpha.1-feature', false]; + yield 'caret from a pre-release with a feature on an older version' => ['^3.5.0-beta.1-feature', 'v3.4.0-beta.1-feature', false]; + yield 'caret from a pre-release with a feature on another feature' => ['^3.5.0-beta.1-feature', 'v3.5.0-beta.2-other', false]; + yield 'caret from a pre-release with a feature on a release without it' => ['^3.5.0-beta.1-feature', 'v3.5.0-beta.2', false]; + yield 'caret from a pre-release with a feature on a feature build' => ['^3.5.0-beta.1-feature', 'v3.6.0-feature', false]; + yield 'caret from a pre-release with a feature and a looser stability' => ['^3.5.0-beta.1-feature@alpha', 'v3.6.0-feature', true]; + yield 'caret from a pre-release with a feature and a stricter stability' => ['^3.5.0-beta.1-feature@rc', 'v3.5.0-beta.2-feature', false]; + yield 'tilde from a pre-release with a feature' => ['~3.5.0-beta.1-feature', 'v3.5.2-rc.1-feature', true]; + yield 'tilde from a pre-release with a feature past the minor' => ['~3.5.0-beta.1-feature', 'v3.6.0-rc.1-feature', false]; + yield 'at least a pre-release with a feature' => ['>=3.5.0-beta.1-feature', 'v4.0.0-beta.1-feature', true]; + yield 'at least a pre-release with a feature below the bound' => ['>=3.5.0-beta.2-feature', 'v3.5.0-beta.1-feature', false]; + yield 'at least a glued pre-release with a feature' => ['>=3.5.0rc1-feature', 'v3.5.0-RC2-feature', true]; + yield 'feature with a stability instead of a pre-release' => ['^3.5.0-feature@beta', 'v3.5.0-beta.1-feature', true]; + yield 'bare stability keyword with a feature below the keyword' => ['^3.5.0-beta-feature@alpha', 'v3.5.0-alpha.1-feature', false]; + // A pre-release glued to the number yield 'caret from a glued release candidate on itself' => ['^2.0.0rc1', '2.0.0rc1', true]; yield 'caret from a glued release candidate on the next one' => ['^2.0.0rc1', 'v2.0.0-RC2', true];