diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5daa10a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +LICENSE export-ignore +README.md export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88e99d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor +composer.lock \ No newline at end of file diff --git a/README.md b/README.md index bc7f6b8..0998b00 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ # PHPStan Custom Rules library -Here you can find custom rules for PHPStan in WP Media Context. \ No newline at end of file +Here you can find custom rules for PHPStan in WP Media Context. + +## Purpose + +This repository contains custom rules for PHPStan to be used in the WP Media context. The goal is to extend PHPStan's set of rules with our own custom rules, allowing us to enforce specific coding standards and practices in our projects. + +## Usage + +To use the custom rules in this repository, you need to add it as a dependency in your project using Composer. Then, you can configure PHPStan to use the custom rules by adding them to your `phpstan.neon` configuration file. + +## Adding Custom Rules + +To add a new custom rule to this repository, follow these steps: + +1. Create a new PHP file for your custom rule in the `src/Rules` directory. +2. Implement the custom rule by extending the appropriate PHPStan rule class and implementing the required methods. +3. Add a test for your custom rule in the `tests/Rules` directory. +4. Update the `composer.json` file to include the new custom rule in the autoload section. +5. Run the tests to ensure your custom rule works as expected. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..84452a5 --- /dev/null +++ b/composer.json @@ -0,0 +1,47 @@ +{ + "name": "wp-media/phpstan-custom-rules", + "description": "A library of custom rules for PHPStan in WP Media context.", + "type": "phpstan-extension", + "keywords": [ + "static analysis", + "code analysis", + "code analyse", + "wordpress", + "phpstan" + ], "license": "GPL-3.0-or-later", + "support": { + "issues": "https://github.com/wp-media/phpstan-apply-filters-typed/issues", + "source": "https://github.com/wp-media/phpstan-apply-filters-typed" + }, + "require": { + "szepeviktor/phpstan-wordpress": "^1.3", + "wp-media/apply-filters-typed": "^1.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpunit/phpunit": "^8.0 || ^9.0", + "wp-coding-standards/wpcs": "^3" + }, + "autoload": { + "psr-4": { + "WPMedia\\PHPStan\\Rules\\ApplyFiltersTyped\\": "src/Rules/" + } + }, + "autoload-dev": { + "classmap": [ + "tests/" + ] + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "sort-packages": true + }, + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + } diff --git a/extension.neon b/extension.neon new file mode 100644 index 0000000..e94d164 --- /dev/null +++ b/extension.neon @@ -0,0 +1,7 @@ +services: + - + class: WPMedia\PHPStan\Rules\ApplyFiltersTyped\ApplyFiltersTypedDynamicFunctionReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension +rules: + - WPMedia\PHPStan\Rules\ApplyFiltersTyped\HookDocsRule diff --git a/src/Rules/ApplyFiltersTypedRule.php b/src/Rules/ApplyFiltersTypedRule.php new file mode 100644 index 0000000..0b528d7 --- /dev/null +++ b/src/Rules/ApplyFiltersTypedRule.php @@ -0,0 +1,61 @@ +hookDocBlock = $hookDocBlock; + } + + public function isFunctionSupported(FunctionReflection $functionReflection): bool { + return in_array( + $functionReflection->getName(), + [ + 'wpm_apply_filters_typed', + 'wpm_apply_filters_typesafe', + ], + true + ); + } + + /** + * @see https://developer.wordpress.org/reference/functions/apply_filters/ + * @see https://developer.wordpress.org/reference/functions/apply_filters_deprecated/ + * @see https://developer.wordpress.org/reference/functions/apply_filters_ref_array/ + * + * @phpcsSuppress SlevomatCodingStandard.Functions.UnusedParameter + */ + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type { + $default = new MixedType(); + $resolvedPhpDoc = $this->hookDocBlock->getNullableHookDocBlock( $functionCall, $scope ); + + if ( null === $resolvedPhpDoc ) { + return $default; + } + + // Fetch the `@param` values from the docblock. + $params = $resolvedPhpDoc->getParamTags(); + + foreach ( $params as $param ) { + return $param->getType(); + } + + return $default; + } +} \ No newline at end of file diff --git a/src/Rules/HookDocsRule.php b/src/Rules/HookDocsRule.php new file mode 100644 index 0000000..3a64c12 --- /dev/null +++ b/src/Rules/HookDocsRule.php @@ -0,0 +1,218 @@ + + */ +class HookDocsRule implements \PHPStan\Rules\Rule +{ + private const SUPPORTED_FUNCTIONS = [ + 'wpm_apply_filters_typed', + ]; + + /** @var \SzepeViktor\PHPStan\WordPress\HookDocBlock */ + protected $hookDocBlock; + + /** @var \PHPStan\Rules\RuleLevelHelper */ + protected $ruleLevelHelper; + + /** @var \PhpParser\Node\Expr\FuncCall */ + protected $currentNode; + + /** @var \PHPStan\Analyser\Scope */ + protected $currentScope; + + /** @var list<\PHPStan\Rules\IdentifierRuleError> */ + private $errors; + + public function __construct( + FileTypeMapper $fileTypeMapper, + RuleLevelHelper $ruleLevelHelper + ) { + $this->hookDocBlock = new HookDocBlock($fileTypeMapper); + $this->ruleLevelHelper = $ruleLevelHelper; + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $this->currentNode = $node; + $this->currentScope = $scope; + $this->errors = []; + + if (! ($node->name instanceof Name)) { + return []; + } + + if (! in_array($node->name->toString(), self::SUPPORTED_FUNCTIONS, true)) { + return []; + } + + $resolvedPhpDoc = $this->hookDocBlock->getNullableHookDocBlock($node, $scope); + + // A docblock is optional. + if ($resolvedPhpDoc === null) { + return []; + } + + $this->validateDocBlock($resolvedPhpDoc); + + return $this->errors; + } + + /** + * Validates the `@param` tags documented in the given docblock. + */ + public function validateDocBlock(ResolvedPhpDocBlock $resolvedPhpDoc): void + { + // Count all documented `@param` tag strings in the docblock. + $numberOfParamTagStrings = substr_count($resolvedPhpDoc->getPhpDocString(), '* @param '); + + // A docblock with no param tags is allowed and gets skipped. + if ($numberOfParamTagStrings === 0) { + return; + } + + $this->validateParamCount($numberOfParamTagStrings); + + // If the number of param tags doesn't match the number of + // parameters, bail out early. There's no point trying to + // reconcile param tags in this situation. + if ($this->errors !== []) { + return; + } + + // Fetch the parsed `@param` tags from the docblock. + $paramTags = $resolvedPhpDoc->getParamTags(); + + $this->validateParamDocumentation(count($paramTags), $resolvedPhpDoc); + if ($this->errors !== []) { + return; + } + + $nodeArgs = $this->currentNode->getArgs(); + $paramIndex = 2; + + foreach ($paramTags as $paramName => $paramTag) { + $this->validateSingleParamTag($paramName, $paramTag, $nodeArgs[$paramIndex]); + $paramIndex += 1; + } + } + + /** + * Validates the number of documented `@param` tags in the docblock. + */ + public function validateParamCount(int $numberOfParamTagStrings): void + { + // The first parameter is the type, the second parameter is the hook name, so we subtract 2. + $numberOfParams = count($this->currentNode->getArgs()) - 2; + + // Correct number of `@param` tags. + if ($numberOfParams === $numberOfParamTagStrings) { + return; + } + + $this->errors[] = RuleErrorBuilder::message( + sprintf( + 'Expected %1$d @param tags, found %2$d.', + $numberOfParams, + $numberOfParamTagStrings + ) + )->identifier('paramTag.count')->build(); + } + + /** + * Validates the number of parsed and valid `@param` tags in the docblock. + */ + public function validateParamDocumentation( + int $numberOfParamTags, + ResolvedPhpDocBlock $resolvedPhpDoc + ): void { + $nodeArgs = $this->currentNode->getArgs(); + $numberOfParams = count($nodeArgs) - 2; + + // No invalid `@param` tags. + if ($numberOfParams === $numberOfParamTags) { + return; + } + + // We might have an invalid `@param` tag because it's named `$this`. + // PHPStan does not detect param tags named `$this`, it skips the tag. + // We can indirectly detect this by checking the actual parameter name, + // and if one of them is `$this` assume that's the problem. + $namedThis = false; + if (strpos($resolvedPhpDoc->getPhpDocString(), ' $this') !== false) { + foreach ($nodeArgs as $param) { + if (($param->value instanceof Variable) && $param->value->name === 'this') { + $namedThis = true; + break; + } + } + } + + $this->errors[] = RuleErrorBuilder::message( + $namedThis === true + ? '@param tag must not be named $this. Choose a descriptive alias, for example $instance.' + : 'One or more @param tags has an invalid name or invalid syntax.' + )->identifier('phpDoc.parseError')->build(); + } + + /** + * Validates a `@param` tag against its actual parameter. + * + * @param string $paramName The param tag name. + * @param \PHPStan\PhpDoc\Tag\ParamTag $paramTag The param tag instance. + * @param \PhpParser\Node\Arg $arg The actual parameter instance. + */ + protected function validateSingleParamTag(string $paramName, ParamTag $paramTag, Arg $arg): void + { + $paramTagType = $paramTag->getType(); + $paramType = $this->currentScope->getType($arg->value); + $accepted = $this->ruleLevelHelper->accepts( + $paramTagType, + $paramType, + $this->currentScope->isDeclareStrictTypes() + ); + + if ($accepted) { + return; + } + + $paramTagVerbosityLevel = VerbosityLevel::getRecommendedLevelByType($paramTagType); + $paramVerbosityLevel = VerbosityLevel::getRecommendedLevelByType($paramType); + + $this->errors[] = RuleErrorBuilder::message( + sprintf( + '@param %1$s $%2$s does not accept actual type of parameter: %3$s.', + $paramTagType->describe($paramTagVerbosityLevel), + $paramName, + $paramType->describe($paramVerbosityLevel) + ) + )->identifier('parameter.phpDocType')->build(); + } +} \ No newline at end of file