diff --git a/moodle/Sniffs/Commenting/FileExpectedTagsSniff.php b/moodle/Sniffs/Commenting/FileExpectedTagsSniff.php new file mode 100644 index 0000000..621ebe9 --- /dev/null +++ b/moodle/Sniffs/Commenting/FileExpectedTagsSniff.php @@ -0,0 +1,160 @@ +. + +namespace MoodleHQ\MoodleCS\moodle\Sniffs\Commenting; + +use MoodleHQ\MoodleCS\moodle\Util\Docblocks; +use MoodleHQ\MoodleCS\moodle\Util\TokenUtil; +use PHP_CodeSniffer\Config; +use PHP_CodeSniffer\Sniffs\Sniff; +use PHP_CodeSniffer\Files\File; +use PHPCSUtils\Tokens\Collections; + +/** + * Checks that a file has appropriate tags in either the file, or single artefact block. + * + * @copyright 2024 Andrew Lyons + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class FileExpectedTagsSniff implements Sniff +{ + /** + * The regular expression used to match the expected license. + * + * Note that the regular expression is applied using preg_quote to escape as required. + * + * Note that, if the regular expression is the empty string, + * then this Sniff will do nothing. + * + * Example values: + * - Empty string or null: No check is done. + * '' + * - The GNU GPL v3 or later license with either http or https license text + * '@https?://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later@': + * + * @var null|string + */ + public ?string $preferredLicenseRegex = '@https?://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later@'; + + /** + * Register for open tag (only process once per file). + */ + public function register() { + return [ + T_OPEN_TAG, + ]; + } + + /** + * Processes php files and perform various checks with file. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr The position in the stack. + */ + public function process(File $phpcsFile, $stackPtr) { + // Get the stack pointer for the file-level docblock. + $stackPtr = Docblocks::getDocBlockPointer($phpcsFile, $stackPtr); + if ($stackPtr === null) { + // There is no file-level docblock. + + if (TokenUtil::countGlobalScopesInFile($phpcsFile) > 1) { + // There are more than one item in the global scope. + // Only accept the file docblock. + return; + } else { + // There is only one item in the global scope. + // We can accept the file docblock or the item docblock. + $stackPtr = $phpcsFile->findNext(Collections::closedScopes(), 0); + } + } + + $this->processFileCopyright($phpcsFile, $stackPtr); + $this->processFileLicense($phpcsFile, $stackPtr); + } + + /** + * Process the file docblock and check for the presence of a @copyright tag. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr The position in the stack. + */ + private function processFileCopyright(File $phpcsFile, $stackPtr): void { + $copyrightTokens = Docblocks::getMatchingDocTags($phpcsFile, $stackPtr, '@copyright'); + if (empty($copyrightTokens)) { + $docPtr = Docblocks::getDocBlockPointer($phpcsFile, $stackPtr); + if (empty($docPtr)) { + $docPtr = $stackPtr; + } + + $phpcsFile->addError( + 'Missing @copyright tag', + $docPtr, + 'CopyrightTagMissing' + ); + return; + } + } + + /** + * Process the file docblock and check for the presence of a @license tag. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr The position in the stack. + */ + private function processFileLicense(File $phpcsFile, $stackPtr): void { + $tokens = $phpcsFile->getTokens(); + $foundTokens = Docblocks::getMatchingDocTags($phpcsFile, $stackPtr, '@license'); + if (empty($foundTokens)) { + $docPtr = Docblocks::getDocBlockPointer($phpcsFile, $stackPtr); + if ($docPtr) { + $phpcsFile->addError( + 'Missing @license tag', + $docPtr, + 'LicenseTagMissing' + ); + } else { + $phpcsFile->addError( + 'Missing @license tag', + $stackPtr, + 'LicenseTagMissing' + ); + } + return; + } + + // If specified, get the regular expression from the config. + if (($regex = Config::getConfigData('moodleLicenseRegex')) !== null) { + $this->preferredLicenseRegex = $regex; + } + + if ($this->preferredLicenseRegex === '') { + return; + } + + $licensePtr = $phpcsFile->findNext(T_DOC_COMMENT_STRING, $foundTokens[0]); + $license = $tokens[$licensePtr]['content']; + + if (!preg_match($this->preferredLicenseRegex, $license)) { + $phpcsFile->addWarning( + 'Invalid @license tag. Value "%s" does not match expected format', + $licensePtr, + 'LicenseTagInvalid', + [$license] + ); + } + } +} diff --git a/moodle/Tests/Sniffs/Commenting/FileExpectedTagsSniffTest.php b/moodle/Tests/Sniffs/Commenting/FileExpectedTagsSniffTest.php new file mode 100644 index 0000000..40f483a --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/FileExpectedTagsSniffTest.php @@ -0,0 +1,162 @@ +. + +namespace MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting; + +use MoodleHQ\MoodleCS\moodle\Tests\MoodleCSBaseTestCase; + +/** + * Test the VariableCommentSniff sniff. + * + * @copyright 2024 onwards Andrew Lyons + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @covers \MoodleHQ\MoodleCS\moodle\Sniffs\Commenting\FileExpectedTagsSniff + */ +class FileExpectedTagsSniffTest extends MoodleCSBaseTestCase +{ + /** + * @dataProvider fixtureProvider + */ + public function testSniffWithFixtures( + string $fixture, + array $errors, + array $warnings, + array $configValues + ): void { + $this->setStandard('moodle'); + $this->setSniff('moodle.Commenting.FileExpectedTags'); + $this->setFixture(sprintf("%s/fixtures/FileExpectedTags/%s.php", __DIR__, $fixture)); + $this->setErrors($errors); + $this->setWarnings($warnings); + + foreach ($configValues as $configKey => $configValue) { + $this->addCustomConfig($configKey, $configValue); + } + + $this->verifyCsResults(); + } + + public static function fixtureProvider(): array { + $cases = [ + 'Single artifact, Single docblock' => [ + 'fixture' => 'single_artifact_single_docblock', + 'errors' => [], + 'warnings' => [], + 'configValues' => [], + ], + 'Single artifact, Single docblock (missing)' => [ + 'fixture' => 'single_artifact_single_docblock_missing', + 'errors' => [ + 3 => [ + 'Missing @copyright tag', + 'Missing @license tag', + ], + ], + 'warnings' => [], + 'configValues' => [], + ], + 'Single artifact, Single docblock (missing tags)' => [ + 'fixture' => 'single_artifact_single_docblock_missing_tags', + 'errors' => [ + 3 => [ + 'Missing @copyright tag', + 'Missing @license tag', + ], + ], + 'warnings' => [], + 'configValues' => [], + ], + 'Single artifact, multiple docblocks' => [ + 'fixture' => 'single_artifact_multiple_docblock', + 'errors' => [ + ], + 'warnings' => [], + 'configValues' => [], + ], + 'Single artifact, multiple docblocks (missing)' => [ + 'fixture' => 'single_artifact_multiple_docblock_missing', + 'errors' => [ + // Note: Covered by MissingDocblockSniff. + ], + 'warnings' => [], + 'configValues' => [], + ], + 'Single artifact, multiple docblocks (wrong)' => [ + 'fixture' => 'single_artifact_multiple_docblock_wrong', + 'errors' => [], + 'warnings' => [], + 'configValues' => [], + ], + 'Multiple artifacts, File docblock' => [ + 'fixture' => 'multiple_artifact_has_file_docblock', + 'errors' => [], + 'warnings' => [], + 'configValues' => [], + ], + 'Multiple artifacts, File docblock (missing)' => [ + 'fixture' => 'multiple_artifact_has_file_docblock_missing', + 'errors' => [], + 'warnings' => [], + 'configValues' => [], + ], + 'Multiple artifacts, File docblock (wrong data)' => [ + 'fixture' => 'multiple_artifact_has_file_docblock_wrong', + 'errors' => [ + 3 => 'Missing @copyright tag', + ], + 'warnings' => [ + 6 => 'Invalid @license tag. Value "Me!" does not match expected format', + ], + 'configValues' => [], + ], + 'Multiple artifacts, File docblock, Custom license value set' => [ + 'fixture' => 'multiple_artifact_has_file_docblock_wrong', + 'errors' => [ + 3 => 'Missing @copyright tag', + ], + 'warnings' => [], + 'configValues' => [ + 'moodleLicenseRegex' => '@Me!@', + ], + ], + 'Multiple artifacts, File docblock, No license value set' => [ + 'fixture' => 'multiple_artifact_has_file_docblock_wrong', + 'errors' => [ + 3 => 'Missing @copyright tag', + ], + 'warnings' => [], + 'configValues' => [ + 'moodleLicenseRegex' => '', + ], + ], + 'Multiple artifacts, File docblock (missing tags)' => [ + 'fixture' => 'multiple_artifact_has_file_docblock_missing_tags', + 'errors' => [ + 3 => [ + 'Missing @copyright tag', + 'Missing @license tag', + ], + ], + 'warnings' => [], + 'configValues' => [], + ], + ]; + + return $cases; + } +} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/multiple_artifact_has_file_docblock.php b/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/multiple_artifact_has_file_docblock.php new file mode 100644 index 0000000..8a70c47 --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/multiple_artifact_has_file_docblock.php @@ -0,0 +1,23 @@ + + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Class docblock. + * + */ +class multiple_artifact_has_file_docblock +{ + /** + * @var string + */ + private string $name; +} + +trait example_trait {} +interface example_interface {} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/multiple_artifact_has_file_docblock_missing.php b/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/multiple_artifact_has_file_docblock_missing.php new file mode 100644 index 0000000..74acb60 --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/multiple_artifact_has_file_docblock_missing.php @@ -0,0 +1,16 @@ + + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Class docblock. + */ +class single_artifact_multiple_docblock +{ + /** + * @var string + */ + private string $name; +} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/single_artifact_multiple_docblock_missing.php b/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/single_artifact_multiple_docblock_missing.php new file mode 100644 index 0000000..1553104 --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/single_artifact_multiple_docblock_missing.php @@ -0,0 +1,17 @@ + + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Class docblock. + */ +class single_artifact_multiple_docblock_missing +{ + /** + * @var string + */ + private string $name; +} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/single_artifact_single_docblock.php b/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/single_artifact_single_docblock.php new file mode 100644 index 0000000..2c73ea1 --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/single_artifact_single_docblock.php @@ -0,0 +1,15 @@ + + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class single_class_single_docblock +{ + /** + * @var string + */ + private string $name; +} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/single_artifact_single_docblock_missing.php b/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/single_artifact_single_docblock_missing.php new file mode 100644 index 0000000..10be8d3 --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/FileExpectedTags/single_artifact_single_docblock_missing.php @@ -0,0 +1,9 @@ +process(); + + $this->assertEquals($expectedCount, TokenUtil::countGlobalScopesInFile($phpcsFile)); + } + + public static function countGlobalScopesInFileProvider(): array { + $cases = [ + 'No global scopes' => [ + ' [ + ' [ + ' [ + ' [ + ' [ + ' [ + '= 0) { + $cases['Enums count'] = [ + 'getTokens(); + $artifactCount = 0; + $find = Tokens::$ooScopeTokens; + $find[] = T_FUNCTION; + + $typePtr = 0; + while ($typePtr = $phpcsFile->findNext($find, $typePtr + 1)) { + $token = $tokens[$typePtr]; + if ($token['code'] === T_FUNCTION && !empty($token['conditions'])) { + // Skip methods of classes, traits and interfaces. + continue; + } + + $artifactCount++; + } + + return $artifactCount; + } }