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
38 changes: 38 additions & 0 deletions data/MethodSignatureMustMatch/RequiredMethodTestClass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Phauthentic\PHPStanRules\Tests\Data\MethodSignatureMustMatch;

// This class is missing the required execute method
class MyTestController
{
public function index(): void
{
}
}

// This class implements the required method correctly
class AnotherTestController
{
public function execute(int $id): void
{
}
}

// This class is missing the required method
class YetAnotherTestController
{
public function something(): void
{
}
}

// This class should not be affected (doesn't match pattern)
class NotAController
{
public function execute(int $id): void
{
}
}

29 changes: 28 additions & 1 deletion docs/rules/Method-Signature-Must-Match-Rule.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Method Signature Must Match Rule

Ensures that methods matching a class and method name pattern have a specific signature, including parameter types, names, and count.
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.

## Configuration Example

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

## Required Methods

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.

### Example with Required Method

```neon
-
class: Phauthentic\PHPStanRules\Architecture\MethodSignatureMustMatchRule
arguments:
signaturePatterns:
-
pattern: '/^.*Controller::execute$/'
minParameters: 1
maxParameters: 1
signature:
-
type: 'Request'
pattern: '/^request$/'
visibilityScope: 'public'
required: true
tags:
- phpstan.rules.rule
```

