* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Bridge\Twig\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\Source; /** * Command that will validate your template syntax and output encountered errors. * * @author Marc Weistroff * @author Jérôme Tamarelle */ class LintCommand extends Command { protected static $defaultName = 'lint:twig'; private $twig; /** * @param Environment $twig */ public function __construct($twig = null) { if (!$twig instanceof Environment) { @trigger_error(sprintf('Passing a command name as the first argument of "%s()" is deprecated since Symfony 3.4 and support for it will be removed in 4.0. If the command was registered by convention, make it a service instead.', __METHOD__), \E_USER_DEPRECATED); parent::__construct($twig); return; } parent::__construct(); $this->twig = $twig; } public function setTwigEnvironment(Environment $twig) { @trigger_error(sprintf('The "%s()" method is deprecated since Symfony 3.4 and will be removed in 4.0.', __METHOD__), \E_USER_DEPRECATED); $this->twig = $twig; } /** * @return Environment $twig */ protected function getTwigEnvironment() { @trigger_error(sprintf('The "%s()" method is deprecated since Symfony 3.4 and will be removed in 4.0.', __METHOD__), \E_USER_DEPRECATED); return $this->twig; } protected function configure() { $this ->setDescription('Lints a template and outputs encountered errors') ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format', 'txt') ->addArgument('filename', InputArgument::IS_ARRAY) ->setHelp(<<<'EOF' The %command.name% command lints a template and outputs to STDOUT the first encountered syntax error. You can validate the syntax of contents passed from STDIN: cat filename | php %command.full_name% Or the syntax of a file: php %command.full_name% filename Or of a whole directory: php %command.full_name% dirname php %command.full_name% dirname --format=json EOF ) ; } protected function execute(InputInterface $input, OutputInterface $output) { $io = new SymfonyStyle($input, $output); // BC to be removed in 4.0 if (__CLASS__ !== static::class) { $r = new \ReflectionMethod($this, 'getTwigEnvironment'); if (__CLASS__ !== $r->getDeclaringClass()->getName()) { @trigger_error(sprintf('Usage of method "%s" is deprecated since Symfony 3.4 and will no longer be supported in 4.0. Construct the command with its required arguments instead.', static::class.'::getTwigEnvironment'), \E_USER_DEPRECATED); $this->twig = $this->getTwigEnvironment(); } } if (null === $this->twig) { throw new \RuntimeException('The Twig environment needs to be set.'); } $filenames = $input->getArgument('filename'); if (0 === \count($filenames)) { if (0 !== ftell(\STDIN)) { throw new RuntimeException('Please provide a filename or pipe template content to STDIN.'); } $template = ''; while (!feof(\STDIN)) { $template .= fread(\STDIN, 1024); } return $this->display($input, $output, $io, [$this->validate($template, uniqid('sf_', true))]); } $filesInfo = $this->getFilesInfo($filenames); return $this->display($input, $output, $io, $filesInfo); } private function getFilesInfo(array $filenames) { $filesInfo = []; foreach ($filenames as $filename) { foreach ($this->findFiles($filename) as $file) { $filesInfo[] = $this->validate(file_get_contents($file), $file); } } return $filesInfo; } protected function findFiles($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($template, $file) { $realLoader = $this->twig->getLoader(); try { $temporaryLoader = new ArrayLoader([(string) $file => $template]); $this->twig->setLoader($temporaryLoader); $nodeTree = $this->twig->parse($this->twig->tokenize(new Source($template, (string) $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, $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, $filesInfo) { $errors = 0; foreach ($filesInfo as $info) { if ($info['valid'] && $output->isVerbose()) { $io->comment('OK'.($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, $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, $template, Error $exception, $file = null) { $line = $exception->getTemplateLine(); if ($file) { $output->text(sprintf(' ERROR in %s (line %s)', $file, $line)); } else { $output->text(sprintf(' ERROR (line %s)', $line)); } foreach ($this->getContext($template, $line) as $lineNumber => $code) { $output->text(sprintf( '%s %-6s %s', $lineNumber === $line ? ' >> ' : ' ', $lineNumber, $code )); if ($lineNumber === $line) { $output->text(sprintf(' >> %s ', $exception->getRawMessage())); } } } private function getContext($template, $line, $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; } }