mirror of
https://github.com/danog/psalm.git
synced 2024-11-29 20:28:59 +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="DuplicateMethod" type="MethodIssueHandlerType" minOccurs="0" />
|
||||||
<xs:element name="DuplicateParam" type="IssueHandlerType" minOccurs="0" />
|
<xs:element name="DuplicateParam" type="IssueHandlerType" minOccurs="0" />
|
||||||
<xs:element name="EmptyArrayAccess" 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="FalsableReturnStatement" type="IssueHandlerType" minOccurs="0" />
|
||||||
<xs:element name="FalseOperand" type="IssueHandlerType" minOccurs="0" />
|
<xs:element name="FalseOperand" type="IssueHandlerType" minOccurs="0" />
|
||||||
<xs:element name="ForbiddenCode" type="IssueHandlerType" minOccurs="0" />
|
<xs:element name="ForbiddenCode" type="IssueHandlerType" minOccurs="0" />
|
||||||
<xs:element name="ForbiddenEcho" type="IssueHandlerType" minOccurs="0" />
|
<xs:element name="ForbiddenEcho" type="IssueHandlerType" minOccurs="0" />
|
||||||
<xs:element name="IfThisIsMismatch" 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="ImplementedParamTypeMismatch" type="IssueHandlerType" minOccurs="0" />
|
||||||
<xs:element name="ImplementedReturnTypeMismatch" type="IssueHandlerType" minOccurs="0" />
|
<xs:element name="ImplementedReturnTypeMismatch" type="IssueHandlerType" minOccurs="0" />
|
||||||
<xs:element name="ImplicitToStringCast" type="IssueHandlerType" minOccurs="0" />
|
<xs:element name="ImplicitToStringCast" type="IssueHandlerType" minOccurs="0" />
|
||||||
|
@ -21,11 +21,13 @@
|
|||||||
- [DuplicateMethod](issues/DuplicateMethod.md)
|
- [DuplicateMethod](issues/DuplicateMethod.md)
|
||||||
- [DuplicateParam](issues/DuplicateParam.md)
|
- [DuplicateParam](issues/DuplicateParam.md)
|
||||||
- [EmptyArrayAccess](issues/EmptyArrayAccess.md)
|
- [EmptyArrayAccess](issues/EmptyArrayAccess.md)
|
||||||
|
- [ExtensionRequirementViolation](issues/ExtensionRequirementViolation.md)
|
||||||
- [FalsableReturnStatement](issues/FalsableReturnStatement.md)
|
- [FalsableReturnStatement](issues/FalsableReturnStatement.md)
|
||||||
- [FalseOperand](issues/FalseOperand.md)
|
- [FalseOperand](issues/FalseOperand.md)
|
||||||
- [ForbiddenCode](issues/ForbiddenCode.md)
|
- [ForbiddenCode](issues/ForbiddenCode.md)
|
||||||
- [ForbiddenEcho](issues/ForbiddenEcho.md)
|
- [ForbiddenEcho](issues/ForbiddenEcho.md)
|
||||||
- [IfThisIsMismatch](issues/IfThisIsMismatch.md)
|
- [IfThisIsMismatch](issues/IfThisIsMismatch.md)
|
||||||
|
- [ImplementationRequirementViolation](issues/ImplementationRequirementViolation.md)
|
||||||
- [ImplementedParamTypeMismatch](issues/ImplementedParamTypeMismatch.md)
|
- [ImplementedParamTypeMismatch](issues/ImplementedParamTypeMismatch.md)
|
||||||
- [ImplementedReturnTypeMismatch](issues/ImplementedReturnTypeMismatch.md)
|
- [ImplementedReturnTypeMismatch](issues/ImplementedReturnTypeMismatch.md)
|
||||||
- [ImplicitToStringCast](issues/ImplicitToStringCast.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',
|
'mutation-free', 'external-mutation-free', 'immutable', 'readonly',
|
||||||
'allow-private-mutation', 'readonly-allow-private-mutation',
|
'allow-private-mutation', 'readonly-allow-private-mutation',
|
||||||
'yield', 'trace', 'import-type', 'flow', 'taint-specialize', 'taint-escape',
|
'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\DeprecatedClass;
|
||||||
use Psalm\Issue\DeprecatedInterface;
|
use Psalm\Issue\DeprecatedInterface;
|
||||||
use Psalm\Issue\DeprecatedTrait;
|
use Psalm\Issue\DeprecatedTrait;
|
||||||
|
use Psalm\Issue\ExtensionRequirementViolation;
|
||||||
|
use Psalm\Issue\ImplementationRequirementViolation;
|
||||||
use Psalm\Issue\InaccessibleMethod;
|
use Psalm\Issue\InaccessibleMethod;
|
||||||
use Psalm\Issue\InternalClass;
|
use Psalm\Issue\InternalClass;
|
||||||
use Psalm\Issue\InvalidExtendClass;
|
use Psalm\Issue\InvalidExtendClass;
|
||||||
@ -54,6 +56,7 @@ use function array_search;
|
|||||||
use function array_keys;
|
use function array_keys;
|
||||||
use function array_merge;
|
use function array_merge;
|
||||||
use function array_filter;
|
use function array_filter;
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @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 ($storage->mutation_free && !$trait_storage->mutation_free) {
|
||||||
if (IssueBuffer::accepts(
|
if (IssueBuffer::accepts(
|
||||||
new MutableDependency(
|
new MutableDependency(
|
||||||
|
@ -17,7 +17,6 @@ use Psalm\Internal\Type\ParseTreeCreator;
|
|||||||
use Psalm\Internal\Type\TypeAlias;
|
use Psalm\Internal\Type\TypeAlias;
|
||||||
use Psalm\Internal\Type\TypeParser;
|
use Psalm\Internal\Type\TypeParser;
|
||||||
use Psalm\Internal\Type\TypeTokenizer;
|
use Psalm\Internal\Type\TypeTokenizer;
|
||||||
use Psalm\Type;
|
|
||||||
use function array_unique;
|
use function array_unique;
|
||||||
use function trim;
|
use function trim;
|
||||||
use function substr_count;
|
use function substr_count;
|
||||||
@ -40,6 +39,7 @@ use function explode;
|
|||||||
use function array_merge;
|
use function array_merge;
|
||||||
use const PREG_OFFSET_CAPTURE;
|
use const PREG_OFFSET_CAPTURE;
|
||||||
use function rtrim;
|
use function rtrim;
|
||||||
|
use function array_key_first;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @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'])) {
|
if (isset($parsed_docblock->combined_tags['implements'])) {
|
||||||
foreach ($parsed_docblock->combined_tags['implements'] as $template_line) {
|
foreach ($parsed_docblock->combined_tags['implements'] as $template_line) {
|
||||||
$info->template_implements[] = trim(preg_replace('@^[ \t]*\*@m', '', $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_properties = $docblock_info->sealed_properties;
|
||||||
$storage->sealed_methods = $docblock_info->sealed_methods;
|
$storage->sealed_methods = $docblock_info->sealed_methods;
|
||||||
|
|
||||||
|
@ -127,4 +127,14 @@ class ClassLikeDocblockComment
|
|||||||
|
|
||||||
/** @var bool */
|
/** @var bool */
|
||||||
public $stub_override = false;
|
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;
|
public $preserve_constructor_signature = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var null|string
|
||||||
|
*/
|
||||||
|
public $extension_requirement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
public $implementation_requirements = [];
|
||||||
|
|
||||||
public function __construct(string $name)
|
public function __construct(string $name)
|
||||||
{
|
{
|
||||||
$this->name = $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