class ComposerInspector

Defines a class to get information from Composer.

This is a PHP wrapper to facilitate interacting with composer and:

  • list installed packages: getInstalledPackagesList() (`composer show`)
  • validate composer state & project: validate() (`composer validate`)
  • read project & package configuration: getConfig() (`composer config`)
  • read root package info: getRootPackageInfo() (`composer show --self`)

Hierarchy

Expanded class hierarchy of ComposerInspector

24 files declare their use of ComposerInspector
AllowedScaffoldPackagesValidator.php in core/modules/package_manager/src/Validator/AllowedScaffoldPackagesValidator.php
ChangeLogger.php in core/modules/package_manager/src/EventSubscriber/ChangeLogger.php
CollectPathsToExcludeFailValidator.php in core/modules/package_manager/tests/modules/package_manager_test_validation/src/CollectPathsToExcludeFailValidator.php
ComposerInspectorTest.php in core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php
ComposerInstallersTrait.php in core/modules/package_manager/tests/src/Traits/ComposerInstallersTrait.php

... See full list

File

core/modules/package_manager/src/ComposerInspector.php, line 29

Namespace

Drupal\package_manager
View source
class ComposerInspector implements LoggerAwareInterface {
  use LoggerAwareTrait {
    setLogger as traitSetLogger;
  }
  use StringTranslationTrait;
  
  /**
   * The process output callback.
   *
   * @var \Drupal\package_manager\ProcessOutputCallback
   */
  private ProcessOutputCallback $processCallback;
  
  /**
   * Statically cached installed package lists, keyed by directory.
   *
   * @var \Drupal\package_manager\InstalledPackagesList[]
   */
  private array $packageLists = [];
  
  /**
   * A semantic version constraint for the supported version(s) of Composer.
   *
   * @see https://endoflife.date/composer
   *
   * @var string
   */
  public final const SUPPORTED_VERSION = '^2.7';
  public function __construct(private readonly ComposerProcessRunnerInterface $runner, private readonly ComposerIsAvailableInterface $composerIsAvailable, private readonly PathFactoryInterface $pathFactory) {
    $this->processCallback = new ProcessOutputCallback();
    $this->setLogger(new NullLogger());
  }
  
  /**
   * {@inheritdoc}
   */
  public function setLogger(LoggerInterface $logger) : void {
    $this->traitSetLogger($logger);
    $this->processCallback
      ->setLogger($logger);
  }
  
  /**
   * Checks that Composer commands can be run.
   *
   * @param string $working_dir
   *   The directory in which Composer will be run.
   *
   * @see ::validateExecutable()
   * @see ::validateProject()
   */
  public function validate(string $working_dir) : void {
    $this->validateExecutable();
    $this->validateProject($working_dir);
  }
  
  /**
   * Checks that `composer.json` is valid and `composer.lock` exists.
   *
   * @param string $working_dir
   *   The directory to check.
   *
   * @throws \Drupal\package_manager\Exception\ComposerNotReadyException
   *   Thrown if:
   *   - `composer.json` doesn't exist in the given directory or is invalid
   *     according to `composer validate`.
   *   - `composer.lock` doesn't exist in the given directory.
   */
  private function validateProject(string $working_dir) : void {
    $messages = [];
    $previous_exception = NULL;
    // If either composer.json or composer.lock have changed, ensure the
    // directory is in a completely valid state, according to Composer.
    if ($this->invalidateCacheIfNeeded($working_dir)) {
      try {
        $this->runner
          ->run([
          'validate',
          '--check-lock',
          '--no-check-publish',
          '--with-dependencies',
          '--no-ansi',
          "--working-dir={$working_dir}",
        ]);
      } catch (RuntimeException $e) {
        $messages[] = $e->getMessage();
        $previous_exception = $e;
      }
    }
    // Check for the presence of composer.lock, because `composer validate`
    // doesn't expect it to exist, but we do (see ::getInstalledPackagesList()).
    if (!file_exists($working_dir . DIRECTORY_SEPARATOR . 'composer.lock')) {
      $messages[] = $this->t('composer.lock not found in @dir.', [
        '@dir' => $working_dir,
      ]);
    }
    if ($messages) {
      throw new ComposerNotReadyException($working_dir, $messages, 0, $previous_exception);
    }
  }
  
