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

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