vendor/symfony/twig-bridge/Command/DebugCommand.php line 219

  1. <?php
  2. /*
  3.  * This file is part of the Symfony package.
  4.  *
  5.  * (c) Fabien Potencier <fabien@symfony.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. namespace Symfony\Bridge\Twig\Command;
  11. use Symfony\Component\Console\Attribute\AsCommand;
  12. use Symfony\Component\Console\Command\Command;
  13. use Symfony\Component\Console\Completion\CompletionInput;
  14. use Symfony\Component\Console\Completion\CompletionSuggestions;
  15. use Symfony\Component\Console\Exception\InvalidArgumentException;
  16. use Symfony\Component\Console\Formatter\OutputFormatter;
  17. use Symfony\Component\Console\Input\InputArgument;
  18. use Symfony\Component\Console\Input\InputInterface;
  19. use Symfony\Component\Console\Input\InputOption;
  20. use Symfony\Component\Console\Output\OutputInterface;
  21. use Symfony\Component\Console\Style\SymfonyStyle;
  22. use Symfony\Component\Finder\Finder;
  23. use Symfony\Component\HttpKernel\Debug\FileLinkFormatter;
  24. use Twig\Environment;
  25. use Twig\Loader\ChainLoader;
  26. use Twig\Loader\FilesystemLoader;
  27. /**
  28.  * Lists twig functions, filters, globals and tests present in the current project.
  29.  *
  30.  * @author Jordi Boggiano <j.boggiano@seld.be>
  31.  */
  32. #[AsCommand(name'debug:twig'description'Show a list of twig functions, filters, globals and tests')]
  33. class DebugCommand extends Command
  34. {
  35.     private Environment $twig;
  36.     private ?string $projectDir;
  37.     private array $bundlesMetadata;
  38.     private ?string $twigDefaultPath;
  39.     /**
  40.      * @var FilesystemLoader[]
  41.      */
  42.     private array $filesystemLoaders;
  43.     private ?FileLinkFormatter $fileLinkFormatter;
  44.     public function __construct(Environment $twigstring $projectDir null, array $bundlesMetadata = [], string $twigDefaultPath nullFileLinkFormatter $fileLinkFormatter null)
  45.     {
  46.         parent::__construct();
  47.         $this->twig $twig;
  48.         $this->projectDir $projectDir;
  49.         $this->bundlesMetadata $bundlesMetadata;
  50.         $this->twigDefaultPath $twigDefaultPath;
  51.         $this->fileLinkFormatter $fileLinkFormatter;
  52.     }
  53.     protected function configure()
  54.     {
  55.         $this
  56.             ->setDefinition([
  57.                 new InputArgument('name'InputArgument::OPTIONAL'The template name'),
  58.                 new InputOption('filter'nullInputOption::VALUE_REQUIRED'Show details for all entries matching this filter'),
  59.                 new InputOption('format'nullInputOption::VALUE_REQUIRED'The output format (text or json)''text'),
  60.             ])
  61.             ->setHelp(<<<'EOF'
  62. The <info>%command.name%</info> command outputs a list of twig functions,
  63. filters, globals and tests.
  64.   <info>php %command.full_name%</info>
  65. The command lists all functions, filters, etc.
  66.   <info>php %command.full_name% @Twig/Exception/error.html.twig</info>
  67. The command lists all paths that match the given template name.
  68.   <info>php %command.full_name% --filter=date</info>
  69. The command lists everything that contains the word date.
  70.   <info>php %command.full_name% --format=json</info>
  71. The command lists everything in a machine readable json format.
  72. EOF
  73.             )
  74.         ;
  75.     }
  76.     protected function execute(InputInterface $inputOutputInterface $output): int
  77.     {
  78.         $io = new SymfonyStyle($input$output);
  79.         $name $input->getArgument('name');
  80.         $filter $input->getOption('filter');
  81.         if (null !== $name && [] === $this->getFilesystemLoaders()) {
  82.             throw new InvalidArgumentException(sprintf('Argument "name" not supported, it requires the Twig loader "%s".'FilesystemLoader::class));
  83.         }
  84.         match ($input->getOption('format')) {
  85.             'text' => $name $this->displayPathsText($io$name) : $this->displayGeneralText($io$filter),
  86.             'json' => $name $this->displayPathsJson($io$name) : $this->displayGeneralJson($io$filter),
  87.             default => throw new InvalidArgumentException(sprintf('The format "%s" is not supported.'$input->getOption('format'))),
  88.         };
  89.         return 0;
  90.     }
  91.     public function complete(CompletionInput $inputCompletionSuggestions $suggestions): void
  92.     {
  93.         if ($input->mustSuggestArgumentValuesFor('name')) {
  94.             $suggestions->suggestValues(array_keys($this->getLoaderPaths()));
  95.         }
  96.         if ($input->mustSuggestOptionValuesFor('format')) {
  97.             $suggestions->suggestValues(['text''json']);
  98.         }
  99.     }
  100.     private function displayPathsText(SymfonyStyle $iostring $name)
  101.     {
  102.         $file = new \ArrayIterator($this->findTemplateFiles($name));
  103.         $paths $this->getLoaderPaths($name);
  104.         $io->section('Matched File');
  105.         if ($file->valid()) {
  106.             if ($fileLink $this->getFileLink($file->key())) {
  107.                 $io->block($file->current(), 'OK'sprintf('fg=black;bg=green;href=%s'$fileLink), ' 'true);
  108.             } else {
  109.                 $io->success($file->current());
  110.             }
  111.             $file->next();
  112.             if ($file->valid()) {
  113.                 $io->section('Overridden Files');
  114.                 do {
  115.                     if ($fileLink $this->getFileLink($file->key())) {
  116.                         $io->text(sprintf('* <href=%s>%s</>'$fileLink$file->current()));
  117.                     } else {
  118.                         $io->text(sprintf('* %s'$file->current()));
  119.                     }
  120.                     $file->next();
  121.                 } while ($file->valid());
  122.             }
  123.         } else {
  124.             $alternatives = [];
  125.             if ($paths) {
  126.                 $shortnames = [];
  127.                 $dirs = [];
  128.                 foreach (current($paths) as $path) {
  129.                     $dirs[] = $this->isAbsolutePath($path) ? $path $this->projectDir.'/'.$path;
  130.                 }
  131.                 foreach (Finder::create()->files()->followLinks()->in($dirs) as $file) {
  132.                     $shortnames[] = str_replace('\\''/'$file->getRelativePathname());
  133.                 }
  134.                 [$namespace$shortname] = $this->parseTemplateName($name);
  135.                 $alternatives $this->findAlternatives($shortname$shortnames);
  136.                 if (FilesystemLoader::MAIN_NAMESPACE !== $namespace) {
  137.                     $alternatives array_map(function ($shortname) use ($namespace) {
  138.                         return '@'.$namespace.'/'.$shortname;
  139.                     }, $alternatives);
  140.                 }
  141.             }
  142.             $this->error($iosprintf('Template name "%s" not found'$name), $alternatives);
  143.         }
  144.         $io->section('Configured Paths');
  145.         if ($paths) {
  146.             $io->table(['Namespace''Paths'], $this->buildTableRows($paths));
  147.         } else {
  148.             $alternatives = [];
  149.             $namespace $this->parseTemplateName($name)[0];
  150.             if (FilesystemLoader::MAIN_NAMESPACE === $namespace) {
  151.                 $message 'No template paths configured for your application';
  152.             } else {
  153.                 $message sprintf('No template paths configured for "@%s" namespace'$namespace);
  154.                 foreach ($this->getFilesystemLoaders() as $loader) {
  155.                     $namespaces $loader->getNamespaces();
  156.                     foreach ($this->findAlternatives($namespace$namespaces) as $namespace) {
  157.                         $alternatives[] = '@'.$namespace;
  158.                     }
  159.                 }
  160.             }
  161.             $this->error($io$message$alternatives);
  162.             if (!$alternatives && $paths $this->getLoaderPaths()) {
  163.                 $io->table(['Namespace''Paths'], $this->buildTableRows($paths));
  164.             }
  165.         }
  166.     }
  167.     private function displayPathsJson(SymfonyStyle $iostring $name)
  168.     {
  169.         $files $this->findTemplateFiles($name);
  170.         $paths $this->getLoaderPaths($name);
  171.         if ($files) {
  172.             $data['matched_file'] = array_shift($files);
  173.             if ($files) {
  174.                 $data['overridden_files'] = $files;
  175.             }
  176.         } else {
  177.             $data['matched_file'] = sprintf('Template name "%s" not found'$name);
  178.         }
  179.         $data['loader_paths'] = $paths;
  180.         $io->writeln(json_encode($data));
  181.     }
  182.     private function displayGeneralText(SymfonyStyle $iostring $filter null)
  183.     {
  184.         $decorated $io->isDecorated();
  185.         $types = ['functions''filters''tests''globals'];
  186.         foreach ($types as $index => $type) {
  187.             $items = [];
  188.             foreach ($this->twig->{'get'.ucfirst($type)}() as $name => $entity) {
  189.                 if (!$filter || str_contains($name$filter)) {
  190.                     $items[$name] = $name.$this->getPrettyMetadata($type$entity$decorated);
  191.                 }
  192.             }
  193.             if (!$items) {
  194.                 continue;
  195.             }
  196.             $io->section(ucfirst($type));
  197.             ksort($items);
  198.             $io->listing($items);
  199.         }
  200.         if (!$filter && $paths $this->getLoaderPaths()) {
  201.             $io->section('Loader Paths');
  202.             $io->table(['Namespace''Paths'], $this->buildTableRows($paths));
  203.         }
  204.         if ($wrongBundles $this->findWrongBundleOverrides()) {
  205.             foreach ($this->buildWarningMessages($wrongBundles) as $message) {
  206.                 $io->warning($message);
  207.             }
  208.         }
  209.     }
  210.     private function displayGeneralJson(SymfonyStyle $io, ?string $filter)
  211.     {
  212.         $decorated $io->isDecorated();
  213.         $types = ['functions''filters''tests''globals'];
  214.         $data = [];
  215.         foreach ($types as $type) {
  216.             foreach ($this->twig->{'get'.ucfirst($type)}() as $name => $entity) {
  217.                 if (!$filter || str_contains($name$filter)) {
  218.                     $data[$type][$name] = $this->getMetadata($type$entity);
  219.                 }
  220.             }
  221.         }
  222.         if (isset($data['tests'])) {
  223.             $data['tests'] = array_keys($data['tests']);
  224.         }
  225.         if (!$filter && $paths $this->getLoaderPaths($filter)) {
  226.             $data['loader_paths'] = $paths;
  227.         }
  228.         if ($wrongBundles $this->findWrongBundleOverrides()) {
  229.             $data['warnings'] = $this->buildWarningMessages($wrongBundles);
  230.         }
  231.         $data json_encode($data\JSON_PRETTY_PRINT);
  232.         $io->writeln($decorated OutputFormatter::escape($data) : $data);
  233.     }
  234.     private function getLoaderPaths(string $name null): array
  235.     {
  236.         $loaderPaths = [];
  237.         foreach ($this->getFilesystemLoaders() as $loader) {
  238.             $namespaces $loader->getNamespaces();
  239.             if (null !== $name) {
  240.                 $namespace $this->parseTemplateName($name)[0];
  241.                 $namespaces array_intersect([$namespace], $namespaces);
  242.             }
  243.             foreach ($namespaces as $namespace) {
  244.                 $paths array_map($this->getRelativePath(...), $loader->getPaths($namespace));
  245.                 if (FilesystemLoader::MAIN_NAMESPACE === $namespace) {
  246.                     $namespace '(None)';
  247.                 } else {
  248.                     $namespace '@'.$namespace;
  249.                 }
  250.                 $loaderPaths[$namespace] = array_merge($loaderPaths[$namespace] ?? [], $paths);
  251.             }
  252.         }
  253.         return $loaderPaths;
  254.     }
  255.     private function getMetadata(string $typemixed $entity)
  256.     {
  257.         if ('globals' === $type) {
  258.             return $entity;
  259.         }
  260.         if ('tests' === $type) {
  261.             return null;
  262.         }
  263.         if ('functions' === $type || 'filters' === $type) {
  264.             $cb $entity->getCallable();
  265.             if (null === $cb) {
  266.                 return null;
  267.             }
  268.             if (\is_array($cb)) {
  269.                 if (!method_exists($cb[0], $cb[1])) {
  270.                     return null;
  271.                 }
  272.                 $refl = new \ReflectionMethod($cb[0], $cb[1]);
  273.             } elseif (\is_object($cb) && method_exists($cb'__invoke')) {
  274.                 $refl = new \ReflectionMethod($cb'__invoke');
  275.             } elseif (\function_exists($cb)) {
  276.                 $refl = new \ReflectionFunction($cb);
  277.             } elseif (\is_string($cb) && preg_match('{^(.+)::(.+)$}'$cb$m) && method_exists($m[1], $m[2])) {
  278.                 $refl = new \ReflectionMethod($m[1], $m[2]);
  279.             } else {
  280.                 throw new \UnexpectedValueException('Unsupported callback type.');
  281.             }
  282.             $args $refl->getParameters();
  283.             // filter out context/environment args
  284.             if ($entity->needsEnvironment()) {
  285.                 array_shift($args);
  286.             }
  287.             if ($entity->needsContext()) {
  288.                 array_shift($args);
  289.             }
  290.             if ('filters' === $type) {
  291.                 // remove the value the filter is applied on
  292.                 array_shift($args);
  293.             }
  294.             // format args
  295.             $args array_map(function (\ReflectionParameter $param) {
  296.                 if ($param->isDefaultValueAvailable()) {
  297.                     return $param->getName().' = '.json_encode($param->getDefaultValue());
  298.                 }
  299.                 return $param->getName();
  300.             }, $args);
  301.             return $args;
  302.         }
  303.         return null;
  304.     }
  305.     private function getPrettyMetadata(string $typemixed $entitybool $decorated): ?string
  306.     {
  307.         if ('tests' === $type) {
  308.             return '';
  309.         }
  310.         try {
  311.             $meta $this->getMetadata($type$entity);
  312.             if (null === $meta) {
  313.                 return '(unknown?)';
  314.             }
  315.         } catch (\UnexpectedValueException $e) {
  316.             return sprintf(' <error>%s</error>'$decorated OutputFormatter::escape($e->getMessage()) : $e->getMessage());
  317.         }
  318.         if ('globals' === $type) {
  319.             if (\is_object($meta)) {
  320.                 return ' = object('.$meta::class.')';
  321.             }
  322.             $description substr(@json_encode($meta), 050);
  323.             return sprintf(' = %s'$decorated OutputFormatter::escape($description) : $description);
  324.         }
  325.         if ('functions' === $type) {
  326.             return '('.implode(', '$meta).')';
  327.         }
  328.         if ('filters' === $type) {
  329.             return $meta '('.implode(', '$meta).')' '';
  330.         }
  331.         return null;
  332.     }
  333.     private function findWrongBundleOverrides(): array
  334.     {
  335.         $alternatives = [];
  336.         $bundleNames = [];
  337.         if ($this->twigDefaultPath && $this->projectDir) {
  338.             $folders glob($this->twigDefaultPath.'/bundles/*'\GLOB_ONLYDIR);
  339.             $relativePath ltrim(substr($this->twigDefaultPath.'/bundles/'\strlen($this->projectDir)), \DIRECTORY_SEPARATOR);
  340.             $bundleNames array_reduce($folders, function ($carry$absolutePath) use ($relativePath) {
  341.                 if (str_starts_with($absolutePath$this->projectDir)) {
  342.                     $name basename($absolutePath);
  343.                     $path ltrim($relativePath.$name\DIRECTORY_SEPARATOR);
  344.                     $carry[$name] = $path;
  345.                 }
  346.                 return $carry;
  347.             }, $bundleNames);
  348.         }
  349.         if ($notFoundBundles array_diff_key($bundleNames$this->bundlesMetadata)) {
  350.             $alternatives = [];
  351.             foreach ($notFoundBundles as $notFoundBundle => $path) {
  352.                 $alternatives[$path] = $this->findAlternatives($notFoundBundlearray_keys($this->bundlesMetadata));
  353.             }
  354.         }
  355.         return $alternatives;
  356.     }
  357.     private function buildWarningMessages(array $wrongBundles): array
  358.     {
  359.         $messages = [];
  360.         foreach ($wrongBundles as $path => $alternatives) {
  361.             $message sprintf('Path "%s" not matching any bundle found'$path);
  362.             if ($alternatives) {
  363.                 if (=== \count($alternatives)) {
  364.                     $message .= sprintf(", did you mean \"%s\"?\n"$alternatives[0]);
  365.                 } else {
  366.                     $message .= ", did you mean one of these:\n";
  367.                     foreach ($alternatives as $bundle) {
  368.                         $message .= sprintf("  - %s\n"$bundle);
  369.                     }
  370.                 }
  371.             }
  372.             $messages[] = trim($message);
  373.         }
  374.         return $messages;
  375.     }
  376.     private function error(SymfonyStyle $iostring $message, array $alternatives = []): void
  377.     {
  378.         if ($alternatives) {
  379.             if (=== \count($alternatives)) {
  380.                 $message .= "\n\nDid you mean this?\n    ";
  381.             } else {
  382.                 $message .= "\n\nDid you mean one of these?\n    ";
  383.             }
  384.             $message .= implode("\n    "$alternatives);
  385.         }
  386.         $io->block($messagenull'fg=white;bg=red'' 'true);
  387.     }
  388.     private function findTemplateFiles(string $name): array
  389.     {
  390.         [$namespace$shortname] = $this->parseTemplateName($name);
  391.         $files = [];
  392.         foreach ($this->getFilesystemLoaders() as $loader) {
  393.             foreach ($loader->getPaths($namespace) as $path) {
  394.                 if (!$this->isAbsolutePath($path)) {
  395.                     $path $this->projectDir.'/'.$path;
  396.                 }
  397.                 $filename $path.'/'.$shortname;
  398.                 if (is_file($filename)) {
  399.                     if (false !== $realpath realpath($filename)) {
  400.                         $files[$realpath] = $this->getRelativePath($realpath);
  401.                     } else {
  402.                         $files[$filename] = $this->getRelativePath($filename);
  403.                     }
  404.                 }
  405.             }
  406.         }
  407.         return $files;
  408.     }
  409.     private function parseTemplateName(string $namestring $default FilesystemLoader::MAIN_NAMESPACE): array
  410.     {
  411.         if (isset($name[0]) && '@' === $name[0]) {
  412.             if (false === ($pos strpos($name'/')) || $pos === \strlen($name) - 1) {
  413.                 throw new InvalidArgumentException(sprintf('Malformed namespaced template name "%s" (expecting "@namespace/template_name").'$name));
  414.             }
  415.             $namespace substr($name1$pos 1);
  416.             $shortname substr($name$pos 1);
  417.             return [$namespace$shortname];
  418.         }
  419.         return [$default$name];
  420.     }
  421.     private function buildTableRows(array $loaderPaths): array
  422.     {
  423.         $rows = [];
  424.         $firstNamespace true;
  425.         $prevHasSeparator false;
  426.         foreach ($loaderPaths as $namespace => $paths) {
  427.             if (!$firstNamespace && !$prevHasSeparator && \count($paths) > 1) {
  428.                 $rows[] = [''''];
  429.             }
  430.             $firstNamespace false;
  431.             foreach ($paths as $path) {
  432.                 $rows[] = [$namespace$path.\DIRECTORY_SEPARATOR];
  433.                 $namespace '';
  434.             }
  435.             if (\count($paths) > 1) {
  436.                 $rows[] = [''''];
  437.                 $prevHasSeparator true;
  438.             } else {
  439.                 $prevHasSeparator false;
  440.             }
  441.         }
  442.         if ($prevHasSeparator) {
  443.             array_pop($rows);
  444.         }
  445.         return $rows;
  446.     }
  447.     private function findAlternatives(string $name, array $collection): array
  448.     {
  449.         $alternatives = [];
  450.         foreach ($collection as $item) {
  451.             $lev levenshtein($name$item);
  452.             if ($lev <= \strlen($name) / || str_contains($item$name)) {
  453.                 $alternatives[$item] = isset($alternatives[$item]) ? $alternatives[$item] - $lev $lev;
  454.             }
  455.         }
  456.         $threshold 1e3;
  457.         $alternatives array_filter($alternatives, function ($lev) use ($threshold) { return $lev $threshold; });
  458.         ksort($alternatives\SORT_NATURAL \SORT_FLAG_CASE);
  459.         return array_keys($alternatives);
  460.     }
  461.     private function getRelativePath(string $path): string
  462.     {
  463.         if (null !== $this->projectDir && str_starts_with($path$this->projectDir)) {
  464.             return ltrim(substr($path\strlen($this->projectDir)), \DIRECTORY_SEPARATOR);
  465.         }
  466.         return $path;
  467.     }
  468.     private function isAbsolutePath(string $file): bool
  469.     {
  470.         return strspn($file'/\\'01) || (\strlen($file) > && ctype_alpha($file[0]) && ':' === $file[1] && strspn($file'/\\'21)) || null !== parse_url($file\PHP_URL_SCHEME);
  471.     }
  472.     /**
  473.      * @return FilesystemLoader[]
  474.      */
  475.     private function getFilesystemLoaders(): array
  476.     {
  477.         if (isset($this->filesystemLoaders)) {
  478.             return $this->filesystemLoaders;
  479.         }
  480.         $this->filesystemLoaders = [];
  481.         $loader $this->twig->getLoader();
  482.         if ($loader instanceof FilesystemLoader) {
  483.             $this->filesystemLoaders[] = $loader;
  484.         } elseif ($loader instanceof ChainLoader) {
  485.             foreach ($loader->getLoaders() as $l) {
  486.                 if ($l instanceof FilesystemLoader) {
  487.                     $this->filesystemLoaders[] = $l;
  488.                 }
  489.             }
  490.         }
  491.         return $this->filesystemLoaders;
  492.     }
  493.     private function getFileLink(string $absolutePath): string
  494.     {
  495.         if (null === $this->fileLinkFormatter) {
  496.             return '';
  497.         }
  498.         return (string) $this->fileLinkFormatter->format($absolutePath1);
  499.     }
  500. }