1<?php 2 3/* 4 * This file is part of the Symfony package. 5 * 6 * (c) Fabien Potencier <fabien@symfony.com> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Symfony\Component\Yaml\Command; 13 14use Symfony\Component\Console\Command\Command; 15use Symfony\Component\Console\Exception\InvalidArgumentException; 16use Symfony\Component\Console\Exception\RuntimeException; 17use Symfony\Component\Console\Input\InputArgument; 18use Symfony\Component\Console\Input\InputInterface; 19use Symfony\Component\Console\Input\InputOption; 20use Symfony\Component\Console\Output\OutputInterface; 21use Symfony\Component\Console\Style\SymfonyStyle; 22use Symfony\Component\Yaml\Exception\ParseException; 23use Symfony\Component\Yaml\Parser; 24use Symfony\Component\Yaml\Yaml; 25 26/** 27 * Validates YAML files syntax and outputs encountered errors. 28 * 29 * @author Grégoire Pineau <lyrixx@lyrixx.info> 30 * @author Robin Chalas <robin.chalas@gmail.com> 31 */ 32class LintCommand extends Command 33{ 34 protected static $defaultName = 'lint:yaml'; 35 36 private $parser; 37 private $format; 38 private $displayCorrectFiles; 39 private $directoryIteratorProvider; 40 private $isReadableProvider; 41 42 public function __construct(string $name = null, callable $directoryIteratorProvider = null, callable $isReadableProvider = null) 43 { 44 parent::__construct($name); 45 46 $this->directoryIteratorProvider = $directoryIteratorProvider; 47 $this->isReadableProvider = $isReadableProvider; 48 } 49 50 /** 51 * {@inheritdoc} 52 */ 53 protected function configure() 54 { 55 $this 56 ->setDescription('Lint a file and outputs encountered errors') 57 ->addArgument('filename', InputArgument::IS_ARRAY, 'A file, a directory or "-" for reading from STDIN') 58 ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format', 'txt') 59 ->addOption('parse-tags', null, InputOption::VALUE_NONE, 'Parse custom tags') 60 ->setHelp(<<<EOF 61The <info>%command.name%</info> command lints a YAML file and outputs to STDOUT 62the first encountered syntax error. 63 64You can validates YAML contents passed from STDIN: 65 66 <info>cat filename | php %command.full_name% -</info> 67 68You can also validate the syntax of a file: 69 70 <info>php %command.full_name% filename</info> 71 72Or of a whole directory: 73 74 <info>php %command.full_name% dirname</info> 75 <info>php %command.full_name% dirname --format=json</info> 76 77EOF 78 ) 79 ; 80 } 81 82 protected function execute(InputInterface $input, OutputInterface $output) 83 { 84 $io = new SymfonyStyle($input, $output); 85 $filenames = (array) $input->getArgument('filename'); 86 $this->format = $input->getOption('format'); 87 $this->displayCorrectFiles = $output->isVerbose(); 88 $flags = $input->getOption('parse-tags') ? Yaml::PARSE_CUSTOM_TAGS : 0; 89 90 if (['-'] === $filenames) { 91 return $this->display($io, [$this->validate(file_get_contents('php://stdin'), $flags)]); 92 } 93 94 // @deprecated to be removed in 5.0 95 if (!$filenames) { 96 if (0 === ftell(\STDIN)) { 97 @trigger_error('Piping content from STDIN to the "lint:yaml" command without passing the dash symbol "-" as argument is deprecated since Symfony 4.4.', \E_USER_DEPRECATED); 98 99 return $this->display($io, [$this->validate(file_get_contents('php://stdin'), $flags)]); 100 } 101 102 throw new RuntimeException('Please provide a filename or pipe file content to STDIN.'); 103 } 104 105 $filesInfo = []; 106 foreach ($filenames as $filename) { 107 if (!$this->isReadable($filename)) { 108 throw new RuntimeException(sprintf('File or directory "%s" is not readable.', $filename)); 109 } 110 111 foreach ($this->getFiles($filename) as $file) { 112 $filesInfo[] = $this->validate(file_get_contents($file), $flags, $file); 113 } 114 } 115 116 return $this->display($io, $filesInfo); 117 } 118 119 private function validate(string $content, int $flags, string $file = null) 120 { 121 $prevErrorHandler = set_error_handler(function ($level, $message, $file, $line) use (&$prevErrorHandler) { 122 if (\E_USER_DEPRECATED === $level) { 123 throw new ParseException($message, $this->getParser()->getRealCurrentLineNb() + 1); 124 } 125 126 return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false; 127 }); 128 129 try { 130 $this->getParser()->parse($content, Yaml::PARSE_CONSTANT | $flags); 131 } catch (ParseException $e) { 132 return ['file' => $file, 'line' => $e->getParsedLine(), 'valid' => false, 'message' => $e->getMessage()]; 133 } finally { 134 restore_error_handler(); 135 } 136 137 return ['file' => $file, 'valid' => true]; 138 } 139 140 private function display(SymfonyStyle $io, array $files): int 141 { 142 switch ($this->format) { 143 case 'txt': 144 return $this->displayTxt($io, $files); 145 case 'json': 146 return $this->displayJson($io, $files); 147 default: 148 throw new InvalidArgumentException(sprintf('The format "%s" is not supported.', $this->format)); 149 } 150 } 151 152 private function displayTxt(SymfonyStyle $io, array $filesInfo): int 153 { 154 $countFiles = \count($filesInfo); 155 $erroredFiles = 0; 156 $suggestTagOption = false; 157 158 foreach ($filesInfo as $info) { 159 if ($info['valid'] && $this->displayCorrectFiles) { 160 $io->comment('<info>OK</info>'.($info['file'] ? sprintf(' in %s', $info['file']) : '')); 161 } elseif (!$info['valid']) { 162 ++$erroredFiles; 163 $io->text('<error> ERROR </error>'.($info['file'] ? sprintf(' in %s', $info['file']) : '')); 164 $io->text(sprintf('<error> >> %s</error>', $info['message'])); 165 166 if (false !== strpos($info['message'], 'PARSE_CUSTOM_TAGS')) { 167 $suggestTagOption = true; 168 } 169 } 170 } 171 172 if (0 === $erroredFiles) { 173 $io->success(sprintf('All %d YAML files contain valid syntax.', $countFiles)); 174 } else { 175 $io->warning(sprintf('%d YAML files have valid syntax and %d contain errors.%s', $countFiles - $erroredFiles, $erroredFiles, $suggestTagOption ? ' Use the --parse-tags option if you want parse custom tags.' : '')); 176 } 177 178 return min($erroredFiles, 1); 179 } 180 181 private function displayJson(SymfonyStyle $io, array $filesInfo): int 182 { 183 $errors = 0; 184 185 array_walk($filesInfo, function (&$v) use (&$errors) { 186 $v['file'] = (string) $v['file']; 187 if (!$v['valid']) { 188 ++$errors; 189 } 190 191 if (isset($v['message']) && false !== strpos($v['message'], 'PARSE_CUSTOM_TAGS')) { 192 $v['message'] .= ' Use the --parse-tags option if you want parse custom tags.'; 193 } 194 }); 195 196 $io->writeln(json_encode($filesInfo, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)); 197 198 return min($errors, 1); 199 } 200 201 private function getFiles(string $fileOrDirectory): iterable 202 { 203 if (is_file($fileOrDirectory)) { 204 yield new \SplFileInfo($fileOrDirectory); 205 206 return; 207 } 208 209 foreach ($this->getDirectoryIterator($fileOrDirectory) as $file) { 210 if (!\in_array($file->getExtension(), ['yml', 'yaml'])) { 211 continue; 212 } 213 214 yield $file; 215 } 216 } 217 218 private function getParser(): Parser 219 { 220 if (!$this->parser) { 221 $this->parser = new Parser(); 222 } 223 224 return $this->parser; 225 } 226 227 private function getDirectoryIterator(string $directory): iterable 228 { 229 $default = function ($directory) { 230 return new \RecursiveIteratorIterator( 231 new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS), 232 \RecursiveIteratorIterator::LEAVES_ONLY 233 ); 234 }; 235 236 if (null !== $this->directoryIteratorProvider) { 237 return ($this->directoryIteratorProvider)($directory, $default); 238 } 239 240 return $default($directory); 241 } 242 243 private function isReadable(string $fileOrDirectory): bool 244 { 245 $default = function ($fileOrDirectory) { 246 return is_readable($fileOrDirectory); 247 }; 248 249 if (null !== $this->isReadableProvider) { 250 return ($this->isReadableProvider)($fileOrDirectory, $default); 251 } 252 253 return $default($fileOrDirectory); 254 } 255} 256