Skip to content

Commit 805cfed

Browse files
authored
Added optional MockObject rules and fixtures to phpstan (#10)
* Fixed PHPStan rule error identifiers * Updated identifier for closure return type rule error message * Added optional MockObject rules and fixtures to phpstan * Added opt-in MockObject rules and fixtures * Normalized PHPStan rule identifiers * Adjusted mock-return rule to require concrete type * Added .gitignore to keep local caches and vendor out of version control
1 parent 161a001 commit 805cfed

10 files changed

+410
-1
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/vendor/
2+
/.php-cs-fixer.cache
3+
/.phpunit.result.cache
4+
/composer.lock

extension-mocks.neon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
rules:
2+
- Ibexa\PHPStan\Rules\RequireMockObjectInPropertyTypeRule
3+
- Ibexa\PHPStan\Rules\RequireConcreteTypeForMockReturnRule

phpstan.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ parameters:
33
paths:
44
- rules
55
- tests
6+
excludePaths:
7+
- tests/rules/Fixtures/*
68
checkMissingCallableSignature: true

rules/NoConfigResolverParametersInConstructorRule.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public function processNode(Node $node, Scope $scope): array
6060
return [
6161
RuleErrorBuilder
6262
::message('Referring to ConfigResolver parameters in constructor is not allowed due to potential scope change.')
63-
->identifier('Ibexa.NoConfigResolverParametersInConstructor')
63+
->identifier('Ibexa.noConfigResolverParametersInConstructor')
6464
->nonIgnorable()
6565
->build(),
6666
];
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<?php
2+
3+
/**
4+
* @copyright Copyright (C) Ibexa AS. All rights reserved.
5+
* @license For full copyright and license information view LICENSE file distributed with this source code.
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Ibexa\PHPStan\Rules;
10+
11+
use PhpParser\Node;
12+
use PhpParser\Node\Expr\MethodCall;
13+
use PhpParser\Node\Expr\StaticCall;
14+
use PhpParser\Node\Expr\Variable;
15+
use PhpParser\Node\Identifier;
16+
use PhpParser\Node\IntersectionType;
17+
use PhpParser\Node\Name;
18+
use PhpParser\Node\NullableType;
19+
use PhpParser\Node\Stmt\ClassMethod;
20+
use PhpParser\Node\UnionType;
21+
use PHPStan\Analyser\Scope;
22+
use PHPStan\Rules\Rule;
23+
use PHPStan\Rules\RuleErrorBuilder;
24+
25+
/**
26+
* @implements Rule<ClassMethod>
27+
*/
28+
final readonly class RequireConcreteTypeForMockReturnRule implements Rule
29+
{
30+
public function getNodeType(): string
31+
{
32+
return ClassMethod::class;
33+
}
34+
35+
/**
36+
* @return list<\PHPStan\Rules\IdentifierRuleError>
37+
*/
38+
public function processNode(Node $node, Scope $scope): array
39+
{
40+
if ($node->returnType === null || $node->stmts === null) {
41+
return [];
42+
}
43+
44+
if (!$this->returnsMock($node)) {
45+
return [];
46+
}
47+
48+
if (!$this->typeNodeIsMockObjectOnly($node->returnType)) {
49+
return [];
50+
}
51+
52+
return [
53+
RuleErrorBuilder::message('Method returns a mock and declares only MockObject as return type. Use an intersection with a concrete type.')
54+
->identifier('Ibexa.requireConcreteTypeForMockReturn')
55+
->build(),
56+
];
57+
}
58+
59+
private function returnsMock(ClassMethod $node): bool
60+
{
61+
$mockVariables = [];
62+
foreach ($node->getStmts() ?? [] as $stmt) {
63+
if ($stmt instanceof Node\Stmt\Expression && $stmt->expr instanceof Node\Expr\Assign) {
64+
$assign = $stmt->expr;
65+
if ($assign->var instanceof Variable && is_string($assign->var->name)) {
66+
if ($assign->expr instanceof MethodCall && $this->isCreateMockCall($assign->expr)) {
67+
$mockVariables[$assign->var->name] = true;
68+
}
69+
70+
if ($assign->expr instanceof StaticCall && $this->isCreateMockCall($assign->expr)) {
71+
$mockVariables[$assign->var->name] = true;
72+
}
73+
}
74+
}
75+
76+
if (!$stmt instanceof Node\Stmt\Return_ || $stmt->expr === null) {
77+
continue;
78+
}
79+
80+
$expr = $stmt->expr;
81+
if ($expr instanceof MethodCall && $this->isCreateMockCall($expr)) {
82+
return true;
83+
}
84+
85+
if ($expr instanceof StaticCall && $this->isCreateMockCall($expr)) {
86+
return true;
87+
}
88+
89+
if ($expr instanceof Variable && is_string($expr->name) && isset($mockVariables[$expr->name])) {
90+
return true;
91+
}
92+
}
93+
94+
return false;
95+
}
96+
97+
/**
98+
* @param \PhpParser\Node\Expr\MethodCall|\PhpParser\Node\Expr\StaticCall $call
99+
*/
100+
private function isCreateMockCall(Node $call): bool
101+
{
102+
if (!$call->name instanceof Node\Identifier) {
103+
return false;
104+
}
105+
106+
if ($call->name->toString() !== 'createMock') {
107+
return false;
108+
}
109+
110+
if ($call instanceof MethodCall) {
111+
return $call->var instanceof Variable && $call->var->name === 'this';
112+
}
113+
114+
return true;
115+
}
116+
117+
private function typeNodeIsMockObjectOnly(Node $type): bool
118+
{
119+
if ($type instanceof NullableType) {
120+
return $this->typeNodeIsMockObjectOnly($type->type);
121+
}
122+
123+
if ($type instanceof IntersectionType) {
124+
$hasMockObject = false;
125+
foreach ($type->types as $innerType) {
126+
if ($this->isMockObjectType($innerType)) {
127+
$hasMockObject = true;
128+
continue;
129+
}
130+
131+
return false;
132+
}
133+
134+
return $hasMockObject;
135+
}
136+
137+
if ($type instanceof UnionType) {
138+
$hasMockObject = false;
139+
foreach ($type->types as $innerType) {
140+
if ($innerType instanceof Name && $innerType->getLast() === 'null') {
141+
continue;
142+
}
143+
144+
if ($this->isMockObjectType($innerType)) {
145+
$hasMockObject = true;
146+
continue;
147+
}
148+
149+
return false;
150+
}
151+
152+
return $hasMockObject;
153+
}
154+
155+
return $this->isMockObjectType($type);
156+
}
157+
158+
private function isMockObjectType(Node $type): bool
159+
{
160+
if ($type instanceof Identifier) {
161+
return $type->toString() === 'MockObject';
162+
}
163+
164+
if ($type instanceof Name) {
165+
return $type->getLast() === 'MockObject';
166+
}
167+
168+
return false;
169+
}
170+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
/**
4+
* @copyright Copyright (C) Ibexa AS. All rights reserved.
5+
* @license For full copyright and license information view LICENSE file distributed with this source code.
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Ibexa\PHPStan\Rules;
10+
11+
use PhpParser\Node;
12+
use PhpParser\Node\Identifier;
13+
use PhpParser\Node\IntersectionType;
14+
use PhpParser\Node\Name;
15+
use PhpParser\Node\NullableType;
16+
use PhpParser\Node\Stmt\Property;
17+
use PhpParser\Node\UnionType;
18+
use PHPStan\Analyser\Scope;
19+
use PHPStan\Rules\Rule;
20+
use PHPStan\Rules\RuleErrorBuilder;
21+
22+
/**
23+
* @implements Rule<Property>
24+
*/
25+
final readonly class RequireMockObjectInPropertyTypeRule implements Rule
26+
{
27+
public function getNodeType(): string
28+
{
29+
return Property::class;
30+
}
31+
32+
/**
33+
* @return list<\PHPStan\Rules\IdentifierRuleError>
34+
*/
35+
public function processNode(Node $node, Scope $scope): array
36+
{
37+
if ($node->type === null) {
38+
return [];
39+
}
40+
41+
if (!$this->docCommentIncludesMockObject($node)) {
42+
return [];
43+
}
44+
45+
if ($this->typeNodeIncludesMockObject($node->type)) {
46+
return [];
47+
}
48+
49+
return [
50+
RuleErrorBuilder::message('Property typed as MockObject only in PHPDoc. Use intersection type with MockObject.')
51+
->identifier('Ibexa.requireMockObjectPropertyType')
52+
->build(),
53+
];
54+
}
55+
56+
private function typeNodeIncludesMockObject(Node $type): bool
57+
{
58+
if ($type instanceof NullableType) {
59+
return $this->typeNodeIncludesMockObject($type->type);
60+
}
61+
62+
if ($type instanceof UnionType || $type instanceof IntersectionType) {
63+
foreach ($type->types as $innerType) {
64+
if ($this->typeNodeIncludesMockObject($innerType)) {
65+
return true;
66+
}
67+
}
68+
69+
return false;
70+
}
71+
72+
if ($type instanceof Identifier) {
73+
return $type->toString() === 'MockObject';
74+
}
75+
76+
if ($type instanceof Name) {
77+
return $type->getLast() === 'MockObject';
78+
}
79+
80+
return false;
81+
}
82+
83+
private function docCommentIncludesMockObject(Property $property): bool
84+
{
85+
$docComment = $property->getDocComment();
86+
if ($docComment === null) {
87+
return false;
88+
}
89+
90+
return preg_match('/@var\\s+[^\\n]*MockObject/', $docComment->getText()) === 1;
91+
}
92+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/**
4+
* @copyright Copyright (C) Ibexa AS. All rights reserved.
5+
* @license For full copyright and license information view LICENSE file distributed with this source code.
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Ibexa\Tests\PHPStan\Rules\Fixtures;
10+
11+
use PHPUnit\Framework\TestCase;
12+
13+
final class ConcreteMockReturnTypeFixture extends TestCase
14+
{
15+
private function createFoo(): Foo
16+
{
17+
$foo = $this->createMock(Foo::class);
18+
19+
return $foo;
20+
}
21+
22+
private function createFooOk(): Foo&MockObject
23+
{
24+
return $this->createMock(Foo::class);
25+
}
26+
27+
private function createMockObjectOnly(): MockObject
28+
{
29+
return $this->createMock(Foo::class);
30+
}
31+
}
32+
33+
final class Foo
34+
{
35+
}
36+
37+
interface MockObject
38+
{
39+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/**
4+
* @copyright Copyright (C) Ibexa AS. All rights reserved.
5+
* @license For full copyright and license information view LICENSE file distributed with this source code.
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Ibexa\Tests\PHPStan\Rules\Fixtures;
10+
11+
use PHPUnit\Framework\TestCase;
12+
13+
final class PropertyMockTypeTest extends TestCase
14+
{
15+
/** @var Foo&MockObject */
16+
private Foo $foo;
17+
}
18+
19+
final class Foo
20+
{
21+
}
22+
23+
interface MockObject
24+
{
25+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/**
4+
* @copyright Copyright (C) Ibexa AS. All rights reserved.
5+
* @license For full copyright and license information view LICENSE file distributed with this source code.
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Ibexa\Tests\PHPStan\Rules;
10+
11+
use Ibexa\PHPStan\Rules\RequireConcreteTypeForMockReturnRule;
12+
use PHPStan\Rules\Rule;
13+
use PHPStan\Testing\RuleTestCase;
14+
15+
/**
16+
* @extends RuleTestCase<RequireConcreteTypeForMockReturnRule>
17+
*/
18+
final class RequireConcreteTypeForMockReturnRuleTest extends RuleTestCase
19+
{
20+
protected function getRule(): Rule
21+
{
22+
return new RequireConcreteTypeForMockReturnRule();
23+
}
24+
25+
public function testRule(): void
26+
{
27+
$this->analyse(
28+
[__DIR__ . '/Fixtures/RequireConcreteTypeForMockReturnFixture.php'],
29+
[
30+
[
31+
'Method returns a mock and declares only MockObject as return type. Use an intersection with a concrete type.',
32+
27,
33+
],
34+
]
35+
);
36+
}
37+
}

0 commit comments

Comments
 (0)