diff --git a/core/lib/Drupal/Component/Version/Constraint.php b/core/lib/Drupal/Component/Version/Constraint.php index cbf72704e0..ba0a60f345 100644 --- a/core/lib/Drupal/Component/Version/Constraint.php +++ b/core/lib/Drupal/Component/Version/Constraint.php @@ -2,6 +2,8 @@ namespace Drupal\Component\Version; +use Composer\Semver\Semver; + /** * A value object representing a Drupal version constraint. */ @@ -14,6 +16,20 @@ class Constraint { */ protected $constraint; + /** + * Core compatibility string. + * + * @var string + */ + protected $coreCompatibility; + + /** + * Constraint as Composer string. + * + * @var string + */ + protected $asComposer; + /** * A list of associative arrays representing the constraint. * @@ -36,7 +52,7 @@ class Constraint { */ public function __construct($constraint, $core_compatibility) { $this->constraint = $constraint; - $this->parseConstraint($constraint, $core_compatibility); + $this->coreCompatibility = $core_compatibility; } /** @@ -68,7 +84,7 @@ public function __toString() { */ public function toArray() { @trigger_error(sprintf('%s() only exists to provide a backwards compatibility layer. See https://www.drupal.org/node/2756875', __METHOD__), E_USER_DEPRECATED); - return $this->constraintArray; + return $this->parseConstraint($this->constraint, $this->coreCompatibility); } /** @@ -82,12 +98,13 @@ public function toArray() { * not. */ public function isCompatible($version) { - foreach ($this->constraintArray as $constraint) { - if (!version_compare($version, $constraint['version'], $constraint['op'])) { - return FALSE; - } + if (!$this->constraint || empty($version)) { + // Drupal supports empty version constraints, which are equivalent to + // Composer's '*' constraint. So an empty constraint is automatically + // compatible with any version. + return TRUE; } - return TRUE; + return Semver::satisfies($version, $this->getComposerConstraint()); } /** @@ -98,18 +115,25 @@ public function isCompatible($version) { * @param string $core_compatibility * Core compatibility declared for the current version of Drupal core. * Normally this is set to \Drupal::CORE_COMPATIBILITY by the caller. + * + * @return array[] + * Constraint as an array with an entry for each constraint. Each entry + * contains an 'op' and a 'version' key. */ private function parseConstraint($constraint_string, $core_compatibility) { - // We use named subpatterns and support every op that version_compare + if ($this->constraintArray) { + return $this->constraintArray; + } + // We use named subpatterns and support every operator that version_compare // supports. Also, op is optional and defaults to equals. - $p_op = '(?!=|==|=|<|<=|>|>=|<>)?'; + $op_pattern = '(?!=|==|=|<|<=|>|>=|<>)?'; // Core version is always optional: 8.x-2.x and 2.x is treated the same. - $p_core = '(?:' . preg_quote($core_compatibility) . '-)?'; - $p_major = '(?\d+)'; + $core_pattern = '(?:' . preg_quote($core_compatibility) . '-)?'; + $major_pattern = '(?\d+)'; // By setting the minor version to x, branches can be matched. - $p_minor = '(?(?:\d+|x)(?:-[A-Za-z]+\d+)?)'; + $minor_pattern = '(?(?:\d+|x)(?:-[A-Za-z]+\d+)?)'; foreach (explode(',', $constraint_string) as $constraint) { - if (preg_match("/^\s*$p_op\s*$p_core$p_major\.$p_minor/", $constraint, $matches)) { + if (preg_match("/^\s*$op_pattern\s*$core_pattern$major_pattern\.$minor_pattern/", $constraint, $matches)) { $op = !empty($matches['operation']) ? $matches['operation'] : '='; if ($matches['minor'] == 'x') { // Drupal considers "2.x" to mean any version that begins with @@ -130,6 +154,214 @@ private function parseConstraint($constraint_string, $core_compatibility) { $this->constraintArray[] = ['op' => $op, 'version' => $matches['major'] . '.' . $matches['minor']]; } } + return $this->constraintArray; + } + + /** + * Gets constraint in a Composer-compatible format. + * + * @return string + * The constraint expressed in a format suitable for use with Composer. + */ + public function getComposerConstraint() { + if (!$this->asComposer) { + $this->asComposer = $this->parseToComposer(); + } + return $this->asComposer; + } + + /** + * Casts from Drupal's dependency format to a Composer-compatible constraint. + * + * @return string + * The constraint expressed in a format suitable for use with Composer. + */ + private function parseToComposer() { + // We use named subpatterns and support every op that version_compare + // supports. Also, op is optional and defaults to equals. + $op_pattern = '(?P<>|!=|==|<=|>=|=|<|>|~|\^)?'; + + // Core version is always optional: 7.x-2.x and 2.x is treated the same. + $core_pattern = '(?P(?:' . preg_quote($this->coreCompatibility) . '-(?=\d))?)'; + $major_pattern = '(?P\d+)?'; + // By setting the minor version to x, branches can be matched. + $minor_pattern = '\.?(?P(?:\d+|x(?=\.|$|-dev))?)'; + $patch_pattern = '\.?(?P(?:\d+|x(?=\.|$))?)'; + $stability_pattern = '(?P(?:-[A-Za-z]+)?)'; + $stability_version_pattern = '(?P(?:\d+)?)'; + + // There is no constraint at all, so any version will suffice. + if (!$this->constraint) { + return '*'; + } + $constraints = explode(',', $this->constraint); + $pattern = "/^\s*$op_pattern\s*$core_pattern$major_pattern$minor_pattern$patch_pattern$stability_pattern$stability_version_pattern/"; + if (count($constraints) == 1) { + // One version constraint. - zero or one operators + preg_match($pattern, trim($constraints[0]), $matches); + return $this->processSingleConstraintMatches($matches); + } + // Two constraints - two operators. + preg_match($pattern, $constraints[0], $first_matches); + preg_match($pattern, $constraints[1], $second_matches); + return $this->processDualConstraintsMatches($first_matches, $second_matches); + } + + /** + * Parses a single constraint. + * + * @param array $matches + * Regex matches. + * + * @return string + * Parsed single constraint. + */ + private function processSingleConstraintMatches(array $matches) { + $op = ''; + // If stability is 'dev' we pretend we didn't see that. + if (!empty($matches['stability']) && $matches['stability'] === '-dev') { + $matches['stability'] = ''; + } + // Having a stability but no patch level implies patch level of 0. + if (!empty($matches['stability']) && empty($matches['patch'])) { + $matches['patch'] = '0'; + } + if (empty($matches['operation']) || $matches['operation'] === '==' || $matches['operation'] === '=') { + // One constraint, op is empty. + if ($matches['patch'] === '' || $matches['patch'] === 'x') { + $op = '~'; + } + if ($matches['minor'] !== '') { + if ($matches['minor'] === 'x') { + $matches['minor'] = '0'; + return $this->buildComposerConstraint($matches, $op); + } + if ($matches['patch'] === 'x' || $matches['patch'] === '') { + $matches['patch'] = '0'; + return $this->buildComposerConstraint($matches, $op); + } + } + return $this->buildComposerConstraint($matches, $op); + } + + if (in_array($matches['operation'], ['<', '<=', '>', '>=', '^', '~'], TRUE)) { + if ($matches['minor'] === 'x') { + $matches['minor'] = ''; + } + if ($matches['patch'] === 'x') { + $matches['patch'] = ''; + } + } + // Op is not empty, there is one constraint. + switch ($matches['operation']) { + case '<>': + case '!=': + if ($matches['patch'] !== '' && $matches['patch'] !== 'x') { + $op = '!='; + return $this->buildComposerConstraint($matches, $op); + } + + if ($matches['minor'] === '' || $matches['minor'] === 'x') { + $matches['minor'] = ''; + return '<' . $matches['major'] . '||' . '>=' . ((int) $matches['major'] + 1); + } + return '<' . $matches['major'] . '.' . $matches['minor'] . '||' . '>=' . $matches['major'] . '.' . ((int) $matches['minor'] + 1); + + case '<': + case '<=': + case '~': + $op = $matches['operation']; + return $this->buildComposerConstraint($matches, $op); + + case '>': + case '>=': + case '^': + $op = '^'; + if ($matches['operation'] === '>') { + if ($matches['stability_ver'] !== '') { + $matches['stability_ver'] = (int) $matches['stability_ver'] + 1; + return $this->buildComposerConstraint($matches, $op); + } + if ($matches['patch'] !== '') { + $matches['patch'] = (int) $matches['patch'] + 1; + return $this->buildComposerConstraint($matches, $op); + } + if ($matches['minor'] !== '') { + $matches['minor'] = (int) $matches['minor'] + 1; + return $this->buildComposerConstraint($matches, $op); + } + $matches['major'] = (int) $matches['major'] + 1; + } + break; + } + + return $this->buildComposerConstraint($matches, $op); + } + + /** + * Builds a Composer constraint. + * + * @param array $matches + * Constraint parts. + * @param string $op + * Constraint operation. + * + * @return string + * Built Composer constraint. + */ + private function buildComposerConstraint(array $matches, string $op) { + $matches['minor'] = ($matches['minor'] !== '') ? "." . $matches['minor'] : ''; + $matches['patch'] = ($matches['patch'] !== '') ? "." . $matches['patch'] : ''; + if ($matches['minor'] === '' && $op === '<=') { + $matches['major']++; + $op = '<'; + } + return $op . $matches['major'] . $matches['minor'] . $matches['patch'] . $matches['stability'] . $matches['stability_ver']; + } + + /** + * Processes two constraint matches. + * + * @param array $first_matches + * First constraint regex matches. + * @param array $second_matches + * Second constraint regex matches. + * + * @return string + * Composer constraint. + */ + private function processDualConstraintsMatches($first_matches, $second_matches) { + if (!empty($first_matches['stability']) && empty($first_matches['patch'])) { + $first_matches['patch'] = '0'; + } + if (!empty($second_matches['stability']) && empty($second_matches['patch'])) { + $second_matches['patch'] = '0'; + } + if ($first_matches['minor'] === 'x') { + $first_matches['minor'] = '0'; + } + if ($first_matches['patch'] === 'x') { + $first_matches['patch'] = '0'; + } + if ($second_matches['minor'] === 'x') { + $second_matches['minor'] = '0'; + } + if ($second_matches['patch'] === 'x') { + $second_matches['patch'] = '0'; + } + + $first_matches['minor'] = ($first_matches['minor'] !== '') ? "." . $first_matches['minor'] : ''; + $first_matches['patch'] = ($first_matches['patch'] !== '') ? "." . $first_matches['patch'] : ''; + $second_matches['minor'] = ($second_matches['minor'] !== '') ? "." . $second_matches['minor'] : ''; + $second_matches['patch'] = ($second_matches['patch'] !== '') ? "." . $second_matches['patch'] : ''; + + $separator = ", "; + if ($first_matches['operation'] === '=' || $second_matches['operation'] === '=') { + // Providing an explicit version (=) and a second constraint implies an + // OR. + $separator = ' || '; + } + return $first_matches['operation'] . $first_matches['major'] . $first_matches['minor'] . $first_matches['patch'] . $first_matches['stability'] . $first_matches['stability_ver'] . $separator . $second_matches['operation'] . $second_matches['major'] . $second_matches['minor'] . $second_matches['patch'] . $second_matches['stability'] . $second_matches['stability_ver']; } } diff --git a/core/modules/system/src/Controller/SystemController.php b/core/modules/system/src/Controller/SystemController.php index 106f4e2915..4cc19c5994 100644 --- a/core/modules/system/src/Controller/SystemController.php +++ b/core/modules/system/src/Controller/SystemController.php @@ -2,6 +2,7 @@ namespace Drupal\system\Controller; +use Drupal\Component\Version\Constraint; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Extension\ThemeHandlerInterface; @@ -223,7 +224,7 @@ public function themesPage() { if (empty($theme->status)) { // Ensure this theme is compatible with this version of core. - $theme->incompatible_core = !isset($theme->info['core']) || ($theme->info['core'] != \DRUPAL::CORE_COMPATIBILITY); + $theme->incompatible_core = !isset($theme->info['core']) ||!(new Constraint($theme->info['core'], \Drupal::CORE_COMPATIBILITY))->isCompatible(\Drupal::VERSION); // Require the 'content' region to make sure the main page // content has a common place in all themes. $theme->incompatible_region = !isset($theme->info['regions']['content']); diff --git a/core/modules/system/src/Form/ModulesListForm.php b/core/modules/system/src/Form/ModulesListForm.php index cabbe2ebaa..ce2171f9f2 100644 --- a/core/modules/system/src/Form/ModulesListForm.php +++ b/core/modules/system/src/Form/ModulesListForm.php @@ -3,6 +3,7 @@ namespace Drupal\system\Form; use Drupal\Component\Utility\Unicode; +use Drupal\Component\Version\Constraint; use Drupal\Core\Config\PreExistingConfigException; use Drupal\Core\Config\UnmetDependenciesException; use Drupal\Core\Access\AccessManagerInterface; @@ -295,10 +296,15 @@ protected function buildRow(array $modules, Extension $module, $distribution) { $reasons = []; // Check the core compatibility. - if ($module->info['core'] != \Drupal::CORE_COMPATIBILITY) { + if (!(new Constraint($module->info['core'], \Drupal::CORE_COMPATIBILITY))->isCompatible(\Drupal::VERSION)) { $compatible = FALSE; $reasons[] = $this->t('This version is not compatible with Drupal @core_version and should be replaced.', [ - '@core_version' => \Drupal::CORE_COMPATIBILITY, + '@core_version' => \Drupal::VERSION, + ]); + $row['#requires']['core'] = $this->t('Drupal Core (@core_requirement) (incompatible with version @core_version)', [ + '@module' => $module->getName(), + '@core_requirement' => $module->info['core'], + '@core_version' => \Drupal::VERSION, ]); } @@ -342,7 +348,7 @@ protected function buildRow(array $modules, Extension $module, $distribution) { } // Disable the checkbox if the dependency is incompatible with this // version of Drupal core. - elseif ($modules[$dependency]->info['core'] != \Drupal::CORE_COMPATIBILITY) { + elseif (!(new Constraint($module->info['core'], \Drupal::CORE_COMPATIBILITY))->isCompatible(\Drupal::VERSION)) { $row['#requires'][$dependency] = $this->t('@module (incompatible with this version of Drupal core)', [ '@module' => $name, ]); diff --git a/core/modules/system/tests/modules/system_incompatible_core_version_test_9x/system_incompatible_core_version_test_9x.info.yml b/core/modules/system/tests/modules/system_incompatible_core_version_test_9x/system_incompatible_core_version_test_9x.info.yml new file mode 100644 index 0000000000..4bc6e455a0 --- /dev/null +++ b/core/modules/system/tests/modules/system_incompatible_core_version_test_9x/system_incompatible_core_version_test_9x.info.yml @@ -0,0 +1,6 @@ +name: 'System incompatible core 9.x version test' +type: module +description: 'Support module for testing system dependencies.' +package: Testing +version: 9.0.0 +core: 9.x diff --git a/core/modules/system/tests/modules/system_test/system_test.module b/core/modules/system/tests/modules/system_test/system_test.module index affc20ea42..bf96719f33 100644 --- a/core/modules/system/tests/modules/system_test/system_test.module +++ b/core/modules/system/tests/modules/system_test/system_test.module @@ -62,6 +62,10 @@ function system_test_system_info_alter(&$info, Extension $file, $type) { } } + if (($core_requirement = \Drupal::state()->get('dependency_test.core_version_requirement')) && $file->getName() === 'common_test') { + $info['core'] = $core_requirement; + } + // Make the system_dependencies_test visible by default. if ($file->getName() == 'system_dependencies_test') { $info['hidden'] = FALSE; @@ -71,6 +75,8 @@ function system_test_system_info_alter(&$info, Extension $file, $type) { 'system_incompatible_core_version_dependencies_test', 'system_incompatible_module_version_test', 'system_incompatible_core_version_test', + 'system_incompatible_core_version_test_9x', + 'system_compatible_core_version_test_80x', ])) { $info['hidden'] = FALSE; } diff --git a/core/modules/system/tests/src/Functional/Module/DependencyTest.php b/core/modules/system/tests/src/Functional/Module/DependencyTest.php index 2e0e60923c..22f1c74382 100644 --- a/core/modules/system/tests/src/Functional/Module/DependencyTest.php +++ b/core/modules/system/tests/src/Functional/Module/DependencyTest.php @@ -102,6 +102,29 @@ public function testIncompatiblePhpVersionDependency() { $this->assert(count($checkbox) == 1, 'Checkbox for the module is disabled.'); } + /** + * Tests enabling modules with different core version specifications. + */ + public function testCoreVersionDependency() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + list($major, $minor) = explode('.', \Drupal::VERSION); + $next_minor = "$major." . ((int) $minor + 1) . ".x"; + \Drupal::state()->set('dependency_test.core_version_requirement', $next_minor); + $this->drupalGet('admin/modules'); + $assert_session->fieldDisabled('modules[system_incompatible_core_version_test_9x][enable]'); + $assert_session->fieldDisabled('modules[common_test][enable]'); + + $current_minor = "$major.$minor.x"; + \Drupal::state()->set('dependency_test.core_version_requirement', $current_minor); + $this->drupalGet('admin/modules'); + $checkbox = $page->find('css', '[name="modules[common_test][enable]"]'); + $this->assertFalse($checkbox->hasAttribute('disabled')); + $edit['modules[common_test][enable]'] = 'common_test'; + $this->drupalPostForm('admin/modules', $edit, t('Install')); + $this->assertModules(['common_test'], TRUE); + } + /** * Tests enabling a module that depends on a module which fails hook_requirements(). */