From 2822370133b4be0538865c3e17395562ec6541b8 Mon Sep 17 00:00:00 2001 From: camilleislasse Date: Tue, 24 Mar 2026 19:52:14 +0100 Subject: [PATCH] feat(twig-hooks): improve sylius:debug:twig-hooks command --- .../Console/Command/DebugTwigHooksCommand.php | 212 ++++++++++++++--- .../Command/DebugTwigHooksCommandTest.php | 222 +++++++++++++++++- 2 files changed, 393 insertions(+), 41 deletions(-) diff --git a/src/TwigHooks/src/Console/Command/DebugTwigHooksCommand.php b/src/TwigHooks/src/Console/Command/DebugTwigHooksCommand.php index 2fc10180..5b8d3589 100644 --- a/src/TwigHooks/src/Console/Command/DebugTwigHooksCommand.php +++ b/src/TwigHooks/src/Console/Command/DebugTwigHooksCommand.php @@ -42,9 +42,10 @@ protected function configure(): void { $this ->setDefinition([ - new InputArgument('name', InputArgument::OPTIONAL, 'A hook name or part of the hook name'), + new InputArgument('name', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'One or more hook names'), new InputOption('all', 'a', InputOption::VALUE_NONE, 'Show all hookables including disabled ones'), - new InputOption('config', 'c', InputOption::VALUE_NONE, 'Show hookables configuration'), + new InputOption('config', 'c', InputOption::VALUE_NONE, 'Show hookables context, configuration and props'), + new InputOption('tree', 't', InputOption::VALUE_NONE, 'Display hooks as a tree'), ]) ->setHelp( <<<'EOF' @@ -62,13 +63,25 @@ protected function configure(): void php %command.full_name% sylius_admin.product.index +To display the merged result of multiple hooks (as resolved at runtime): + + php %command.full_name% sylius_admin.dashboard.index sylius_admin.common.index + To include disabled hookables: php %command.full_name% sylius_admin.product.index --all -To show hookables configuration: +To show hookables context, configuration and props: php %command.full_name% sylius_admin.product.index --config + +To display the full hooks hierarchy as a tree: + + php %command.full_name% sylius_admin.common.create --tree + +To display the merged tree of multiple hooks (as resolved at runtime): + + php %command.full_name% sylius_admin.dashboard.index sylius_admin.common.index --tree EOF ); } @@ -83,37 +96,77 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $name = $input->getArgument('name'); + /** @var array $names */ + $names = array_unique((array) $input->getArgument('name')); /** @var bool $showAll */ $showAll = $input->getOption('all'); /** @var bool $showConfig */ $showConfig = $input->getOption('config'); + /** @var bool $showTree */ + $showTree = $input->getOption('tree'); + + if ($showTree && $showConfig) { + $io->note('The --config option has no effect with --tree and will be ignored.'); + } + + $registeredHookNames = $this->hookablesRegistry->getHookNames(); + sort($registeredHookNames); + + // Multiple hooks — direct merge + if (count($names) > 1) { + $unknownNames = array_diff($names, $registeredHookNames); + if (0 < count($unknownNames)) { + $io->warning(sprintf('Hook(s) not found: "%s".', implode('", "', $unknownNames))); + + return Command::SUCCESS; + } + + $io->title(implode(', ', $names)); + if ($showTree) { + $this->displayHookTree($output, $names, $showAll); + } else { + $this->displayHookDetails($io, $names, $showAll, $showConfig); + } + + return Command::SUCCESS; + } - $hookNames = $this->hookablesRegistry->getHookNames(); - sort($hookNames); + // Single hook name + if (1 === count($names)) { + $singleName = $names[0]; - if (\is_string($name)) { - // Exact match - show details - if (\in_array($name, $hookNames, true)) { - $this->displayHookDetails($io, $name, $showAll, $showConfig); + // Exact match + if (in_array($singleName, $registeredHookNames, true)) { + $io->title($singleName); + if ($showTree) { + $this->displayHookTree($output, [$singleName], $showAll); + } else { + $this->displayHookDetails($io, [$singleName], $showAll, $showConfig); + } return Command::SUCCESS; } - // Partial match - filter and show table or details (case-insensitive) + // Partial match (case-insensitive) $filteredHooks = array_filter( - $hookNames, - static fn (string $hookName): bool => false !== stripos($hookName, $name), + $registeredHookNames, + static fn (string $hookName): bool => false !== stripos($hookName, $singleName), ); - if (0 === \count($filteredHooks)) { - $io->warning(\sprintf('No hooks found matching "%s".', $name)); + if (0 === count($filteredHooks)) { + $io->warning(sprintf('No hooks found matching "%s".', $singleName)); return Command::SUCCESS; } - if (1 === \count($filteredHooks)) { - $this->displayHookDetails($io, reset($filteredHooks), $showAll, $showConfig); + if (1 === count($filteredHooks)) { + $firstHook = reset($filteredHooks); + $io->title($firstHook); + if ($showTree) { + $this->displayHookTree($output, [$firstHook], $showAll); + } else { + $this->displayHookDetails($io, [$firstHook], $showAll, $showConfig); + } return Command::SUCCESS; } @@ -123,13 +176,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - if (0 === \count($hookNames)) { + if (0 === count($registeredHookNames)) { $io->warning('No hooks registered.'); return Command::SUCCESS; } - $this->displayHooksTable($io, $hookNames, $showAll); + $this->displayHooksTable($io, $registeredHookNames, $showAll); return Command::SUCCESS; } @@ -143,14 +196,14 @@ private function displayHooksTable(SymfonyStyle $io, array $hookNames, bool $sho foreach ($hookNames as $hookName) { $hookables = $this->hookablesRegistry->getFor($hookName); - $enabledCount = \count(array_filter( + $enabledCount = count(array_filter( $hookables, static fn (AbstractHookable $hookable): bool => !$hookable instanceof DisabledHookable, )); - $disabledCount = \count($hookables) - $enabledCount; + $disabledCount = count($hookables) - $enabledCount; $countDisplay = $showAll && $disabledCount > 0 - ? \sprintf('%d (%d disabled)', \count($hookables), $disabledCount) + ? sprintf('%d (%d disabled)', count($hookables), $disabledCount) : (string) $enabledCount; $rows[] = [ @@ -160,23 +213,24 @@ private function displayHooksTable(SymfonyStyle $io, array $hookNames, bool $sho } $io->table(['Hook', 'Hookables'], $rows); - $io->text(\sprintf('Total: %d hooks', \count($hookNames))); + $io->text(sprintf('Total: %d hooks', count($hookNames))); } - private function displayHookDetails(SymfonyStyle $io, string $hookName, bool $showAll, bool $showConfig): void + /** + * @param array $hookNames + */ + private function displayHookDetails(SymfonyStyle $io, array $hookNames, bool $showAll, bool $showConfig): void { - $io->title($hookName); - - $hookables = $this->hookablesRegistry->getFor($hookName); - if (!$showAll) { - $hookables = array_filter( - $hookables, - static fn (AbstractHookable $hookable): bool => !$hookable instanceof DisabledHookable, + $hookables = $showAll + ? $this->hookablesRegistry->getFor($hookNames) + : $this->hookablesRegistry->getEnabledFor($hookNames); + + if (0 === count($hookables)) { + $io->warning( + 1 === count($hookNames) + ? 'No hookables registered for this hook.' + : 'No hookables registered for these hooks.', ); - } - - if (0 === \count($hookables)) { - $io->warning('No hookables registered for this hook.'); return; } @@ -186,7 +240,9 @@ private function displayHookDetails(SymfonyStyle $io, string $hookName, bool $sh $headers[] = 'Status'; } if ($showConfig) { + $headers[] = 'Context'; $headers[] = 'Configuration'; + $headers[] = 'Props'; } $rows = []; @@ -203,7 +259,11 @@ private function displayHookDetails(SymfonyStyle $io, string $hookName, bool $sh } if ($showConfig) { + $row[] = $this->formatConfiguration($hookable->context); $row[] = $this->formatConfiguration($hookable->configuration); + $row[] = $hookable instanceof HookableComponent + ? $this->formatConfiguration($hookable->props) + : '-'; } $rows[] = $row; @@ -212,12 +272,88 @@ private function displayHookDetails(SymfonyStyle $io, string $hookName, bool $sh $io->table($headers, $rows); } + /** + * @param array $hookNames + */ + private function displayHookTree(OutputInterface $output, array $hookNames, bool $showAll, string $prefix = ''): void + { + $hookables = $showAll + ? $this->hookablesRegistry->getFor($hookNames) + : $this->hookablesRegistry->getEnabledFor($hookNames); + + $childGroups = $this->getDirectChildHookGroups($hookNames); + $hookablesList = array_values($hookables); + $lastHookableIndex = count($hookablesList) - 1; + + foreach ($hookablesList as $index => $hookable) { + $isLast = $index === $lastHookableIndex && 0 === count($childGroups); + $connector = $isLast ? '└── ' : '├── '; + + $output->writeln($prefix . $connector . $this->formatHookableLine($hookable)); + } + + $childGroupsList = array_values($childGroups); + foreach ($childGroupsList as $index => $childHookNames) { + $isLast = $index === count($childGroupsList) - 1; + $connector = $isLast ? '└── ' : '├── '; + $childPrefix = $prefix . ($isLast ? ' ' : '│ '); + + $output->writeln(sprintf('%s%s(Hook) %s', $prefix, $connector, implode(', ', $childHookNames))); + $this->displayHookTree($output, $childHookNames, $showAll, $childPrefix); + } + } + + private function formatHookableLine(AbstractHookable $hookable): string + { + $type = $this->getHookableType($hookable); + $target = $this->getHookableTarget($hookable); + $status = $hookable instanceof DisabledHookable ? ' [disabled]' : ''; + + $coloredType = $hookable instanceof HookableComponent + ? sprintf('(%s)', $type) + : sprintf('(%s)', $type); + + return sprintf('%s [↑ %d] %s (%s)%s', $coloredType, $hookable->priority(), $hookable->name, $target, $status); + } + + /** + * @param array $hookNames + * + * @return array> + */ + private function getDirectChildHookGroups(array $hookNames): array + { + $groups = []; + $allHookNames = $this->hookablesRegistry->getHookNames(); + + foreach ($hookNames as $hookName) { + foreach ($allHookNames as $registeredName) { + foreach (['.', '#'] as $separator) { + if (!str_starts_with($registeredName, $hookName . $separator)) { + continue; + } + + $rest = substr($registeredName, strlen($hookName) + 1); + if (str_contains($rest, '.') || str_contains($rest, '#')) { + continue; + } + + $groups[$separator . $rest][] = $registeredName; + } + } + } + + ksort($groups); + + return $groups; + } + /** * @param array $configuration */ private function formatConfiguration(array $configuration): string { - if (0 === \count($configuration)) { + if (0 === count($configuration)) { return '-'; } @@ -227,8 +363,8 @@ private function formatConfiguration(array $configuration): string private function getHookableType(AbstractHookable $hookable): string { return match (true) { - $hookable instanceof HookableTemplate => 'template', - $hookable instanceof HookableComponent => 'component', + $hookable instanceof HookableTemplate => 'Template', + $hookable instanceof HookableComponent => 'Component', default => '-', }; } diff --git a/src/TwigHooks/tests/Unit/Console/Command/DebugTwigHooksCommandTest.php b/src/TwigHooks/tests/Unit/Console/Command/DebugTwigHooksCommandTest.php index 2486643a..30afab4c 100644 --- a/src/TwigHooks/tests/Unit/Console/Command/DebugTwigHooksCommandTest.php +++ b/src/TwigHooks/tests/Unit/Console/Command/DebugTwigHooksCommandTest.php @@ -82,8 +82,8 @@ public function testItDisplaysHookDetailsForExactMatchSortedByPriority(): void $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); + $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'); @@ -219,7 +219,60 @@ public function testItShowsConfigurationWithConfigOption(): void $this->assertStringContainsString('string_key', $display); $this->assertStringContainsString('true', $display); $this->assertStringContainsString('null', $display); - $this->assertMatchesRegularExpression('/empty_config.*-/s', $display); + $this->assertDoesNotMatchRegularExpression('/empty_config.*string_key/m', $display); + } + + public function testItShowsContextWithConfigOption(): void + { + $registry = $this->createRegistry([ + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.product.index', + 'name' => 'header', + 'context' => ['foo' => 'bar'], + ]), + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.product.index', + 'name' => 'no_context', + 'context' => [], + ]), + ]); + + $commandTester = $this->createCommandTester($registry); + $commandTester->execute(['name' => 'sylius_admin.product.index', '--config' => true]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $display = $commandTester->getDisplay(); + + $this->assertStringContainsString('Context', $display); + $this->assertStringContainsString('foo', $display); + $this->assertDoesNotMatchRegularExpression('/no_context.*foo/m', $display); + } + + public function testItShowsPropsForComponentsWithConfigOption(): void + { + $registry = $this->createRegistry([ + HookableComponentMotherObject::with([ + 'hookName' => 'sylius_admin.product.index', + 'name' => 'grid', + 'component' => 'sylius_admin:product:grid', + 'props' => ['limit' => 10], + ]), + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.product.index', + 'name' => 'header', + 'template' => '@SyliusAdmin/product/header.html.twig', + ]), + ]); + + $commandTester = $this->createCommandTester($registry); + $commandTester->execute(['name' => 'sylius_admin.product.index', '--config' => true]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $display = $commandTester->getDisplay(); + + $this->assertStringContainsString('Props', $display); + $this->assertStringContainsString('limit', $display); + $this->assertDoesNotMatchRegularExpression('/header.*limit/m', $display); } #[DataProvider('provideHookableCountCases')] @@ -260,6 +313,20 @@ public function testItDisplaysWarningWhenHookHasNoVisibleHookables(): void $this->assertStringContainsString('No hookables registered for this hook', $commandTester->getDisplay()); } + public function testItDisplaysWarningWhenMultipleHooksHaveNoVisibleHookables(): void + { + $registry = $this->createRegistry([ + DisabledHookableMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'disabled_a']), + DisabledHookableMotherObject::with(['hookName' => 'sylius_admin.order.index', 'name' => 'disabled_b']), + ]); + + $commandTester = $this->createCommandTester($registry); + $commandTester->execute(['name' => ['sylius_admin.product.index', 'sylius_admin.order.index']]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('No hookables registered for these hooks', $commandTester->getDisplay()); + } + public function testItDisplaysDashForUnknownHookableTypeAndTarget(): void { $registry = $this->createRegistry([ @@ -276,6 +343,155 @@ public function testItDisplaysDashForUnknownHookableTypeAndTarget(): void $this->assertMatchesRegularExpression('/unknown_type\s+-\s+-/', $commandTester->getDisplay()); } + public function testItDisplaysChildHooksInTree(): void + { + $registry = $this->createRegistry([ + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.product.index', + 'name' => 'content', + 'template' => '@SyliusAdmin/product/content.html.twig', + ]), + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.product.index.content', + 'name' => 'form', + 'template' => '@SyliusAdmin/product/content/form.html.twig', + ]), + ]); + + $commandTester = $this->createCommandTester($registry); + $commandTester->execute(['name' => 'sylius_admin.product.index', '--tree' => true]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $display = $commandTester->getDisplay(); + + $this->assertStringContainsString('sylius_admin.product.index.content', $display); + $this->assertStringContainsString('form', $display); + } + + public function testItDisplaysChildHooksWithHashSeparatorInTree(): void + { + $registry = $this->createRegistry([ + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.common.create', + 'name' => 'content', + 'template' => '@SyliusAdmin/common/create/content.html.twig', + ]), + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.common.create#title', + 'name' => 'default', + 'template' => '@SyliusAdmin/common/create/title.html.twig', + ]), + ]); + + $commandTester = $this->createCommandTester($registry); + $commandTester->execute(['name' => 'sylius_admin.common.create', '--tree' => true]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $display = $commandTester->getDisplay(); + + $this->assertStringContainsString('sylius_admin.common.create#title', $display); + $this->assertStringContainsString('default', $display); + } + + public function testItDisplaysWarningWhenUnknownHookNamePassedForMultipleHooks(): void + { + $registry = $this->createRegistry([ + HookableTemplateMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'header']), + ]); + + $commandTester = $this->createCommandTester($registry); + $commandTester->execute(['name' => ['sylius_admin.product.index', 'nonexistent']]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('Hook(s) not found: "nonexistent"', $commandTester->getDisplay()); + } + + public function testItDisplaysMergedHookablesFromMultipleHooks(): void + { + $registry = $this->createRegistry([ + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.common.index.content', + 'name' => 'header', + 'template' => '@SyliusAdmin/common/content/header.html.twig', + ]), + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.common.index.content', + 'name' => 'grid', + 'template' => '@SyliusAdmin/common/content/grid.html.twig', + ]), + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.product.index.content', + 'name' => 'grid', + 'template' => '@SyliusAdmin/product/content/grid.html.twig', + ]), + ]); + + $commandTester = $this->createCommandTester($registry); + $commandTester->execute(['name' => ['sylius_admin.product.index.content', 'sylius_admin.common.index.content']]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $display = $commandTester->getDisplay(); + + $this->assertStringContainsString('header', $display); + // grid from product overrides common + $this->assertStringContainsString('@SyliusAdmin/product/content/grid.html.twig', $display); + $this->assertStringNotContainsString('@SyliusAdmin/common/content/grid.html.twig', $display); + } + + public function testItDisplaysMergedTreeFromMultipleHooks(): void + { + $registry = $this->createRegistry([ + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.common.index.content', + 'name' => 'header', + 'template' => '@SyliusAdmin/common/content/header.html.twig', + ]), + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.common.index.content', + 'name' => 'grid', + 'template' => '@SyliusAdmin/common/content/grid.html.twig', + ]), + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.product.index.content', + 'name' => 'grid', + 'template' => '@SyliusAdmin/product/content/grid.html.twig', + ]), + HookableTemplateMotherObject::with([ + 'hookName' => 'sylius_admin.product.index.content.grid', + 'name' => 'filters', + 'template' => '@SyliusAdmin/product/content/grid/filters.html.twig', + ]), + ]); + + $commandTester = $this->createCommandTester($registry); + $commandTester->execute(['name' => ['sylius_admin.product.index.content', 'sylius_admin.common.index.content'], '--tree' => true]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $display = $commandTester->getDisplay(); + + // merged hookables + $this->assertStringContainsString('header', $display); + // product grid overrides common grid + $this->assertStringContainsString('@SyliusAdmin/product/content/grid.html.twig', $display); + $this->assertStringNotContainsString('@SyliusAdmin/common/content/grid.html.twig', $display); + // child hook visible in tree + $this->assertStringContainsString('sylius_admin.product.index.content.grid', $display); + $this->assertStringContainsString('filters', $display); + } + + public function testItDisplaysNoteWhenConfigOptionUsedWithTreeOption(): void + { + $registry = $this->createRegistry([ + HookableTemplateMotherObject::with(['hookName' => 'sylius_admin.product.index', 'name' => 'header']), + ]); + + $commandTester = $this->createCommandTester($registry); + $commandTester->execute(['name' => 'sylius_admin.product.index', '--tree' => true, '--config' => true]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('--config option has no effect with --tree', $commandTester->getDisplay()); + } + public function testItProvidesAutocompletion(): void { $registry = $this->createRegistry([