I think that everyone these days agrees that typecasting is iffy (to say the least) and that relying on typecasting can be a major source of bugs, which sometimes are very hard to debug. In PHP a solution to this problem is placing a declare(strict_types=1)
statement right after the classic <?php
.
Recently while navigating older codebases I noticed that some files did not include such statement. The first thing I thought was looking up a rule in PHPStan to enforce this on the whole codebase, but after some Googling I couldn't find a ready to use solution. So after browsing PHPStan documentation for a few minutes I discovered Custom Rules, which as the name suggests are rules you write yourself by implementing an interface provided by PHPStan. You can read more about it on this link.
<?php
namespace App;
class Mouth
{
public function shout(string $phrase): void
{
echo $phrase;
}
}
php ./vendor/bin/phpstan
Note: Using configuration file /home/davi/dev/strict-types/phpstan.neon.
4/4 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
------ ---------------------------------------------------
Line src/Mouth.php
------ ---------------------------------------------------
3 File is missing declare(strict_types=1) statement
------ ---------------------------------------------------
[ERROR] Found 1 error
This is the interface defined by PHPStan which we need to implement:
/**
* @api
* @phpstan-template TNodeType of Node
*/
interface Rule
{
/**
* @phpstan-return class-string<TNodeType>
*/
public function getNodeType(): string;
/**
* @phpstan-param TNodeType $node
* @return (string|RuleError)[] errors
*/
public function processNode(Node $node, Scope $scope): array;
}
The getNodeType
method tells on which nodes our processNode
method will be called. Our processNode
method should return an array of RuleError objects or an empty array in case everything is ok.
Since we are dealing with files our TNodeType
should be the PHPStan\Node\FileNode
class, that way our rule will be called on every file, we should also update our generic Docblock, that way PHPStan won't complain about our rule class itself.
Here's how the implementation looks like so far:
use PHPStan\Node\FileNode;
/**
* @implements Rule<FileNode>
*/
class EnforceStrictTypes implements Rule
{
public function getNodeType(): string
{
return FileNode::class;
}
public function processNode(Node $node, Scope $scope): array
{
throw new \Exception('To be implemented');
}
}
Now on to the processNode
implementation. We're gonna receive two objects: Node
and Scope
, we are only interested in the first one. Our Node
object is an instance of what we defined on the getNodeType
method, so in this case a FileNode
.
Since we are dealing with a file our node will consist of a bunch of sub-nodes. Luckily we are looking for exactly the first node, because declare statements must be put at the start of files. I decided that empty files should not be checked, here's how our code looks so far:
public function processNode(Node $node, Scope $scope): array
{
$nodes = $node->getNodes();
if (empty($nodes)) {
return [];
}
$firstStatement = $nodes[0] ?? null;
// check if $firstStatement is a declare block
}
Now it's basically done, all we have to do is check if the $firstStatement
variable is defined and is an instance of PhpParser\Node\Stmt\Declare_
.
$firstStatement = $nodes[0];
$fileDeclaresStrictTypes = $firstStatement instanceof Declare_;
if (!$fileDeclaresStrictTypes) {
return [RuleErrorBuilder::message(
'File is missing declare(strict_types=1) statement'
)->build()];
}
We also need to check if strict_types
is set to 1. AFAIK PhpParser generates some code during runtime while it parses the code, that caused PHPStan to not recognize some properties of the object, hence the @phpstan-ignore-next-line
. Anyway, here's how it looks:
/* @phpstan-ignore-next-line */
$strictTypesSetAsOne = $firstStatement->declares[0]->value->value === 1;
if (!$strictTypesSetAsOne) {
return [RuleErrorBuilder::message(
'declare(strict_types=1) statement value must be set as 1'
)->build()];
}
And here's the final code:
/**
* @implements Rule<FileNode>
*/
class EnforceStrictTypes implements Rule
{
public function getNodeType(): string
{
return FileNode::class;
}
public function processNode(Node $node, Scope $scope): array
{
$nodes = $node->getNodes();
if (empty($nodes)) {
return [];
}
$firstStatement = $nodes[0];
$fileDeclaresStrictTypes = $firstStatement instanceof Declare_;
if (!$fileDeclaresStrictTypes) {
return [RuleErrorBuilder::message(
'File is missing declare(strict_types=1) statement'
)->build()];
}
/* @phpstan-ignore-next-line */
$strictTypesSetAsOne = $firstStatement->declares[0]->value->value === 1;
if (!$strictTypesSetAsOne) {
return [RuleErrorBuilder::message(
'declare(strict_types=1) statement value must be set as 1'
)->build()];
}
return [];
}
}
Right now everything should be working and when you analyse the code it will complain about files not declaring strict types.
Writing a custom rule is not enough, we should also unit test it to make sure the rule itself will do what it's suppose to do. PHPStan paired with PHPUnit gives us a very easy to use API to test custom rules.
To test our rule we need to create a PHPUnit test and extend the PHPStan\Testing\RuleTestCase
class. We also need to create fixture files that will be used as examples for testing our rule. I created 5 examples (the names are pretty self-explanatory):
The tests I wrote:
/**
* @extends RuleTestCase<EnforceStrictTypes>
*/
class EnforceStrictTypesTest extends RuleTestCase
{
protected function getRule(): Rule
{
return new EnforceStrictTypes();
}
#[Test]
public function itEnforcesStrictTypesOnAllFiles(): void
{
$fixtures = [
__DIR__ . '/../../Data/ClassWithoutStrictTypes.php',
__DIR__ . '/../../Data/file-without-strict-types.php',
__DIR__ . '/../../Data/ClassWithStrictTypes.php', // this one is ok
];
$this->analyse($fixtures, [
['File is missing declare(strict_types=1) statement', 3],
['File is missing declare(strict_types=1) statement', 3],
]);
}
#[Test]
public function itChecksIfStrictTypesIsSetToAValueDifferentThanOne(): void
{
$fixtures = [__DIR__ . '/../../Data/ClassWithStrictTypesSetAsFalse.php'];
$this->analyse($fixtures, [['declare(strict_types=1) statement value must be set as 1', 3]]);
}
#[Test]
public function itDoesNotCheckEmptyFiles(): void
{
$fixtures = [__DIR__ . '/../../Data/empty-file.php'];
$this->analyse($fixtures, []);
}
}
The analyse method second parameter is an array of errors that should return, it should contain the message and the line where the error occurred.
PHPStan will also complain about our fixture files, to ignore them set this to your phpstan.neon
config file.
parameters:
level: 8
paths:
- src
- tests
excludePaths:
- */tests/Data/*.php
rules:
- App\Rules\EnforceStrictTypes