Skip to content

Commit

Permalink
Add support for disallowing namespaces
Browse files Browse the repository at this point in the history
  • Loading branch information
ruudk committed Feb 18, 2021
1 parent d78e950 commit 058dd4e
Show file tree
Hide file tree
Showing 11 changed files with 503 additions and 3 deletions.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,13 @@ includes:

### Custom rules

There are four different disallowed types (and configuration keys) that can be disallowed:
There are five different disallowed types (and configuration keys) that can be disallowed:

1. `disallowedMethodCalls` - for detecting `$object->method()` calls
2. `disallowedStaticCalls` - for static calls `Class::method()`
3. `disallowedFunctionCalls` - for functions like `function()`
4. `disallowedConstants` - for constants like `DATE_ISO8601` or `DateTime::ISO8601` (which needs to be split to `class: DateTime` & `constant: ISO8601` in the configuration, see below)
5. `disallowedNamespaces` - for usages of classes from a namespace

Use them to add rules to your `phpstan.neon` config file. I like to use a separate file (`disallowed-calls.neon`) for these which I'll include later on in the main `phpstan.neon` config file. Here's an example, update to your needs:

Expand Down Expand Up @@ -107,6 +108,16 @@ parameters:
class: 'DateTimeInterface'
constant: 'ISO8601'
message: 'use DateTimeInterface::ATOM instead'
disallowedNamespaces:
-
namespace: 'Symfony\Component\HttpFoundation\RequestStack'
message: 'pass Request via controller instead'
allowIn:
- tests/*
-
namespace: 'Assert\*'
message: 'use Webmozart\Assert instead'
```

The `message` key is optional. Functions and methods can be specified with or without `()`. Omitting `()` is not recommended though to avoid confusing method calls with class constants.
Expand Down
15 changes: 15 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
parameters:
disallowedNamespaces: []
disallowedMethodCalls: []
disallowedStaticCalls: []
disallowedFunctionCalls: []
Expand All @@ -7,6 +8,15 @@ parameters:
parametersSchema:
# These should be defined using `structure` with listed keys but it seems to me that PHPStan requires
# all keys to be present in a structure but `message`, `allowIn` & `allowParamsInAllowed` are optional.
disallowedNamespaces: listOf(
arrayOf(
anyOf(
string(),
listOf(string()),
arrayOf(anyOf(int(), string(), bool()))
)
)
)
disallowedMethodCalls: listOf(
arrayOf(
anyOf(
Expand Down Expand Up @@ -46,6 +56,11 @@ parametersSchema:

services:
- Spaze\PHPStan\Rules\Disallowed\DisallowedHelper
- Spaze\PHPStan\Rules\Disallowed\DisallowedNamespaceHelper
-
factory: Spaze\PHPStan\Rules\Disallowed\NamespaceUsages(forbiddenNamespaces: %disallowedNamespaces%)
tags:
- phpstan.rules.rule
-
factory: Spaze\PHPStan\Rules\Disallowed\MethodCalls(forbiddenCalls: %disallowedMethodCalls%)
tags:
Expand Down
52 changes: 52 additions & 0 deletions src/DisallowedNamespace.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php
declare(strict_types = 1);

namespace Spaze\PHPStan\Rules\Disallowed;

class DisallowedNamespace
{

/** @var string */
private $namespace;

/** @var string|null */
private $message;

/** @var string[] */
private $allowIn;


/**
* @param string $namespace
* @param string|null $message
* @param string[] $allowIn
*/
public function __construct(string $namespace, ?string $message, array $allowIn)
{
$this->namespace = ltrim($namespace, '\\');
$this->message = $message;
$this->allowIn = $allowIn;
}


public function getNamespace(): string
{
return $this->namespace;
}


public function getMessage(): string
{
return $this->message ?? 'because reasons';
}


/**
* @return string[]
*/
public function getAllowIn(): array
{
return $this->allowIn;
}

}
102 changes: 102 additions & 0 deletions src/DisallowedNamespaceHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php
declare(strict_types = 1);

namespace Spaze\PHPStan\Rules\Disallowed;

use PHPStan\Analyser\Scope;
use PHPStan\File\FileHelper;

