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

Open in your IDE?
  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.         switch ($input->getOption('format')) {
  85.             case 'text':
  86.                 $name $this->displayPathsText($io$name) : $this->displayGeneralText($io$filter);
  87.                 break;
  88.             case 'json':
  89.                 $name $this->displayPathsJson($io$name) : $this->displayGeneralJson($io$filter);
  90.                 break;
  91.             default:
  92.                 throw new InvalidArgumentException(sprintf('The format "%s" is not supported.'$input->getOption('format')));
  93.         }
  94.         return 0;
  95.     }
  96.     public function complete(CompletionInput $inputCompletionSuggestions $suggestions): void
  97.     {
  98.         if ($input->mustSuggestArgumentValuesFor('name')) {
  99.             $suggestions->suggestValues(array_keys($this->getLoaderPaths()));
  100.         }
  101.         if ($input->mustSuggestOptionValuesFor('format')) {
  102.             $suggestions->suggestValues(['text''json']);
  103.         }
  104.     }
  105.     private function displayPathsText(SymfonyStyle $iostring $name)
  106.     {
  107.         $file = new \ArrayIterator($this->findTemplateFiles($name));
  108.         $paths $this->getLoaderPaths($name);
  109.         $io->section('Matched File');
  110.         if ($file->valid()) {
  111.             if ($fileLink $this->getFileLink($file->key())) {
  112.                 $io->block($file->current(), 'OK'sprintf('fg=black;bg=green;href=%s'$fileLink), ' 'true);
  113.             } else {
  114.                 $io->success($file->current());
  115.             }
  116.             $file->next();
  117.             if ($file->valid()) {
  118.                 $io->section('Overridden Files');
  119.                 do {
  120.                     if ($fileLink $this->getFileLink($file->key())) {
  121.                         $io->text(sprintf('* <href=%s>%s</>'$fileLink$file->current()));
  122.                     } else {
  123.                         $io->text(sprintf('* %s'$file->current()));
  124.                     }
  125.                     $file->next();
  126.                 } while ($file->valid());
  127.             }
  128.         } else {
  129.             $alternatives = [];
  130.             if ($paths) {
  131.                 $shortnames = [];
  132.                 $dirs = [];
  133.                 foreach (current($paths) as $path) {
  134.                     $dirs[] = $this->isAbsolutePath($path) ? $path $this->projectDir.'/'.$path;
  135.                 }
  136.                 foreach (Finder::create()->files()->followLinks()->in($dirs) as $file) {
  137.                     $shortnames[] = str_replace('\\''/'$file->getRelativePathname());
  138.                 }
  139.                 [$namespace$shortname] = $this->parseTemplateName($name);
  140.                 $alternatives $this->findAlternatives($shortname$shortnames);
  141.                 if (FilesystemLoader::MAIN_NAMESPACE !== $namespace) {
  142.                     $alternatives array_map(function ($shortname) use ($namespace) {
  143.                         return '@'.$namespace.'/'.$shortname;
  144.                     }, $alternatives);
  145.                 }
  146.             }
  147.             $this->error($iosprintf('Template name "%s" not found'$name), $alternatives);
  148.         }
  149.         $io->section('Configured Paths');
  150.         if ($paths) {
  151.             $io->table(['Namespace''Paths'], $this->buildTableRows($paths));
  152.         } else {
  153.             $alternatives = [];
  154.             $namespace $this->parseTemplateName($name)[0];
  155.             if (FilesystemLoader::MAIN_NAMESPACE === $namespace) {
  156.                 $message 'No template paths configured for your application';
  157.             } else {
  158.                 $message sprintf('No template paths configured for "@%s" namespace'$namespace);
  159.                 foreach ($this->getFilesystemLoaders() as $loader) {
  160.                     $namespaces $loader->getNamespaces();
  161.                     foreach ($this->findAlternatives($namespace$namespaces) as $namespace) {
  162.                         $alternatives[] = '@'.$namespace;
  163.                     }
  164.                 }
  165.             }
  166.             $this->error($io$message$alternatives);
  167.             if (!$alternatives && $paths $this->getLoaderPaths()) {
  168.                 $io->table(['Namespace''Paths'], $this->buildTableRows($paths));
  169.             }
  170.         }
  171.     }
  172.     private function displayPathsJson(SymfonyStyle $iostring $name)
  173.     {
  174.         $files $this->findTemplateFiles($name);
  175.         $paths $this->getLoaderPaths($name);
  176.         if ($files) {
  177.             $data['matched_file'] = array_shift($files);
  178.             if ($files) {
  179.                 $data['overridden_files'] = $files;
  180.             }
  181.         } else {
  182.             $data['matched_file'] = sprintf('Template name "%s" not found'$name);
  183.         }
  184.         $data['loader_paths'] = $paths;
  185.         $io->writeln(json_encode($data));
  186.     }
  187.     private function displayGeneralText(SymfonyStyle $iostring $filter null)
  188.     {
  189.         $decorated $io->isDecorated();
  190.         $types = ['functions''filters''tests''globals'];
  191.         foreach ($types as $index => $type) {
  192.             $items = [];
  193.             foreach ($this->twig->{'get'.ucfirst($type)}() as $name => $entity) {
  194.                 if (!$filter || str_contains($name$filter)) {
  195.                     $items[$name] = $name.$this->getPrettyMetadata($type$entity$decorated);
  196.                 }
  197.             }
  198.             if (!$items) {
  199.                 continue;
  200.             }
  201.             $io->section(ucfirst($type));
  202.             ksort($items);
  203.             $io->listing($items);
  204.         }
  205.         if (!$filter && $paths $this->getLoaderPaths()) {
  206.             $io->section('Loader Paths');
  207.             $io->table(['Namespace''Paths'], $this->buildTableRows($paths));
  208.         }
  209.         if ($wrongBundles $this->findWrongBundleOverrides()) {
  210.             foreach ($this->buildWarningMessages($wrongBundles) as $message) {
  211.                 $io->warning($message);
  212.             }
  213.         }
  214.     }
  215.     private function displayGeneralJson(SymfonyStyle $io, ?string $filter)
  216.     {
  217.         $decorated $io->isDecorated();
  218.         $types = ['functions''filters''tests''globals'];
  219.         $data = [];
  220.         foreach ($types as $type) {
  221.             foreach ($this->twig->{'get'.ucfirst($type)}() as $name => $entity) {
  222.                 if (!$filter || str_contains($name$filter)) {
  223.                     $data[$type][$name] = $this->getMetadata($type$entity);
  224.                 }
  225.             }
  226.         }
  227.         if (isset($data['tests'])) {
  228.             $data['tests'] = array_keys($data['tests']);
  229.         }
  230.         if (!$filter && $paths $this->getLoaderPaths($filter)) {
  231.             $data['loader_paths'] = $paths;
  232.         }
  233.         if ($wrongBundles $this->findWrongBundleOverrides()) {
  234.             $data['warnings'] = $this->buildWarningMessages($wrongBundles);
  235.         }
  236.         $data json_encode($data\JSON_PRETTY_PRINT);
  237.         $io->writeln($decorated OutputFormatter::escape($data) : $data);
  238.     }
  239.     private function getLoaderPaths(string $name null): array
  240.     {
  241.         $loaderPaths = [];
  242.         foreach ($this->getFilesystemLoaders() as $loader) {
  243.             $namespaces $loader->getNamespaces();
  244.             if (null !== $name) {
  245.                 $namespace $this->parseTemplateName($name)[0];
  246.                 $namespaces array_intersect([$namespace], $namespaces);
  247.             }
  248.             foreach ($namespaces as $namespace) {
  249.                 $paths array_map($this->getRelativePath(...), $loader->getPaths($namespace));
  250.                 if (FilesystemLoader::MAIN_NAMESPACE === $namespace) {
  251.                     $namespace '(None)';
  252.                 } else {
  253.                     $namespace '@'.$namespace;
  254.                 }
  255.                 $loaderPaths[$namespace] = array_merge($loaderPaths[$namespace] ?? [], $paths);
  256.             }
  257.         }
  258.         return $loaderPaths;
  259.     }
  260.     private function getMetadata(string $typemixed $entity)
  261.     {
  262.         if ('globals' === $type) {
  263.             return $entity;
  264.         }
  265.         if ('tests' === $type) {
  266.             return null;
  267.         }
  268.         if ('functions' === $type || 'filters' === $type) {
  269.             $cb $entity->getCallable();
  270.             if (null === $cb) {
  271.                 return null;
  272.             }
  273.             if (\is_array($cb)) {
  274.                 if (!method_exists($cb[0], $cb[1])) {
  275.                     return null;
  276.                 }
  277.                 $refl = new \ReflectionMethod($cb[0], $cb[1]);
  278.             } elseif (\is_object($cb) && method_exists($cb'__invoke')) {
  279.                 $refl = new \ReflectionMethod($cb'__invoke');
  280.             } elseif (\function_exists($cb)) {
  281.                 $refl = new \ReflectionFunction($cb);
  282.             } elseif (\is_string($cb) && preg_match('{^(.+)::(.+)$}'$cb$m) && method_exists($m[1], $m[2])) {
  283.                 $refl = new \ReflectionMethod($m[1], $m[2]);
  284.             } else {
  285.                 throw new \UnexpectedValueException('Unsupported callback type.');
  286.             }
  287.             $args $refl->getParameters();
  288.             // filter out context/environment args
  289.             if ($entity->needsEnvironment()) {
  290.                 array_shift($args);
  291.             }
  292.             if ($entity->needsContext()) {
  293.                 array_shift($args);
  294.             }
  295.             if ('filters' === $type) {
  296.                 // remove the value the filter is applied on
  297.                 array_shift($args);
  298.             }
  299.             // format args
  300.             $args array_map(function (\ReflectionParameter $param) {
  301.                 if ($param->isDefaultValueAvailable()) {
  302.                     return $param->getName().' = '.json_encode($param->getDefaultValue());
  303.                 }
  304.                 return $param->getName();
  305.             }, $args);
  306.             return $args;
  307.         }
  308.         return null;
  309.     }
  310.     private function getPrettyMetadata(string $typemixed $entitybool $decorated): ?string
  311.     {
  312.         if ('tests' === $type) {
  313.             return '';
  314.         }
  315.         try {
  316.             $meta $this->getMetadata($type$entity);
  317.             if (null === $meta) {
  318.                 return '(unknown?)';
  319.             }
  320.         } catch (\UnexpectedValueException $e) {
  321.             return sprintf(' <error>%s</error>'$decorated OutputFormatter::escape($e->getMessage()) : $e->getMessage());
  322.         }
  323.         if ('globals' === $type) {
  324.             if (\is_object($meta)) {
  325.                 return ' = object('.\get_class($meta).')';
  326.             }
  327.             $description substr(@json_encode($meta), 050);
  328.             return sprintf(' = %s'$decorated OutputFormatter::escape($description) : $description);
  329.         }
  330.         if ('functions' === $type) {
  331.             return '('.implode(', '$meta).')';
  332.         }
  333.         if ('filters' === $type) {
  334.             return $meta '('.implode(', '$meta).')' '';
  335.         }
  336.         return null;
  337.     }
  338.     private function findWrongBundleOverrides(): array
  339.     {
  340.         $alternatives = [];
  341.         $bundleNames = [];
  342.         if ($this->twigDefaultPath && $this->projectDir) {
  343.             $folders glob($this->twigDefaultPath.'/bundles/*'\GLOB_ONLYDIR);
  344.             $relativePath ltrim(substr($this->twigDefaultPath.'/bundles/'\strlen($this->projectDir)), \DIRECTORY_SEPARATOR);
  345.             $bundleNames array_reduce($folders, function ($carry$absolutePath) use ($relativePath) {
  346.                 if (str_starts_with($absolutePath$this->projectDir)) {
  347.                     $name basename($absolutePath);
  348.                     $path ltrim($relativePath.$name\DIRECTORY_SEPARATOR);
  349.                     $carry[$name] = $path;
  350.                 }
  351.                 return $carry;
  352.             }, $bundleNames);
  353.         }
  354.         if ($notFoundBundles array_diff_key($bundleNames$this->bundlesMetadata)) {
  355.             $alternatives = [];
  356.             foreach ($notFoundBundles as $notFoundBundle => $path) {
  357.                 $alternatives[$path] = $this->findAlternatives($notFoundBundlearray_keys($this->bundlesMetadata));
  358.             }
  359.         }
  360.         return $alternatives;
  361.     }
  362.     private function buildWarningMessages(array $wrongBundles): array
  363.     {
  364.         $messages = [];
  365.         foreach ($wrongBundles as $path => $alternatives) {
  366.             $message sprintf('Path "%s" not matching any bundle found'$path);
  367.             if ($alternatives) {
  368.                 if (=== \count($alternatives)) {
  369.                     $message .= sprintf(", did you mean \"%s\"?\n"$alternatives[0]);
  370.                 } else {
  371.                     $message .= ", did you mean one of these:\n";
  372.                     foreach ($alternatives as $bundle) {
  373.                         $message .= sprintf("  - %s\n"$bundle);
  374.                     }
  375.                 }
  376.             }
  377.             $messages[] = trim($message);
  378.         }
  379.         return $messages;
  380.     }
  381.     private function error(SymfonyStyle $iostring $message, array $alternatives = []): void
  382.     {
  383.         if ($alternatives) {
  384.             if (=== \count($alternatives)) {
  385.                 $message .= "\n\nDid you mean this?\n    ";
  386.             } else {
  387.                 $message .= "\n\nDid you mean one of these?\n    ";
  388.             }
  389.             $message .= implode("\n    "$alternatives);
  390.         }
  391.         $io->block($messagenull'fg=white;bg=red'' 'true);
  392.     }
  393.     private function findTemplateFiles(string $name): array
  394.     {
  395.         [$namespace$shortname] = $this->parseTemplateName($name);
  396.         $files = [];
  397.         foreach ($this->getFilesystemLoaders() as $loader) {
  398.             foreach ($loader->getPaths($namespace) as $path) {
  399.                 if (!$this->isAbsolutePath($path)) {
  400.                     $path $this->projectDir.'/'.$path;
  401.                 }
  402.                 $filename $path.'/'.$shortname;
  403.                 if (is_file($filename)) {
  404.                     if (false !== $realpath realpath($filename)) {
  405.                         $files[$realpath] = $this->getRelativePath($realpath);
  406.                     } else {
  407.                         $files[$filename] = $this->getRelativePath($filename);
  408.                     }
  409.                 }
  410.             }
  411.         }
  412.         return $files;
  413.     }
  414.     private function parseTemplateName(string $namestring $default FilesystemLoader::MAIN_NAMESPACE): array
  415.     {
  416.         if (isset($name[0]) && '@' === $name[0]) {
  417.             if (false === ($pos strpos($name'/')) || $pos === \strlen($name) - 1) {
  418.                 throw new InvalidArgumentException(sprintf('Malformed namespaced template name "%s" (expecting "@namespace/template_name").'$name));
  419.             }
  420.             $namespace substr($name1$pos 1);
  421.             $shortname substr($name$pos 1);
  422.             return [$namespace$shortname];
  423.         }
  424.         return [$default$name];
  425.     }
  426.     private function buildTableRows(array $loaderPaths): array
  427.     {
  428.         $rows = [];
  429.         $firstNamespace true;
  430.         $prevHasSeparator false;
  431.         foreach ($loaderPaths as $namespace => $paths) {
  432.             if (!$firstNamespace && !$prevHasSeparator && \count($paths) > 1) {
  433.                 $rows[] = [''''];
  434.             }
  435.             $firstNamespace false;
  436.             foreach ($paths as $path) {
  437.                 $rows[] = [$namespace$path.\DIRECTORY_SEPARATOR];
  438.                 $namespace '';
  439.             }
  440.             if (\count($paths) > 1) {
  441.                 $rows[] = [''''];
  442.                 $prevHasSeparator true;
  443.             } else {
  444.                 $prevHasSeparator false;
  445.             }
  446.         }
  447.         if ($prevHasSeparator) {
  448.             array_pop($rows);
  449.         }
  450.         return $rows;
  451.     }
  452.     private function findAlternatives(string $name, array $collection): array
  453.     {
  454.         $alternatives = [];
  455.         foreach ($collection as $item) {
  456.             $lev levenshtein($name$item);
  457.             if ($lev <= \strlen($name) / || str_contains($item$name)) {
  458.                 $alternatives[$item] = isset($alternatives[$item]) ? $alternatives[$item] - $lev $lev;
  459.             }
  460.         }
  461.         $threshold 1e3;
  462.         $alternatives array_filter($alternatives, function ($lev) use ($threshold) { return $lev $threshold; });
  463.         ksort($alternatives\SORT_NATURAL \SORT_FLAG_CASE);
  464.         return array_keys($alternatives);
  465.     }
  466.     private function getRelativePath(string $path): string
  467.     {
  468.         if (null !== $this->projectDir && str_starts_with($path$this->projectDir)) {
  469.             return ltrim(substr($path\strlen($this->projectDir)), \DIRECTORY_SEPARATOR);
  470.         }
  471.         return $path;
  472.     }
  473.     private function isAbsolutePath(string $file): bool
  474.     {
  475.         return strspn($file'/\\'01) || (\strlen($file) > && ctype_alpha($file[0]) && ':' === $file[1] && strspn($file'/\\'21)) || null !== parse_url($file\PHP_URL_SCHEME);
  476.     }
  477.     /**
  478.      * @return FilesystemLoader[]
  479.      */
  480.     private function getFilesystemLoaders(): array
  481.     {
  482.         if (isset($this->filesystemLoaders)) {
  483.             return $this->filesystemLoaders;
  484.         }
  485.         $this->filesystemLoaders = [];
  486.         $loader $this->twig->getLoader();
  487.         if ($loader instanceof FilesystemLoader) {
  488.             $this->filesystemLoaders[] = $loader;
  489.         } elseif ($loader instanceof ChainLoader) {
  490.             foreach ($loader->getLoaders() as $l) {
  491.                 if ($l instanceof FilesystemLoader) {
  492.                     $this->filesystemLoaders[] = $l;
  493.                 }
  494.             }
  495.         }
  496.         return $this->filesystemLoaders;
  497.     }
  498.     private function getFileLink(string $absolutePath): string
  499.     {
  500.         if (null === $this->fileLinkFormatter) {
  501.             return '';
  502.         }
  503.         return (string) $this->fileLinkFormatter->format($absolutePath1);
  504.     }
  505. }