diff --git a/CHANGELOG.md b/CHANGELOG.md index fc22c371a..df6c059f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### Unreleased + +* Typo in `\Robo\Runner:errorCondtion()` fixed as `\Robo\Runner:errorCondition()`. + ### 1.2.1 12/28/2017 * Fixes to tests / build only. diff --git a/docs/extending.md b/docs/extending.md index 55e4f17ad..adf890eac 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -33,6 +33,65 @@ Once you have done this, all of the tasks defined in the extension you selected Note that at the moment, it is not possible to extend Robo when using the robo.phar. This capability may be added in the future via [embedded composer](https://github.com/dflydev/dflydev-embedded-composer). +## Expose discoverable command classes + +You can have your project expose extra Robo command files by creating them within your project's PSR-4 namespace. + +For example, given the following PSR-4 namespace in your `composer.json`: + +```json +{ + "autoload": { + "psr-4": { + "My\\Project\\": "./src/" + } + } +} +``` + +Extra command files can be exposed by creating one or more classes under `./src/Robo/Plugin/Commands`, as shown in the +example below: + +```php +setSelfUpdateRepository('consolidation/robo'); +$runner + ->setRelativePluginNamespace('Robo\Plugin') + ->setSelfUpdateRepository('consolidation/robo') + ->setClassLoader($classLoader); $statusCode = $runner->execute($_SERVER['argv']); -exit($statusCode); +exit($statusCode); \ No newline at end of file diff --git a/src/ClassDiscovery/AbstractClassDiscovery.php b/src/ClassDiscovery/AbstractClassDiscovery.php new file mode 100644 index 000000000..319543a54 --- /dev/null +++ b/src/ClassDiscovery/AbstractClassDiscovery.php @@ -0,0 +1,26 @@ +searchPattern = $searchPattern; + + return $this; + } +} diff --git a/src/ClassDiscovery/ClassDiscoveryInterface.php b/src/ClassDiscovery/ClassDiscoveryInterface.php new file mode 100644 index 000000000..ebbb67272 --- /dev/null +++ b/src/ClassDiscovery/ClassDiscoveryInterface.php @@ -0,0 +1,30 @@ +classLoader = $classLoader; + } + + /** + * @param string $relativeNamespace + * + * @return RelativeNamespaceDiscovery + */ + public function setRelativeNamespace($relativeNamespace) + { + $this->relativeNamespace = $relativeNamespace; + + return $this; + } + + /** + * @inheritDoc + */ + public function getClasses() + { + $classes = []; + $relativePath = $this->convertNamespaceToPath($this->relativeNamespace); + + foreach ($this->classLoader->getPrefixesPsr4() as $baseNamespace => $directories) { + $directories = array_filter(array_map(function ($directory) use ($relativePath) { + return $directory.$relativePath; + }, $directories), 'is_dir'); + + if ($directories) { + foreach ($this->search($directories, $this->searchPattern) as $file) { + $relativePathName = $file->getRelativePathname(); + $classes[] = $baseNamespace.$this->convertPathToNamespace($relativePath.'/'.$relativePathName); + } + } + } + + return $classes; + } + + /** + * {@inheritdoc} + */ + public function getFile($class) + { + return $this->classLoader->findFile($class); + } + + /** + * @param $directories + * @param $pattern + * + * @return \Symfony\Component\Finder\Finder + */ + protected function search($directories, $pattern) + { + $finder = new Finder(); + $finder->files() + ->name($pattern) + ->in($directories); + + return $finder; + } + + /** + * @param $path + * + * @return mixed + */ + protected function convertPathToNamespace($path) + { + return str_replace(['/', '.php'], ['\\', ''], trim($path, '/')); + } + + /** + * @return string + */ + public function convertNamespaceToPath($namespace) + { + return '/'.str_replace("\\", '/', trim($namespace, '\\')); + } +} diff --git a/src/Robo.php b/src/Robo.php index 164fdec36..161a2deed 100644 --- a/src/Robo.php +++ b/src/Robo.php @@ -1,6 +1,7 @@ setDispatcher($container->get('eventDispatcher')); @@ -175,8 +177,9 @@ public static function createDefaultContainer($input = null, $output = null, $ap * @param ConfigInterface $config * @param null|\Symfony\Component\Console\Input\InputInterface $input * @param null|\Symfony\Component\Console\Output\OutputInterface $output + * @param null|\Composer\Autoload\ClassLoader $classLoader */ - public static function configureContainer(ContainerInterface $container, SymfonyApplication $app, ConfigInterface $config, $input = null, $output = null) + public static function configureContainer(ContainerInterface $container, SymfonyApplication $app, ConfigInterface $config, $input = null, $output = null, $classLoader = null) { // Self-referential container refernce for the inflector $container->add('container', $container); @@ -189,6 +192,9 @@ public static function configureContainer(ContainerInterface $container, Symfony if (!$output) { $output = new \Symfony\Component\Console\Output\ConsoleOutput(); } + if (!$classLoader) { + $classLoader = new ClassLoader(); + } $config->set(Config::DECORATED, $output->isDecorated()); $config->set(Config::INTERACTIVE, $input->isInteractive()); @@ -197,6 +203,7 @@ public static function configureContainer(ContainerInterface $container, Symfony $container->share('input', $input); $container->share('output', $output); $container->share('outputAdapter', \Robo\Common\OutputAdapter::class); + $container->share('classLoader', $classLoader); // Register logging and related services. $container->share('logStyler', \Robo\Log\RoboLogStyle::class); @@ -245,6 +252,8 @@ function ($output, $message) use ($container) { ); $container->share('commandFactory', \Consolidation\AnnotatedCommand\AnnotatedCommandFactory::class) ->withMethodCall('setCommandProcessor', ['commandProcessor']); + $container->share('relativeNamespaceDiscovery', \Robo\ClassDiscovery\RelativeNamespaceDiscovery::class) + ->withArgument('classLoader'); // Deprecated: favor using collection builders to direct use of collections. $container->add('collection', \Robo\Collection\Collection::class); diff --git a/src/Runner.php b/src/Runner.php index 800ad281a..4e2c5c0cc 100644 --- a/src/Runner.php +++ b/src/Runner.php @@ -1,6 +1,7 @@ dir = getcwd(); } - protected function errorCondtion($msg, $errorType) + protected function errorCondition($msg, $errorType) { $this->errorConditions[$msg] = $errorType; } @@ -82,7 +93,7 @@ protected function loadRoboFile($output) return true; } if (!file_exists($this->dir)) { - $this->errorCondtion("Path `{$this->dir}` is invalid; please provide a valid absolute path to the Robofile to load.", 'red'); + $this->errorCondition("Path `{$this->dir}` is invalid; please provide a valid absolute path to the Robofile to load.", 'red'); return false; } @@ -91,13 +102,13 @@ protected function loadRoboFile($output) $roboFilePath = $realDir . DIRECTORY_SEPARATOR . $this->roboFile; if (!file_exists($roboFilePath)) { $requestedRoboFilePath = $this->dir . DIRECTORY_SEPARATOR . $this->roboFile; - $this->errorCondtion("Requested RoboFile `$requestedRoboFilePath` is invalid, please provide valid absolute path to load Robofile.", 'red'); + $this->errorCondition("Requested RoboFile `$requestedRoboFilePath` is invalid, please provide valid absolute path to load Robofile.", 'red'); return false; } require_once $roboFilePath; if (!class_exists($this->roboClass)) { - $this->errorCondtion("Class {$this->roboClass} was not loaded.", 'red'); + $this->errorCondition("Class {$this->roboClass} was not loaded.", 'red'); return false; } return true; @@ -120,7 +131,7 @@ public function execute($argv, $appName = null, $appVersion = null, $output = nu $app = Robo::createDefaultApplication($appName, $appVersion); } $commandFiles = $this->getRoboFileCommands($output); - return $this->run($argv, $output, $app, $commandFiles); + return $this->run($argv, $output, $app, $commandFiles, $this->classLoader); } /** @@ -128,10 +139,11 @@ public function execute($argv, $appName = null, $appVersion = null, $output = nu * @param null|\Symfony\Component\Console\Output\OutputInterface $output * @param null|\Robo\Application $app * @param array[] $commandFiles + * @param null|ClassLoader $classLoader * * @return int */ - public function run($input = null, $output = null, $app = null, $commandFiles = []) + public function run($input = null, $output = null, $app = null, $commandFiles = [], $classLoader = null) { // Create default input and output objects if they were not provided if (!$input) { @@ -151,7 +163,7 @@ public function run($input = null, $output = null, $app = null, $commandFiles = $userConfig = 'robo.yml'; $roboAppConfig = dirname(__DIR__) . '/robo.yml'; $config = Robo::createConfiguration([$userConfig, $roboAppConfig]); - $container = Robo::createDefaultContainer($input, $output, $app, $config); + $container = Robo::createDefaultContainer($input, $output, $app, $config, $classLoader); $this->setContainer($container); // Automatically register a shutdown function and // an error handler when we provide the container. @@ -164,11 +176,17 @@ public function run($input = null, $output = null, $app = null, $commandFiles = if ($app instanceof \Robo\Application) { $app->addSelfUpdateCommand($this->getSelfUpdateRepository()); if (!isset($commandFiles)) { - $this->errorCondtion("Robo is not initialized here. Please run `robo init` to create a new RoboFile.", 'yellow'); + $this->errorCondition("Robo is not initialized here. Please run `robo init` to create a new RoboFile.", 'yellow'); $app->addInitRoboFileCommand($this->roboFile, $this->roboClass); $commandFiles = []; } } + + if (!empty($this->relativePluginNamespace)) { + $commandClasses = $this->discoverCommandClasses($this->relativePluginNamespace); + $commandFiles = array_merge((array)$commandFiles, $commandClasses); + } + $this->registerCommandClasses($app, $commandFiles); try { @@ -212,6 +230,19 @@ public function registerCommandClasses($app, $commandClasses) } } + /** + * @param $relativeNamespace + * + * @return array|string[] + */ + protected function discoverCommandClasses($relativeNamespace) + { + /** @var \Robo\ClassDiscovery\RelativeNamespaceDiscovery $discovery */ + $discovery = Robo::service('relativeNamespaceDiscovery'); + $discovery->setRelativeNamespace($relativeNamespace.'\Commands'); + return $discovery->getClasses(); + } + /** * @param \Robo\Application $app * @param string|BuilderAwareInterface|ContainerAwareInterface $commandClass @@ -456,10 +487,35 @@ public function getSelfUpdateRepository() } /** - * @param string $selfUpdateRepository + * @param $selfUpdateRepository + * + * @return $this */ public function setSelfUpdateRepository($selfUpdateRepository) { $this->selfUpdateRepository = $selfUpdateRepository; + return $this; + } + + /** + * @param \Composer\Autoload\ClassLoader $classLoader + * + * @return $this + */ + public function setClassLoader(ClassLoader $classLoader) + { + $this->classLoader = $classLoader; + return $this; + } + + /** + * @param string $relativeNamespace + * + * @return $this + */ + public function setRelativePluginNamespace($relativeNamespace) + { + $this->relativePluginNamespace = $relativeNamespace; + return $this; } } diff --git a/tests/plugins/Robo/Plugin/Commands/FirstCustomCommands.php b/tests/plugins/Robo/Plugin/Commands/FirstCustomCommands.php new file mode 100644 index 000000000..b5a98452c --- /dev/null +++ b/tests/plugins/Robo/Plugin/Commands/FirstCustomCommands.php @@ -0,0 +1,25 @@ +addPsr4('\\Robo\\PluginTest\\', [realpath(__DIR__.'/../../plugins')]); + $service = new RelativeNamespaceDiscovery($classLoader); + $service->setRelativeNamespace('Robo\Plugin'); + $classes = $service->getClasses(); + + $this->assertContains('\Robo\PluginTest\Robo\Plugin\Commands\FirstCustomCommands', $classes); + $this->assertContains('\Robo\PluginTest\Robo\Plugin\Commands\SecondCustomCommands', $classes); + } + + public function testGetFile() + { + $classLoader = new ClassLoader(); + $classLoader->addPsr4('\\Robo\\PluginTest\\', [realpath(__DIR__.'/../../plugins')]); + $service = new RelativeNamespaceDiscovery($classLoader); + $service->setRelativeNamespace('Robo\Plugin'); + + $actual = $service->getFile('\Robo\PluginTest\Robo\Plugin\Commands\FirstCustomCommands'); + $this->assertStringEndsWith('FirstCustomCommands.php', $actual); + + $actual = $service->getFile('\Robo\PluginTest\Robo\Plugin\Commands\SecondCustomCommands'); + $this->assertStringEndsWith('SecondCustomCommands.php', $actual); + } + + /** + * @dataProvider testConvertPathToNamespaceData + * + * @param $path + * @param $expected + */ + public function testConvertPathToNamespace($path, $expected) + { + $classLoader = new ClassLoader(); + $discovery = new RelativeNamespaceDiscovery($classLoader); + $actual = $this->callProtected($discovery, 'convertPathToNamespace', [$path]); + $this->assertEquals($expected, $actual); + } + + public function testConvertPathToNamespaceData() + { + return [ + ['/A/B/C', 'A\B\C'], + ['A/B/C', 'A\B\C'], + ['A/B/C', 'A\B\C'], + ['A/B/C.php', 'A\B\C'], + ]; + } + + /** + * @dataProvider testConvertNamespaceToPathData + * + * @param $namespace + * @param $expected + */ + public function testConvertNamespaceToPath($namespace, $expected) + { + $classLoader = new ClassLoader(); + $discovery = new RelativeNamespaceDiscovery($classLoader); + $actual = $this->callProtected($discovery, 'convertNamespaceToPath', [$namespace]); + $this->assertEquals($expected, $actual); + } + + public function testConvertNamespaceToPathData() + { + return [ + ['A\B\C', '/A/B/C'], + ['\A\B\C\\', '/A/B/C'], + ['A\B\C\\', '/A/B/C'], + ]; + } + + protected function callProtected($object, $method, $args = []) + { + $r = new \ReflectionMethod($object, $method); + $r->setAccessible(true); + return $r->invokeArgs($object, $args); + } +}