vendor/symfony/twig-bridge/Command/LintCommand.php line 239

Open in your IDE?
  1. <?php
  2. /*
  3.  * This file is part of the Symfony package.
  4.  *
  5.  * (c) Fabien Potencier <fabien@symfony.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. namespace Symfony\Bridge\Twig\Command;
  11. use Symfony\Component\Console\CI\GithubActionReporter;
  12. use Symfony\Component\Console\Command\Command;
  13. use Symfony\Component\Console\Completion\CompletionInput;
  14. use Symfony\Component\Console\Completion\CompletionSuggestions;
  15. use Symfony\Component\Console\Exception\InvalidArgumentException;
  16. use Symfony\Component\Console\Exception\RuntimeException;
  17. use Symfony\Component\Console\Input\InputArgument;
  18. use Symfony\Component\Console\Input\InputInterface;
  19. use Symfony\Component\Console\Input\InputOption;
  20. use Symfony\Component\Console\Output\OutputInterface;
  21. use Symfony\Component\Console\Style\SymfonyStyle;
  22. use Symfony\Component\Finder\Finder;
  23. use Twig\Environment;
  24. use Twig\Error\Error;
  25. use Twig\Loader\ArrayLoader;
  26. use Twig\Loader\FilesystemLoader;
  27. use Twig\Source;
  28. /**
  29.  * Command that will validate your template syntax and output encountered errors.
  30.  *
  31.  * @author Marc Weistroff <marc.weistroff@sensiolabs.com>
  32.  * @author Jérôme Tamarelle <jerome@tamarelle.net>
  33.  */
  34. class LintCommand extends Command
  35. {
  36.     protected static $defaultName 'lint:twig';
  37.     protected static $defaultDescription 'Lint a Twig template and outputs encountered errors';
  38.     private $twig;
  39.     /**
  40.      * @var string|null
  41.      */
  42.     private $format;
  43.     public function __construct(Environment $twig)
  44.     {
  45.         parent::__construct();
  46.         $this->twig $twig;
  47.     }
  48.     protected function configure()
  49.     {
  50.         $this
  51.             ->setDescription(self::$defaultDescription)
  52.             ->addOption('format'nullInputOption::VALUE_REQUIRED'The output format')
  53.             ->addOption('show-deprecations'nullInputOption::VALUE_NONE'Show deprecations as errors')
  54.             ->addArgument('filename'InputArgument::IS_ARRAY'A file, a directory or "-" for reading from STDIN')
  55.             ->setHelp(<<<'EOF'
  56. The <info>%command.name%</info> command lints a template and outputs to STDOUT
  57. the first encountered syntax error.
  58. You can validate the syntax of contents passed from STDIN:
  59.   <info>cat filename | php %command.full_name% -</info>
  60. Or the syntax of a file:
  61.   <info>php %command.full_name% filename</info>
  62. Or of a whole directory:
  63.   <info>php %command.full_name% dirname</info>
  64.   <info>php %command.full_name% dirname --format=json</info>
  65. EOF
  66.             )
  67.         ;
  68.     }
  69.     protected function execute(InputInterface $inputOutputInterface $output)
  70.     {
  71.         $io = new SymfonyStyle($input$output);
  72.         $filenames $input->getArgument('filename');
  73.         $showDeprecations $input->getOption('show-deprecations');
  74.         $this->format $input->getOption('format');
  75.         if (null === $this->format) {
  76.             $this->format GithubActionReporter::isGithubActionEnvironment() ? 'github' 'txt';
  77.         }
  78.         if (['-'] === $filenames) {
  79.             return $this->display($input$output$io, [$this->validate(file_get_contents('php://stdin'), uniqid('sf_'true))]);
  80.         }
  81.         if (!$filenames) {
  82.             $loader $this->twig->getLoader();
  83.             if ($loader instanceof FilesystemLoader) {
  84.                 $paths = [];
  85.                 foreach ($loader->getNamespaces() as $namespace) {
  86.                     $paths[] = $loader->getPaths($namespace);
  87.                 }
  88.                 $filenames array_merge(...$paths);
  89.             }
  90.             if (!$filenames) {
  91.                 throw new RuntimeException('Please provide a filename or pipe template content to STDIN.');
  92.             }
  93.         }
  94.         if ($showDeprecations) {
  95.             $prevErrorHandler set_error_handler(static function ($level$message$file$line) use (&$prevErrorHandler) {
  96.                 if (\E_USER_DEPRECATED === $level) {
  97.                     $templateLine 0;
  98.                     if (preg_match('/ at line (\d+)[ .]/'$message$matches)) {
  99.                         $templateLine $matches[1];
  100.                     }
  101.                     throw new Error($message$templateLine);
  102.                 }
  103.                 return $prevErrorHandler $prevErrorHandler($level$message$file$line) : false;
  104.             });
  105.         }
  106.         try {
  107.             $filesInfo $this->getFilesInfo($filenames);
  108.         } finally {
  109.             if ($showDeprecations) {
  110.                 restore_error_handler();
  111.             }
  112.         }
  113.         return $this->display($input$output$io$filesInfo);
  114.     }
  115.     private function getFilesInfo(array $filenames): array
  116.     {
  117.         $filesInfo = [];
  118.         foreach ($filenames as $filename) {
  119.             foreach ($this->findFiles($filename) as $file) {
  120.                 $filesInfo[] = $this->validate(file_get_contents($file), $file);
  121.             }
  122.         }
  123.         return $filesInfo;
  124.     }
  125.     protected function findFiles(string $filename)
  126.     {
  127.         if (is_file($filename)) {
  128.             return [$filename];
  129.         } elseif (is_dir($filename)) {
  130.             return Finder::create()->files()->in($filename)->name('*.twig');
  131.         }
  132.         throw new RuntimeException(sprintf('File or directory "%s" is not readable.'$filename));
  133.     }
  134.     private function validate(string $templatestring $file): array
  135.     {
  136.         $realLoader $this->twig->getLoader();
  137.         try {
  138.             $temporaryLoader = new ArrayLoader([$file => $template]);
  139.             $this->twig->setLoader($temporaryLoader);
  140.             $nodeTree $this->twig->parse($this->twig->tokenize(new Source($template$file)));
  141.             $this->twig->compile($nodeTree);
  142.             $this->twig->setLoader($realLoader);
  143.         } catch (Error $e) {
  144.             $this->twig->setLoader($realLoader);
  145.             return ['template' => $template'file' => $file'line' => $e->getTemplateLine(), 'valid' => false'exception' => $e];
  146.         }
  147.         return ['template' => $template'file' => $file'valid' => true];
  148.     }
  149.     private function display(InputInterface $inputOutputInterface $outputSymfonyStyle $io, array $files)
  150.     {
  151.         switch ($this->format) {
  152.             case 'txt':
  153.                 return $this->displayTxt($output$io$files);
  154.             case 'json':
  155.                 return $this->displayJson($output$files);
  156.             case 'github':
  157.                 return $this->displayTxt($output$io$filestrue);
  158.             default:
  159.                 throw new InvalidArgumentException(sprintf('The format "%s" is not supported.'$input->getOption('format')));
  160.         }
  161.     }
  162.     private function displayTxt(OutputInterface $outputSymfonyStyle $io, array $filesInfobool $errorAsGithubAnnotations false)
  163.     {
  164.         $errors 0;
  165.         $githubReporter $errorAsGithubAnnotations ? new GithubActionReporter($output) : null;
  166.         foreach ($filesInfo as $info) {
  167.             if ($info['valid'] && $output->isVerbose()) {
  168.                 $io->comment('<info>OK</info>'.($info['file'] ? sprintf(' in %s'$info['file']) : ''));
  169.             } elseif (!$info['valid']) {
  170.                 ++$errors;
  171.                 $this->renderException($io$info['template'], $info['exception'], $info['file'], $githubReporter);
  172.             }
  173.         }
  174.         if (=== $errors) {
  175.             $io->success(sprintf('All %d Twig files contain valid syntax.'\count($filesInfo)));
  176.         } else {
  177.             $io->warning(sprintf('%d Twig files have valid syntax and %d contain errors.'\count($filesInfo) - $errors$errors));
  178.         }
  179.         return min($errors1);
  180.     }
  181.     private function displayJson(OutputInterface $output, array $filesInfo)
  182.     {
  183.         $errors 0;
  184.         array_walk($filesInfo, function (&$v) use (&$errors) {
  185.             $v['file'] = (string) $v['file'];
  186.             unset($v['template']);
  187.             if (!$v['valid']) {
  188.                 $v['message'] = $v['exception']->getMessage();
  189.                 unset($v['exception']);
  190.                 ++$errors;
  191.             }
  192.         });
  193.         $output->writeln(json_encode($filesInfo\JSON_PRETTY_PRINT \JSON_UNESCAPED_SLASHES));
  194.         return min($errors1);
  195.     }
  196.     private function renderException(SymfonyStyle $outputstring $templateError $exceptionstring $file nullGithubActionReporter $githubReporter null)
  197.     {
  198.         $line $exception->getTemplateLine();
  199.         if ($githubReporter) {
  200.             $githubReporter->error($exception->getRawMessage(), $file$line <= null $line);
  201.         }
  202.         if ($file) {
  203.             $output->text(sprintf('<error> ERROR </error> in %s (line %s)'$file$line));
  204.         } else {
  205.             $output->text(sprintf('<error> ERROR </error> (line %s)'$line));
  206.         }
  207.         // If the line is not known (this might happen for deprecations if we fail at detecting the line for instance),
  208.         // we render the message without context, to ensure the message is displayed.
  209.         if ($line <= 0) {
  210.             $output->text(sprintf('<error> >> %s</error> '$exception->getRawMessage()));
  211.             return;
  212.         }
  213.         foreach ($this->getContext($template$line) as $lineNumber => $code) {
  214.             $output->text(sprintf(
  215.                 '%s %-6s %s',
  216.                 $lineNumber === $line '<error> >> </error>' '    ',
  217.                 $lineNumber,
  218.                 $code
  219.             ));
  220.             if ($lineNumber === $line) {
  221.                 $output->text(sprintf('<error> >> %s</error> '$exception->getRawMessage()));
  222.             }
  223.         }
  224.     }
  225.     private function getContext(string $templateint $lineint $context 3)
  226.     {
  227.         $lines explode("\n"$template);
  228.         $position max(0$line $context);
  229.         $max min(\count($lines), $line $context);
  230.         $result = [];
  231.         while ($position $max) {
  232.             $result[$position 1] = $lines[$position];
  233.             ++$position;
  234.         }
  235.         return $result;
  236.     }
  237.     public function complete(CompletionInput $inputCompletionSuggestions $suggestions): void
  238.     {
  239.         if ($input->mustSuggestOptionValuesFor('format')) {
  240.             $suggestions->suggestValues(['txt''json''github']);
  241.         }
  242.     }
  243. }