Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
},
"autoload-dev": {
"psr-4": {
"Ibexa\\Tests\\PHPStan\\Rules\\": "tests/rules/"
"Ibexa\\Tests\\PHPStan\\Rules\\": "tests/rules/",
"Ibexa\\Tests\\PHPStan\\Rules\\Data\\": "tests/rules/data/"
}
},
"scripts": {
Expand Down
3 changes: 3 additions & 0 deletions extension-mocks.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
rules:
- Ibexa\PHPStan\Rules\RequireMockObjectInPropertyTypeRule
- Ibexa\PHPStan\Rules\RequireMockObjectInReturnTypeRule
2 changes: 2 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ parameters:
paths:
- rules
- tests
excludePaths:
- tests/rules/data/*
checkMissingCallableSignature: true
5 changes: 4 additions & 1 deletion rules/RequireClosureReturnTypeRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ public function getNodeType(): string
return Node\Expr::class;
}

/**
* @return list<\PHPStan\Rules\IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
if (!$node instanceof Node\Expr\Closure && !$node instanceof Node\Expr\ArrowFunction) {
Expand All @@ -35,7 +38,7 @@ public function processNode(Node $node, Scope $scope): array
return [
RuleErrorBuilder::message(
sprintf('%s is missing a return type declaration', $nodeType)
)->build(),
)->identifier('Ibexa.requireClosureReturnType')->build(),
];
}

Expand Down
92 changes: 92 additions & 0 deletions rules/RequireMockObjectInPropertyTypeRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\PHPStan\Rules;

use PhpParser\Node;
use PhpParser\Node\Identifier;
use PhpParser\Node\IntersectionType;
use PhpParser\Node\Name;
use PhpParser\Node\NullableType;
use PhpParser\Node\Stmt\Property;
use PhpParser\Node\UnionType;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
* @implements Rule<Property>
*/
final class RequireMockObjectInPropertyTypeRule implements Rule
Comment thread
mikadamczyk marked this conversation as resolved.
Outdated
{
public function getNodeType(): string
{
return Property::class;
}

/**
* @return list<\PHPStan\Rules\IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
if ($node->type === null) {
return [];
}

if (!$this->docCommentIncludesMockObject($node)) {
return [];
}

if ($this->typeNodeIncludesMockObject($node->type)) {
return [];
}

return [
RuleErrorBuilder::message('Property typed as MockObject only in PHPDoc. Use intersection type with MockObject.')
->identifier('tests.mockObject.propertyType')
Comment thread
mikadamczyk marked this conversation as resolved.
Outdated
->build(),
];
}

private function typeNodeIncludesMockObject(Node $type): bool
{
if ($type instanceof NullableType) {
return $this->typeNodeIncludesMockObject($type->type);
}

if ($type instanceof UnionType || $type instanceof IntersectionType) {
foreach ($type->types as $innerType) {
if ($this->typeNodeIncludesMockObject($innerType)) {
return true;
}
}

return false;
}

if ($type instanceof Identifier) {
return $type->toString() === 'MockObject';
}

if ($type instanceof Name) {
return $type->getLast() === 'MockObject';
}

return false;
}

private function docCommentIncludesMockObject(Property $property): bool
{
$docComment = $property->getDocComment();
if ($docComment === null) {
return false;
}

return preg_match('/@var\\s+[^\\n]*MockObject/', $docComment->getText()) === 1;
}
}
143 changes: 143 additions & 0 deletions rules/RequireMockObjectInReturnTypeRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\PHPStan\Rules;

use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PhpParser\Node\IntersectionType;
use PhpParser\Node\Name;
use PhpParser\Node\NullableType;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\UnionType;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
* @implements Rule<ClassMethod>
*/
final class RequireMockObjectInReturnTypeRule implements Rule
Comment thread
mikadamczyk marked this conversation as resolved.
Outdated
{
public function getNodeType(): string
{
return ClassMethod::class;
}

/**
* @return list<\PHPStan\Rules\IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
if ($node->returnType === null || $node->stmts === null) {
return [];
}

if (!$this->returnsMock($node)) {
return [];
}

if ($this->typeNodeIncludesMockObject($node->returnType)) {
return [];
}

return [
RuleErrorBuilder::message('Method returns a mock but return type is missing MockObject intersection.')
->identifier('tests.mockObject.returnType')
Comment thread
mikadamczyk marked this conversation as resolved.
Outdated
->build(),
];
}

private function returnsMock(ClassMethod $node): bool
{
$mockVariables = [];
foreach ($node->getStmts() ?? [] as $stmt) {
if ($stmt instanceof Node\Stmt\Expression && $stmt->expr instanceof Node\Expr\Assign) {
$assign = $stmt->expr;
if ($assign->var instanceof Variable && is_string($assign->var->name)) {
if ($assign->expr instanceof MethodCall && $this->isCreateMockCall($assign->expr)) {
$mockVariables[$assign->var->name] = true;
}

if ($assign->expr instanceof StaticCall && $this->isCreateMockCall($assign->expr)) {
$mockVariables[$assign->var->name] = true;
}
}
}

if (!$stmt instanceof Node\Stmt\Return_ || $stmt->expr === null) {
continue;
}

$expr = $stmt->expr;
if ($expr instanceof MethodCall && $this->isCreateMockCall($expr)) {
return true;
}

if ($expr instanceof StaticCall && $this->isCreateMockCall($expr)) {
return true;
}

if ($expr instanceof Variable && is_string($expr->name) && isset($mockVariables[$expr->name])) {
return true;
}
}

return false;
}

/**
* @param \PhpParser\Node\Expr\MethodCall|\PhpParser\Node\Expr\StaticCall $call
*/
private function isCreateMockCall(Node $call): bool
{
if (!$call->name instanceof Node\Identifier) {
return false;
}

if ($call->name->toString() !== 'createMock') {
return false;
}

if ($call instanceof MethodCall) {
return $call->var instanceof Variable && $call->var->name === 'this';
}

return true;
}

private function typeNodeIncludesMockObject(Node $type): bool
{
if ($type instanceof NullableType) {
return $this->typeNodeIncludesMockObject($type->type);
}

if ($type instanceof UnionType || $type instanceof IntersectionType) {
foreach ($type->types as $innerType) {
if ($this->typeNodeIncludesMockObject($innerType)) {
return true;
}
}

return false;
}

if ($type instanceof Identifier) {
return $type->toString() === 'MockObject';
}

if ($type instanceof Name) {
return $type->getLast() === 'MockObject';
}

return false;
}
}
37 changes: 37 additions & 0 deletions tests/rules/RequireMockObjectInPropertyTypeRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Tests\PHPStan\Rules;

use Ibexa\PHPStan\Rules\RequireMockObjectInPropertyTypeRule;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<RequireMockObjectInPropertyTypeRule>
*/
final class RequireMockObjectInPropertyTypeRuleTest extends RuleTestCase
{
protected function getRule(): Rule
{
return new RequireMockObjectInPropertyTypeRule();
}

public function testRule(): void
{
$this->analyse(
[__DIR__ . '/data/require-mockobject-property.php'],
[
[
'Property typed as MockObject only in PHPDoc. Use intersection type with MockObject.',
16,
],
]
);
}
}
37 changes: 37 additions & 0 deletions tests/rules/RequireMockObjectInReturnTypeRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Tests\PHPStan\Rules;

use Ibexa\PHPStan\Rules\RequireMockObjectInReturnTypeRule;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<RequireMockObjectInReturnTypeRule>
*/
final class RequireMockObjectInReturnTypeRuleTest extends RuleTestCase
{
protected function getRule(): Rule
{
return new RequireMockObjectInReturnTypeRule();
}

public function testRule(): void
{
$this->analyse(
[__DIR__ . '/data/require-mockobject-return.php'],
[
[
'Method returns a mock but return type is missing MockObject intersection.',
15,
],
]
);
}
}
25 changes: 25 additions & 0 deletions tests/rules/data/require-mockobject-property.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Tests\PHPStan\Rules\Data;

use PHPUnit\Framework\TestCase;

final class PropertyMockTypeTest extends TestCase
{
/** @var Foo&MockObject */
private Foo $foo;
}

final class Foo
{
}

interface MockObject
{
}
Loading
Loading