DebugCommand.php 20 KB

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