1
0
mirror of https://github.com/danog/psalm.git synced 2024-11-26 12:24:49 +01:00

Initial proposal for psalm-require-{extends, implements} (#4361)

* initial implementation of psalm-require-extends

* Added @psalm-require-implements

* Added shortcode for ExtensionRequirementViolation

* Docs & cofig entries for @pasalm-require-{implements,extends}

* Added requirement violations to issues.md
This commit is contained in:
Niclas van Eyk 2020-10-19 21:08:18 +02:00 committed by Daniil Gentili
parent f28c35d96a
commit 5f019cef53
Signed by: danog
GPG Key ID: 8C1BE3B34B230CA7
14 changed files with 327 additions and 2 deletions

View File

@ -197,11 +197,13 @@
<xs:element name="DuplicateMethod" type="MethodIssueHandlerType" minOccurs="0" />
<xs:element name="DuplicateParam" type="IssueHandlerType" minOccurs="0" />
<xs:element name="EmptyArrayAccess" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ExtensionRequirementViolation" type="IssueHandlerType" minOccurs="0" />
<xs:element name="FalsableReturnStatement" type="IssueHandlerType" minOccurs="0" />
<xs:element name="FalseOperand" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ForbiddenCode" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ForbiddenEcho" type="IssueHandlerType" minOccurs="0" />
<xs:element name="IfThisIsMismatch" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ImplementationRequirementViolation" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ImplementedParamTypeMismatch" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ImplementedReturnTypeMismatch" type="IssueHandlerType" minOccurs="0" />
<xs:element name="ImplicitToStringCast" type="IssueHandlerType" minOccurs="0" />

View File

@ -21,11 +21,13 @@
- [DuplicateMethod](issues/DuplicateMethod.md)
- [DuplicateParam](issues/DuplicateParam.md)
- [EmptyArrayAccess](issues/EmptyArrayAccess.md)
- [ExtensionRequirementViolation](issues/ExtensionRequirementViolation.md)
- [FalsableReturnStatement](issues/FalsableReturnStatement.md)
- [FalseOperand](issues/FalseOperand.md)
- [ForbiddenCode](issues/ForbiddenCode.md)
- [ForbiddenEcho](issues/ForbiddenEcho.md)
- [IfThisIsMismatch](issues/IfThisIsMismatch.md)
- [ImplementationRequirementViolation](issues/ImplementationRequirementViolation.md)
- [ImplementedParamTypeMismatch](issues/ImplementedParamTypeMismatch.md)
- [ImplementedReturnTypeMismatch](issues/ImplementedReturnTypeMismatch.md)
- [ImplicitToStringCast](issues/ImplicitToStringCast.md)

View File

@ -0,0 +1,20 @@
# ExtensionRequirementViolation
Emitted when a using class of a trait does not extend the class specified using `@psalm-require-extends`.
```php
<?php
class A { }
/**
* @psalm-require-extends A
*/
trait T { }
class B {
// ExtensionRequirementViolation is emitted, as T requires
// the using class B to extend A, which is not the case
use T;
}
```

View File

@ -0,0 +1,22 @@
# ImplementationRequirementViolation
Emitted when a using class of a trait does not implement all interfaces specified using `@psalm-require-implements`.
```php
<?php
interface A { }
interface B { }
/**
* @psalm-require-implements A
* @psalm-require-implements B
*/
trait T { }
class C {
// ImplementationRequirementViolation is emitted, as T requires
// the using class C to implement A and B, which is not the case
use T;
}
```

View File

@ -34,7 +34,9 @@ class DocComment
'mutation-free', 'external-mutation-free', 'immutable', 'readonly',
'allow-private-mutation', 'readonly-allow-private-mutation',
'yield', 'trace', 'import-type', 'flow', 'taint-specialize', 'taint-escape',
'taint-unescape', 'self-out', 'if-this-is', 'consistent-constructor', 'stub-override'
'taint-unescape', 'self-out', 'consistent-constructor', 'stub-override',
'require-extends', 'require-implements',
'if-this-is'
];
/**

View File

@ -16,6 +16,8 @@ use Psalm\Context;
use Psalm\Issue\DeprecatedClass;
use Psalm\Issue\DeprecatedInterface;
use Psalm\Issue\DeprecatedTrait;
use Psalm\Issue\ExtensionRequirementViolation;
use Psalm\Issue\ImplementationRequirementViolation;
use Psalm\Issue\InaccessibleMethod;
use Psalm\Issue\InternalClass;
use Psalm\Issue\InvalidExtendClass;
@ -54,6 +56,7 @@ use function array_search;
use function array_keys;
use function array_merge;
use function array_filter;
use function in_array;
/**
* @internal
@ -1540,6 +1543,44 @@ class ClassAnalyzer extends ClassLikeAnalyzer
}
}
if ($trait_storage->extension_requirement !== null) {
$extension_requirement = $codebase->classlikes->getUnAliasedName(
$trait_storage->extension_requirement
);
$extensionRequirementMet = in_array($extension_requirement, $storage->parent_classes);
if (!$extensionRequirementMet) {
if (IssueBuffer::accepts(
new ExtensionRequirementViolation(
$fq_trait_name . ' requires using class to extend ' . $extension_requirement
. ', but ' . $storage->name . ' does not',
new CodeLocation($previous_trait_analyzer ?: $this, $trait_name)
),
$storage->suppressed_issues + $this->getSuppressedIssues()
)) {
// fall through
}
}
}
foreach ($trait_storage->implementation_requirements as $implementation_requirement) {
$implementation_requirement = $codebase->classlikes->getUnAliasedName($implementation_requirement);
$implementationRequirementMet = in_array($implementation_requirement, $storage->class_implements);
if (!$implementationRequirementMet) {
if (IssueBuffer::accepts(
new ImplementationRequirementViolation(
$fq_trait_name . ' requires using class to implement '
. $implementation_requirement . ', but ' . $storage->name . ' does not',
new CodeLocation($previous_trait_analyzer ?: $this, $trait_name)
),
$storage->suppressed_issues + $this->getSuppressedIssues()
)) {
// fall through
}
}
}
if ($storage->mutation_free && !$trait_storage->mutation_free) {
if (IssueBuffer::accepts(
new MutableDependency(

View File

@ -17,7 +17,6 @@ use Psalm\Internal\Type\ParseTreeCreator;
use Psalm\Internal\Type\TypeAlias;
use Psalm\Internal\Type\TypeParser;
use Psalm\Internal\Type\TypeTokenizer;
use Psalm\Type;
use function array_unique;
use function trim;
use function substr_count;
@ -40,6 +39,7 @@ use function explode;
use function array_merge;
use const PREG_OFFSET_CAPTURE;
use function rtrim;
use function array_key_first;
/**
* @internal
@ -889,6 +889,25 @@ class CommentAnalyzer
}
}
if (isset($parsed_docblock->tags['psalm-require-extends'])
&& count($extension_requirements = $parsed_docblock->tags['psalm-require-extends']) > 0) {
$info->extension_requirement = trim(preg_replace(
'@^[ \t]*\*@m',
'',
$extension_requirements[array_key_first($extension_requirements)]
));
}
if (isset($parsed_docblock->tags['psalm-require-implements'])) {
foreach ($parsed_docblock->tags['psalm-require-implements'] as $implementation_requirement) {
$info->implementation_requirements[] = trim(preg_replace(
'@^[ \t]*\*@m',
'',
$implementation_requirement
));
}
}
if (isset($parsed_docblock->combined_tags['implements'])) {
foreach ($parsed_docblock->combined_tags['implements'] as $template_line) {
$info->template_implements[] = trim(preg_replace('@^[ \t]*\*@m', '', $template_line));

View File

@ -1277,6 +1277,34 @@ class ReflectorVisitor extends PhpParser\NodeVisitorAbstract implements FileSour
}
}
if ($docblock_info->extension_requirement !== null) {
$storage->extension_requirement = (string) TypeParser::parseTokens(
TypeTokenizer::getFullyQualifiedTokens(
$docblock_info->extension_requirement,
$this->aliases,
$this->class_template_types,
$this->type_aliases
),
null,
$this->class_template_types,
$this->type_aliases
);
}
foreach ($docblock_info->implementation_requirements as $implementation_requirement) {
$storage->implementation_requirements[] = (string) TypeParser::parseTokens(
TypeTokenizer::getFullyQualifiedTokens(
$implementation_requirement,
$this->aliases,
$this->class_template_types,
$this->type_aliases
),
null,
$this->class_template_types,
$this->type_aliases
);
}
$storage->sealed_properties = $docblock_info->sealed_properties;
$storage->sealed_methods = $docblock_info->sealed_methods;

View File

@ -127,4 +127,14 @@ class ClassLikeDocblockComment
/** @var bool */
public $stub_override = false;
/**
* @var null|string
*/
public $extension_requirement;
/**
* @var array<int, string>
*/
public $implementation_requirements = [];
}