In this example, any class ending with "Controller" must implement a public `execute` method that takes exactly one parameter of type `Request` named `request`.
147 changes: 146 additions & 1 deletion src/Architecture/MethodSignatureMustMatchRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
* - Checks if the types of the parameters match the expected types.
* - Checks if the parameter names match the expected patterns.
* - Checks if the method has the required visibility scope if specified (public, protected, private).
* - When required is set to true, enforces that matching classes must implement the method with the specified signature.
*/
class MethodSignatureMustMatchRule implements Rule
{
Expand All @@ -32,6 +33,7 @@ class MethodSignatureMustMatchRule implements Rule
private const ERROR_MESSAGE_MIN_PARAMETERS = 'Method %s has %d parameters, but at least %d required.';
private const ERROR_MESSAGE_MAX_PARAMETERS = 'Method %s has %d parameters, but at most %d allowed.';
private const ERROR_MESSAGE_VISIBILITY_SCOPE = 'Method %s must be %s.';
private const ERROR_MESSAGE_REQUIRED_METHOD = 'Class %s must implement method %s with signature: %s.';

/**
* @param array<array{
Expand All @@ -42,7 +44,8 @@ class MethodSignatureMustMatchRule implements Rule
* type: string,
* pattern: string|null,
* }>,
* visibilityScope?: string|null
* visibilityScope?: string|null,
* required?: bool|null
* }> $signaturePatterns
*/
public function __construct(
Expand All @@ -65,6 +68,12 @@ public function processNode(Node $node, Scope $scope): array
$errors = [];
$className = $node->name ? $node->name->toString() : '';

// Check for required methods first
$requiredMethodErrors = $this->checkRequiredMethods($node, $className);
foreach ($requiredMethodErrors as $error) {
$errors[] = $error;
}

foreach ($node->getMethods() as $method) {
$methodName = $method->name->toString();
$fullName = $className . '::' . $methodName;
Expand Down Expand Up @@ -320,4 +329,140 @@ private function getTypeAsString(mixed $type): ?string
default => null,
};
}

/**
* Extract class name pattern and method name from a regex pattern.
* Expected pattern format: '/^ClassName::methodName$/' or '/ClassName::methodName$/'
*
* @param string $pattern
* @return array|null Array with 'classPattern' and 'methodName', or null if parsing fails
*/
private function extractClassAndMethodFromPattern(string $pattern): ?array
{
// Remove pattern delimiters and anchors
$cleaned = preg_replace('/^\/\^?/', '', $pattern);
$cleaned = preg_replace('/\$?\/$/', '', $cleaned);

if ($cleaned === null || !str_contains($cleaned, '::')) {
return null;
}

$parts = explode('::', $cleaned, 2);
if (count($parts) !== 2) {
return null;
}

return [
'classPattern' => $parts[0],
'methodName' => $parts[1],
];
}

/**
* Check if a class name matches a pattern extracted from regex.
*
* @param string $className
* @param string $classPattern
* @return bool
*/
private function classMatchesPattern(string $className, string $classPattern): bool
{
// Build a regex from the class pattern
$regex = '/^' . $classPattern . '$/';
return preg_match($regex, $className) === 1;
}

/**
* Format the expected method signature for error messages.
*
* @param array $patternConfig
* @return string
*/
private function formatSignatureForError(array $patternConfig): string
{
$parts = [];

// Add visibility scope if specified
if (isset($patternConfig['visibilityScope']) && $patternConfig['visibilityScope'] !== null) {
$parts[] = $patternConfig['visibilityScope'];
}

$parts[] = 'function';

// Extract method name from pattern
$extracted = $this->extractClassAndMethodFromPattern($patternConfig['pattern']);
if ($extracted !== null) {
$parts[] = $extracted['methodName'];
}

// Build parameters
$params = [];
if (!empty($patternConfig['signature'])) {
foreach ($patternConfig['signature'] as $i => $sig) {
$paramParts = [];
if (isset($sig['type']) && $sig['type'] !== null) {
$paramParts[] = $sig['type'];
}
$paramParts[] = '$param' . ($i + 1);
$params[] = implode(' ', $paramParts);
}
}

return implode(' ', $parts) . '(' . implode(', ', $params) . ')';
}

/**
* Check if required methods are implemented in the class.
*
* @param Class_ $node
* @param string $className
* @return array
*/
private function checkRequiredMethods(Class_ $node, string $className): array
{
$errors = [];

// Get list of implemented methods
$implementedMethods = [];
foreach ($node->getMethods() as $method) {
$implementedMethods[] = $method->name->toString();
}

// Check each pattern with required flag
foreach ($this->signaturePatterns as $patternConfig) {
// Skip if not required
if (!isset($patternConfig['required']) || $patternConfig['required'] !== true) {
continue;
}

// Extract class and method patterns
$extracted = $this->extractClassAndMethodFromPattern($patternConfig['pattern']);
if ($extracted === null) {
continue;
}

// Check if class matches the pattern
if (!$this->classMatchesPattern($className, $extracted['classPattern'])) {
continue;
}

// Check if method is implemented
if (!in_array($extracted['methodName'], $implementedMethods, true)) {
$signature = $this->formatSignatureForError($patternConfig);
$errors[] = RuleErrorBuilder::message(
sprintf(
self::ERROR_MESSAGE_REQUIRED_METHOD,
$className,
$extracted['methodName'],
$signature
)
)
->identifier(self::IDENTIFIER)
->line($node->getLine())
->build();
}
}

return $errors;
}
}
14 changes: 7 additions & 7 deletions src/CleanCode/MaxLineLengthRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public function __construct(
) {
$this->maxLineLength = $maxLineLength;
$this->excludePatterns = $excludePatterns;

// BC: ignoreUseStatements parameter takes precedence over array when both are set
$this->ignoreUseStatements = $ignoreUseStatements ?: ($ignoreLineTypes['useStatements'] ?? false);
$this->ignoreNamespaceDeclaration = $ignoreLineTypes['namespaceDeclaration'] ?? false;
Expand All @@ -110,7 +110,7 @@ public function processNode(Node $node, Scope $scope): array
if ($node instanceof FileNode) {
return [];
}

// Skip if file should be excluded
if ($this->shouldExcludeFile($scope)) {
return [];
Expand All @@ -122,7 +122,7 @@ public function processNode(Node $node, Scope $scope): array
// Track use statement lines for this file
if ($node instanceof Use_) {
$this->markLineAsUseStatement($filePath, $lineNumber);

// If ignoring use statements, skip processing this node
if ($this->ignoreUseStatements) {
return [];
Expand All @@ -134,7 +134,7 @@ public function processNode(Node $node, Scope $scope): array
// Only mark the start line where the namespace declaration appears
$namespaceLine = $node->getStartLine();
$this->markLineAsNamespace($filePath, $namespaceLine);

// If ignoring namespaces and this is the namespace declaration line, skip it
if ($this->ignoreNamespaceDeclaration && $lineNumber === $namespaceLine) {
return [];
Expand All @@ -147,20 +147,20 @@ public function processNode(Node $node, Scope $scope): array
if ($docComment !== null) {
$startLine = $docComment->getStartLine();
$endLine = $docComment->getEndLine();

// Mark all docblock lines
for ($line = $startLine; $line <= $endLine; $line++) {
$this->markLineAsDocBlock($filePath, $line);
}

// If not ignoring docblocks, check each line in the docblock
if (!$this->ignoreDocBlocks) {
for ($line = $startLine; $line <= $endLine; $line++) {
// Skip if we've already processed this line
if ($this->isLineProcessed($filePath, $line)) {
continue;
}

$lineLength = $this->getLineLength($filePath, $line);
if ($lineLength > $this->maxLineLength) {
$this->markLineAsProcessed($filePath, $line);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Phauthentic\PHPStanRules\Tests\TestCases\Architecture;

use Phauthentic\PHPStanRules\Architecture\MethodSignatureMustMatchRule;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<MethodSignatureMustMatchRule>
*/
class MethodSignatureMustMatchRuleRequiredTest extends RuleTestCase
{
protected function getRule(): Rule
{
return new MethodSignatureMustMatchRule([
[
'pattern' => '/^.*TestController::execute$/',
'minParameters' => 1,
'maxParameters' => 1,
'signature' => [
['type' => 'int', 'pattern' => '/^id$/'],
],
'visibilityScope' => 'public',
'required' => true,
],
]);
}

public function testRequiredMethodRule(): void
{
$this->analyse([__DIR__ . '/../../../data/MethodSignatureMustMatch/RequiredMethodTestClass.php'], [
// MyTestController is missing the required execute method
[
'Class MyTestController must implement method execute with signature: public function execute(int $param1).',
8,
],
// AnotherTestController implements the method correctly - no error expected

// YetAnotherTestController is missing the required execute method
[
'Class YetAnotherTestController must implement method execute with signature: public function execute(int $param1).',
24,
],
// NotAController doesn't match the pattern - no error expected
]);
}
}
3 changes: 1 addition & 2 deletions tests/TestCases/CleanCode/MaxLineLengthRuleArrayApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

/**
* Test the new array API for ignore options
*
*
* @extends RuleTestCase<MaxLineLengthRule>
*/
class MaxLineLengthRuleArrayApiTest extends RuleTestCase
Expand Down Expand Up @@ -41,4 +41,3 @@ public function testArrayApiForUseStatements(): void
]);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

/**
* Test backward compatibility: ignoreUseStatements parameter still works
*
*
* @extends RuleTestCase<MaxLineLengthRule>
*/
class MaxLineLengthRuleBackwardCompatibilityTest extends RuleTestCase
Expand Down Expand Up @@ -41,4 +41,3 @@ public function testBackwardCompatibilityWithOldIgnoreUseStatementsParameter():
]);
}
}

Loading