| 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 |
namespace WindPressDeps\Symfony\Component\Yaml\Command; |
| 12 |
|
| 13 |
use WindPressDeps\Symfony\Component\Console\CI\GithubActionReporter; |
| 14 |
use WindPressDeps\Symfony\Component\Console\Command\Command; |
| 15 |
use WindPressDeps\Symfony\Component\Console\Completion\CompletionInput; |
| 16 |
use WindPressDeps\Symfony\Component\Console\Completion\CompletionSuggestions; |
| 17 |
use WindPressDeps\Symfony\Component\Console\Exception\InvalidArgumentException; |
| 18 |
use WindPressDeps\Symfony\Component\Console\Exception\RuntimeException; |
| 19 |
use WindPressDeps\Symfony\Component\Console\Input\InputArgument; |
| 20 |
use WindPressDeps\Symfony\Component\Console\Input\InputInterface; |
| 21 |
use WindPressDeps\Symfony\Component\Console\Input\InputOption; |
| 22 |
use WindPressDeps\Symfony\Component\Console\Output\OutputInterface; |
| 23 |
use WindPressDeps\Symfony\Component\Console\Style\SymfonyStyle; |
| 24 |
use WindPressDeps\Symfony\Component\Yaml\Exception\ParseException; |
| 25 |
use WindPressDeps\Symfony\Component\Yaml\Parser; |
| 26 |
use WindPressDeps\Symfony\Component\Yaml\Yaml; |
| 27 |
/** |
| 28 |
* Validates YAML files syntax and outputs encountered errors. |
| 29 |
* |
| 30 |
* @author Grégoire Pineau <lyrixx@lyrixx.info> |
| 31 |
* @author Robin Chalas <robin.chalas@gmail.com> |
| 32 |
*/ |
| 33 |
class LintCommand extends Command |
| 34 |
{ |
| 35 |
protected static $defaultName = 'lint:yaml'; |
| 36 |
protected static $defaultDescription = 'Lint a YAML file and outputs encountered errors'; |
| 37 |
private $parser; |
| 38 |
private $format; |
| 39 |
private $displayCorrectFiles; |
| 40 |
private $directoryIteratorProvider; |
| 41 |
private $isReadableProvider; |
| 42 |
public function __construct(?string $name = null, ?callable $directoryIteratorProvider = null, ?callable $isReadableProvider = null) |
| 43 |
{ |
| 44 |
parent::__construct($name); |
| 45 |
$this->directoryIteratorProvider = $directoryIteratorProvider; |
| 46 |
$this->isReadableProvider = $isReadableProvider; |
| 47 |
} |
| 48 |
/** |
| 49 |
* {@inheritdoc} |
| 50 |
*/ |
| 51 |
protected function configure() |
| 52 |
{ |
| 53 |
$this->setDescription(self::$defaultDescription)->addArgument('filename', InputArgument::IS_ARRAY, 'A file, a directory or "-" for reading from STDIN')->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format')->addOption('exclude', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Path(s) to exclude')->addOption('parse-tags', null, InputOption::VALUE_NEGATABLE, 'Parse custom tags', null)->setHelp(<<<EOF |
| 54 |
The <info>%command.name%</info> command lints a YAML file and outputs to STDOUT |
| 55 |
the first encountered syntax error. |
| 56 |
|
| 57 |
You can validates YAML contents passed from STDIN: |
| 58 |
|
| 59 |
<info>cat filename | php %command.full_name% -</info> |
| 60 |
|
| 61 |
You can also validate the syntax of a file: |
| 62 |
|
| 63 |
<info>php %command.full_name% filename</info> |
| 64 |
|
| 65 |
Or of a whole directory: |
| 66 |
|
| 67 |
<info>php %command.full_name% dirname</info> |
| 68 |
<info>php %command.full_name% dirname --format=json</info> |
| 69 |
|
| 70 |
You can also exclude one or more specific files: |
| 71 |
|
| 72 |
<info>php %command.full_name% dirname --exclude="dirname/foo.yaml" --exclude="dirname/bar.yaml"</info> |
| 73 |
|
| 74 |
EOF |
| 75 |
); |
| 76 |
} |
| 77 |
protected function execute(InputInterface $input, OutputInterface $output) |
| 78 |
{ |
| 79 |
$io = new SymfonyStyle($input, $output); |
| 80 |
$filenames = (array) $input->getArgument('filename'); |
| 81 |
$excludes = $input->getOption('exclude'); |
| 82 |
$this->format = $input->getOption('format'); |
| 83 |
$flags = $input->getOption('parse-tags'); |
| 84 |
if ('github' === $this->format && !class_exists(GithubActionReporter::class)) { |
| 85 |
throw new \InvalidArgumentException('The "github" format is only available since "symfony/console" >= 5.3.'); |
| 86 |
} |
| 87 |
if (null === $this->format) { |
| 88 |
// Autodetect format according to CI environment |
| 89 |
$this->format = class_exists(GithubActionReporter::class) && GithubActionReporter::isGithubActionEnvironment() ? 'github' : 'txt'; |
| 90 |
} |
| 91 |
$flags = $flags ? Yaml::PARSE_CUSTOM_TAGS : 0; |
| 92 |
$this->displayCorrectFiles = $output->isVerbose(); |
| 93 |
if (['-'] === $filenames) { |
| 94 |
return $this->display($io, [$this->validate(file_get_contents('php://stdin'), $flags)]); |
| 95 |
} |
| 96 |
if (!$filenames) { |
| 97 |
throw new RuntimeException('Please provide a filename or pipe file content to STDIN.'); |
| 98 |
} |
| 99 |
$filesInfo = []; |
| 100 |
foreach ($filenames as $filename) { |
| 101 |
if (!$this->isReadable($filename)) { |
| 102 |
throw new RuntimeException(sprintf('File or directory "%s" is not readable.', $filename)); |
| 103 |
} |
| 104 |
foreach ($this->getFiles($filename) as $file) { |
| 105 |
if (!\in_array($file->getPathname(), $excludes, \true)) { |
| 106 |
$filesInfo[] = $this->validate(file_get_contents($file), $flags, $file); |
| 107 |
} |
| 108 |
} |
| 109 |
} |
| 110 |
return $this->display($io, $filesInfo); |
| 111 |
} |
| 112 |
private function validate(string $content, int $flags, ?string $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 |
return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : \false; |
| 119 |
}); |
| 120 |
try { |
| 121 |
$this->getParser()->parse($content, Yaml::PARSE_CONSTANT | $flags); |
| 122 |
} catch (ParseException $e) { |
| 123 |
return ['file' => $file, 'line' => $e->getParsedLine(), 'valid' => \false, 'message' => $e->getMessage()]; |
| 124 |
} finally { |
| 125 |
restore_error_handler(); |
| 126 |
} |
| 127 |
return ['file' => $file, 'valid' => \true]; |
| 128 |
} |
| 129 |
private function display(SymfonyStyle $io, array $files): int |
| 130 |
{ |
| 131 |
switch ($this->format) { |
| 132 |
case 'txt': |
| 133 |
return $this->displayTxt($io, $files); |
| 134 |
case 'json': |
| 135 |
return $this->displayJson($io, $files); |
| 136 |
case 'github': |
| 137 |
return $this->displayTxt($io, $files, \true); |
| 138 |
default: |
| 139 |
throw new InvalidArgumentException(sprintf('The format "%s" is not supported.', $this->format)); |
| 140 |
} |
| 141 |
} |
| 142 |
private function displayTxt(SymfonyStyle $io, array $filesInfo, bool $errorAsGithubAnnotations = \false): int |
| 143 |
{ |
| 144 |
$countFiles = \count($filesInfo); |
| 145 |
$erroredFiles = 0; |
| 146 |
$suggestTagOption = \false; |
| 147 |
if ($errorAsGithubAnnotations) { |
| 148 |
$githubReporter = new GithubActionReporter($io); |
| 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 |
if (\false !== strpos($info['message'], 'PARSE_CUSTOM_TAGS')) { |
| 158 |
$suggestTagOption = \true; |
| 159 |
} |
| 160 |
if ($errorAsGithubAnnotations) { |
| 161 |
$githubReporter->error($info['message'], $info['file'] ?? 'php://stdin', $info['line']); |
| 162 |
} |
| 163 |
} |
| 164 |
} |
| 165 |
if (0 === $erroredFiles) { |
| 166 |
$io->success(sprintf('All %d YAML files contain valid syntax.', $countFiles)); |
| 167 |
} else { |
| 168 |
$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.' : '')); |
| 169 |
} |
| 170 |
return min($erroredFiles, 1); |
| 171 |
} |
| 172 |
private function displayJson(SymfonyStyle $io, array $filesInfo): int |
| 173 |
{ |
| 174 |
$errors = 0; |
| 175 |
array_walk($filesInfo, function (&$v) use (&$errors) { |
| 176 |
$v['file'] = (string) $v['file']; |
| 177 |
if (!$v['valid']) { |
| 178 |
++$errors; |
| 179 |
} |
| 180 |
if (isset($v['message']) && \false !== strpos($v['message'], 'PARSE_CUSTOM_TAGS')) { |
| 181 |
$v['message'] .= ' Use the --parse-tags option if you want parse custom tags.'; |
| 182 |
} |
| 183 |
}); |
| 184 |
$io->writeln(json_encode($filesInfo, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)); |
| 185 |
return min($errors, 1); |
| 186 |
} |
| 187 |
private function getFiles(string $fileOrDirectory): iterable |
| 188 |
{ |
| 189 |
if (is_file($fileOrDirectory)) { |
| 190 |
yield new \SplFileInfo($fileOrDirectory); |
| 191 |
return; |
| 192 |
} |
| 193 |
foreach ($this->getDirectoryIterator($fileOrDirectory) as $file) { |
| 194 |
if (!\in_array($file->getExtension(), ['yml', 'yaml'])) { |
| 195 |
continue; |
| 196 |
} |
| 197 |
yield $file; |
| 198 |
} |
| 199 |
} |
| 200 |
private function getParser(): Parser |
| 201 |
{ |
| 202 |
if (!$this->parser) { |
| 203 |
$this->parser = new Parser(); |
| 204 |
} |
| 205 |
return $this->parser; |
| 206 |
} |
| 207 |
private function getDirectoryIterator(string $directory): iterable |
| 208 |
{ |
| 209 |
$default = function ($directory) { |
| 210 |
return new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS), \RecursiveIteratorIterator::LEAVES_ONLY); |
| 211 |
}; |
| 212 |
if (null !== $this->directoryIteratorProvider) { |
| 213 |
return ($this->directoryIteratorProvider)($directory, $default); |
| 214 |
} |
| 215 |
return $default($directory); |
| 216 |
} |
| 217 |
private function isReadable(string $fileOrDirectory): bool |
| 218 |
{ |
| 219 |
$default = function ($fileOrDirectory) { |
| 220 |
return is_readable($fileOrDirectory); |
| 221 |
}; |
| 222 |
if (null !== $this->isReadableProvider) { |
| 223 |
return ($this->isReadableProvider)($fileOrDirectory, $default); |
| 224 |
} |
| 225 |
return $default($fileOrDirectory); |
| 226 |
} |
| 227 |
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void |
| 228 |
{ |
| 229 |
if ($input->mustSuggestOptionValuesFor('format')) { |
| 230 |
$suggestions->suggestValues(['txt', 'json', 'github']); |
| 231 |
} |
| 232 |
} |
| 233 |
} |
| 234 |
|