diff --git a/library.xml b/library.xml index 85d5131..dd94293 100644 --- a/library.xml +++ b/library.xml @@ -68,7 +68,7 @@ - + diff --git a/src/Stefna/Sniffs/ControlStructures/ControlSignatureSniff.php b/src/Stefna/Sniffs/ControlStructures/ControlSignatureSniff.php new file mode 100644 index 0000000..9c514de --- /dev/null +++ b/src/Stefna/Sniffs/ControlStructures/ControlSignatureSniff.php @@ -0,0 +1,160 @@ +getTokens()); + + if (!$tokens->has($stackPtr + 1)) { + return; + } + + $isAlternative = false; + if ($tokens->hasScopeOpener($stackPtr) && $tokens->code($tokens->scopeOpener($stackPtr)) === T_COLON) { + $isAlternative = true; + } + + // Single newline after opening brace. + if ($tokens->hasScopeOpener($stackPtr)) { + $opener = $tokens->scopeOpener($stackPtr); + for ($next = ($opener + 1); $next < $phpcsFile->numTokens; $next++) { + $code = $tokens->code($next); + + if ($code === T_WHITESPACE || ($code === T_INLINE_HTML && trim($tokens->content($next)) === '')) { + continue; + } + + // Skip all empty tokens on the same line as the opener + if ($tokens->sameLine($next, $opener) && (isset(Tokens::EMPTY_TOKENS[$code]) || $code === T_CLOSE_TAG)) { + continue; + } + + // We found the first bit of a code, or a comment on the following line + break; + } + + if ($tokens->sameLine($next, $opener)) { + $error = 'Newline required after opening brace'; + $fix = $phpcsFile->addFixableError($error, $opener, 'NewlineAfterOpenBrace'); + if ($fix) { + $phpcsFile->fixer->beginChangeset(); + for ($i = $opener + 1; $i < $next; $i++) { + if (trim($tokens->content($i)) !== '') { + break; + } + + $phpcsFile->fixer->replaceToken($i, ''); + } + + $phpcsFile->fixer->addContent($opener, $phpcsFile->eolChar); + $phpcsFile->fixer->endChangeset(); + } + } + } + elseif ($tokens->code($stackPtr) === T_WHILE) { + // Zero spaces after parenthesis closer. + $closer = $tokens->parenthesisCloser($stackPtr); + $found = 0; + if ($tokens->code($closer + 1) === T_WHITESPACE) { + if (strpos($tokens->content($closer + 1), $phpcsFile->eolChar) !== false) { + $found = 'newline'; + } + else { + $found = strlen($tokens->content($closer + 1)); + } + } + + if ($found !== 0) { + $error = 'Expected 0 spaces before semicolon; %s found'; + $data = [$found]; + $fix = $phpcsFile->addFixableError($error, $closer, 'SpaceBeforeSemicolon', $data); + if ($fix) { + $phpcsFile->fixer->replaceToken($closer + 1, ''); + } + } + } + + // Only want to check multi-keyword structures from here on. + if ($tokens->code($stackPtr) === T_WHILE) { + if (!$tokens->hasScopeCloser($stackPtr)) { + return; + } + + $closer = $phpcsFile->findPrevious(Tokens::EMPTY_TOKENS, $stackPtr - 1, exclude: true); + if ($closer === false || $tokens->code($closer) !== T_CLOSE_CURLY_BRACKET || $tokens->code($tokens->scopeCondition($closer)) !== T_DO) { + return; + } + } + elseif (in_array($tokens->code($stackPtr), [T_ELSE, T_ELSEIF, T_CATCH, T_FINALLY], true)) { + if ($tokens->hasScopeOpener($stackPtr) && $tokens->code($tokens->scopeOpener($stackPtr)) === T_COLON) { + // Special case for alternative syntax, where this token is actually + // the closer for the previous block, so there is no spacing to check. + return; + } + + $closer = $phpcsFile->findPrevious(Tokens::EMPTY_TOKENS, $stackPtr - 1, exclude: true); + if ($closer === false || $tokens->code($closer) !== T_CLOSE_CURLY_BRACKET) { + return; + } + } + else { + return; + } + + // Single space after closing brace. + $found = 1; + if ($tokens->code($closer + 1) !== T_WHITESPACE) { + $found = 0; + } + elseif (!$tokens->sameLine($closer, $stackPtr)) { + // Custom to allow newline before else and catch + $found = 1; + } + elseif ($tokens->content($closer + 1) !== ' ') { + $found = strlen($tokens->content($closer + 1)); + } + + if ($found !== 1) { + $error = 'Expected 1 space after closing brace; %s found'; + $data = [$found]; + + if ($phpcsFile->findNext(Tokens::COMMENT_TOKENS, $closer + 1, $stackPtr) !== false) { + // Comment found between closing brace and keyword, don't auto-fix. + $phpcsFile->addError($error, $closer, 'SpaceAfterCloseBrace', $data); + return; + } + + $fix = $phpcsFile->addFixableError($error, $closer, 'SpaceAfterCloseBrace', $data); + if ($fix) { + if ($found === 0) { + $phpcsFile->fixer->addContent($closer, ' '); + } + else { + $phpcsFile->fixer->replaceToken($closer + 1, ' '); + } + } + } + } +} diff --git a/src/Stefna/Utils/TokenCollection.php b/src/Stefna/Utils/TokenCollection.php new file mode 100644 index 0000000..76e2ce3 --- /dev/null +++ b/src/Stefna/Utils/TokenCollection.php @@ -0,0 +1,69 @@ + $tokens + */ + public function __construct( + private array $tokens, + ) {} + + public function code(int $stackPtr): int|string + { + return $this->tokens[$stackPtr]['code']; + } + + public function content(int $stackPtr): string + { + return $this->tokens[$stackPtr]['content']; + } + + public function line(int $stackPtr): int + { + return $this->tokens[$stackPtr]['line']; + } + + public function parenthesisCloser(int $stackPtr): int + { + return $this->tokens[$stackPtr]['parenthesis_closer']; + } + + public function scopeCondition(int $stackPtr): int + { + return $this->tokens[$stackPtr]['scope_condition']; + } + + public function scopeOpener(int $stackPtr): int + { + return $this->tokens[$stackPtr]['scope_opener']; + } + + public function has(int $stackPtr): bool + { + return isset($this->tokens[$stackPtr]); + } + + public function hasScopeCloser(int $stackPtr): bool + { + return isset($this->tokens[$stackPtr]['scope_closer']); + } + + public function hasScopeOpener(int $stackPtr): bool + { + return isset($this->tokens[$stackPtr]['scope_opener']); + } + + public function hasParenthesisCloser(int $stackPtr): bool + { + return isset($this->tokens[$stackPtr]['parenthesis_closer']); + } + + + public function sameLine(int $firstPtr, int $secondPtr): bool + { + return $this->tokens[$firstPtr]['line'] === $this->tokens[$secondPtr]['line']; + } +} diff --git a/tests/Sniffs/ControlStructures/ControlSignatureSniffTest.php b/tests/Sniffs/ControlStructures/ControlSignatureSniffTest.php new file mode 100644 index 0000000..a0cc4f1 --- /dev/null +++ b/tests/Sniffs/ControlStructures/ControlSignatureSniffTest.php @@ -0,0 +1,37 @@ +checkFile('OK'); + + self::assertNoSniffErrorInFile($report); + } + + public function testFixErrors(): void + { + $report = $this->checkFile('Bad'); + + self::assertSniffError($report, 3, 'NewlineAfterOpenBrace'); + self::assertSniffError($report, 3, 'NewlineAfterOpenBrace'); + self::assertSniffError($report, 3, 'SpaceAfterCloseBrace'); + self::assertSniffError($report, 5, 'NewlineAfterOpenBrace'); + self::assertSniffError($report, 7, 'NewlineAfterOpenBrace'); + self::assertSniffError($report, 9, 'NewlineAfterOpenBrace'); + self::assertSniffError($report, 11, 'NewlineAfterOpenBrace'); + self::assertSniffError($report, 13, 'NewlineAfterOpenBrace'); + self::assertSniffError($report, 13, 'NewlineAfterOpenBrace'); + self::assertSniffError($report, 13, 'NewlineAfterOpenBrace'); + self::assertSniffError($report, 15, 'NewlineAfterOpenBrace'); + self::assertSniffError($report, 17, 'NewlineAfterOpenBrace'); + self::assertSniffError($report, 19, 'NewlineAfterOpenBrace'); + self::assertSniffError($report, 21, 'NewlineAfterOpenBrace'); + + self::assertAllFixedInFile($report); + } +} diff --git a/tests/Sniffs/ControlStructures/data/ControlSignatureSniff.Bad.php b/tests/Sniffs/ControlStructures/data/ControlSignatureSniff.Bad.php new file mode 100644 index 0000000..b646cdb --- /dev/null +++ b/tests/Sniffs/ControlStructures/data/ControlSignatureSniff.Bad.php @@ -0,0 +1,21 @@ + $v){} + +switch(false){default:break;} + +if(false):elseif(true):else:endif; + +while(false):endwhile; + +for(;;):endfor; + +foreach([] as $k => $v):endforeach; + +switch(false):default:break;endswitch; diff --git a/tests/Sniffs/ControlStructures/data/ControlSignatureSniff.OK.php b/tests/Sniffs/ControlStructures/data/ControlSignatureSniff.OK.php new file mode 100644 index 0000000..d4478ee --- /dev/null +++ b/tests/Sniffs/ControlStructures/data/ControlSignatureSniff.OK.php @@ -0,0 +1,38 @@ + $v) { +} + +switch (false) { + default: + break;} + +if (false) : +elseif (true) : +else : +endif; + +while (false) : +endwhile; + +for (;;) : +endfor; + +foreach ([] as $k => $v) : +endforeach; + +switch (false) : + default: + break; +endswitch; diff --git a/tests/Sniffs/TestCase.php b/tests/Sniffs/TestCase.php index 730aca3..f30f25b 100644 --- a/tests/Sniffs/TestCase.php +++ b/tests/Sniffs/TestCase.php @@ -18,35 +18,12 @@ protected function checkFile(string $fileVairant): File { $codeSniffer = new Runner(); - $codeSniffer->config = new Config(['-s']); + $codeSniffer->config = new Config(['-s', '--standard=phpcs.xml']); $codeSniffer->init(); $sniffFqcn = static::getSniffFqcn(); $sniff = new $sniffFqcn(); - $codeSniffer->ruleset->sniffs = [$sniffFqcn => $sniff]; - - $codeSniffer->ruleset->populateTokenListeners(); - - $filePath = static::getSniffDataVariantFilePath($fileVairant); - - $file = new LocalFile($filePath, $codeSniffer->ruleset, $codeSniffer->config); - $file->process(); - - return $file; - } - - protected function fixFile(string $fileVairant): File - { - $codeSniffer = new Runner(); - $codeSniffer->config = new Config(['-s']); - $codeSniffer->init(); - - $sniffFqcn = static::getSniffFqcn(); - $sniff = new $sniffFqcn(); - - $codeSniffer->ruleset->sniffs = [$sniffFqcn => $sniff]; - $codeSniffer->ruleset->populateTokenListeners(); $filePath = static::getSniffDataVariantFilePath($fileVairant);