Chi
4 years ago
3 changed files with 315 additions and 0 deletions
@ -0,0 +1,6 @@ |
|||||||
|
services: |
||||||
|
twig_tweak.lint: |
||||||
|
class: Drupal\twig_tweak\Command\TwigLintCommand |
||||||
|
arguments: ['@twig'] |
||||||
|
tags: |
||||||
|
- { name: console.command } |
@ -0,0 +1,271 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
/* |
||||||
|
* This file is part of the Symfony package. |
||||||
|
* |
||||||
|
* (c) Fabien Potencier <fabien@symfony.com> |
||||||
|
* |
||||||
|
* For the full copyright and license information, please view the LICENSE |
||||||
|
* file that was distributed with this source code. |
||||||
|
*/ |
||||||
|
|
||||||
|
// @codingStandardsIgnoreFile |
||||||
|
// This code is a literal copy of Symfony's LintCommand. |
||||||
|
// @see https://github.com/symfony/symfony/blob/5.x/src/Symfony/Bridge/Twig/Command/LintCommand.php |
||||||
|
namespace Drupal\twig_tweak\Command; |
||||||
|
|
||||||
|
use Symfony\Component\Console\Command\Command; |
||||||
|
use Symfony\Component\Console\Exception\InvalidArgumentException; |
||||||
|
use Symfony\Component\Console\Exception\RuntimeException; |
||||||
|
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\Error\Error; |
||||||
|
use Twig\Loader\ArrayLoader; |
||||||
|
use Twig\Loader\FilesystemLoader; |
||||||
|
use Twig\Source; |
||||||
|
|
||||||
|
/** |
||||||
|
* Command that will validate your template syntax and output encountered errors. |
||||||
|
* |
||||||
|
* @author Marc Weistroff <marc.weistroff@sensiolabs.com> |
||||||
|
* @author Jérôme Tamarelle <jerome@tamarelle.net> |
||||||
|
*/ |
||||||
|
class LintCommand extends Command |
||||||
|
{ |
||||||
|
protected static $defaultName = 'lint:twig'; |
||||||
|
|
||||||
|
private $twig; |
||||||
|
|
||||||
|
public function __construct(Environment $twig) |
||||||
|
{ |
||||||
|
parent::__construct(); |
||||||
|
|
||||||
|
$this->twig = $twig; |
||||||
|
} |
||||||
|
|
||||||
|
protected function configure() |
||||||
|
{ |
||||||
|
$this |
||||||
|
->setDescription('Lints a template and outputs encountered errors') |
||||||
|
->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format', 'txt') |
||||||
|
->addOption('show-deprecations', null, InputOption::VALUE_NONE, 'Show deprecations as errors') |
||||||
|
->addArgument('filename', InputArgument::IS_ARRAY, 'A file, a directory or "-" for reading from STDIN') |
||||||
|
->setHelp(<<<'EOF' |
||||||
|
The <info>%command.name%</info> command lints a template and outputs to STDOUT |
||||||
|
the first encountered syntax error. |
||||||
|
|
||||||
|
You can validate the syntax of contents passed from STDIN: |
||||||
|
|
||||||
|
<info>cat filename | php %command.full_name% -</info> |
||||||
|
|
||||||
|
Or the syntax of a file: |
||||||
|
|
||||||
|
<info>php %command.full_name% filename</info> |
||||||
|
|
||||||
|
Or of a whole directory: |
||||||
|
|
||||||
|
<info>php %command.full_name% dirname</info> |
||||||
|
<info>php %command.full_name% dirname --format=json</info> |
||||||
|
|
||||||
|
EOF |
||||||
|
) |
||||||
|
; |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output) |
||||||
|
{ |
||||||
|
$io = new SymfonyStyle($input, $output); |
||||||
|
$filenames = $input->getArgument('filename'); |
||||||
|
$showDeprecations = $input->getOption('show-deprecations'); |
||||||
|
|
||||||
|
if (['-'] === $filenames) { |
||||||
|
return $this->display($input, $output, $io, [$this->validate(file_get_contents('php://stdin'), uniqid('sf_', true))]); |
||||||
|
} |
||||||
|
|
||||||
|
if (!$filenames) { |
||||||
|
$loader = $this->twig->getLoader(); |
||||||
|
if ($loader instanceof FilesystemLoader) { |
||||||
|
$paths = []; |
||||||
|
foreach ($loader->getNamespaces() as $namespace) { |
||||||
|
$paths[] = $loader->getPaths($namespace); |
||||||
|
} |
||||||
|
$filenames = array_merge(...$paths); |
||||||
|
} |
||||||
|
|
||||||
|
if (!$filenames) { |
||||||
|
throw new RuntimeException('Please provide a filename or pipe template content to STDIN.'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if ($showDeprecations) { |
||||||
|
$prevErrorHandler = set_error_handler(static function ($level, $message, $file, $line) use (&$prevErrorHandler) { |
||||||
|
if (\E_USER_DEPRECATED === $level) { |
||||||
|
$templateLine = 0; |
||||||
|
if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) { |
||||||
|
$templateLine = $matches[1]; |
||||||
|
} |
||||||
|
|
||||||
|
throw new Error($message, $templateLine); |
||||||
|
} |
||||||
|
|
||||||
|
return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
$filesInfo = $this->getFilesInfo($filenames); |
||||||
|
} finally { |
||||||
|
if ($showDeprecations) { |
||||||
|
restore_error_handler(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $this->display($input, $output, $io, $filesInfo); |
||||||
|
} |
||||||
|
|
||||||
|
private function getFilesInfo(array $filenames): array |
||||||
|
{ |
||||||
|
$filesInfo = []; |
||||||
|
foreach ($filenames as $filename) { |
||||||
|
foreach ($this->findFiles($filename) as $file) { |
||||||
|
$filesInfo[] = $this->validate(file_get_contents($file), $file); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $filesInfo; |
||||||
|
} |
||||||
|
|
||||||
|
protected function findFiles(string $filename) |
||||||
|
{ |
||||||
|
if (is_file($filename)) { |
||||||
|
return [$filename]; |
||||||
|
} elseif (is_dir($filename)) { |
||||||
|
return Finder::create()->files()->in($filename)->name('*.twig'); |
||||||
|
} |
||||||
|
|
||||||
|
throw new RuntimeException(sprintf('File or directory "%s" is not readable.', $filename)); |
||||||
|
} |
||||||
|
|
||||||
|
private function validate(string $template, string $file): array |
||||||
|
{ |
||||||
|
$realLoader = $this->twig->getLoader(); |
||||||
|
try { |
||||||
|
$temporaryLoader = new ArrayLoader([$file => $template]); |
||||||
|
$this->twig->setLoader($temporaryLoader); |
||||||
|
$nodeTree = $this->twig->parse($this->twig->tokenize(new Source($template, $file))); |
||||||
|
$this->twig->compile($nodeTree); |
||||||
|
$this->twig->setLoader($realLoader); |
||||||
|
} catch (Error $e) { |
||||||
|
$this->twig->setLoader($realLoader); |
||||||
|
|
||||||
|
return ['template' => $template, 'file' => $file, 'line' => $e->getTemplateLine(), 'valid' => false, 'exception' => $e]; |
||||||
|
} |
||||||
|
|
||||||
|
return ['template' => $template, 'file' => $file, 'valid' => true]; |
||||||
|
} |
||||||
|
|
||||||
|
private function display(InputInterface $input, OutputInterface $output, SymfonyStyle $io, array $files) |
||||||
|
{ |
||||||
|
switch ($input->getOption('format')) { |
||||||
|
case 'txt': |
||||||
|
return $this->displayTxt($output, $io, $files); |
||||||
|
case 'json': |
||||||
|
return $this->displayJson($output, $files); |
||||||
|
default: |
||||||
|
throw new InvalidArgumentException(sprintf('The format "%s" is not supported.', $input->getOption('format'))); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function displayTxt(OutputInterface $output, SymfonyStyle $io, array $filesInfo) |
||||||
|
{ |
||||||
|
$errors = 0; |
||||||
|
|
||||||
|
foreach ($filesInfo as $info) { |
||||||
|
if ($info['valid'] && $output->isVerbose()) { |
||||||
|
$io->comment('<info>OK</info>'.($info['file'] ? sprintf(' in %s', $info['file']) : '')); |
||||||
|
} elseif (!$info['valid']) { |
||||||
|
++$errors; |
||||||
|
$this->renderException($io, $info['template'], $info['exception'], $info['file']); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (0 === $errors) { |
||||||
|
$io->success(sprintf('All %d Twig files contain valid syntax.', \count($filesInfo))); |
||||||
|
} else { |
||||||
|
$io->warning(sprintf('%d Twig files have valid syntax and %d contain errors.', \count($filesInfo) - $errors, $errors)); |
||||||
|
} |
||||||
|
|
||||||
|
return min($errors, 1); |
||||||
|
} |
||||||
|
|
||||||
|
private function displayJson(OutputInterface $output, array $filesInfo) |
||||||
|
{ |
||||||
|
$errors = 0; |
||||||
|
|
||||||
|
array_walk($filesInfo, function (&$v) use (&$errors) { |
||||||
|
$v['file'] = (string) $v['file']; |
||||||
|
unset($v['template']); |
||||||
|
if (!$v['valid']) { |
||||||
|
$v['message'] = $v['exception']->getMessage(); |
||||||
|
unset($v['exception']); |
||||||
|
++$errors; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
$output->writeln(json_encode($filesInfo, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)); |
||||||
|
|
||||||
|
return min($errors, 1); |
||||||
|
} |
||||||
|
|
||||||
|
private function renderException(OutputInterface $output, string $template, Error $exception, string $file = null) |
||||||
|
{ |
||||||
|
$line = $exception->getTemplateLine(); |
||||||
|
|
||||||
|
if ($file) { |
||||||
|
$output->text(sprintf('<error> ERROR </error> in %s (line %s)', $file, $line)); |
||||||
|
} else { |
||||||
|
$output->text(sprintf('<error> ERROR </error> (line %s)', $line)); |
||||||
|
} |
||||||
|
|
||||||
|
// If the line is not known (this might happen for deprecations if we fail at detecting the line for instance), |
||||||
|
// we render the message without context, to ensure the message is displayed. |
||||||
|
if ($line <= 0) { |
||||||
|
$output->text(sprintf('<error> >> %s</error> ', $exception->getRawMessage())); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
foreach ($this->getContext($template, $line) as $lineNumber => $code) { |
||||||
|
$output->text(sprintf( |
||||||
|
'%s %-6s %s', |
||||||
|
$lineNumber === $line ? '<error> >> </error>' : ' ', |
||||||
|
$lineNumber, |
||||||
|
$code |
||||||
|
)); |
||||||
|
if ($lineNumber === $line) { |
||||||
|
$output->text(sprintf('<error> >> %s</error> ', $exception->getRawMessage())); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function getContext(string $template, int $line, int $context = 3) |
||||||
|
{ |
||||||
|
$lines = explode("\n", $template); |
||||||
|
|
||||||
|
$position = max(0, $line - $context); |
||||||
|
$max = min(\count($lines), $line - 1 + $context); |
||||||
|
|
||||||
|
$result = []; |
||||||
|
while ($position < $max) { |
||||||
|
$result[$position + 1] = $lines[$position]; |
||||||
|
++$position; |
||||||
|
} |
||||||
|
|
||||||
|
return $result; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,38 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace Drupal\twig_tweak\Command; |
||||||
|
|
||||||
|
use Symfony\Component\Finder\Finder; |
||||||
|
|
||||||
|
/** |
||||||
|
* Implements twig-tweak:lint console command. |
||||||
|
*/ |
||||||
|
final class TwigLintCommand extends LintCommand { |
||||||
|
|
||||||
|
/** |
||||||
|
* {@inheritdoc} |
||||||
|
*/ |
||||||
|
protected static $defaultName = 'twig-tweak:validate'; |
||||||
|
|
||||||
|
/** |
||||||
|
* {@inheritdoc} |
||||||
|
*/ |
||||||
|
protected function configure(): void { |
||||||
|
|
||||||
|
if (!\class_exists(Finder::class)) { |
||||||
|
throw new \LogicException('To validate Twig templates you must install symfony/finder component.'); |
||||||
|
} |
||||||
|
|
||||||
|
parent::configure(); |
||||||
|
$this->setAliases(['twig-validate']); |
||||||
|
|
||||||
|
$help = <<< 'TEXT' |
||||||
|
|
||||||
|
This command only validates Twig Syntax. For checking code style |
||||||
|
consider using <info>friendsoftwig/twigcs</info> package. |
||||||
|
TEXT; |
||||||
|
|
||||||
|
$this->setHelp($this->getHelp() . $help); |
||||||
|
} |
||||||
|
|
||||||
|
} |
Loading…
Reference in new issue