Skip to content
Merged
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
10 changes: 10 additions & 0 deletions config/help/MakeDecorator.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
The <info>%command.name%</info> command generates a new service decorator class.

<info>php %command.full_name%</info>
<info>php %command.full_name% My\Decorated\Service\Class</info>
<info>php %command.full_name% My\Decorated\Service\Class MyServiceDecorator</info>
<info>php %command.full_name% My\Decorated\Service\Class Service\MyServiceDecorator</info>
<info>php %command.full_name% my_decorated.service.id MyServiceDecorator</info>
<info>php %command.full_name% my_decorated.service.id MyServiceDecorator</info>

If one argument is missing, the command will ask for it interactively.
5 changes: 5 additions & 0 deletions config/makers.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Symfony\Bundle\MakerBundle\Maker\MakeCommand;
use Symfony\Bundle\MakerBundle\Maker\MakeController;
use Symfony\Bundle\MakerBundle\Maker\MakeCrud;
use Symfony\Bundle\MakerBundle\Maker\MakeDecorator;
use Symfony\Bundle\MakerBundle\Maker\MakeDockerDatabase;
use Symfony\Bundle\MakerBundle\Maker\MakeEntity;
use Symfony\Bundle\MakerBundle\Maker\MakeFixtures;
Expand Down Expand Up @@ -69,6 +70,10 @@
])
->tag('maker.command');

$services->set('maker.maker.make_decorator', MakeDecorator::class)
->args([service('maker.decorator_helper')])
->tag('maker.command');

$services->set('maker.maker.make_docker_database', MakeDockerDatabase::class)
->args([service('maker.file_manager')])
->tag('maker.command');
Expand Down
8 changes: 8 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use Symfony\Bundle\MakerBundle\Command\MakerCommand;
use Symfony\Bundle\MakerBundle\DependencyInjection\DecoratorHelper;
use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
use Symfony\Bundle\MakerBundle\Doctrine\EntityClassGenerator;
use Symfony\Bundle\MakerBundle\Event\ConsoleErrorSubscriber;
Expand Down Expand Up @@ -61,6 +62,13 @@
service('doctrine')->ignoreOnInvalid(),
]);

$services->set('maker.decorator_helper', DecoratorHelper::class)
->args([
null, // Service ids
null, // Map of service id to class
null, // Map of short class name to service ids
]);

$services->set('maker.auto_command.abstract', MakerCommand::class)
->abstract()
->args([
Expand Down
70 changes: 70 additions & 0 deletions src/DependencyInjection/CompilerPass/MakeDecoratorPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass;

use Symfony\Bundle\MakerBundle\Str;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* @author Benjamin Georgeault <git@wedgesama.fr>
*/
class MakeDecoratorPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('maker.decorator_helper')) {
return;
}

$shortNameMap = [];
$serviceClasses = [];
foreach ($container->getServiceIds() as $id) {
if (str_starts_with($id, '.')) {
continue;
}

if (str_contains($id, '\\')) {
$shortClass = Str::getShortClassName($id);
$shortNameMap[$shortClass] ??= [];
$shortNameMap[$shortClass][] = $id;
}

if (!$container->hasDefinition($id)) {
continue;
}

if (
(null === $class = $container->getDefinition($id)->getClass())
|| $class === $id
) {
continue;
}

$shortClass = Str::getShortClassName($class);
$shortNameMap[$shortClass] ??= [];
$shortNameMap[$shortClass][] = $id;
$serviceClasses[$id] = $class;
}

foreach ($shortNameMap as $shortClass => $ids) {
$shortNameMap[$shortClass] = array_unique($ids);
}

$ids = $container->getServiceIds();
$container->getDefinition('maker.decorator_helper')
->replaceArgument(0, $ids)
->replaceArgument(1, $serviceClasses)
->replaceArgument(2, $shortNameMap)
;
}
}
92 changes: 92 additions & 0 deletions src/DependencyInjection/DecoratorHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bundle\MakerBundle\DependencyInjection;

use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;

/**
* @author Benjamin Georgeault <git@wedgesama.fr>
*
* @internal
*/
final class DecoratorHelper
{
/**
* @param array<string> $ids
* @param array<string, string> $serviceClasses
* @param array<string, string[]> $shortNameMap
*/
public function __construct(
private readonly array $ids,
private readonly array $serviceClasses,
private readonly array $shortNameMap,
) {
}

public function suggestIds(): array
{
return [
...array_keys($this->shortNameMap),
...$this->ids,
];
}

public function getRealId(string $id): ?string
{
if (\in_array($id, $this->ids, true)) {
return $id;
}

if (\array_key_exists($id, $this->shortNameMap) && 1 === \count($this->shortNameMap[$id])) {
return $this->shortNameMap[$id][0];
}

return null;
}

public function guessRealIds(string $id): array
{
$guessTypos = [];
foreach ($this->shortNameMap as $shortName => $ids) {
if (levenshtein($id, $shortName) < 3) {
$guessTypos = [
...$guessTypos,
...$ids,
];
}
}

foreach ($this->ids as $suggestId) {
if (levenshtein($id, $suggestId) < 3) {
$guessTypos[] = $suggestId;
}
}

return $guessTypos;
}

/**
* @return class-string
*/
public function getClass(string $id): string
{
if (class_exists($id) || interface_exists($id)) {
return $id;
}

if (\array_key_exists($id, $this->serviceClasses)) {
return $this->serviceClasses[$id];
}

throw new RuntimeCommandException(\sprintf('Cannot getClass for id "%s".', $id));
}
}
165 changes: 165 additions & 0 deletions src/Maker/MakeDecorator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php