  /**
   * Validates that the Composer executable exists in a supported version.
   *
   * @throws \Exception
   *   Thrown if the Composer executable is not available or the detected
   *   version of Composer is not supported.
   */
  private function validateExecutable() : void {
    $messages = [];
    // Ensure the Composer executable is available. For performance reasons,
    // statically cache the result, since it's unlikely to change during the
    // current request. If $unavailable_message is NULL, it means we haven't
    // done this check yet. If it's FALSE, it means we did the check and there
    // were no errors; and, if it's a string, it's the error message we received
    // the last time we did this check.
    static $unavailable_message;
    if ($unavailable_message === NULL) {
      try {
        // The "Composer is available" precondition requires active and stage
        // directories, but they don't actually matter to it, nor do path
        // exclusions, so dummies can be passed for simplicity.
        $active_dir = $this->pathFactory
          ->create(__DIR__);
        $stage_dir = $active_dir;
        $this->composerIsAvailable
          ->assertIsFulfilled($active_dir, $stage_dir);
        $unavailable_message = FALSE;
      } catch (PreconditionException $e) {
        $unavailable_message = $e->getMessage();
      }
    }
    if ($unavailable_message) {
      $messages[] = $unavailable_message;
    }
    // The detected version of Composer is unlikely to change during the
    // current request, so statically cache it. If $unsupported_message is NULL,
    // it means we haven't done this check yet. If it's FALSE, it means we did
    // the check and there were no errors; and, if it's a string, it's the error
    // message we received the last time we did this check.
    static $unsupported_message;
    if ($unsupported_message === NULL) {
      try {
        $detected_version = $this->getVersion();
        if (Semver::satisfies($detected_version, static::SUPPORTED_VERSION)) {
          // We did the version check, and it did not produce an error message.
          $unsupported_message = FALSE;
        }
        else {
          $unsupported_message = $this->t('The detected Composer version, @version, does not satisfy <code>@constraint</code>.', [
            '@version' => $detected_version,
            '@constraint' => static::SUPPORTED_VERSION,
          ]);
        }
      } catch (\UnexpectedValueException $e) {
        $unsupported_message = $e->getMessage();
      }
    }
    if ($unsupported_message) {
      $messages[] = $unsupported_message;
    }
    if ($messages) {
      throw new ComposerNotReadyException(NULL, $messages);
    }
  }
  
  /**
   * Returns a config value from Composer.
   *
   * @param string $key
   *   The config key to get.
   * @param string $context
   *   The path of either the directory in which to run Composer, or a specific
   *   configuration file (such as a particular package's `composer.json`) from
   *   which to read specific values.
   *
   * @return string|null
   *   The output data. Note that the caller must know the shape of the
   *   requested key's value: if it's a string, no further processing is needed,
   *   but if it is a boolean, an array or a map, JSON decoding should be
   *   applied.
   *
   * @see ::getAllowPluginsConfig()
   * @see \Composer\Command\ConfigCommand::execute()
   */
  public function getConfig(string $key, string $context) : ?string {
    $this->validateExecutable();
    $command = [
      'config',
      $key,
    ];
    // If we're consulting a specific file for the config value, we don't need
    // to validate the project as a whole.
    if (is_file($context)) {
      $command[] = "--file={$context}";
    }
    else {
      $this->validateProject($context);
      $command[] = "--working-dir={$context}";
    }
    try {
      $this->runner
        ->run($command, callback: $this->processCallback
        ->reset());
    } catch (RuntimeException $e) {
      // Assume any error from `composer config` is about an undefined key-value
      // pair which may have a known default value.
      return match ($key) {  'extra' => '{}',
        default => throw $e,
      
      };
    }
    $output = $this->processCallback
      ->getOutput();
    return $output ? trim(implode('', $output)) : NULL;
  }
  
  /**
   * Returns the current Composer version.
   *
   * @return string
   *   The Composer version.
   *
   * @throws \UnexpectedValueException
   *   Thrown if the Composer version cannot be determined.
   */
  public function getVersion() : string {
    $this->runner
      ->run([
      '--format=json',
    ], callback: $this->processCallback
      ->reset());
    $data = $this->processCallback
      ->parseJsonOutput();
    if (isset($data['application']['name']) && isset($data['application']['version']) && $data['application']['name'] === 'Composer' && is_string($data['application']['version'])) {
      return $data['application']['version'];
    }
    throw new \UnexpectedValueException('Unable to determine Composer version');
  }
  
