From 36913b13d579f794a19f7e74ef02c311d08a0d4b Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Fri, 3 Mar 2023 03:57:56 -0400 Subject: [PATCH] Support for `readonly` classes --- composer.json | 2 +- src/Psalm/Internal/Analyzer/ClassAnalyzer.php | 12 +++++ .../Reflector/ClassLikeNodeScanner.php | 25 ++++++++++- src/Psalm/Storage/ClassLikeStorage.php | 2 + tests/ClassTest.php | 45 +++++++++++++++++++ 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 48731769287..e19832f4cfe 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "felixfbecker/language-server-protocol": "^1.5.2", "fidry/cpu-core-counter": "^0.4.1 || ^0.5.1", "netresearch/jsonmapper": "^1.0 || ^2.0 || ^3.0 || ^4.0", - "nikic/php-parser": "^4.13", + "nikic/php-parser": "^4.14", "sebastian/diff": "^4.0 || ^5.0", "spatie/array-to-xml": "^2.17.0 || ^3.0", "symfony/console": "^4.1.6 || ^5.0 || ^6.0", diff --git a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php index 7108d136126..137b47550a2 100644 --- a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php @@ -2360,6 +2360,18 @@ private function checkParentClass( ); } + if ($parent_class_storage->readonly && !$storage->readonly) { + IssueBuffer::maybeAdd( + new InvalidExtendClass( + 'Non-readonly class ' . $fq_class_name . ' may not inherit from ' + . 'readonly class ' . $parent_fq_class_name, + $code_location, + $fq_class_name, + ), + $storage->suppressed_issues + $this->getSuppressedIssues(), + ); + } + if ($parent_class_storage->deprecated) { IssueBuffer::maybeAdd( new DeprecatedClass( diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php index fb06972b2a2..2cf10dc19ff 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php @@ -42,11 +42,13 @@ use Psalm\Issue\DuplicateClass; use Psalm\Issue\DuplicateConstant; use Psalm\Issue\DuplicateEnumCase; +use Psalm\Issue\InvalidAttribute; use Psalm\Issue\InvalidDocblock; use Psalm\Issue\InvalidEnumBackingType; use Psalm\Issue\InvalidEnumCaseValue; use Psalm\Issue\InvalidTypeImport; use Psalm\Issue\MissingDocblockType; +use Psalm\Issue\MissingPropertyType; use Psalm\Issue\ParseError; use Psalm\IssueBuffer; use Psalm\Storage\AttributeStorage; @@ -256,6 +258,7 @@ public function start(PhpParser\Node\Stmt\ClassLike $node): ?bool if ($node instanceof PhpParser\Node\Stmt\Class_) { $storage->abstract = $node->isAbstract(); $storage->final = $node->isFinal(); + $storage->readonly = $node->isReadonly(); $this->codebase->classlikes->addFullyQualifiedClassName($fq_classlike_name, $this->file_path); @@ -765,6 +768,14 @@ public function start(PhpParser\Node\Stmt\ClassLike $node): ?bool $storage->external_mutation_free = true; } + if ($attribute->fq_class_name === 'AllowDynamicProperties' && $storage->readonly) { + IssueBuffer::maybeAdd(new InvalidAttribute( + 'Readonly classes cannot have dynamic properties', + new CodeLocation($this->file_scanner, $attr, null, true), + )); + continue; + } + $storage->attributes[] = $attribute; } } @@ -1586,10 +1597,22 @@ private function visitPropertyDeclaration( if (count($property_storage->internal) === 0 && $var_comment && $var_comment->internal) { $property_storage->internal = [NamespaceAnalyzer::getNameSpaceRoot($fq_classlike_name)]; } - $property_storage->readonly = $stmt->isReadonly() || ($var_comment && $var_comment->readonly); + $property_storage->readonly = $storage->readonly + || $stmt->isReadonly() + || ($var_comment && $var_comment->readonly); $property_storage->allow_private_mutation = $var_comment ? $var_comment->allow_private_mutation : false; $property_storage->description = $var_comment ? $var_comment->description : null; + if (!$signature_type && $storage->readonly) { + IssueBuffer::maybeAdd( + new MissingPropertyType( + 'Properties of readonly classes must have a type', + new CodeLocation($this->file_scanner, $stmt, null, true), + $fq_classlike_name . '::$' . $property->name->name, + ), + ); + } + if (!$signature_type && !$doc_var_group_type) { if ($property->default) { $property_storage->suggested_type = SimpleTypeInferer::infer( diff --git a/src/Psalm/Storage/ClassLikeStorage.php b/src/Psalm/Storage/ClassLikeStorage.php index d095cefb519..45fa635d444 100644 --- a/src/Psalm/Storage/ClassLikeStorage.php +++ b/src/Psalm/Storage/ClassLikeStorage.php @@ -470,6 +470,8 @@ final class ClassLikeStorage implements HasAttributesInterface public bool $public_api = false; + public bool $readonly = false; + public function __construct(string $name) { $this->name = $name; diff --git a/tests/ClassTest.php b/tests/ClassTest.php index 633286068e1..6a697b63eb3 100644 --- a/tests/ClassTest.php +++ b/tests/ClassTest.php @@ -1248,6 +1248,51 @@ final private function baz(): void {} PHP, 'error_message' => 'PrivateFinalMethod', ], + 'readonlyClass' => [ + 'code' => <<<'PHP' + a = 33; + PHP, + 'error_message' => 'InaccessibleProperty', + 'ignored_issues' => [], + 'php_version' => '8.2', + ], + 'readonlyClassRequiresTypedProperties' => [ + 'code' => <<<'PHP' + 'MissingPropertyType', + 'ignored_issues' => [], + 'php_version' => '8.2', + ], + 'readonlyClassCannotHaveDynamicProperties' => [ + 'code' => <<<'PHP' + 'InvalidAttribute', + 'ignored_issues' => [], + 'php_version' => '8.2', + ], + 'readonlyClassesCannotBeExtendedByNonReadonlyOnes' => [ + 'code' => <<<'PHP' + 'InvalidExtendClass', + 'ignored_issues' => [], + 'php_version' => '8.2', + ], ]; } }