diff --git a/src/Symfony/Bridge/Twig/CHANGELOG.md b/src/Symfony/Bridge/Twig/CHANGELOG.md
index fd0a0fa54b4fc..a2e55f6db2364 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 82d7b8523ab1e..072022c0a417e 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 @@ protected function configure()
{
$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 @@ protected function configure()
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);
+ if (null !== $name && !$this->twig->getLoader() instanceof FilesystemLoader) {
+ throw new InvalidArgumentException(sprintf('Argument "name" not supported, it requires the Twig loader "%s"', FilesystemLoader::class));
+ }
+
+ 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);
}
}
- $data['tests'] = array_keys($data['tests']);
- $data['loader_paths'] = $this->getLoaderPaths();
- if ($wrongBundles = $this->findWrongBundleOverrides()) {
- $data['warnings'] = $this->buildWarningMessages($wrongBundles);
+
+ $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;
+ }
}
- $io->writeln(json_encode($data));
+ $this->error($io, $message, $alternatives);
- return 0;
+ if (!$alternatives && $paths = $this->getLoaderPaths()) {
+ $io->table(array('Namespace', 'Paths'), $this->buildTableRows($paths));
+ }
}
+ }
- $filter = $input->getArgument('filter');
+ 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 @@ protected function execute(InputInterface $input, OutputInterface $output)
$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 (!$filter && $paths = $this->getLoaderPaths()) {
+ $io->section('Loader Paths');
+ $io->table(array('Namespace', 'Paths'), $this->buildTableRows($paths));
+ }
+
+ if ($wronBundles = $this->findWrongBundleOverrides()) {
+ foreach ($this->buildWarningMessages($wronBundles) as $message) {
+ $io->warning($message);
}
- if (\count($paths) > 1) {
- $rows[] = array('', '');
- $prevHasSeparator = true;
- } else {
- $prevHasSeparator = false;
+ }
+ }
+
+ private function displayGeneralJson(SymfonyStyle $io, $filter)
+ {
+ $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 ($prevHasSeparator) {
- array_pop($rows);
+ if (isset($data['tests'])) {
+ $data['tests'] = array_keys($data['tests']);
+ }
+
+ if (!$filter && $paths = $this->getLoaderPaths($filter)) {
+ $data['loader_paths'] = $paths;
}
- $io->section('Loader Paths');
- $io->table(array('Namespace', 'Paths'), $rows);
- $messages = $this->buildWarningMessages($this->findWrongBundleOverrides());
- foreach ($messages as $message) {
- $io->warning($message);
+
+ if ($wronBundles = $this->findWrongBundleOverrides()) {
+ $data['warnings'] = $this->buildWarningMessages($wronBundles);
}
- return 0;
+ $io->writeln(json_encode($data));
}
- private function getLoaderPaths()
+ private function getLoaderPaths(string $name = null): array
{
- if (!($loader = $this->twig->getLoader()) instanceof FilesystemLoader) {
- return array();
+ /** @var FilesystemLoader $loader */
+ $loader = $this->twig->getLoader();
+ $loaderPaths = array();
+ $namespaces = $loader->getNamespaces();
+ if (null !== $name) {
+ $namespace = $this->parseTemplateName($name)[0];
+ $namespaces = array_intersect(array($namespace), $namespaces);
}
- $loaderPaths = array();
- foreach ($loader->getNamespaces() as $namespace) {
+ 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 @@ private function buildWarningMessages(array $wrongBundles): array
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 39b0d0df5a2b3..ed3fea871e68f 100644
--- a/src/Symfony/Bridge/Twig/Tests/Command/DebugCommandTest.php
+++ b/src/Symfony/Bridge/Twig/Tests/Command/DebugCommandTest.php
@@ -29,51 +29,224 @@ public function testDebugCommand()
$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));
+ }
+
+ /**
+ * @expectedException \Symfony\Component\Console\Exception\InvalidArgumentException
+ * @expectedExceptionMessage Malformed namespaced template name "@foo" (expecting "@namespace/template_name").
+ */
+ public function testMalformedTemplateName()
+ {
+ $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())
+ private function createCommandTester(array $paths = array()): CommandTester
{
- $filesystemLoader = new FilesystemLoader(array(), \dirname(__DIR__).'/Fixtures');
- foreach ($paths as $namespace => $relDirs) {
- foreach ($relDirs as $relDir) {
- $filesystemLoader->addPath($relDir, $namespace);
+ $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 0000000000000..e69de29bb2d1d
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 0000000000000..e69de29bb2d1d
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 0000000000000..e69de29bb2d1d
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 0000000000000..e69de29bb2d1d