diff --git a/.github/workflows/quicktest.yml b/.github/workflows/quicktest.yml index 298d8cf..1b55ccc 100644 --- a/.github/workflows/quicktest.yml +++ b/.github/workflows/quicktest.yml @@ -68,10 +68,14 @@ jobs: if: ${{ matrix.php >= '7.2' }} run: composer lint - - name: Lint against parse errors (PHP < 7.2) - if: ${{ matrix.php < '7.2' }} + - name: Lint against parse errors (PHP < 7.2 && PHP >= 7.0) + if: ${{ matrix.php < '7.2' && matrix.php >= '7.0' }} run: composer lintlt72 + - name: Lint against parse errors (PHP < 7.0) + if: ${{ matrix.php < '7.0' }} + run: composer lintlt70 + # Check that any sniffs available are feature complete. # This acts as an integration test for the feature completeness script, # which is why it is run against various PHP versions. @@ -182,3 +186,86 @@ jobs: - name: Run the unit tests for the PHPCSDebug sniff run: composer test-sniff${{ steps.phpunit_script.outputs.SUFFIX }} + + # Run CI checks/tests for the DocCodeExamples script as it requires PHP >= 7.0. + quicktest-doc-code-examples: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: ['ubuntu-latest', 'windows-latest'] + php: ['7.0', 'latest'] + phpcs_version: ['dev-master'] + + include: + - os: 'ubuntu-latest' + php: '7.0' + phpcs_version: '3.1.0' + - os: 'windows-latest' + php: '7.0' + phpcs_version: '3.1.0' + + name: "QTest: PHP ${{ matrix.php }} - PHPCS ${{ matrix.phpcs_version }} (${{ matrix.os == 'windows-latest' && 'Win' || 'Linux' }})" + + steps: + - name: Prepare git to leave line endings alone + run: git config --global core.autocrlf input + + - name: Checkout code + uses: actions/checkout@v4 + + # On stable PHPCS versions, allow for PHP deprecation notices. + # Unit tests don't need to fail on those for stable releases where those issues won't get fixed anymore. + - name: Setup ini config + id: set_ini + shell: bash + run: | + if [ "${{ matrix.phpcs_version }}" != "dev-master" ]; then + echo 'PHP_INI=error_reporting=E_ALL & ~E_DEPRECATED, display_errors=On' >> "$GITHUB_OUTPUT" + else + echo 'PHP_INI=error_reporting=-1, display_errors=On' >> "$GITHUB_OUTPUT" + fi + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + ini-values: ${{ steps.set_ini.outputs.PHP_INI }} + coverage: none + + - name: 'Composer: adjust dependencies' + run: | + # Set the PHPCS version to be used in the tests. + composer require --no-update squizlabs/php_codesniffer:"${{ matrix.phpcs_version }}" --no-scripts --no-interaction + # Remove the PHPCSDevCS dependency as it has different PHPCS requirements and would block installs. + composer remove --no-update --dev phpcsstandards/phpcsdevcs --no-scripts --no-interaction + + # Install dependencies and handle caching in one go. + # @link https://github.com/marketplace/actions/install-php-dependencies-with-composer + - name: Install Composer dependencies + uses: "ramsey/composer-install@v3" + with: + # Bust the cache at least once a month - output format: YYYY-MM. + custom-cache-suffix: $(date -u "+%Y-%m") + + - name: Grab PHPUnit version + id: phpunit_version + shell: bash + # yamllint disable rule:line-length + run: echo "VERSION=$(vendor/bin/phpunit --version | grep --only-matching --max-count=1 --extended-regexp '\b[0-9]+\.[0-9]+')" >> "$GITHUB_OUTPUT" + # yamllint enable rule:line-length + + - name: Determine PHPUnit composer script to use + id: phpunit_script + shell: bash + run: | + if [ "${{ startsWith( steps.phpunit_version.outputs.VERSION, '11.' ) }}" == "true" ]; then + echo 'SUFFIX=' >> "$GITHUB_OUTPUT" + elif [ "${{ startsWith( steps.phpunit_version.outputs.VERSION, '10.' ) }}" == "true" ]; then + echo 'SUFFIX=' >> "$GITHUB_OUTPUT" + else + echo 'SUFFIX=-lte9' >> "$GITHUB_OUTPUT" + fi + + - name: Run the unit tests for the DocCodeExamples script + run: composer test-doc-code-examples${{ steps.phpunit_script.outputs.SUFFIX }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c35b256..969e183 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,9 +73,13 @@ jobs: if: ${{ matrix.php >= '7.2' }} run: composer lint -- --checkstyle | cs2pr - - name: Lint against parse errors (PHP < 7.2) - if: ${{ matrix.php < '7.2' }} - run: composer lintlt72 -- --checkstyle | cs2pr + - name: Lint against parse errors (PHP < 7.2 && PHP >= 7.0) + if: ${{ matrix.php < '7.2' && matrix.php >= '7.0' }} + run: composer lintlt72 + + - name: Lint against parse errors (PHP < 7.0) + if: ${{ matrix.php < '7.0' }} + run: composer lintlt70 # Check that any sniffs available are feature complete. # This also acts as an integration test for the feature completeness script, @@ -229,3 +233,192 @@ jobs: - name: Run the unit tests for the PHPCSDebug sniff run: composer test-sniff${{ steps.phpunit_script.outputs.SUFFIX }} + + # Run CI checks/tests for the DocCodeExamples script as it requires PHP >= 7.0. + test-doc-code-examples: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: ['ubuntu-latest', 'windows-latest'] + # IMPORTANT: test runs shouldn't fail because of PHPCS being incompatible with a PHP version. + # - PHPCS will run without errors on PHP 7.0 - 7.2 on any version. + # - PHP 7.3 needs PHPCS 3.3.1+ to run without errors. + # - PHP 7.4 needs PHPCS 3.5.0+ to run without errors. + # - PHP 8.0 needs PHPCS 3.5.7+ to run without errors. + # - PHP 8.1 needs PHPCS 3.6.1+ to run without errors. + # - PHP 8.2 needs PHPCS 3.6.1+ to run without errors. + # - PHP 8.3 needs PHPCS 3.8.0+ to run without errors (though the errors don't affect this package). + # - PHP 8.4 needs PHPCS 3.8.0+ to run without errors (officially 3.11.0, but 3.8.0 will work fine). + php: ['7.0', '7.1', '7.2'] + phpcs_version: ['3.1.0', 'dev-master'] + + include: + # Complete the matrix, while preventing issues with PHPCS versions incompatible with certain PHP versions. + - os: 'ubuntu-latest' + php: '7.3' + phpcs_version: 'dev-master' + - os: 'windows-latest' + php: '7.3' + phpcs_version: 'dev-master' + - os: 'ubuntu-latest' + php: '7.3' + phpcs_version: '3.3.1' + - os: 'windows-latest' + php: '7.3' + phpcs_version: '3.3.1' + + - os: 'ubuntu-latest' + php: '7.4' + phpcs_version: 'dev-master' + - os: 'windows-latest' + php: '7.4' + phpcs_version: 'dev-master' + - os: 'ubuntu-latest' + php: '7.4' + phpcs_version: '3.5.0' + - os: 'windows-latest' + php: '7.4' + phpcs_version: '3.5.0' + + - os: 'ubuntu-latest' + php: '8.0' + phpcs_version: 'dev-master' + - os: 'windows-latest' + php: '8.0' + phpcs_version: 'dev-master' + - os: 'ubuntu-latest' + php: '8.0' + phpcs_version: '3.5.7' + - os: 'windows-latest' + php: '8.0' + phpcs_version: '3.5.7' + + - os: 'ubuntu-latest' + php: '8.1' + phpcs_version: 'dev-master' + - os: 'windows-latest' + php: '8.1' + phpcs_version: 'dev-master' + - os: 'ubuntu-latest' + php: '8.1' + phpcs_version: '3.6.1' + - os: 'windows-latest' + php: '8.1' + phpcs_version: '3.6.1' + + - os: 'ubuntu-latest' + php: '8.2' + phpcs_version: 'dev-master' + - os: 'windows-latest' + php: '8.2' + phpcs_version: 'dev-master' + - os: 'ubuntu-latest' + php: '8.2' + phpcs_version: '3.6.1' + - os: 'windows-latest' + php: '8.2' + phpcs_version: '3.6.1' + + - os: 'ubuntu-latest' + php: '8.3' + phpcs_version: 'dev-master' + - os: 'windows-latest' + php: '8.3' + phpcs_version: 'dev-master' + - os: 'ubuntu-latest' + php: '8.3' + phpcs_version: '3.8.0' + - os: 'windows-latest' + php: '8.3' + phpcs_version: '3.8.0' + + - os: 'ubuntu-latest' + php: '8.4' + phpcs_version: 'dev-master' + - os: 'windows-latest' + php: '8.4' + phpcs_version: 'dev-master' + - os: 'ubuntu-latest' + php: '8.4' + phpcs_version: '3.8.0' + - os: 'windows-latest' + php: '8.4' + phpcs_version: '3.8.0' + + # Experimental builds. These are allowed to fail. + - os: 'ubuntu-latest' + php: '7.4' + phpcs_version: '4.x-dev' + + - os: 'ubuntu-latest' + php: '8.5' # Nightly. + phpcs_version: 'dev-master' + + name: "Test: PHP ${{ matrix.php }} - PHPCS ${{ matrix.phpcs_version }} (${{ matrix.os == 'windows-latest' && 'Win' || 'Linux' }})" + + continue-on-error: ${{ matrix.php == '8.5' || matrix.phpcs_version == '4.x-dev' }} + + steps: + - name: Prepare git to leave line endings alone + run: git config --global core.autocrlf input + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup ini config + id: set_ini + shell: bash + run: | + # On stable PHPCS versions, allow for PHP deprecation notices. + # Unit tests don't need to fail on those for stable releases where those issues won't get fixed anymore. + if [[ "${{ matrix.phpcs_version }}" != "dev-master" && "${{ matrix.phpcs_version }}" != "4.x-dev" ]]; then + echo 'PHP_INI=error_reporting=E_ALL & ~E_DEPRECATED, display_errors=On' >> "$GITHUB_OUTPUT" + else + echo 'PHP_INI=error_reporting=-1, display_errors=On' >> "$GITHUB_OUTPUT" + fi + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + ini-values: ${{ steps.set_ini.outputs.PHP_INI }} + coverage: none + + - name: 'Composer: adjust dependencies' + run: | + # Set the PHPCS version to be used in the tests. + composer require --no-update squizlabs/php_codesniffer:"${{ matrix.phpcs_version }}" --no-scripts --no-interaction + # Remove the PHPCSDevCS dependency as it has different PHPCS requirements and would block installs. + composer remove --no-update --dev phpcsstandards/phpcsdevcs --no-scripts --no-interaction + + # Install dependencies and handle caching in one go. + # @link https://github.com/marketplace/actions/install-php-dependencies-with-composer + - name: Install Composer dependencies + uses: "ramsey/composer-install@v3" + with: + composer-options: ${{ matrix.php == '8.5' && '--ignore-platform-req=php+' || '' }} + # Bust the cache at least once a month - output format: YYYY-MM. + custom-cache-suffix: $(date -u "+%Y-%m") + + - name: Grab PHPUnit version + id: phpunit_version + shell: bash + # yamllint disable rule:line-length + run: echo "VERSION=$(vendor/bin/phpunit --version | grep --only-matching --max-count=1 --extended-regexp '\b[0-9]+\.[0-9]+')" >> "$GITHUB_OUTPUT" + # yamllint enable rule:line-length + + - name: Determine PHPUnit composer script to use + id: phpunit_script + shell: bash + run: | + if [ "${{ startsWith( steps.phpunit_version.outputs.VERSION, '11.' ) }}" == "true" ]; then + echo 'SUFFIX=' >> "$GITHUB_OUTPUT" + elif [ "${{ startsWith( steps.phpunit_version.outputs.VERSION, '10.' ) }}" == "true" ]; then + echo 'SUFFIX=' >> "$GITHUB_OUTPUT" + else + echo 'SUFFIX=-lte9' >> "$GITHUB_OUTPUT" + fi + + - name: Run the unit tests for the DocCodeExamples script + run: composer test-doc-code-examples${{ steps.phpunit_script.outputs.SUFFIX }} diff --git a/README.md b/README.md index 0249b62..a2c8d01 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ This is a set of tools to assist developers of sniffs for [PHP CodeSniffer][phpc + [Stand-alone Installation](#stand-alone-installation) * [Features](#features) + [Checking whether all sniffs in a PHPCS standard are feature complete](#checking-whether-all-sniffs-in-a-phpcs-standard-are-feature-complete) + + [Checking code examples in sniff documentation are correct](#checking-code-examples-in-sniff-documentation-are-correct) + [Sniff Debugging](#sniff-debugging) + [Documentation XSD Validation](#documentation-xsd-validation) * [Contributing](#contributing) @@ -133,6 +134,49 @@ directories
` block. This means that for invalid examples, the script might miss ones that don't trigger the sniff if, in the same `` block, there is at least one example that does trigger the sniff.
+
+To use the tool, run it from the root of your standards repo like so:
+```bash
+# When installed as a project dependency:
+vendor/bin/phpcs-check-doc-examples
+
+# When installed globally with Composer:
+phpcs-check-doc-examples
+
+# When installed as a git clone or otherwise:
+php path/to/PHPCSDevTools/bin/phpcs-check-doc-examples
+```
+
+If all is good, the script will exit with code 0 and a summary of what was checked.
+
+If there are issues with the code examples, you will see error messages for each problematic example, like so:
+```text
+Errors found while processing path/to/project/StandardName/Docs/Category/SniffNameStandard.xml:
+
+ERROR: Code block is valid and PHPCS should have returned nothing, but instead it returned an error.
+Code block title: "Valid: invalid valid code example."
+Code block content: "function sniffValidationWillFail() {}"
+```
+
+#### Options
+```text
+directories|files One or more specific directories or files to examine.
+ Defaults to the directory from which the script is run.
+--exclude= Comma-delimited list of relative paths of directories to
+ exclude from the scan.
+--ignore-sniffs= Comma-delimited list of sniffs to ignore.
+--colors Enable colors in console output. (disables auto detection of color support)
+--no-colors Disable colors in console output.
+--help Print the script help page.
+-V, --version Display the current version of this script.
+```
### Sniff Debugging
diff --git a/Scripts/DocCodeExamples/Check.php b/Scripts/DocCodeExamples/Check.php
new file mode 100644
index 0000000..e07c072
--- /dev/null
+++ b/Scripts/DocCodeExamples/Check.php
@@ -0,0 +1,208 @@
+
+ */
+ protected $xmlFiles = [];
+
+ /**
+ * Constructor.
+ *
+ * @param \PHPCSDevTools\Scripts\DocCodeExamples\Config $config Configuration as passed on the
+ * command line.
+ * @param \PHPCSDevTools\Scripts\DocCodeExamples\CodeBlocksExtractor $extractor CodeBlocksExtractor instance.
+ * @param \PHP_CodeSniffer\Config $phpcsConfig PHPCS configuration object.
+ * @param \PHPCSDevTools\Scripts\Utils\Writer $writer A helper object to write output.
+ */
+ public function __construct(
+ Config $config,
+ CodeBlocksExtractor $extractor,
+ PHPCSConfig $phpcsConfig,
+ Writer $writer
+ ) {
+ $this->config = $config;
+ $this->extractor = $extractor;
+ $this->phpcsConfig = $phpcsConfig;
+ $this->writer = $writer;
+
+ // Handle excluded dirs.
+ $exclude = '(?!\.git/)';
+ if (empty($config->getProperty('excludedDirs')) === false) {
+ $excludedDirs = \array_map(
+ 'preg_quote',
+ $config->getProperty('excludedDirs'),
+ \array_fill(0, \count($config->getProperty('excludedDirs')), '`')
+ );
+ $exclude = '(?!(\.git|' . \implode('|', $excludedDirs) . ')/)';
+ }
+
+ $quotedProjectRoot = \preg_quote($config->getProperty('projectRoot') . '/', '`');
+ $docsRegex = \sprintf(self::DOC_FILTER_REGEX, $quotedProjectRoot, $exclude);
+
+ $xmlFilesInDirs = [];
+ $individualXmlFiles = [];
+
+ foreach ($config->getProperty('targetPaths') as $targetPath) {
+ if (\is_dir($targetPath)) {
+ $xmlFilesInDirs[] = (new FileList($targetPath, $config->getProperty('projectRoot'), $docsRegex))
+ ->getList();
+ }
+
+ if (\is_file($targetPath)) {
+ $individualXmlFiles[] = \str_replace($config->getProperty('projectRoot'), '', $targetPath);
+ }
+ }
+
+ if (empty($xmlFilesInDirs) === false) {
+ $xmlFilesInDirs = \call_user_func_array('array_merge', $xmlFilesInDirs);
+ }
+
+ $this->xmlFiles = array_merge($individualXmlFiles, $xmlFilesInDirs);
+ \sort($this->xmlFiles, \SORT_NATURAL);
+ }
+
+ /**
+ * Run the check for sniff documentation code examples.
+ *
+ * @return int Exit code for the script.
+ */
+ public function run(): int
+ {
+ $this->writer->toStderr($this->config->getVersion());
+
+ $exitCode = 0;
+ $validationFailedCount = 0;
+
+ if (empty($this->xmlFiles)) {
+ $message = 'ERROR: No XML documentation files found.';
+
+ if ($this->config->getProperty('showColored') === true) {
+ $message = "\033[31m$message\033[0m";
+ }
+
+ $this->writer->toStderr($message . PHP_EOL);
+ return 1;
+ }
+
+ foreach ($this->xmlFiles as $xmlDocFilePath) {
+ $xmlDocValidator = $this->getXmlDocValidator($xmlDocFilePath);
+
+ if (\in_array($xmlDocValidator->sniff, $this->config->getProperty('ignoredSniffs'), true)) {
+ continue;
+ }
+
+ $validationSuccessful = $xmlDocValidator->validate();
+
+ if ($validationSuccessful === false) {
+ $validationFailedCount++;
+ $exitCode = 1;
+ }
+ }
+
+ $xmlFilesCount = \count($this->xmlFiles);
+ $feedback = "Checked {$xmlFilesCount} XML documentation files.";
+
+ if ($xmlFilesCount === 1) {
+ $feedback = 'Checked 1 XML documentation file.';
+ }
+
+ if ($exitCode === 0) {
+ $feedback .= ' All code examples are valid.';
+
+ if ($this->config->getProperty('showColored') === true) {
+ $feedback = "\033[32m{$feedback}\033[0m";
+ }
+ } else {
+ $feedback .= " Found incorrect code examples in {$validationFailedCount}.";
+
+ if ($this->config->getProperty('showColored') === true) {
+ $feedback = "\033[31m{$feedback}\033[0m";
+ }
+ }
+
+ $this->writer->toStdout($feedback . PHP_EOL);
+
+ return $exitCode;
+ }
+
+ /**
+ * Retrieves the XML documentation validator object for a given file path.
+ *
+ * @param string $xmlDocFilePath The relative file path to the XML documentation file.
+ *
+ * @return \PHPCSDevTools\Scripts\DocCodeExamples\XmlDocValidator The XML documentation validator object.
+ */
+ protected function getXmlDocValidator(string $xmlDocFilePath): XmlDocValidator
+ {
+ return new XmlDocValidator(
+ $this->config->getProperty('projectRoot') . $xmlDocFilePath,
+ $this->extractor,
+ $this->phpcsConfig,
+ $this->writer,
+ $this->config
+ );
+ }
+}
diff --git a/Scripts/DocCodeExamples/CodeBlock.php b/Scripts/DocCodeExamples/CodeBlock.php
new file mode 100644
index 0000000..52b0519
--- /dev/null
+++ b/Scripts/DocCodeExamples/CodeBlock.php
@@ -0,0 +1,54 @@
+title = $title;
+ $this->content = $content;
+ $this->position = $position;
+ }
+}
diff --git a/Scripts/DocCodeExamples/CodeBlocksExtractor.php b/Scripts/DocCodeExamples/CodeBlocksExtractor.php
new file mode 100644
index 0000000..ea1ee2b
--- /dev/null
+++ b/Scripts/DocCodeExamples/CodeBlocksExtractor.php
@@ -0,0 +1,126 @@
+> An associative array where the keys are 'valid'
+ * and 'invalid' and the values are arrays of
+ * strings with the valid and invalid code blocks.
+ */
+ public function extract(string $xmlDocFilePath): array
+ {
+ $this->codeBlocksCounter = 0;
+ $xmlDoc = $this->loadXmlFile($xmlDocFilePath);
+
+ $codeBlocks = [];
+
+ $xpath = new \DOMXPath($xmlDoc);
+ $codeBlocks['valid'] = $this->extractCodeBlocksOfGivenType($xpath, 'Valid');
+ $codeBlocks['invalid'] = $this->extractCodeBlocksOfGivenType($xpath, 'Invalid');
+
+ return $codeBlocks;
+ }
+
+ /**
+ * Load the XML documentation file and return a DOMDocument object.
+ *
+ * @param string $xmlDocFilePath Path to the XML documentation file.
+ *
+ * @return \DOMDocument The loaded DOMDocument object.
+ *
+ * @throws \RuntimeException If the file is empty or if there are errors while loading the XML.
+ */
+ private function loadXmlFile(string $xmlDocFilePath): \DOMDocument
+ {
+ if (\filesize($xmlDocFilePath) === 0) {
+ throw new \RuntimeException("The {$xmlDocFilePath} file is empty.");
+ }
+
+ $xmlDoc = new \DOMDocument();
+
+ libxml_use_internal_errors(true);
+ $loadResult = $xmlDoc->load($xmlDocFilePath);
+
+ if ($loadResult === false) {
+ $errors = libxml_get_errors();
+ libxml_clear_errors();
+
+ $errorMessages = [];
+ foreach ($errors as $error) {
+ $errorMessages[] = sprintf(
+ 'Line %d, Column %d: %s',
+ $error->line,
+ $error->column,
+ trim($error->message)
+ );
+ }
+
+ throw new \RuntimeException(
+ sprintf(
+ "Failed to parse XML file %s. Errors:\n%s",
+ $xmlDocFilePath,
+ implode("\n", $errorMessages)
+ )
+ );
+ }
+
+ libxml_use_internal_errors(false);
+
+ return $xmlDoc;
+ }
+
+ /**
+ * Extract the code blocks of a given type ('Valid' or 'Invalid') from the XML documentation file.
+ *
+ * @param \DOMXPath $xpath The DOMXPath object to use to query the XML document.
+ * @param string $type The type of code block to extract. Either 'Valid' or 'Invalid'.
+ *
+ * @return array The extracted code blocks.
+ */
+ private function extractCodeBlocksOfGivenType(\DOMXPath $xpath, string $type): array
+ {
+ $codeBlocks = [];
+
+ $codeBlocksExpression = "//code[starts-with(@title, '{$type}')]";
+ $codeBlockElements = $xpath->query($codeBlocksExpression);
+
+ foreach ($codeBlockElements as $codeBlockElement) {
+ if ($codeBlockElement instanceof \DOMElement === false) {
+ continue;
+ }
+
+ $title = $codeBlockElement->getAttribute('title');
+ $content = \trim($codeBlockElement->nodeValue);
+ $content = \preg_replace('`?em>`', '', $content);
+ $codeBlocks[] = new CodeBlock($title, $content, $this->codeBlocksCounter++);
+ }
+
+ return $codeBlocks;
+ }
+}
diff --git a/Scripts/DocCodeExamples/Config.php b/Scripts/DocCodeExamples/Config.php
new file mode 100644
index 0000000..52f8119
--- /dev/null
+++ b/Scripts/DocCodeExamples/Config.php
@@ -0,0 +1,326 @@
+
+ */
+ protected $targetPaths = [];
+
+ /**
+ * The directories to exclude from the search.
+ *
+ * @var array
+ */
+ protected $excludedDirs = [];
+
+ /**
+ * List of sniffs to ignore.
+ *
+ * @var array
+ */
+ protected $ignoredSniffs = [];
+
+ /**
+ * Whether or not to execute the check.
+ *
+ * @var bool
+ */
+ protected $executeCheck = true;
+
+ /**
+ * Whether or not to show colored output.
+ *
+ * This is automatically detected.
+ *
+ * @var bool
+ */
+ protected $showColored;
+
+ /**
+ * Help texts.
+ *
+ * @var array>>
+ */
+ private $helpTexts = [
+ // phpcs:disable Generic.Files.LineLength.TooLong
+ 'Usage' => [
+ ['text' => 'phpcs-check-doc-examples'],
+ ['text' => 'phpcs-check-doc-examples [--exclude=] [--ignore-sniffs=] [directories|files]'],
+ ],
+ 'Options' => [
+ [
+ 'arg' => 'directories|files',
+ 'desc' => 'One or more specific directories or files to examine. Defaults to the directory from which the script is run.',
+ ],
+ [
+ 'arg' => '--exclude=',
+ 'desc' => 'Comma-delimited list of relative paths of directories to exclude from the scan.',
+ ],
+ [
+ 'arg' => '--ignore-sniffs=',
+ 'desc' => 'Comma-delimited list of sniffs to ignore.',
+ ],
+ [
+ 'arg' => '--help',
+ 'desc' => 'Print this help.',
+ ],
+ [
+ 'arg' => '-V, --version',
+ 'desc' => 'Display the current version of this script.',
+ ],
+ [
+ 'arg' => '--colors',
+ 'desc' => 'Enable colors in console output. (disables auto detection of color support)',
+ ],
+ [
+ 'arg' => '--no-colors',
+ 'desc' => 'Disable colors in console output.',
+ ],
+ ],
+ // phpcs:enable
+ ];
+
+ /**
+ * List of supported named arguments.
+ *
+ * @var array
+ */
+ private $supportedArgs = [
+ '--exclude=',
+ '--ignore-sniffs=',
+ '--help',
+ '-V',
+ '--version',
+ '--colors',
+ '--no-colors',
+ ];
+
+ /**
+ * Constructor.
+ *
+ * @param \PHPCSDevTools\Scripts\Utils\Writer $writer Writer for sending output.
+ */
+ public function __construct(Writer $writer)
+ {
+ $this->writer = $writer;
+ $this->processCliCommand();
+ }
+
+ /**
+ * Get the value of a property.
+ *
+ * @param string $propertyName The name of the property to retrieve.
+ *
+ * @return mixed The value of the property, or null if it doesn't exist.
+ */
+ public function getProperty(string $propertyName)
+ {
+ if (isset($this->{$propertyName}) === false) {
+ throw new \RuntimeException(sprintf('ERROR: Property "%s" does not exist', $propertyName));
+ }
+
+ return $this->{$propertyName};
+ }
+
+ /**
+ * Retrieve the version number of this script.
+ *
+ * @return string
+ */
+ public function getVersion()
+ {
+ $text = 'PHPCSDevTools: XML documentation code examples checker version ';
+ $text .= \file_get_contents(__DIR__ . '/../../VERSION');
+ $text .= \PHP_EOL . 'by PHPCSDevTools Contributors' . \PHP_EOL . \PHP_EOL;
+
+ return $text;
+ }
+
+ /**
+ * Process the received command arguments.
+ *
+ * @return void
+ */
+ private function processCliCommand()
+ {
+ $this->projectRoot = Helper::normalizePath(\getcwd());
+ $args = $_SERVER['argv'];
+
+ // Remove the call to the script itself.
+ \array_shift($args);
+
+ $argsFlipped = \array_flip($args);
+
+ if (isset($argsFlipped['--no-colors'])) {
+ $this->showColored = false;
+ } elseif (isset($argsFlipped['--colors'])) {
+ $this->showColored = true;
+ } else {
+ $this->showColored = HelpTextFormatter::isColorSupported();
+ }
+
+ if (isset($argsFlipped['--help'])) {
+ $helpText = HelpTextFormatter::format($this->helpTexts, $this->showColored);
+ $this->writer->toStdout($this->getVersion());
+ $this->writer->toStdout($helpText);
+ $this->executeCheck = false;
+ return;
+ }
+
+ if (isset($argsFlipped['-V']) || isset($argsFlipped['--version'])) {
+ $this->writer->toStdout($this->getVersion());
+ $this->executeCheck = false;
+ return;
+ }
+
+ foreach ($args as $arg) {
+ if (\strpos($arg, '-') === 0 && $this->isUnsupportedNamedArgument($arg) === true) {
+ throw new \RuntimeException("ERROR: Unsupported argument $arg");
+ }
+
+ if (\strpos($arg, '--exclude=') === 0) {
+ $this->excludedDirs = $this->processExcludeArgument($arg);
+ continue;
+ }
+
+ if (\strpos($arg, '--ignore-sniffs=') === 0) {
+ $this->ignoredSniffs = $this->processIgnoreSniffsArgument($arg);
+ continue;
+ }
+
+ if (isset($arg[0]) && $arg[0] !== '-') {
+ $this->targetPaths[] = $this->processTargetPathArgument($arg);
+ }
+ }
+
+ if (empty($this->targetPaths)) {
+ // If the user didn't provide a path, use the directory from which the script was run.
+ $this->targetPaths[] = $this->projectRoot;
+ }
+ }
+
+ /**
+ * Check if a named argument is unsupported.
+ *
+ * @param string $arg The argument to check.
+ *
+ * @return bool True if the argument is unsupported, false otherwise.
+ */
+ private function isUnsupportedNamedArgument(string $arg): bool
+ {
+ foreach ($this->supportedArgs as $supportedArg) {
+ if (\strpos($arg, $supportedArg) === 0) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Process the --exclude argument.
+ *
+ * @param string $arg The argument to process.
+ *
+ * @return array
+ */
+ protected function processExcludeArgument(string $arg): array
+ {
+ $excludeString = \substr($arg, 10);
+ if (empty($excludeString)) {
+ return [];
+ }
+
+ $excludeString = \trim($excludeString, '"\''); // Strip potential quotes.
+ $excludedDirs = \explode(',', $excludeString);
+
+ $excludedDirs = \array_map(
+ function ($dir) {
+ $dir = \trim($dir, '/');
+ return Helper::normalizePath($dir);
+ },
+ $excludedDirs
+ );
+
+ return $excludedDirs;
+ }
+
+ /**
+ * Process the --ignore-sniffs argument.
+ *
+ * @param string $arg The argument to process.
+ *
+ * @return array
+ */
+ protected function processIgnoreSniffsArgument(string $arg): array
+ {
+ $ignoredSniffs = \substr($arg, 16);
+ if (empty($ignoredSniffs)) {
+ return [];
+ }
+
+ $ignoredSniffs = \trim($ignoredSniffs, '"\'');
+ $ignoredSniffs = \explode(',', $ignoredSniffs);
+
+ return $ignoredSniffs;
+ }
+
+ /**
+ * Process the target path argument.
+ *
+ * @param string $arg The argument to process.
+ *
+ * @return string
+ * @throws \RuntimeException If the path does not exist.
+ */
+ protected function processTargetPathArgument(string $arg): string
+ {
+ $realpath = \realpath($arg);
+
+ if ($realpath === false) {
+ throw new \RuntimeException(\sprintf('ERROR: Target path %s does not exist', $arg));
+ }
+
+ return Helper::normalizePath($realpath);
+ }
+}
diff --git a/Scripts/DocCodeExamples/Functions.php b/Scripts/DocCodeExamples/Functions.php
new file mode 100644
index 0000000..2fb0168
--- /dev/null
+++ b/Scripts/DocCodeExamples/Functions.php
@@ -0,0 +1,27 @@
+ $files An array of possible file paths.
+ *
+ * @return void
+ */
+function requireCorrectAutoloadFile(array $files)
+{
+ foreach ($files as $file) {
+ $file = realpath($file);
+ if ($file !== false && is_file($file) === true) {
+ require_once $file;
+ return;
+ }
+ }
+}
diff --git a/Scripts/DocCodeExamples/Helper.php b/Scripts/DocCodeExamples/Helper.php
new file mode 100644
index 0000000..32ea0d5
--- /dev/null
+++ b/Scripts/DocCodeExamples/Helper.php
@@ -0,0 +1,38 @@
+extractor = $extractor;
+ $this->phpcsConfig = $phpcsConfig;
+ $this->writer = $writer;
+ $this->config = $config;
+ $this->path = $xmlDocFile;
+ $pathInfo = \pathinfo($this->path);
+
+ if (\substr($pathInfo['basename'], -12) !== 'Standard.xml') {
+ throw new \RuntimeException(
+ "ERROR: The XML file \"{$pathInfo['basename']}\" is invalid. File names should end in \"Standard.xml\"."
+ );
+ }
+
+ // Split the path into directories regardless of which directory separator is used.
+ $dirs = \preg_split('`[/\\\\]`', $pathInfo['dirname']);
+
+ if (\count($dirs) < 3 || $dirs[\count($dirs) - 2] !== 'Docs') {
+ throw new \RuntimeException(
+ 'ERROR: Invalid directory structure in the XML file path. Expected: '
+ . '{STANDARD_NAME}/Docs/{CATEGORY_NAME}/' . $pathInfo['basename'] . '.'
+ );
+ }
+
+ $this->standard = $dirs[\count($dirs) - 3];
+ $subset = $dirs[\count($dirs) - 1];
+ $this->sniff = $this->standard . '.' . $subset . '.' . \str_replace('Standard', '', $pathInfo['filename']);
+
+ if (Standards::isInstalledStandard($this->standard) === false) {
+ throw new \RuntimeException(
+ "ERROR: The standard \"{$this->standard}\" is not installed in PHPCS."
+ );
+ }
+ }
+
+ /**
+ * Validates all the code blocks in a given XML documentation file.
+ *
+ * @return boolean Whether the validation was successful or not.
+ */
+ public function validate(): bool
+ {
+ $this->setUpPhpcs();
+
+ $validated = true;
+
+ $codeBlocks = $this->extractor->extract($this->path);
+ $validCodeBlocks = $codeBlocks['valid'];
+ $invalidCodeBlocks = $codeBlocks['invalid'];
+
+ $validPassedValidation = $this->verifyCodeBlocks($validCodeBlocks, true);
+ $invalidPassedValidation = $this->verifyCodeBlocks($invalidCodeBlocks, false);
+
+ if ($validPassedValidation === false || $invalidPassedValidation === false) {
+ $validated = false;
+ }
+
+ return $validated;
+ }
+
+ /**
+ * Set up the PHPCS to be able to run the sniff against a code example programmatically.
+ *
+ * @return void
+ */
+ private function setUpPhpcs()
+ {
+ if (\defined('PHP_CODESNIFFER_VERBOSITY') === false) {
+ \define('PHP_CODESNIFFER_VERBOSITY', 0);
+ }
+
+ if (\defined('PHP_CODESNIFFER_CBF') === false) {
+ \define('PHP_CODESNIFFER_CBF', false);
+ }
+
+ $this->phpcsConfig->standards = [$this->standard];
+ $this->phpcsConfig->sniffs = [$this->sniff];
+
+ new Tokens(); // @phpstan-ignore new.resultUnused
+
+ $this->phpcsRuleset = new Ruleset($this->phpcsConfig);
+ }
+
+ /**
+ * Verify code blocks of a given section (valid or invalid).
+ *
+ * @param array $codeBlocks The code blocks to
+ * validate.
+ * @param bool $isExpectedToBeValid Whether the code blocks
+ * are expected to be valid
+ * or invalid.
+ *
+ * @return bool
+ */
+ private function verifyCodeBlocks(array $codeBlocks, bool $isExpectedToBeValid): bool
+ {
+ $codeBlocksPassedValidation = true;
+
+ foreach ($codeBlocks as $codeBlock) {
+ $passedValidation = $this->verifyCodeBlock($codeBlock, $isExpectedToBeValid);
+
+ if ($passedValidation === false && $codeBlocksPassedValidation !== false) {
+ $codeBlocksPassedValidation = false;
+ }
+ }
+
+ return $codeBlocksPassedValidation;
+ }
+
+ /**
+ * Run a given group of code examples through PHPCS and check if what is returned matches the
+ * expectation (whether errors/warnings are expected or not depending on whether the code
+ * belong to the valid or invalid section).
+ *
+ * @param CodeBlock $codeBlock PHP code example to be checked.
+ * @param bool $isExpectedToBeValid Whether the code example was extracted from the valid
+ * or invalid section and thus is expected to produce
+ * PHPCS errors or not.
+ *
+ * @return bool Whether the code block matched the expectation.
+ */
+ private function verifyCodeBlock(CodeBlock $codeBlock, bool $isExpectedToBeValid): bool
+ {
+ $codeBlockContent = $this->maybeAddPhpOpenTag($codeBlock->content);
+ $codeBlockBehavesAsExpected = true;
+
+ try {
+ // @phpstan-ignore function.resultUnused (maybe a PHPStan false positive?)
+ \token_get_all($codeBlockContent, \TOKEN_PARSE);
+ } catch (\Throwable $e) {
+ $message = 'There is a syntax error in the code block.' . \PHP_EOL;
+ $message .= $e->getMessage() . \PHP_EOL;
+ $this->displayErrorMessage(
+ $this->prepareCodeBlockErrorMessage($message, $codeBlock)
+ );
+ $codeBlockBehavesAsExpected = false;
+ }
+
+ // Set a unique file name for the DummyFile instance to avoid cache conflicts for
+ // sniffs that rely on PHPCSUtils functionality.
+ $dummyFileName = md5($this->sniff . $codeBlock->position);
+ $fileContents = 'phpcs_input_file: ' . $dummyFileName . PHP_EOL;
+ $fileContents .= $codeBlockContent;
+
+ $file = new DummyFile($fileContents, $this->phpcsRuleset, $this->phpcsConfig);
+ $file->process();
+
+ if ($isExpectedToBeValid === true && ($file->getErrorCount() !== 0 || $file->getWarningCount() !== 0)) {
+ $message = 'Code block is valid and PHPCS should have returned nothing, ' .
+ 'but instead it returned an error.';
+ $this->displayErrorMessage(
+ $this->prepareCodeBlockErrorMessage($message, $codeBlock)
+ );
+ $codeBlockBehavesAsExpected = false;
+ }
+
+ if ($isExpectedToBeValid === false && ($file->getErrorCount() === 0 && $file->getWarningCount() === 0)) {
+ $message = 'Code block is invalid and PHPCS should have returned an error message, ' .
+ 'but instead it returned nothing.';
+ $this->displayErrorMessage(
+ $this->prepareCodeBlockErrorMessage($message, $codeBlock)
+ );
+ $codeBlockBehavesAsExpected = false;
+ }
+
+ return $codeBlockBehavesAsExpected;
+ }
+
+ /**
+ * Prepare the error message together with the code block title and content.
+ *
+ * @param string $message The error message.
+ * @param CodeBlock $codeBlock The code block that caused the error.
+ *
+ * @return string The error message with additional information added.
+ */
+ private function prepareCodeBlockErrorMessage(string $message, CodeBlock $codeBlock): string
+ {
+ $errorPrefix = 'ERROR: ';
+
+ if ($this->config->getProperty('showColored') === true) {
+ $errorPrefix = "\033[31m{$errorPrefix}\033[0m";
+ }
+
+ $errorMessage = $errorPrefix . $message . \PHP_EOL;
+ $errorMessage .= "Code block title: \"{$codeBlock->title}\"" . \PHP_EOL;
+ $errorMessage .= "Code block content: \"{$codeBlock->content}\"" . \PHP_EOL;
+
+ return $errorMessage . \PHP_EOL;
+ }
+
+ /**
+ * Display an error message. Might include the file name at the top, if it is the first
+ * error message for this XML file.
+ *
+ * @param string $message The error message to display.
+ *
+ * @return void
+ */
+ private function displayErrorMessage(string $message)
+ {
+ if ($this->firstErrorMessageDisplayed === false) {
+ $errorHeader = "Errors found while processing {$this->path}";
+
+ if ($this->config->getProperty('showColored') === true) {
+ $errorHeader = "\033[31m{$errorHeader}\033[0m";
+ }
+
+ $this->writer->toStderr($errorHeader . \PHP_EOL . \PHP_EOL);
+ }
+
+ $this->firstErrorMessageDisplayed = true;
+
+ $this->writer->toStderr($message);
+ }
+
+ /**
+ * Adds a PHP open tag to the beginning of the code block content if it doesn't already have one.
+ *
+ * @param string $codeBlockContent The code block content to check.
+ *
+ * @return string
+ */
+ private function maybeAddPhpOpenTag(string $codeBlockContent): string
+ {
+ $normalizedCodeBlockContent = \trim(\strtolower($codeBlockContent));
+
+ if (\strpos($normalizedCodeBlockContent, 'showColored = $this->isColorSupported();
+ $this->showColored = HelpTextFormatter::isColorSupported();
return;
}
@@ -296,7 +283,7 @@ protected function processCliCommand()
} elseif (isset($argsFlipped['--colors'])) {
$this->showColored = true;
} else {
- $this->showColored = $this->isColorSupported();
+ $this->showColored = HelpTextFormatter::isColorSupported();
}
if (isset($argsFlipped['-h'])
@@ -373,41 +360,6 @@ static function ($subdir) {
}
}
- /**
- * Detect whether or not the CLI supports colored output.
- *
- * @codeCoverageIgnore
- *
- * @return bool
- */
- protected function isColorSupported()
- {
- // Windows.
- if (\DIRECTORY_SEPARATOR === '\\') {
- if (\getenv('ANSICON') !== false || \getenv('ConEmuANSI') === 'ON') {
- return true;
- }
-
- if (\function_exists('sapi_windows_vt100_support')) {
- // phpcs:ignore PHPCompatibility.FunctionUse.NewFunctions.sapi_windows_vt100_supportFound
- return @\sapi_windows_vt100_support(\STDOUT);
- }
-
- return false;
- }
-
- if (\getenv('GITHUB_ACTIONS')) {
- return true;
- }
-
- // Linux/MacOS.
- if (\function_exists('posix_isatty')) {
- return @\posix_isatty(\STDOUT);
- }
-
- return false;
- }
-
/**
* Retrieve the version number of this script.
*
@@ -429,58 +381,6 @@ public function getVersion()
*/
private function getHelp()
{
- $output = '';
- foreach ($this->helpTexts as $section => $options) {
- $longestOptionLength = 0;
- foreach ($options as $option) {
- if (isset($option['arg'])) {
- $longestOptionLength = \max($longestOptionLength, \strlen($option['arg']));
- }
- }
-
- if ($this->showColored === true) {
- $output .= "\033[33m{$section}:\033[0m" . \PHP_EOL;
- } else {
- $output .= "{$section}:" . \PHP_EOL;
- }
-
- $descWidth = (self::MAX_WIDTH - ($longestOptionLength + 1 + \strlen(self::LEFT_MARGIN)));
- $descBreak = \PHP_EOL . self::LEFT_MARGIN . \str_pad(' ', ($longestOptionLength + 1));
-
- foreach ($options as $option) {
- if (isset($option['text'])) {
- $text = $option['text'];
- if ($this->showColored === true) {
- $text = \preg_replace('`(\[[^\]]+\])`', "\033[36m" . '$1' . "\033[0m", $text);
- }
- $output .= self::LEFT_MARGIN . $text . \PHP_EOL;
- }
-
- if (isset($option['arg'])) {
- $arg = \str_pad($option['arg'], $longestOptionLength);
- if ($this->showColored === true) {
- $arg = \preg_replace('`(<[^>]+>)`', "\033[0m\033[36m" . '$1', $arg);
- $arg = "\033[32m{$arg}\033[0m";
- }
-
- $descText = \wordwrap($option['desc'], $descWidth, $descBreak);
- $desc = \explode('. ', $option['desc']);
- if (\count($desc) > 1) {
- $descText = '';
- foreach ($desc as $key => $sentence) {
- $descText .= ($key === 0) ? '' : $descBreak;
- $descText .= \wordwrap($sentence, $descWidth, $descBreak);
- $descText = \rtrim($descText, '.') . '.';
- }
- }
-
- $output .= self::LEFT_MARGIN . $arg . ' ' . $descText . \PHP_EOL;
- }
- }
-
- $output .= \PHP_EOL;
- }
-
- return $output;
+ return HelpTextFormatter::format($this->helpTexts, $this->showColored);
}
}
diff --git a/Scripts/Utils/HelpTextFormatter.php b/Scripts/Utils/HelpTextFormatter.php
new file mode 100644
index 0000000..2be4a18
--- /dev/null
+++ b/Scripts/Utils/HelpTextFormatter.php
@@ -0,0 +1,137 @@
+>> $helpTexts The help texts to format.
+ * @param bool $showColored Whether to use colored output.
+ *
+ * @return string The formatted help text.
+ */
+ public static function format(array $helpTexts, $showColored)
+ {
+ $output = '';
+ foreach ($helpTexts as $section => $options) {
+ $longestOptionLength = 0;
+ foreach ($options as $option) {
+ if (isset($option['arg'])) {
+ $longestOptionLength = \max($longestOptionLength, \strlen($option['arg']));
+ }
+ }
+
+ if ($showColored === true) {
+ $output .= "\033[33m{$section}:\033[0m" . \PHP_EOL;
+ } else {
+ $output .= "{$section}:" . \PHP_EOL;
+ }
+
+ $descWidth = (self::MAX_WIDTH - ($longestOptionLength + 1 + \strlen(self::LEFT_MARGIN)));
+ $descBreak = \PHP_EOL . self::LEFT_MARGIN . \str_pad(' ', ($longestOptionLength + 1));
+
+ foreach ($options as $option) {
+ if (isset($option['text'])) {
+ $text = $option['text'];
+ if ($showColored === true) {
+ $text = \preg_replace('`(\[[^\]]+\])`', "\033[36m" . '$1' . "\033[0m", $text);
+ }
+ $output .= self::LEFT_MARGIN . $text . \PHP_EOL;
+ }
+
+ if (isset($option['arg'])) {
+ $arg = \str_pad($option['arg'], $longestOptionLength);
+ if ($showColored === true) {
+ $arg = \preg_replace('`(<[^>]+>)`', "\033[0m\033[36m" . '$1', $arg);
+ $arg = "\033[32m{$arg}\033[0m";
+ }
+
+ $descText = \wordwrap($option['desc'], $descWidth, $descBreak);
+ $desc = \explode('. ', $option['desc']);
+ if (\count($desc) > 1) {
+ $descText = '';
+ foreach ($desc as $key => $sentence) {
+ $descText .= ($key === 0) ? '' : $descBreak;
+ $descText .= \wordwrap($sentence, $descWidth, $descBreak);
+ $descText = \rtrim($descText, '.') . '.';
+ }
+ }
+
+ $output .= self::LEFT_MARGIN . $arg . ' ' . $descText . \PHP_EOL;
+ }
+ }
+
+ $output .= \PHP_EOL;
+ }
+
+ return $output;
+ }
+
+ /**
+ * Detect whether or not the CLI supports colored output.
+ *
+ * @codeCoverageIgnore
+ *
+ * @return bool
+ */
+ public static function isColorSupported()
+ {
+ // Windows.
+ if (\DIRECTORY_SEPARATOR === '\\') {
+ if (\getenv('ANSICON') !== false || \getenv('ConEmuANSI') === 'ON') {
+ return true;
+ }
+
+ if (\function_exists('sapi_windows_vt100_support')) {
+ // phpcs:ignore PHPCompatibility.FunctionUse.NewFunctions.sapi_windows_vt100_supportFound
+ return @\sapi_windows_vt100_support(\STDOUT);
+ }
+
+ return false;
+ }
+
+ if (\getenv('GITHUB_ACTIONS')) {
+ return true;
+ }
+
+ // Linux/MacOS.
+ if (\function_exists('posix_isatty')) {
+ return @\posix_isatty(\STDOUT);
+ }
+
+ return false;
+ }
+}
diff --git a/Tests/DocCodeExamples/CheckTest.php b/Tests/DocCodeExamples/CheckTest.php
new file mode 100644
index 0000000..59d780f
--- /dev/null
+++ b/Tests/DocCodeExamples/CheckTest.php
@@ -0,0 +1,504 @@
+config = $this->createCompatibleMockBuilderWithMethods(Config::class, [])
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->setObjectProperty($this->config, 'showColored', false);
+ $this->extractor = new CodeBlocksExtractor();
+ $cliArgs = ['--runtime-set', 'installed_paths', \realpath(self::STANDARD_DIR)];
+ $this->phpcsConfig = PHPCSConfigLoader::getPHPCSConfigInstance($cliArgs);
+ $this->writer = new TestWriter();
+ }
+
+ /**
+ * Test the constructor method with various target paths and excluded directories
+ * configurations.
+ *
+ * @dataProvider dataConstructorTargets
+ *
+ * @param array $targetPaths Paths to set as targets.
+ * @param array $excludedDirs Dirs to exclude.
+ * @param array $expectedDocs Expected docs or callback to verify docs.
+ *
+ * @return void
+ */
+ public function testConstructorTargets(array $targetPaths, array $excludedDirs, array $expectedDocs)
+ {
+ $this->setObjectProperty($this->config, 'projectRoot', Helper::normalizePath(\realpath(self::FIXTURE_DIR)));
+ $this->setObjectProperty($this->config, 'targetPaths', $targetPaths);
+ $this->setObjectProperty($this->config, 'excludedDirs', $excludedDirs);
+
+ $check = new Check($this->config, $this->extractor, $this->phpcsConfig, $this->writer);
+
+ $this->assertSame($expectedDocs, $this->getObjectProperty($check, 'xmlFiles'));
+ }
+
+ /**
+ * Data provider for testConstructorTargets.
+ *
+ * @return array>>
+ */
+ public static function dataConstructorTargets(): array
+ {
+ return [
+ 'no targets' => [
+ 'targetPaths' => [],
+ 'excludedDirs' => [],
+ 'expectedDocs' => [],
+ ],
+ 'target path does not exist' => [
+ 'targetPaths' => [\realpath(self::FIXTURE_DIR . 'nonexistent')],
+ 'excludedDirs' => [],
+ 'expectedDocs' => [],
+ ],
+ 'no XML files found in target directory' => [
+ 'targetPaths' => [\realpath(self::FIXTURE_DIR . '../../DocsXsd')],
+ 'excludedDirs' => [],
+ 'expectedDocs' => [],
+ ],
+ 'directory target' => [
+ 'targetPaths' => [Helper::normalizePath(\realpath(self::FIXTURE_DIR))],
+ 'excludedDirs' => [],
+ 'expectedDocs' => [
+ '/CheckCodeExamplesStandard/Docs/Constructor/TestXmlDocValidatorConstructorStandard.xml',
+ '/CheckCodeExamplesStandard/Docs/Examples/CorrectExamplesStandard.xml',
+ '/CheckCodeExamplesStandard/Docs/Examples/IncorrectInvalidExampleStandard.xml',
+ '/CheckCodeExamplesStandard/Docs/Examples/IncorrectValidExampleStandard.xml',
+ '/CheckCodeExamplesStandard/Docs/Examples/PhpOpenTagStandard.xml',
+ '/CheckCodeExamplesStandard/Docs/Examples/PhpcsUtilsCacheStandard.xml',
+ '/CheckCodeExamplesStandard/Docs/Examples/SyntaxErrorExampleStandard.xml',
+ '/CheckDocPathStandard/Docs/MissingCategoryDirStandard.xml',
+ '/UninstalledStandard/Docs/Category/TestErrorWhenStandardIsNotInstalledStandard.xml',
+ ],
+ ],
+ 'file target' => [
+ 'targetPaths' => [Helper::normalizePath(\realpath(self::FIXTURE_DIR . 'CheckCodeExamplesStandard/Docs/Examples/SyntaxErrorExampleStandard.xml'))],
+ 'excludedDirs' => [],
+ 'expectedDocs' => [
+ '/CheckCodeExamplesStandard/Docs/Examples/SyntaxErrorExampleStandard.xml',
+ ],
+ ],
+ 'multiple targets' => [
+ 'targetPaths' => [
+ Helper::normalizePath(\realpath(self::FIXTURE_DIR . 'CheckCodeExamplesStandard/Docs/Examples/SyntaxErrorExampleStandard.xml')),
+ Helper::normalizePath(\realpath(self::FIXTURE_DIR . 'CheckCodeExamplesStandard/Docs/Examples/CorrectExamplesStandard.xml')),
+ ],
+ 'excludedDirs' => [],
+ 'expectedDocs' => [
+ '/CheckCodeExamplesStandard/Docs/Examples/CorrectExamplesStandard.xml',
+ '/CheckCodeExamplesStandard/Docs/Examples/SyntaxErrorExampleStandard.xml',
+ ],
+ ],
+ 'excluded directories' => [
+ 'targetPaths' => [Helper::normalizePath(\realpath(self::FIXTURE_DIR))],
+ 'excludedDirs' => [
+ 'CheckCodeExamplesStandard/Docs',
+ ],
+ 'expectedDocs' => [
+ '/CheckDocPathStandard/Docs/MissingCategoryDirStandard.xml',
+ '/UninstalledStandard/Docs/Category/TestErrorWhenStandardIsNotInstalledStandard.xml',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Test getXmlDocValidator() method.
+ *
+ * @return void
+ */
+ public function testGetXmlDocValidator()
+ {
+ $this->setObjectProperty($this->config, 'projectRoot', \realpath(self::FIXTURE_DIR));
+
+ $check = new Check($this->config, $this->extractor, $this->phpcsConfig, $this->writer);
+
+ $reflection = new \ReflectionClass($check);
+ $method = $reflection->getMethod('getXmlDocValidator');
+ $method->setAccessible(true);
+
+ $result = $method->invokeArgs($check, ['/CheckCodeExamplesStandard/Docs/Examples/CorrectExamplesStandard.xml']);
+
+ $method->setAccessible(false);
+
+ $this->assertInstanceOf(XmlDocValidator::class, $result);
+ }
+
+ /**
+ * Test the run() method without mocking the Check class. Test only the two happy paths.
+ * More detailed tests are performed in the testRun() method.
+ *
+ * @dataProvider dataRunWithoutMockingCheckClass
+ *
+ * @param array $xmlDocs XML doc files to process.
+ * @param int $expectedExitCode Expected exit code from the run() method.
+ * @param string $expectedStdout Expected stdout output.
+ * @param string $expectedStderr Expected stderr output.
+ *
+ * @return void
+ */
+ public function testRunWithoutMockingCheckClass(array $xmlDocs, int $expectedExitCode, string $expectedStdout, string $expectedStderr)
+ {
+ $this->setObjectProperty($this->config, 'projectRoot', \realpath(self::FIXTURE_DIR));
+ $this->setObjectProperty($this->config, 'targetPaths', $xmlDocs);
+
+ $check = new Check($this->config, $this->extractor, $this->phpcsConfig, $this->writer);
+
+ $exitCode = $check->run();
+
+ $this->assertSame($expectedExitCode, $exitCode);
+ $this->assertSame($expectedStdout, $this->writer->getStdout());
+ $this->assertMatchesRegularExpression('`' . $expectedStderr . '`', $this->writer->getStderr());
+ }
+
+ /**
+ * Data provider for testRunWithoutMockingCheckClass.
+ *
+ * @return array>>
+ */
+ public static function dataRunWithoutMockingCheckClass(): array
+ {
+ return [
+ 'valid XML file' => [
+ 'xmlDocs' => [
+ \realpath(self::FIXTURE_DIR . 'CheckCodeExamplesStandard/Docs/Examples/CorrectExamplesStandard.xml'),
+ ],
+ 'expectedExitCode' => 0,
+ 'expectedStdout' => 'Checked 1 XML documentation file. All code examples are valid.' . PHP_EOL,
+ 'expectedStderr' => '',
+ ],
+ 'invalid XML file' => [
+ 'xmlDocs' => [
+ \realpath(self::FIXTURE_DIR . 'CheckCodeExamplesStandard/Docs/Examples/IncorrectInvalidExampleStandard.xml'),
+ ],
+ 'expectedExitCode' => 1,
+ 'expectedStdout' => 'Checked 1 XML documentation file. Found incorrect code examples in 1.' . PHP_EOL,
+ 'expectedStderr' => 'Errors found while processing .*?[/\\\\]CheckCodeExamplesStandard[/\\\\]Docs[/\\\\]Examples[/\\\\]IncorrectInvalidExampleStandard\.xml\R\s*\R\s*ERROR: Code block is invalid and PHPCS should have returned an error message, but instead it returned nothing\.\R\s*Code block title: "Invalid: invalid code examples\."\R\s*Code block content: "function sniffValidationWillPass\(\) \{\}"\R\s*\R',
+ ],
+ ];
+ }
+
+ /**
+ * Test the run() method.
+ *
+ * @dataProvider dataRun
+ *
+ * @param array $xmlDocs XML doc files to process.
+ * @param array $validationResults Validation results for each doc file.
+ * @param int $expectedExitCode Expected exit code from the run() method.
+ * @param string $expectedStdout Expected stdout output.
+ * @param string $expectedStderr Expected stderr output.
+ * @param bool $useColors Whether to use colorized output or not.
+ *
+ * @return void
+ */
+ public function testRun(
+ array $xmlDocs,
+ array $validationResults,
+ int $expectedExitCode,
+ string $expectedStdout,
+ string $expectedStderr,
+ bool $useColors = false
+ ) {
+ $this->setObjectProperty($this->config, 'showColored', $useColors);
+
+ $check = $this->createCompatibleMockBuilderWithMethods(Check::class, ['getXmlDocValidator'])
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $this->setObjectProperty($check, 'config', $this->config);
+ $this->setObjectProperty($check, 'xmlFiles', $xmlDocs);
+ $this->setObjectProperty($check, 'writer', $this->writer);
+
+ // Set up mock XML files with their expected validation results
+ $mockXmlFiles = [];
+ foreach ($xmlDocs as $index => $xmlDocPath) {
+ $mockXmlFile = $this->createMock(XmlDocValidator::class);
+ $mockXmlFile->expects($this->once())
+ ->method('validate')
+ ->willReturn($validationResults[$index]);
+ $mockXmlFiles[$xmlDocPath] = $mockXmlFile;
+ }
+
+ $check->expects($this->exactly(count($xmlDocs)))
+ ->method('getXmlDocValidator')
+ ->willReturnCallback(function ($xmlDocPath) use ($mockXmlFiles) {
+ return $mockXmlFiles[$xmlDocPath];
+ });
+
+ $exitCode = $check->run();
+ $this->assertSame($expectedExitCode, $exitCode);
+ $this->assertSame($expectedStdout, $this->writer->getStdout());
+ // Stderr will only contain output from the Check class as the XmlDocValidator class is mocked in this test.
+ $this->assertSame($this->config->getVersion() . $expectedStderr, $this->writer->getStderr());
+ }
+
+ /**
+ * Data provider for testRun.
+ *
+ * @return array>
+ */
+ public static function dataRun(): array
+ {
+ return [
+ 'no docs' => [
+ 'xmlDocs' => [],
+ 'validationResults' => [],
+ 'expectedExitCode' => 1,
+ 'expectedStdout' => '',
+ 'expectedStderr' => 'ERROR: No XML documentation files found.' . PHP_EOL,
+ ],
+ 'all valid docs' => [
+ 'xmlDocs' => [
+ 'valid1.xml',
+ 'valid2.xml',
+ ],
+ 'validationResults' => [true, true],
+ 'expectedExitCode' => 0,
+ 'expectedStdout' => 'Checked 2 XML documentation files. All code examples are valid.' . PHP_EOL,
+ 'expectedStderr' => '',
+ ],
+ 'one invalid and one valid' => [
+ 'xmlDocs' => [
+ 'valid1.xml',
+ 'invalid1.xml',
+ ],
+ 'validationResults' => [true, false],
+ 'expectedExitCode' => 1,
+ 'expectedStdout' => 'Checked 2 XML documentation files. Found incorrect code examples in 1.' . PHP_EOL,
+ 'expectedStderr' => '',
+ ],
+ 'all invalid docs' => [
+ 'xmlDocs' => [
+ 'invalid1.xml',
+ 'invalid2.xml',
+ ],
+ 'validationResults' => [false, false],
+ 'expectedExitCode' => 1,
+ 'expectedStdout' => 'Checked 2 XML documentation files. Found incorrect code examples in 2.' . PHP_EOL,
+ 'expectedStderr' => '',
+ ],
+ 'single doc valid' => [
+ 'xmlDocs' => [
+ 'valid1.xml',
+ ],
+ 'validationResults' => [true],
+ 'expectedExitCode' => 0,
+ 'expectedStdout' => 'Checked 1 XML documentation file. All code examples are valid.' . PHP_EOL,
+ 'expectedStderr' => '',
+ ],
+ 'single doc invalid' => [
+ 'xmlDocs' => [
+ 'invalid1.xml',
+ ],
+ 'validationResults' => [false],
+ 'expectedExitCode' => 1,
+ 'expectedStdout' => 'Checked 1 XML documentation file. Found incorrect code examples in 1.' . PHP_EOL,
+ 'expectedStderr' => '',
+ ],
+ 'no docs (with colorized output)' => [
+ 'xmlDocs' => [],
+ 'validationResults' => [],
+ 'expectedExitCode' => 1,
+ 'expectedStdout' => '',
+ 'expectedStderr' => "\033[31mERROR: No XML documentation files found.\033[0m" . PHP_EOL,
+ 'useColors' => true,
+ ],
+ 'all valid docs (with colorized output)' => [
+ 'xmlDocs' => [
+ 'valid1.xml',
+ 'valid2.xml',
+ ],
+ 'validationResults' => [true, true],
+ 'expectedExitCode' => 0,
+ 'expectedStdout' => "\033[32mChecked 2 XML documentation files. All code examples are valid.\033[0m" . PHP_EOL,
+ 'expectedStderr' => '',
+ 'useColors' => true,
+ ],
+ 'one invalid and one valid (with colorized output)' => [
+ 'xmlDocs' => [
+ 'valid1.xml',
+ 'invalid1.xml',
+ ],
+ 'validationResults' => [true, false],
+ 'expectedExitCode' => 1,
+ 'expectedStdout' => "\033[31mChecked 2 XML documentation files. Found incorrect code examples in 1.\033[0m" . PHP_EOL,
+ 'expectedStderr' => '',
+ 'useColors' => true,
+ ],
+ ];
+ }
+
+ /**
+ * Test that Check::run() correctly ignores sniffs.
+ *
+ * @dataProvider dataRunIgnoredSniffs
+ *
+ * @param array $xmlDocs List of XML doc file paths.
+ * @param array $ignoredSniffs List of sniff names to ignore.
+ * @param array $validateCallMap Map of which files should have validate() called.
+ *
+ * @return void
+ */
+ public function testRunIgnoredSniffs(array $xmlDocs, array $ignoredSniffs, array $validateCallMap)
+ {
+ $xmlDocValidatorMocks = [];
+ $projectRoot = \realpath(self::FIXTURE_DIR);
+ $this->setObjectProperty($this->config, 'projectRoot', $projectRoot);
+ $this->setObjectProperty($this->config, 'ignoredSniffs', $ignoredSniffs);
+
+ // Create a partial mock to override just the getXmlDocValidator() method.
+ $check = $this->createCompatibleMockBuilderWithMethods(Check::class, ['getXmlDocValidator'])
+ ->setConstructorArgs([$this->config, $this->extractor, $this->phpcsConfig, $this->writer])
+ ->getMock();
+
+ foreach ($xmlDocs as $index => $xmlDocPath) {
+ $xmlDocValidatorMock = $this->createCompatibleMockBuilderWithMethods(XmlDocValidator::class, ['validate'])
+ ->setConstructorArgs([$projectRoot . $xmlDocPath, $this->extractor, $this->phpcsConfig, $this->writer, $this->config])
+ ->getMock();
+ $xmlDocValidatorMock->expects($this->exactly($validateCallMap[$index]))->method('validate');
+ $xmlDocValidatorMocks[] = $xmlDocValidatorMock;
+ }
+
+ $check->method('getXmlDocValidator')->willReturnOnConsecutiveCalls(...$xmlDocValidatorMocks);
+
+ $this->setObjectProperty($check, 'xmlFiles', $xmlDocs);
+
+ $check->run();
+ }
+
+ /**
+ * Data provider for testRunIgnoredSniffs.
+ *
+ * @return array>
+ */
+ public static function dataRunIgnoredSniffs(): array
+ {
+ return [
+ 'no ignored sniffs' => [
+ 'xmlDocs' => [
+ '/CheckCodeExamplesStandard/Docs/Examples/CorrectExamplesStandard.xml',
+ ],
+ 'ignoredSniffs' => [],
+ 'validateCallMap' => [1], // File should be validated.
+ ],
+ 'all sniffs ignored' => [
+ 'xmlDocs' => [
+ '/CheckCodeExamplesStandard/Docs/Examples/CorrectExamplesStandard.xml',
+ '/CheckCodeExamplesStandard/Docs/Examples/IncorrectInvalidExampleStandard.xml',
+ ],
+ 'ignoredSniffs' => [
+ 'CheckCodeExamplesStandard.Examples.CorrectExamples',
+ 'CheckCodeExamplesStandard.Examples.IncorrectInvalidExample',
+ ],
+ 'validateCallMap' => [0, 0], // No files should be validated.
+ ],
+ 'some sniffs ignored' => [
+ 'xmlDocs' => [
+ '/CheckCodeExamplesStandard/Docs/Examples/CorrectExamplesStandard.xml',
+ '/CheckCodeExamplesStandard/Docs/Examples/IncorrectInvalidExampleStandard.xml',
+ '/CheckCodeExamplesStandard/Docs/Examples/IncorrectValidExampleStandard.xml',
+ ],
+ 'ignoredSniffs' => ['CheckCodeExamplesStandard.Examples.CorrectExamples'],
+ 'validateCallMap' => [0, 1, 1], // First file should be skipped.
+ ],
+ ];
+ }
+
+ /**
+ * Helper method to create a mock builder of a given class and the set methods to mock in a way
+ * that's compatible with different PHPUnit versions.
+ *
+ * @param class-string $className The name of the class to mock.
+ * @param array $methods The methods to set.
+ *
+ * @return object The created mock builder object.
+ */
+ private function createCompatibleMockBuilderWithMethods(string $className, array $methods)
+ {
+ $mockBuilder = $this->getMockBuilder($className);
+
+ // PHPUnit < 9.0 uses setMethods(), newer versions use onlyMethods()
+ if (\method_exists($mockBuilder, 'setMethods')) { // @phpstan-ignore function.impossibleType
+ if (empty($methods)) {
+ $methods = null;
+ }
+ return $mockBuilder->setMethods($methods);
+ }
+
+ return $mockBuilder->onlyMethods($methods);
+ }
+}
diff --git a/Tests/DocCodeExamples/CodeBlocksExtractorTest.php b/Tests/DocCodeExamples/CodeBlocksExtractorTest.php
new file mode 100644
index 0000000..8f6bdfb
--- /dev/null
+++ b/Tests/DocCodeExamples/CodeBlocksExtractorTest.php
@@ -0,0 +1,164 @@
+expectException(\RuntimeException::class);
+ $this->expectExceptionMessage($expectedMessage);
+
+ self::$codeBlocksExtractor->extract($filePath);
+ }
+
+ /**
+ * Data provider for testExtractCodeBlocksWithInvalidFiles.
+ *
+ * @return array>
+ */
+ public static function dataExtractCodeBlocksWithInvalidFiles(): array
+ {
+ return [
+ 'Empty XML File' => [
+ 'filePath' => self::FIXTURE_DIR . 'EmptyFile.xml',
+ 'expectedMessage' => 'The Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/EmptyFile.xml file is empty.',
+ ],
+ 'Invalid XML File' => [
+ 'filePath' => self::FIXTURE_DIR . 'InvalidXMLFile.xml',
+ 'expectedMessage' => "Failed to parse XML file Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/InvalidXMLFile.xml. Errors:\nLine 1, Column 1: Start tag expected, '<' not found",
+ ],
+ ];
+ }
+
+ /**
+ * Test that the code blocks are correctly extracted from the XML file.
+ *
+ * @dataProvider dataExtractCodeBlocks
+ *
+ * @param string $xmlFilePath The path to the XML file to extract code blocks from.
+ * @param array>|string> $expectedResult The expected result of the extraction.
+ *
+ * @return void
+ */
+ public function testExtractCodeBlocks(string $xmlFilePath, array $expectedResult)
+ {
+ $codeBlocks = self::$codeBlocksExtractor->extract($xmlFilePath);
+
+ $this->assertEquals($expectedResult, $codeBlocks);
+ }
+
+ /**
+ * Data provider for testExtractCodeBlocks.
+ *
+ * @return array>|string>>
+ */
+ public static function dataExtractCodeBlocks(): array
+ {
+ return [
+ 'File with no code blocks' => [
+ 'xmlFilePath' => self::FIXTURE_DIR . 'NoCodeBlocks.xml',
+ 'expectedResult' => [
+ 'valid' => [],
+ 'invalid' => [],
+ ],
+ ],
+ 'One valid block' => [
+ 'xmlFilePath' => self::FIXTURE_DIR . 'OneValidBlock.xml',
+ 'expectedResult' => [
+ 'valid' => [
+ new CodeBlock(
+ 'Valid: Example of a valid code block.',
+ '// Some PHP code.',
+ 0
+ )
+ ],
+ 'invalid' => [],
+ ],
+ ],
+ 'Valid and invalid code blocks' => [
+ 'xmlFilePath' => self::FIXTURE_DIR . 'ValidAndInvalidBlocks.xml',
+ 'expectedResult' => [
+ 'valid' => [
+ new CodeBlock(
+ 'Valid: Example of a valid code block.',
+ '// Some PHP code that does not trigger the sniff.',
+ 0
+ ),
+ new CodeBlock(
+ 'Valid: Another example of a valid code block.',
+ '// Another PHP code that does not trigger the sniff.',
+ 1
+ ),
+ ],
+ 'invalid' => [
+ new CodeBlock(
+ 'Invalid: Example of an invalid code block.',
+ '// Some PHP code that does trigger the sniff.',
+ 2
+ ),
+ new CodeBlock(
+ 'Invalid: Another example of an invalid code block.',
+ '// Another PHP code that does trigger the sniff.',
+ 3
+ ),
+ ],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/Tests/DocCodeExamples/ConfigTest.php b/Tests/DocCodeExamples/ConfigTest.php
new file mode 100644
index 0000000..3113001
--- /dev/null
+++ b/Tests/DocCodeExamples/ConfigTest.php
@@ -0,0 +1,487 @@
+|bool|null>
+ */
+ private $defaultSettings = [
+ 'projectRoot' => '',
+ 'targetPaths' => [],
+ 'excludedDirs' => [],
+ 'ignoredSniffs' => [],
+ 'executeCheck' => true,
+ 'showColored' => null, // Defined in setUpPrerequisites() as the default value depends on the environment.
+ ];
+
+ /**
+ * TestWriter instance.
+ *
+ * @var \PHPCSDevTools\Tests\TestWriter
+ */
+ private $writer;
+
+ /**
+ * Set up.
+ *
+ * @before
+ *
+ * @return void
+ */
+ public function setUpPrerequisites()
+ {
+ $this->writer = new TestWriter();
+ $this->defaultSettings['showColored'] = HelpTextFormatter::isColorSupported();
+ }
+
+ /**
+ * Test getProperty() throws exception if property does not exist.
+ *
+ * @return void
+ */
+ public function testGetPropertyThrowsException()
+ {
+ $this->expectException('RuntimeException');
+ $this->expectExceptionMessage('Property "NonExistent" does not exist');
+
+ $_SERVER['argv'] = [];
+ $config = new Config($this->writer);
+ $config->getProperty('NonExistent');
+ }
+
+ /**
+ * Test getProperty().
+ *
+ * @dataProvider dataGetProperty
+ *
+ * @param string $propertyName The name of the property to retrieve.
+ * @param mixed $expected The expected value for the property.
+ *
+ * @return void
+ */
+ public function testGetProperty($propertyName, $expected)
+ {
+ $_SERVER['argv'] = [];
+ $config = new Config($this->writer);
+ $this->assertSame($expected, $config->getProperty($propertyName));
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array>>
+ */
+ public static function dataGetProperty(): array
+ {
+ $projectRoot = Helper::normalizePath(\getcwd());
+
+ return [
+ 'projectRoot' => [
+ 'propertyName' => 'projectRoot',
+ 'expected' => $projectRoot,
+ ],
+ 'targetPaths' => [
+ 'propertyName' => 'targetPaths',
+ 'expected' => [$projectRoot],
+ ],
+ 'excludedDirs' => [
+ 'propertyName' => 'excludedDirs',
+ 'expected' => [],
+ ],
+ ];
+ }
+
+ /**
+ * Verify that unsupported arguments thrown an exception.
+ *
+ * @dataProvider dataProcessCliCommandUnsupportedArgument
+ *
+ * @param string $unsupportedArgument The unsupported argument.
+ *
+ * @return void
+ */
+ public function testProcessCliCommandUnsupportedArgument(string $unsupportedArgument)
+ {
+ $_SERVER['argv'] = \explode(' ', "./phpcs-check-doc-examples $unsupportedArgument");
+
+ $this->expectException('RuntimeException');
+ $this->expectExceptionMessage("Unsupported argument $unsupportedArgument");
+
+ new Config($this->writer);
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array>
+ */
+ public static function dataProcessCliCommandUnsupportedArgument(): array
+ {
+ return [
+ 'Unsupported short arguments' => [
+ 'unsupportedArgument' => '-a',
+ ],
+ 'Unsupported long arguments' => [
+ 'unsupportedArgument' => '--unsupported-arg',
+ ],
+ 'Unsupported long argument using an = sign' => [
+ 'unsupportedArgument' => '--ignore=vendor',
+ ],
+ ];
+ }
+
+ /**
+ * Verify that an exception is thrown when an invalid target path is passed.
+ *
+ * @return void
+ */
+ public function testProcessInvalidTargetThrowException()
+ {
+ $this->expectException('RuntimeException');
+ $this->expectExceptionMessage('Target path ./doesnotexist does not exist');
+
+ $_SERVER['argv'] = ['phpcs-check-doc-examples', './doesnotexist'];
+ new Config($this->writer);
+ }
+
+ /**
+ * Test parsing the arguments received from the command line.
+ *
+ * @dataProvider dataProcessCliCommand
+ *
+ * @param string $command The command as received from the command line.
+ * @param array> $expectedChanged The Config class properties which are expected
+ * to have been changed (key) with their value.
+ *
+ * @return void
+ */
+ public function testProcessCliCommand(string $command, array $expectedChanged)
+ {
+ $expected = \array_merge($this->defaultSettings, $expectedChanged);
+
+ $_SERVER['argv'] = \explode(' ', $command);
+ $config = new Config($this->writer);
+ $actual = $this->getCurrentValues($config);
+
+ $this->assertSame($expected, $actual, 'Parsing the command line did not set the properties correctly');
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array>|string>>
+ */
+ public static function dataProcessCliCommand(): array
+ {
+ /*
+ * For project root, we only really verify that it has been set as the value will depend
+ * on the environment in which the tests are being run.
+ */
+ $projectRoot = Helper::normalizePath(\getcwd());
+
+ $testData = [
+ 'No arguments at all - verify target dir will be set to project root' => [
+ 'command' => './phpcs-check-doc-examples',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ $projectRoot,
+ ],
+ ],
+ ],
+ 'No arguments at all and trailing whitespace in the command' => [
+ 'command' => './phpcs-check-doc-examples ',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ $projectRoot,
+ ],
+ ],
+ ],
+ 'No arguments other than a path' => [
+ 'command' => './phpcs-check-doc-examples .',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ Helper::normalizePath(\realpath('.')),
+ ],
+ ],
+ ],
+ 'No arguments other than multiple valid paths in varying formats' => [
+ 'command' => './phpcs-check-doc-examples ./PHPCSDebug ./Tests bin ' . __DIR__ . '/../../.github/',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ Helper::normalizePath(\realpath('./PHPCSDebug')),
+ Helper::normalizePath(\realpath('./Tests')),
+ Helper::normalizePath(\realpath('bin')),
+ Helper::normalizePath(\realpath(__DIR__ . '/../../.github/')),
+ ],
+ ],
+ ],
+ 'Multiple excludes, varying formats' => [
+ 'command' => './phpcs-check-doc-examples .'
+ . ' --exclude=.git,./.github/,Tests/FeatureComplete,/node_modules/,tests/notvalid,../../levelup',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ Helper::normalizePath(\realpath('.')),
+ ],
+ 'excludedDirs' => [
+ '.git',
+ './.github',
+ 'Tests/FeatureComplete',
+ 'node_modules',
+ 'tests/notvalid',
+ '../../levelup',
+ ],
+ ],
+ ],
+ 'Exclude, complete value wrapped in quotes' => [
+ 'command' => './phpcs-check-doc-examples --exclude=".git,./.github/,Tests/FeatureComplete"',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ $projectRoot,
+ ],
+ 'excludedDirs' => [
+ '.git',
+ './.github',
+ 'Tests/FeatureComplete',
+ ],
+ ],
+ ],
+ 'Exclude, no value' => [
+ 'command' => './phpcs-check-doc-examples --exclude=',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ $projectRoot,
+ ],
+ 'excludedDirs' => [],
+ ],
+ ],
+ 'Single sniff to ignore' => [
+ 'command' => './phpcs-check-doc-examples --ignore-sniffs=Generic.CodeAnalysis.EmptyStatement',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ $projectRoot,
+ ],
+ 'ignoredSniffs' => [
+ 'Generic.CodeAnalysis.EmptyStatement',
+ ],
+ ],
+ ],
+ 'Multiple sniffs to ignore wrapped in quotes' => [
+ 'command' => "./phpcs-check-doc-examples --ignore-sniffs='Generic.CodeAnalysis.EmptyStatement,Squiz.ControlStructures.ForEachLoopDeclaration,Squiz.Arrays.ArrayDeclaration'",
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ $projectRoot,
+ ],
+ 'ignoredSniffs' => [
+ 'Generic.CodeAnalysis.EmptyStatement',
+ 'Squiz.ControlStructures.ForEachLoopDeclaration',
+ 'Squiz.Arrays.ArrayDeclaration',
+ ],
+ ],
+ ],
+ 'All together now, includes testing for handling of additional whitespace between arguments' => [
+ 'command' => 'phpcs-check-doc-examples Scripts --exclude=ignoreme,/other,./tests/'
+ . ' --ignore-sniffs=Generic.CodeAnalysis.EmptyStatement PHPCSDebug ./Tests .',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ Helper::normalizePath(\realpath('Scripts')),
+ Helper::normalizePath(\realpath('PHPCSDebug')),
+ Helper::normalizePath(\realpath('./Tests')),
+ Helper::normalizePath(\realpath('.')),
+ ],
+ 'excludedDirs' => [
+ 'ignoreme',
+ 'other',
+ './tests',
+ ],
+ 'ignoredSniffs' => [
+ 'Generic.CodeAnalysis.EmptyStatement',
+ ],
+ ],
+ ],
+ 'Help argument' => [
+ 'command' => './phpcs-check-doc-examples --help',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'executeCheck' => false,
+ ],
+ ],
+ 'Version argument short' => [
+ 'command' => './phpcs-check-doc-examples -V',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'executeCheck' => false,
+ ],
+ ],
+ 'Version argument long' => [
+ 'command' => './phpcs-check-doc-examples --version',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'executeCheck' => false,
+ ],
+ ],
+ 'Colors argument' => [
+ 'command' => './phpcs-check-doc-examples --colors',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ $projectRoot,
+ ],
+ 'showColored' => true,
+ ],
+ ],
+ 'No colors argument' => [
+ 'command' => './phpcs-check-doc-examples --no-colors',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ $projectRoot,
+ ],
+ 'showColored' => false,
+ ],
+ ],
+ 'Both colors and no-colors arguments (no-colors should win)' => [
+ 'command' => './phpcs-check-doc-examples --no-colors --colors',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ $projectRoot,
+ ],
+ 'showColored' => false,
+ ],
+ ],
+ 'Both colors and no-colors arguments in reverse order (no-colors should win)' => [
+ 'command' => './phpcs-check-doc-examples --colors --no-colors',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ $projectRoot,
+ ],
+ 'showColored' => false,
+ ],
+ ],
+ ];
+
+ // Windows only test: verify that the paths are normalized to use forward slashes.
+ if (\DIRECTORY_SEPARATOR === '\\') {
+ $testData['Target paths and excluded dirs paths normalized to use forward slashes'] = [
+ 'command' => 'phpcs-check-doc-examples Scripts\DocCodeExamples Tests\Fixtures\DocCodeExamples'
+ . ' --exclude=Scripts\Utils,Tests\Fixtures\DocsXsd',
+ 'expectedChanged' => [
+ 'projectRoot' => $projectRoot,
+ 'targetPaths' => [
+ Helper::normalizePath(\realpath('Scripts/DocCodeExamples')),
+ Helper::normalizePath(\realpath('Tests/Fixtures/DocCodeExamples')),
+ ],
+ 'excludedDirs' => [
+ 'Scripts/Utils',
+ 'Tests/Fixtures/DocsXsd',
+ ],
+ ],
+ ];
+ }
+
+ return $testData;
+ }
+
+ /**
+ * Test that the help command outputs the expected text.
+ *
+ * @return void
+ */
+ public function testHelpOutput()
+ {
+ $_SERVER['argv'] = ['phpcs-check-doc-examples', '--help'];
+ new Config($this->writer);
+
+ $output = $this->writer->getStdout();
+
+ // Just check that a key element in the help text is displayed.
+ $this->assertStringContainsString('Usage:', $output);
+ $this->assertStringContainsString('phpcs-check-doc-examples', $output);
+ }
+
+ /**
+ * Test that the version command outputs the expected text.
+ *
+ * @dataProvider dataVersionOutput
+ *
+ * @param string $command The command to run.
+ *
+ * @return void
+ */
+ public function testVersionOutput($command)
+ {
+ $_SERVER['argv'] = \explode(' ', $command);
+ new Config($this->writer);
+
+ $this->assertMatchesRegularExpression(VersionTestUtils::VERSION_HEADER_REGEX, $this->writer->getStdout());
+ }
+
+ /**
+ * Data provider for the testVersionOutput method.
+ *
+ * @return array>
+ */
+ public static function dataVersionOutput()
+ {
+ return [
+ '-V' => [
+ 'command' => 'phpcs-check-doc-examples -V',
+ ],
+ '--version' => [
+ 'command' => 'phpcs-check-doc-examples --version',
+ ],
+ ];
+ }
+
+ /**
+ * Helper method: retrieve the current values of the Config properties as an array.
+ *
+ * @param Config $config Config object
+ *
+ * @return array>
+ */
+ private function getCurrentValues(Config $config): array
+ {
+ $current = [];
+ foreach ($this->defaultSettings as $name => $value) {
+ $current[$name] = $config->getProperty($name);
+ }
+
+ return $current;
+ }
+}
diff --git a/Tests/DocCodeExamples/EndToEndTest.php b/Tests/DocCodeExamples/EndToEndTest.php
new file mode 100644
index 0000000..cb4b8cc
--- /dev/null
+++ b/Tests/DocCodeExamples/EndToEndTest.php
@@ -0,0 +1,186 @@
+executeCliCommand($command);
+
+ $this->assertSame($expectedExitCode, $result['exitcode']);
+
+ $this->assertSame($expectedStdout, $result['stdout']);
+
+ if (empty($expectedStderr)) {
+ $this->assertStderrContainsOnlyScriptHeader($result['stderr']);
+ } else {
+ $this->assertMatchesRegularExpression($expectedStderr, $result['stderr']);
+ }
+ }
+
+ /**
+ * Assert that stderr contains only the script header and nothing else.
+ *
+ * @param string $stderr The stderr output to check.
+ *
+ * @return void
+ */
+ protected function assertStderrContainsOnlyScriptHeader(string $stderr)
+ {
+ $this->assertMatchesRegularExpression(VersionTestUtils::VERSION_HEADER_REGEX, $stderr);
+ }
+
+ /**
+ * Data provider for testScriptBasicExecution.
+ *
+ * @return array>
+ */
+ public static function dataScriptBasicExecution(): array
+ {
+ return [
+ 'Valid XML file' => [
+ 'cliArgs' => 'Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/CorrectExamplesStandard.xml',
+ 'expectedExitCode' => 0,
+ 'expectedStdout' => 'Checked 1 XML documentation file. All code examples are valid.' . PHP_EOL,
+ 'expectedStderr' => '',
+ ],
+ 'Directory with valid XML files (invalid files ignored via --ignore-sniffs)' => [
+ 'cliArgs' => 'Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/ --ignore-sniffs=CheckCodeExamplesStandard.Constructor.TestXmlDocValidatorConstructor,CheckCodeExamplesStandard.Examples.IncorrectInvalidExample,CheckCodeExamplesStandard.Examples.IncorrectValidExample,CheckCodeExamplesStandard.Examples.SyntaxErrorExample',
+ 'expectedExitCode' => 0,
+ 'expectedStdout' => 'Checked 7 XML documentation files. All code examples are valid.' . PHP_EOL,
+ 'expectedStderr' => '',
+ ],
+ 'Invalid XML file' => [
+ 'cliArgs' => 'Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/IncorrectValidExampleStandard.xml',
+ 'expectedExitCode' => 1,
+ 'expectedStdout' => 'Checked 1 XML documentation file. Found incorrect code examples in 1.' . PHP_EOL,
+ 'expectedStderr' => '`Errors found while processing .*?Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/IncorrectValidExampleStandard\.xml`',
+ ],
+ 'Directory with valid and invalid XML files (empty file ignored via --ignore-sniffs)' => [
+ 'cliArgs' => 'Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/ --ignore-sniffs=CheckCodeExamplesStandard.Constructor.TestXmlDocValidatorConstructor',
+ 'expectedExitCode' => 1,
+ 'expectedStdout' => 'Checked 7 XML documentation files. Found incorrect code examples in 3.' . PHP_EOL,
+ 'expectedStderr' => '`Errors found while processing .*?Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/IncorrectInvalidExampleStandard\.xml`',
+ ],
+ ];
+ }
+
+ /**
+ * If on Windows, convert forward slashes to backslashes so that paths work.
+ *
+ * @param string $path The path to convert.
+ *
+ * @return string The converted path.
+ */
+ public static function maybeConvertDirectorySeparators($path): string
+ {
+ if (DIRECTORY_SEPARATOR === '\\') {
+ return str_replace('/', '\\', $path);
+ }
+
+ return $path;
+ }
+}
diff --git a/Tests/DocCodeExamples/HelperTest.php b/Tests/DocCodeExamples/HelperTest.php
new file mode 100644
index 0000000..1871d52
--- /dev/null
+++ b/Tests/DocCodeExamples/HelperTest.php
@@ -0,0 +1,65 @@
+assertSame($expected, Helper::normalizePath($path));
+ }
+
+ /**
+ * Data provider for testing normalizePath().
+ *
+ * @return array>
+ */
+ public static function dataNormalizePath()
+ {
+ return [
+ 'Windows-style path' => [
+ 'path' => 'C:\\path\\to\\file.txt',
+ 'expected' => 'C:/path/to/file.txt',
+ ],
+ 'Unix-style path' => [
+ 'path' => '/path/to/file.txt',
+ 'expected' => '/path/to/file.txt',
+ ],
+ 'Mixed path' => [
+ 'path' => '/path\\to/file\\txt',
+ 'expected' => '/path/to/file/txt',
+ ],
+ 'Empty string' => [
+ 'path' => '',
+ 'expected' => '',
+ ],
+ ];
+ }
+}
diff --git a/Tests/DocCodeExamples/ManipulateObjectsTrait.php b/Tests/DocCodeExamples/ManipulateObjectsTrait.php
new file mode 100644
index 0000000..54b8620
--- /dev/null
+++ b/Tests/DocCodeExamples/ManipulateObjectsTrait.php
@@ -0,0 +1,57 @@
+getProperty($propertyName);
+ $property->setAccessible(true);
+ $property->setValue($object, $value);
+ $property->setAccessible(false);
+ }
+
+ /**
+ * Get a private/protected property from an object.
+ *
+ * @param object $object The object to get the property from.
+ * @param string $propertyName The name of the property to retrieve.
+ *
+ * @return mixed
+ */
+ private function getObjectProperty($object, string $propertyName)
+ {
+ $reflection = new \ReflectionClass($object);
+ $property = $reflection->getProperty($propertyName);
+ $property->setAccessible(true);
+ $value = $property->getValue($object);
+ $property->setAccessible(false);
+
+ return $value;
+ }
+}
diff --git a/Tests/DocCodeExamples/PHPCSConfigLoader.php b/Tests/DocCodeExamples/PHPCSConfigLoader.php
new file mode 100644
index 0000000..31a7907
--- /dev/null
+++ b/Tests/DocCodeExamples/PHPCSConfigLoader.php
@@ -0,0 +1,40 @@
+= 3.9.0
+ * or the regular PHPCS Config class when running the tests with PHPCS < 3.9.0.
+ */
+class PHPCSConfigLoader
+{
+
+ /**
+ * Returns an instance of the PHPCS ConfigDouble class or the PHPCS Config class when the former
+ * is not available. Necessary when running the tests with PHPCS < 3.9.0 as the ConfigDouble
+ * class is not available in those versions.
+ *
+ * @param array $cliArgs CLI arguments to pass to the PHPCS Config/ConfigDouble class.
+ *
+ * @return \PHP_CodeSniffer\Config|\PHP_CodeSniffer\Tests\ConfigDouble
+ */
+ public static function getPHPCSConfigInstance(array $cliArgs): PHPCSConfig
+ {
+ if (\class_exists('PHP_CodeSniffer\Tests\ConfigDouble') === false) {
+ return new PHPCSConfig($cliArgs);
+ }
+
+ return new PHPCSConfigDouble($cliArgs);
+ }
+}
diff --git a/Tests/DocCodeExamples/VersionTestUtils.php b/Tests/DocCodeExamples/VersionTestUtils.php
new file mode 100644
index 0000000..8f43f68
--- /dev/null
+++ b/Tests/DocCodeExamples/VersionTestUtils.php
@@ -0,0 +1,21 @@
+extractor = new CodeBlocksExtractor();
+ $cliArgs = ['--runtime-set', 'installed_paths', \realpath(self::STANDARD_DIR)];
+ $this->phpcsConfig = PHPCSConfigLoader::getPHPCSConfigInstance($cliArgs);
+ $this->writer = new TestWriter();
+ $_SERVER['argv'] = [];
+ $this->config = new Config($this->writer);
+ }
+
+ /**
+ * Test that the constructor throws exceptions for various invalid inputs.
+ *
+ * @dataProvider dataConstructorExceptions
+ *
+ * @param string $filePath Path to the file to test with.
+ * @param string $expectedMessage Expected exception message.
+ *
+ * @return void
+ */
+ public function testConstructorThrowsExceptions($filePath, $expectedMessage)
+ {
+ $this->expectException('RuntimeException');
+ $this->expectExceptionMessage($expectedMessage);
+
+ new XmlDocValidator($filePath, $this->extractor, $this->phpcsConfig, $this->writer, $this->config);
+ }
+
+ /**
+ * Data provider for testing constructor exceptions.
+ *
+ * @return array>
+ */
+ public static function dataConstructorExceptions()
+ {
+ return [
+ 'non-existent file' => [
+ 'filePath' => '/non/existent/file.xml',
+ 'expectedMessage' => 'The XML file "/non/existent/file.xml" does not exist.',
+ ],
+ 'invalid file extension' => [
+ 'filePath' => __DIR__ . '/../Fixtures/DocCodeExamples/CheckDocPathStandard/Docs/Category/SniffName.txt',
+ 'expectedMessage' => 'The XML file "SniffName.txt" is invalid. File names should end in "Standard.xml".',
+ ],
+ 'invalid directory structure: missing category dir' => [
+ 'filePath' => __DIR__ . '/../Fixtures/DocCodeExamples/CheckDocPathStandard/Docs/MissingCategoryDirStandard.xml',
+ 'expectedMessage' => 'Invalid directory structure in the XML file path. Expected: {STANDARD_NAME}/Docs/{CATEGORY_NAME}/MissingCategoryDirStandard.xml.',
+ ],
+ 'invalid directory structure: missing sniffs dir' => [
+ 'filePath' => __DIR__ . '/../Fixtures/DocCodeExamples/CheckDocPathStandard/Category/MissingDocsDirStandard.xml',
+ 'expectedMessage' => 'Invalid directory structure in the XML file path. Expected: {STANDARD_NAME}/Docs/{CATEGORY_NAME}/MissingDocsDirStandard.xml.',
+ ],
+ 'standard is not installed in PHPCS' => [
+ 'filePath' => __DIR__ . '/../Fixtures/DocCodeExamples/UninstalledStandard/Docs/Category/TestErrorWhenStandardIsNotInstalledStandard.xml',
+ 'expectedMessage' => 'The standard "UninstalledStandard" is not installed in PHPCS.',
+ ],
+ ];
+ }
+
+ /**
+ * Test that the constructor correctly sets the properties.
+ *
+ * @return void
+ */
+ public function testConstructor()
+ {
+ $fixturePath = __DIR__ . '/../Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Constructor/TestXmlDocValidatorConstructorStandard.xml';
+
+ $xmlDocFile = new XmlDocValidator($fixturePath, $this->extractor, $this->phpcsConfig, $this->writer, $this->config);
+
+ $this->assertSame($fixturePath, $xmlDocFile->path);
+ $this->assertSame('CheckCodeExamplesStandard', $xmlDocFile->standard);
+ $this->assertSame('CheckCodeExamplesStandard.Constructor.TestXmlDocValidatorConstructor', $xmlDocFile->sniff);
+ }
+
+ /**
+ * Test the validate() method. It should return 0 when the code examples match the
+ * expectation (an error when it is an invalid example and no errors when it is a valid example)
+ * and 1 when it doesn't. It should also output any errors found.
+ *
+ * @param string $xmlPath The path to a sniff documentation XML file.
+ * @param string $expectedErrorMessage The expected error messages.
+ * @param int $expectedReturnValue The expected return value.
+ * @param bool $useColor Whether to use color in the output.
+ *
+ * @dataProvider dataValidate
+ *
+ * @return void
+ */
+ public function testValidate($xmlPath, $expectedErrorMessage, $expectedReturnValue, $useColor = false)
+ {
+ $this->setObjectProperty($this->config, 'showColored', $useColor);
+ $xmlDocValidator = new XmlDocValidator($xmlPath, $this->extractor, $this->phpcsConfig, $this->writer, $this->config);
+
+ $this->assertSame($expectedReturnValue, $xmlDocValidator->validate());
+ $this->assertSame($expectedErrorMessage, $this->writer->getStderr());
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array>
+ */
+ public static function dataValidate()
+ {
+ return [
+ 'All code examples match the expectation' => [
+ 'xmlPath' => 'Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/CorrectExamplesStandard.xml',
+ 'expectedErrorMessage' => '',
+ 'expectedReturnValue' => true,
+ ],
+ 'One valid code example which is actually invalid' => [
+ 'xmlPath' => 'Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/IncorrectValidExampleStandard.xml',
+ 'expectedErrorMessage' => 'Errors found while processing Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/IncorrectValidExampleStandard.xml' . PHP_EOL . PHP_EOL .
+ 'ERROR: Code block is valid and PHPCS should have returned nothing, but instead it returned an error.' . PHP_EOL .
+ 'Code block title: "Valid: invalid valid code example."' . PHP_EOL .
+ 'Code block content: "function sniffValidationWillFail() {}"' . PHP_EOL . PHP_EOL,
+ 'expectedReturnValue' => false,
+ ],
+ 'One invalid code example which is actually valid' => [
+ 'xmlPath' => 'Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/IncorrectInvalidExampleStandard.xml',
+ 'expectedErrorMessage' => 'Errors found while processing Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/IncorrectInvalidExampleStandard.xml' . PHP_EOL . PHP_EOL .
+ 'ERROR: Code block is invalid and PHPCS should have returned an error message, but instead it returned nothing.' . PHP_EOL .
+ 'Code block title: "Invalid: invalid code examples."' . PHP_EOL .
+ 'Code block content: "function sniffValidationWillPass() {}"' . PHP_EOL . PHP_EOL,
+ 'expectedReturnValue' => false,
+ ],
+ 'Code example with syntax error' => [
+ 'xmlPath' => 'Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/SyntaxErrorExampleStandard.xml',
+ 'expectedErrorMessage' => 'Errors found while processing Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/SyntaxErrorExampleStandard.xml' . PHP_EOL . PHP_EOL .
+ 'ERROR: There is a syntax error in the code block.' . PHP_EOL .
+ 'syntax error, unexpected end of file' . PHP_EOL . PHP_EOL .
+ 'Code block title: "Valid: syntax error example."' . PHP_EOL .
+ 'Code block content: "sniffValidationWillPass() // Syntax error: missing semicolon."' . PHP_EOL . PHP_EOL .
+ 'ERROR: There is a syntax error in the code block.' . PHP_EOL .
+ 'syntax error, unexpected end of file' . PHP_EOL . PHP_EOL .
+ 'Code block title: "Invalid: syntax error example."' . PHP_EOL .
+ 'Code block content: "sniffValidationWillFail() // Syntax error: missing semicolon."' . PHP_EOL . PHP_EOL,
+ 'expectedReturnValue' => false,
+ ],
+ 'Code example from a sniff that uses PHPCSUtils caching' => [
+ 'xmlPath' => 'Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/PhpcsUtilsCacheStandard.xml',
+ 'expectedErrorMessage' => '',
+ 'expectedReturnValue' => true,
+ ],
+ 'Code examples testing adding or not the PHP open tag' => [
+ 'xmlPath' => 'Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/PhpOpenTagStandard.xml',
+ 'expectedErrorMessage' => '',
+ 'expectedReturnValue' => true,
+ ],
+ 'Code example with syntax error using colorized output' => [
+ 'xmlPath' => 'Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/SyntaxErrorExampleStandard.xml',
+ 'expectedErrorMessage' => "\033[31mErrors found while processing Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/SyntaxErrorExampleStandard.xml\033[0m" . PHP_EOL . PHP_EOL .
+ "\033[31mERROR: \033[0mThere is a syntax error in the code block." . PHP_EOL .
+ 'syntax error, unexpected end of file' . PHP_EOL . PHP_EOL .
+ 'Code block title: "Valid: syntax error example."' . PHP_EOL .
+ 'Code block content: "sniffValidationWillPass() // Syntax error: missing semicolon."' . PHP_EOL . PHP_EOL .
+ "\033[31mERROR: \033[0mThere is a syntax error in the code block." . PHP_EOL .
+ 'syntax error, unexpected end of file' . PHP_EOL . PHP_EOL .
+ 'Code block title: "Invalid: syntax error example."' . PHP_EOL .
+ 'Code block content: "sniffValidationWillFail() // Syntax error: missing semicolon."' . PHP_EOL . PHP_EOL,
+ 'expectedReturnValue' => false,
+ 'useColor' => true,
+ ],
+ ];
+ }
+}
diff --git a/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Constructor/TestXmlDocValidatorConstructorStandard.xml b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Constructor/TestXmlDocValidatorConstructorStandard.xml
new file mode 100644
index 0000000..e69de29
diff --git a/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/CorrectExamplesStandard.xml b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/CorrectExamplesStandard.xml
new file mode 100644
index 0000000..06c6e8e
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/CorrectExamplesStandard.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ block containing valid and invalid code examples that are correct. The valid
+ examples don't violate the sniff, and the invalid examples do.
+ ]]>
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/IncorrectInvalidExampleStandard.xml b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/IncorrectInvalidExampleStandard.xml
new file mode 100644
index 0000000..b925091
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/IncorrectInvalidExampleStandard.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/IncorrectValidExampleStandard.xml b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/IncorrectValidExampleStandard.xml
new file mode 100644
index 0000000..8ece159
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/IncorrectValidExampleStandard.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/PhpOpenTagStandard.xml b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/PhpOpenTagStandard.xml
new file mode 100644
index 0000000..d8728db
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/PhpOpenTagStandard.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+
+ ]]>
+
+
+
+
+ = $text ?>
+ ]]>
+
+
+
diff --git a/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/PhpcsUtilsCacheStandard.xml b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/PhpcsUtilsCacheStandard.xml
new file mode 100644
index 0000000..964c30d
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/PhpcsUtilsCacheStandard.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/SyntaxErrorExampleStandard.xml b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/SyntaxErrorExampleStandard.xml
new file mode 100644
index 0000000..24b94d3
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Docs/Examples/SyntaxErrorExampleStandard.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Sniffs/Examples/BaseExamplesSniff.php b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Sniffs/Examples/BaseExamplesSniff.php
new file mode 100644
index 0000000..4a31cb0
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Sniffs/Examples/BaseExamplesSniff.php
@@ -0,0 +1,28 @@
+getTokens()[$stackPtr]['content'] === 'sniffValidationWillFail') {
+ $phpcsFile->addError('This is a error', $stackPtr, 'Error');
+ }
+ }
+}
diff --git a/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Sniffs/Examples/CorrectExamplesSniff.php b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Sniffs/Examples/CorrectExamplesSniff.php
new file mode 100644
index 0000000..198855e
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Sniffs/Examples/CorrectExamplesSniff.php
@@ -0,0 +1,11 @@
+addError('This is a error', $stackPtr, 'TestError');
+ }
+ }
+
+}
diff --git a/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Sniffs/Examples/SyntaxErrorExampleSniff.php b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Sniffs/Examples/SyntaxErrorExampleSniff.php
new file mode 100644
index 0000000..2ad10ed
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CheckCodeExamplesStandard/Sniffs/Examples/SyntaxErrorExampleSniff.php
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/Tests/Fixtures/DocCodeExamples/CheckDocPathStandard/Category/MissingDocsDirStandard.xml b/Tests/Fixtures/DocCodeExamples/CheckDocPathStandard/Category/MissingDocsDirStandard.xml
new file mode 100644
index 0000000..e69de29
diff --git a/Tests/Fixtures/DocCodeExamples/CheckDocPathStandard/Docs/Category/SniffName.txt b/Tests/Fixtures/DocCodeExamples/CheckDocPathStandard/Docs/Category/SniffName.txt
new file mode 100644
index 0000000..7630413
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CheckDocPathStandard/Docs/Category/SniffName.txt
@@ -0,0 +1 @@
+XML documentation files should end with the "Standard.xml" suffix
diff --git a/Tests/Fixtures/DocCodeExamples/CheckDocPathStandard/Docs/MissingCategoryDirStandard.xml b/Tests/Fixtures/DocCodeExamples/CheckDocPathStandard/Docs/MissingCategoryDirStandard.xml
new file mode 100644
index 0000000..e69de29
diff --git a/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/EmptyFile.xml b/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/EmptyFile.xml
new file mode 100644
index 0000000..e69de29
diff --git a/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/InvalidXMLFile.xml b/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/InvalidXMLFile.xml
new file mode 100644
index 0000000..41318be
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/InvalidXMLFile.xml
@@ -0,0 +1 @@
+Not a valid XML file.
diff --git a/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/NoCodeBlocks.xml b/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/NoCodeBlocks.xml
new file mode 100644
index 0000000..c3e7fe2
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/NoCodeBlocks.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/OneValidBlock.xml b/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/OneValidBlock.xml
new file mode 100644
index 0000000..2a1d851
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/OneValidBlock.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/ValidAndInvalidBlocks.xml b/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/ValidAndInvalidBlocks.xml
new file mode 100644
index 0000000..e0b57fe
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/CodeBlocksExtractor/ValidAndInvalidBlocks.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ block in this test file with valid and invalid code blocks.
+ ]]>
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/Fixtures/DocCodeExamples/InvalidPathToDocStandard.xml b/Tests/Fixtures/DocCodeExamples/InvalidPathToDocStandard.xml
new file mode 100644
index 0000000..65ea34f
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/InvalidPathToDocStandard.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/Tests/Fixtures/DocCodeExamples/UninstalledStandard/Docs/Category/TestErrorWhenStandardIsNotInstalledStandard.xml b/Tests/Fixtures/DocCodeExamples/UninstalledStandard/Docs/Category/TestErrorWhenStandardIsNotInstalledStandard.xml
new file mode 100644
index 0000000..9039a1c
--- /dev/null
+++ b/Tests/Fixtures/DocCodeExamples/UninstalledStandard/Docs/Category/TestErrorWhenStandardIsNotInstalledStandard.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/Tests/Utils/HelpTextFormatterTest.php b/Tests/Utils/HelpTextFormatterTest.php
new file mode 100644
index 0000000..195a3c3
--- /dev/null
+++ b/Tests/Utils/HelpTextFormatterTest.php
@@ -0,0 +1,114 @@
+>> $helpTexts The help texts to format.
+ * @param string $expected The expected formatted output.
+ * @param bool $useColor Whether to use colored output.
+ *
+ * @return void
+ */
+ public function testFormat(array $helpTexts, $expected, $useColor)
+ {
+ $this->assertSame($expected, HelpTextFormatter::format($helpTexts, $useColor));
+ }
+
+ /**
+ * Data provider for testing the format() method.
+ *
+ * @return array>>|string|bool>>
+ */
+ public static function dataFormat()
+ {
+ return [
+ 'empty help texts' => [
+ 'helpTexts' => [],
+ 'expected' => '',
+ 'useColor' => false,
+ ],
+ 'empty section' => [
+ 'helpTexts' => [
+ 'Empty Section' => [],
+ ],
+ 'expected' => 'Empty Section:' . PHP_EOL . PHP_EOL,
+ 'useColor' => false,
+ ],
+ 'without color' => [
+ 'helpTexts' => [
+ 'Usage' => [
+ [
+ 'text' => 'Command [options]',
+ ],
+ ],
+ 'Options' => [
+ [
+ 'arg' => '--help',
+ 'desc' => 'Display this help message.',
+ ],
+ [
+ 'arg' => '--version',
+ 'desc' => 'Display version information.',
+ ],
+ [
+ 'arg' => '',
+ 'desc' => 'The file to process. This is a test with multiple sentences. Each sentence should be properly wrapped.',
+ ],
+ ],
+ ],
+ 'expected' => 'Usage:' . PHP_EOL .
+ ' Command [options]' . PHP_EOL . PHP_EOL .
+ 'Options:' . PHP_EOL .
+ ' --help Display this help message.' . PHP_EOL .
+ ' --version Display version information.' . PHP_EOL .
+ ' The file to process.' . PHP_EOL .
+ ' This is a test with multiple sentences.' . PHP_EOL .
+ ' Each sentence should be properly wrapped.' . PHP_EOL . PHP_EOL,
+ 'useColor' => false,
+ ],
+ 'with color' => [
+ 'helpTexts' => [
+ 'Usage' => [
+ [
+ 'text' => 'Command [options]',
+ ],
+ ],
+ 'Options' => [
+ [
+ 'arg' => '--help',
+ 'desc' => 'Display this help message.',
+ ],
+ ],
+ ],
+ 'expected' => "\033[33mUsage:\033[0m" . PHP_EOL .
+ " Command \033[36m[options]\033[0m" . PHP_EOL . PHP_EOL .
+ "\033[33mOptions:\033[0m" . PHP_EOL .
+ " \033[32m--help\033[0m Display this help message." . PHP_EOL . PHP_EOL,
+ 'useColor' => true,
+ ],
+ ];
+ }
+}
diff --git a/bin/phpcs-check-doc-examples b/bin/phpcs-check-doc-examples
new file mode 100644
index 0000000..c47f188
--- /dev/null
+++ b/bin/phpcs-check-doc-examples
@@ -0,0 +1,87 @@
+#!/usr/bin/env php
+getProperty('executeCheck') === false) {
+ // This was a help request.
+ exit(0);
+ }
+
+ $extractor = new PHPCSDevTools\Scripts\DocCodeExamples\CodeBlocksExtractor();
+
+ // It is necessary to reset argv to avoid issues with the PHPCS Config,
+ // as it also checks for command line arguments.
+ $_SERVER['argv'] = [];
+ $phpcsConfig = new PHP_CodeSniffer\Config();
+
+ $check = new PHPCSDevTools\Scripts\DocCodeExamples\Check($config, $extractor, $phpcsConfig, $writer);
+ exit($check->run());
+} catch (RuntimeException $e) {
+ $message = $e->getMessage();
+
+ if ($config->getProperty('showColored') === true) {
+ $message = "\033[31m{$message}\033[0m";
+ }
+
+ echo $message . PHP_EOL;
+ exit(1);
+}
diff --git a/composer.json b/composer.json
index 8feec7b..e0f6f31 100644
--- a/composer.json
+++ b/composer.json
@@ -52,7 +52,8 @@
}
},
"bin": [
- "bin/phpcs-check-feature-completeness"
+ "bin/phpcs-check-feature-completeness",
+ "bin/phpcs-check-doc-examples"
],
"minimum-stability": "dev",
"prefer-stable": true,
@@ -63,6 +64,9 @@
"lintlt72": [
"@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --show-deprecated --exclude vendor --exclude .git --exclude .github/build"
],
+ "lintlt70": [
+ "@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --show-deprecated --exclude vendor --exclude .git --exclude .github/build --exclude Scripts/DocCodeExamples --exclude Tests/DocCodeExamples"
+ ],
"checkcs": [
"@php ./vendor/squizlabs/php_codesniffer/bin/phpcs"
],
@@ -78,6 +82,9 @@
"test-tools": [
"@php ./vendor/phpunit/phpunit/phpunit --testsuite DevTools"
],
+ "test-doc-code-examples": [
+ "@php ./vendor/phpunit/phpunit/phpunit --testsuite DocCodeExamples"
+ ],
"test-lte9": [
"@php ./vendor/phpunit/phpunit/phpunit -c phpunitlte9.xml.dist"
],
@@ -87,6 +94,9 @@
"test-tools-lte9": [
"@php ./vendor/phpunit/phpunit/phpunit -c phpunitlte9.xml.dist --testsuite DevTools"
],
+ "test-doc-code-examples-lte9": [
+ "@php ./vendor/phpunit/phpunit/phpunit -c phpunitlte9.xml.dist --testsuite DocCodeExamples"
+ ],
"check-complete": [
"@php ./bin/phpcs-check-feature-completeness ./PHPCSDebug"
]
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 426a5c5..f524727 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -62,4 +62,90 @@
/Tests/*\.php$
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
+
+ /Scripts/DocCodeExamples/*\.php$
+ /Tests/DocCodeExamples/*\.php$
+
+
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 1e48290..716fe8b 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -28,6 +28,10 @@
./Tests/DocsXsd/
./Tests/FeatureComplete/
+ ./Tests/Utils/
+
+
+ ./Tests/DocCodeExamples/
diff --git a/phpunitlte9.xml.dist b/phpunitlte9.xml.dist
index 047630d..9a7b955 100644
--- a/phpunitlte9.xml.dist
+++ b/phpunitlte9.xml.dist
@@ -19,6 +19,10 @@
./Tests/DocsXsd/
./Tests/FeatureComplete/
+ ./Tests/Utils/
+
+
+ ./Tests/DocCodeExamples/