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:
parent
f28c35d96a
commit
5f019cef53
@ -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" />
|
||||
|
@ -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)
|
||||
|
20
docs/running_psalm/issues/ExtensionRequirementViolation.md
Normal file
20
docs/running_psalm/issues/ExtensionRequirementViolation.md
Normal 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;
|
||||
}
|
||||
```
|
@ -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;
|
||||
}
|
||||
```
|
@ -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'
|
||||
];
|
||||
|
||||
/**
|
||||
|
@ -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(
|
||||
|
@ -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));
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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 = [];
|
||||
}
|
||||
|
9
src/Psalm/Issue/ExtensionRequirementViolation.php
Normal file
9
src/Psalm/Issue/ExtensionRequirementViolation.php
Normal file
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Psalm\Issue;
|
||||
|
||||
class ExtensionRequirementViolation extends CodeIssue
|
||||
{
|
||||
public const ERROR_LEVEL = -1;
|
||||
public const SHORTCODE = 239;
|
||||
}
|
9
src/Psalm/Issue/ImplementationRequirementViolation.php
Normal file
9
src/Psalm/Issue/ImplementationRequirementViolation.php
Normal file
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Psalm\Issue;
|
||||
|
||||
class ImplementationRequirementViolation extends CodeIssue
|
||||
{
|
||||
public const ERROR_LEVEL = -1;
|
||||
public const SHORTCODE = 240;
|
||||
}
|
@ -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;
|
||||
|
67
tests/ExtensionRequirementTest.php
Normal file
67
tests/ExtensionRequirementTest.php
Normal 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'
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
84
tests/ImplementationRequirementTest.php
Normal file
84
tests/ImplementationRequirementTest.php
Normal 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'
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user