From a8ae40f22ee968e1f044411fad7dd6a19b452990 Mon Sep 17 00:00:00 2001 From: Guy Sartorelli Date: Fri, 27 Oct 2023 16:39:31 +1300 Subject: [PATCH] NEW Wrap PHP Code Sniffer with a markdown parser --- .gitignore | 2 + bin/mdphpcs | 76 +++++++++++++++ composer.json | 15 +++ phpcs.default.xml | 9 ++ src/Sniffer.php | 229 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 331 insertions(+) create mode 100644 .gitignore create mode 100755 bin/mdphpcs create mode 100644 composer.json create mode 100644 phpcs.default.xml create mode 100644 src/Sniffer.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94d6d75 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +/composer.lock \ No newline at end of file diff --git a/bin/mdphpcs b/bin/mdphpcs new file mode 100755 index 0000000..7b8d9de --- /dev/null +++ b/bin/mdphpcs @@ -0,0 +1,76 @@ +#!/usr/bin/env php +run($lintLanguage, $usingExplicitStandard); + exit($exitCode); +} catch (DeepExitException $e) { + echo $e->getMessage(); + exit($e->getCode()); +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d35dd8e --- /dev/null +++ b/composer.json @@ -0,0 +1,15 @@ +{ + "name": "silverstripe/markdown-php-codesniffer", + "require": { + "squizlabs/php_codesniffer": "^3.7", + "league/commonmark": "^2.4" + }, + "autoload": { + "psr-4": { + "SilverStripe\\MD_PHP_CodeSniffer\\": "src/" + } + }, + "bin": [ + "bin/mdphpcs" + ] +} diff --git a/phpcs.default.xml b/phpcs.default.xml new file mode 100644 index 0000000..cc64cd1 --- /dev/null +++ b/phpcs.default.xml @@ -0,0 +1,9 @@ + + + PSR12 pared down to what's sensible for code blocks in markdown + + + + + + \ No newline at end of file diff --git a/src/Sniffer.php b/src/Sniffer.php new file mode 100644 index 0000000..8bbb7f5 --- /dev/null +++ b/src/Sniffer.php @@ -0,0 +1,229 @@ +query = new Query(); + $this->query->where(Query::type(FencedCode::class)); + $environment = new Environment(); + $environment->addExtension(new CommonMarkCoreExtension()); + $this->parser = new MarkdownParser($environment); + } + + public function run(string $lintLanguage, bool $usingExplicitStandard = false): int + { + // Prevents errors when unexpected args are passed in, and forces fixing OFF + if (!defined('PHP_CODESNIFFER_CBF')) { + define('PHP_CODESNIFFER_CBF', false); + } + + $sniffer = new Runner(); + $sniffer->checkRequirements(); + $sniffer->config = $this->prepareConfig($usingExplicitStandard, $lintLanguage); + $sniffer->init(); + $sniffer->reporter = new Reporter($sniffer->config); + + // Find all the relevant code blocks for linting + // $files = $this->findMarkdownFiles($sniffer->config, $sniffer->ruleset); + if (PHP_CODESNIFFER_VERBOSITY > 0) { + echo 'Finding markdown files... '; + } + + $files = new FileList($sniffer->config, $sniffer->ruleset); + + if (PHP_CODESNIFFER_VERBOSITY > 0) { + $numFiles = count($files); + echo "DONE ($numFiles files in queue)" . PHP_EOL; + } + + $codeBlocks = $this->findFencedCodeblocks($files, $lintLanguage); + + // Add code blocks to the file list for linting + $todo = []; + foreach ($codeBlocks as $block) { + $dummy = new DummyFile($block['content'], $sniffer->ruleset, $sniffer->config); + $dummy->path = $block['path']; + $todo[$dummy->path] = $dummy; + } + + // Do the actual linting + $numErrors = $this->sniff($sniffer, $todo); + $sniffer->reporter->printReports(); + + if ($numErrors === 0) { + // No errors found. + return 0; + /* we can't fix errors directly yet. + } else if ($sniffer->reporter->totalFixable === 0) { + // Errors found, but none of them can be fixed by PHPCBF. + return 1; + } else { + // Errors found, and some can be fixed by PHPCBF. + return 2; + */ + } + return 1; + } + + private function prepareConfig(bool $usingExplicitStandard, string $lintLanguage): Config + { + // Creating the Config object populates it with all required settings based on the phpcs/phpcbf CLI arguments provided. + $config = new Config(); + + // We don't support STDIN for passing markdown in + if ($config->stdin === true) { + // 3 is the exit code phpcs uses for errors like this + throw new DeepExitException('STDIN isn\'t supported', 3); + } + + // Ensure we can find and lint markdown files + $config->extensions = array_merge($config->extensions, ['md' => $lintLanguage]); + // We're not passing the sniffer any real files, so caching could be unreliable + $config->cache = false; + // We must sniff all "files" sequentially - asyncronous sniffing isn't supported + $config->parallel = 1; + + // If the user hasn't defined an explicit standard, and there's no default standards file to use, + // use our customised PSR12 standard + if (!$usingExplicitStandard && $config->standards === ['PEAR']) { + $config->standards = [__DIR__ . '/../phpcs.default.xml']; + } + + return $config; + } + + /** + * Finds all fenced codeblocks for the relevant language in all the markdown files + */ + private function findFencedCodeblocks(FileList $paths, string $lintLanguage): array + { + if (PHP_CODESNIFFER_VERBOSITY > 0) { + echo 'Finding fenced codeblocks... '; + } + + $blocks = []; + + /** @var string $path */ + foreach ($paths as $path => $v) { + $document = $this->parser->parse(file_get_contents($path)); + $codeBlocks = $this->query->findAll($document); + + $n = 0; + /** @var FencedCode $block */ + foreach ($codeBlocks as $block) { + if (strtoupper($block->getInfo()) !== $lintLanguage) { + continue; + } + // We only want to count relevant code blocks + $n++; + + // $startAt is the line in the md file where the ```php line sits + $startAt = $block->getStartLine(); + + // Pad the content out so we have an accurate line count, and prepend a php code opening tag + $content = str_repeat(PHP_EOL, $startAt - 1); + $content .= 'getLiteral(); + + // Report each block separately (by making the path unique) so it's treated as its own file + // This lets us lint for things like namespaces more easily + $blocks[] = [ + 'content' => $content, + 'path' => dirname($path) . '/' . basename($path, '.md') . "_{$n}" . '.md', + ]; + } + } + + if (PHP_CODESNIFFER_VERBOSITY > 0) { + $numBlocks = count($blocks); + echo "DONE ($numBlocks codeblocks in queue)" . PHP_EOL; + } + + return $blocks; + } + + /** + * Run the codesniffing rules over the identified markdown codeblocks + * + * This is very nearly a direct copy of Runner::run() + */ + private function sniff(Runner $sniffer, array $todo): int + { + // Turn all sniff errors into exceptions. + set_error_handler([$sniffer, 'handleErrors']); + + $lastDir = ''; + $numFiles = count($todo); + + // Process each "file" sequentially - running sniff in parallel isn't supported + // We're not actually running this across real files, but we should give the same output we'd get if we were. + $numProcessed = 0; + foreach ($todo as $path => $file) { + if ($file->ignored === false) { + $currDir = dirname($path); + if ($lastDir !== $currDir) { + if (PHP_CODESNIFFER_VERBOSITY > 0) { + echo 'Changing into directory ' . Common::stripBasepath($currDir, $sniffer->config->basepath) . PHP_EOL; + } + + $lastDir = $currDir; + } + + $sniffer->processFile($file); + } else if (PHP_CODESNIFFER_VERBOSITY > 0) { + echo 'Skipping ' . basename($file->path) . PHP_EOL; + } + + $numProcessed++; + $sniffer->printProgress($file, $numFiles, $numProcessed); + } + + restore_error_handler(); + + if (PHP_CODESNIFFER_VERBOSITY === 0 + && $sniffer->config->interactive === false + && $sniffer->config->showProgress === true + ) { + echo PHP_EOL . PHP_EOL; + } + + $ignoreWarnings = Config::getConfigData('ignore_warnings_on_exit'); + $ignoreErrors = Config::getConfigData('ignore_errors_on_exit'); + + $return = ($sniffer->reporter->totalErrors + $sniffer->reporter->totalWarnings); + if ($ignoreErrors !== null) { + $ignoreErrors = (bool) $ignoreErrors; + if ($ignoreErrors === true) { + $return -= $sniffer->reporter->totalErrors; + } + } + + if ($ignoreWarnings !== null) { + $ignoreWarnings = (bool) $ignoreWarnings; + if ($ignoreWarnings === true) { + $return -= $sniffer->reporter->totalWarnings; + } + } + + return $return; + } +} \ No newline at end of file