PluginCollection.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
  5. * Copyright 2005-2011, Cake Software Foundation, Inc. (https://cakefoundation.org)
  6. *
  7. * Licensed under The MIT License
  8. * Redistributions of files must retain the above copyright notice.
  9. *
  10. * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
  11. * @link https://cakephp.org CakePHP(tm) Project
  12. * @since 3.6.0
  13. * @license https://opensource.org/licenses/mit-license.php MIT License
  14. */
  15. namespace Cake\Core;
  16. use Cake\Core\Exception\CakeException;
  17. use Cake\Core\Exception\MissingPluginException;
  18. use Cake\Utility\Hash;
  19. use Countable;
  20. use Generator;
  21. use InvalidArgumentException;
  22. use Iterator;
  23. /**
  24. * Plugin Collection
  25. *
  26. * Holds onto plugin objects loaded into an application, and
  27. * provides methods for iterating, and finding plugins based
  28. * on criteria.
  29. *
  30. * This class implements the Iterator interface to allow plugins
  31. * to be iterated, handling the situation where a plugin's hook
  32. * method (usually bootstrap) loads another plugin during iteration.
  33. *
  34. * While its implementation supported nested iteration it does not
  35. * support using `continue` or `break` inside loops.
  36. *
  37. * @template-implements \Iterator<string, \Cake\Core\PluginInterface>
  38. */
  39. class PluginCollection implements Iterator, Countable
  40. {
  41. /**
  42. * Plugin list
  43. *
  44. * @var array<\Cake\Core\PluginInterface>
  45. */
  46. protected array $plugins = [];
  47. /**
  48. * Names of plugins
  49. *
  50. * @var array<string>
  51. */
  52. protected array $names = [];
  53. /**
  54. * Iterator position stack.
  55. *
  56. * @var array<int>
  57. */
  58. protected array $positions = [];
  59. /**
  60. * Loop depth
  61. *
  62. * @var int
  63. */
  64. protected int $loopDepth = -1;
  65. /**
  66. * Constructor
  67. *
  68. * @param array<\Cake\Core\PluginInterface> $plugins The map of plugins to add to the collection.
  69. */
  70. public function __construct(array $plugins = [])
  71. {
  72. foreach ($plugins as $plugin) {
  73. $this->add($plugin);
  74. }
  75. $this->loadConfig();
  76. }
  77. /**
  78. * Add plugins from config array.
  79. *
  80. * @param array $config Configuration array. For e.g.:
  81. * ```
  82. * [
  83. * 'Company/TestPluginThree',
  84. * 'TestPlugin' => ['onlyDebug' => true, 'onlyCli' => true],
  85. * 'Nope' => ['optional' => true],
  86. * 'Named' => ['routes' => false, 'bootstrap' => false],
  87. * ]
  88. * ```
  89. * @return void
  90. */
  91. public function addFromConfig(array $config): void
  92. {
  93. $debug = Configure::read('debug');
  94. $cli = PHP_SAPI === 'cli';
  95. foreach (Hash::normalize($config) as $name => $options) {
  96. $options = (array)$options;
  97. $onlyDebug = $options['onlyDebug'] ?? false;
  98. $onlyCli = $options['onlyCli'] ?? false;
  99. $optional = $options['optional'] ?? false;
  100. if (
  101. ($onlyDebug && !$debug)
  102. || ($onlyCli && !$cli)
  103. ) {
  104. continue;
  105. }
  106. try {
  107. $plugin = $this->create($name, $options);
  108. $this->add($plugin);
  109. } catch (MissingPluginException $e) {
  110. if (!$optional) {
  111. throw $e;
  112. }
  113. }
  114. }
  115. }
  116. /**
  117. * Load the path information stored in vendor/cakephp-plugins.php
  118. *
  119. * This file is generated by the cakephp/plugin-installer package and used
  120. * to locate plugins on the filesystem as applications can use `extra.plugin-paths`
  121. * in their composer.json file to move plugin outside of vendor/
  122. *
  123. * @internal
  124. * @return void
  125. */
  126. protected function loadConfig(): void
  127. {
  128. if (Configure::check('plugins')) {
  129. return;
  130. }
  131. $vendorFile = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'cakephp-plugins.php';
  132. if (!is_file($vendorFile)) {
  133. $vendorFile = dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'cakephp-plugins.php';
  134. if (!is_file($vendorFile)) {
  135. Configure::write(['plugins' => []]);
  136. return;
  137. }
  138. }
  139. $config = require $vendorFile;
  140. Configure::write($config);
  141. }
  142. /**
  143. * Locate a plugin path by looking at configuration data.
  144. *
  145. * This will use the `plugins` Configure key, and fallback to enumerating `App::path('plugins')`
  146. *
  147. * This method is not part of the official public API as plugins with
  148. * no plugin class are being phased out.
  149. *
  150. * @param string $name The plugin name to locate a path for.
  151. * @return string
  152. * @throws \Cake\Core\Exception\MissingPluginException when a plugin path cannot be resolved.
  153. * @internal
  154. */
  155. public function findPath(string $name): string
  156. {
  157. // Ensure plugin config is loaded each time. This is necessary primarily
  158. // for testing because the Configure::clear() call in TestCase::tearDown()
  159. // wipes out all configuration including plugin paths config.
  160. $this->loadConfig();
  161. $path = Configure::read('plugins.' . $name);
  162. if ($path) {
  163. return $path;
  164. }
  165. $pluginPath = str_replace('/', DIRECTORY_SEPARATOR, $name);
  166. $paths = App::path('plugins');
  167. foreach ($paths as $path) {
  168. if (is_dir($path . $pluginPath)) {
  169. return $path . $pluginPath . DIRECTORY_SEPARATOR;
  170. }
  171. }
  172. throw new MissingPluginException(['plugin' => $name]);
  173. }
  174. /**
  175. * Add a plugin to the collection
  176. *
  177. * Plugins will be keyed by their names.
  178. *
  179. * @param \Cake\Core\PluginInterface $plugin The plugin to load.
  180. * @return $this
  181. */
  182. public function add(PluginInterface $plugin)
  183. {
  184. $name = $plugin->getName();
  185. $this->plugins[$name] = $plugin;
  186. $this->names = array_keys($this->plugins);
  187. return $this;
  188. }
  189. /**
  190. * Remove a plugin from the collection if it exists.
  191. *
  192. * @param string $name The named plugin.
  193. * @return $this
  194. */
  195. public function remove(string $name)
  196. {
  197. unset($this->plugins[$name]);
  198. $this->names = array_keys($this->plugins);
  199. return $this;
  200. }
  201. /**
  202. * Remove all plugins from the collection
  203. *
  204. * @return $this
  205. */
  206. public function clear()
  207. {
  208. $this->plugins = [];
  209. $this->names = [];
  210. $this->positions = [];
  211. $this->loopDepth = -1;
  212. return $this;
  213. }
  214. /**
  215. * Check whether the named plugin exists in the collection.
  216. *
  217. * @param string $name The named plugin.
  218. * @return bool
  219. */
  220. public function has(string $name): bool
  221. {
  222. return isset($this->plugins[$name]);
  223. }
  224. /**
  225. * Get the a plugin by name.
  226. *
  227. * If a plugin isn't already loaded it will be autoloaded on first access
  228. * and that plugins loaded this way may miss some hook methods.
  229. *
  230. * @param string $name The plugin to get.
  231. * @return \Cake\Core\PluginInterface The plugin.
  232. * @throws \Cake\Core\Exception\MissingPluginException when unknown plugins are fetched.
  233. */
  234. public function get(string $name): PluginInterface
  235. {
  236. if ($this->has($name)) {
  237. return $this->plugins[$name];
  238. }
  239. $plugin = $this->create($name);
  240. $this->add($plugin);
  241. return $plugin;
  242. }
  243. /**
  244. * Create a plugin instance from a name/classname and configuration.
  245. *
  246. * @param string $name The plugin name or classname
  247. * @param array<string, mixed> $config Configuration options for the plugin.
  248. * @return \Cake\Core\PluginInterface
  249. * @throws \Cake\Core\Exception\MissingPluginException When plugin instance could not be created.
  250. * @throws \InvalidArgumentException When class name cannot be found.
  251. * @psalm-param class-string<\Cake\Core\PluginInterface>|string $name
  252. */
  253. public function create(string $name, array $config = []): PluginInterface
  254. {
  255. if ($name === '') {
  256. throw new CakeException('Cannot create a plugin with empty name');
  257. }
  258. if (str_contains($name, '\\')) {
  259. if (!class_exists($name)) {
  260. throw new InvalidArgumentException(sprintf('Class `%s` does not exist.', $name));
  261. }
  262. /** @var \Cake\Core\PluginInterface */
  263. return new $name($config);
  264. }
  265. $config += ['name' => $name];
  266. $namespace = str_replace('/', '\\', $name);
  267. $className = $namespace . '\\' . 'Plugin';
  268. // Check for [Vendor/]Foo/Plugin class
  269. if (!class_exists($className)) {
  270. $pos = strpos($name, '/');
  271. if ($pos === false) {
  272. $className = $namespace . '\\' . $name . 'Plugin';
  273. } else {
  274. $className = $namespace . '\\' . substr($name, $pos + 1) . 'Plugin';
  275. }
  276. // Check for [Vendor/]Foo/FooPlugin
  277. if (!class_exists($className)) {
  278. $className = BasePlugin::class;
  279. if (empty($config['path'])) {
  280. $config['path'] = $this->findPath($name);
  281. }
  282. }
  283. }
  284. /** @var class-string<\Cake\Core\PluginInterface> $className */
  285. return new $className($config);
  286. }
  287. /**
  288. * Implementation of Countable.
  289. *
  290. * Get the number of plugins in the collection.
  291. *
  292. * @return int
  293. */
  294. public function count(): int
  295. {
  296. return count($this->plugins);
  297. }
  298. /**
  299. * Part of Iterator Interface
  300. *
  301. * @return void
  302. */
  303. public function next(): void
  304. {
  305. $this->positions[$this->loopDepth]++;
  306. }
  307. /**
  308. * Part of Iterator Interface
  309. *
  310. * @return string
  311. */
  312. public function key(): string
  313. {
  314. return $this->names[$this->positions[$this->loopDepth]];
  315. }
  316. /**
  317. * Part of Iterator Interface
  318. *
  319. * @return \Cake\Core\PluginInterface
  320. */
  321. public function current(): PluginInterface
  322. {
  323. $position = $this->positions[$this->loopDepth];
  324. $name = $this->names[$position];
  325. return $this->plugins[$name];
  326. }
  327. /**
  328. * Part of Iterator Interface
  329. *
  330. * @return void
  331. */
  332. public function rewind(): void
  333. {
  334. $this->positions[] = 0;
  335. $this->loopDepth += 1;
  336. }
  337. /**
  338. * Part of Iterator Interface
  339. *
  340. * @return bool
  341. */
  342. public function valid(): bool
  343. {
  344. $valid = isset($this->names[$this->positions[$this->loopDepth]]);
  345. if (!$valid) {
  346. array_pop($this->positions);
  347. $this->loopDepth -= 1;
  348. }
  349. return $valid;
  350. }
  351. /**
  352. * Filter the plugins to those with the named hook enabled.
  353. *
  354. * @param string $hook The hook to filter plugins by
  355. * @return \Generator<\Cake\Core\PluginInterface> A generator containing matching plugins.
  356. * @throws \InvalidArgumentException on invalid hooks
  357. */
  358. public function with(string $hook): Generator
  359. {
  360. if (!in_array($hook, PluginInterface::VALID_HOOKS, true)) {
  361. throw new InvalidArgumentException(sprintf('The `%s` hook is not a known plugin hook.', $hook));
  362. }
  363. foreach ($this as $plugin) {
  364. if ($plugin->isEnabled($hook)) {
  365. yield $plugin;
  366. }
  367. }
  368. }
  369. }