Skip to content

Commit e358a06

Browse files
author
Florian Krämer
committed
Improving the MethodMustReturnType rule
1 parent ba9dd3c commit e358a06

File tree

6 files changed

+327
-27
lines changed

6 files changed

+327
-27
lines changed

data/MethodMustReturnType/ReturnTypeTestClass.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22

33
class ReturnTypeTestClass
44
{
5-
public function mustReturnInt(): void {}
6-
public function mustReturnNullableString(): string {}
7-
public function mustReturnVoid(): int {}
8-
public function mustReturnSpecificObject(): OtherObject {}
5+
// Invalid cases (should trigger errors)
6+
public function mustReturnInt(): void { return; }
7+
public function mustReturnNullableString(): string { return 'test'; }
8+
public function mustReturnVoid(): int { return 1; }
9+
public function mustReturnVoidLegacy(): int { return 1; }
10+
public function mustReturnSpecificObject(): OtherObject { return new OtherObject(); }
11+
public function mustReturnOneOf(): float { return 1.0; }
12+
public function mustReturnAllOf(): int { return 1; }
13+
public function mustReturnOneOfNullable(): string { return 'test'; }
914
}
1015

1116
class SomeObject {}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
class UnionTypeTestClass
4+
{
5+
// Test cases for oneOf functionality
6+
public function validOneOfInt(): int { return 1; }
7+
public function validOneOfString(): string { return 'test'; }
8+
public function validOneOfBool(): bool { return true; }
9+
10+
// Test cases for allOf functionality (simplified)
11+
public function validAllOfInt(): int { return 1; }
12+
public function validAllOfString(): string { return 'test'; }
13+
14+
// Invalid cases
15+
public function invalidOneOf(): float { return 1.0; }
16+
public function invalidAllOf(): bool { return true; }
17+
}

docs/Rules.md

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Add them to your `phpstan.neon` configuration file under the section `services`.
77
Ensures that the nesting level of `if` and `try-catch` statements does not exceed a specified limit.
88

99
**Configuration Example:**
10+
1011
```neon
1112
-
1213
class: Phauthentic\PHPStanRules\CleanCode\ControlStructureNestingRule
@@ -21,6 +22,7 @@ Ensures that the nesting level of `if` and `try-catch` statements does not excee
2122
Checks that methods do not have more than a specified number of arguments.
2223

2324
**Configuration Example:**
25+
2426
```neon
2527
-
2628
class: Phauthentic\PHPStanRules\CleanCode\TooManyArgumentsRule
@@ -35,6 +37,7 @@ Checks that methods do not have more than a specified number of arguments.
3537
Ensures that classes matching specified patterns are declared as `readonly`.
3638

3739
**Configuration Example:**
40+
3841
```neon
3942
-
4043
class: Phauthentic\PHPStanRules\Architecture\ClassMustBeReadonlyRule
@@ -53,6 +56,7 @@ The constructor takes an array of namespace dependencies. The key is the namespa
5356
In the example below nothing from `App\Domain` can depend on anything from `App\Controller`.
5457

5558
**Configuration Example:**
59+
5660
```neon
5761
-
5862
class: Phauthentic\PHPStanRules\Architecture\DependencyConstraintsRule
@@ -69,6 +73,7 @@ In the example below nothing from `App\Domain` can depend on anything from `App\
6973
Ensures that classes matching specified patterns are declared as `final`.
7074

7175
**Configuration Example:**
76+
7277
```neon
7378
-
7479
class: Phauthentic\PHPStanRules\Architecture\ClassMustBeFinalRule
@@ -83,6 +88,7 @@ Ensures that classes matching specified patterns are declared as `final`.
8388
Ensures that classes inside namespaces matching a given regex must have names matching at least one of the provided patterns.
8489

8590
**Configuration Example:**
91+
8692
```neon
8793
-
8894
class: Phauthentic\PHPStanRules\Architecture\ClassnameMustMatchPatternRule
@@ -104,6 +110,7 @@ Ensures that classes inside namespaces matching a given regex must have names ma
104110
Ensures that specific exception types are not caught in catch blocks. This is useful for preventing the catching of overly broad exception types like `Exception`, `Error`, or `Throwable`.
105111

106112
**Configuration Example:**
113+
107114
```neon
108115
-
109116
class: Phauthentic\PHPStanRules\Architecture\CatchExceptionOfTypeNotAllowedRule
@@ -118,6 +125,7 @@ Ensures that specific exception types are not caught in catch blocks. This is us
118125
Ensures that methods returning boolean values follow a specific naming convention. By default, boolean methods should start with `is`, `has`, `can`, `should`, `was`, or `will`.
119126

