diff --git a/SlevomatCodingStandard/Sniffs/TypeHints/ClassConstantTypeHintSniff.php b/SlevomatCodingStandard/Sniffs/TypeHints/ClassConstantTypeHintSniff.php new file mode 100644 index 00000000..ff96c3d8 --- /dev/null +++ b/SlevomatCodingStandard/Sniffs/TypeHints/ClassConstantTypeHintSniff.php @@ -0,0 +1,196 @@ + */ + private static array $tokenToTypeHintMapping = [ + T_FALSE => 'false', + T_TRUE => 'true', + T_DNUMBER => 'float', + T_LNUMBER => 'int', + T_NULL => 'null', + T_OPEN_SHORT_ARRAY => 'array', + T_CONSTANT_ENCAPSED_STRING => 'string', + T_START_NOWDOC => 'string', + T_START_HEREDOC => 'string', + ]; + + /** + * @return array + */ + public function register(): array + { + return [ + T_CONST, + ]; + } + + /** + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + * @param int $constantPointer + */ + public function process(File $phpcsFile, $constantPointer): void + { + if (ClassHelper::getClassPointer($phpcsFile, $constantPointer) === null) { + // Constant in namespace + return; + } + + $this->checkNativeTypeHint($phpcsFile, $constantPointer); + $this->checkDocComment($phpcsFile, $constantPointer); + } + + private function checkNativeTypeHint(File $phpcsFile, int $constantPointer): void + { + $this->enableNativeTypeHint = SniffSettingsHelper::isEnabledByPhpVersion($this->enableNativeTypeHint, 80300); + + if (!$this->enableNativeTypeHint) { + return; + } + + $namePointer = $this->getConstantNamePointer($phpcsFile, $constantPointer); + $typeHintPointer = TokenHelper::findPreviousEffective($phpcsFile, $namePointer - 1); + + if ($typeHintPointer !== $constantPointer) { + // Has type hint + return; + } + + $tokens = $phpcsFile->getTokens(); + + $namePointer = $this->getConstantNamePointer($phpcsFile, $constantPointer); + $equalPointer = TokenHelper::findNext($phpcsFile, T_EQUAL, $constantPointer + 1); + + $valuePointer = TokenHelper::findNextEffective($phpcsFile, $equalPointer + 1); + if ($tokens[$valuePointer]['code'] === T_MINUS) { + $valuePointer = TokenHelper::findNextEffective($phpcsFile, $valuePointer + 1); + } + + $constantName = $tokens[$namePointer]['content']; + + $typeHint = null; + if (array_key_exists($tokens[$valuePointer]['code'], self::$tokenToTypeHintMapping)) { + $typeHint = self::$tokenToTypeHintMapping[$tokens[$valuePointer]['code']]; + } + + $errorParameters = [ + sprintf('Constant %s does not have native type hint.', $constantName), + $constantPointer, + self::CODE_MISSING_NATIVE_TYPE_HINT, + ]; + + if ($typeHint === null) { + $phpcsFile->addError(...$errorParameters); + return; + } + + $fix = $phpcsFile->addFixableError(...$errorParameters); + + if (!$fix) { + return; + } + + $phpcsFile->fixer->beginChangeset(); + $phpcsFile->fixer->addContent($constantPointer, ' ' . $typeHint); + $phpcsFile->fixer->endChangeset(); + } + + private function checkDocComment(File $phpcsFile, int $constantPointer): void + { + $docCommentOpenPointer = DocCommentHelper::findDocCommentOpenPointer($phpcsFile, $constantPointer); + if ($docCommentOpenPointer === null) { + return; + } + + $annotations = AnnotationHelper::getAnnotations($phpcsFile, $constantPointer, '@var'); + + if ($annotations === []) { + return; + } + + $tokens = $phpcsFile->getTokens(); + + $namePointer = $this->getConstantNamePointer($phpcsFile, $constantPointer); + $constantName = $tokens[$namePointer]['content']; + + $uselessDocComment = !DocCommentHelper::hasDocCommentDescription($phpcsFile, $constantPointer) && count($annotations) === 1; + if ($uselessDocComment) { + $fix = $phpcsFile->addFixableError( + sprintf('Useless documentation comment for constant %s.', $constantName), + $docCommentOpenPointer, + self::CODE_USELESS_DOC_COMMENT, + ); + + /** @var int $fixerStart */ + $fixerStart = TokenHelper::findLastTokenOnPreviousLine($phpcsFile, $docCommentOpenPointer); + $fixerEnd = $tokens[$docCommentOpenPointer]['comment_closer']; + } else { + $annotation = $annotations[0]; + + $fix = $phpcsFile->addFixableError( + sprintf('Useless @var annotation for constant %s.', $constantName), + $annotation->getStartPointer(), + self::CODE_USELESS_VAR_ANNOTATION, + ); + + /** @var int $fixerStart */ + $fixerStart = TokenHelper::findPreviousContent( + $phpcsFile, + T_DOC_COMMENT_WHITESPACE, + $phpcsFile->eolChar, + $annotation->getStartPointer() - 1, + ); + $fixerEnd = $annotation->getEndPointer(); + } + + if (!$fix) { + return; + } + + $phpcsFile->fixer->beginChangeset(); + FixerHelper::removeBetweenIncluding($phpcsFile, $fixerStart, $fixerEnd); + $phpcsFile->fixer->endChangeset(); + } + + private function getConstantNamePointer(File $phpcsFile, int $constantPointer): int + { + $equalPointer = TokenHelper::findNext($phpcsFile, T_EQUAL, $constantPointer + 1); + + return TokenHelper::findPreviousEffective($phpcsFile, $equalPointer - 1); + } + +} diff --git a/doc/type-hints.md b/doc/type-hints.md index f52e2b1d..60ff669c 100644 --- a/doc/type-hints.md +++ b/doc/type-hints.md @@ -1,5 +1,14 @@ ## Type hints +#### SlevomatCodingStandard.TypeHints.ClassConstantTypeHint 🔧 + +* Checks for missing typehints in case they can be declared natively. +* Reports useless `@var` annotation (or whole documentation comment) because the type of constant is always clear. + +Sniff provides the following settings: + +* `enableNativeTypeHint`: enforces native typehint. It's on by default if you're on PHP 8.3+ + #### SlevomatCodingStandard.TypeHints.DeclareStrictTypes 🔧 Enforces having `declare(strict_types = 1)` at the top of each PHP file. Allows configuring how many newlines should be between the ` false, + ]); + self::assertNoSniffErrorInFile($report); + } + + public function testNativeTypeHintNoErrors(): void + { + $report = self::checkFile(__DIR__ . '/data/classConstantTypeHintNativeNoErrors.php', [ + 'enableNativeTypeHint' => true, + ]); + self::assertNoSniffErrorInFile($report); + } + + public function testNativeTypeHintErrors(): void + { + $report = self::checkFile(__DIR__ . '/data/classConstantTypeHintNativeErrors.php', [ + 'enableNativeTypeHint' => true, + ]); + + self::assertSame(16, $report->getErrorCount()); + + foreach (range(6, 16) as $line) { + self::assertSniffError($report, $line, ClassConstantTypeHintSniff::CODE_MISSING_NATIVE_TYPE_HINT); + } + self::assertSniffError($report, 19, ClassConstantTypeHintSniff::CODE_MISSING_NATIVE_TYPE_HINT); + foreach (range(22, 25) as $line) { + self::assertSniffError($report, $line, ClassConstantTypeHintSniff::CODE_MISSING_NATIVE_TYPE_HINT); + } + + self::assertAllFixedInFile($report); + } + + public function testUselessDocCommentNoErrors(): void + { + $report = self::checkFile(__DIR__ . '/data/classConstantTypeHintUselessDocCommentNoErrors.php', [ + 'enableNativeTypeHint' => false, + ]); + self::assertNoSniffErrorInFile($report); + } + + public function testUselessDocCommentErrors(): void + { + $report = self::checkFile(__DIR__ . '/data/classConstantTypeHintUselessDocCommentErrors.php', [ + 'enableNativeTypeHint' => false, + ]); + + self::assertSame(3, $report->getErrorCount()); + + self::assertSniffError($report, 11, ClassConstantTypeHintSniff::CODE_USELESS_VAR_ANNOTATION); + self::assertSniffError($report, 23, ClassConstantTypeHintSniff::CODE_USELESS_VAR_ANNOTATION); + self::assertSniffError($report, 33, ClassConstantTypeHintSniff::CODE_USELESS_DOC_COMMENT); + + self::assertAllFixedInFile($report); + } + +} diff --git a/tests/Sniffs/TypeHints/data/classConstantTypeHintNativeErrors.fixed.php b/tests/Sniffs/TypeHints/data/classConstantTypeHintNativeErrors.fixed.php new file mode 100644 index 00000000..042bca29 --- /dev/null +++ b/tests/Sniffs/TypeHints/data/classConstantTypeHintNativeErrors.fixed.php @@ -0,0 +1,27 @@ += 8.3 + +class Whatever +{ + + const null C_NULL = null; + const true C_TRUE = true; + const false C_FALSE = false; + const int C_NUMBER = 123; + const int C_NEGATIVE_NUMBER = -123; + const float C_FLOAT = 123.456; + const float C_NEGATIVE_FLOAT = -123.456; + const array C_ARRAY = ['php']; + const string C_STRING = 'string'; + const string C_STRING_DOUBLE_QUOTES = "string"; + const string C_NOWDOC = <<<'NOWDOC' + nowdoc + NOWDOC; + const string C_HEREDOC = <<= 8.3 + +class Whatever +{ + + const C_NULL = null; + const C_TRUE = true; + const C_FALSE = false; + const C_NUMBER = 123; + const C_NEGATIVE_NUMBER = -123; + const C_FLOAT = 123.456; + const C_NEGATIVE_FLOAT = -123.456; + const C_ARRAY = ['php']; + const C_STRING = 'string'; + const C_STRING_DOUBLE_QUOTES = "string"; + const C_NOWDOC = <<<'NOWDOC' + nowdoc + NOWDOC; + const C_HEREDOC = <<= 8.3 + +namespace SomeNamespace; + +const IGNORED = 'ignored'; + +class Whatever +{ + + const null C_NULL = null; + const true C_TRUE = true; + const false C_FALSE = false; + const string C_STRING = 'aa'; + const int C_NUMBER = 123; + const int C_NEGATIVE_NUMBER = -123; + const float C_FLOAT = 123.456; + const float C_NEGATIVE_FLOAT = -123.456; + const array C_ARRAY = ['php']; + const SomeEnum C_ENUM = SomeEnum::VALUE; + const string C_CONSTANT = Whatever::STRING; + + +} diff --git a/tests/Sniffs/TypeHints/data/classConstantTypeHintUselessDocCommentErrors.fixed.php b/tests/Sniffs/TypeHints/data/classConstantTypeHintUselessDocCommentErrors.fixed.php new file mode 100644 index 00000000..8bc720b0 --- /dev/null +++ b/tests/Sniffs/TypeHints/data/classConstantTypeHintUselessDocCommentErrors.fixed.php @@ -0,0 +1,33 @@ +