Skip to content

Commit

Permalink
Fixes #4. Add support for union types on Yii::createObject()
Browse files Browse the repository at this point in the history
  • Loading branch information
erickskrauch committed Sep 5, 2023
1 parent 0d703b1 commit 855910b
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 128 deletions.
51 changes: 51 additions & 0 deletions src/Helper/RuleHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);

namespace ErickSkrauch\PHPStan\Yii2\Helper;

use PHPStan\Rules\FileRuleError;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\MetadataRuleError;
use PHPStan\Rules\NonIgnorableRuleError;
use PHPStan\Rules\RuleError;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Rules\TipRuleError;

final class RuleHelper {

/**
* @template T of RuleError
* @param T $error
* @return T
*/
public static function removeLine(RuleError $error): RuleError {
$builder = RuleErrorBuilder::message($error->getMessage());
// @phpstan-ignore-next-line
if ($error instanceof IdentifierRuleError) {
$builder = $builder->identifier($error->getIdentifier());
}

// @phpstan-ignore-next-line
if ($error instanceof NonIgnorableRuleError) {
$builder = $builder->nonIgnorable();
}

// @phpstan-ignore-next-line
if ($error instanceof TipRuleError) {
$builder = $builder->tip($error->getTip());
}

// @phpstan-ignore-next-line
if ($error instanceof MetadataRuleError) {
$builder = $builder->metadata($error->getMetadata());
}

// @phpstan-ignore-next-line
if ($error instanceof FileRuleError) {
$builder = $builder->file($error->getFile());
}

return $builder->build(); // @phpstan-ignore-line I don't understand why there is an error
}

}
4 changes: 3 additions & 1 deletion src/Rule/CreateConfigurableObjectRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Type\ObjectType;
use yii\base\Configurable;

/**
Expand Down Expand Up @@ -77,10 +78,11 @@ public function processNode(Node $node, Scope $scope): array {
return [];
}

$objectType = new ObjectType($className);
$configArgType = $scope->getType($configArg->value);
$errors = [];
foreach ($configArgType->getConstantArrays() as $constantArray) {
$errors = array_merge($errors, $this->configHelper->validateArray($class, $constantArray, $scope));
$errors = array_merge($errors, $this->configHelper->validateArray($objectType, $constantArray, $scope));
}

return $errors;
Expand Down
52 changes: 9 additions & 43 deletions src/Rule/CreateObjectRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleError;
use PHPStan\Rules\RuleErrorBuilder;
use yii\BaseYii;

/**
Expand Down Expand Up @@ -63,61 +62,28 @@ public function processNode(Node $node, Scope $scope): array {
if ($firstArgType->isConstantArray()->yes()) {
/** @var \PHPStan\Type\Constant\ConstantArrayType $config */
$config = $firstArgType->getConstantArrays()[0];
$classNamesOrError = $this->configHelper->findClasses($config);
if ($classNamesOrError instanceof RuleError) {
return [$classNamesOrError];
$objectTypeOrError = $this->configHelper->findObjectType($config);
if ($objectTypeOrError instanceof IdentifierRuleError) {
return [$objectTypeOrError];
}

// At this moment I'll skip supporting of multiple classes at once
if (count($classNamesOrError) > 1) {
return [];
}

[$className] = $classNamesOrError;

if (!$this->reflectionProvider->hasClass($className)) {
return [
RuleErrorBuilder::message(sprintf('Class %s not found.', $className))
->identifier('class.notFound')
->discoveringSymbolsTip()
->build(),
];
}

$classReflection = $this->reflectionProvider->getClass($className);

$errors = array_merge($errors, $this->configHelper->validateArray($classReflection, $config, $scope));
$objectType = $objectTypeOrError;
$errors = $this->configHelper->validateArray($objectTypeOrError, $config, $scope);
} elseif ($firstArgType->isClassStringType()->yes()) {
$classNamesTypes = $firstArgType->getConstantStrings();
// At this moment I'll skip supporting of multiple classes at once
if (count($classNamesTypes) !== 1) {
return [];
}

$className = $classNamesTypes[0]->getValue();
if (!$this->reflectionProvider->hasClass($className)) {
return [
RuleErrorBuilder::message(sprintf('Class %s not found.', $className))
->identifier('class.notFound')
->discoveringSymbolsTip()
->build(),
];
}

$classReflection = $this->reflectionProvider->getClass($className);
$objectType = $firstArgType->getClassStringObjectType();
} else {
// We can't process second argument without knowing the class
return [];
}