View File

@ -0,0 +1,9 @@
<?php
namespace Psalm\Issue;
class ExtensionRequirementViolation extends CodeIssue
{
public const ERROR_LEVEL = -1;
public const SHORTCODE = 239;
}

View File

@ -0,0 +1,9 @@
<?php
namespace Psalm\Issue;
class ImplementationRequirementViolation extends CodeIssue
{
public const ERROR_LEVEL = -1;
public const SHORTCODE = 240;
}

View File

@ -368,6 +368,16 @@ class ClassLikeStorage
*/
public $preserve_constructor_signature = false;
/**
* @var null|string
*/
public $extension_requirement;
/**
* @var array<int, string>
*/
public $implementation_requirements = [];
public function __construct(string $name)
{
$this->name = $name;

View File

@ -0,0 +1,67 @@
<?php
namespace Psalm\Tests;
class ExtensionRequirementTest extends TestCase
{
use Traits\ValidCodeAnalysisTestTrait;
use Traits\InvalidCodeAnalysisTestTrait;
public function setUp(): void
{
parent::setUp();
$this->addFile(
'base.php',
'<?php
namespace ExtensionRequirements\Base;
class MyBaseClass { }
'
);
$this->addFile(
'trait.php',
'<?php
namespace ExtensionRequirements\Trait;
use ExtensionRequirements\Base\MyBaseClass as MyAliasedBaseClass;
/** @psalm-require-extends MyAliasedBaseClass */
trait ImposesExtensionRequirements { }
'
);
}
public function providerValidCodeParse(): iterable
{
return [
'extendsBaseClass' => [
'<?php
use ExtensionRequirements\Base\MyBaseClass;
use ExtensionRequirements\Trait\ImposesExtensionRequirements;
class Valid extends MyBaseClass {
use ImposesExtensionRequirements;
}
'
]
];
}
public function providerInvalidCodeParse(): iterable
{
return [
'extendsBaseClass' => [
'<?php
use ExtensionRequirements\Trait\ImposesExtensionRequirements;
class Invalid {
use ImposesExtensionRequirements;
}
',
'error_message' => 'requires using class to extend'
]
];
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace Psalm\Tests;
class ImplementationRequirementTest extends TestCase
{
use Traits\ValidCodeAnalysisTestTrait;
use Traits\InvalidCodeAnalysisTestTrait;
public function setUp(): void
{
parent::setUp();
$this->addFile(
'base.php',
'<?php
namespace ImplementationRequirements\Base;
interface A { }
interface B { }
'
);
$this->addFile(
'trait.php',
'<?php
namespace ImplementationRequirements\Trait;
use ImplementationRequirements\Base\A as MyAliasedInterfaceA;
use ImplementationRequirements\Base\B as MyAliasedInterfaceB;
/**
* @psalm-require-implements MyAliasedInterfaceA
* @psalm-require-implements MyAliasedInterfaceB
*/
trait ImposesImplementationRequirements { }
'
);
}
public function providerValidCodeParse(): iterable
{
return [
'implementsAllRequirements' => [
'<?php
use ImplementationRequirements\Base\A;
use ImplementationRequirements\Base\B;
use ImplementationRequirements\Trait\ImposesImplementationRequirements;
class Valid implements A, B {
use ImposesImplementationRequirements;
}
'
]
];
}
public function providerInvalidCodeParse(): iterable
{
return [
'doesNotImplementAnything' => [
'<?php
use ImplementationRequirements\Trait\ImposesImplementationRequirements;
class Invalid {
use ImposesImplementationRequirements;
}
',
'error_message' => 'requires using class to implement'
],
'onlyImplementsOneRequirement' => [
'<?php
use ImplementationRequirements\Trait\ImposesImplementationRequirements;
use ImplementationRequirements\Base\A;
class Invalid implements A {
use ImposesImplementationRequirements;
}
',
'error_message' => 'requires using class to implement'
]
];
}
}