From 9dc0c5d55c7e2b7d03105d2f11ca285e302bd8ed Mon Sep 17 00:00:00 2001 From: Jaroslav Hanslik Date: Thu, 21 Apr 2016 20:56:13 +0200 Subject: [PATCH] Added type hint declaration sniff --- .../Typehints/TypeHintDeclarationSniff.php | 429 ++++++++++++++++++ ruleset.xml | 1 + .../TypeHintDeclarationSniffTest.php | 51 +++ .../data/typeHintDeclarationErrors.php | 102 +++++ .../data/typeHintDeclarationNoErrors.php | 202 +++++++++ 5 files changed, 785 insertions(+) create mode 100644 SlevomatCodingStandard/Sniffs/Typehints/TypeHintDeclarationSniff.php create mode 100644 tests/Sniffs/Typehints/TypeHintDeclarationSniffTest.php create mode 100644 tests/Sniffs/Typehints/data/typeHintDeclarationErrors.php create mode 100644 tests/Sniffs/Typehints/data/typeHintDeclarationNoErrors.php diff --git a/SlevomatCodingStandard/Sniffs/Typehints/TypeHintDeclarationSniff.php b/SlevomatCodingStandard/Sniffs/Typehints/TypeHintDeclarationSniff.php new file mode 100644 index 000000000..43292b908 --- /dev/null +++ b/SlevomatCodingStandard/Sniffs/Typehints/TypeHintDeclarationSniff.php @@ -0,0 +1,429 @@ +checkParametersTypeHints($phpcsFile, $functionPointer); + $this->checkReturnTypeHints($phpcsFile, $functionPointer); + $this->checkUselessDocComment($phpcsFile, $functionPointer); + } + + /** + * @param \PHP_CodeSniffer_File $phpcsFile + * @param integer $functionPointer + */ + private function checkParametersTypeHints(\PHP_CodeSniffer_File $phpcsFile, $functionPointer) + { + if (SuppressHelper::isSniffSuppressed($phpcsFile, $functionPointer, $this->getSniffName(self::MISSING_PARAMETER_TYPE_HINT))) { + return; + } + + $parametersWithoutTypeHint = FunctionHelper::getParametersWithoutTypeHint($phpcsFile, $functionPointer); + if (count($parametersWithoutTypeHint) === 0) { + return; + } + + $parametersTypeHintsDefinitions = []; + foreach (FunctionHelper::getParametersAnnotations($phpcsFile, $functionPointer) as $parameterAnnotationContent) { + list($parameterTypeHintDefinition, $parameterName) = preg_split('~\\s+~', $parameterAnnotationContent); + $parameterName = preg_replace('~^\.{3}\\s*(\$.+)~', '\\1', $parameterName); + $parametersTypeHintsDefinitions[$parameterName] = $parameterTypeHintDefinition; + } + + foreach ($parametersWithoutTypeHint as $parameterName) { + if (!isset($parametersTypeHintsDefinitions[$parameterName])) { + $phpcsFile->addError( + sprintf( + '%s %s() does not have parameter type hint nor @param annotation for its parameter %s.', + $this->getFunctionTypeLabel($phpcsFile, $functionPointer), + FunctionHelper::getFullyQualifiedName($phpcsFile, $functionPointer), + $parameterName + ), + $functionPointer, + self::MISSING_PARAMETER_TYPE_HINT + ); + + continue; + } + + $parameterTypeHintDefinition = $parametersTypeHintsDefinitions[$parameterName]; + + if ($this->definitionContainsMixedTypeHint($parameterTypeHintDefinition) || strtolower($parameterTypeHintDefinition) === 'null') { + continue; + } + + if ($this->definitionContainsOneTypeHint($parameterTypeHintDefinition)) { + $phpcsFile->addError( + sprintf( + '%s %s() does not have parameter type hint for its parameter %s but it should be possible to add it based on @param annotation "%s".', + $this->getFunctionTypeLabel($phpcsFile, $functionPointer), + FunctionHelper::getFullyQualifiedName($phpcsFile, $functionPointer), + $parameterName, + $parameterTypeHintDefinition + ), + $functionPointer, + self::MISSING_PARAMETER_TYPE_HINT + ); + } elseif ($this->definitionContainsJustTwoTypeHints($parameterTypeHintDefinition)) { + $parameterTypeHints = explode('|', $parameterTypeHintDefinition); + if (strtolower($parameterTypeHints[0]) === 'null' || strtolower($parameterTypeHints[1]) === 'null') { + $parameterTypeHint = strtolower($parameterTypeHints[0]) === 'null' ? $parameterTypeHints[1] : $parameterTypeHints[0]; + if ($this->definitionContainsOneTypeHint($parameterTypeHint)) { + $phpcsFile->addError( + sprintf( + '%s %s() does not have parameter type hint for its parameter %s but it should be possible to add it based on @param annotation "%s".', + $this->getFunctionTypeLabel($phpcsFile, $functionPointer), + FunctionHelper::getFullyQualifiedName($phpcsFile, $functionPointer), + $parameterName, + $parameterTypeHintDefinition + ), + $functionPointer, + self::MISSING_PARAMETER_TYPE_HINT + ); + } + } elseif ($this->definitionContainsTraversableTypeHint($phpcsFile, $functionPointer, $parameterTypeHintDefinition)) { + $phpcsFile->addError( + sprintf( + '%s %s() does not have parameter type hint for its parameter %s but it should be possible to add it based on @param annotation "%s".', + $this->getFunctionTypeLabel($phpcsFile, $functionPointer), + FunctionHelper::getFullyQualifiedName($phpcsFile, $functionPointer), + $parameterName, + $parameterTypeHintDefinition + ), + $functionPointer, + self::MISSING_PARAMETER_TYPE_HINT + ); + } + } + } + } + + /** + * @param \PHP_CodeSniffer_File $phpcsFile + * @param integer $functionPointer + */ + private function checkReturnTypeHints(\PHP_CodeSniffer_File $phpcsFile, $functionPointer) + { + if (SuppressHelper::isSniffSuppressed($phpcsFile, $functionPointer, $this->getSniffName(self::MISSING_RETURN_TYPE_HINT))) { + return; + } + + if (FunctionHelper::isAbstract($phpcsFile, $functionPointer)) { + return; + } + + if (!FunctionHelper::returnsValue($phpcsFile, $functionPointer)) { + return; + } + + if (FunctionHelper::hasReturnTypeHint($phpcsFile, $functionPointer)) { + return; + } + + $returnAnnotation = FunctionHelper::findReturnAnnotation($phpcsFile, $functionPointer); + if ($returnAnnotation === null) { + $phpcsFile->addError( + sprintf( + '%s %s() does not have return type hint nor @return annotation for its return value.', + $this->getFunctionTypeLabel($phpcsFile, $functionPointer), + FunctionHelper::getFullyQualifiedName($phpcsFile, $functionPointer) + ), + $functionPointer, + self::MISSING_RETURN_TYPE_HINT + ); + + return; + } + + $returnTypeHintDefinition = preg_split('~\\s+~', $returnAnnotation)[0]; + + if ($this->definitionContainsMixedTypeHint($returnTypeHintDefinition)) { + return; + } elseif ($this->definitionContainsNullTypeHint($returnTypeHintDefinition)) { + return; + } elseif ($this->definitionContainsOneTypeHint($returnTypeHintDefinition)) { + $phpcsFile->addError( + sprintf( + '%s %s() does not have return type hint for its return value but it should be possible to add it based on @return annotation "%s".', + $this->getFunctionTypeLabel($phpcsFile, $functionPointer), + FunctionHelper::getFullyQualifiedName($phpcsFile, $functionPointer), + $returnTypeHintDefinition + ), + $functionPointer, + self::MISSING_RETURN_TYPE_HINT + ); + } elseif ($this->definitionContainsJustTwoTypeHints($returnTypeHintDefinition) && $this->definitionContainsTraversableTypeHint($phpcsFile, $functionPointer, $returnTypeHintDefinition)) { + $phpcsFile->addError( + sprintf( + '%s %s() does not have return type hint for its return value but it should be possible to add it based on @return annotation "%s".', + $this->getFunctionTypeLabel($phpcsFile, $functionPointer), + FunctionHelper::getFullyQualifiedName($phpcsFile, $functionPointer), + $returnTypeHintDefinition + ), + $functionPointer, + self::MISSING_RETURN_TYPE_HINT + ); + } + } + + /** + * @param \PHP_CodeSniffer_File $phpcsFile + * @param integer $functionPointer + */ + private function checkUselessDocComment(\PHP_CodeSniffer_File $phpcsFile, $functionPointer) + { + if (SuppressHelper::isSniffSuppressed($phpcsFile, $functionPointer, $this->getSniffName(self::USELESS_DOC_COMMENT))) { + return; + } + + if (!DocCommentHelper::hasDocComment($phpcsFile, $functionPointer)) { + return; + } + + if (DocCommentHelper::hasDocCommentDescription($phpcsFile, $functionPointer)) { + return; + } + + $returnTypeHint = FunctionHelper::findReturnTypeHint($phpcsFile, $functionPointer); + if (FunctionHelper::isAbstract($phpcsFile, $functionPointer)) { + $returnAnnotation = FunctionHelper::findReturnAnnotation($phpcsFile, $functionPointer); + if ( + ($returnTypeHint !== null && $this->isTraversableTypeHint($this->getFullyQualifiedTypeHint($phpcsFile, $functionPointer, $returnTypeHint))) + || ($returnAnnotation !== null && ($this->definitionContainsMixedTypeHint($returnAnnotation) || $this->definitionContainsNullTypeHint($returnAnnotation) || !$this->definitionContainsOneTypeHint($returnAnnotation))) + ) { + return; + } + } else { + if ( + FunctionHelper::returnsValue($phpcsFile, $functionPointer) + && ($returnTypeHint === null || $this->isTraversableTypeHint($this->getFullyQualifiedTypeHint($phpcsFile, $functionPointer, $returnTypeHint))) + ) { + return; + } + } + + foreach (FunctionHelper::getParametersTypeHints($phpcsFile, $functionPointer) as $parameterTypeHint) { + if ($parameterTypeHint === null || $this->isTraversableTypeHint($this->getFullyQualifiedTypeHint($phpcsFile, $functionPointer, $parameterTypeHint))) { + return; + } + } + + foreach (array_keys(AnnotationHelper::getAnnotations($phpcsFile, $functionPointer)) as $annotationName) { + if (array_key_exists($annotationName, $this->getNormalizedUsefulAnnotations())) { + return; + } + } + + $phpcsFile->addError( + sprintf( + '%s %s() does not need documentation comment.', + FunctionHelper::getFullyQualifiedName($phpcsFile, $functionPointer), + $this->getFunctionTypeLabel($phpcsFile, $functionPointer) + ), + $functionPointer, + self::USELESS_DOC_COMMENT + ); + } + + /** + * @param string $sniffName + * @return string + */ + private function getSniffName($sniffName) + { + return sprintf('%s.%s', self::NAME, $sniffName); + } + + /** + * @param \PHP_CodeSniffer_File $phpcsFile + * @param integer $functionPointer + * @param string $typeHint + * @return string + */ + private function getFullyQualifiedTypeHint(\PHP_CodeSniffer_File $phpcsFile, $functionPointer, $typeHint) + { + if (in_array($typeHint, $this->getSimpleTypeHints(), true) || NamespaceHelper::isFullyQualifiedName($typeHint)) { + return $typeHint; + } + + $canonicalQualifiedTypeHint = $typeHint; + + $useStatements = UseStatementHelper::getUseStatements($phpcsFile, $phpcsFile->findPrevious(T_OPEN_TAG, $functionPointer)); + $normalizedTypeHint = UseStatement::normalizedNameAsReferencedInFile($typeHint); + if (isset($useStatements[$normalizedTypeHint])) { + $useStatement = $useStatements[$normalizedTypeHint]; + $canonicalQualifiedTypeHint = $useStatement->getFullyQualifiedTypeName(); + } else { + $fileNamespace = NamespaceHelper::findCurrentNamespaceName($phpcsFile, $functionPointer); + if ($fileNamespace !== null) { + $canonicalQualifiedTypeHint = sprintf('%s%s%s', $fileNamespace, NamespaceHelper::NAMESPACE_SEPARATOR, $typeHint); + } + } + + return sprintf('%s%s', NamespaceHelper::NAMESPACE_SEPARATOR, $canonicalQualifiedTypeHint); + } + + /** + * @param string $typeHintDefinition + * @return boolean + */ + private function definitionContainsMixedTypeHint($typeHintDefinition) + { + return preg_match('~(?:^mixed$)|(?:^mixed\|)|(\|mixed\|)|(?:\|mixed$)~i', $typeHintDefinition) !== 0; + } + + /** + * @param string $typeHintDefinition + * @return boolean + */ + private function definitionContainsNullTypeHint($typeHintDefinition) + { + return preg_match('~(?:^null$)|(?:^null\|)|(\|null\|)|(?:\|null$)~i', $typeHintDefinition) !== 0; + } + + /** + * @param string $typeHintDefinition + * @return boolean + */ + private function definitionContainsOneTypeHint($typeHintDefinition) + { + return preg_match(sprintf('~^(?:%s|(\\\\\\w+)+)(?:\[\])?$~i', implode('|', $this->getSimpleTypeHints())), $typeHintDefinition) !== 0; + } + + /** + * @param string $typeHintDefinition + * @return boolean + */ + private function definitionContainsJustTwoTypeHints($typeHintDefinition) + { + return count(explode('|', $typeHintDefinition)) === 2; + } + + /** + * @param \PHP_CodeSniffer_File $phpcsFile + * @param integer $functionPointer + * @param string $typeHintDefinition + * @return boolean + */ + private function definitionContainsTraversableTypeHint(\PHP_CodeSniffer_File $phpcsFile, $functionPointer, $typeHintDefinition) + { + if (!preg_match('~\[\](?:\||$)~', $typeHintDefinition)) { + return false; + } + + return array_reduce(explode('|', $typeHintDefinition), function ($carry, $typeHint) use ($phpcsFile, $functionPointer) { + $fullyQualifiedTypeHint = $this->getFullyQualifiedTypeHint($phpcsFile, $functionPointer, $typeHint); + if ($this->isTraversableTypeHint($fullyQualifiedTypeHint)) { + $carry = true; + } + return $carry; + }, false); + } + + /** + * @param string $typeHint + * @return boolean + */ + private function isTraversableTypeHint($typeHint) + { + return strtolower($typeHint) === 'array' || array_key_exists($typeHint, $this->getNormalizedTraversableTypeHints()); + } + + /** + * @return string[] + */ + private function getSimpleTypeHints() + { + static $simpleTypeHints = [ + 'int', + 'integer', + 'float', + 'string', + 'bool', + 'boolean', + 'callable', + 'self', + 'array', + ]; + + return $simpleTypeHints; + } + + /** + * @return int[] [string => int] + */ + private function getNormalizedTraversableTypeHints() + { + if ($this->normalizedTraversableTypeHints === null) { + $this->normalizedTraversableTypeHints = array_flip(array_map(function ($typeHint) { + return NamespaceHelper::isFullyQualifiedName($typeHint) ? $typeHint : sprintf('%s%s', NamespaceHelper::NAMESPACE_SEPARATOR, $typeHint); + }, SniffSettingsHelper::normalizeArray($this->traversableTypeHints))); + } + return $this->normalizedTraversableTypeHints; + } + + /** + * @return int[] [string => int] + */ + private function getNormalizedUsefulAnnotations() + { + if ($this->normalizedUsefulAnnotations === null) { + $this->normalizedUsefulAnnotations = array_flip(SniffSettingsHelper::normalizeArray($this->usefulAnnotations)); + } + return $this->normalizedUsefulAnnotations; + } + + /** + * @param \PHP_CodeSniffer_File $phpcsFile + * @param integer $functionPointer + * @return string + */ + private function getFunctionTypeLabel(\PHP_CodeSniffer_File $phpcsFile, $functionPointer) + { + return FunctionHelper::isMethod($phpcsFile, $functionPointer) ? 'Method' : 'Function'; + } + +} diff --git a/ruleset.xml b/ruleset.xml index f65b7fe58..5b7634829 100644 --- a/ruleset.xml +++ b/ruleset.xml @@ -3,6 +3,7 @@ + diff --git a/tests/Sniffs/Typehints/TypeHintDeclarationSniffTest.php b/tests/Sniffs/Typehints/TypeHintDeclarationSniffTest.php new file mode 100644 index 000000000..16f827a03 --- /dev/null +++ b/tests/Sniffs/Typehints/TypeHintDeclarationSniffTest.php @@ -0,0 +1,51 @@ +assertNoSniffErrorInFile($this->checkFile(__DIR__ . '/data/typeHintDeclarationNoErrors.php', [ + 'traversableTypeHints' => [ + \Traversable::class, + '\QueryResultSet', + '\FooNamespace\ClassFromCurrentNamespace', + '\UsedNamespace\UsedClass', + ], + 'usefulAnnotations' => [ + '@see', + ], + ])); + } + + public function testErrors() + { + $report = $this->checkFile(__DIR__ . '/data/typeHintDeclarationErrors.php', [ + 'traversableTypeHints' => [ + \Traversable::class, + ], + ]); + + $this->assertSame(14, $report->getErrorCount()); + + $this->assertSniffError($report, 6, TypeHintDeclarationSniff::MISSING_PARAMETER_TYPE_HINT); + $this->assertSniffError($report, 13, TypeHintDeclarationSniff::MISSING_PARAMETER_TYPE_HINT); + $this->assertSniffError($report, 20, TypeHintDeclarationSniff::MISSING_PARAMETER_TYPE_HINT); + $this->assertSniffError($report, 27, TypeHintDeclarationSniff::MISSING_PARAMETER_TYPE_HINT); + $this->assertSniffError($report, 34, TypeHintDeclarationSniff::MISSING_PARAMETER_TYPE_HINT); + $this->assertSniffError($report, 41, TypeHintDeclarationSniff::MISSING_PARAMETER_TYPE_HINT); + + $this->assertSniffError($report, 45, TypeHintDeclarationSniff::MISSING_RETURN_TYPE_HINT); + $this->assertSniffError($report, 53, TypeHintDeclarationSniff::MISSING_RETURN_TYPE_HINT); + $this->assertSniffError($report, 61, TypeHintDeclarationSniff::MISSING_RETURN_TYPE_HINT); + $this->assertSniffError($report, 69, TypeHintDeclarationSniff::MISSING_RETURN_TYPE_HINT); + $this->assertSniffError($report, 77, TypeHintDeclarationSniff::MISSING_RETURN_TYPE_HINT); + $this->assertSniffError($report, 85, TypeHintDeclarationSniff::MISSING_RETURN_TYPE_HINT); + + $this->assertSniffError($report, 93, TypeHintDeclarationSniff::USELESS_DOC_COMMENT); + $this->assertSniffError($report, 100, TypeHintDeclarationSniff::USELESS_DOC_COMMENT); + } + +} diff --git a/tests/Sniffs/Typehints/data/typeHintDeclarationErrors.php b/tests/Sniffs/Typehints/data/typeHintDeclarationErrors.php new file mode 100644 index 000000000..2e2d54f90 --- /dev/null +++ b/tests/Sniffs/Typehints/data/typeHintDeclarationErrors.php @@ -0,0 +1,102 @@ +