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

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