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([