Skip to content

Commit

Permalink
SlevomatCodingStandard.TypeHints.ClassConstantTypeHint: New sniff
Browse files Browse the repository at this point in the history
  • Loading branch information
kukulich committed Feb 2, 2025
1 parent ad01519 commit cae7dc6
Show file tree
Hide file tree
Showing 9 changed files with 455 additions and 0 deletions.
196 changes: 196 additions & 0 deletions SlevomatCodingStandard/Sniffs/TypeHints/ClassConstantTypeHintSniff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php declare(strict_types = 1);

namespace SlevomatCodingStandard\Sniffs\TypeHints;

use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
use SlevomatCodingStandard\Helpers\AnnotationHelper;
use SlevomatCodingStandard\Helpers\ClassHelper;
use SlevomatCodingStandard\Helpers\DocCommentHelper;
use SlevomatCodingStandard\Helpers\FixerHelper;
use SlevomatCodingStandard\Helpers\SniffSettingsHelper;
use SlevomatCodingStandard\Helpers\TokenHelper;
use function array_key_exists;
use function count;
use function sprintf;
use const T_CONST;
use const T_CONSTANT_ENCAPSED_STRING;
use const T_DNUMBER;
use const T_DOC_COMMENT_WHITESPACE;
use const T_EQUAL;
use const T_FALSE;
use const T_LNUMBER;
use const T_MINUS;
use const T_NULL;
use const T_OPEN_SHORT_ARRAY;
use const T_START_HEREDOC;
use const T_START_NOWDOC;
use const T_TRUE;

class ClassConstantTypeHintSniff implements Sniff
{

public const CODE_MISSING_NATIVE_TYPE_HINT = 'MissingNativeTypeHint';
public const CODE_USELESS_DOC_COMMENT = 'UselessDocComment';
public const CODE_USELESS_VAR_ANNOTATION = 'UselessVarAnnotation';

public ?bool $enableNativeTypeHint = null;

/** @var array<int|string, string> */
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<int, (int|string)>
*/
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);
}

}
9 changes: 9 additions & 0 deletions doc/type-hints.md
Original file line number Diff line number Diff line change
@@ -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 `<?php` opening tag and the `declare` statement.
Expand Down
69 changes: 69 additions & 0 deletions tests/Sniffs/TypeHints/ClassConstantTypeHintSniffTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php declare(strict_types = 1);

namespace SlevomatCodingStandard\Sniffs\TypeHints;

use SlevomatCodingStandard\Sniffs\TestCase;
use function range;

class ClassConstantTypeHintSniffTest extends TestCase
{

public function testNativeTypeHintDisabled(): void
{
$report = self::checkFile(__DIR__ . '/data/classConstantTypeHintNativeNoErrors.php', [
'enableNativeTypeHint' => 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);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php // lint >= 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 = <<<HEREDOC
heredoc
HEREDOC;
const C_ENUM = SomeEnum::VALUE;
const C_CONSTANT = Whatever::STRING;
const C_CONSTANT_SELF = self::STRING;
const C_CONSTANT_PARENT = parent::STRING;

}
27 changes: 27 additions & 0 deletions tests/Sniffs/TypeHints/data/classConstantTypeHintNativeErrors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php // lint >= 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 = <<<HEREDOC
heredoc
HEREDOC;
const C_ENUM = SomeEnum::VALUE;
const C_CONSTANT = Whatever::STRING;
const C_CONSTANT_SELF = self::STRING;
const C_CONSTANT_PARENT = parent::STRING;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php // lint >= 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;


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Alphabet;

class A
{

/**
* AA constant
*
*/
const AA = 'aa';

}

interface B
{

/**
* BB constant
*
* @see anything
*/
public const BB = true;

}

new class implements B
{

const CC = 0;

};
Loading

0 comments on commit cae7dc6

Please sign in to comment.