Skip to content

Commit e6eb523

Browse files
author
Florian Krämer
committed
Refactor rules to utilize ClassNameResolver trait for full class name resolution
- Added ClassNameResolver trait to multiple rules (AttributeRule, CatchExceptionOfTypeNotAllowedRule, ClassMustBeFinalRule, ClassMustBeReadonlyRule, ClassMustHaveSpecificationDocblockRule, ForbiddenAccessorsRule, MethodMustReturnTypeRule, MethodSignatureMustMatchRule, PropertyMustMatchRule). - Updated processNode methods to resolve full class names using the new trait, improving consistency and reducing code duplication. - Removed redundant code related to manual full class name construction and unnecessary private methods. This refactor enhances maintainability and aligns the rules with a unified approach for class name resolution.
1 parent e1f0a15 commit e6eb523

12 files changed

Lines changed: 180 additions & 159 deletions

src/Architecture/AttributeRule.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
*/
5555
class AttributeRule implements Rule
5656
{
57+
use ClassNameResolver;
58+
5759
private const ERROR_FORBIDDEN = 'Attribute %s is forbidden on %s %s.';
5860

5961
private const ERROR_NOT_ALLOWED = 'Attribute %s is not in the allowed list for %s %s. Allowed patterns: %s';
@@ -104,14 +106,11 @@ public function getNodeType(): string
104106
*/
105107
public function processNode(Node $node, Scope $scope): array
106108
{
107-
if (!isset($node->name)) {
109+
$fullClassName = $this->resolveFullClassName($node, $scope);
110+
if ($fullClassName === null) {
108111
return [];
109112
}
110113

111-
$className = $node->name->toString();
112-
$namespaceName = $scope->getNamespace() ?? '';
113-
$fullClassName = $namespaceName !== '' ? $namespaceName . '\\' . $className : $className;
114-
115114
/** @var list<IdentifierRuleError> $errors */
116115
$errors = [];
117116

src/Architecture/CatchExceptionOfTypeNotAllowedRule.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,27 @@
3232
*/
3333
class CatchExceptionOfTypeNotAllowedRule implements Rule
3434
{
35+
use ClassNameResolver;
36+
3537
private const ERROR_MESSAGE = 'Catching exception of type %s is not allowed.';
3638

3739
private const IDENTIFIER = 'phauthentic.architecture.catchExceptionOfTypeNotAllowed';
3840

3941
/**
40-
* @var array<string> An array of exception class names that are not allowed to be caught.
41-
* e.g., ['Exception', 'Error', 'Throwable']
42+
* @var array<string> Normalized forbidden exception types (without leading backslash)
4243
*/
43-
private array $forbiddenExceptionTypes;
44+
private array $normalizedForbiddenTypes;
4445

4546
/**
4647
* @param array<string> $forbiddenExceptionTypes An array of exception class names that are not allowed to be caught.
4748
*/
4849
public function __construct(array $forbiddenExceptionTypes)
4950
{
50-
$this->forbiddenExceptionTypes = $forbiddenExceptionTypes;
51+
// Normalize all forbidden types by removing leading backslash
52+
$this->normalizedForbiddenTypes = array_map(
53+
fn(string $type): string => $this->normalizeClassName($type),
54+
$forbiddenExceptionTypes
55+
);
5156
}
5257

5358
public function getNodeType(): string
@@ -64,9 +69,11 @@ public function processNode(Node $node, Scope $scope): array
6469

6570
foreach ($node->types as $type) {
6671
$exceptionType = $type->toString();
72+
// Normalize the caught exception type for comparison
73+
$normalizedType = $this->normalizeClassName($exceptionType);
6774

6875
// Check if the caught exception type is in the forbidden list
69-
if (in_array($exceptionType, $this->forbiddenExceptionTypes, true)) {
76+
if (in_array($normalizedType, $this->normalizedForbiddenTypes, true)) {
7077
$errors[] = RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $exceptionType))
7178
->line($node->getLine())
7279
->identifier(self::IDENTIFIER)

src/Architecture/ClassMustBeFinalRule.php

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
*/
3535
class ClassMustBeFinalRule implements Rule
3636
{
37+
use ClassNameResolver;
38+
3739
private const ERROR_MESSAGE = 'Class %s must be final.';
3840

3941
private const IDENTIFIER = 'phauthentic.architecture.classMustBeFinal';
@@ -59,7 +61,8 @@ public function getNodeType(): string
5961
*/
6062
public function processNode(Node $node, Scope $scope): array
6163
{
62-
if (!isset($node->name)) {
64+
$fullClassName = $this->resolveFullClassName($node, $scope);
65+
if ($fullClassName === null) {
6366
return [];
6467
}
6568

@@ -68,14 +71,8 @@ public function processNode(Node $node, Scope $scope): array
6871
return [];
6972
}
7073

71-
$className = $node->name->toString();
72-
$namespaceName = $scope->getNamespace() ?? '';
73-
$fullClassName = $namespaceName . '\\' . $className;
74-
75-
foreach ($this->patterns as $pattern) {
76-
if (preg_match($pattern, $fullClassName) && !$node->isFinal()) {
77-
return [$this->buildRuleError($fullClassName)];
78-
}
74+
if ($this->matchesAnyPattern($fullClassName, $this->patterns) && !$node->isFinal()) {
75+
return [$this->buildRuleError($fullClassName)];
7976
}
8077

8178
return [];

src/Architecture/ClassMustBeReadonlyRule.php

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
*/
3333
class ClassMustBeReadonlyRule implements Rule
3434
{
35+
use ClassNameResolver;
36+
3537
private const ERROR_MESSAGE = 'Class %s must be readonly.';
3638

3739
private const IDENTIFIER = 'phauthentic.architecture.classMustBeReadonly';
@@ -59,22 +61,17 @@ public function getNodeType(): string
5961
*/
6062
public function processNode(Node $node, Scope $scope): array
6163
{
62-
if (!isset($node->name)) {
64+
$fullClassName = $this->resolveFullClassName($node, $scope);
65+
if ($fullClassName === null) {
6366
return [];
6467
}
6568

66-
$className = $node->name->toString();
67-
$namespaceName = $scope->getNamespace() ?? '';
68-
$fullClassName = $namespaceName . '\\' . $className;
69-
70-
foreach ($this->patterns as $pattern) {
71-
if (preg_match($pattern, $fullClassName) && !$node->isReadonly()) {
72-
return [
73-
RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $fullClassName))
74-
->identifier(self::IDENTIFIER)
75-
->build(),
76-
];
77-
}
69+
if ($this->matchesAnyPattern($fullClassName, $this->patterns) && !$node->isReadonly()) {
70+
return [
71+
RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $fullClassName))
72+
->identifier(self::IDENTIFIER)
73+
->build(),
74+
];
7875
}
7976

8077
return [];

src/Architecture/ClassMustHaveSpecificationDocblockRule.php

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
*/
4040
class ClassMustHaveSpecificationDocblockRule implements Rule
4141
{
42+
use ClassNameResolver;
43+
4244
private const ERROR_MESSAGE_MISSING = '%s %s must have a docblock with a "%s" section.';
4345
private const ERROR_MESSAGE_INVALID = '%s %s has an invalid specification docblock. %s';
4446
private const IDENTIFIER = 'phauthentic.architecture.classMustHaveSpecificationDocblock';
@@ -96,20 +98,18 @@ public function processNode(Node $node, Scope $scope): array
9698
return [];
9799
}
98100

99-
if (!isset($node->name)) {
101+
$fullClassName = $this->resolveFullClassName($node, $scope);
102+
if ($fullClassName === null) {
100103
return [];
101104
}
102105

103106
$errors = [];
104-
$className = $node->name->toString();
105-
$namespaceName = $scope->getNamespace() ?? '';
106-
$fullClassName = $namespaceName . '\\' . $className;
107107

108108
// Determine the type for error messages
109109
$type = $node instanceof Interface_ ? 'Interface' : 'Class';
110110

111111
// Check class/interface docblock
112-
if ($this->matchesPatterns($fullClassName, $this->classPatterns)) {
112+
if ($this->matchesAnyPattern($fullClassName, $this->classPatterns)) {
113113
$docComment = $node->getDocComment();
114114
if ($docComment === null) {
115115
$errors[] = $this->buildMissingDocblockError($type, $fullClassName, $node);
@@ -123,7 +123,7 @@ public function processNode(Node $node, Scope $scope): array
123123
$methodName = $method->name->toString();
124124
$fullMethodName = $fullClassName . '::' . $methodName;
125125

126-
if ($this->matchesPatterns($fullMethodName, $this->methodPatterns)) {
126+
if ($this->matchesAnyPattern($fullMethodName, $this->methodPatterns)) {
127127
$docComment = $method->getDocComment();
128128
if ($docComment === null) {
129129
$errors[] = $this->buildMissingDocblockError('Method', $fullMethodName, $method);
@@ -136,20 +136,6 @@ public function processNode(Node $node, Scope $scope): array
136136
return $errors;
137137
}
138138

139-
/**
140-
* @param array<string> $patterns
141-
*/
142-
private function matchesPatterns(string $target, array $patterns): bool
143-
{
144-
foreach ($patterns as $pattern) {
145-
if (preg_match($pattern, $target)) {
146-
return true;
147-
}
148-
}
149-
150-
return false;
151-
}
152-
153139
private function isValidSpecificationDocblock(Doc $docComment): bool
154140
{
155141
$text = $docComment->getText();
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
/**
4+
* Copyright (c) Florian Krämer (https://florian-kraemer.net)
5+
* Licensed under The MIT License
6+
* For full copyright and license information, please see the LICENSE file
7+
* Redistributions of files must retain the above copyright notice.
8+
*
9+
* @copyright Copyright (c) Florian Krämer (https://florian-kraemer.net)
10+
* @author Florian Krämer
11+
* @link https://github.com/Phauthentic
12+
* @license https://opensource.org/licenses/MIT MIT License
13+
*/
14+
15+
declare(strict_types=1);
16+
17+
namespace Phauthentic\PHPStanRules\Architecture;
18+
19+
use PhpParser\Node\ComplexType;
20+
use PhpParser\Node\Identifier;
21+
use PhpParser\Node\IntersectionType;
22+
use PhpParser\Node\Name;
23+
use PhpParser\Node\NullableType;
24+
use PhpParser\Node\Stmt\Class_;
25+
use PhpParser\Node\Stmt\Interface_;
26+
use PhpParser\Node\UnionType;
27+
use PHPStan\Analyser\Scope;
28+
29+
/**
30+
* Trait providing common FQCN resolution and type conversion utilities for PHPStan rules.
31+
*/
32+
trait ClassNameResolver
33+
{
34+
/**
35+
* Build FQCN from a Class_ or Interface_ node and scope.
36+
*
37+
* @param Class_|Interface_ $node
38+
* @param Scope $scope
39+
* @return string|null Returns null if the node has no name (anonymous class)
40+
*/
41+
protected function resolveFullClassName(Class_|Interface_ $node, Scope $scope): ?string
42+
{
43+
if (!isset($node->name)) {
44+
return null;
45+
}
46+
47+
$className = $node->name->toString();
48+
$namespaceName = $scope->getNamespace() ?? '';
49+
50+
return $namespaceName !== '' ? $namespaceName . '\\' . $className : $className;
51+
}
52+
53+
/**
54+
* Convert a type node to its string representation.
55+
*
56+
* Handles Identifier, Name, NullableType, UnionType, and IntersectionType.
57+
*
58+
* @param ComplexType|Identifier|Name|null $type
59+
* @return string|null
60+
*/
61+
protected function getTypeAsString(ComplexType|Identifier|Name|null $type): ?string
62+
{
63+
return match (true) {
64+
$type === null => null,
65+
$type instanceof Identifier => $type->name,
66+
$type instanceof Name => $type->toString(),
67+
$type instanceof NullableType =>
68+
($inner = $this->getTypeAsString($type->type)) !== null
69+
? '?' . $inner
70+
: null,
71+
$type instanceof UnionType => implode('|', array_filter(
72+
array_map(fn($t) => $this->getTypeAsString($t), $type->types)
73+
)),
74+
$type instanceof IntersectionType => implode('&', array_filter(
75+
array_map(fn($t) => $this->getTypeAsString($t), $type->types)
76+
)),
77+
default => null,
78+
};
79+
}
80+
81+
/**
82+
* Check if a subject string matches any of the given regex patterns.
83+
*
84+
* @param string $subject The string to test
85+
* @param array<string> $patterns Array of regex patterns
86+
* @return bool True if any pattern matches
87+
*/
88+
protected function matchesAnyPattern(string $subject, array $patterns): bool
89+
{
90+
foreach ($patterns as $pattern) {
91+
if (preg_match($pattern, $subject) === 1) {
92+
return true;
93+
}
94+
}
95+
96+
return false;
97+
}
98+
99+
/**
100+
* Normalize a class name by removing leading backslash.
101+
*
102+
* @param string $className
103+
* @return string
104+
*/
105+
protected function normalizeClassName(string $className): string
106+
{
107+
return ltrim($className, '\\');
108+
}
109+
}

src/Architecture/ForbiddenAccessorsRule.php

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
*/
3434
class ForbiddenAccessorsRule implements Rule
3535
{
36+
use ClassNameResolver;
37+
3638
private const ERROR_MESSAGE_GETTER = 'Class %s must not have a %s getter method %s().';
3739
private const ERROR_MESSAGE_SETTER = 'Class %s must not have a %s setter method %s().';
3840
private const IDENTIFIER = 'phauthentic.architecture.forbiddenAccessors';
@@ -82,15 +84,12 @@ public function getNodeType(): string
8284
*/
8385
public function processNode(Node $node, Scope $scope): array
8486
{
85-
if (!isset($node->name)) {
87+
$fullClassName = $this->resolveFullClassName($node, $scope);
88+
if ($fullClassName === null) {
8689
return [];
8790
}
8891

89-
$className = $node->name->toString();
90-
$namespaceName = $scope->getNamespace() ?? '';
91-
$fullClassName = $namespaceName !== '' ? $namespaceName . '\\' . $className : $className;
92-
93-
if (!$this->matchesClassPatterns($fullClassName)) {
92+
if (!$this->matchesAnyPattern($fullClassName, $this->classPatterns)) {
9493
return [];
9594
}
9695

@@ -116,26 +115,6 @@ public function processNode(Node $node, Scope $scope): array
116115
return $errors;
117116
}
118117

119-
/**
120-
* Check if the class FQCN matches any of the configured patterns.
121-
*/
122-
private function matchesClassPatterns(string $fullClassName): bool
123-
{
124-
foreach ($this->classPatterns as $pattern) {
125-
$result = @preg_match($pattern, $fullClassName);
126-
if ($result === false) {
127-
throw new \InvalidArgumentException(
128-
sprintf('Invalid regex pattern "%s": %s', $pattern, preg_last_error_msg())
129-
);
130-
}
131-
if ($result === 1) {
132-
return true;
133-
}
134-
}
135-
136-
return false;
137-
}
138-
139118
/**
140119
* Get the visibility of a method as a string.
141120
*/

0 commit comments

Comments
 (0)