diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..347aa2f
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,31 @@
+# For more information about the properties used in
+# this file, please see the EditorConfig documentation:
+# http://editorconfig.org/
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{yml,js,json,css,scss,eslintrc,feature}]
+indent_size = 2
+indent_style = space
+
+[composer.json]
+indent_size = 4
+
+# Don't perform any clean-up on thirdparty files
+
+[thirdparty/**]
+trim_trailing_whitespace = false
+insert_final_newline = false
+
+[admin/thirdparty/**]
+trim_trailing_whitespace = false
+insert_final_newline = false
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..7bf7c94
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,6 @@
+/.github export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/phpunit.xml.dist export-ignore
+/tests export-ignore
+/.editorconfig export-ignore
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..bf02210
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,11 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ ci:
+ name: CI
+ uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1
diff --git a/.github/workflows/dispatch-ci.yml b/.github/workflows/dispatch-ci.yml
new file mode 100644
index 0000000..5632551
--- /dev/null
+++ b/.github/workflows/dispatch-ci.yml
@@ -0,0 +1,16 @@
+name: Dispatch CI
+
+on:
+ # At 6:30 PM UTC, only on Sunday and Monday
+ schedule:
+ - cron: '30 18 * * 0,1'
+
+jobs:
+ dispatch-ci:
+ name: Dispatch CI
+ # Only run cron on the silverstripe account
+ if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Dispatch CI
+ uses: silverstripe/gha-dispatch-ci@v1
diff --git a/.github/workflows/keepalive.yml b/.github/workflows/keepalive.yml
new file mode 100644
index 0000000..ab90e57
--- /dev/null
+++ b/.github/workflows/keepalive.yml
@@ -0,0 +1,17 @@
+name: Keepalive
+
+on:
+ # At 6:30 PM UTC, on day 15 of the month
+ schedule:
+ - cron: '30 18 15 * *'
+ workflow_dispatch:
+
+jobs:
+ keepalive:
+ name: Keepalive
+ # Only run cron on the silverstripe account
+ if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Keepalive
+ uses: silverstripe/gha-keepalive@v1
diff --git a/.github/workflows/merge-up.yml b/.github/workflows/merge-up.yml
new file mode 100644
index 0000000..d343ab7
--- /dev/null
+++ b/.github/workflows/merge-up.yml
@@ -0,0 +1,17 @@
+name: Merge-up
+
+on:
+ # At 6:30 PM UTC, only on Thursday
+ schedule:
+ - cron: '30 18 * * 4'
+ workflow_dispatch:
+
+jobs:
+ merge-up:
+ name: Merge-up
+ # Only run cron on the silverstripe account
+ if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Merge-up
+ uses: silverstripe/gha-merge-up@v1
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..525642d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/vendor/
+/composer.lock
+.phpunit.result.cache
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f86cb0e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,29 @@
+BSD 3-Clause License
+
+Copyright (c) 2023, Silverstripe Limited - www.silverstripe.com
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
index 8b13789..af6c9d8 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,52 @@
+# Markdown PHP Codesniffer
+A wrapper around [`squizlabs/PHP_CodeSniffer`](https://github.com/squizlabs/PHP_CodeSniffer) which lets you lint PHP fenced code blocks in markdown files.
+
+## Installation
+
+Unlike `squizlabs/PHP_CodeSniffer`, this isn't intended to be installed globally - you should install it as a dev dependency of your project.
+
+```bash
+composer require --dev silverstripe/markdown-php-codesniffer
+```
+
+## Usage
+
+To sniff markdown files, run `mdphpcs` from the vendor bin directory:
+
+```bash
+# sniff a directory
+vendor/bin/mdphpcs /path/to/docs
+
+# sniff a specific file
+vendor/bin/mdphpcs /path/to/docs/file.md
+```
+
+Most of the options available with the `phpcs` and `phpcbf` commands from `squizlabs/PHP_CodeSniffer` are available with `mdphpcs` as well.
+See [PHP_CodeSniffer usage](https://github.com/squizlabs/PHP_CodeSniffer/wiki/Usage) for more details.
+
+### Fixing violations automatically
+
+Some violations can be fixed automatically, and PHP_CodeSniffer will include information about those in the CLI output. To fix them, simply pass the `--fix` option to `mdphpcs`:
+
+```bash
+vendor/bin/mdphpcs /path/to/docs --fix
+```
+
+This is the equivalent of using the `phpcbf` command on regular PHP files.
+
+### Linting other languages
+
+`squizlabs/PHP_CodeSniffer` supports linting some languages other than PHP. Theoretically that can be done with this tool as well. You'll need to pass the language (as it's written in the markdown language hint) in with the `--linting-language` option.
+
+```bash
+vendor/bin/mdphpcs /path/to/docs --linting-language=JS
+```
+
+### Linting rules
+
+If you have a [default configuration file](https://github.com/squizlabs/PHP_CodeSniffer/wiki/Advanced-Usage#using-a-default-configuration-file) or explicitly pass in a standard using the `--standard` option, those rules will be used for linting - but be aware that some rules won't be appropriate for linting code blocks.
+
+For example, the `PSR12.Files.FileHeader.HeaderPosition` rule will always fail linting, because we need to include empty lines prior to the content of the code block in the content we pass to `squizlabs/PHP_CodeSniffer` so it can correctly report the line of each violation in the original markdown file.
+
+If you don't specify a standard and have no default configuration file, the default configuration [included in this package](./phpcs.default.xml) will be used. This configuration is based on PSR12, with some exclusions that make it appropriate for use in linting code blocks.
diff --git a/bin/mdphpcs b/bin/mdphpcs
new file mode 100755
index 0000000..e7af6ae
--- /dev/null
+++ b/bin/mdphpcs
@@ -0,0 +1,75 @@
+#!/usr/bin/env php
+run($lintLanguage, $fixing, $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..5a8c847
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,29 @@
+{
+ "name": "silverstripe/markdown-php-codesniffer",
+ "description": "A wrapper around squizlabs/PHP_CodeSniffer which lets you lint PHP fenced code blocks in markdown files",
+ "keywords": [
+ "phpcs",
+ "standards",
+ "markdown",
+ "sniffer",
+ "codesniffer"
+ ],
+ "license": "BSD-3-Clause",
+ "require": {
+ "php": "^8.0",
+ "squizlabs/php_codesniffer": "^3.7",
+ "league/commonmark": "^2.4"
+ },
+ "autoload": {
+ "psr-4": {
+ "SilverStripe\\MarkdownPhpCodeSniffer\\": "src/",
+ "SilverStripe\\MarkdownPhpCodeSniffer\\Tests\\": "tests/"
+ }
+ },
+ "bin": [
+ "bin/mdphpcs"
+ ],
+ "require-dev": {
+ "phpunit/phpunit": "^9.6"
+ }
+}
diff --git a/phpcs.default.xml b/phpcs.default.xml
new file mode 100644
index 0000000..c8a26f2
--- /dev/null
+++ b/phpcs.default.xml
@@ -0,0 +1,9 @@
+
+
+ PSR12 pared down to what's sensible for code blocks in markdown
+
+
+
+
+
+
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..9bf83ab
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,7 @@
+
+
+
+
+ tests/bootstrap\.php
+
+
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..d50172b
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,12 @@
+
+
+
+
+ tests
+ tests/SnifferFixTest.php
+
+
+ tests/SnifferFixTest.php
+
+
+
diff --git a/src/CodeBlock.php b/src/CodeBlock.php
new file mode 100644
index 0000000..3f01148
--- /dev/null
+++ b/src/CodeBlock.php
@@ -0,0 +1,46 @@
+path = $path;
+ $this->realPath = $realPath;
+ $this->num = $num;
+ }
+
+ public function cleanUp()
+ {
+ $this->finalContent = $this->content ?? '';
+ parent::cleanUp();
+ }
+
+ public function getContent(): ?string
+ {
+ if ($this->content) {
+ return $this->content;
+ }
+
+ return $this->finalContent;
+ }
+}
diff --git a/src/FixerReport.php b/src/FixerReport.php
new file mode 100644
index 0000000..e86d5ea
--- /dev/null
+++ b/src/FixerReport.php
@@ -0,0 +1,88 @@
+getFixableCount();
+ if ($errors !== 0) {
+ if (PHP_CODESNIFFER_VERBOSITY > 0) {
+ ob_end_clean();
+ $startTime = microtime(true);
+ echo "\t=> Fixing file: $errors/$errors violations remaining";
+ if (PHP_CODESNIFFER_VERBOSITY > 1) {
+ echo PHP_EOL;
+ }
+ }
+
+ $fixed = $phpcsFile->fixer->fixFile();
+ }
+
+ if ($phpcsFile->config->stdin === true) {
+ // Replacing STDIN, so output current file to STDOUT
+ // even if nothing was fixed. Exit here because we
+ // can't process any more than 1 file in this setup.
+ $fixedContent = $phpcsFile->fixer->getContents();
+ throw new DeepExitException($fixedContent, 1);
+ }
+
+ if ($errors === 0) {
+ return false;
+ }
+
+ if (PHP_CODESNIFFER_VERBOSITY > 0) {
+ if ($fixed === false) {
+ echo 'ERROR';
+ } else {
+ echo 'DONE';
+ }
+
+ $timeTaken = ((microtime(true) - $startTime) * 1000);
+ if ($timeTaken < 1000) {
+ $timeTaken = round($timeTaken);
+ echo " in {$timeTaken}ms" . PHP_EOL;
+ } else {
+ $timeTaken = round(($timeTaken / 1000), 2);
+ echo " in $timeTaken secs" . PHP_EOL;
+ }
+ }
+
+ // NOTE: This is the only change from the parent method!
+ // We've ripped out all of the code here which would have written changes to the file.
+ // Instead, we need to find the old content for a given block and override that with
+ // the new content. This is done back in the Sniffer class.
+
+ if (PHP_CODESNIFFER_VERBOSITY > 0) {
+ if ($fixed === true) {
+ echo "\t=> Fixed content stored in memory" . PHP_EOL;
+ }
+ ob_start();
+ }
+
+ $errorCount = $phpcsFile->getErrorCount();
+ $warningCount = $phpcsFile->getWarningCount();
+ $fixableCount = $phpcsFile->getFixableCount();
+ $fixedCount = ($errors - $fixableCount);
+ echo $report['filename'] . ">>$errorCount>>$warningCount>>$fixableCount>>$fixedCount" . PHP_EOL;
+
+ return $fixed;
+ }
+}
diff --git a/src/Sniffer.php b/src/Sniffer.php
new file mode 100644
index 0000000..feeae2d
--- /dev/null
+++ b/src/Sniffer.php
@@ -0,0 +1,296 @@
+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 $fixing, bool $usingExplicitStandard = false): int
+ {
+ // MUST be false when not fixing, and MUST be true when fixing.
+ // This affects how codesniffer treats various CLI args, changes the output, and defines how
+ // some rules are actioned.
+ if (!defined('PHP_CODESNIFFER_CBF')) {
+ define('PHP_CODESNIFFER_CBF', $fixing);
+ }
+ if (PHP_CODESNIFFER_CBF !== $fixing) {
+ throw new LogicException('PHP_CODESNIFFER_CBF was defined with an incorrect value');
+ }
+
+ $sniffer = new Runner();
+ $sniffer->checkRequirements();
+ $sniffer->config = $this->prepareConfig($usingExplicitStandard, $lintLanguage, $fixing);
+ $sniffer->init();
+ $sniffer->reporter = new Reporter($sniffer->config);
+
+ // Find all the relevant code blocks for linting
+ if (PHP_CODESNIFFER_VERBOSITY > 0) {
+ echo 'Finding markdown files... ' . PHP_EOL;
+ }
+
+ $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 CodeBlock(
+ $sniffer->ruleset,
+ $sniffer->config,
+ $block['content'],
+ $block['path'],
+ $block['realpath'],
+ $block['num']
+ );
+ $todo[] = $dummy;
+ }
+
+ // Do the actual linting
+ $numErrors = $this->sniff($sniffer, $todo);
+
+ // The dummy files have the fixed content stored - but we still need to write that to the original files.
+ // There's no good AST to markdown renderer for league/commonmark so we're just doing a bit of an ugly
+ // search and replace.
+ if ($fixing) {
+ /** @var CodeBlock $dummy */
+ foreach ($todo as $dummy) {
+ if ($dummy->getFixedCount() < 1) {
+ continue;
+ }
+
+ if (!is_file($dummy->realPath)) {
+ // 3 is the exit code phpcs uses for errors like this
+ throw new DeepExitException("Can't find file {$dummy->realPath} to set new content", 3);
+ }
+
+ /** @var FencedCode $mdBlock */
+ $mdBlock = $codeBlocks[$dummy->path]['md'];
+ $indent = str_repeat(' ', $mdBlock->getOffset());
+ // Apply indent to each line of the original block content so we can search/replace
+ $origBlockContent = preg_replace('/^/m', $indent, $mdBlock->getLiteral());
+ // Strip out temporary php opening tag and apply indent to new block content
+ $newBlockContent = preg_replace('/\s*<\?php\n?/', '', $dummy->getContent());
+ $newBlockContent = preg_replace('/^/m', $indent, $newBlockContent);
+
+ // Search for the original block content and replace it with the new block content
+ $newFileContent = str_replace($origBlockContent, $newBlockContent, file_get_contents($dummy->realPath));
+ file_put_contents($dummy->realPath, $newFileContent);
+ }
+ }
+
+ $sniffer->reporter->printReports();
+
+ // These return values are directly from Runner::runPHPCS()
+ if ($numErrors === 0) {
+ // No errors found.
+ return 0;
+ } elseif ($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;
+ }
+ }
+
+ private function prepareConfig(bool $usingExplicitStandard, string $lintLanguage, bool $fixing): Config
+ {
+ // Creating the Config object populates it with all required settings based on the phpcs/phpcbf
+ // CLI arguments provided.
+ $config = new Config();
+
+ if (defined('PHP_CODESNIFFER_IN_TESTS') && PHP_CODESNIFFER_IN_TESTS) {
+ $config->files = [str_replace('/src', '/tests/fixtures', __DIR__)];
+ }
+
+ // 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'];
+ }
+
+ // Most of these overrides are directly from Runner::runPHPCBF()
+ if ($fixing) {
+ // Override some of the command line settings that might break the fixes.
+ $config->generator = null;
+ $config->explain = false;
+ $config->interactive = false;
+ $config->cache = false;
+ $config->showSources = false;
+ $config->recordErrors = false;
+ $config->reportFile = null;
+ $config->reports = [FixerReport::class => null];
+ $config->dieOnUnknownArg = false;
+ }
+
+ 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... ' . PHP_EOL;
+ }
+
+ $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 since the namespace in an earlier block
+ // won't be counted towards a later block in the same file
+ $key = dirname($path) . '/' . basename($path, '.md') . "_{$n}" . '.md';
+ $blocks[$key] = [
+ 'content' => $content,
+ 'path' => $key,
+ 'realpath' => $path,
+ 'num' => $n,
+ 'md' => $block,
+ ];
+ }
+ }
+
+ 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 = '';
+ $numBlocks = count($todo);
+
+ // Process each block 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;
+ /** @var CodeBlock $block */
+ foreach ($todo as $block) {
+ if ($block->ignored === false) {
+ $currDir = dirname($block->realPath);
+ if ($lastDir !== $currDir) {
+ if (PHP_CODESNIFFER_VERBOSITY > 0) {
+ echo 'Changing into directory '
+ . Common::stripBasepath($currDir, $sniffer->config->basepath)
+ . PHP_EOL;
+ }
+
+ $lastDir = $currDir;
+ }
+
+ $sniffer->processFile($block);
+ } elseif (PHP_CODESNIFFER_VERBOSITY > 0) {
+ echo 'Skipping ' . basename($block->path) . PHP_EOL;
+ }
+
+ $numProcessed++;
+ $sniffer->printProgress($block, $numBlocks, $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;
+ }
+}
diff --git a/tests/CodeBlockTest.php b/tests/CodeBlockTest.php
new file mode 100644
index 0000000..604b294
--- /dev/null
+++ b/tests/CodeBlockTest.php
@@ -0,0 +1,50 @@
+assertSame('This is the content', $block->getContent());
+
+ $block->setContent('New content now');
+
+ $this->assertSame('New content now', $block->getContent());
+ }
+
+ public function testCleanup()
+ {
+ $config = new Config();
+ $block = new CodeBlock(new Ruleset($config), $config, 'This is the content', '', '', 0);
+ $block->cleanUp();
+
+ $this->assertSame('This is the content', $block->getContent());
+
+ $reflectionContent = new ReflectionProperty($block, 'content');
+ $reflectionContent->setAccessible(true);
+ $reflectionFinalContent = new ReflectionProperty($block, 'finalContent');
+ $reflectionFinalContent->setAccessible(true);
+
+ $this->assertNull($reflectionContent->getValue($block));
+ $this->assertSame('This is the content', $reflectionFinalContent->getValue($block));
+ }
+}
diff --git a/tests/SnifferFixTest.php b/tests/SnifferFixTest.php
new file mode 100644
index 0000000..c3429cb
--- /dev/null
+++ b/tests/SnifferFixTest.php
@@ -0,0 +1,66 @@
+ $v) {
+ $orig[$path] = file_get_contents($path);
+ }
+
+ try {
+ ob_start();
+ $exitCode = $sniffer->run('PHP', true, true);
+ $output = ob_get_clean();
+
+ // Validate that the files which should change did, and which shouldn't change didn't
+ foreach ($orig as $path => $content) {
+ $this->assertFileExists($path);
+
+ if (str_contains($path, 'lint-with-problems')) {
+ $this->assertFileEquals(str_replace('/fixtures/', '/expected-after-fixing/', $path), $path);
+ } else {
+ $this->assertSame($content, file_get_contents($path));
+ }
+ }
+
+ // There are no remaining auto-fixable problems
+ $this->assertSame(1, $exitCode);
+ } finally {
+ // Put the original content back
+ foreach ($orig as $path => $content) {
+ file_put_contents($path, $content);
+ }
+ }
+ }
+
+ private static function getFilesList(Sniffer $sniffer): FileList
+ {
+ $prepareConfig = new ReflectionMethod($sniffer, 'prepareConfig');
+ $prepareConfig->setAccessible(true);
+ $config = $prepareConfig->invoke($sniffer, false, 'PHP', true);
+
+ return new FileList($config, new Ruleset($config));
+ }
+}
diff --git a/tests/SnifferTest.php b/tests/SnifferTest.php
new file mode 100644
index 0000000..e5f9dfc
--- /dev/null
+++ b/tests/SnifferTest.php
@@ -0,0 +1,150 @@
+setAccessible(true);
+ $blocks = $findFencedCodeblocks->invoke($sniffer, $files, 'PHP');
+
+ $blockKey = __DIR__ . $path;
+
+ if ($exists) {
+ $this->assertArrayHasKey($blockKey, $blocks, 'block must be found');
+
+ $block = $blocks[$blockKey];
+ $this->assertSame($blockKey, $block['path'], 'block path must be correct');
+ $this->assertSame(__DIR__ . $realPath, $block['realpath'], 'block realpath must be correct');
+ $this->assertSame($num, $block['num'], 'block must be numbered correctly');
+ $this->assertSame($content, ltrim($block['content']), 'block content must be correct');
+ } else {
+ $this->assertArrayNotHasKey($blockKey, $blocks, 'block must not be found');
+ }
+ }
+
+ public function provideFindFencedBlocks()
+ {
+ return [
+ 'nothing to lint 1' => [
+ 'path' => '/fixtures/nothing-to-lint.md',
+ 'exists' => false,
+ ],
+ 'nothing to lint 2' => [
+ 'path' => '/fixtures/nothing-to-lint_1.md',
+ 'exists' => false,
+ ],
+ 'file paths all include block numbers' => [
+ 'path' => '/fixtures/lint-but-no-problems.md',
+ 'exists' => false,
+ ],
+ [
+ 'path' => '/fixtures/lint-but-no-problems_1.md',
+ 'exists' => true,
+ 'realpath' => '/fixtures/lint-but-no-problems.md',
+ 'num' => 1,
+ 'content' => <<<'MD'
+ [
+ 'path' => '/fixtures/lint-but-no-problems_2.md',
+ 'exists' => false,
+ ],
+ // No need to check lint-with-problems_1 and lint-with-problems_2 - they're functionality
+ // identical to lint-but-no-problems_1 for the purposes of this test.
+ 'language identifier not case sensitive' => [
+ 'path' => '/fixtures/lint-with-problems_3.md',
+ 'exists' => true,
+ 'realpath' => '/fixtures/lint-with-problems.md',
+ 'num' => 3,
+ 'content' => <<<'MD'
+ [
+ 'path' => '/fixtures/lint-with-problems_4.md',
+ 'exists' => true,
+ 'realpath' => '/fixtures/lint-with-problems.md',
+ 'num' => 4,
+ 'content' => <<<'MD'
+ run('PHP', false, true);
+ $output = ob_get_clean();
+
+ // There are auto-fixable problems
+ $this->assertSame(2, $exitCode);
+
+ // Check we didn't find problems where there aren't any
+ $this->assertStringNotContainsString('nothing-to-lint', $output, 'nothing to lint, so nothing found');
+ $this->assertStringNotContainsString('lint-but-no-problems', $output, 'linted but no problems found');
+ $this->assertStringNotContainsString(
+ 'lint-with-problems_2',
+ $output,
+ 'that code block has no linting problems'
+ );
+
+ // Check we did find problems where there are plenty
+ $this->assertStringContainsString('lint-with-problems_1', $output);
+ $this->assertStringContainsString('lint-with-problems_3', $output);
+ $this->assertStringContainsString('lint-with-problems_4', $output);
+ }
+
+ private static function getFilesList(Sniffer $sniffer): FileList
+ {
+ $prepareConfig = new ReflectionMethod($sniffer, 'prepareConfig');
+ $prepareConfig->setAccessible(true);
+ $config = $prepareConfig->invoke($sniffer, false, 'PHP', true);
+
+ return new FileList($config, new Ruleset($config));
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..7713bfa
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,28 @@
+