diff --git a/moodle/Sniffs/Files/BoilerplateCommentSniff.php b/moodle/Sniffs/Files/BoilerplateCommentSniff.php index 57efe20..f87fd7b 100644 --- a/moodle/Sniffs/Files/BoilerplateCommentSniff.php +++ b/moodle/Sniffs/Files/BoilerplateCommentSniff.php @@ -1,5 +1,4 @@ getTokens(); + // Allow declare() call in same line as opening tag. + if (!str_contains($tokens[$stackptr]['content'], "\n")) { + if (array_key_exists($stackptr + 1, $tokens) && $tokens[$stackptr + 1]['code'] === T_DECLARE) { + + // Don't use findEndOfStatement() as a declare statement can legally be followed directly by an expression. + $stackptr = $tokens[$stackptr + 1]['parenthesis_closer']; + + // Invalid syntax if nothing after declare. + if ($stackptr === count($tokens) - 1) { + return; + } + + $stackptr++; + + // Allow multi-line whitespace as it will be picked up by SemicolonSpacingSniff. + while ($tokens[$stackptr]['code'] === T_WHITESPACE && array_key_exists($stackptr + 1, $tokens)) { + $stackptr++; + } + + if ($tokens[$stackptr]['code'] !== T_SEMICOLON) { + $file->addError( + 'First line declare may only be followed by whitespace and semicolon', + $stackptr, + 'InvalidDeclare' + ); + return; + } + + if ($stackptr < count($tokens) - 1) { + $stackptr++; + + if ($tokens[$stackptr]['code'] !== T_WHITESPACE || $tokens[$stackptr]['content'] !== "\n") { + $file->addError( + 'Declare statement may only be followed by a new line', + $stackptr, + 'InvalidTrailingCode' + ); + + return; + } + } + + } + + } + // Allow T_PHPCS_XXX comment annotations in the first line (skip them). if ($commentptr = $file->findNext(Tokens::$phpcsCommentTokens, $stackptr + 1, $stackptr + 3)) { $stackptr = $commentptr; } - // Find count the number of newlines after the opening addFixableError( + 'Moodle boilerplate not found before end of file', + $stackptr, + 'EndOfFile' + ); + + if ($fix) { + $this->insertBoilerplate($file, $stackptr); } + return; } - if ($numnewlines > 0) { - $file->addError( - 'The opening addFixableError( + 'Moodle boilerplate not found at first line', + $expectedafter + 1, + 'NotAtFirstLine' ); - return; + + if ($fix) { + $this->moveOrInsertBoilerplate($file, $expectedafter); + return; + } + + // If boilerplate found after an acceptable amount of whitespace, + // allow it to be checked. + $nextnonwhitespace = $file->findNext(T_WHITESPACE, $stackptr + 1, $stackptr + 5, true); + if ($nextnonwhitespace === false || $tokens[$nextnonwhitespace]['code'] !== T_COMMENT) { + return; + } + + $firstcommentlocation = $nextnonwhitespace; } - $offset = $stackptr + $numnewlines + 1; // Now check the text of the comment. foreach (self::$comment as $lineindex => $line) { - $tokenptr = $offset + $lineindex; + $tokenptr = $firstcommentlocation + $lineindex; if (!array_key_exists($tokenptr, $tokens)) { - $file->addError('Reached the end of the file before finding ' . - 'all of the opening comment.', $tokenptr - 1, 'FileTooShort'); + $fix = $file->addFixableError( + 'Reached the end of the file before finding ' . + 'all of the opening comment.', + $tokenptr - 1, + 'FileTooShort' + ); + if ($fix) { + $this->completeBoilerplate($file, $tokenptr - 1, $lineindex); + } return; } + if ($tokens[$tokenptr]['code'] != T_COMMENT) { + $fix = $file->addFixableError('Comment does not contain full Moodle boilerplate', + $tokenptr, + 'CommentEndedTooSoon'); + if ($fix) { + $this->completeBoilerplate($file, $tokenptr, $lineindex); + } + break; + } + $regex = str_replace( ['Moodle', 'http\\:'], ['.*', 'https?\\:'], '/^' . preg_quote($line, '/') . '/' ); - if ( - $tokens[$tokenptr]['code'] != T_COMMENT || - !preg_match($regex, $tokens[$tokenptr]['content']) - ) { + if (!preg_match($regex, $tokens[$tokenptr]['content'])) { $file->addError( 'Line %s of the opening comment must start "%s".', $tokenptr, @@ -120,4 +198,80 @@ public function process(File $file, $stackptr) { } } } + + private function fullComment(): array { + $result = self::$comment; + $result[0] = '// This file is part of Moodle - https://moodle.org/'; + return $result; + } + + private function insertBoilerplate(File $file, int $stackptr): void { + $prefix = str_ends_with($file->getTokens()[$stackptr]['content'], "\n") ? '' : "\n"; + $file->fixer->addContent($stackptr, $prefix . implode("\n", $this->fullComment()) . "\n"); + } + + private function moveOrInsertBoilerplate(File $file, int $stackptr): void { + $tokens = $file->getTokens(); + + $firstcomment = $file->findNext(T_COMMENT, $stackptr); + + if ($firstcomment === false) { + $this->insertBoilerplate($file, $stackptr); + return; + } + + $file->fixer->beginChangeset(); + + // If we have only whitespace between expected location and first comment, just remove it. + $nextnonwhitespace = $file->findPrevious(T_WHITESPACE, $firstcomment - 1, $stackptr, true); + + // The token the boilerplate is expected after may well be a whitespace node. + if ($nextnonwhitespace === false) { + $nextnonwhitespace = $stackptr; + } + + foreach (range($nextnonwhitespace + 1, $firstcomment - 1) as $whitespaceptr) { + $file->fixer->replaceToken($whitespaceptr, ''); + } + + // If there's nothing else between the first comment and the expected location we're done. + if ($nextnonwhitespace === $stackptr) { + $file->fixer->endChangeset(); + return; + } + + // Otherwise shift existing comment to correct place. + $existingboilerplate = []; + foreach (self::$comment as $lineindex => $line) { + $tokenptr = $firstcomment + $lineindex; + + $regex = str_replace( + ['Moodle', 'http\\:'], + ['.*', 'https?\\:'], + '/^' . preg_quote($line, '/') . '/' + ); + + if ($tokens[$tokenptr]['code'] != T_COMMENT || !preg_match($regex, $tokens[$tokenptr]['content'])) { + $file->fixer->rollbackChangeset(); + $this->insertBoilerplate($file, $stackptr); + return; + } + + $existingboilerplate[] = $tokens[$tokenptr]['content']; + } + + $file->fixer->addContent($stackptr, implode("", $existingboilerplate) . "\n"); + + foreach (array_keys(self::$comment) as $i) { + $tokenptr = $firstcomment + $i; + $file->fixer->replaceToken($tokenptr, ''); + } + + $file->fixer->endChangeset(); + + } + + private function completeBoilerplate(File $file, $stackptr, int $lineindex): void { + $file->fixer->addContentBefore($stackptr, implode("\n", array_slice($this->fullComment(), $lineindex)) . "\n"); + } } diff --git a/moodle/Tests/FilesBoilerPlateCommentTest.php b/moodle/Tests/FilesBoilerPlateCommentTest.php index b9fd94b..b233870 100644 --- a/moodle/Tests/FilesBoilerPlateCommentTest.php +++ b/moodle/Tests/FilesBoilerPlateCommentTest.php @@ -69,7 +69,7 @@ public function testMoodleFilesBoilerplateCommentBlank() { $this->setFixture(__DIR__ . '/fixtures/files/boilerplatecomment/blank.php'); $this->setErrors([ - 2 => 'followed by exactly one newline', + 2 => 'not found at first line', ]); $this->setWarnings([]); @@ -95,7 +95,7 @@ public function testMoodleFilesBoilerplateCommentShortEmpty() { $this->setFixture(__DIR__ . '/fixtures/files/boilerplatecomment/short_empty.php'); $this->setErrors([ - 1 => 'FileTooShort', + 1 => 'EndOfFile', ]); $this->setWarnings([]);