<?php
declare(strict_types=1);
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright 2005-2011, Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.6.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace Cake\Core;
use Cake\Core\Exception\MissingPluginException;
use Countable;
use Generator;
use InvalidArgumentException;
use Iterator;
/**
* Plugin Collection
*
* Holds onto plugin objects loaded into an application, and
* provides methods for iterating, and finding plugins based
* on criteria.
*
* This class implements the Iterator interface to allow plugins
* to be iterated, handling the situation where a plugin's hook
* method (usually bootstrap) loads another plugin during iteration.
*
* While its implementation supported nested iteration it does not
* support using `continue` or `break` inside loops.
*/
class PluginCollection implements Iterator, Countable
{
/**
* Plugin list
*
* @var \Cake\Core\PluginInterface[]
*/
protected $plugins = [];
/**
* Names of plugins
*
* @var string[]
*/
protected $names = [];
/**
* Iterator position stack.
*
* @var int[]
*/
protected $positions = [];
/**
* Loop depth
*
* @var int
*/
protected $loopDepth = -1;
/**
* Constructor
*
* @param \Cake\Core\PluginInterface[] $plugins The map of plugins to add to the collection.
*/
public function __construct(array $plugins = [])
{
foreach ($plugins as $plugin) {
$this->add($plugin);
}
$this->loadConfig();
}
/**
* Load the path information stored in vendor/cakephp-plugins.php
*
* This file is generated by the cakephp/plugin-installer package and used
* to locate plugins on the filesystem as applications can use `extra.plugin-paths`
* in their composer.json file to move plugin outside of vendor/
*
* @internal
* @return void
*/
protected function loadConfig(): void
{
if (Configure::check('plugins')) {
return;
}
$vendorFile = dirname(dirname(__DIR__)) . DIRECTORY_SEPARATOR . 'cakephp-plugins.php';
if (!is_file($vendorFile)) {
$vendorFile = dirname(dirname(dirname(dirname(__DIR__)))) . DIRECTORY_SEPARATOR . 'cakephp-plugins.php';
if (!is_file($vendorFile)) {
Configure::write(['plugins' => []]);
return;
}
}
$config = require $vendorFile;
Configure::write($config);
}
/**
* Locate a plugin path by looking at configuration data.
*
* This will use the `plugins` Configure key, and fallback to enumerating `App::path('plugins')`
*
* This method is not part of the official public API as plugins with
* no plugin class are being phased out.
*
* @param string $name The plugin name to locate a path for. Will return '' when a plugin cannot be found.
* @return string
* @throws \Cake\Core\Exception\MissingPluginException when a plugin path cannot be resolved.
* @internal
*/
public function findPath(string $name): string
{
// Ensure plugin config is loaded each time. This is necessary primarily
// for testing because the Configure::clear() call in TestCase::tearDown()
// wipes out all configuration including plugin paths config.
$this->loadConfig();
$path = Configure::read('plugins.' . $name);
if ($path) {
return $path;
}
$pluginPath = str_replace('/', DIRECTORY_SEPARATOR, $name);
$paths = App::path('plugins');
foreach ($paths as $path) {
if (is_dir($path . $pluginPath)) {
return $path . $pluginPath . DIRECTORY_SEPARATOR;
}
}
throw new MissingPluginException(['plugin' => $name]);
}
/**
* Add a plugin to the collection
*
* Plugins will be keyed by their names.
*
* @param \Cake\Core\PluginInterface $plugin The plugin to load.
* @return $this
*/
public function add(PluginInterface $plugin)
{
$name = $plugin->getName();
$this->plugins[$name] = $plugin;
$this->names = array_keys($this->plugins);
return $this;
}
/**
* Remove a plugin from the collection if it exists.
*
* @param string $name The named plugin.
* @return $this
*/
public function remove(string $name)
{
unset($this->plugins[$name]);
$this->names = array_keys($this->plugins);
return $this;
}
/**
* Remove all plugins from the collection
*
* @return $this
*/
public function clear()
{
$this->plugins = [];
$this->names = [];
$this->positions = [];
$this->loopDepth = -1;
return $this;
}
/**
* Check whether the named plugin exists in the collection.
*
* @param string $name The named plugin.
* @return bool
*/
public function has(string $name): bool
{
return isset($this->plugins[$name]);
}
/**
* Get the a plugin by name.
*
* If a plugin isn't already loaded it will be autoloaded on first access
* and that plugins loaded this way may miss some hook methods.
*
* @param string $name The plugin to get.
* @return \Cake\Core\PluginInterface The plugin.
* @throws \Cake\Core\Exception\MissingPluginException when unknown plugins are fetched.
*/
public function get(string $name): PluginInterface
{
if ($this->has($name)) {
return $this->plugins[$name];
}
$plugin = $this->create($name);
$this->add($plugin);
return $plugin;
}
/**
* Create a plugin instance from a name/classname and configuration.
*
* @param string $name The plugin name or classname
* @param array $config Configuration options for the plugin.
* @return \Cake\Core\PluginInterface
* @throws \Cake\Core\Exception\MissingPluginException When plugin instance could not be created.
* @psalm-var class-string<\Cake\Core\PluginInterface> $name
*/
public function create(string $name, array $config = []): PluginInterface
{
if (strpos($name, '\\') !== false) {
// phpcs:ignore SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.InvalidFormat
/** @var \Cake\Core\PluginInterface */
return new $name($config);
}
$config += ['name' => $name];
/** @var class-string<\Cake\Core\PluginInterface> $className */
$className = str_replace('/', '\\', $name) . '\\' . 'Plugin';
if (!class_exists($className)) {
$className = BasePlugin::class;
if (empty($config['path'])) {
$config['path'] = $this->findPath($name);
}
}
return new $className($config);
}
/**
* Implementation of Countable.
*
* Get the number of plugins in the collection.
*
* @return int
*/
public function count(): int
{
return count($this->plugins);
}
/**
* Part of Iterator Interface
*
* @return void
*/
public function next(): void
{
$this->positions[$this->loopDepth]++;
}
/**
* Part of Iterator Interface
*
* @return string
*/
public function key(): string
{
return $this->names[$this->positions[$this->loopDepth]];
}
/**
* Part of Iterator Interface
*
* @return \Cake\Core\PluginInterface
*/
public function current(): PluginInterface
{
$position = $this->positions[$this->loopDepth];
$name = $this->names[$position];
return $this->plugins[$name];
}
/**
* Part of Iterator Interface
*
* @return void
*/
public function rewind(): void
{
$this->positions[] = 0;
$this->loopDepth += 1;
}
/**
* Part of Iterator Interface
*
* @return bool
*/
public function valid(): bool
{
$valid = isset($this->names[$this->positions[$this->loopDepth]]);
if (!$valid) {
array_pop($this->positions);
$this->loopDepth -= 1;
}
return $valid;
}
/**
* Filter the plugins to those with the named hook enabled.
*
* @param string $hook The hook to filter plugins by
* @return \Generator&\Cake\Core\PluginInterface[] A generator containing matching plugins.
* @throws \InvalidArgumentException on invalid hooks
* @psalm-return \Generator<\Cake\Core\PluginInterface>
*/
public function with(string $hook): Generator
{
if (!in_array($hook, PluginInterface::VALID_HOOKS, true)) {
throw new InvalidArgumentException("The `{$hook}` hook is not a known plugin hook.");
}
foreach ($this as $plugin) {
if ($plugin->isEnabled($hook)) {
yield $plugin;
}
}
}
}