Skip to content

Commit 2876393

Browse files
floriankraemerFlorian Krämer
andauthored
Making it possible to require methods in a class (#25)
Co-authored-by: Florian Krämer <[email protected]>
1 parent 3b1e0fa commit 2876393

File tree

4 files changed

+262
-2
lines changed

4 files changed

+262
-2
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Phauthentic\PHPStanRules\Tests\Data\MethodSignatureMustMatch;
6+
7+
// This class is missing the required execute method
8+
class MyTestController
9+
{
10+
public function index(): void
11+
{
12+
}
13+
}
14+
15+
// This class implements the required method correctly
16+
class AnotherTestController
17+
{
18+
public function execute(int $id): void
19+
{
20+
}
21+
}
22+
23+
// This class is missing the required method
24+
class YetAnotherTestController
25+
{
26+
public function something(): void
27+
{
28+
}
29+
}
30+
31+
// This class should not be affected (doesn't match pattern)
32+
class NotAController
33+
{
34+
public function execute(int $id): void
35+
{
36+
}
37+
}
38+

docs/rules/Method-Signature-Must-Match-Rule.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Method Signature Must Match Rule
22

3-
Ensures that methods matching a class and method name pattern have a specific signature, including parameter types, names, and count.
3+
Ensures that methods matching a class and method name pattern have a specific signature, including parameter types, names, and count. Optionally enforces that matching classes must implement the specified method.
44

55
## Configuration Example
66

@@ -30,4 +30,31 @@ Ensures that methods matching a class and method name pattern have a specific si
3030
- `minParameters`/`maxParameters`: Minimum/maximum number of parameters.
3131
- `signature`: List of expected parameter types and (optionally) name patterns.
3232
- `visibilityScope`: Optional visibility scope (e.g., `public`, `protected`, `private`).
33+
- `required`: Optional boolean (default: `false`). When `true`, enforces that any class matching the pattern must implement the method with the specified signature.
3334

35+
## Required Methods
36+
37+
When the `required` parameter is set to `true`, the rule will check if classes matching the pattern actually implement the specified method. If a matching class is missing the method, an error will be reported with details about the expected signature.
38+
39+
### Example with Required Method
40+
41+
```neon
42+
-
43+
class: Phauthentic\PHPStanRules\Architecture\MethodSignatureMustMatchRule
44+
arguments:
45+
signaturePatterns:
46+
-
47+
pattern: '/^.*Controller::execute$/'
48+
minParameters: 1
49+
maxParameters: 1
50+
signature:
51+
-
52+
type: 'Request'
53+
pattern: '/^request$/'
54+
visibilityScope: 'public'
55+
required: true
56+
tags:
57+
- phpstan.rules.rule
58+
```
59+
60+
In this example, any class ending with "Controller" must implement a public `execute` method that takes exactly one parameter of type `Request` named `request`.

src/Architecture/MethodSignatureMustMatchRule.php

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
* - Checks if the types of the parameters match the expected types.
3434
* - Checks if the parameter names match the expected patterns.
3535
* - Checks if the method has the required visibility scope if specified (public, protected, private).
36+
* - When required is set to true, enforces that matching classes must implement the method with the specified signature.
3637
*/
3738
class MethodSignatureMustMatchRule implements Rule
3839
{
@@ -44,6 +45,7 @@ class MethodSignatureMustMatchRule implements Rule
4445
private const ERROR_MESSAGE_MIN_PARAMETERS = 'Method %s has %d parameters, but at least %d required.';
4546
private const ERROR_MESSAGE_MAX_PARAMETERS = 'Method %s has %d parameters, but at most %d allowed.';
4647
private const ERROR_MESSAGE_VISIBILITY_SCOPE = 'Method %s must be %s.';
48+
private const ERROR_MESSAGE_REQUIRED_METHOD = 'Class %s must implement method %s with signature: %s.';
4749

4850
/**
4951
* @param array<array{
@@ -54,7 +56,8 @@ class MethodSignatureMustMatchRule implements Rule
5456
* type: string,
5557
* pattern: string|null,
5658
* }>,
57-
* visibilityScope?: string|null
59+
* visibilityScope?: string|null,
60+
* required?: bool|null
5861
* }> $signaturePatterns
5962
*/
6063
public function __construct(
@@ -77,6 +80,12 @@ public function processNode(Node $node, Scope $scope): array
7780
$errors = [];
7881
$className = $node->name ? $node->name->toString() : '';
7982

83+
// Check for required methods first
84+
$requiredMethodErrors = $this->checkRequiredMethods($node, $className);
85+
foreach ($requiredMethodErrors as $error) {
86+
$errors[] = $error;
87+
}
88+
8089
foreach ($node->getMethods() as $method) {
8190
$methodName = $method->name->toString();
8291
$fullName = $className . '::' . $methodName;
@@ -332,4 +341,140 @@ private function getTypeAsString(mixed $type): ?string
332341
default => null,
333342
};
334343
}
344+
345+
/**
346+
* Extract class name pattern and method name from a regex pattern.
347+
* Expected pattern format: '/^ClassName::methodName$/' or '/ClassName::methodName$/'
348+
*
349+
* @param string $pattern
350+
* @return array|null Array with 'classPattern' and 'methodName', or null if parsing fails
351+
*/
352+
private function extractClassAndMethodFromPattern(string $pattern): ?array
353+
{
354+
// Remove pattern delimiters and anchors
355+
$cleaned = preg_replace('/^\/\^?/', '', $pattern);
356+
$cleaned = preg_replace('/\$?\/$/', '', $cleaned);
357+
358+
if ($cleaned === null || !str_contains($cleaned, '::')) {
359+
return null;
360+
}
361+
362+
$parts = explode('::', $cleaned, 2);
363+
if (count($parts) !== 2) {
364+
return null;
365+
}
366+
367+
return [
368+
'classPattern' => $parts[0],
369+
'methodName' => $parts[1],
370+
];
371+
}
372+
373+
/**
374+
* Check if a class name matches a pattern extracted from regex.
375+
*
376+
* @param string $className
377+
* @param string $classPattern
378+
* @return bool
379+
*/
380+
private function classMatchesPattern(string $className, string $classPattern): bool
381+
{
382+
// Build a regex from the class pattern
383+
$regex = '/^' . $classPattern . '$/';
384+
return preg_match($regex, $className) === 1;
385+
}
386+
387+
/**
388+
* Format the expected method signature for error messages.
389+
*
390+
* @param array $patternConfig
391+
* @return string
392+
*/
393+
private function formatSignatureForError(array $patternConfig): string
394+
{
395+
$parts = [];
396+
397+
// Add visibility scope if specified
398+
if (isset($patternConfig['visibilityScope']) && $patternConfig['visibilityScope'] !== null) {
399+
$parts[] = $patternConfig['visibilityScope'];
400+
}
401+
402+
$parts[] = 'function';
403+
404+
// Extract method name from pattern
405+
$extracted = $this->extractClassAndMethodFromPattern($patternConfig['pattern']);
406+
if ($extracted !== null) {
407+
$parts[] = $extracted['methodName'];
408+
}
409+
410+
// Build parameters
411+
$params = [];
412+
if (!empty($patternConfig['signature'])) {
413+
foreach ($patternConfig['signature'] as $i => $sig) {
414+
$paramParts = [];
415+
if (isset($sig['type']) && $sig['type'] !== null) {
416+
$paramParts[] = $sig['type'];
417+
}
418+
$paramParts[] = '$param' . ($i + 1);
419+
$params[] = implode(' ', $paramParts);
420+
}
421+
}
422+
423+
return implode(' ', $parts) . '(' . implode(', ', $params) . ')';
424+
}
425+
426+
/**
427+
* Check if required methods are implemented in the class.
428+
*
429+
* @param Class_ $node
430+
* @param string $className
431+
* @return array
432+
*/
433+
private function checkRequiredMethods(Class_ $node, string $className): array
434+
{
435+
$errors = [];
436+
437+
// Get list of implemented methods
438+
$implementedMethods = [];
439+
foreach ($node->getMethods() as $method) {
440+
$implementedMethods[] = $method->name->toString();
441+
}
442+
443+
// Check each pattern with required flag
444+
foreach ($this->signaturePatterns as $patternConfig) {
445+
// Skip if not required
446+
if (!isset($patternConfig['required']) || $patternConfig['required'] !== true) {
447+
continue;
448+
}
449+
450+
// Extract class and method patterns
451+
$extracted = $this->extractClassAndMethodFromPattern($patternConfig['pattern']);
452+
if ($extracted === null) {
453+
continue;
454+
}
455+
456+
// Check if class matches the pattern
457+
if (!$this->classMatchesPattern($className, $extracted['classPattern'])) {
458+
continue;
459+
}
460+
461+
// Check if method is implemented
462+
if (!in_array($extracted['methodName'], $implementedMethods, true)) {
463+
$signature = $this->formatSignatureForError($patternConfig);
464+
$errors[] = RuleErrorBuilder::message(
465+
sprintf(
466+
self::ERROR_MESSAGE_REQUIRED_METHOD,
467+
$className,
468+
$extracted['methodName'],
469+
$signature
470+
)
471+
)
472+
->identifier(self::IDENTIFIER)
473+
->line($node->getLine())
474+
->build();
475+
}
476+
}
477+
478+
return $errors;
479+
}
335480
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Phauthentic\PHPStanRules\Tests\TestCases\Architecture;
6+
7+
use Phauthentic\PHPStanRules\Architecture\MethodSignatureMustMatchRule;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Testing\RuleTestCase;
10+
11+
/**
12+
* @extends RuleTestCase<MethodSignatureMustMatchRule>
13+
*/
14+
class MethodSignatureMustMatchRuleRequiredTest extends RuleTestCase
15+
{
16+
protected function getRule(): Rule
17+
{
18+
return new MethodSignatureMustMatchRule([
19+
[
20+
'pattern' => '/^.*TestController::execute$/',
21+
'minParameters' => 1,
22+
'maxParameters' => 1,
23+
'signature' => [
24+
['type' => 'int', 'pattern' => '/^id$/'],
25+
],
26+
'visibilityScope' => 'public',
27+
'required' => true,
28+
],
29+
]);
30+
}
31+
32+
public function testRequiredMethodRule(): void
33+
{
34+
$this->analyse([__DIR__ . '/../../../data/MethodSignatureMustMatch/RequiredMethodTestClass.php'], [
35+
// MyTestController is missing the required execute method
36+
[
37+
'Class MyTestController must implement method execute with signature: public function execute(int $param1).',
38+
8,
39+
],
40+
// AnotherTestController implements the method correctly - no error expected
41+
42+
// YetAnotherTestController is missing the required execute method
43+
[
44+
'Class YetAnotherTestController must implement method execute with signature: public function execute(int $param1).',
45+
24,
46+
],
47+
// NotAController doesn't match the pattern - no error expected
48+
]);
49+
}
50+
}

0 commit comments

Comments
 (0)