class DisallowedNamespaceHelper
{

/** @var FileHelper */
private $fileHelper;


public function __construct(FileHelper $fileHelper)
{
$this->fileHelper = $fileHelper;
}


/**
* @param Scope $scope
* @param DisallowedNamespace $disallowedNamespace
* @return boolean
*/
private function isAllowed(Scope $scope, DisallowedNamespace $disallowedNamespace): bool
{
foreach ($disallowedNamespace->getAllowIn() as $allowedPath) {
$match = fnmatch($this->fileHelper->absolutizePath($allowedPath), $scope->getFile());
if ($match) {
return true;
}
}
return false;
}


/**
* @param array<array{namespace:string, message?:string, allowIn?:string[], allowParamsInAllowed?:array<integer, integer|boolean|string>, allowParamsAnywhere?:array<integer, integer|boolean|string>}> $config
* @return DisallowedNamespace[]
*/
public function createDisallowedNamespacesFromConfig(array $config): array
{
$disallowedNamespaces = [];
foreach ($config as $disallowed) {
$disallowed = new DisallowedNamespace(
$disallowed['namespace'],
$disallowed['message'] ?? null,
$disallowed['allowIn'] ?? []
);
$disallowedNamespaces[$disallowed->getNamespace()] = $disallowed;
}
return array_values($disallowedNamespaces);
}


/**
* @param string $namespace
* @param Scope $scope
* @param DisallowedNamespace[] $disallowedNamespaces
* @return string[]
*/
public function getDisallowedMessage(string $namespace, Scope $scope, array $disallowedNamespaces): array
{
foreach ($disallowedNamespaces as $disallowedNamespace) {
if ($this->isAllowed($scope, $disallowedNamespace)) {
continue;
}

if (!$this->matchesNamespace($disallowedNamespace->getNamespace(), $namespace)) {
continue;
}

return [
sprintf(
'Namespace %s is forbidden, %s%s',
$namespace,
$disallowedNamespace->getMessage(),
$disallowedNamespace->getNamespace() !== $namespace ? " [{$namespace} matches {$disallowedNamespace->getNamespace()}]" : ''
),
];
}

return [];
}


private function matchesNamespace(string $pattern, string $value): bool
{
if ($pattern === $value) {
return true;
}

if (fnmatch($pattern, $value, FNM_NOESCAPE)) {
return true;
}

return false;
}

}
95 changes: 95 additions & 0 deletions src/NamespaceUsages.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php
declare(strict_types = 1);

namespace Spaze\PHPStan\Rules\Disallowed;

use PhpParser\Node;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\TraitUse;
use PhpParser\Node\Stmt\UseUse;
use PHPStan\Analyser\Scope;
use PHPStan\Broker\ClassNotFoundException;
use PHPStan\Rules\Rule;

/**
* @implements Rule<Node>
*/
class NamespaceUsages implements Rule
{

/** @var DisallowedNamespaceHelper */
private $disallowedHelper;

/** @var DisallowedNamespace[] */
private $disallowedNamespace;


/**
* @param DisallowedNamespaceHelper $disallowedNamespaceHelper
* @param array<array{namespace:string, message?:string, allowIn?:string[]}> $forbiddenNamespaces
*/
public function __construct(DisallowedNamespaceHelper $disallowedNamespaceHelper, array $forbiddenNamespaces)
{
$this->disallowedHelper = $disallowedNamespaceHelper;
$this->disallowedNamespace = $this->disallowedHelper->createDisallowedNamespacesFromConfig($forbiddenNamespaces);
}


public function getNodeType(): string
{
return Node::class;
}


/**
* @param Node $node
* @param Scope $scope
* @return string[]
* @throws ClassNotFoundException
*/
public function processNode(Node $node, Scope $scope): array
{
if ($node instanceof FullyQualified) {
$namespaces = [$node->toString()];
} elseif ($node instanceof UseUse) {
$namespaces = [$node->name->toString()];
} elseif ($node instanceof StaticCall && $node->class instanceof Name) {
$namespaces = [$node->class->toString()];
} elseif ($node instanceof ClassConstFetch && $node->class instanceof Name) {
$namespaces = [$node->class->toString()];
} elseif ($node instanceof Class_ && ($node->extends !== null || count($node->implements) > 0)) {
$namespaces = [];

if ($node->extends !== null) {
$namespaces[] = $node->extends->toString();
}

foreach ($node->implements as $implement) {
$namespaces[] = $implement->toString();
}
} elseif ($node instanceof TraitUse) {
$namespaces = [];

foreach ($node->traits as $trait) {
$namespaces[] = $trait->toString();
}
} else {
return [];
}

$errors = [];
foreach ($namespaces as $namespace) {
$errors = array_merge(
$errors,
$this->disallowedHelper->getDisallowedMessage(ltrim($namespace, '\\'), $scope, $this->disallowedNamespace)
);
}

return $errors;
}

}
Loading

0 comments on commit 058dd4e

Please sign in to comment.