  /**
   * Returns the installed packages list.
   *
   * @param string $working_dir
   *   The working directory in which to run Composer. Should contain a
   *   `composer.lock` file.
   *
   * @return \Drupal\package_manager\InstalledPackagesList
   *   The installed packages list for the directory.
   *
   * @throws \UnexpectedValueException
   *   Thrown if a package reports that its install path is the same as the
   *   working directory, and it is not of the `metapackage` type.
   */
  public function getInstalledPackagesList(string $working_dir) : InstalledPackagesList {
    $working_dir = realpath($working_dir);
    $this->validate($working_dir);
    if (array_key_exists($working_dir, $this->packageLists)) {
      return $this->packageLists[$working_dir];
    }
    $packages_data = $this->show($working_dir);
    $packages_data = $this->getPackageTypes($packages_data, $working_dir);
    foreach ($packages_data as $name => $package) {
      $path = $package['path'];
      // For packages installed as dev snapshots from certain version control
      // systems, `composer show` displays the version like `1.0.x-dev 0a1b2c`,
      // which will cause an exception if we try to parse it as a legitimate
      // semantic version. Since we don't need the abbreviated commit hash, just
      // remove it.
      if (str_contains($package['version'], '-dev ')) {
        $packages_data[$name]['version'] = explode(' ', $package['version'], 2)[0];
      }
      // We expect Composer to report that metapackages' install paths are the
      // same as the working directory, in which case InstalledPackage::$path
      // should be NULL. For all other package types, we consider it invalid
      // if the install path is the same as the working directory.
      if (isset($package['type']) && $package['type'] === 'metapackage') {
        if ($path !== NULL) {
          throw new \UnexpectedValueException("Metapackage '{$name}' is installed at unexpected path: '{$path}', expected NULL");
        }
        $packages_data[$name]['path'] = $path;
      }
      elseif ($path === $working_dir) {
        throw new \UnexpectedValueException("Package '{$name}' cannot be installed at path: '{$path}'");
      }
      else {
        $packages_data[$name]['path'] = realpath($path);
      }
    }
    $packages_data = array_map(InstalledPackage::createFromArray(...), $packages_data);
    $list = new InstalledPackagesList($packages_data);
    $this->packageLists[$working_dir] = $list;
    return $list;
  }
  
  /**
   * Loads package types from the lock file.
   *
   * The package type is not available using `composer show` for listing
   * packages. To avoiding making many calls to `composer show package-name`,
   * load the lock file data to get the `type` key.
   *
   * @param array $packages_data
   *   The packages data returned from ::show().
   * @param string $working_dir
   *   The directory where Composer was run.
   *
   * @return array
   *   The packages data, with a `type` key added to each package.
   */
  private function getPackageTypes(array $packages_data, string $working_dir) : array {
    $lock_content = file_get_contents($working_dir . DIRECTORY_SEPARATOR . 'composer.lock');
    $lock_data = json_decode($lock_content, TRUE, flags: JSON_THROW_ON_ERROR);
    $lock_packages = array_merge($lock_data['packages'] ?? [], $lock_data['packages-dev'] ?? []);
    foreach ($lock_packages as $lock_package) {
      $name = $lock_package['name'];
      if (isset($packages_data[$name]) && isset($lock_package['type'])) {
        $packages_data[$name]['type'] = $lock_package['type'];
      }
    }
    return $packages_data;
  }
  
  /**
   * Returns the output of `composer show --self` in a directory.
   *
   * @param string $working_dir
   *   The directory in which to run Composer.
   *
   * @return array
   *   The parsed output of `composer show --self`.
   */
  public function getRootPackageInfo(string $working_dir) : array {
    $this->validate($working_dir);
    $this->runner
      ->run([
      'show',
      '--self',
      '--format=json',
      "--working-dir={$working_dir}",
    ], callback: $this->processCallback
      ->reset());
    return $this->processCallback
      ->parseJsonOutput();
  }
  
  /**
   * Gets the installed packages data from running `composer show`.
   *
   * @param string $working_dir
   *   The directory in which to run `composer show`.
   *
   * @return array[]
   *   The installed packages data, keyed by package name.
   */
  protected function show(string $working_dir) : array {
    $data = [];
    $options = [
      'show',
      '--format=json',
      "--working-dir={$working_dir}",
    ];
    // We don't get package installation paths back from `composer show` unless
    // we explicitly pass the --path option to it. However, for some
    // inexplicable reason, that option hides *other* relevant information
    // about the installed packages. So, to work around this maddening quirk, we
    // call `composer show` once without the --path option, and once with it,
    // then merge the results together. Composer, for its part, will not support
    // returning the install path from `composer show`: see
    // https://github.com/composer/composer/pull/11340.
    $this->runner
      ->run($options, callback: $this->processCallback
      ->reset());
    $output = $this->processCallback
      ->parseJsonOutput();
    // $output['installed'] will not be set if no packages are installed.
    if (isset($output['installed'])) {
      foreach ($output['installed'] as $installed_package) {
        $data[$installed_package['name']] = $installed_package;
      }
      $options[] = '--path';
      $this->runner
        ->run($options, callback: $this->processCallback
        ->reset());
      $output = $this->processCallback
        ->parseJsonOutput();
      foreach ($output['installed'] as $installed_package) {
        $data[$installed_package['name']]['path'] = $installed_package['path'];
      }
    }
    return $data;
  }
  
