Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/vendor/
/.php-cs-fixer.cache
/.phpunit.result.cache
/composer.lock
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\RequireConcreteTypeForMockReturnRule
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/Fixtures/*
checkMissingCallableSignature: true
2 changes: 1 addition & 1 deletion rules/NoConfigResolverParametersInConstructorRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public function processNode(Node $node, Scope $scope): array
return [
RuleErrorBuilder
::message('Referring to ConfigResolver parameters in constructor is not allowed due to potential scope change.')
->identifier('Ibexa.NoConfigResolverParametersInConstructor')
->identifier('Ibexa.noConfigResolverParametersInConstructor')
->nonIgnorable()
->build(),
];
Expand Down
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
170 changes: 170 additions & 0 deletions rules/RequireConcreteTypeForMockReturnRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?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 readonly class RequireConcreteTypeForMockReturnRule implements Rule
{
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->typeNodeIsMockObjectOnly($node->returnType)) {
return [];
}

return [
RuleErrorBuilder::message('Method returns a mock and declares only MockObject as return type. Use an intersection with a concrete type.')
->identifier('Ibexa.requireConcreteTypeForMockReturn')
->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 typeNodeIsMockObjectOnly(Node $type): bool
{
if ($type instanceof NullableType) {
return $this->typeNodeIsMockObjectOnly($type->type);
}

if ($type instanceof IntersectionType) {
$hasMockObject = false;
foreach ($type->types as $innerType) {
if ($this->isMockObjectType($innerType)) {
$hasMockObject = true;
continue;
}

return false;
}

return $hasMockObject;
}

if ($type instanceof UnionType) {
$hasMockObject = false;
foreach ($type->types as $innerType) {
if ($innerType instanceof Name && $innerType->getLast() === 'null') {
continue;
}

if ($this->isMockObjectType($innerType)) {
$hasMockObject = true;
continue;
}

return false;
}

return $hasMockObject;
}

return $this->isMockObjectType($type);
}

private function isMockObjectType(Node $type): bool
{
if ($type instanceof Identifier) {
return $type->toString() === 'MockObject';
}

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

return false;
}
}
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 readonly class RequireMockObjectInPropertyTypeRule implements Rule
{
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('Ibexa.requireMockObjectPropertyType')
->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;
}
}
39 changes: 39 additions & 0 deletions tests/rules/Fixtures/RequireConcreteTypeForMockReturnFixture.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?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\Fixtures;

use PHPUnit\Framework\TestCase;

final class ConcreteMockReturnTypeFixture extends TestCase
{
private function createFoo(): Foo
{
$foo = $this->createMock(Foo::class);

return $foo;
}

private function createFooOk(): Foo&MockObject
{
return $this->createMock(Foo::class);
}

private function createMockObjectOnly(): MockObject
{
return $this->createMock(Foo::class);
}
}

final class Foo
{
}

interface MockObject
{
}
25 changes: 25 additions & 0 deletions tests/rules/Fixtures/RequireMockObjectInPropertyTypeFixture.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\Fixtures;

use PHPUnit\Framework\TestCase;

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

final class Foo
{
}

interface MockObject
{
}
Loading
Loading