Skip to content

Commit

Permalink
Bleeding edge - check type in @property tags
Browse files Browse the repository at this point in the history
  • Loading branch information
ondrejmirtes committed Aug 25, 2024
1 parent 030acbb commit 55ea2ae
Show file tree
Hide file tree
Showing 10 changed files with 553 additions and 1 deletion.
10 changes: 10 additions & 0 deletions conf/config.level2.neon
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ rules:
- PHPStan\Rules\PhpDoc\RequireExtendsDefinitionTraitRule

conditionalTags:
PHPStan\Rules\Classes\PropertyTagRule:
phpstan.rules.rule: %featureToggles.absentTypeChecks%
PHPStan\Rules\Classes\PropertyTagTraitRule:
phpstan.rules.rule: %featureToggles.absentTypeChecks%
PHPStan\Rules\Functions\IncompatibleArrowFunctionDefaultParameterTypeRule:
phpstan.rules.rule: %featureToggles.closureDefaultParameterTypeRule%
PHPStan\Rules\Functions\IncompatibleClosureDefaultParameterTypeRule:
Expand All @@ -75,6 +79,12 @@ services:
tags:
- phpstan.rules.rule

-
class: PHPStan\Rules\Classes\PropertyTagRule

-
class: PHPStan\Rules\Classes\PropertyTagTraitRule

-
class: PHPStan\Rules\PhpDoc\RequireExtendsCheck
arguments:
Expand Down
5 changes: 5 additions & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,11 @@ services:
checkClassCaseSensitivity: %checkClassCaseSensitivity%
absentTypeChecks: %featureToggles.absentTypeChecks%

-
class: PHPStan\Rules\Classes\PropertyTagCheck
arguments:
checkClassCaseSensitivity: %checkClassCaseSensitivity%

-
class: PHPStan\Rules\Comparison\ConstantConditionRuleHelper
arguments:
Expand Down
2 changes: 1 addition & 1 deletion src/Reflection/ClassReflection.php
Original file line number Diff line number Diff line change
Expand Up @@ -1732,7 +1732,7 @@ public function getRequireImplementsTags(): array
}

/**
* @return array<PropertyTag>
* @return array<string, PropertyTag>
*/
public function getPropertyTags(): array
{
Expand Down
174 changes: 174 additions & 0 deletions src/Rules/Classes/PropertyTagCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Classes;

use PhpParser\Node\Stmt\ClassLike;
use PHPStan\Internal\SprintfHelper;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\ClassNameCheck;
use PHPStan\Rules\ClassNameNodePair;
use PHPStan\Rules\Generics\GenericObjectTypeCheck;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\MissingTypehintCheck;
use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\Type;
use PHPStan\Type\VerbosityLevel;
use function array_merge;
use function implode;
use function sprintf;

final class PropertyTagCheck
{

public function __construct(
private ReflectionProvider $reflectionProvider,
private ClassNameCheck $classCheck,
private GenericObjectTypeCheck $genericObjectTypeCheck,
private MissingTypehintCheck $missingTypehintCheck,
private UnresolvableTypeHelper $unresolvableTypeHelper,
private bool $checkClassCaseSensitivity,
)
{
}

/**
* @return list<IdentifierRuleError>
*/
public function check(
ClassReflection $classReflection,
ClassLike $node,
): array
{
$errors = [];
foreach ($classReflection->getPropertyTags() as $propertyName => $propertyTag) {
$readableType = $propertyTag->getReadableType();
$writableType = $propertyTag->getWritableType();

$types = [];
$tagName = '@property';
if ($readableType !== null) {
if ($writableType !== null) {
if ($writableType->equals($readableType)) {
$types[] = $readableType;
} else {
$types[] = $readableType;
$types[] = $writableType;
}
} else {
$tagName = '@property-read';
$types[] = $readableType;
}
} elseif ($writableType !== null) {
$tagName = '@property-write';
$types[] = $writableType;
} else {
throw new ShouldNotHappenException();
}

foreach ($types as $type) {
foreach ($this->checkPropertyType($classReflection, $propertyName, $tagName, $type, $node) as $error) {
$errors[] = $error;
}
}
}

return $errors;
}

/**
* @return list<IdentifierRuleError>
*/
private function checkPropertyType(ClassReflection $classReflection, string $propertyName, string $tagName, Type $type, ClassLike $node): array
{
if ($this->unresolvableTypeHelper->containsUnresolvableType($type)) {
return [
RuleErrorBuilder::message(sprintf(
'PHPDoc tag %s for property %s::$%s contains unresolvable type.',
$tagName,
$classReflection->getDisplayName(),
$propertyName,
))->identifier('propertyTag.unresolvableType')
->build(),
];
}

$escapedClassName = SprintfHelper::escapeFormatString($classReflection->getDisplayName());
$escapedPropertyName = SprintfHelper::escapeFormatString($propertyName);
$escapedTagName = SprintfHelper::escapeFormatString($tagName);

$errors = $this->genericObjectTypeCheck->check(
$type,
sprintf('PHPDoc tag %s for property %s::$%s contains generic type %%s but %%s %%s is not generic.', $escapedTagName, $escapedClassName, $escapedPropertyName),
sprintf('Generic type %%s in PHPDoc tag %s for property %s::$%s does not specify all template types of %%s %%s: %%s', $escapedTagName, $escapedClassName, $escapedPropertyName),
sprintf('Generic type %%s in PHPDoc tag %s for property %s::$%s specifies %%d template types, but %%s %%s supports only %%d: %%s', $escapedTagName, $escapedClassName, $escapedPropertyName),
sprintf('Type %%s in generic type %%s in PHPDoc tag %s for property %s::$%s is not subtype of template type %%s of %%s %%s.', $escapedTagName, $escapedClassName, $escapedPropertyName),
sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag %s for property %s::$%s is in conflict with %%s template type %%s of %%s %%s.', $escapedTagName, $escapedClassName, $escapedPropertyName),
sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag %s for property %s::$%s is redundant, template type %%s of %%s %%s has the same variance.', $escapedTagName, $escapedClassName, $escapedPropertyName),
);

foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($type) as [$innerName, $genericTypeNames]) {
$errors[] = RuleErrorBuilder::message(sprintf(
'PHPDoc tag %s for property %s::$%s contains generic %s but does not specify its types: %s',
$tagName,
$classReflection->getDisplayName(),
$propertyName,
$innerName,
implode(', ', $genericTypeNames),
))
->identifier('missingType.generics')
->build();
}

foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) {
$iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly());
$errors[] = RuleErrorBuilder::message(sprintf(
'%s %s has PHPDoc tag %s for property $%s with no value type specified in iterable type %s.',
$classReflection->getClassTypeDescription(),
$classReflection->getDisplayName(),
$tagName,
$propertyName,
$iterableTypeDescription,
))
->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP)
->identifier('missingType.iterableValue')
->build();
}

foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($type) as $callableType) {
$errors[] = RuleErrorBuilder::message(sprintf(
'%s %s has PHPDoc tag %s for property $%s with no signature specified for %s.',
$classReflection->getClassTypeDescription(),
$classReflection->getDisplayName(),
$tagName,
$propertyName,
$callableType->describe(VerbosityLevel::typeOnly()),
))->identifier('missingType.callable')->build();
}

foreach ($type->getReferencedClasses() as $class) {
if (!$this->reflectionProvider->hasClass($class)) {
$errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag %s for property %s::$%s contains unknown class %s.', $tagName, $classReflection->getDisplayName(), $propertyName, $class))
->identifier('class.notFound')
->discoveringSymbolsTip()
->build();
} elseif ($this->reflectionProvider->getClass($class)->isTrait()) {
$errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag %s for property %s::$%s contains invalid type %s.', $tagName, $classReflection->getDisplayName(), $propertyName, $class))
->identifier('propertyTag.trait')
->build();
} else {
$errors = array_merge(
$errors,
$this->classCheck->checkClassNames([
new ClassNameNodePair($class, $node),
], $this->checkClassCaseSensitivity),
);
}
}

return $errors;
}

}
30 changes: 30 additions & 0 deletions src/Rules/Classes/PropertyTagRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Classes;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassNode;
use PHPStan\Rules\Rule;

/**
* @implements Rule<InClassNode>
*/
final class PropertyTagRule implements Rule
{

public function __construct(private PropertyTagCheck $check)
{
}

public function getNodeType(): string
{
return InClassNode::class;
}

public function processNode(Node $node, Scope $scope): array
{
return $this->check->check($node->getClassReflection(), $node->getOriginalNode());
}

}
39 changes: 39 additions & 0 deletions src/Rules/Classes/PropertyTagTraitRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Classes;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;

/**
* @implements Rule<Node\Stmt\Trait_>
*/
final class PropertyTagTraitRule implements Rule
{

public function __construct(private PropertyTagCheck $check, private ReflectionProvider $reflectionProvider)
{
}

public function getNodeType(): string
{
return Node\Stmt\Trait_::class;
}

public function processNode(Node $node, Scope $scope): array
{
$traitName = $node->namespacedName;
if ($traitName === null) {
return [];
}

if (!$this->reflectionProvider->hasClass($traitName->toString())) {
return [];
}

return $this->check->check($this->reflectionProvider->getClass($traitName->toString()), $node);
}

}
Loading

0 comments on commit 55ea2ae

Please sign in to comment.