Chris@0: Chris@0: * Chris@0: * For the full copyright and license information, please view Chris@0: * the LICENSE file that was distributed with this source code. Chris@0: */ Chris@0: Chris@0: namespace Composer\Semver; Chris@0: Chris@0: use Composer\Semver\Constraint\ConstraintInterface; Chris@0: use Composer\Semver\Constraint\EmptyConstraint; Chris@0: use Composer\Semver\Constraint\MultiConstraint; Chris@0: use Composer\Semver\Constraint\Constraint; Chris@0: Chris@0: /** Chris@0: * Version parser. Chris@0: * Chris@0: * @author Jordi Boggiano Chris@0: */ Chris@0: class VersionParser Chris@0: { Chris@0: /** Chris@0: * Regex to match pre-release data (sort of). Chris@0: * Chris@0: * Due to backwards compatibility: Chris@0: * - Instead of enforcing hyphen, an underscore, dot or nothing at all are also accepted. Chris@0: * - Only stabilities as recognized by Composer are allowed to precede a numerical identifier. Chris@0: * - Numerical-only pre-release identifiers are not supported, see tests. Chris@0: * Chris@0: * |--------------| Chris@0: * [major].[minor].[patch] -[pre-release] +[build-metadata] Chris@0: * Chris@0: * @var string Chris@0: */ Chris@0: private static $modifierRegex = '[._-]?(?:(stable|beta|b|RC|alpha|a|patch|pl|p)((?:[.-]?\d+)*+)?)?([.-]?dev)?'; Chris@0: Chris@0: /** @var array */ Chris@0: private static $stabilities = array('stable', 'RC', 'beta', 'alpha', 'dev'); Chris@0: Chris@0: /** Chris@0: * Returns the stability of a version. Chris@0: * Chris@0: * @param string $version Chris@0: * Chris@0: * @return string Chris@0: */ Chris@0: public static function parseStability($version) Chris@0: { Chris@0: $version = preg_replace('{#.+$}i', '', $version); Chris@0: Chris@0: if ('dev-' === substr($version, 0, 4) || '-dev' === substr($version, -4)) { Chris@0: return 'dev'; Chris@0: } Chris@0: Chris@0: preg_match('{' . self::$modifierRegex . '(?:\+.*)?$}i', strtolower($version), $match); Chris@0: if (!empty($match[3])) { Chris@0: return 'dev'; Chris@0: } Chris@0: Chris@0: if (!empty($match[1])) { Chris@0: if ('beta' === $match[1] || 'b' === $match[1]) { Chris@0: return 'beta'; Chris@0: } Chris@0: if ('alpha' === $match[1] || 'a' === $match[1]) { Chris@0: return 'alpha'; Chris@0: } Chris@0: if ('rc' === $match[1]) { Chris@0: return 'RC'; Chris@0: } Chris@0: } Chris@0: Chris@0: return 'stable'; Chris@0: } Chris@0: Chris@0: /** Chris@0: * @param string $stability Chris@0: * Chris@0: * @return string Chris@0: */ Chris@0: public static function normalizeStability($stability) Chris@0: { Chris@0: $stability = strtolower($stability); Chris@0: Chris@0: return $stability === 'rc' ? 'RC' : $stability; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Normalizes a version string to be able to perform comparisons on it. Chris@0: * Chris@0: * @param string $version Chris@0: * @param string $fullVersion optional complete version string to give more context Chris@0: * Chris@0: * @throws \UnexpectedValueException Chris@0: * Chris@0: * @return string Chris@0: */ Chris@0: public function normalize($version, $fullVersion = null) Chris@0: { Chris@0: $version = trim($version); Chris@0: if (null === $fullVersion) { Chris@0: $fullVersion = $version; Chris@0: } Chris@0: Chris@0: // strip off aliasing Chris@0: if (preg_match('{^([^,\s]++) ++as ++([^,\s]++)$}', $version, $match)) { Chris@0: $version = $match[1]; Chris@0: } Chris@0: Chris@0: // match master-like branches Chris@0: if (preg_match('{^(?:dev-)?(?:master|trunk|default)$}i', $version)) { Chris@0: return '9999999-dev'; Chris@0: } Chris@0: Chris@0: // if requirement is branch-like, use full name Chris@0: if ('dev-' === strtolower(substr($version, 0, 4))) { Chris@0: return 'dev-' . substr($version, 4); Chris@0: } Chris@0: Chris@0: // strip off build metadata Chris@0: if (preg_match('{^([^,\s+]++)\+[^\s]++$}', $version, $match)) { Chris@0: $version = $match[1]; Chris@0: } Chris@0: Chris@0: // match classical versioning Chris@0: if (preg_match('{^v?(\d{1,5})(\.\d++)?(\.\d++)?(\.\d++)?' . self::$modifierRegex . '$}i', $version, $matches)) { Chris@0: $version = $matches[1] Chris@0: . (!empty($matches[2]) ? $matches[2] : '.0') Chris@0: . (!empty($matches[3]) ? $matches[3] : '.0') Chris@0: . (!empty($matches[4]) ? $matches[4] : '.0'); Chris@0: $index = 5; Chris@0: // match date(time) based versioning Chris@0: } elseif (preg_match('{^v?(\d{4}(?:[.:-]?\d{2}){1,6}(?:[.:-]?\d{1,3})?)' . self::$modifierRegex . '$}i', $version, $matches)) { Chris@0: $version = preg_replace('{\D}', '.', $matches[1]); Chris@0: $index = 2; Chris@0: } Chris@0: Chris@0: // add version modifiers if a version was matched Chris@0: if (isset($index)) { Chris@0: if (!empty($matches[$index])) { Chris@0: if ('stable' === $matches[$index]) { Chris@0: return $version; Chris@0: } Chris@0: $version .= '-' . $this->expandStability($matches[$index]) . (!empty($matches[$index + 1]) ? ltrim($matches[$index + 1], '.-') : ''); Chris@0: } Chris@0: Chris@0: if (!empty($matches[$index + 2])) { Chris@0: $version .= '-dev'; Chris@0: } Chris@0: Chris@0: return $version; Chris@0: } Chris@0: Chris@0: // match dev branches Chris@0: if (preg_match('{(.*?)[.-]?dev$}i', $version, $match)) { Chris@0: try { Chris@0: return $this->normalizeBranch($match[1]); Chris@0: } catch (\Exception $e) { Chris@0: } Chris@0: } Chris@0: Chris@0: $extraMessage = ''; Chris@0: if (preg_match('{ +as +' . preg_quote($version) . '$}', $fullVersion)) { Chris@0: $extraMessage = ' in "' . $fullVersion . '", the alias must be an exact version'; Chris@0: } elseif (preg_match('{^' . preg_quote($version) . ' +as +}', $fullVersion)) { Chris@0: $extraMessage = ' in "' . $fullVersion . '", the alias source must be an exact version, if it is a branch name you should prefix it with dev-'; Chris@0: } Chris@0: Chris@0: throw new \UnexpectedValueException('Invalid version string "' . $version . '"' . $extraMessage); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Extract numeric prefix from alias, if it is in numeric format, suitable for version comparison. Chris@0: * Chris@0: * @param string $branch Branch name (e.g. 2.1.x-dev) Chris@0: * Chris@0: * @return string|false Numeric prefix if present (e.g. 2.1.) or false Chris@0: */ Chris@0: public function parseNumericAliasPrefix($branch) Chris@0: { Chris@0: if (preg_match('{^(?P(\d++\\.)*\d++)(?:\.x)?-dev$}i', $branch, $matches)) { Chris@0: return $matches['version'] . '.'; Chris@0: } Chris@0: Chris@0: return false; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Normalizes a branch name to be able to perform comparisons on it. Chris@0: * Chris@0: * @param string $name Chris@0: * Chris@0: * @return string Chris@0: */ Chris@0: public function normalizeBranch($name) Chris@0: { Chris@0: $name = trim($name); Chris@0: Chris@0: if (in_array($name, array('master', 'trunk', 'default'))) { Chris@0: return $this->normalize($name); Chris@0: } Chris@0: Chris@0: if (preg_match('{^v?(\d++)(\.(?:\d++|[xX*]))?(\.(?:\d++|[xX*]))?(\.(?:\d++|[xX*]))?$}i', $name, $matches)) { Chris@0: $version = ''; Chris@0: for ($i = 1; $i < 5; ++$i) { Chris@0: $version .= isset($matches[$i]) ? str_replace(array('*', 'X'), 'x', $matches[$i]) : '.x'; Chris@0: } Chris@0: Chris@0: return str_replace('x', '9999999', $version) . '-dev'; Chris@0: } Chris@0: Chris@0: return 'dev-' . $name; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parses a constraint string into MultiConstraint and/or Constraint objects. Chris@0: * Chris@0: * @param string $constraints Chris@0: * Chris@0: * @return ConstraintInterface Chris@0: */ Chris@0: public function parseConstraints($constraints) Chris@0: { Chris@0: $prettyConstraint = $constraints; Chris@0: Chris@0: if (preg_match('{^([^,\s]*?)@(' . implode('|', self::$stabilities) . ')$}i', $constraints, $match)) { Chris@0: $constraints = empty($match[1]) ? '*' : $match[1]; Chris@0: } Chris@0: Chris@0: if (preg_match('{^(dev-[^,\s@]+?|[^,\s@]+?\.x-dev)#.+$}i', $constraints, $match)) { Chris@0: $constraints = $match[1]; Chris@0: } Chris@0: Chris@0: $orConstraints = preg_split('{\s*\|\|?\s*}', trim($constraints)); Chris@0: $orGroups = array(); Chris@0: foreach ($orConstraints as $constraints) { Chris@0: $andConstraints = preg_split('{(?< ,]) *(? 1) { Chris@0: $constraintObjects = array(); Chris@0: foreach ($andConstraints as $constraint) { Chris@0: foreach ($this->parseConstraint($constraint) as $parsedConstraint) { Chris@0: $constraintObjects[] = $parsedConstraint; Chris@0: } Chris@0: } Chris@0: } else { Chris@0: $constraintObjects = $this->parseConstraint($andConstraints[0]); Chris@0: } Chris@0: Chris@0: if (1 === count($constraintObjects)) { Chris@0: $constraint = $constraintObjects[0]; Chris@0: } else { Chris@0: $constraint = new MultiConstraint($constraintObjects); Chris@0: } Chris@0: Chris@0: $orGroups[] = $constraint; Chris@0: } Chris@0: Chris@0: if (1 === count($orGroups)) { Chris@0: $constraint = $orGroups[0]; Chris@0: } elseif (2 === count($orGroups) Chris@0: // parse the two OR groups and if they are contiguous we collapse Chris@0: // them into one constraint Chris@0: && $orGroups[0] instanceof MultiConstraint Chris@0: && $orGroups[1] instanceof MultiConstraint Chris@0: && 2 === count($orGroups[0]->getConstraints()) Chris@0: && 2 === count($orGroups[1]->getConstraints()) Chris@0: && ($a = (string) $orGroups[0]) Chris@0: && substr($a, 0, 3) === '[>=' && (false !== ($posA = strpos($a, '<', 4))) Chris@0: && ($b = (string) $orGroups[1]) Chris@0: && substr($b, 0, 3) === '[>=' && (false !== ($posB = strpos($b, '<', 4))) Chris@0: && substr($a, $posA + 2, -1) === substr($b, 4, $posB - 5) Chris@0: ) { Chris@0: $constraint = new MultiConstraint(array( Chris@0: new Constraint('>=', substr($a, 4, $posA - 5)), Chris@0: new Constraint('<', substr($b, $posB + 2, -1)), Chris@0: )); Chris@0: } else { Chris@0: $constraint = new MultiConstraint($orGroups, false); Chris@0: } Chris@0: Chris@0: $constraint->setPrettyString($prettyConstraint); Chris@0: Chris@0: return $constraint; Chris@0: } Chris@0: Chris@0: /** Chris@0: * @param string $constraint Chris@0: * Chris@0: * @throws \UnexpectedValueException Chris@0: * Chris@0: * @return array Chris@0: */ Chris@0: private function parseConstraint($constraint) Chris@0: { Chris@0: if (preg_match('{^([^,\s]+?)@(' . implode('|', self::$stabilities) . ')$}i', $constraint, $match)) { Chris@0: $constraint = $match[1]; Chris@0: if ($match[2] !== 'stable') { Chris@0: $stabilityModifier = $match[2]; Chris@0: } Chris@0: } Chris@0: Chris@0: if (preg_match('{^v?[xX*](\.[xX*])*$}i', $constraint)) { Chris@0: return array(new EmptyConstraint()); Chris@0: } Chris@0: Chris@0: $versionRegex = 'v?(\d++)(?:\.(\d++))?(?:\.(\d++))?(?:\.(\d++))?' . self::$modifierRegex . '(?:\+[^\s]+)?'; Chris@0: Chris@0: // Tilde Range Chris@0: // Chris@0: // Like wildcard constraints, unsuffixed tilde constraints say that they must be greater than the previous Chris@0: // version, to ensure that unstable instances of the current version are allowed. However, if a stability Chris@0: // suffix is added to the constraint, then a >= match on the current version is used instead. Chris@0: if (preg_match('{^~>?' . $versionRegex . '$}i', $constraint, $matches)) { Chris@0: if (substr($constraint, 0, 2) === '~>') { Chris@0: throw new \UnexpectedValueException( Chris@0: 'Could not parse version constraint ' . $constraint . ': ' . Chris@0: 'Invalid operator "~>", you probably meant to use the "~" operator' Chris@0: ); Chris@0: } Chris@0: Chris@0: // Work out which position in the version we are operating at Chris@18: if (isset($matches[4]) && '' !== $matches[4] && null !== $matches[4]) { Chris@0: $position = 4; Chris@18: } elseif (isset($matches[3]) && '' !== $matches[3] && null !== $matches[3]) { Chris@0: $position = 3; Chris@18: } elseif (isset($matches[2]) && '' !== $matches[2] && null !== $matches[2]) { Chris@0: $position = 2; Chris@0: } else { Chris@0: $position = 1; Chris@0: } Chris@0: Chris@0: // Calculate the stability suffix Chris@0: $stabilitySuffix = ''; Chris@18: if (empty($matches[5]) && empty($matches[7])) { Chris@0: $stabilitySuffix .= '-dev'; Chris@0: } Chris@0: Chris@18: $lowVersion = $this->normalize(substr($constraint . $stabilitySuffix, 1)); Chris@0: $lowerBound = new Constraint('>=', $lowVersion); Chris@0: Chris@0: // For upper bound, we increment the position of one more significance, Chris@0: // but highPosition = 0 would be illegal Chris@0: $highPosition = max(1, $position - 1); Chris@0: $highVersion = $this->manipulateVersionString($matches, $highPosition, 1) . '-dev'; Chris@0: $upperBound = new Constraint('<', $highVersion); Chris@0: Chris@0: return array( Chris@0: $lowerBound, Chris@0: $upperBound, Chris@0: ); Chris@0: } Chris@0: Chris@0: // Caret Range Chris@0: // Chris@0: // Allows changes that do not modify the left-most non-zero digit in the [major, minor, patch] tuple. Chris@0: // In other words, this allows patch and minor updates for versions 1.0.0 and above, patch updates for Chris@0: // versions 0.X >=0.1.0, and no updates for versions 0.0.X Chris@0: if (preg_match('{^\^' . $versionRegex . '($)}i', $constraint, $matches)) { Chris@0: // Work out which position in the version we are operating at Chris@18: if ('0' !== $matches[1] || '' === $matches[2] || null === $matches[2]) { Chris@0: $position = 1; Chris@18: } elseif ('0' !== $matches[2] || '' === $matches[3] || null === $matches[3]) { Chris@0: $position = 2; Chris@0: } else { Chris@0: $position = 3; Chris@0: } Chris@0: Chris@0: // Calculate the stability suffix Chris@0: $stabilitySuffix = ''; Chris@0: if (empty($matches[5]) && empty($matches[7])) { Chris@0: $stabilitySuffix .= '-dev'; Chris@0: } Chris@0: Chris@0: $lowVersion = $this->normalize(substr($constraint . $stabilitySuffix, 1)); Chris@0: $lowerBound = new Constraint('>=', $lowVersion); Chris@0: Chris@0: // For upper bound, we increment the position of one more significance, Chris@0: // but highPosition = 0 would be illegal Chris@0: $highVersion = $this->manipulateVersionString($matches, $position, 1) . '-dev'; Chris@0: $upperBound = new Constraint('<', $highVersion); Chris@0: Chris@0: return array( Chris@0: $lowerBound, Chris@0: $upperBound, Chris@0: ); Chris@0: } Chris@0: Chris@0: // X Range Chris@0: // Chris@0: // Any of X, x, or * may be used to "stand in" for one of the numeric values in the [major, minor, patch] tuple. Chris@0: // A partial version range is treated as an X-Range, so the special character is in fact optional. Chris@0: if (preg_match('{^v?(\d++)(?:\.(\d++))?(?:\.(\d++))?(?:\.[xX*])++$}', $constraint, $matches)) { Chris@18: if (isset($matches[3]) && '' !== $matches[3] && null !== $matches[3]) { Chris@0: $position = 3; Chris@18: } elseif (isset($matches[2]) && '' !== $matches[2] && null !== $matches[2]) { Chris@0: $position = 2; Chris@0: } else { Chris@0: $position = 1; Chris@0: } Chris@0: Chris@0: $lowVersion = $this->manipulateVersionString($matches, $position) . '-dev'; Chris@0: $highVersion = $this->manipulateVersionString($matches, $position, 1) . '-dev'; Chris@0: Chris@0: if ($lowVersion === '0.0.0.0-dev') { Chris@0: return array(new Constraint('<', $highVersion)); Chris@0: } Chris@0: Chris@0: return array( Chris@0: new Constraint('>=', $lowVersion), Chris@0: new Constraint('<', $highVersion), Chris@0: ); Chris@0: } Chris@0: Chris@0: // Hyphen Range Chris@0: // Chris@0: // Specifies an inclusive set. If a partial version is provided as the first version in the inclusive range, Chris@0: // then the missing pieces are replaced with zeroes. If a partial version is provided as the second version in Chris@0: // the inclusive range, then all versions that start with the supplied parts of the tuple are accepted, but Chris@0: // nothing that would be greater than the provided tuple parts. Chris@0: if (preg_match('{^(?P' . $versionRegex . ') +- +(?P' . $versionRegex . ')($)}i', $constraint, $matches)) { Chris@0: // Calculate the stability suffix Chris@0: $lowStabilitySuffix = ''; Chris@0: if (empty($matches[6]) && empty($matches[8])) { Chris@0: $lowStabilitySuffix = '-dev'; Chris@0: } Chris@0: Chris@0: $lowVersion = $this->normalize($matches['from']); Chris@0: $lowerBound = new Constraint('>=', $lowVersion . $lowStabilitySuffix); Chris@0: Chris@0: $empty = function ($x) { Chris@0: return ($x === 0 || $x === '0') ? false : empty($x); Chris@0: }; Chris@0: Chris@0: if ((!$empty($matches[11]) && !$empty($matches[12])) || !empty($matches[14]) || !empty($matches[16])) { Chris@0: $highVersion = $this->normalize($matches['to']); Chris@0: $upperBound = new Constraint('<=', $highVersion); Chris@0: } else { Chris@0: $highMatch = array('', $matches[10], $matches[11], $matches[12], $matches[13]); Chris@0: $highVersion = $this->manipulateVersionString($highMatch, $empty($matches[11]) ? 1 : 2, 1) . '-dev'; Chris@0: $upperBound = new Constraint('<', $highVersion); Chris@0: } Chris@0: Chris@0: return array( Chris@0: $lowerBound, Chris@0: $upperBound, Chris@0: ); Chris@0: } Chris@0: Chris@0: // Basic Comparators Chris@0: if (preg_match('{^(<>|!=|>=?|<=?|==?)?\s*(.*)}', $constraint, $matches)) { Chris@0: try { Chris@0: $version = $this->normalize($matches[2]); Chris@0: Chris@0: if (!empty($stabilityModifier) && $this->parseStability($version) === 'stable') { Chris@0: $version .= '-' . $stabilityModifier; Chris@0: } elseif ('<' === $matches[1] || '>=' === $matches[1]) { Chris@0: if (!preg_match('/-' . self::$modifierRegex . '$/', strtolower($matches[2]))) { Chris@0: if (substr($matches[2], 0, 4) !== 'dev-') { Chris@0: $version .= '-dev'; Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: return array(new Constraint($matches[1] ?: '=', $version)); Chris@0: } catch (\Exception $e) { Chris@0: } Chris@0: } Chris@0: Chris@0: $message = 'Could not parse version constraint ' . $constraint; Chris@0: if (isset($e)) { Chris@0: $message .= ': ' . $e->getMessage(); Chris@0: } Chris@0: Chris@0: throw new \UnexpectedValueException($message); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Increment, decrement, or simply pad a version number. Chris@0: * Chris@0: * Support function for {@link parseConstraint()} Chris@0: * Chris@0: * @param array $matches Array with version parts in array indexes 1,2,3,4 Chris@0: * @param int $position 1,2,3,4 - which segment of the version to increment/decrement Chris@0: * @param int $increment Chris@0: * @param string $pad The string to pad version parts after $position Chris@0: * Chris@0: * @return string The new version Chris@0: */ Chris@0: private function manipulateVersionString($matches, $position, $increment = 0, $pad = '0') Chris@0: { Chris@0: for ($i = 4; $i > 0; --$i) { Chris@0: if ($i > $position) { Chris@0: $matches[$i] = $pad; Chris@0: } elseif ($i === $position && $increment) { Chris@0: $matches[$i] += $increment; Chris@0: // If $matches[$i] was 0, carry the decrement Chris@0: if ($matches[$i] < 0) { Chris@0: $matches[$i] = $pad; Chris@0: --$position; Chris@0: Chris@0: // Return null on a carry overflow Chris@0: if ($i === 1) { Chris@0: return; Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: return $matches[1] . '.' . $matches[2] . '.' . $matches[3] . '.' . $matches[4]; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Expand shorthand stability string to long version. Chris@0: * Chris@0: * @param string $stability Chris@0: * Chris@0: * @return string Chris@0: */ Chris@0: private function expandStability($stability) Chris@0: { Chris@0: $stability = strtolower($stability); Chris@0: Chris@0: switch ($stability) { Chris@0: case 'a': Chris@0: return 'alpha'; Chris@0: case 'b': Chris@0: return 'beta'; Chris@0: case 'p': Chris@0: case 'pl': Chris@0: return 'patch'; Chris@0: case 'rc': Chris@0: return 'RC'; Chris@0: default: Chris@0: return $stability; Chris@0: } Chris@0: } Chris@0: }