Skip to content

Commit 47a8e3c

Browse files
committed
Allow excluding disallowed classes based on attribute
1 parent fd7975e commit 47a8e3c

File tree

8 files changed

+150
-3
lines changed

8 files changed

+150
-3
lines changed

docs/custom-rules.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,23 @@ parameters:
130130
This config would disallow all `pcntl` functions except (an imaginary) `pcntl_foobar()`.
131131
Please note `exclude` also accepts [`fnmatch`](https://www.php.net/function.fnmatch) patterns so please be careful to not create a contradicting config, and that it can accept both a string and an array of strings.
132132

133+
### Wildcards, except when having an attribute
134+
135+
If there's this one class (or multiple of them) that you'd like to exclude from the set, you can do that with `excludeWithAttribute`:
136+
137+
```neon
138+
parameters:
139+
disallowedClasses:
140+
-
141+
class: 'App\PrivateModule\*'
142+
excludeWithAttribute:
143+
- '\App\Support\IsPublic'
144+
```
145+
146+
This config would disallow all `App\PrivateModule\*` classes except those classes marked with a `#[\App\Support\IsPublic] attribute`.
147+
148+
Please note `excludeWithAttribute` also accepts [`fnmatch`](https://www.php.net/function.fnmatch) patterns, and that it can accept both a string and an array of strings.
149+
133150
### Wildcards, except when defined in this path
134151

135152
Another option how to limit the set of functions or methods selected by the `function` or `method` directive is a file path in which these are defined which mostly makes sense when a [`fnmatch`](https://www.php.net/function.fnmatch) pattern is used in those directives.

extension.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ parametersSchema:
2020
?namespace: anyOf(string(), listOf(string())),
2121
?class: anyOf(string(), listOf(string())),
2222
?exclude: anyOf(string(), listOf(string())),
23+
?excludeWithAttribute: anyOf(string(), listOf(string())),
2324
?message: string(),
2425
?allowIn: listOf(string()),
2526
?allowExceptIn: listOf(string()),
@@ -49,6 +50,7 @@ parametersSchema:
4950
?namespace: anyOf(string(), listOf(string())),
5051
?class: anyOf(string(), listOf(string())),
5152
?exclude: anyOf(string(), listOf(string())),
53+
?excludeWithAttribute: anyOf(string(), listOf(string())),
5254
?message: string(),
5355
?allowIn: listOf(string()),
5456
?allowExceptIn: listOf(string()),

src/DisallowedNamespace.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ class DisallowedNamespace implements Disallowed
1313
/** @var list<string> */
1414
private array $excludes;
1515

16+
/** @var list<string> */
17+
private array $excludeWithAttributes;
18+
1619
private ?string $message;
1720

1821
private ?string $errorIdentifier;
@@ -27,6 +30,7 @@ class DisallowedNamespace implements Disallowed
2730
/**
2831
* @param string $namespace
2932
* @param list<string> $excludes
33+
* @param list<string> $excludeWithAttributes
3034
* @param string|null $message
3135
* @param AllowedConfig $allowedConfig
3236
* @param string|null $errorIdentifier
@@ -35,6 +39,7 @@ class DisallowedNamespace implements Disallowed
3539
public function __construct(
3640
string $namespace,
3741
array $excludes,
42+
array $excludeWithAttributes,
3843
?string $message,
3944
AllowedConfig $allowedConfig,
4045
bool $allowInUse,
@@ -43,6 +48,7 @@ public function __construct(
4348
) {
4449
$this->namespace = $namespace;
4550
$this->excludes = $excludes;
51+
$this->excludeWithAttributes = $excludeWithAttributes;
4652
$this->message = $message;
4753
$this->allowedConfig = $allowedConfig;
4854
$this->allowInUse = $allowInUse;
@@ -66,6 +72,15 @@ public function getExcludes(): array
6672
}
6773

6874

75+
/**
76+
* @return list<string>
77+
*/
78+
public function getExcludeWithAttributes(): array
79+
{
80+
return $this->excludeWithAttributes;
81+
}
82+
83+
6984
public function getMessage(): ?string
7085
{
7186
return $this->message;

src/DisallowedNamespaceFactory.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public function __construct(Formatter $formatter, Normalizer $normalizer, Allowe
2828

2929

3030
/**
31-
* @param array<array{namespace?:string|list<string>, class?:string|list<string>, exclude?:string|list<string>, message?:string, allowIn?:list<string>, allowExceptIn?:list<string>, disallowIn?:list<string>, allowInUse?:bool, errorIdentifier?:string, errorTip?:string}> $config
31+
* @param array<array{namespace?:string|list<string>, class?:string|list<string>, exclude?:string|list<string>, excludeWithAttribute?:string|list<string>, message?:string, allowIn?:list<string>, allowExceptIn?:list<string>, disallowIn?:list<string>, allowInUse?:bool, errorIdentifier?:string, errorTip?:string}> $config
3232
* @return list<DisallowedNamespace>
3333
* @throws ShouldNotHappenException
3434
*/
@@ -45,12 +45,17 @@ public function createFromConfig(array $config): array
4545
foreach ((array)($disallowed['exclude'] ?? []) as $exclude) {
4646
$excludes[] = $this->normalizer->normalizeNamespace($exclude);
4747
}
48+
$excludeWithAttributes = [];
49+
foreach ((array)($disallowed['excludeWithAttribute'] ?? []) as $excludeWithAttribute) {
50+
$excludeWithAttributes[] = $this->normalizer->normalizeNamespace($excludeWithAttribute);
51+
}
4852
$namespaces = (array)$namespaces;
4953
try {
5054
foreach ($namespaces as $namespace) {
5155
$disallowedNamespace = new DisallowedNamespace(
5256
$this->normalizer->normalizeNamespace($namespace),
5357
$excludes,
58+
$excludeWithAttributes,
5459
$disallowed['message'] ?? null,
5560
$this->allowedConfigFactory->getConfig($disallowed),
5661
$disallowed['allowInUse'] ?? false,

src/Identifier/Identifier.php

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,28 @@
33

44
namespace Spaze\PHPStan\Rules\Disallowed\Identifier;
55

6+
use PHPStan\BetterReflection\Reflector\Exception\IdentifierNotFound;
7+
use PHPStan\BetterReflection\Reflector\Reflector;
8+
69
class Identifier
710
{
11+
private Reflector $reflector;
12+
13+
14+
public function __construct(Reflector $reflector)
15+
{
16+
$this->reflector = $reflector;
17+
}
18+
819

920
/**
1021
* @param string $pattern
1122
* @param string $value
1223
* @param list<string> $excludes
24+
* @param list<string> $excludeWithAttributes
1325
* @return bool
1426
*/
15-
public function matches(string $pattern, string $value, array $excludes = []): bool
27+
public function matches(string $pattern, string $value, array $excludes = [], array $excludeWithAttributes = []): bool
1628
{
1729
$matches = false;
1830
if ($pattern === $value) {
@@ -27,6 +39,21 @@ public function matches(string $pattern, string $value, array $excludes = []): b
2739
}
2840
}
2941
}
42+
if ($matches && $excludeWithAttributes) {
43+
try {
44+
$attributes = array_map(fn($a) => $a->getName(), $this->reflector->reflectClass($value)->getAttributes());
45+
} catch (IdentifierNotFound $e) {
46+
$attributes = [];
47+
}
48+
49+
foreach ($attributes as $attribute) {
50+
foreach ($excludeWithAttributes as $excludeWithAttribute) {
51+
if (fnmatch($excludeWithAttribute, $attribute, FNM_NOESCAPE | FNM_CASEFOLD)) {
52+
return false;
53+
}
54+
}
55+
}
56+
}
3057
return $matches;
3158
}
3259

src/RuleErrors/DisallowedNamespaceRuleErrors.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public function getDisallowedMessage(Node $node, NamespaceUsage $namespaceUsage,
4444
{
4545
foreach ($disallowedNamespaces as $disallowedNamespace) {
4646
if (
47-
!$this->identifier->matches($disallowedNamespace->getNamespace(), $namespaceUsage->getNamespace(), $disallowedNamespace->getExcludes())
47+
!$this->identifier->matches($disallowedNamespace->getNamespace(), $namespaceUsage->getNamespace(), $disallowedNamespace->getExcludes(), $disallowedNamespace->getExcludeWithAttributes())
4848
|| $this->allowed->isAllowed($node, $scope, null, $disallowedNamespace)
4949
|| ($disallowedNamespace->isAllowInUse() && $namespaceUsage->isUseItem())
5050
) {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
declare(strict_types = 1);
3+
4+
namespace Spaze\PHPStan\Rules\Disallowed\Usages;
5+
6+
use PHPStan\Rules\Rule;
7+
use PHPStan\Testing\RuleTestCase;
8+
use Spaze\PHPStan\Rules\Disallowed\DisallowedNamespaceFactory;
9+
use Spaze\PHPStan\Rules\Disallowed\RuleErrors\DisallowedNamespaceRuleErrors;
10+
11+
class NamespaceUsagesExcludeAttributeTest extends RuleTestCase
12+
{
13+
14+
protected function getRule(): Rule
15+
{
16+
$container = self::getContainer();
17+
return new NamespaceUsages(
18+
$container->getByType(DisallowedNamespaceRuleErrors::class),
19+
$container->getByType(DisallowedNamespaceFactory::class),
20+
$container->getByType(NamespaceUsageFactory::class),
21+
[
22+
[
23+
'namespace' => 'NoBigDeal\*',
24+
'message' => 'no private modules',
25+
'excludeWithAttribute' => [
26+
'\Attributes\*Class',
27+
],
28+
],
29+
]
30+
);
31+
}
32+
33+
34+
public function testRule(): void
35+
{
36+
$this->analyse([__DIR__ . '/../src/disallowed/namespaceUsagesExcludeAttribute.php'], [
37+
[
38+
'Class NoBigDeal\PrivateClass is forbidden, no private modules. [NoBigDeal\PrivateClass matches NoBigDeal\*]',
39+
17,
40+
],
41+
]);
42+
}
43+
44+
45+
public static function getAdditionalConfigFiles(): array
46+
{
47+
return [
48+
__DIR__ . '/../../extension.neon',
49+
];
50+
}
51+
52+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
declare(strict_types = 1);
3+
4+
namespace NoBigDeal;
5+
6+
use Attributes\AttributeClass;
7+
8+
class Service
9+
{
10+
public function usePublicClass()
11+
{
12+
return new PublicClass();
13+
}
14+
15+
public function usePrivateClass()
16+
{
17+
return new PrivateClass();
18+
}
19+
20+
}
21+
22+
interface PrivateClass
23+
{
24+
}
25+
26+
#[AttributeClass]
27+
class PublicClass
28+
{
29+
}

0 commit comments

Comments
 (0)