  /**
   * Invalidates cached data if composer.json or composer.lock have changed.
   *
   * The following cached data may be invalidated:
   * - Installed package lists (see ::getInstalledPackageList()).
   *
   * @param string $working_dir
   *   A directory that contains a `composer.json` file, and optionally a
   *   `composer.lock`. If either file has changed since the last time this
   *   method was called, any cached data for the directory will be invalidated.
   *
   * @return bool
   *   TRUE if the cached data was invalidated, otherwise FALSE.
   */
  private function invalidateCacheIfNeeded(string $working_dir) : bool {
    static $known_hashes = [];
    $invalidate = FALSE;
    foreach ([
      'composer.json',
      'composer.lock',
    ] as $filename) {
      $known_hash = $known_hashes[$working_dir][$filename] ?? '';
      // If the file doesn't exist, hash_file() will return FALSE.
      $current_hash = @hash_file('xxh64', $working_dir . DIRECTORY_SEPARATOR . $filename);
      if ($known_hash && $current_hash && hash_equals($known_hash, $current_hash)) {
        continue;
      }
      $known_hashes[$working_dir][$filename] = $current_hash;
      $invalidate = TRUE;
    }
    if ($invalidate) {
      unset($this->packageLists[$working_dir]);
    }
    return $invalidate;
  }
  
  /**
   * Returns the value of `allow-plugins` config setting.
   *
   * @param string $dir
   *   The directory in which to run Composer.
   *
   * @return bool[]|bool
   *   An array of boolean flags to allow or disallow certain plugins, or TRUE
   *   if all plugins are allowed.
   *
   * @see https://getcomposer.org/doc/06-config.md#allow-plugins
   */
  public function getAllowPluginsConfig(string $dir) : array|bool {
    $value = $this->getConfig('allow-plugins', $dir);
    // Try to convert the value we got back to a boolean. If it's not a boolean,
    // it should be an array of plugin-specific flags.
    $value = json_decode($value, TRUE, flags: JSON_THROW_ON_ERROR);
    // An empty array indicates that no plugins are allowed.
    return $value ?: [];
  }

}

Members

Title Sort descending Modifiers Object type Summary Overrides
ComposerInspector::$packageLists private property Statically cached installed package lists, keyed by directory.
ComposerInspector::$processCallback private property The process output callback.
ComposerInspector::getAllowPluginsConfig public function Returns the value of `allow-plugins` config setting.
ComposerInspector::getConfig public function Returns a config value from Composer.
ComposerInspector::getInstalledPackagesList public function Returns the installed packages list.
ComposerInspector::getPackageTypes private function Loads package types from the lock file.
ComposerInspector::getRootPackageInfo public function Returns the output of `composer show --self` in a directory.
ComposerInspector::getVersion public function Returns the current Composer version.
ComposerInspector::invalidateCacheIfNeeded private function Invalidates cached data if composer.json or composer.lock have changed.
ComposerInspector::setLogger public function
ComposerInspector::show protected function Gets the installed packages data from running `composer show`.
ComposerInspector::SUPPORTED_VERSION final public constant A semantic version constraint for the supported version(s) of Composer.
ComposerInspector::validate public function Checks that Composer commands can be run.
ComposerInspector::validateExecutable private function Validates that the Composer executable exists in a supported version.
ComposerInspector::validateProject private function Checks that `composer.json` is valid and `composer.lock` exists.
ComposerInspector::__construct public function
StringTranslationTrait::$stringTranslation protected property The string translation service. 3
StringTranslationTrait::formatPlural protected function Formats a string containing a count of items.
StringTranslationTrait::getNumberOfPlurals protected function Returns the number of plurals supported by a given language.
StringTranslationTrait::getStringTranslation protected function Gets the string translation service.
StringTranslationTrait::setStringTranslation public function Sets the string translation service to use. 2
StringTranslationTrait::t protected function Translates a string to the current language or to a given language. 1

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.