[TwigBridge] Added template \"name\" argument to debug:twig command to find their paths

This commit is contained in:
Yonel Ceruto 2018-07-17 18:31:55 -04:00 committed by Fabien Potencier
parent 1f629c8789
commit 7ef3d39a4c
7 changed files with 472 additions and 87 deletions

View File

@ -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
-----

View File

@ -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 <info>%command.name%</info> command outputs a list of twig functions,
filters, globals and tests. Output can be filtered with an optional argument.
filters, globals and tests.
<info>php %command.full_name%</info>
The command lists all functions, filters, etc.
<info>php %command.full_name% date</info>
<info>php %command.full_name% @Twig/Exception/error.html.twig</info>
The command lists all paths that match the given template name.
<info>php %command.full_name% --filter=date</info>
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);
}
}

View File

@ -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 = <<<TXT
Loader Paths
------------
$tester = $this->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' => <<<TXT
Matched File
------------
Template name "base.html.twig" not found%A
Configured Paths
----------------
No template paths configured for your application%s
----------- -------------------------------------%A
Namespace Paths%A
----------- -------------------------------------%A
@Twig vendors/twig-bundle/Resources/views%e%A
----------- -------------------------------------%A
TXT
,
'paths' => array('vendors/twig-bundle/Resources/views/' => 'Twig'),
);
yield 'no matched template' => array(
'input' => array('name' => '@App/foo.html.twig'),
'output' => <<<TXT
Matched File
------------
Template name "@App/foo.html.twig" not found%A
Configured Paths
----------------
No template paths configured for "@App" namespace%A
----------- -------------------------------------%A
Namespace Paths%A
----------- -------------------------------------%A
(None) templates%e%A
%A
@Twig templates/bundles/TwigBundle%e%A
vendors/twig-bundle/Resources/views%e%A
----------- -------------------------------------%A
TXT
,
'paths' => $defaultPaths,
);
yield 'matched file' => array(
'input' => array('name' => 'base.html.twig'),
'output' => <<<TXT
Matched File
------------
[OK] templates%ebase.html.twig%A
Configured Paths
----------------
----------- ------------%A
Namespace Paths%A
----------- ------------%A
(None) templates%e%A
----------- ------------%A
TXT
,
'paths' => $defaultPaths,
);
yield 'overridden files' => array(
'input' => array('name' => '@Twig/error.html.twig'),
'output' => <<<TXT
Matched File
------------
[OK] templates%ebundles%eTwigBundle%eerror.html.twig%A
Overridden Files
----------------
* vendors%etwig-bundle%eResources%eviews%eerror.html.twig
Configured Paths
----------------
----------- --------------------------------------
Namespace Paths%A
----------- --------------------------------------
@Twig templates/bundles/TwigBundle%e%A
vendors/twig-bundle/Resources/views%e%A
----------- --------------------------------------
TXT
,
'paths' => $defaultPaths,
);
yield 'template namespace alternative' => array(
'input' => array('name' => '@Twg/error.html.twig'),
'output' => <<<TXT
Matched File
------------
Template name "@Twg/error.html.twig" not found%A
Configured Paths
----------------
No template paths configured for "@Twg" namespace%A
%A
%wDid you mean this?%A
%w@Twig%A
TXT
,
'paths' => $defaultPaths,
);
yield 'template name alternative' => array(
'input' => array('name' => '@Twig/eror.html.twig'),
'output' => <<<TXT
Matched File
------------
Template name "@Twig/eror.html.twig" not found%A
%A
%wDid you mean one of these?%A
%w@Twig/base.html.twig%A
%w@Twig/error.html.twig%A
Configured Paths
----------------
----------- --------------------------------------
Namespace Paths
----------- --------------------------------------
@Twig templates/bundles/TwigBundle%e%A
vendors/twig-bundle/Resources/views%e%A
----------- --------------------------------------
TXT
,
'paths' => $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);