120127
**Configuration Example:**
128+
121129
```neon
122130
-
123131
class: Phauthentic\PHPStanRules\Architecture\MethodsReturningBoolMustFollowNamingConventionRule
@@ -132,6 +140,7 @@ Ensures that methods returning boolean values follow a specific naming conventio
132140
Ensures that methods matching a class and method name pattern have a specific signature, including parameter types, names, and count.
133141

134142
**Configuration Example:**
143+
135144
```neon
136145
-
137146
class: Phauthentic\PHPStanRules\Architecture\MethodSignatureMustMatchRule
@@ -151,16 +160,18 @@ Ensures that methods matching a class and method name pattern have a specific si
151160
tags:
152161
- phpstan.rules.rule
153162
```
163+
154164
- `pattern`: Regex for `ClassName::methodName`.
155165
- `minParameters`/`maxParameters`: Minimum/maximum number of parameters.
156166
- `signature`: List of expected parameter types and (optionally) name patterns.
157167
- `visibilityScope`: Optional visibility scope (e.g., `public`, `protected`, `private`).
158168

159169
## Method Must Return Type Rule {#method-must-return-type-rule}
160170

161-
Ensures that methods matching a class and method name pattern have a specific return type, nullability, or are void.
171+
Ensures that methods matching a class and method name pattern have a specific return type, nullability, or are void. Supports union types with "oneOf" and "allOf" configurations.
162172

163173
**Configuration Example:**
174+
164175
```neon
165176
-
166177
class: Phauthentic\PHPStanRules\Architecture\MethodMustReturnTypeRule
@@ -182,13 +193,28 @@ Ensures that methods matching a class and method name pattern have a specific re
182193
pattern: '/^MyClass::reset$/'
183194
type: 'void'
184195
nullable: false
185-
void: true
196+
void: false
197+
objectTypePattern: null
198+
-
199+
pattern: '/^MyClass::getValue$/'
200+
nullable: false
201+
void: false
202+
oneOf: ['int', 'string', 'bool']
203+
objectTypePattern: null
204+
-
205+
pattern: '/^MyClass::getUnionType$/'
206+
nullable: false
207+
void: false
208+
allOf: ['int', 'string']
186209
objectTypePattern: null
187210
tags:
188211
- phpstan.rules.rule
189212
```
213+
190214
- `pattern`: Regex for `ClassName::methodName`.
191-
- `type`: Expected return type (`int`, `string`, `object`, etc.).
215+
- `type`: Expected return type (`int`, `string`, `object`, `void`, etc.). When using `oneOf` or `allOf`, this field is optional.
192216
- `nullable`: Whether the return type must be nullable.
193-
- `void`: Whether the method must return void.
217+
- `void`: Legacy field for void return types (use `type: 'void'` instead).
194218
- `objectTypePattern`: Regex for object return types (if `type` is `object`).
219+
- `oneOf`: Array of types where one must match (for union types).
220+
- `allOf`: Array of types where all must be present in the union type.

src/Architecture/MethodMustReturnTypeRule.php

Lines changed: 153 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
* - Checks if the method returns the expected type or object, is nullable or void.
2121
* - Check if the types of the parameters match the expected types.
2222
* - If an object type is expected, it can match a specific class or a pattern.
23+
* - Supports union types with "oneOf" (one type must match) and "allOf" (all types must match).
2324
*/
2425
class MethodMustReturnTypeRule implements Rule
2526
{
@@ -31,14 +32,18 @@ class MethodMustReturnTypeRule implements Rule
3132
private const ERROR_MESSAGE_OBJECT_TYPE_PATTERN = 'Method %s must return an object matching pattern %s, %s given.';
3233
private const ERROR_MESSAGE_OBJECT_TYPE = 'Method %s must return an object type.';
3334
private const ERROR_MESSAGE_TYPE_MISMATCH = 'Method %s must have return type %s, %s given.';
35+
private const ERROR_MESSAGE_ONE_OF_MISMATCH = 'Method %s must have one of the return types: %s, %s given.';
36+
private const ERROR_MESSAGE_ALL_OF_MISMATCH = 'Method %s must have all of the return types: %s, %s given.';
3437

3538
/**
3639
* @param array<array{
3740
* pattern: string,
38-
* type: string,
41+
* type?: string,
3942
* nullable: bool,
4043
* void: bool,
4144
* objectTypePattern: string|null,
45+
* oneOf?: array<string>,
46+
* allOf?: array<string>,
4247
* }> $returnTypePatterns
4348
*/
4449
public function __construct(
@@ -81,7 +86,8 @@ public function processNode(Node $node, Scope $scope): array
8186

8287
// Check for missing return type
8388
if ($this->shouldErrorOnMissingReturnType($returnType)) {
84-
$errors[] = $this->buildMissingReturnTypeError($fullName, $patternConfig['type'], $method->getLine());
89+
$expectedType = $this->getExpectedTypeDescription($patternConfig);
90+
$errors[] = $this->buildMissingReturnTypeError($fullName, $expectedType, $method->getLine());
8591
continue;
8692
}
8793

@@ -92,18 +98,32 @@ public function processNode(Node $node, Scope $scope): array
9298
$errors[] = $this->buildNullabilityError($fullName, $patternConfig['nullable'], $method->getLine());
9399
}
94100

95-
// Check for type
96-
if ($patternConfig['type'] === 'object') {
97-
if ($returnTypeNode instanceof Name) {
98-
$objectType = $returnTypeNode->toString();
99-
if ($this->shouldErrorOnObjectTypePattern($patternConfig, $objectType)) {
100-
$errors[] = $this->buildObjectTypePatternError($fullName, $patternConfig['objectTypePattern'], $objectType, $method->getLine());
101+
// Check for union types (oneOf/allOf)
102+
if (isset($patternConfig['oneOf'])) {
103+
if ($this->shouldErrorOnOneOf($patternConfig['oneOf'], $returnType)) {
104+
$errors[] = $this->buildOneOfError($fullName, $patternConfig['oneOf'], $returnType, $method->getLine());
105+
continue;
106+
}
107+
} elseif (isset($patternConfig['allOf'])) {
108+
if ($this->shouldErrorOnAllOf($patternConfig['allOf'], $returnType)) {
109+
$errors[] = $this->buildAllOfError($fullName, $patternConfig['allOf'], $returnType, $method->getLine());
110+
continue;
111+
}
112+
} else {
113+
// Check for single type
114+
$expectedType = $patternConfig['type'] ?? 'void';
115+
if ($expectedType === 'object') {
116+
if ($returnTypeNode instanceof Name) {
117+
$objectType = $returnTypeNode->toString();
118+
if ($this->shouldErrorOnObjectTypePattern($patternConfig, $objectType)) {
119+
$errors[] = $this->buildObjectTypePatternError($fullName, $patternConfig['objectTypePattern'], $objectType, $method->getLine());
120+
}
121+
} else {
122+
$errors[] = $this->buildObjectTypeError($fullName, $method->getLine());
101123
}
102-
} else {
103-
$errors[] = $this->buildObjectTypeError($fullName, $method->getLine());
124+
} elseif ($this->shouldErrorOnTypeMismatch($returnType, $expectedType)) {
125+
$errors[] = $this->buildTypeMismatchError($fullName, $expectedType, $returnType, $method->getLine());
104126
}
105-
} elseif ($this->shouldErrorOnTypeMismatch($returnType, $patternConfig['type'])) {
106-
$errors[] = $this->buildTypeMismatchError($fullName, $patternConfig['type'], $returnType, $method->getLine());
107127
}
108128
}
109129
}
@@ -134,6 +154,17 @@ private function shouldErrorOnMissingReturnType(?string $returnType): bool
134154
return $returnType === null;
135155
}
136156

157+
private function getExpectedTypeDescription(array $patternConfig): string
158+
{
159+
if (isset($patternConfig['oneOf'])) {
160+
return 'one of: ' . implode(', ', $patternConfig['oneOf']);
161+
}
162+
if (isset($patternConfig['allOf'])) {
163+
return 'all of: ' . implode(', ', $patternConfig['allOf']);
164+
}
165+
return $patternConfig['type'] ?? 'void';
166+
}
167+
137168
private function buildMissingReturnTypeError(string $fullName, string $expectedType, int $line)
138169
{
139170
return RuleErrorBuilder::message(
@@ -167,6 +198,114 @@ private function buildNullabilityError(string $fullName, bool $expectedNullable,
167198
->build();
168199
}
169200

201+
private function shouldErrorOnOneOf(array $expectedTypes, ?string $returnType): bool
202+
{
203+
if ($returnType === null) {
204+
return true;
205+
}
206+
207+
foreach ($expectedTypes as $expectedType) {
208+
if ($this->isTypeMatch($returnType, $expectedType)) {
209+
return false;
210+
}
211+
}
212+
213+
return true;
214+
}
215+
216+
private function buildOneOfError(string $fullName, array $expectedTypes, ?string $actualType, int $line)
217+
{
218+
return RuleErrorBuilder::message(
219+
sprintf(
220+
self::ERROR_MESSAGE_ONE_OF_MISMATCH,
221+
$fullName,
222+
implode(', ', $expectedTypes),
223+
$actualType ?? 'no return type'
224+
)
225+
)
226+
->identifier(self::IDENTIFIER)
227+
->line($line)
228+
->build();
229+
}
230+
231+
private function shouldErrorOnAllOf(array $expectedTypes, ?string $returnType): bool
232+
{
233+
if ($returnType === null) {
234+
return true;
235+
}
236+
237+
// For allOf, we need to check if the return type is a union type that contains all expected types
238+
// This is a simplified implementation - in practice, you might need more sophisticated union type parsing
239+
$actualTypes = $this->parseUnionType($returnType);
240+
241+
foreach ($expectedTypes as $expectedType) {
242+
$found = false;
243+
foreach ($actualTypes as $actualType) {
244+
if ($this->isTypeMatch($actualType, $expectedType)) {
245+
$found = true;
246+
break;
247+
}
248+
}
249+
if (!$found) {
250+
return true;
251+
}
252+
}
253+
254+
return false;
255+
}
256+
257+
private function buildAllOfError(string $fullName, array $expectedTypes, ?string $actualType, int $line)
258+
{
259+
return RuleErrorBuilder::message(
260+
sprintf(
261+
self::ERROR_MESSAGE_ALL_OF_MISMATCH,
262+
$fullName,
263+
implode(', ', $expectedTypes),
264+
$actualType ?? 'no return type'
265+
)
266+
)
267+
->identifier(self::IDENTIFIER)
268+
->line($line)
269+
->build();
270+
}
271+
272+
private function parseUnionType(?string $type): array
273+
{
274+
if ($type === null) {
275+
return [];
276+
}
277+
278+
// Simple union type parsing - split by '|' and trim
279+
return array_map('trim', explode('|', $type));
280+
}
281+
282+
private function isTypeMatch(?string $actual, string $expected): bool
283+
{
284+
if ($actual === null) {
285+
return false;
286+
}
287+
288+
// Direct match
289+
if ($actual === $expected) {
290+
return true;
291+
}
292+
293+
// Handle nullable types
294+
if ($this->isNullableMatch($actual, $expected)) {
295+
return true;
296+
}
297+
298+
// Handle union types
299+
$actualTypes = $this->parseUnionType($actual);
300+
foreach ($actualTypes as $actualType) {
301+
if ($actualType === $expected || $this->isNullableMatch($actualType, $expected)) {
302+
return true;
303+
}
304+
}
305+
306+
return false;
307+
}
308+
170309
private function shouldErrorOnObjectTypePattern(array $patternConfig, string $objectType): bool
171310
{
172311
return $patternConfig['objectTypePattern'] !== null &&
@@ -203,7 +342,7 @@ private function buildObjectTypeError(string $fullName, int $line)
203342

204343
private function shouldErrorOnTypeMismatch(?string $returnType, string $expectedType): bool
205344
{
206-
return $returnType !== $expectedType && !$this->isNullableMatch($returnType, $expectedType);
345+
return !$this->isTypeMatch($returnType, $expectedType);
207346
}
208347

209348
private function buildTypeMismatchError(string $fullName, string $expectedType, ?string $actualType, int $line)
@@ -213,7 +352,7 @@ private function buildTypeMismatchError(string $fullName, string $expectedType,
213352
self::ERROR_MESSAGE_TYPE_MISMATCH,
214353
$fullName,
215354
$expectedType,
216-
$actualType
355+
$actualType ?? 'no return type'
217356
)
218357
)
219358
->identifier(self::IDENTIFIER)

0 commit comments

Comments
 (0)