if (isset($args[1])) {
// TODO: it is possible to pass botch 2 argument and __construct() config param.
// TODO: it is possible to pass both 2 argument and __construct() config param.
// at the moment I'll not cover that case.
// Note for future me 2nd argument value has priority when merging with __construct()
$secondArgConstantArrays = $scope->getType($args[1]->value)->getConstantArrays();
if (count($secondArgConstantArrays) === 1) {
$argsConfig = $secondArgConstantArrays[0];
$errors = array_merge($errors, $this->configHelper->validateConstructorArgs($classReflection, $argsConfig, $scope));
$errors = array_merge($errors, $this->configHelper->validateConstructorArgs($objectType, $argsConfig, $scope));
}
}

Expand Down
139 changes: 64 additions & 75 deletions src/Rule/YiiConfigHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,49 @@

namespace ErickSkrauch\PHPStan\Yii2\Rule;

use ErickSkrauch\PHPStan\Yii2\Helper\RuleHelper;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Internal\SprintfHelper;
use PHPStan\Node\Expr\TypeExpr;
use PHPStan\Rules\Classes\InstantiationRule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Rules\RuleLevelHelper;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\ErrorType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\VerbosityLevel;

final class YiiConfigHelper {

private RuleLevelHelper $ruleLevelHelper;

public function __construct(RuleLevelHelper $ruleLevelHelper) {
private InstantiationRule $instantiationRule;

public function __construct(
RuleLevelHelper $ruleLevelHelper,
InstantiationRule $instantiationRule
) {
$this->ruleLevelHelper = $ruleLevelHelper;
$this->instantiationRule = $instantiationRule;
}

/**
* @phpstan-return non-empty-list<string>|\PHPStan\Rules\IdentifierRuleError
* @return string[]|\PHPStan\Rules\IdentifierRuleError
* @return Type|\PHPStan\Rules\IdentifierRuleError
*/
public function findClasses(ConstantArrayType $config) {
public function findObjectType(ConstantArrayType $config) {
foreach (['__class', 'class'] as $classKey) {
$class = $config->getOffsetValueType(new ConstantStringType($classKey));
$constantStrings = $class->getConstantStrings();
if (empty($constantStrings)) {
$classType = $config->getOffsetValueType(new ConstantStringType($classKey));
// This condition will also filter our invalid type, which should be already reported by PHPStan itself
if (!$classType->isClassStringType()->yes()) {
continue;
}

return array_map(fn($string) => $string->getValue(), $constantStrings);
return $classType->getClassStringObjectType();
}

return RuleErrorBuilder::message('Configuration params array must have "class" or "__class" key')
Expand All @@ -44,11 +56,11 @@ public function findClasses(ConstantArrayType $config) {
/**
* @phpstan-return list<\PHPStan\Rules\IdentifierRuleError>
*/
public function validateArray(ClassReflection $classReflection, ConstantArrayType $config, Scope $scope): array {
public function validateArray(Type $object, ConstantArrayType $config, Scope $scope): array {
$errors = [];
/** @var ConstantIntegerType|ConstantStringType $key */
foreach ($config->getKeyTypes() as $i => $key) {
/** @var \PHPStan\Type\Type $value */
/** @var Type $value */
$value = $config->getValueTypes()[$i];
// @phpstan-ignore-next-line according to getKeyType() typing it is only possible to have those or ConstantIntType
if (!$key instanceof ConstantStringType) {
Expand Down Expand Up @@ -76,7 +88,7 @@ public function validateArray(ClassReflection $classReflection, ConstantArrayTyp
continue;
}

$errors = array_merge($errors, $this->validateConstructorArgs($classReflection, $value->getConstantArrays()[0], $scope));
$errors = array_merge($errors, $this->validateConstructorArgs($object, $value->getConstantArrays()[0], $scope));
continue;
}

Expand All @@ -86,18 +98,29 @@ public function validateArray(ClassReflection $classReflection, ConstantArrayTyp
continue;
}

if (!$classReflection->hasProperty($propertyName)) {
$typeResult = $this->ruleLevelHelper->findTypeToCheck(
$scope,
new TypeExpr($object), // @phpstan-ignore-line @ondrejmirtes said that I can use that method
sprintf('Access to property $%s on an unknown class %%s.', SprintfHelper::escapeFormatString($propertyName)), // @phpstan-ignore-line @ondrejmirtes said that I can use that method
static fn(Type $type): bool => $type->canAccessProperties()->yes() && $type->hasProperty($propertyName)->yes(),
);
$objectType = $typeResult->getType();
if ($objectType instanceof ErrorType) {
return $typeResult->getUnknownClassErrors();
}

if (!$objectType->canAccessProperties()->yes() || !$objectType->hasProperty($propertyName)->yes()) {
$errors[] = RuleErrorBuilder::message(sprintf(
"The config for %s is wrong: the property %s doesn't exists",
$classReflection->getName(),
$objectType->describe(VerbosityLevel::typeOnly()),
$propertyName,
))
->identifier('property.notFound')
->build();
continue;
}

$property = $classReflection->getProperty($propertyName, $scope);
$property = $objectType->getProperty($propertyName, $scope);
if (!$property->isPublic()) {
$errors[] = RuleErrorBuilder::message(sprintf(
'Access to %s property %s::$%s.',
Expand Down Expand Up @@ -144,14 +167,14 @@ public function validateArray(ClassReflection $classReflection, ConstantArrayTyp
/**
* @phpstan-return list<\PHPStan\Rules\IdentifierRuleError>
*/
public function validateConstructorArgs(ClassReflection $classReflection, ConstantArrayType $config, Scope $scope): array {
$constructorParams = ParametersAcceptorSelector::selectSingle($classReflection->getConstructor()->getVariants())->getParameters();
/** @var \PHPStan\Type\Type|null $firstKeyType */
$firstKeyType = null;
public function validateConstructorArgs(Type $object, ConstantArrayType $config, Scope $scope): array {
$errors = [];
/** @var ConstantIntegerType|ConstantStringType $key */
/** @var \PhpParser\Node\Arg[] $args */
$args = [];
/** @var Type|null $firstKeyType */
$firstKeyType = null;
foreach ($config->getKeyTypes() as $i => $key) {
/** @var \PHPStan\Type\Type $value */
/** @var Type $value */
$value = $config->getValueTypes()[$i];

if ($firstKeyType === null) {
Expand All @@ -164,65 +187,31 @@ public function validateConstructorArgs(ClassReflection $classReflection, Consta
}

if ($key instanceof ConstantIntegerType) {
if (!isset($constructorParams[$key->getValue()])) {
$errors[] = RuleErrorBuilder::message(sprintf(
'Unknown parameter #%d in call to %s constructor.',
$key->getValue() + 1,
$classReflection->getName(),
))
->identifier('argument.unknown')
->build();
continue;
}

$paramIndex = $key->getValue();
$paramReflection = $constructorParams[$paramIndex];
// @phpstan-ignore-next-line I know about backward compatibility promise
$args[] = new Node\Arg(new TypeExpr($value));
} else {
$paramReflection = null;
$paramIndex = null;
foreach ($constructorParams as $j => $constructorParam) {
if ($constructorParam->getName() === $key->getValue()) {
$paramReflection = $constructorParam;
$paramIndex = $j;
break;
}
}

if ($paramReflection === null) {
$errors[] = RuleErrorBuilder::message(sprintf(
'Unknown parameter $%s in call to %s constructor.',
$key->getValue(),
$classReflection->getName(),
))
->identifier('argument.unknown')
->build();

continue;
}
// @phpstan-ignore-next-line I know about backward compatibility promise
$args[] = new Node\Arg(new TypeExpr($value), false, false, [], new Node\Identifier($key->getValue()));
}
}

/** @var \PHPStan\Reflection\ParameterReflection $paramReflection */
// TODO: prevent direct pass of 'config' param to constructor args (\yii\base\Configurable)
if (!empty($errors)) {
return $errors;
}

$paramType = $paramReflection->getType();
$result = $this->ruleLevelHelper->acceptsWithReason($paramType, $value, $scope->isDeclareStrictTypes());
if (!$result->result) {
$level = VerbosityLevel::getRecommendedLevelByType($paramType, $value);
$errors[] = RuleErrorBuilder::message(sprintf(
'Parameter #%d %s of class %s constructor expects %s, %s given.',
$paramIndex + 1,
$paramReflection->getName(),
$classReflection->getName(),
$paramType->describe($level),
$value->describe($level),
))
->identifier('argument.type')
->acceptsReasonsTip($result->reasons)
->build();
}
$classNamesTypes = [];
foreach ($object->getObjectClassNames() as $className) {
$classNamesTypes[] = new ConstantStringType($className, true);
}

return $errors;
// @phpstan-ignore-next-line I know about backward compatibility promise
$newNode = new Expr\New_(new TypeExpr(TypeCombinator::union(...$classNamesTypes)), $args);

// @phpstan-ignore-next-line I know about backward compatibility promise
$errors = $this->instantiationRule->processNode($newNode, $scope);

// @phpstan-ignore-next-line it does return the correct type, but some internal PHPStan's magic fails here
return array_map([RuleHelper::class, 'removeLine'], $errors);
}

}
Loading

0 comments on commit 855910b

Please sign in to comment.