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('Lints a file and outputs encountered errors') 57 ->addArgument('filename', InputArgument::IS_ARRAY, 'A file or a directory or 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 (0 === \count($filenames)) { 91 if (!$stdin = $this->getStdin()) { 92 throw new RuntimeException('Please provide a filename or pipe file content to STDIN.'); 93 } 94 95 return $this->display($io, [$this->validate($stdin, $flags)]); 96 } 97 98 $filesInfo = []; 99 foreach ($filenames as $filename) { 100 if (!$this->isReadable($filename)) { 101 throw new RuntimeException(sprintf('File or directory "%s" is not readable.', $filename)); 102 } 103 104 foreach ($this->getFiles($filename) as $file) { 105 $filesInfo[] = $this->validate(file_get_contents($file), $flags, $file); 106 } 107 } 108 109 return $this->display($io, $filesInfo); 110 } 111 112 private function validate($content, $flags, $file = null) 113 { 114 $prevErrorHandler = set_error_handler(function ($level, $message, $file, $line) use (&$prevErrorHandler) { 115 if (E_USER_DEPRECATED === $level) { 116 throw new ParseException($message, $this->getParser()->getRealCurrentLineNb() + 1); 117 } 118 119 return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false; 120 }); 121 122 try { 123 $this->getParser()->parse($content, Yaml::PARSE_CONSTANT | $flags); 124 } catch (ParseException $e) { 125 return ['file' => $file, 'line' => $e->getParsedLine(), 'valid' => false, 'message' => $e->getMessage()]; 126 } finally { 127 restore_error_handler(); 128 } 129 130 return ['file' => $file, 'valid' => true]; 131 } 132 133 private function display(SymfonyStyle $io, array $files) 134 { 135 switch ($this->format) { 136 case 'txt': 137 return $this->displayTxt($io, $files); 138 case 'json': 139 return $this->displayJson($io, $files); 140 default: 141 throw new InvalidArgumentException(sprintf('The format "%s" is not supported.', $this->format)); 142 } 143 } 144 145 private function displayTxt(SymfonyStyle $io, array $filesInfo) 146 { 147 $countFiles = \count($filesInfo); 148 $erroredFiles = 0; 149 150 foreach ($filesInfo as $info) { 151 if ($info['valid'] && $this->displayCorrectFiles) { 152 $io->comment('<info>OK</info>'.($info['file'] ? sprintf(' in %s', $info['file']) : '')); 153 } elseif (!$info['valid']) { 154 ++$erroredFiles; 155 $io->text('<error> ERROR </error>'.($info['file'] ? sprintf(' in %s', $info['file']) : '')); 156 $io->text(sprintf('<error> >> %s</error>', $info['message'])); 157 } 158 } 159 160 if (0 === $erroredFiles) { 161 $io->success(sprintf('All %d YAML files contain valid syntax.', $countFiles)); 162 } else { 163 $io->warning(sprintf('%d YAML files have valid syntax and %d contain errors.', $countFiles - $erroredFiles, $erroredFiles)); 164 } 165 166 return min($erroredFiles, 1); 167 } 168 169 private function displayJson(SymfonyStyle $io, array $filesInfo) 170 { 171 $errors = 0; 172 173 array_walk($filesInfo, function (&$v) use (&$errors) { 174 $v['file'] = (string) $v['file']; 175 if (!$v['valid']) { 176 ++$errors; 177 } 178 }); 179 180 $io->writeln(json_encode($filesInfo, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 181 182 return min($errors, 1); 183 } 184 185 private function getFiles($fileOrDirectory) 186 { 187 if (is_file($fileOrDirectory)) { 188 yield new \SplFileInfo($fileOrDirectory); 189 190 return; 191 } 192 193 foreach ($this->getDirectoryIterator($fileOrDirectory) as $file) { 194 if (!\in_array($file->getExtension(), ['yml', 'yaml'])) { 195 continue; 196 } 197 198 yield $file; 199 } 200 } 201 202 private function getStdin() 203 { 204 if (0 !== ftell(STDIN)) { 205 return; 206 } 207 208 $inputs = ''; 209 while (!feof(STDIN)) { 210 $inputs .= fread(STDIN, 1024); 211 } 212 213 return $inputs; 214 } 215 216 private function getParser() 217 { 218 if (!$this->parser) { 219 $this->parser = new Parser(); 220 } 221 222 return $this->parser; 223 } 224 225 private function getDirectoryIterator($directory) 226 { 227 $default = function ($directory) { 228 return new \RecursiveIteratorIterator( 229 new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS), 230 \RecursiveIteratorIterator::LEAVES_ONLY 231 ); 232 }; 233 234 if (null !== $this->directoryIteratorProvider) { 235 return ($this->directoryIteratorProvider)($directory, $default); 236 } 237 238 return $default($directory); 239 } 240 241 private function isReadable($fileOrDirectory) 242 { 243 $default = function ($fileOrDirectory) { 244 return is_readable($fileOrDirectory); 245 }; 246 247 if (null !== $this->isReadableProvider) { 248 return ($this->isReadableProvider)($fileOrDirectory, $default); 249 } 250 251 return $default($fileOrDirectory); 252 } 253} 254