Browse Source

Merge pull request #10767 from cakephp/command-collection-discovery

3.next - Command collection autoDiscover()
Mark Story 8 years ago
parent
commit
a4a2654609

+ 77 - 1
src/Console/CommandCollection.php

@@ -15,6 +15,7 @@
 namespace Cake\Console;
 
 use ArrayIterator;
+use Cake\Console\CommandScanner;
 use Cake\Console\Shell;
 use Countable;
 use InvalidArgumentException;
@@ -60,16 +61,34 @@ class CommandCollection implements IteratorAggregate, Countable
         // 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;
 
         return $this;
     }
 
     /**
+     * Add multiple commands at once.
+     *
+     * @param array $commands A map of command names => command classes/instances.
+     * @return $this
+     * @see \Cake\Console\CommandCollection::add()
+     */
+    public function addMany(array $commands)
+    {
+        foreach ($commands as $name => $class) {
+            $this->add($name, $class);
+        }
+
+        return $this;
+    }
+
+    /**
      * Remove a command from the collection if it exists.
      *
      * @param string $name The named shell.
@@ -130,4 +149,61 @@ class CommandCollection implements IteratorAggregate, Countable
     {
         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 array An array of command names and their classes.
+     */
+    public function autoDiscover()
+    {
+        $scanner = new CommandScanner();
+        $shells = $scanner->scanAll();
+
+        $adder = function ($out, $shells, $key) {
+            if (empty($shells[$key])) {
+                return $out;
+            }
+
+            foreach ($shells[$key] as $info) {
+                $name = $info['name'];
+                $addLong = $name !== $info['fullName'];
+
+                // 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 (isset($out[$name]) && $addLong) {
+                    $name = $info['fullName'];
+                }
+
+                $out[$name] = $info['class'];
+                if ($addLong) {
+                    $out[$info['fullName']] = $info['class'];
+                }
+            }
+
+            return $out;
+        };
+
+        $out = $adder([], $shells, 'CORE');
+        $out = $adder($out, $shells, 'app');
+        foreach (array_keys($shells['plugins']) as $key) {
+            $out = $adder($out, $shells['plugins'], $key);
+        }
+
+        return $out;
+    }
 }

+ 109 - 0
src/Console/CommandScanner.php

@@ -0,0 +1,109 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         3.5.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Console;
+
+use Cake\Core\App;
+use Cake\Core\Configure;
+use Cake\Core\Plugin;
+use Cake\Filesystem\Folder;
+use Cake\Utility\Inflector;
+
+/**
+ * Used by CommanCollection and CommandTask to scan the filesystem
+ * for command classes.
+ *
+ * @internal
+ */
+class CommandScanner
+{
+    /**
+     * Scan CakePHP core, the applications and plugins for shell classes
+     *
+     * @return array
+     */
+    public function scanAll()
+    {
+        $shellList = [];
+
+        $appNamespace = Configure::read('App.namespace');
+        $shellList['app'] = $this->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;
+    }
+}

+ 98 - 3
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 @@ use stdClass;
  */
 class CommandCollectionTest extends TestCase
 {
+    public function setUp()
+    {
+        parent::setUp();
+        Configure::write('App.namespace', 'TestApp');
+    }
+
     /**
      * Test constructor with valid classnames
      *
@@ -46,7 +54,7 @@ class CommandCollectionTest extends TestCase
      *
      * @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 @@ class CommandCollectionTest extends TestCase
      * 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 @@ class CommandCollectionTest extends TestCase
      * 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,91 @@ class CommandCollectionTest extends TestCase
         }
         $this->assertEquals($in, $out);
     }
+
+    /**
+     * test autodiscovering app shells
+     *
+     * @return void
+     */
+    public function testAutoDiscoverApp()
+    {
+        $collection = new CommandCollection();
+        $collection->addMany($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->addMany($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->addMany($collection->autoDiscover());
+
+        $this->assertTrue(
+            $collection->has('example'),
+            'Used short name for unique plugin shell'
+        );
+        $this->assertTrue(
+            $collection->has('test_plugin.example'),
+            'Long names are 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->assertTrue(
+            $collection->has('company'),
+            'Used short name for unique plugin shell'
+        );
+        $this->assertTrue(
+            $collection->has('company/test_plugin_three.company'),
+            'Long names are stored as well'
+        );
+
+        $this->assertEquals('TestPlugin\Shell\ExampleShell', $collection->get('example'));
+        $this->assertEquals($collection->get('example'), $collection->get('test_plugin.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'));
+    }
 }

+ 2 - 0
tests/TestCase/Console/ShellDispatcherTest.php

@@ -104,6 +104,7 @@ class ShellDispatcherTest extends TestCase
     public function testAddShortPluginAlias()
     {
         $expected = [
+            'Company' => 'Company/TestPluginThree.company',
             'Example' => 'TestPlugin.example'
         ];
         $result = $this->dispatcher->addShortPluginAliases();
@@ -111,6 +112,7 @@ class ShellDispatcherTest extends TestCase
 
         ShellDispatcher::alias('Example', 'SomeOther.PluginsShell');
         $expected = [
+            'Company' => 'Company/TestPluginThree.company',
             'Example' => 'SomeOther.PluginsShell'
         ];
         $result = $this->dispatcher->addShortPluginAliases();

+ 8 - 0
tests/test_app/Plugin/Company/TestPluginThree/src/Shell/CompanyShell.php

@@ -0,0 +1,8 @@
+<?php
+namespace Company\TestPluginThree\Shell;
+
+use Cake\Console\Shell;
+
+class CompanyShell extends Shell
+{
+}

+ 1 - 1
tests/test_app/TestApp/Shell/I18mShell.php

@@ -22,7 +22,7 @@ namespace TestApp\Shell;
 
 use Cake\Console\Shell;
 
-class I18m extends Shell
+class I18mShell extends Shell
 {
 
     /**