/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bundle\MakerBundle\Maker;

use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\DependencyInjection\DecoratorHelper;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\DecoratorInfo;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;

/**
* @author Benjamin Georgeault <git@wedgesama.fr>
*/
final class MakeDecorator extends AbstractMaker
{
public function __construct(
private readonly DecoratorHelper $helper,
) {
}

public static function getCommandName(): string
{
return 'make:decorator';
}

public static function getCommandDescription(): string
{
return 'Create a decorator of a service';
}

public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('id', InputArgument::OPTIONAL, 'The ID of the service to decorate.')
->addArgument('decorator-class', InputArgument::OPTIONAL, \sprintf('The class name of the service to create (e.g. <fg=yellow>%sDecorator</>)', Str::asClassName(Str::getRandomTerm())))
->addOption('priority', null, InputOption::VALUE_REQUIRED, 'The priority of this decoration when multiple decorators are declared for the same service.')
->addOption('on-invalid', null, InputOption::VALUE_REQUIRED, 'The behavior to adopt when the decoration is invalid.')
->setHelp($this->getHelpFileContents('MakeDecorator.txt'))
;

$inputConfig->setArgumentAsNonInteractive('id');
$inputConfig->setArgumentAsNonInteractive('decorator-class');
}

public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
AsDecorator::class,
'dependency-injection',
);
}

public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
// Ask for service id.
if (null === $input->getArgument('id')) {
$argument = $command->getDefinition()->getArgument('id');

($question = new Question($argument->getDescription()))
->setAutocompleterValues($suggestIds = $this->helper->suggestIds())
->setValidator(fn ($answer) => Validator::serviceExists($answer, $suggestIds))
->setMaxAttempts(3);

$input->setArgument('id', $io->askQuestion($question));
}

$id = $input->getArgument('id');
if (null === $realId = $this->helper->getRealId($id)) {
$guessCount = \count($guessRealIds = $this->helper->guessRealIds($id));

if (0 === $guessCount) {
throw new RuntimeCommandException(\sprintf('Cannot find nor guess service for given id "%s".', $id));
} elseif (1 === $guessCount) {
$question = new ConfirmationQuestion(\sprintf('<fg=green>Did you mean</> <fg=yellow>"%s"</> <fg=green>?</>', $guessRealIds[0]), true);

if (!$io->askQuestion($question)) {
throw new RuntimeCommandException(\sprintf('Cannot find nor guess service for given id "%s".', $id));
}

$input->setArgument('id', $id = $guessRealIds[0]);
} else {
$input->setArgument(
'id',
$id = $io->choice(\sprintf('Multiple services found for "%s", choose which one you want to decorate:', $id), $guessRealIds),
);
}
} else {
$input->setArgument('id', $id = $realId);
}

// Ask for decorator classname.
if (null === $input->getArgument('decorator-class')) {
$argument = $command->getDefinition()->getArgument('decorator-class');

$basename = Str::getShortClassName(match (true) {
interface_exists($id) => Str::removeSuffix($id, 'Interface'),
class_exists($id) => $id,
default => Str::asClassName($id),
});

$defaultClass = Str::asClassName(\sprintf('%s Decorator', $basename));

($question = new Question($argument->getDescription(), $defaultClass))
->setValidator(fn ($answer) => Validator::validateClassName(Validator::classDoesNotExist($answer)))
->setMaxAttempts(3);

$input->setArgument('decorator-class', $io->askQuestion($question));
}
}

public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$id = $input->getArgument('id');

$classNameDetails = $generator->createClassNameDetails(
Validator::validateClassName(Validator::classDoesNotExist($input->getArgument('decorator-class'))),
'',
);

$priority = $input->getOption('priority');
$onInvalid = $input->getOption('on-invalid');

$decoratedInfo = new DecoratorInfo(
$classNameDetails->getFullName(),
$id,
$this->helper->getClass($id),
empty($priority) ? null : $priority,
null === $onInvalid || 1 === $onInvalid ? null : $onInvalid,
);

$classData = $decoratedInfo->getClassData();

$generator->generateClassFromClassData(
$classData,
'decorator/Decorator.tpl.php',
[
'decorated_info' => $decoratedInfo,
],
);

$generator->writeChanges();

$this->writeSuccessMessage($io);
}
}
2 changes: 2 additions & 0 deletions src/MakerBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Bundle\MakerBundle;

use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\MakeCommandRegistrationPass;
use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\MakeDecoratorPass;
use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\RemoveMissingParametersPass;
use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\SetDoctrineAnnotatedPrefixesPass;
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
Expand Down Expand Up @@ -77,6 +78,7 @@ public function build(ContainerBuilder $container): void
{
// add a priority so we run before the core command pass
$container->addCompilerPass(new MakeCommandRegistrationPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10);
$container->addCompilerPass(new MakeDecoratorPass());
$container->addCompilerPass(new RemoveMissingParametersPass());
$container->addCompilerPass(new SetDoctrineAnnotatedPrefixesPass());
}
Expand Down
Loading
Loading