Skip to content

Commit

Permalink
Add support for @psalm-inheritors
Browse files Browse the repository at this point in the history
  • Loading branch information
robchett committed Apr 28, 2023
1 parent 96a7133 commit ff440a7
Show file tree
Hide file tree
Showing 8 changed files with 145 additions and 3 deletions.
11 changes: 9 additions & 2 deletions docs/running_psalm/issues/InvalidExtendClass.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# InvalidExtendClass

Emitted when attempting to extend a final class or a class annotated with `@final`.
Emitted when attempting to extend a final class, a class annotated with `@final` or a class using @psalm-inheritors and not in the inheritor list

```php
<?php
Expand All @@ -15,4 +15,11 @@ class B extends A {}
class DoctrineA {}

class DoctrineB extends DoctrineA {}
```

/**
* @psalm-inheritors A|B
*/
class C {}

class D extends C {}
```
2 changes: 1 addition & 1 deletion src/Psalm/DocComment.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ final class DocComment
'taint-unescape', 'self-out', 'consistent-constructor', 'stub-override',
'require-extends', 'require-implements', 'param-out', 'ignore-var',
'consistent-templates', 'if-this-is', 'this-out', 'check-type', 'check-type-exact',
'api',
'api', 'inheritors',
];

/**
Expand Down
20 changes: 20 additions & 0 deletions src/Psalm/Internal/Analyzer/ClassLikeAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Psalm\Internal\Type\TemplateStandinTypeReplacer;
use Psalm\Issue\InaccessibleProperty;
use Psalm\Issue\InvalidClass;
use Psalm\Issue\InvalidExtendClass;
use Psalm\Issue\InvalidTemplateParam;
use Psalm\Issue\MissingDependency;
use Psalm\Issue\MissingTemplateParam;
Expand All @@ -28,6 +29,7 @@
use Psalm\StatementsSource;
use Psalm\Storage\ClassLikeStorage;
use Psalm\Type;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Atomic\TTemplateParam;
use Psalm\Type\Union;
use UnexpectedValueException;
Expand Down Expand Up @@ -331,6 +333,24 @@ public static function checkFullyQualifiedClassLikeName(
return null;
}


$classUnion = new Union([new TNamedObject($fq_class_name)]);
foreach ($class_storage->parent_classes as $parent_class) {
$parent_storage = $codebase->classlikes->getStorageFor($parent_class);
if ($parent_storage && $parent_storage->inheritors) {
if (!UnionTypeComparator::isContainedBy($codebase, $classUnion, $parent_storage->inheritors)) {
IssueBuffer::maybeAdd(
new InvalidExtendClass(
'Class ' . $fq_class_name . ' in not an allowed inheritor of parent class ' . $parent_class,
$code_location,
$fq_class_name,
),
$suppressed_issues,
);
}
}
}

foreach ($class_storage->invalid_dependencies as $dependency_class_name => $_) {
// if the implemented/extended class is stubbed, it may not yet have
// been hydrated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,14 @@ public static function parse(
$info->sealed_methods = true;
}

if (isset($parsed_docblock->tags['psalm-inheritors'])) {
foreach ($parsed_docblock->tags['psalm-inheritors'] as $template_line) {
$doc_line_parts = CommentAnalyzer::splitDocLine($template_line);
$doc_line_parts[0] = CommentAnalyzer::sanitizeDocblockType($doc_line_parts[0]);
$info->inheritors = $doc_line_parts[0];
}
}

if (isset($parsed_docblock->tags['psalm-immutable'])
|| isset($parsed_docblock->tags['psalm-mutation-free'])
) {
Expand Down
25 changes: 25 additions & 0 deletions src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,31 @@ public function start(PhpParser\Node\Stmt\ClassLike $node): ?bool
$storage->sealed_properties = $docblock_info->sealed_properties;
$storage->sealed_methods = $docblock_info->sealed_methods;


if ($docblock_info->inheritors) {
try {
$storage->inheritors = TypeParser::parseTokens(
TypeTokenizer::getFullyQualifiedTokens(
$docblock_info->inheritors,
$storage->aliases,
$storage->template_types ?? [],
$storage->type_aliases,
$fq_classlike_name,
),
null,
$storage->template_types ?? [],
$storage->type_aliases,
true,
);
} catch (TypeParseTreeException $e) {
$storage->docblock_issues[] = new InvalidDocblock(
'@psalm-inheritors contains invalid reference:' . $e->getMessage(),
$name_location ?? $class_location,
);
}
}


if ($docblock_info->properties) {
foreach ($docblock_info->properties as $property) {
$pseudo_property_type_tokens = TypeTokenizer::getFullyQualifiedTokens(
Expand Down
2 changes: 2 additions & 0 deletions src/Psalm/Internal/Scanner/ClassLikeDocblockComment.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ class ClassLikeDocblockComment
*/
public array $imported_types = [];

public ?string $inheritors = null;

public bool $consistent_constructor = false;

public bool $consistent_templates = false;
Expand Down
5 changes: 5 additions & 0 deletions src/Psalm/Storage/ClassLikeStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,11 @@ final class ClassLikeStorage implements HasAttributesInterface
*/
public $appearing_property_ids = [];

/**
* @var ?Union
*/
public $inheritors = null;

/**
* @var array<string, string>
*/
Expand Down
75 changes: 75 additions & 0 deletions tests/ClassTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,68 @@ private final function __construct() {}
}
PHP,
],
'singleInheritorIsAllowed' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass
*/
class BaseClass {}
class FooClass extends BaseClass {}
$a = new FooClass();
PHP,
],
'unionInheritorIsAllowed' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass|BarClass
*/
class BaseClass {}
class FooClass extends BaseClass {}
$a = new FooClass();
class BarClass extends FooClass {}
$b = new BarClass();
PHP,
],
'multiInheritorIsAllowed' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass|arClass
*/
class BaseClass {}
class FooClass extends BaseClass {}
$a = new FooClass();
class BarClass extends FooClass {}
$b = new BarClass();
PHP,
],
'skippedInheritorIsAllowed' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass|BarClass
*/
class BaseClass {}
class FooClass extends BaseClass {}
$a = new FooClass();
class BarClass extends FooClass {}
$b = new BarClass();
PHP,
],
'CompositeInheritorIsAllowed' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors BarClass&FooInterface
*/
class BaseClass {}
interface FooInterface {}
class BarClass extends BaseClass implements FooInterface {}
$b = new BarClass();
PHP,
],
];
}

Expand Down Expand Up @@ -1293,6 +1355,19 @@ class Bar extends Foo {}
'ignored_issues' => [],
'php_version' => '8.2',
],
'classCannotExtendIfNotInInheritors' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass|BarClass
*/
class BaseClass {}
class BazClass extends BaseClass {} // this is an error
$a = new BazClass();
PHP,
'error_message' => 'InvalidExtendClass',
'ignored_issues' => [],
],
];
}
}

0 comments on commit ff440a7

Please sign in to comment.