Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Integration of [Symfony Console](https://symfony.com/doc/current/console.html) i
- [Commands](#commands)
- [Example command](#example-command)
- [Invokable commands](#invokable-commands)
- [Commands without extending Command](#commands-without-extending-command)
- [UI](#ui)
- [Styled output](#styled-output)
- [Cursor control](#cursor-control)
Expand Down Expand Up @@ -280,6 +281,58 @@ The `#[Argument]` and `#[Option]` attributes support these parameters:

> See [Console Input](https://symfony.com/doc/current/console/input.html) in Symfony docs.

## Commands without extending Command

Since Symfony Console 7.3, a service doesn't have to extend Symfony's `Command` class at all - a plain class with a public `__invoke()` method is enough. Behind the scenes, the extension wraps it into a synthetic `Command` instance via `Command::setCode()`, the same way Symfony's own `AddConsoleCommandPass` does for the framework bundle.

```php
namespace App\Console;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
name: 'app:greet',
description: 'Greets the given name',
usages: ['John', '--yell John'],
)]
final class GreetCommand
{

public function __invoke(InputInterface $input, OutputInterface $output): int
{
$output->writeln('Hello!');

return Command::SUCCESS;
}

}
```

```neon
services:
- App\Console\GreetCommand
```

The command can also be discovered purely via the `console.command` tag, without the attribute at all:

```neon
services:
greet:
class: App\Console\GreetCommand
tags: [console.command: app:greet]
```

A service is registered as a console command if it meets at least one of these conditions:

- it extends `Symfony\Component\Console\Command\Command`
- it carries the `console.command` tag
- it's annotated with `#[AsCommand]`

> See [Invokable Commands](https://symfony.com/blog/new-in-symfony-7-3-invokable-commands-and-input-attributes) blog post (Symfony 7.3+).

---

# UI
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"require": {
"php": ">=8.2",
"nette/di": "^3.1.8",
"symfony/console": "^7.2.0 || ^8.0.0"
"symfony/console": "^7.4.0 || ^8.0.0"
},
"require-dev": {
"nette/http": "^3.2.3",
Expand Down
2 changes: 1 addition & 1 deletion src/CommandLoader/ContainerCommandLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class ContainerCommandLoader implements CommandLoaderInterface
{

/**
* @param array<string> $commandMap
* @param array<string, string> $commandMap
*/
public function __construct(
private readonly Container $container,
Expand Down
203 changes: 146 additions & 57 deletions src/DI/ConsoleExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@
use Contributte\Console\CommandLoader\ContainerCommandLoader;
use Contributte\Console\Http\ConsoleRequestFactory;
use Nette\DI\CompilerExtension;
use Nette\DI\ContainerBuilder;
use Nette\DI\Definitions\Definition;
use Nette\DI\Definitions\ServiceDefinition;
use Nette\DI\Definitions\Statement;
use Nette\DI\MissingServiceException;
use Nette\DI\ServiceCreationException;
use Nette\Http\RequestFactory;
use Nette\Schema\Expect;
use Nette\Schema\Schema;
use Nette\Utils\Arrays;
use ReflectionClass;
use ReflectionProperty;
use stdClass;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
Expand Down Expand Up @@ -146,62 +146,10 @@ public function beforeCompile(): void
}

// Add all commands to map for command loader
$commands = $builder->findByType(Command::class);
$commandMap = [];

// Iterate over all commands and build commandMap
foreach ($commands as $serviceName => $service) {
$tags = $service->getTags();
$commandName = null;

// Try to use console.command tag
if (isset($tags['console.command'])) {
if (is_string($tags['console.command'])) {
$commandName = $tags['console.command'];
} elseif (is_array($tags['console.command'])) {
$commandName = Arrays::get($tags['console.command'], 'name', null);
}
}

$aliases = [];
// Try to detect command name from Command::getDefaultName() or Command::defaultName property
if (!is_string($commandName) || $commandName === '') {
/** @var class-string $className */
$className = $service->getType();
$reflection = new ReflectionClass($className);
$attributes = $reflection->getAttributes(AsCommand::class);

if ($attributes !== []) {
$commandName = $attributes[0]->newInstance()->name;
} elseif (method_exists($className, 'getDefaultName')) {
$commandName = call_user_func([$service->getType(), 'getDefaultName']); // @phpstan-ignore-line
} elseif (property_exists($className, 'defaultName')) {
$rp = new ReflectionProperty($className, 'defaultName');
$commandName = $rp->getValue();
}

if (!is_string($commandName) || $commandName === '') {
throw new ServiceCreationException(
sprintf(
'Command "%s" missing #[AsCommand] attribute',
$service->getType(),
)
);
}

$aliases = explode('|', $commandName);
$commandName = array_shift($aliases);
if ($commandName === '') {
$commandName = array_shift($aliases);
}
}

// Append service to command map
$commandMap[$commandName] = $serviceName;

foreach ($aliases as $alias) {
$commandMap[$alias] = $serviceName;
}
foreach ($builder->getDefinitions() as $service) {
$commandMap = array_replace($commandMap, $this->resolveCommandServices($builder, $service));
}

/** @var ServiceDefinition $commandLoaderDef */
Expand All @@ -212,9 +160,150 @@ public function beforeCompile(): void
try {
$dispatcherDef = $builder->getDefinitionByType(EventDispatcherInterface::class);
$applicationDef->addSetup('setDispatcher', [$dispatcherDef]);
} catch (MissingServiceException $e) {
} catch (MissingServiceException) {
// Event dispatcher is not installed, ignore
}
}

/**
* Returns the "name => service" map for a console command (including aliases), or an empty array otherwise.
* Commands are detected via Command inheritance, the "console.command" tag, or the #[AsCommand] attribute.
*
* @return array<string, string>
*/
private function resolveCommandServices(ContainerBuilder $builder, Definition $service): array
{
$serviceName = $service->getName();
$type = $service->getType();

if ($serviceName === null || $type === null) {
return [];
}

$tag = $service->getTag('console.command');
$reflectionClass = new ReflectionClass($type);
$extendsCommand = $reflectionClass->isSubclassOf(Command::class);
$asCommandAttribute = ($reflectionClass->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();

if (!$extendsCommand) {
if ($tag === null && $asCommandAttribute === null) {
return [];
}

if (!$reflectionClass->hasMethod('__invoke')) {
throw new ServiceCreationException(sprintf(
'Service "%s" of type "%s" is registered as a console command (via "console.command" tag or #[AsCommand] attribute), but is neither a subclass of "%s" nor has an __invoke()" method.',
$serviceName,
$type,
Command::class,
));
}
}

// The resolved name may be pipe-separated (name|alias1|alias2).
// A leading pipe marks a hidden command, as in Symfony's Command constructor.
$aliases = explode('|', $this->resolveCommandName($type, $tag, $asCommandAttribute));
/** @var string $commandName */
$commandName = array_shift($aliases);
$isHidden = $commandName === '';

if ($isHidden) {
/** @var string $commandName */
$commandName = array_shift($aliases);
}

$commandServiceName = $serviceName;

if (!$extendsCommand) {
$commandServiceName = $this->registerInvokableCommand(
$builder,
$serviceName,
$commandName,
$aliases,
$isHidden,
$asCommandAttribute,
);
}

return array_fill_keys([$commandName, ...$aliases], $commandServiceName);
}

/**
* @param class-string $type
*/
private function resolveCommandName(string $type, mixed $tag, ?AsCommand $asCommandAttribute): string
{
$commandName = null;

if (is_string($tag)) {
$commandName = $tag;
} elseif (is_array($tag)) {
$commandName = $tag['name'] ?? null;
}

if (is_string($commandName) && $commandName !== '') {
return $commandName;
}

if ($asCommandAttribute !== null) {
$commandName = $asCommandAttribute->name;
} elseif (is_callable([$type, 'getDefaultName'])) {
$commandName = $type::getDefaultName();
}

if (!is_string($commandName) || $commandName === '') {
throw new ServiceCreationException(sprintf('Command "%s" missing #[AsCommand] attribute', $type));
}

return $commandName;
}

/**
* Registers a new Command wrapper service for an invokable command.
*
* @param string[] $aliases
*/
private function registerInvokableCommand(
ContainerBuilder $builder,
string $invokableServiceName,
string $commandName,
array $aliases,
bool $isHidden,
?AsCommand $asCommandAttribute,
): string
{
$commandServiceName = $this->prefix($invokableServiceName . '.command');
$commandDef = $builder->addDefinition($commandServiceName)
->setFactory(Command::class)
->setAutowired(false)
->addSetup('setCode', ['@' . $invokableServiceName])
->addSetup('setName', [$commandName]);

if ($aliases !== []) {
$commandDef->addSetup('setAliases', [$aliases]);
}

if ($isHidden) {
$commandDef->addSetup('setHidden', [true]);
}

if ($asCommandAttribute === null) {
return $commandServiceName;
}

if (($asCommandAttribute->description ?? '') !== '') {
$commandDef->addSetup('setDescription', [$asCommandAttribute->description]);
}

if (($asCommandAttribute->help ?? '') !== '') {
$commandDef->addSetup('setHelp', [$asCommandAttribute->help]);
}

foreach ($asCommandAttribute->usages as $usage) {
$commandDef->addSetup('addUsage', [$usage]);
}

return $commandServiceName;
}

}
Loading