From 7ef3d39a4c032247d219b871a7b3db43371ae4e3 Mon Sep 17 00:00:00 2001 From: Yonel Ceruto Date: Tue, 17 Jul 2018 18:31:55 -0400 Subject: [PATCH] [TwigBridge] Added template \"name\" argument to debug:twig command to find their paths --- src/Symfony/Bridge/Twig/CHANGELOG.md | 1 + .../Bridge/Twig/Command/DebugCommand.php | 317 +++++++++++++++--- .../Twig/Tests/Command/DebugCommandTest.php | 241 +++++++++++-- .../Tests/Fixtures/templates/base.html.twig | 0 .../bundles/TwigBundle/error.html.twig | 0 .../Resources/views/base.html.twig | 0 .../Resources/views/error.html.twig | 0 7 files changed, 472 insertions(+), 87 deletions(-) create mode 100644 src/Symfony/Bridge/Twig/Tests/Fixtures/templates/base.html.twig create mode 100644 src/Symfony/Bridge/Twig/Tests/Fixtures/templates/bundles/TwigBundle/error.html.twig create mode 100644 src/Symfony/Bridge/Twig/Tests/Fixtures/vendors/twig-bundle/Resources/views/base.html.twig create mode 100644 src/Symfony/Bridge/Twig/Tests/Fixtures/vendors/twig-bundle/Resources/views/error.html.twig diff --git a/src/Symfony/Bridge/Twig/CHANGELOG.md b/src/Symfony/Bridge/Twig/CHANGELOG.md index fd0a0fa54b..a2e55f6db2 100644 --- a/src/Symfony/Bridge/Twig/CHANGELOG.md +++ b/src/Symfony/Bridge/Twig/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG ----- * add bundle name suggestion on wrongly overridden templates paths +* added `name` argument in `debug:twig` command and changed `filter` argument as `--filter` option 4.1.0 ----- diff --git a/src/Symfony/Bridge/Twig/Command/DebugCommand.php b/src/Symfony/Bridge/Twig/Command/DebugCommand.php index 82d7b8523a..072022c0a4 100644 --- a/src/Symfony/Bridge/Twig/Command/DebugCommand.php +++ b/src/Symfony/Bridge/Twig/Command/DebugCommand.php @@ -12,11 +12,13 @@ namespace Symfony\Bridge\Twig\Command; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Finder\Finder; use Twig\Environment; use Twig\Loader\FilesystemLoader; @@ -50,19 +52,24 @@ class DebugCommand extends Command { $this ->setDefinition(array( - new InputArgument('filter', InputArgument::OPTIONAL, 'Show details for all entries matching this filter'), + new InputArgument('name', InputArgument::OPTIONAL, 'The template name'), + new InputOption('filter', null, InputOption::VALUE_REQUIRED, 'Show details for all entries matching this filter'), new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (text or json)', 'text'), )) ->setDescription('Shows a list of twig functions, filters, globals and tests') ->setHelp(<<<'EOF' The %command.name% command outputs a list of twig functions, -filters, globals and tests. Output can be filtered with an optional argument. +filters, globals and tests. php %command.full_name% The command lists all functions, filters, etc. - php %command.full_name% date + php %command.full_name% @Twig/Exception/error.html.twig + +The command lists all paths that match the given template name. + + php %command.full_name% --filter=date The command lists everything that contains the word date. @@ -77,28 +84,107 @@ EOF protected function execute(InputInterface $input, OutputInterface $output) { $io = new SymfonyStyle($input, $output); - $types = array('functions', 'filters', 'tests', 'globals'); + $name = $input->getArgument('name'); + $filter = $input->getOption('filter'); - if ('json' === $input->getOption('format')) { - $data = array(); - foreach ($types as $type) { - foreach ($this->twig->{'get'.ucfirst($type)}() as $name => $entity) { - $data[$type][$name] = $this->getMetadata($type, $entity); - } - } - $data['tests'] = array_keys($data['tests']); - $data['loader_paths'] = $this->getLoaderPaths(); - if ($wrongBundles = $this->findWrongBundleOverrides()) { - $data['warnings'] = $this->buildWarningMessages($wrongBundles); - } - - $io->writeln(json_encode($data)); - - return 0; + if (null !== $name && !$this->twig->getLoader() instanceof FilesystemLoader) { + throw new InvalidArgumentException(sprintf('Argument "name" not supported, it requires the Twig loader "%s"', FilesystemLoader::class)); } - $filter = $input->getArgument('filter'); + switch ($input->getOption('format')) { + case 'text': + return $name ? $this->displayPathsText($io, $name) : $this->displayGeneralText($io, $filter); + case 'json': + return $name ? $this->displayPathsJson($io, $name) : $this->displayGeneralJson($io, $filter); + default: + throw new InvalidArgumentException(sprintf('The format "%s" is not supported.', $input->getOption('format'))); + } + } + private function displayPathsText(SymfonyStyle $io, string $name) + { + $files = $this->findTemplateFiles($name); + $paths = $this->getLoaderPaths($name); + + $io->section('Matched File'); + if ($files) { + $io->success(array_shift($files)); + + if ($files) { + $io->section('Overridden Files'); + $io->listing($files); + } + } else { + $alternatives = array(); + + if ($paths) { + $shortnames = array(); + $dirs = array(); + foreach (current($paths) as $path) { + $dirs[] = $this->isAbsolutePath($path) ? $path : $this->projectDir.'/'.$path; + } + foreach (Finder::create()->files()->followLinks()->in($dirs) as $file) { + $shortnames[] = str_replace('\\', '/', $file->getRelativePathname()); + } + + list($namespace, $shortname) = $this->parseTemplateName($name); + $alternatives = $this->findAlternatives($shortname, $shortnames); + if (FilesystemLoader::MAIN_NAMESPACE !== $namespace) { + $alternatives = array_map(function ($shortname) use ($namespace) { + return '@'.$namespace.'/'.$shortname; + }, $alternatives); + } + } + + $this->error($io, sprintf('Template name "%s" not found', $name), $alternatives); + } + + $io->section('Configured Paths'); + if ($paths) { + $io->table(array('Namespace', 'Paths'), $this->buildTableRows($paths)); + } else { + $alternatives = array(); + $namespace = $this->parseTemplateName($name)[0]; + + if (FilesystemLoader::MAIN_NAMESPACE === $namespace) { + $message = 'No template paths configured for your application'; + } else { + $message = sprintf('No template paths configured for "@%s" namespace', $namespace); + $namespaces = $this->twig->getLoader()->getNamespaces(); + foreach ($this->findAlternatives($namespace, $namespaces) as $namespace) { + $alternatives[] = '@'.$namespace; + } + } + + $this->error($io, $message, $alternatives); + + if (!$alternatives && $paths = $this->getLoaderPaths()) { + $io->table(array('Namespace', 'Paths'), $this->buildTableRows($paths)); + } + } + } + + private function displayPathsJson(SymfonyStyle $io, string $name) + { + $files = $this->findTemplateFiles($name); + $paths = $this->getLoaderPaths($name); + + if ($files) { + $data['matched_file'] = array_shift($files); + if ($files) { + $data['overridden_files'] = $files; + } + } else { + $data['matched_file'] = sprintf('Template name "%s" not found', $name); + } + $data['loader_paths'] = $paths; + + $io->writeln(json_encode($data)); + } + + private function displayGeneralText(SymfonyStyle $io, string $filter = null) + { + $types = array('functions', 'filters', 'tests', 'globals'); foreach ($types as $index => $type) { $items = array(); foreach ($this->twig->{'get'.ucfirst($type)}() as $name => $entity) { @@ -117,46 +203,56 @@ EOF $io->listing($items); } - $rows = array(); - $firstNamespace = true; - $prevHasSeparator = false; - foreach ($this->getLoaderPaths() as $namespace => $paths) { - if (!$firstNamespace && !$prevHasSeparator && \count($paths) > 1) { - $rows[] = array('', ''); - } - $firstNamespace = false; - foreach ($paths as $path) { - $rows[] = array($namespace, $path.\DIRECTORY_SEPARATOR); - $namespace = ''; - } - if (\count($paths) > 1) { - $rows[] = array('', ''); - $prevHasSeparator = true; - } else { - $prevHasSeparator = false; - } - } - if ($prevHasSeparator) { - array_pop($rows); - } - $io->section('Loader Paths'); - $io->table(array('Namespace', 'Paths'), $rows); - $messages = $this->buildWarningMessages($this->findWrongBundleOverrides()); - foreach ($messages as $message) { - $io->warning($message); + if (!$filter && $paths = $this->getLoaderPaths()) { + $io->section('Loader Paths'); + $io->table(array('Namespace', 'Paths'), $this->buildTableRows($paths)); } - return 0; + if ($wronBundles = $this->findWrongBundleOverrides()) { + foreach ($this->buildWarningMessages($wronBundles) as $message) { + $io->warning($message); + } + } } - private function getLoaderPaths() + private function displayGeneralJson(SymfonyStyle $io, $filter) { - if (!($loader = $this->twig->getLoader()) instanceof FilesystemLoader) { - return array(); + $types = array('functions', 'filters', 'tests', 'globals'); + $data = array(); + foreach ($types as $type) { + foreach ($this->twig->{'get'.ucfirst($type)}() as $name => $entity) { + if (!$filter || false !== strpos($name, $filter)) { + $data[$type][$name] = $this->getMetadata($type, $entity); + } + } + } + if (isset($data['tests'])) { + $data['tests'] = array_keys($data['tests']); } + if (!$filter && $paths = $this->getLoaderPaths($filter)) { + $data['loader_paths'] = $paths; + } + + if ($wronBundles = $this->findWrongBundleOverrides()) { + $data['warnings'] = $this->buildWarningMessages($wronBundles); + } + + $io->writeln(json_encode($data)); + } + + private function getLoaderPaths(string $name = null): array + { + /** @var FilesystemLoader $loader */ + $loader = $this->twig->getLoader(); $loaderPaths = array(); - foreach ($loader->getNamespaces() as $namespace) { + $namespaces = $loader->getNamespaces(); + if (null !== $name) { + $namespace = $this->parseTemplateName($name)[0]; + $namespaces = array_intersect(array($namespace), $namespaces); + } + + foreach ($namespaces as $namespace) { $paths = array_map(function ($path) { if (null !== $this->projectDir && 0 === strpos($path, $this->projectDir)) { $path = ltrim(substr($path, \strlen($this->projectDir)), \DIRECTORY_SEPARATOR); @@ -345,4 +441,119 @@ EOF return $messages; } + + private function error(SymfonyStyle $io, string $message, array $alternatives = array()): void + { + if ($alternatives) { + if (1 === \count($alternatives)) { + $message .= "\n\nDid you mean this?\n "; + } else { + $message .= "\n\nDid you mean one of these?\n "; + } + $message .= implode("\n ", $alternatives); + } + + $io->block($message, null, 'fg=white;bg=red', ' ', true); + } + + private function findTemplateFiles(string $name): array + { + /** @var FilesystemLoader $loader */ + $loader = $this->twig->getLoader(); + $files = array(); + list($namespace, $shortname) = $this->parseTemplateName($name); + + foreach ($loader->getPaths($namespace) as $path) { + if (!$this->isAbsolutePath($path)) { + $path = $this->projectDir.'/'.$path; + } + $filename = $path.'/'.$shortname; + + if (is_file($filename)) { + if (false !== $realpath = realpath($filename)) { + $files[] = $this->getRelativePath($realpath); + } else { + $files[] = $this->getRelativePath($filename); + } + } + } + + return $files; + } + + private function parseTemplateName(string $name, string $default = FilesystemLoader::MAIN_NAMESPACE): array + { + if (isset($name[0]) && '@' === $name[0]) { + if (false === ($pos = strpos($name, '/')) || $pos === \strlen($name) - 1) { + throw new InvalidArgumentException(sprintf('Malformed namespaced template name "%s" (expecting "@namespace/template_name").', $name)); + } + + $namespace = substr($name, 1, $pos - 1); + $shortname = substr($name, $pos + 1); + + return array($namespace, $shortname); + } + + return array($default, $name); + } + + private function buildTableRows(array $loaderPaths): array + { + $rows = array(); + $firstNamespace = true; + $prevHasSeparator = false; + + foreach ($loaderPaths as $namespace => $paths) { + if (!$firstNamespace && !$prevHasSeparator && \count($paths) > 1) { + $rows[] = array('', ''); + } + $firstNamespace = false; + foreach ($paths as $path) { + $rows[] = array($namespace, $path.\DIRECTORY_SEPARATOR); + $namespace = ''; + } + if (\count($paths) > 1) { + $rows[] = array('', ''); + $prevHasSeparator = true; + } else { + $prevHasSeparator = false; + } + } + if ($prevHasSeparator) { + array_pop($rows); + } + + return $rows; + } + + private function findAlternatives(string $name, array $collection): array + { + $alternatives = array(); + foreach ($collection as $item) { + $lev = levenshtein($name, $item); + if ($lev <= \strlen($name) / 3 || false !== strpos($item, $name)) { + $alternatives[$item] = isset($alternatives[$item]) ? $alternatives[$item] - $lev : $lev; + } + } + + $threshold = 1e3; + $alternatives = array_filter($alternatives, function ($lev) use ($threshold) { return $lev < 2 * $threshold; }); + ksort($alternatives, SORT_NATURAL | SORT_FLAG_CASE); + + return array_keys($alternatives); + } + + private function getRelativePath(string $path): string + { + if (null !== $this->projectDir && 0 === strpos($path, $this->projectDir)) { + return ltrim(substr($path, \strlen($this->projectDir)), \DIRECTORY_SEPARATOR); + } + + return $path; + } + + private function isAbsolutePath(string $file): bool + { + 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); + } } diff --git a/src/Symfony/Bridge/Twig/Tests/Command/DebugCommandTest.php b/src/Symfony/Bridge/Twig/Tests/Command/DebugCommandTest.php index 39b0d0df5a..ed3fea871e 100644 --- a/src/Symfony/Bridge/Twig/Tests/Command/DebugCommandTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Command/DebugCommandTest.php @@ -29,51 +29,224 @@ class DebugCommandTest extends TestCase $this->assertContains('Functions', trim($tester->getDisplay())); } - public function testLineSeparatorInLoaderPaths() + public function testFilterAndJsonFormatOptions() { - // these paths aren't realistic, - // they're configured to force the line separator - $tester = $this->createCommandTester(array( - 'Acme' => array('extractor', 'extractor'), - '!Acme' => array('extractor', 'extractor'), - FilesystemLoader::MAIN_NAMESPACE => array('extractor', 'extractor'), - )); - $ret = $tester->execute(array(), array('decorated' => false)); - $ds = \DIRECTORY_SEPARATOR; - $loaderPaths = <<createCommandTester(); + $ret = $tester->execute(array('--filter' => 'abs', '--format' => 'json'), array('decorated' => false)); - ----------- ------------ - Namespace Paths - ----------- ------------ - @Acme extractor$ds - extractor$ds - - @!Acme extractor$ds - extractor$ds - - (None) extractor$ds - extractor$ds - ----------- ------------ -TXT; + $expected = array( + 'filters' => array('abs' => array()), + ); $this->assertEquals(0, $ret, 'Returns 0 in case of success'); - $this->assertContains($loaderPaths, trim($tester->getDisplay(true))); + $this->assertEquals($expected, json_decode($tester->getDisplay(true), true)); } - private function createCommandTester(array $paths = array()) + /** + * @expectedException \Symfony\Component\Console\Exception\InvalidArgumentException + * @expectedExceptionMessage Malformed namespaced template name "@foo" (expecting "@namespace/template_name"). + */ + public function testMalformedTemplateName() { - $filesystemLoader = new FilesystemLoader(array(), \dirname(__DIR__).'/Fixtures'); - foreach ($paths as $namespace => $relDirs) { - foreach ($relDirs as $relDir) { - $filesystemLoader->addPath($relDir, $namespace); + $this->createCommandTester()->execute(array('name' => '@foo')); + } + + /** + * @dataProvider getDebugTemplateNameTestData + */ + public function testDebugTemplateName(array $input, string $output, array $paths) + { + $tester = $this->createCommandTester($paths); + $ret = $tester->execute($input, array('decorated' => false)); + + $this->assertEquals(0, $ret, 'Returns 0 in case of success'); + $this->assertStringMatchesFormat($output, $tester->getDisplay(true)); + } + + public function getDebugTemplateNameTestData() + { + $defaultPaths = array( + 'templates/' => null, + 'templates/bundles/TwigBundle/' => 'Twig', + 'vendors/twig-bundle/Resources/views/' => 'Twig', + ); + + yield 'no template paths configured for your application' => array( + 'input' => array('name' => 'base.html.twig'), + 'output' => << array('vendors/twig-bundle/Resources/views/' => 'Twig'), + ); + + yield 'no matched template' => array( + 'input' => array('name' => '@App/foo.html.twig'), + 'output' => << $defaultPaths, + ); + + yield 'matched file' => array( + 'input' => array('name' => 'base.html.twig'), + 'output' => << $defaultPaths, + ); + + yield 'overridden files' => array( + 'input' => array('name' => '@Twig/error.html.twig'), + 'output' => << $defaultPaths, + ); + + yield 'template namespace alternative' => array( + 'input' => array('name' => '@Twg/error.html.twig'), + 'output' => << $defaultPaths, + ); + + yield 'template name alternative' => array( + 'input' => array('name' => '@Twig/eror.html.twig'), + 'output' => << $defaultPaths, + ); + } + + private function createCommandTester(array $paths = array()): CommandTester + { + $projectDir = \dirname(__DIR__).\DIRECTORY_SEPARATOR.'Fixtures'; + $loader = new FilesystemLoader(array(), $projectDir); + foreach ($paths as $path => $namespace) { + if (null === $namespace) { + $loader->addPath($path); + } else { + $loader->addPath($path, $namespace); } } - $command = new DebugCommand(new Environment($filesystemLoader)); $application = new Application(); - $application->add($command); + $application->add(new DebugCommand(new Environment($loader), $projectDir)); $command = $application->find('debug:twig'); return new CommandTester($command); diff --git a/src/Symfony/Bridge/Twig/Tests/Fixtures/templates/base.html.twig b/src/Symfony/Bridge/Twig/Tests/Fixtures/templates/base.html.twig new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Symfony/Bridge/Twig/Tests/Fixtures/templates/bundles/TwigBundle/error.html.twig b/src/Symfony/Bridge/Twig/Tests/Fixtures/templates/bundles/TwigBundle/error.html.twig new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Symfony/Bridge/Twig/Tests/Fixtures/vendors/twig-bundle/Resources/views/base.html.twig b/src/Symfony/Bridge/Twig/Tests/Fixtures/vendors/twig-bundle/Resources/views/base.html.twig new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Symfony/Bridge/Twig/Tests/Fixtures/vendors/twig-bundle/Resources/views/error.html.twig b/src/Symfony/Bridge/Twig/Tests/Fixtures/vendors/twig-bundle/Resources/views/error.html.twig new file mode 100644 index 0000000000..e69de29bb2