Files
prestashop/vendor/symfony/ux-twig-component/src/Command/TwigComponentDebugCommand.php
2026-04-09 18:31:51 +02:00

360 lines
12 KiB
PHP

<?php
/*
* This file is part of the Symfony 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\UX\TwigComponent\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Finder\Finder;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
use Symfony\UX\TwigComponent\ComponentFactory;
use Symfony\UX\TwigComponent\ComponentMetadata;
use Symfony\UX\TwigComponent\Twig\PropsNode;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
#[AsCommand(name: 'debug:twig-component', description: 'Display components and them usages for an application')]
class TwigComponentDebugCommand extends Command
{
private readonly string $anonymousDirectory;
public function __construct(
private string $twigTemplatesPath,
private ComponentFactory $componentFactory,
private Environment $twig,
private readonly array $componentClassMap,
?string $anonymousDirectory = null,
) {
parent::__construct();
$this->anonymousDirectory = $anonymousDirectory ?? 'components';
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('name', InputArgument::OPTIONAL, 'A component name or part of the component name'),
])
->setHelp(
<<<'EOF'
The <info>%command.name%</info> display all the Twig components in your application.
To list all components:
<info>php %command.full_name%</info>
To get specific information about a component, specify its name (or a part of it):
<info>php %command.full_name% Alert</info>
EOF
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$name = $input->getArgument('name');
if (\is_string($name)) {
$component = $this->findComponentName($io, $name, $input->isInteractive());
if (null === $component) {
$io->error(\sprintf('Unknown component "%s".', $name));
return Command::FAILURE;
}
$this->displayComponentDetails($io, $component);
return Command::SUCCESS;
}
$components = $this->findComponents();
$this->displayComponentsTable($io, $components);
return Command::SUCCESS;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('name')) {
$suggestions->suggestValues(array_keys($this->findComponents()));
}
}
private function findComponentName(SymfonyStyle $io, string $name, bool $interactive): ?string
{
$components = [];
foreach ($this->componentClassMap as $componentName) {
if ($name === $componentName) {
return $name;
}
if (str_contains($componentName, $name)) {
$components[$componentName] = $componentName;
}
}
foreach ($this->findAnonymousComponents() as $componentName) {
if (isset($components[$componentName])) {
continue;
}
if ($name === $componentName) {
return $name;
}
if (str_contains($componentName, $name)) {
$components[$componentName] = $componentName;
}
}
if ($interactive && \count($components)) {
return $io->choice('Select one of the following component to display its information', array_values($components), 0);
}
return null;
}
/**
* @return array<string, ComponentMetadata>
*/
private function findComponents(): array
{
$components = [];
foreach ($this->componentClassMap as $class => $name) {
$components[$name] ??= $this->componentFactory->metadataFor($name);
}
foreach ($this->findAnonymousComponents() as $name => $template) {
$components[$name] ??= $this->componentFactory->metadataFor($name);
}
return $components;
}
/**
* Return a map of component name => template.
*
* @return array<string, string>
*/
private function findAnonymousComponents(): array
{
$componentsDir = $this->twigTemplatesPath.'/'.$this->anonymousDirectory;
$dirs = [$componentsDir => FilesystemLoader::MAIN_NAMESPACE];
$twigLoader = $this->twig->getLoader();
if ($twigLoader instanceof FilesystemLoader) {
foreach ($twigLoader->getNamespaces() as $namespace) {
if (str_starts_with($namespace, '!')) {
continue; // ignore parent convention namespaces
}
foreach ($twigLoader->getPaths($namespace) as $path) {
if (FilesystemLoader::MAIN_NAMESPACE === $namespace) {
$componentsDir = $path.'/'.$this->anonymousDirectory;
} else {
$componentsDir = $path.'/components';
}
if (!is_dir($componentsDir)) {
continue;
}
$dirs[$componentsDir] = $namespace;
}
}
}
$components = [];
$finderTemplates = new Finder();
$finderTemplates->files()
->in(array_keys($dirs))
->notPath('/_')
->name('*.html.twig')
;
foreach ($finderTemplates as $template) {
$component = str_replace(\DIRECTORY_SEPARATOR, ':', $template->getRelativePathname());
$component = substr($component, 0, -10); // remove file extension ".html.twig"
$path = $template->getPath();
if ($template->getRelativePath()) {
$path = rtrim(substr($template->getPath(), 0, -1 * \strlen($template->getRelativePath())), \DIRECTORY_SEPARATOR);
}
if (isset($dirs[$path]) && FilesystemLoader::MAIN_NAMESPACE !== $dirs[$path]) {
$component = $dirs[$path].':'.$component;
}
$components[$component] = $component;
}
return $components;
}
private function displayComponentDetails(SymfonyStyle $io, string $name): void
{
$metadata = $this->componentFactory->metadataFor($name);
$table = $io->createTable();
$table->setHeaderTitle('Component');
$table->setHeaders(['Property', 'Value']);
$table->addRows([
['Name', $metadata->getName()],
['Class', $metadata->get('class') ?? ''],
['Template', $metadata->getTemplate()],
]);
// Anonymous Component
if ($metadata->isAnonymous()) {
$table->addRows([
['Type', '<comment>Anonymous</comment>'],
new TableSeparator(),
['Properties', implode("\n", $this->getAnonymousComponentProperties($metadata))],
]);
$table->render();
return;
}
$table->addRows([
['Type', $metadata->get('live') ? '<info>Live</info>' : ''],
new TableSeparator(),
// ['Attributes Var', $metadata->get('attributes_var')],
['Public Props', $metadata->isPublicPropsExposed() ? 'Yes' : 'No'],
['Properties', implode("\n", $this->getComponentProperties($metadata))],
]);
$logMethod = function (\ReflectionMethod $m) {
$params = array_map(
fn (\ReflectionParameter $p) => '$'.$p->getName(),
$m->getParameters(),
);
return \sprintf('%s(%s)', $m->getName(), implode(', ', $params));
};
$hooks = [];
$reflector = new \ReflectionClass($metadata->getClass());
foreach ($metadata->getPreMounts() as $method) {
$hooks[] = ['PreMount', $logMethod($reflector->getMethod($method))];
}
foreach ($metadata->getMounts() as $method) {
$hooks[] = ['Mount', $logMethod($reflector->getMethod($method))];
}
foreach ($metadata->getPostMounts() as $method) {
$hooks[] = ['PostMount', $logMethod($reflector->getMethod($method))];
}
if ($hooks) {
$table->addRows([
new TableSeparator(),
...$hooks,
]);
}
$table->render();
}
/**
* @param array<ComponentMetadata> $components
*/
private function displayComponentsTable(SymfonyStyle $io, array $components): void
{
$table = $io->createTable();
$table->setStyle('default');
$table->setHeaderTitle('Components');
$table->setHeaders(['Name', 'Class', 'Template', 'Type']);
foreach ($components as $metadata) {
$table->addRow([
$metadata->getName(),
$metadata->get('class') ? $metadata->getClass() : '',
$metadata->getTemplate(),
$metadata->get('live') ? '<info>Live</info>' : ($metadata->get('class') ? '' : '<comment>Anon</comment>'),
]);
}
$table->render();
}
/**
* @return array<string, string>
*/
private function getComponentProperties(ComponentMetadata $metadata): array
{
$properties = [];
$reflectionClass = new \ReflectionClass($metadata->getClass());
foreach ($reflectionClass->getProperties() as $property) {
$propertyName = $property->getName();
if ($metadata->isPublicPropsExposed() && $property->isPublic()) {
$type = $property->getType();
if ($type instanceof \ReflectionNamedType) {
$typeName = $type->getName();
} else {
$typeName = (string) $type;
}
$value = $property->hasDefaultValue() ? $property->getDefaultValue() : null;
$propertyDisplay = $typeName.' $'.$propertyName.(null !== $value ? ' = '.json_encode($value) : '');
$properties[$property->name] = $propertyDisplay;
}
foreach ($property->getAttributes(ExposeInTemplate::class) as $exposeAttribute) {
/** @var ExposeInTemplate $attribute */
$attribute = $exposeAttribute->newInstance();
$properties[$property->name] = $attribute->name ?? $property->name;
}
}
return $properties;
}
/**
* Extract properties from {% props %} tag in anonymous template.
*
* @return array<string, string>
*/
private function getAnonymousComponentProperties(ComponentMetadata $metadata): array
{
$source = $this->twig->load($metadata->getTemplate())->getSourceContext();
$tokenStream = $this->twig->tokenize($source);
$moduleNode = $this->twig->parse($tokenStream);
$propsNode = null;
foreach ($moduleNode->getNode('body') as $bodyNode) {
foreach ($bodyNode as $node) {
if (PropsNode::class === $node::class) {
$propsNode = $node;
break 2;
}
}
}
if (!$propsNode instanceof PropsNode) {
return [];
}
$propertyNames = $propsNode->getAttribute('names');
$properties = array_combine($propertyNames, $propertyNames);
foreach ($propertyNames as $propName) {
if ($propsNode->hasNode($propName)
&& ($valueNode = $propsNode->getNode($propName))
&& $valueNode->hasAttribute('value')
) {
$value = $valueNode->getAttribute('value');
if (\is_bool($value)) {
$value = $value ? 'true' : 'false';
} else {
$value = json_encode($value);
}
$properties[$propName] = $propName.' = '.$value;
}
}
return $properties;
}
}