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