diff --git a/src/TwigHooks/config/services/command.php b/src/TwigHooks/config/services/command.php new file mode 100644 index 000000000..0e638e700 --- /dev/null +++ b/src/TwigHooks/config/services/command.php @@ -0,0 +1,27 @@ +services(); + + $services->set('sylius_twig_hooks.command.debug', DebugTwigHooksCommand::class) + ->args([ + service('sylius_twig_hooks.registry.hookables'), + ]) + ->tag('console.command') + ; +}; diff --git a/src/TwigHooks/src/Command/DebugTwigHooksCommand.php b/src/TwigHooks/src/Command/DebugTwigHooksCommand.php new file mode 100644 index 000000000..0fa44d467 --- /dev/null +++ b/src/TwigHooks/src/Command/DebugTwigHooksCommand.php @@ -0,0 +1,254 @@ +setDefinition([ + new InputArgument('name', InputArgument::OPTIONAL, 'A hook name or part of the hook name'), + new InputOption('all', 'a', InputOption::VALUE_NONE, 'Show all hookables including disabled ones'), + new InputOption('config', 'c', InputOption::VALUE_NONE, 'Show hookables configuration'), + ]) + ->setHelp( + <<<'EOF' +The %command.name% displays all Twig hooks in your application. + +To list all hooks: + + php %command.full_name% + +To filter hooks by name: + + php %command.full_name% sylius_admin + +To get specific information about a hook: + + php %command.full_name% sylius_admin.product.index + +To include disabled hookables: + + php %command.full_name% sylius_admin.product.index --all + +To show hookables configuration: + + php %command.full_name% sylius_admin.product.index --config +EOF + ); + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('name')) { + $suggestions->suggestValues($this->hookablesRegistry->getHookNames()); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('name'); + /** @var bool $showAll */ + $showAll = $input->getOption('all'); + /** @var bool $showConfig */ + $showConfig = $input->getOption('config'); + + $hookNames = $this->hookablesRegistry->getHookNames(); + sort($hookNames); + + if (\is_string($name)) { + // Exact match - show details + if (\in_array($name, $hookNames, true)) { + $this->displayHookDetails($io, $name, $showAll, $showConfig); + + return Command::SUCCESS; + } + + // Partial match - filter and show table or details (case-insensitive) + $filteredHooks = array_filter( + $hookNames, + static fn (string $hookName): bool => false !== stripos($hookName, $name), + ); + + if (0 === \count($filteredHooks)) { + $io->warning(\sprintf('No hooks found matching "%s".', $name)); + + return Command::SUCCESS; + } + + if (1 === \count($filteredHooks)) { + $this->displayHookDetails($io, reset($filteredHooks), $showAll, $showConfig); + + return Command::SUCCESS; + } + + $this->displayHooksTable($io, $filteredHooks, $showAll); + + return Command::SUCCESS; + } + + if (0 === \count($hookNames)) { + $io->warning('No hooks registered.'); + + return Command::SUCCESS; + } + + $this->displayHooksTable($io, $hookNames, $showAll); + + return Command::SUCCESS; + } + + /** + * @param array $hookNames + */ + private function displayHooksTable(SymfonyStyle $io, array $hookNames, bool $showAll): void + { + $rows = []; + + foreach ($hookNames as $hookName) { + $hookables = $this->hookablesRegistry->getAllFor($hookName); + $enabledCount = \count(array_filter( + $hookables, + static fn (AbstractHookable $hookable): bool => !$hookable instanceof DisabledHookable, + )); + $disabledCount = \count($hookables) - $enabledCount; + + $countDisplay = $showAll && $disabledCount > 0 + ? \sprintf('%d (%d disabled)', \count($hookables), $disabledCount) + : (string) $enabledCount; + + $rows[] = [ + $hookName, + $countDisplay, + ]; + } + + $io->table(['Hook', 'Hookables'], $rows); + $io->text(\sprintf('Total: %d hooks', \count($hookNames))); + } + + private function displayHookDetails(SymfonyStyle $io, string $hookName, bool $showAll, bool $showConfig): void + { + $io->title($hookName); + + $hookables = $this->hookablesRegistry->getAllFor($hookName); + if (!$showAll) { + $hookables = array_filter( + $hookables, + static fn (AbstractHookable $hookable): bool => !$hookable instanceof DisabledHookable, + ); + } + + if (0 === \count($hookables)) { + $io->warning('No hookables registered for this hook.'); + + return; + } + + $headers = ['Name', 'Type', 'Target', 'Priority']; + if ($showAll) { + $headers[] = 'Status'; + } + if ($showConfig) { + $headers[] = 'Configuration'; + } + + $rows = []; + foreach ($hookables as $hookable) { + $row = [ + $hookable->name, + $this->getHookableType($hookable), + $this->getHookableTarget($hookable), + $hookable->priority(), + ]; + + if ($showAll) { + $row[] = $hookable instanceof DisabledHookable ? 'disabled' : 'enabled'; + } + + if ($showConfig) { + $row[] = $this->formatConfiguration($hookable->configuration); + } + + $rows[] = $row; + } + + $io->table($headers, $rows); + } + + /** + * @param array $configuration + */ + private function formatConfiguration(array $configuration): string + { + if (0 === \count($configuration)) { + return '-'; + } + + $parts = []; + foreach ($configuration as $key => $value) { + $parts[] = \sprintf('%s: %s', $key, $this->formatValue($value)); + } + + return implode("\n", $parts); + } + + private function formatValue(mixed $value): string + { + return VarExporter::export($value); + } + + private function getHookableType(AbstractHookable $hookable): string + { + return match (true) { + $hookable instanceof HookableTemplate => 'template', + $hookable instanceof HookableComponent => 'component', + default => '-', + }; + } + + private function getHookableTarget(AbstractHookable $hookable): string + { + return match (true) { + $hookable instanceof HookableTemplate => $hookable->template, + $hookable instanceof HookableComponent => $hookable->component, + default => '-', + }; + } +} diff --git a/src/TwigHooks/src/Registry/HookablesRegistry.php b/src/TwigHooks/src/Registry/HookablesRegistry.php index 09a57a249..daf3bfb09 100644 --- a/src/TwigHooks/src/Registry/HookablesRegistry.php +++ b/src/TwigHooks/src/Registry/HookablesRegistry.php @@ -43,6 +43,14 @@ public function __construct( } } + /** + * @return array + */ + public function getHookNames(): array + { + return array_keys($this->hookables); + } + /** * @param string|array $hooksNames * @@ -66,6 +74,24 @@ public function getEnabledFor(string|array $hooksNames): array return $priorityQueue->toArray(); } + /** + * @param string|array $hooksNames + * + * @return array + */ + public function getAllFor(string|array $hooksNames): array + { + $hooksNames = is_string($hooksNames) ? [$hooksNames] : $hooksNames; + $hookables = $this->mergeHookables($hooksNames); + + $priorityQueue = new SplPriorityQueue(); + foreach ($hookables as $hookable) { + $priorityQueue->insert($hookable, $hookable->priority()); + } + + return $priorityQueue->toArray(); + } + /** * @param array $hooksNames * diff --git a/src/TwigHooks/tests/Unit/Command/DebugTwigHooksCommandTest.php b/src/TwigHooks/tests/Unit/Command/DebugTwigHooksCommandTest.php new file mode 100644 index 000000000..6d16f0dbb --- /dev/null +++ b/src/TwigHooks/tests/Unit/Command/DebugTwigHooksCommandTest.php @@ -0,0 +1,377 @@ +hookablesRegistry = $this->createMock(HookablesRegistry::class); + } + + public function testItDisplaysAllHooksSortedAlphabetically(): void + { + $this->hookablesRegistry + ->method('getHookNames') + ->willReturn(['sylius_shop.cart.summary', 'sylius_admin.product.index']); + + $this->hookablesRegistry + ->method('getAllFor') + ->willReturnMap([ + ['sylius_admin.product.index', [ + HookableTemplateMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'header']), + ]], + ['sylius_shop.cart.summary', [ + HookableTemplateMotherObject::with(['hookName' => 'sylius_shop.cart.summary', 'name' => 'items']), + ]], + ]); + + $commandTester = $this->createCommandTester(); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $display = $commandTester->getDisplay(); + + $this->assertStringContainsString('Total: 2 hooks', $display); + + $adminPosition = strpos($display, 'sylius_admin.product.index'); + $shopPosition = strpos($display, 'sylius_shop.cart.summary'); + $this->assertLessThan($shopPosition, $adminPosition, 'Hooks should be sorted alphabetically'); + } + + public function testItDisplaysWarningWhenNoHooksRegistered(): void + { + $this->hookablesRegistry + ->method('getHookNames') + ->willReturn([]); + + $commandTester = $this->createCommandTester(); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('No hooks registered', $commandTester->getDisplay()); + } + + public function testItDisplaysHookDetailsForExactMatchSortedByPriority(): void + { + $this->hookablesRegistry + ->method('getHookNames') + ->willReturn(['sylius_admin.product.index']); + + $this->hookablesRegistry + ->method('getAllFor') + ->with('sylius_admin.product.index') + ->willReturn([ + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.product.index', + 'name' => 'header', + 'template' => '@SyliusAdmin/product/header.html.twig', + 'priority' => 100, + ]), + HookableComponentMotherObject::with([ + 'hookName' => 'sylius_admin.product.index', + 'name' => 'grid', + 'component' => 'sylius_admin:product:grid', + 'priority' => 50, + ]), + ]); + + $commandTester = $this->createCommandTester(); + $commandTester->execute(['name' => 'sylius_admin.product.index']); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $display = $commandTester->getDisplay(); + + $this->assertMatchesRegularExpression('/header\s+template\s+@SyliusAdmin\/product\/header\.html\.twig\s+100/', $display); + $this->assertMatchesRegularExpression('/grid\s+component\s+sylius_admin:product:grid\s+50/', $display); + + $headerPosition = strpos($display, 'header'); + $gridPosition = strpos($display, 'grid'); + $this->assertLessThan($gridPosition, $headerPosition, 'Hookables should be sorted by priority (highest first)'); + } + + public function testItFiltersHooksByPartialNameCaseInsensitive(): void + { + $this->hookablesRegistry + ->method('getHookNames') + ->willReturn(['sylius_admin.product.index', 'sylius_admin.order.index', 'sylius_shop.cart.summary']); + + $this->hookablesRegistry + ->method('getAllFor') + ->willReturnMap([ + ['sylius_admin.product.index', [ + HookableTemplateMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'header']), + ]], + ['sylius_admin.order.index', [ + HookableTemplateMotherObject::with(['hookName' => 'sylius_admin.order.index', 'name' => 'header']), + ]], + ]); + + $commandTester = $this->createCommandTester(); + $commandTester->execute(['name' => 'ADMIN']); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $display = $commandTester->getDisplay(); + + $this->assertStringContainsString('sylius_admin.product.index', $display); + $this->assertStringContainsString('sylius_admin.order.index', $display); + $this->assertStringNotContainsString('sylius_shop.cart.summary', $display); + } + + public function testItDisplaysWarningWhenNoHooksMatchFilter(): void + { + $this->hookablesRegistry + ->method('getHookNames') + ->willReturn(['sylius_admin.product.index']); + + $commandTester = $this->createCommandTester(); + $commandTester->execute(['name' => 'nonexistent']); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('No hooks found matching "nonexistent"', $commandTester->getDisplay()); + } + + public function testItDisplaysDetailsWhenSingleHookMatchesFilter(): void + { + $this->hookablesRegistry + ->method('getHookNames') + ->willReturn(['sylius_admin.product.index', 'sylius_shop.cart.summary']); + + $this->hookablesRegistry + ->method('getAllFor') + ->with('sylius_admin.product.index') + ->willReturn([ + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.product.index', + 'name' => 'header', + 'template' => '@SyliusAdmin/product/header.html.twig', + ]), + ]); + + $commandTester = $this->createCommandTester(); + $commandTester->execute(['name' => 'product']); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $display = $commandTester->getDisplay(); + + $this->assertStringContainsString('sylius_admin.product.index', $display); + $this->assertStringContainsString('@SyliusAdmin/product/header.html.twig', $display); + } + + #[DataProvider('provideAllOptionCases')] + public function testItHandlesDisabledHookablesBasedOnAllOption( + bool $useAllOption, + bool $shouldShowDisabled, + bool $shouldShowStatusColumn, + ): void { + $this->hookablesRegistry + ->method('getHookNames') + ->willReturn(['sylius_admin.product.index']); + + $this->hookablesRegistry + ->method('getAllFor') + ->with('sylius_admin.product.index') + ->willReturn([ + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.product.index', + 'name' => 'header', + ]), + DisabledHookableMotherObject::with([ + 'hookName' => 'sylius_admin.product.index', + 'name' => 'disabled_item', + ]), + ]); + + $commandTester = $this->createCommandTester(); + $commandTester->execute(['name' => 'sylius_admin.product.index', '--all' => $useAllOption]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $display = $commandTester->getDisplay(); + + $this->assertStringContainsString('header', $display); + + if ($shouldShowDisabled) { + $this->assertStringContainsString('disabled_item', $display); + } else { + $this->assertStringNotContainsString('disabled_item', $display); + } + + if ($shouldShowStatusColumn) { + $this->assertStringContainsString('Status', $display); + } else { + $this->assertStringNotContainsString('Status', $display); + } + } + + /** + * @return iterable + */ + public static function provideAllOptionCases(): iterable + { + yield 'without --all option' => [false, false, false]; + yield 'with --all option' => [true, true, true]; + } + + public function testItShowsConfigurationWithConfigOption(): void + { + $this->hookablesRegistry + ->method('getHookNames') + ->willReturn(['sylius_admin.product.index']); + + $this->hookablesRegistry + ->method('getAllFor') + ->with('sylius_admin.product.index') + ->willReturn([ + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.product.index', + 'name' => 'header', + 'configuration' => [ + 'string_key' => 'value', + 'boolean' => true, + 'null_value' => null, + ], + ]), + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.product.index', + 'name' => 'empty_config', + 'configuration' => [], + ]), + ]); + + $commandTester = $this->createCommandTester(); + $commandTester->execute(['name' => 'sylius_admin.product.index', '--config' => true]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $display = $commandTester->getDisplay(); + + $this->assertStringContainsString('Configuration', $display); + $this->assertStringContainsString('string_key', $display); + $this->assertStringContainsString('true', $display); + $this->assertStringContainsString('null', $display); + $this->assertMatchesRegularExpression('/empty_config.*-/s', $display); + } + + #[DataProvider('provideHookableCountCases')] + public function testItDisplaysCorrectHookableCountInTable(bool $useAllOption, string $expectedPattern): void + { + $this->hookablesRegistry + ->method('getHookNames') + ->willReturn(['sylius_admin.product.index']); + + $this->hookablesRegistry + ->method('getAllFor') + ->with('sylius_admin.product.index') + ->willReturn([ + HookableTemplateMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'header']), + HookableTemplateMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'content']), + DisabledHookableMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'disabled_item']), + ]); + + $commandTester = $this->createCommandTester(); + $commandTester->execute(['--all' => $useAllOption]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertMatchesRegularExpression($expectedPattern, $commandTester->getDisplay()); + } + + /** + * @return iterable + */ + public static function provideHookableCountCases(): iterable + { + yield 'without --all shows only enabled count' => [false, '/sylius_admin\.product\.index\s+2\s/']; + yield 'with --all shows total and disabled count' => [true, '/3 \(1 disabled\)/']; + } + + public function testItDisplaysWarningWhenHookHasNoVisibleHookables(): void + { + $this->hookablesRegistry + ->method('getHookNames') + ->willReturn(['sylius_admin.product.index']); + + $this->hookablesRegistry + ->method('getAllFor') + ->with('sylius_admin.product.index') + ->willReturn([ + DisabledHookableMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'disabled_item']), + ]); + + $commandTester = $this->createCommandTester(); + $commandTester->execute(['name' => 'sylius_admin.product.index']); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('No hookables registered for this hook', $commandTester->getDisplay()); + } + + public function testItDisplaysDashForUnknownHookableTypeAndTarget(): void + { + $this->hookablesRegistry + ->method('getHookNames') + ->willReturn(['sylius_admin.product.index']); + + $this->hookablesRegistry + ->method('getAllFor') + ->with('sylius_admin.product.index') + ->willReturn([ + DisabledHookableMotherObject::with([ + 'hookName' => 'sylius_admin.product.index', + 'name' => 'unknown_type', + ]), + ]); + + $commandTester = $this->createCommandTester(); + $commandTester->execute(['name' => 'sylius_admin.product.index', '--all' => true]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertMatchesRegularExpression('/unknown_type\s+-\s+-/', $commandTester->getDisplay()); + } + + public function testItProvidesAutocompletion(): void + { + $this->hookablesRegistry + ->method('getHookNames') + ->willReturn(['sylius_admin.product.index', 'sylius_shop.cart.summary']); + + $command = new DebugTwigHooksCommand($this->hookablesRegistry); + $completionTester = new CommandCompletionTester($command); + + $suggestions = $completionTester->complete(['']); + + $this->assertSame(['sylius_admin.product.index', 'sylius_shop.cart.summary'], $suggestions); + } + + private function createCommandTester(): CommandTester + { + $command = new DebugTwigHooksCommand($this->hookablesRegistry); + + return new CommandTester($command); + } +}