From 43229d448523e7eadda8d417d9be34364a7f502d Mon Sep 17 00:00:00 2001 From: Mark Story Date: Mon, 12 Jun 2017 14:41:06 -0400 Subject: [PATCH] Implement command auto-discovery. Auto-discovery will allow us to maintain backwards compatibility and provide convention based defaults that people can replace with their own behavior if they so wish. --- src/Console/CommandCollection.php | 55 ++++++++- src/Console/CommandScanner.php | 105 ++++++++++++++++++ .../Console/CommandCollectionTest.php | 100 ++++++++++++++++- .../src/Shell/CompanyShell.php | 8 ++ 4 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 src/Console/CommandScanner.php create mode 100644 tests/test_app/Plugin/Company/TestPluginThree/src/Shell/CompanyShell.php diff --git a/src/Console/CommandCollection.php b/src/Console/CommandCollection.php index e80c3e92495..c887f306f9d 100644 --- a/src/Console/CommandCollection.php +++ b/src/Console/CommandCollection.php @@ -15,7 +15,9 @@ namespace Cake\Console; use ArrayIterator; +use Cake\Console\CommandScanner; use Cake\Console\Shell; +use Cake\Log\Log; use Countable; use InvalidArgumentException; use IteratorAggregate; @@ -60,8 +62,9 @@ public function add($name, $command) // Once we have a new Command class this should check // against that interface. if (!is_subclass_of($command, Shell::class)) { + $class = is_string($command) ? $command : get_class($command); throw new InvalidArgumentException( - "'$name' is not a subclass of Cake\Console\Shell or a valid command." + "Cannot use '$class' for command '$name' it is not a subclass of Cake\Console\Shell." ); } $this->commands[$name] = $command; @@ -130,4 +133,54 @@ public function count() { return count($this->commands); } + + /** + * Automatically discover shell commands in CakePHP, the application and all plugins. + * + * Commands will be located using filesystem conventions. Commands are + * discovered in the following order: + * + * - CakePHP provided commands + * - Application commands + * - Plugin commands + * + * Commands from plugins will be added based on the order plugins are loaded. + * Plugin shells will attempt to use a short name. If however, a plugin + * provides a shell that conflicts with CakePHP or the application shells, + * the full `plugin_name.shell` name will be used. Plugin shells are added + * in the order that plugins were loaded. + * + * @return $this + */ + public function autoDiscover() + { + $scanner = new CommandScanner(); + $shells = $scanner->scanAll(); + + $adder = function ($shells, $key) { + if (!empty($shells[$key])) { + foreach ($shells[$key] as $info) { + $name = $info['name']; + // If the short name has been used, use the full name. + // This allows app shells to have name preference. + // and app shells to overwrite core shells. + if ($this->has($name) && $name !== $info['fullName']) { + $name = $info['fullName']; + } + try { + $this->add($name, $info['class']); + } catch (InvalidArgumentException $e) { + Log::debug("Could not add {$info['class']} via autodiscovery. " . $e->getMessage()); + } + } + } + }; + $adder($shells, 'CORE'); + $adder($shells, 'app'); + foreach (array_keys($shells['plugins']) as $key) { + $adder($shells['plugins'], $key); + } + + return $this; + } } diff --git a/src/Console/CommandScanner.php b/src/Console/CommandScanner.php new file mode 100644 index 00000000000..77535ab06a7 --- /dev/null +++ b/src/Console/CommandScanner.php @@ -0,0 +1,105 @@ +scanDir( + App::path('Shell')[0], + $appNamespace . '\Shell\\', + '', + ['app'] + ); + + $shellList['CORE'] = $this->scanDir( + dirname(__DIR__) . DIRECTORY_SEPARATOR . 'Shell' . DIRECTORY_SEPARATOR, + 'Cake\Shell\\', + '', + ['command_list'] + ); + $plugins = []; + foreach (Plugin::loaded() as $plugin) { + $plugins[$plugin] = $this->scanDir( + Plugin::classPath($plugin) . 'Shell', + str_replace('/', '\\', $plugin) . '\Shell\\', + Inflector::underscore($plugin) . '.', + [] + ); + } + $shellList['plugins'] = $plugins; + + return $shellList; + } + + /** + * Scan a directory for .php files and return the class names that + * should be within them. + * + * @param string $path The directory to read. + * @param string $namespace The namespace the shells live in. + * @param string $prefix The prefix to apply to commands for their full name. + * @param array $hide A list of command names to hide as they are internal commands. + * @return array The list of shell info arrays based on scanning the filesystem and inflection. + */ + protected function scanDir($path, $namespace, $prefix, array $hide) + { + $dir = new Folder($path); + $contents = $dir->read(true, true); + if (empty($contents[1])) { + return []; + } + $shells = []; + foreach ($contents[1] as $file) { + if (substr($file, -4) !== '.php') { + continue; + } + $shell = substr($file, 0, -4); + $name = Inflector::underscore(str_replace('Shell', '', $shell)); + if (in_array($name, $hide, true)) { + continue; + } + $shells[] = [ + 'file' => $path . $file, + 'fullName' => $prefix . $name, + 'name' => $name, + 'class' => $namespace . $shell + ]; + } + + return $shells; + } +} diff --git a/tests/TestCase/Console/CommandCollectionTest.php b/tests/TestCase/Console/CommandCollectionTest.php index 24c05d115de..af3aef2d591 100644 --- a/tests/TestCase/Console/CommandCollectionTest.php +++ b/tests/TestCase/Console/CommandCollectionTest.php @@ -15,6 +15,8 @@ namespace Cake\Test\Console; use Cake\Console\CommandCollection; +use Cake\Core\Configure; +use Cake\Core\Plugin; use Cake\Shell\I18nShell; use Cake\Shell\RoutesShell; use Cake\TestSuite\TestCase; @@ -25,6 +27,12 @@ */ class CommandCollectionTest extends TestCase { + public function setUp() + { + parent::setUp(); + Configure::write('App.namespace', 'TestApp'); + } + /** * Test constructor with valid classnames * @@ -46,7 +54,7 @@ public function testConstructor() * * @return void * @expectedException InvalidArgumentException - * @expectedExceptionMessage 'nope' is not a subclass of Cake\Console\Shell + * @expectedExceptionMessage Cannot use 'stdClass' for command 'nope' it is not a subclass of Cake\Console\Shell */ public function testConstructorInvalidClass() { @@ -105,7 +113,7 @@ public function testAddInstance() * Instances that are not shells should fail. * * @expectedException InvalidArgumentException - * @expectedExceptionMessage 'routes' is not a subclass of Cake\Console\Shell + * @expectedExceptionMessage Cannot use 'stdClass' for command 'routes' it is not a subclass of Cake\Console\Shell */ public function testAddInvalidInstance() { @@ -118,7 +126,7 @@ public function testAddInvalidInstance() * Class names that are not shells should fail * * @expectedException InvalidArgumentException - * @expectedExceptionMessage 'routes' is not a subclass of Cake\Console\Shell + * @expectedExceptionMessage Cannot use 'stdClass' for command 'routes' it is not a subclass of Cake\Console\Shell */ public function testInvalidShellClassName() { @@ -169,4 +177,90 @@ public function testGetIterator() } $this->assertEquals($in, $out); } + + /** + * test autodiscovering app shells + * + * @return void + */ + public function testAutoDiscoverApp() + { + $collection = new CommandCollection(); + $this->assertSame($collection, $collection->autoDiscover()); + + $this->assertTrue($collection->has('i18m')); + $this->assertTrue($collection->has('sample')); + $this->assertTrue($collection->has('testing_dispatch')); + + $this->assertSame('TestApp\Shell\I18mShell', $collection->get('i18m')); + $this->assertSame('TestApp\Shell\SampleShell', $collection->get('sample')); + } + + /** + * test autodiscovering core shells + * + * @return void + */ + public function testAutoDiscoverCore() + { + $collection = new CommandCollection(); + $collection->autoDiscover(); + + $this->assertTrue($collection->has('routes')); + $this->assertTrue($collection->has('i18n')); + $this->assertTrue($collection->has('orm_cache')); + $this->assertTrue($collection->has('server')); + $this->assertTrue($collection->has('cache')); + $this->assertFalse($collection->has('command_list'), 'Hidden commands should stay hidden'); + + // These have to be strings as ::class uses the local namespace. + $this->assertSame('Cake\Shell\RoutesShell', $collection->get('routes')); + $this->assertSame('Cake\Shell\I18nShell', $collection->get('i18n')); + } + + /** + * test autodiscovering plugin shells + * + * @return void + */ + public function testAutoDiscoverPlugin() + { + Plugin::load('TestPlugin'); + Plugin::load('Company/TestPluginThree'); + $collection = new CommandCollection(); + $collection->autoDiscover(); + + $this->assertTrue( + $collection->has('example'), + 'Used short name for unique plugin shell' + ); + $this->assertFalse( + $collection->has('test_plugin.example'), + 'Long names not stored for unique shells' + ); + $this->assertTrue( + $collection->has('sample'), + 'Has app shell' + ); + $this->assertTrue( + $collection->has('test_plugin.sample'), + 'Duplicate shell was given a full alias' + ); + $this->assertFalse( + $collection->has('company/test_plugin_three.company'), + 'Long names not stored for unique shells' + ); + $this->assertTrue( + $collection->has('company'), + 'Used short name for unique plugin shell' + ); + + $this->assertEquals('TestPlugin\Shell\ExampleShell', $collection->get('example')); + $this->assertEquals( + 'TestApp\Shell\SampleShell', + $collection->get('sample'), + 'Should prefer app shells over plugin ones' + ); + $this->assertEquals('TestPlugin\Shell\SampleShell', $collection->get('test_plugin.sample')); + } } diff --git a/tests/test_app/Plugin/Company/TestPluginThree/src/Shell/CompanyShell.php b/tests/test_app/Plugin/Company/TestPluginThree/src/Shell/CompanyShell.php new file mode 100644 index 00000000000..22138c9bd59 --- /dev/null +++ b/tests/test_app/Plugin/Company/TestPluginThree/src/Shell/CompanyShell.php @@ -0,0 +1,8 @@ +