From 53748246092cab55d5fdf92735e9c51f59439531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gonz=C3=A1lez?= Date: Thu, 9 Apr 2026 13:43:24 +0100 Subject: [PATCH 1/4] #79-fix-vendor-plugins check first and second level for vendor based plugins --- src/Plugin.php | 87 ++++++++++++++++++++++++++--------- tests/TestCase/PluginTest.php | 6 +++ 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/src/Plugin.php b/src/Plugin.php index 2155c9c..0ea76be 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -69,23 +69,44 @@ public function preAutoloadDump(Event $event): void continue; } foreach (new DirectoryIterator($root . $pluginsPath) as $fileInfo) { - if (!$fileInfo->isDir() || $fileInfo->isDot()) { + if (!$fileInfo->isDir() || $fileInfo->isDot() || $fileInfo->getFilename()[0] === '.') { continue; } $folderName = $fileInfo->getFilename(); - if ($folderName[0] === '.') { - continue; - } - - $pluginNamespace = $folderName . '\\'; - $pluginTestNamespace = $folderName . '\\Test\\'; $path = $pluginsPath . '/' . $folderName . '/'; - if (!isset($autoload['psr-4'][$pluginNamespace]) && is_dir($root . $path . '/src')) { - $autoload['psr-4'][$pluginNamespace] = $path . 'src'; - } - if (!isset($devAutoload['psr-4'][$pluginTestNamespace]) && is_dir($root . $path . '/tests')) { - $devAutoload['psr-4'][$pluginTestNamespace] = $path . 'tests'; + + if (is_dir($root . $path . '/src')) { + // Level 1: plugins/MyPlugin/src/ + $ns = $folderName . '\\'; + $testNs = $folderName . '\\Test\\'; + if (!isset($autoload['psr-4'][$ns])) { + $autoload['psr-4'][$ns] = $path . 'src'; + } + if (!isset($devAutoload['psr-4'][$testNs]) && is_dir($root . $path . '/tests')) { + $devAutoload['psr-4'][$testNs] = $path . 'tests'; + } + } elseif (is_dir($root . $path)) { + // Level 2: plugins/YourVendor/YourPlugin/src/ — vendor-namespaced app plugins only. + // No deeper nesting is supported beyond this second level. + foreach (new DirectoryIterator($root . $path) as $subInfo) { + if (!$subInfo->isDir() || $subInfo->isDot() || $subInfo->getFilename()[0] === '.') { + continue; + } + $subName = $subInfo->getFilename(); + $subPath = $path . $subName . '/'; + if (!is_dir($root . $subPath . '/src')) { + continue; + } + $ns = $folderName . '\\' . $subName . '\\'; + $testNs = $folderName . '\\' . $subName . '\\Test\\'; + if (!isset($autoload['psr-4'][$ns])) { + $autoload['psr-4'][$ns] = $subPath . 'src'; + } + if (!isset($devAutoload['psr-4'][$testNs]) && is_dir($root . $subPath . '/tests')) { + $devAutoload['psr-4'][$testNs] = $subPath . 'tests'; + } + } } } } @@ -154,19 +175,39 @@ public function findPlugins( foreach ($pluginDirs as $path) { $path = $this->getFullPath($path, $vendorDir); - if (is_dir($path)) { - $dir = new DirectoryIterator($path); - foreach ($dir as $info) { - if (!$info->isDir() || $info->isDot()) { - continue; - } + if (!is_dir($path)) { + continue; + } + foreach (new DirectoryIterator($path) as $info) { + if (!$info->isDir() || $info->isDot() || $info->getFilename()[0] === '.') { + continue; + } - $name = $info->getFilename(); - if ($name[0] === '.') { - continue; + $name = $info->getFilename(); + $pluginPath = $path . DIRECTORY_SEPARATOR . $name; + + if (is_dir($pluginPath . DIRECTORY_SEPARATOR . 'src')) { + // Level 1: plugins/MyPlugin/ + $plugins[$name] = $pluginPath; + } else { + // Level 2: plugins/YourVendor/YourPlugin/ — vendor-namespaced app plugins only. + // No deeper nesting is supported beyond this second level. + // Dirs with no src/ and no plugin children are registered as-is for backward compatibility. + $hasVendoredPlugins = false; + foreach (new DirectoryIterator($pluginPath) as $subInfo) { + if (!$subInfo->isDir() || $subInfo->isDot() || $subInfo->getFilename()[0] === '.') { + continue; + } + $subName = $subInfo->getFilename(); + $subPluginPath = $pluginPath . DIRECTORY_SEPARATOR . $subName; + if (is_dir($subPluginPath . DIRECTORY_SEPARATOR . 'src')) { + $plugins[$name . '/' . $subName] = $subPluginPath; + $hasVendoredPlugins = true; + } + } + if (!$hasVendoredPlugins) { + $plugins[$name] = $pluginPath; } - - $plugins[$name] = $path . DIRECTORY_SEPARATOR . $name; } } } diff --git a/tests/TestCase/PluginTest.php b/tests/TestCase/PluginTest.php index a13beef..bac2463 100644 --- a/tests/TestCase/PluginTest.php +++ b/tests/TestCase/PluginTest.php @@ -39,6 +39,8 @@ class PluginTest extends TestCase 'plugins/Fee/tests', 'plugins/Foe/src', 'plugins/Fum', + 'plugins/YourVendor/YourPlugin/src', + 'plugins/YourVendor/YourPlugin/tests', 'app_plugins/Bar/src', 'app_plugins/Bar/tests', ]; @@ -148,6 +150,7 @@ public function testPreAutoloadDump() 'Foo\\' => 'xyz/Foo/src', 'Fee\\' => 'plugins/Fee/src', 'Foe\\' => 'plugins/Foe/src', + 'YourVendor\\YourPlugin\\' => 'plugins/YourVendor/YourPlugin/src', 'Bar\\' => 'app_plugins/Bar/src', ], ]; @@ -157,6 +160,7 @@ public function testPreAutoloadDump() 'psr-4' => [ 'Foo\Test\\' => 'xyz/Foo/tests', 'Fee\Test\\' => 'plugins/Fee/tests', + 'YourVendor\\YourPlugin\\Test\\' => 'plugins/YourVendor/YourPlugin/tests', 'Bar\Test\\' => 'app_plugins/Bar/tests', ], ]; @@ -324,6 +328,7 @@ public function testFindPlugins() 'Fum' => $this->path . '/plugins/Fum', 'Princess' => $this->path . '/vendor/cakephp/princess', 'TheThing' => $this->path . '/vendor/cakephp/the-thing', + 'YourVendor/YourPlugin' => $this->path . '/plugins/YourVendor/YourPlugin', ]; $this->assertSame($expected, $return, 'Composer and application plugins should be listed'); @@ -341,6 +346,7 @@ public function testFindPlugins() 'Fum' => $this->path . '/plugins/Fum', 'Princess' => $this->path . '/vendor/cakephp/princess', 'TheThing' => $this->path . '/vendor/cakephp/the-thing', + 'YourVendor/YourPlugin' => $this->path . '/plugins/YourVendor/YourPlugin', ]; $this->assertSame($expected, $return, 'Composer and application plugins should be listed'); } From c15576f8f39ca83473a8de76180f6cd946b4f92c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Gonz=C3=A1lez?= Date: Thu, 9 Apr 2026 13:43:25 +0100 Subject: [PATCH 2/4] fix cs --- src/Plugin.php | 1 + tests/TestCase/PluginTest.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Plugin.php b/src/Plugin.php index 0ea76be..1864069 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -1,4 +1,5 @@ Date: Thu, 9 Apr 2026 13:45:37 +0100 Subject: [PATCH 3/4] fix cs --- src/Plugin.php | 1 - tests/TestCase/PluginTest.php | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Plugin.php b/src/Plugin.php index 1864069..0ea76be 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -1,5 +1,4 @@ Date: Thu, 9 Apr 2026 15:10:46 +0200 Subject: [PATCH 4/4] Refactor app plugin discovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Plugin.php | 162 ++++++++++++++++++++-------------- tests/TestCase/PluginTest.php | 3 + 2 files changed, 97 insertions(+), 68 deletions(-) diff --git a/src/Plugin.php b/src/Plugin.php index 0ea76be..311c6a1 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -65,48 +65,20 @@ public function preAutoloadDump(Event $event): void $root = dirname(realpath($event->getComposer()->getConfig()->get('vendor-dir'))) . '/'; foreach ($extra['plugin-paths'] as $pluginsPath) { - if (!is_dir($root . $pluginsPath)) { + $pluginPath = $root . $pluginsPath; + if (!is_dir($pluginPath)) { continue; } - foreach (new DirectoryIterator($root . $pluginsPath) as $fileInfo) { - if (!$fileInfo->isDir() || $fileInfo->isDot() || $fileInfo->getFilename()[0] === '.') { - continue; - } + foreach ($this->findAppPlugins($pluginPath) as $pluginName => $pluginPath) { + $namespace = str_replace('/', '\\', $pluginName) . '\\'; + $testNamespace = $namespace . 'Test\\'; + $path = $this->getRelativePath($pluginPath, $root); - $folderName = $fileInfo->getFilename(); - $path = $pluginsPath . '/' . $folderName . '/'; - - if (is_dir($root . $path . '/src')) { - // Level 1: plugins/MyPlugin/src/ - $ns = $folderName . '\\'; - $testNs = $folderName . '\\Test\\'; - if (!isset($autoload['psr-4'][$ns])) { - $autoload['psr-4'][$ns] = $path . 'src'; - } - if (!isset($devAutoload['psr-4'][$testNs]) && is_dir($root . $path . '/tests')) { - $devAutoload['psr-4'][$testNs] = $path . 'tests'; - } - } elseif (is_dir($root . $path)) { - // Level 2: plugins/YourVendor/YourPlugin/src/ — vendor-namespaced app plugins only. - // No deeper nesting is supported beyond this second level. - foreach (new DirectoryIterator($root . $path) as $subInfo) { - if (!$subInfo->isDir() || $subInfo->isDot() || $subInfo->getFilename()[0] === '.') { - continue; - } - $subName = $subInfo->getFilename(); - $subPath = $path . $subName . '/'; - if (!is_dir($root . $subPath . '/src')) { - continue; - } - $ns = $folderName . '\\' . $subName . '\\'; - $testNs = $folderName . '\\' . $subName . '\\Test\\'; - if (!isset($autoload['psr-4'][$ns])) { - $autoload['psr-4'][$ns] = $subPath . 'src'; - } - if (!isset($devAutoload['psr-4'][$testNs]) && is_dir($root . $subPath . '/tests')) { - $devAutoload['psr-4'][$testNs] = $subPath . 'tests'; - } - } + if (!isset($autoload['psr-4'][$namespace])) { + $autoload['psr-4'][$namespace] = $path . '/src'; + } + if (!isset($devAutoload['psr-4'][$testNamespace]) && is_dir($pluginPath . '/tests')) { + $devAutoload['psr-4'][$testNamespace] = $path . '/tests'; } } } @@ -178,45 +150,99 @@ public function findPlugins( if (!is_dir($path)) { continue; } - foreach (new DirectoryIterator($path) as $info) { - if (!$info->isDir() || $info->isDot() || $info->getFilename()[0] === '.') { + $plugins += $this->findAppPlugins($path, true); + } + + ksort($plugins); + + return $plugins; + } + + /** + * Find application plugins in a plugin path. + * + * Supports both `plugins/MyPlugin/src` and `plugins/Vendor/Plugin/src`. + * When requested, top-level directories with no plugin children are kept + * for backward compatibility. + * + * @param string $path The absolute plugin path. + * @param bool $keepLegacyDirectories Whether to keep legacy top-level entries. + * @return array + */ + protected function findAppPlugins(string $path, bool $keepLegacyDirectories = false): array + { + $plugins = []; + + foreach (new DirectoryIterator($path) as $info) { + if ($this->shouldSkipDirectory($info)) { + continue; + } + + $name = $info->getFilename(); + $pluginPath = $info->getPathname(); + if ($this->isPluginDirectory($pluginPath)) { + $plugins[$name] = $pluginPath; + + continue; + } + + $vendorPlugins = []; + foreach (new DirectoryIterator($pluginPath) as $subInfo) { + if ($this->shouldSkipDirectory($subInfo)) { continue; } - $name = $info->getFilename(); - $pluginPath = $path . DIRECTORY_SEPARATOR . $name; - - if (is_dir($pluginPath . DIRECTORY_SEPARATOR . 'src')) { - // Level 1: plugins/MyPlugin/ - $plugins[$name] = $pluginPath; - } else { - // Level 2: plugins/YourVendor/YourPlugin/ — vendor-namespaced app plugins only. - // No deeper nesting is supported beyond this second level. - // Dirs with no src/ and no plugin children are registered as-is for backward compatibility. - $hasVendoredPlugins = false; - foreach (new DirectoryIterator($pluginPath) as $subInfo) { - if (!$subInfo->isDir() || $subInfo->isDot() || $subInfo->getFilename()[0] === '.') { - continue; - } - $subName = $subInfo->getFilename(); - $subPluginPath = $pluginPath . DIRECTORY_SEPARATOR . $subName; - if (is_dir($subPluginPath . DIRECTORY_SEPARATOR . 'src')) { - $plugins[$name . '/' . $subName] = $subPluginPath; - $hasVendoredPlugins = true; - } - } - if (!$hasVendoredPlugins) { - $plugins[$name] = $pluginPath; - } + $subName = $subInfo->getFilename(); + $subPluginPath = $subInfo->getPathname(); + if ($this->isPluginDirectory($subPluginPath)) { + $vendorPlugins[$name . '/' . $subName] = $subPluginPath; } } - } - ksort($plugins); + if ($vendorPlugins) { + $plugins += $vendorPlugins; + + continue; + } + if ($keepLegacyDirectories) { + $plugins[$name] = $pluginPath; + } + } return $plugins; } + /** + * @param \DirectoryIterator $info Directory iterator entry. + * @return bool + */ + protected function shouldSkipDirectory(DirectoryIterator $info): bool + { + return !$info->isDir() || $info->isDot() || $info->getFilename()[0] === '.'; + } + + /** + * @param string $path Directory path. + * @return bool + */ + protected function isPluginDirectory(string $path): bool + { + return is_dir($path . DIRECTORY_SEPARATOR . 'src'); + } + + /** + * @param string $path Absolute plugin path. + * @param string $root Absolute application root path. + * @return string + */ + protected function getRelativePath(string $path, string $root): string + { + $path = str_replace('\\', '/', $path); + $root = str_replace('\\', '/', $root); + + return trim(substr($path, strlen($root)), '/'); + } + /** * Turns relative paths in full paths. * diff --git a/tests/TestCase/PluginTest.php b/tests/TestCase/PluginTest.php index bac2463..3ed10ec 100644 --- a/tests/TestCase/PluginTest.php +++ b/tests/TestCase/PluginTest.php @@ -39,6 +39,7 @@ class PluginTest extends TestCase 'plugins/Fee/tests', 'plugins/Foe/src', 'plugins/Fum', + 'plugins/LegacyVendor/Childless', 'plugins/YourVendor/YourPlugin/src', 'plugins/YourVendor/YourPlugin/tests', 'app_plugins/Bar/src', @@ -326,6 +327,7 @@ public function testFindPlugins() 'Foe' => $this->path . '/plugins/Foe', 'Foo' => $this->path . '/plugins/Foo', 'Fum' => $this->path . '/plugins/Fum', + 'LegacyVendor' => $this->path . '/plugins/LegacyVendor', 'Princess' => $this->path . '/vendor/cakephp/princess', 'TheThing' => $this->path . '/vendor/cakephp/the-thing', 'YourVendor/YourPlugin' => $this->path . '/plugins/YourVendor/YourPlugin', @@ -344,6 +346,7 @@ public function testFindPlugins() 'Foe' => $this->path . '/plugins/Foe', 'Foo' => $this->path . '/plugins/Foo', 'Fum' => $this->path . '/plugins/Fum', + 'LegacyVendor' => $this->path . '/plugins/LegacyVendor', 'Princess' => $this->path . '/vendor/cakephp/princess', 'TheThing' => $this->path . '/vendor/cakephp/the-thing', 'YourVendor/YourPlugin' => $this->path . '/plugins/YourVendor/YourPlugin',