Skip to content

Commit

Permalink
Implement command auto-discovery.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
markstory committed Jun 12, 2017
1 parent 26a1eed commit 43229d4
Show file tree
Hide file tree
Showing 4 changed files with 264 additions and 4 deletions.
55 changes: 54 additions & 1 deletion src/Console/CommandCollection.php
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
105 changes: 105 additions & 0 deletions src/Console/CommandScanner.php
@@ -0,0 +1,105 @@
<?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 the application, cakephp and plugins for shells
*
* @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;
}
}
100 changes: 97 additions & 3 deletions tests/TestCase/Console/CommandCollectionTest.php
Expand Up @@ -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;
Expand All @@ -25,6 +27,12 @@
*/
class CommandCollectionTest extends TestCase
{
public function setUp()
{
parent::setUp();
Configure::write('App.namespace', 'TestApp');
}

/**
* Test constructor with valid classnames
*
Expand All @@ -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()
{
Expand Down Expand Up @@ -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()
{
Expand All @@ -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()
{
Expand Down Expand Up @@ -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'));
}
}
@@ -0,0 +1,8 @@
<?php
namespace Company\TestPluginThree\Shell;

use Cake\Console\Shell;

class CompanyShell extends Shell
{
}

0 comments on commit 43229d4

Please sign in to comment.