Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closes #1 Create PHPStan extension for apply filters typed rule #2

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
LICENSE export-ignore
README.md export-ignore
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
vendor
composer.lock
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
# PHPStan Custom Rules library

Here you can find custom rules for PHPStan in WP Media Context.
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.
47 changes: 47 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
7 changes: 7 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
services:
-
class: WPMedia\PHPStan\Rules\ApplyFiltersTyped\ApplyFiltersTypedDynamicFunctionReturnTypeExtension
tags:
- phpstan.broker.dynamicFunctionReturnTypeExtension
rules:
- WPMedia\PHPStan\Rules\ApplyFiltersTyped\HookDocsRule
61 changes: 61 additions & 0 deletions src/Rules/ApplyFiltersTypedRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

/**
* Set return type of wpm_apply_filters_typed() based on its optional preceding docblock.
*/

declare(strict_types=1);

namespace WPMedia\PHPStan\Rules\ApplyFiltersTyped;

use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Type\MixedType;
use PHPStan\Type\Type;
use SzepeViktor\PHPStan\WordPress\HookDocBlock;

class ApplyFiltersTypedDynamicFunctionReturnTypeExtension implements \PHPStan\Type\DynamicFunctionReturnTypeExtension {
/** @var \SzepeViktor\PHPStan\WordPress\HookDocBlock */
protected $hookDocBlock;

public function __construct(HookDocBlock $hookDocBlock) {
$this->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;
}
}
218 changes: 218 additions & 0 deletions src/Rules/HookDocsRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
<?php

/**
* Custom rule to validate a PHPDoc docblock that precedes a hook.
*/

declare(strict_types=1);

namespace WPMedia\PHPStan\Rules\ApplyFiltersTyped;

use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\PhpDoc\ResolvedPhpDocBlock;
use PHPStan\PhpDoc\Tag\ParamTag;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Rules\RuleLevelHelper;
use PHPStan\Type\FileTypeMapper;
use PHPStan\Type\VerbosityLevel;
use SzepeViktor\PHPStan\WordPress\HookDocBlock;

/**
* @implements \PHPStan\Rules\Rule<\PhpParser\Node\Expr\FuncCall>
*/
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();
}
}