diff --git a/src/Illuminate/Console/Application.php b/src/Illuminate/Console/Application.php index 36e77ffbdc80..f78f816a6814 100755 --- a/src/Illuminate/Console/Application.php +++ b/src/Illuminate/Console/Application.php @@ -12,6 +12,7 @@ use Illuminate\Support\ProcessUtils; use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Command\Command as SymfonyCommand; +use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArrayInput; @@ -54,11 +55,18 @@ class Application extends SymfonyApplication implements ApplicationContract protected static $bootstrappers = []; /** - * A map of command names to classes. + * Indicates whether the console application has booted. * - * @var array + * @var bool + */ + protected $booted = false; + + /** + * The lazy command loader. + * + * @var \Symfony\Component\Console\CommandLoader\CommandLoaderInterface */ - protected $commandMap = []; + protected $commandLoader; /** * Create a new Artisan console application. @@ -79,7 +87,7 @@ public function __construct(Container $laravel, Dispatcher $events, $version) $this->events->dispatch(new ArtisanStarting($this)); - $this->bootstrap(); + $this->setContainerCommandLoader(); } /** @@ -151,13 +159,21 @@ public static function starting(Closure $callback) /** * Bootstrap the console application. * - * @return void + * @return $this */ - protected function bootstrap() + public function bootstrap() { + if ($this->booted) { + return $this; + } + foreach (static::$bootstrappers as $bootstrapper) { $bootstrapper($this); } + + $this->booted = true; + + return $this; } /** @@ -265,8 +281,8 @@ protected function addToParent(SymfonyCommand $command) */ public function resolve($command) { - if (class_exists($command) && ($commandName = $command::getDefaultName())) { - $this->commandMap[$commandName] = $command; + if ($this->commandLoader && $this->commandLoader->accepts($command)) { + $this->commandLoader->add($command); return null; } @@ -291,18 +307,46 @@ public function resolveCommands($commands) return $this; } + /** + * Add deferred commands list. + * + * @param array $commands Must be keyed by command name e.g. ['migrate' => MigrateCommand::class] + * @return $this + */ + public function addDeferredCommands(array $commands) + { + $this->commandLoader->merge($commands); + + return $this; + } + /** * Set the container command loader for lazy resolution. * + * @param \Symfony\Component\Console\CommandLoader\CommandLoaderInterface|null * @return $this */ - public function setContainerCommandLoader() + public function setContainerCommandLoader(CommandLoaderInterface $loader = null) { - $this->setCommandLoader(new ContainerCommandLoader($this->laravel, $this->commandMap)); + if (is_null($loader)) { + $loader = new ContainerCommandLoader($this->laravel); + } + + $this->setCommandLoader($this->commandLoader = $loader); return $this; } + /** + * Get the deferred console commander loader. + * + * @return \Symfony\Component\Console\CommandLoader\CommandLoaderInterface + */ + public function getCommandLoader() + { + return $this->commandLoader; + } + /** * Get the default input definition for the application. * diff --git a/src/Illuminate/Console/ContainerCommandLoader.php b/src/Illuminate/Console/ContainerCommandLoader.php index f770f6c7101f..281e6d1ed660 100644 --- a/src/Illuminate/Console/ContainerCommandLoader.php +++ b/src/Illuminate/Console/ContainerCommandLoader.php @@ -2,6 +2,7 @@ namespace Illuminate\Console; +use InvalidArgumentException; use Psr\Container\ContainerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; @@ -21,19 +22,64 @@ class ContainerCommandLoader implements CommandLoaderInterface * * @var array */ - protected $commandMap; + protected $commandMap = []; /** * Create a new command loader instance. * * @param \Psr\Container\ContainerInterface $container - * @param array $commandMap * @return void */ - public function __construct(ContainerInterface $container, array $commandMap) + public function __construct(ContainerInterface $container) { $this->container = $container; - $this->commandMap = $commandMap; + } + + /** + * Merge the command map list + * + * @param array $commandMap + * @return void + */ + public function merge(array $commandMap) + { + $this->commandMap = array_merge($this->commandMap, $commandMap); + } + + /** + * Determine if the command is accepted by the cmand loader. + * + * @param string $name + * @return bool + */ + public function accepts(string $name): bool + { + if (in_array($name, $this->commandMap)) { + return true; + } + + return class_exists($name) && ! is_null($name::getDefaultName()); + } + + /** + * Add class to the loader. + * + * @param string $name + * @return void + * + * @throws \InvalidArgumentException + */ + public function add(string $name) + { + if (in_array($name, $this->commandMap)) { + return; + } + + if (! $this->accepts($name)) { + throw new InvalidArgumentException(sprintf('Command "%s" was not accepted by the command loader.', $name)); + } + + $this->commandMap[$name::getDefaultName()] = $name; } /** @@ -61,7 +107,7 @@ public function get(string $name): Command */ public function has(string $name): bool { - return $name && isset($this->commandMap[$name]); + return isset($this->commandMap[$name]); } /** @@ -73,4 +119,14 @@ public function getNames(): array { return array_keys($this->commandMap); } + + /** + * Get the full list of commands. + * + * @return array + */ + public function all(): array + { + return $this->commandMap; + } } diff --git a/src/Illuminate/Foundation/Console/CommandCacheCommand.php b/src/Illuminate/Foundation/Console/CommandCacheCommand.php new file mode 100644 index 000000000000..64f44d9ffc8a --- /dev/null +++ b/src/Illuminate/Foundation/Console/CommandCacheCommand.php @@ -0,0 +1,35 @@ +info('Commands cached successfully!'); + } +} diff --git a/src/Illuminate/Foundation/Console/Kernel.php b/src/Illuminate/Foundation/Console/Kernel.php index 002297c82ff1..503d18a61f85 100644 --- a/src/Illuminate/Foundation/Console/Kernel.php +++ b/src/Illuminate/Foundation/Console/Kernel.php @@ -47,6 +47,13 @@ class Kernel implements KernelContract */ protected $commands = []; + /** + * The deferred command list. + * + * @var array|null + */ + protected $deferredCommands; + /** * Indicates if the Closure commands have been loaded. * @@ -199,6 +206,50 @@ public function command($signature, Closure $callback) return $command; } + /** + * Cache the list of registered deferred/lazily loaded commands. + * + * @return void + */ + public function cacheCommands() + { + $commands = $this->getArtisan()->getCommandLoader()->all(); + + file_put_contents($this->getCommandCachePath(), 'getCommandCachePath(); + + return file_exists($path) ? require $this->getCommandCachePath() : []; + } + + /** + * Get full path to the command cache file. + * + * @return string + */ + protected function getCommandCachePath(): string + { + return base_path('bootstrap/cache/commands.php'); + } + + /** + * Load the cached commands, used for deferring/lazy resolving of commands. + * + * @return void + */ + protected function loadCachedCommands() + { + $this->deferredCommands = $this->getCachedCommands(); + } + /** * Register all of the commands in the given directory. * @@ -313,7 +364,9 @@ public function bootstrap() $this->app->loadDeferredProviders(); if (! $this->commandsLoaded) { - $this->commands(); + if (! $this->loadCachedCommands()) { + $this->commands(); + } $this->commandsLoaded = true; } @@ -328,8 +381,9 @@ protected function getArtisan() { if (is_null($this->artisan)) { $this->artisan = (new Artisan($this->app, $this->events, $this->app->version())) + ->addDeferredCommands($this->deferredCommands ?? []) ->resolveCommands($this->commands) - ->setContainerCommandLoader(); + ->bootstrap(); } return $this->artisan; diff --git a/src/Illuminate/Foundation/Console/OptimizeCommand.php b/src/Illuminate/Foundation/Console/OptimizeCommand.php index 30ed992f00ba..4cfe89327cc3 100644 --- a/src/Illuminate/Foundation/Console/OptimizeCommand.php +++ b/src/Illuminate/Foundation/Console/OptimizeCommand.php @@ -38,6 +38,7 @@ public function handle() { $this->call('config:cache'); $this->call('route:cache'); + $this->call('command:cache'); $this->info('Files cached successfully!'); } diff --git a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php index 58bc54e3e57f..4d12c950f2ac 100755 --- a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php @@ -22,6 +22,7 @@ use Illuminate\Foundation\Console\CastMakeCommand; use Illuminate\Foundation\Console\ChannelMakeCommand; use Illuminate\Foundation\Console\ClearCompiledCommand; +use Illuminate\Foundation\Console\CommandCacheCommand; use Illuminate\Foundation\Console\ComponentMakeCommand; use Illuminate\Foundation\Console\ConfigCacheCommand; use Illuminate\Foundation\Console\ConfigClearCommand; @@ -95,6 +96,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'ClearCompiled' => ClearCompiledCommand::class, 'ClearResets' => ClearResetsCommand::class, 'ConfigCache' => ConfigCacheCommand::class, + 'CommandCache' => CommandCacheCommand::class, 'ConfigClear' => ConfigClearCommand::class, 'Db' => DbCommand::class, 'DbPrune' => PruneCommand::class, @@ -320,6 +322,18 @@ protected function registerConfigClearCommand() }); } + /** + * Register the command. + * + * @return void + */ + protected function registerCommandCacheCommand() + { + $this->app->singleton(CommandCacheCommand::class, function ($app) { + return new CommandCacheCommand; + }); + } + /** * Register the command. * diff --git a/tests/Console/ConsoleApplicationTest.php b/tests/Console/ConsoleApplicationTest.php index 6189a375adda..955efef57c16 100755 --- a/tests/Console/ConsoleApplicationTest.php +++ b/tests/Console/ConsoleApplicationTest.php @@ -4,11 +4,13 @@ use Illuminate\Console\Application; use Illuminate\Console\Command; +use Illuminate\Console\ContainerCommandLoader; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Foundation\Application as ApplicationContract; use Mockery as m; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Command\Command as SymfonyCommand; +use TypeError; class ConsoleApplicationTest extends TestCase { @@ -50,6 +52,38 @@ public function testResolveAddsCommandViaApplicationResolution() $this->assertEquals($command, $result); } + public function testAddingToCommandLoaderAndDoesNotResolve() + { + $app = $this->getMockConsole([]); + $commandName = FoobarCommand::class; + + $loader = new ContainerCommandLoader($app->getLaravel()); + $app->setContainerCommandLoader($loader); + + $app->getLaravel()->shouldNotReceive('make'); + + $resolvedResult = $app->resolve($commandName); + $namesResult = $loader->getNames(); + + $this->assertNull($resolvedResult); + $this->assertEquals(['foobar'], $namesResult); + } + + public function testCommanderLoaderDoesntAddForClassThatDoesntExist() + { + $app = $this->getMockConsole([]); + $commandName = 'Some\Command\That\Doesnt\Exist'; + + $loader = new ContainerCommandLoader($app->getLaravel()); + $app->setContainerCommandLoader($loader); + + $app->getLaravel()->shouldReceive('make'); + + $this->expectException(TypeError::class); + + $resolvedResult = $app->resolve($commandName); + } + public function testCallFullyStringCommandLine() { $app = new Application( @@ -87,3 +121,11 @@ protected function getMockConsole(array $methods) ])->getMock(); } } + +class FoobarCommand extends Command +{ + public static function getDefaultName(): string + { + return 'foobar'; + } +}