PluginCollection.php 11 KB

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