1
0
mirror of https://github.com/danog/psalm.git synced 2024-11-26 20:34:47 +01:00

Added support of asserting properties of objects out of scope

This commit is contained in:
Aleksandr Zhuravlev 2021-10-17 21:29:25 +13:00
parent f9f9167d74
commit b664850cdc
5 changed files with 473 additions and 24 deletions

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="dev-master@f62c76a334982eae94741b966ea886f0b15e4293">
<files psalm-version="dev-master@50ec62ffd8a4b46d48835abb007d4bdd4da6c4c9">
<file src="examples/TemplateChecker.php">
<PossiblyUndefinedIntArrayOffset occurrences="2">
<code>$comment_block-&gt;tags['variablesfrom'][0]</code>
@ -88,7 +88,8 @@
</InvalidPropertyAssignmentValue>
</file>
<file src="src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php">
<PossiblyUndefinedIntArrayOffset occurrences="33">
<PossiblyUndefinedIntArrayOffset occurrences="34">
<code>$assertion-&gt;rule[0]</code>
<code>$assertion-&gt;rule[0]</code>
<code>$assertion-&gt;rule[0]</code>
<code>$assertion-&gt;rule[0]</code>

View File

@ -19,8 +19,11 @@ use Psalm\FileSource;
use Psalm\Internal\Analyzer\ClassLikeAnalyzer;
use Psalm\Internal\Analyzer\ClassLikeNameOptions;
use Psalm\Internal\Analyzer\StatementsAnalyzer;
use Psalm\Internal\Provider\ClassLikeStorageProvider;
use Psalm\Internal\Provider\NodeDataProvider;
use Psalm\Internal\Type\Comparator\UnionTypeComparator;
use Psalm\Issue\DocblockTypeContradiction;
use Psalm\Issue\InvalidDocblock;
use Psalm\Issue\RedundantCondition;
use Psalm\Issue\RedundantConditionGivenDocblockType;
use Psalm\Issue\RedundantIdentityWithTrue;
@ -28,14 +31,21 @@ use Psalm\Issue\TypeDoesNotContainNull;
use Psalm\Issue\TypeDoesNotContainType;
use Psalm\Issue\UnevaluatedCode;
use Psalm\IssueBuffer;
use Psalm\Storage\PropertyStorage;
use Psalm\Type;
use Psalm\Type\Atomic\TNamedObject;
use UnexpectedValueException;
use function array_key_exists;
use function assert;
use function count;
use function explode;
use function in_array;
use function is_callable;
use function is_int;
use function is_numeric;
use function sprintf;
use function str_replace;
use function strpos;
use function strtolower;
use function substr;
@ -943,7 +953,17 @@ class AssertionFinder
if ($var_name) {
$if_types[$var_name] = [[$assertion->rule[0][0]]];
}
} elseif ($assertion->var_id === '$this' && $expr instanceof PhpParser\Node\Expr\MethodCall) {
} elseif ($assertion->var_id === '$this') {
if (!$expr instanceof PhpParser\Node\Expr\MethodCall) {
IssueBuffer::add(
new InvalidDocblock(
'Assertion of $this can be done only on method of a class',
new CodeLocation($source, $expr)
)
);
continue;
}
$var_id = ExpressionIdentifier::getArrayVarId(
$expr->var,
$this_class_name,
@ -953,17 +973,74 @@ class AssertionFinder
if ($var_id) {
$if_types[$var_id] = [[$assertion->rule[0][0]]];
}
} elseif (\is_string($assertion->var_id)
&& (
$expr instanceof PhpParser\Node\Expr\MethodCall
|| $expr instanceof PhpParser\Node\Expr\StaticCall
)
) {
$var_id = $assertion->var_id;
if (strpos($var_id, 'self::') === 0) {
$var_id = $this_class_name . '::' . substr($var_id, 6);
} elseif (\is_string($assertion->var_id)) {
$is_function = substr($assertion->var_id, -2) === '()';
$exploded_id = explode('->', $assertion->var_id);
$var_id = $exploded_id[0] ?? null;
$property = $exploded_id[1] ?? null;
if (is_numeric($var_id) && null !== $property && !$is_function) {
$args = $expr->getArgs();
if (!array_key_exists($var_id, $args)) {
IssueBuffer::accepts(
new InvalidDocblock(
'Variable '.$var_id.' is not an argument so cannot be asserted',
new CodeLocation($source, $expr)
)
);
continue;
}
$arg_value = $args[$var_id]->value;
assert($arg_value instanceof PhpParser\Node\Expr\Variable);
$arg_var_id = ExpressionIdentifier::getArrayVarId($arg_value, null, $source);
if (null === $arg_var_id) {
IssueBuffer::accepts(
new InvalidDocblock(
'Variable being asserted as argument ' . ($var_id+1) . ' cannot be found
in local scope',
new CodeLocation($source, $expr)
)
);
continue;
}
if (count($exploded_id) === 2) {
$failedMessage = self::isPropertyImmutableOnArgument(
$property,
$source->getNodeTypeProvider(),
$source->getCodebase()->classlike_storage_provider,
$arg_value
);
if (null !== $failedMessage) {
IssueBuffer::accepts(
new InvalidDocblock($failedMessage, new CodeLocation($source, $expr))
);
continue;
}
}
$assertion_var_id = str_replace($var_id, $arg_var_id, $assertion->var_id);
} elseif (!$expr instanceof PhpParser\Node\Expr\FuncCall) {
$assertion_var_id = $assertion->var_id;
if (strpos($assertion_var_id, 'self::') === 0) {
$assertion_var_id = $this_class_name.'::'.substr($assertion_var_id, 6);
}
} else {
IssueBuffer::accepts(
new InvalidDocblock(
sprintf('Assertion of variable "%s" cannot be recognized', $assertion->var_id),
new CodeLocation($source, $expr)
)
);
continue;
}
$if_types[$var_id] = [[$assertion->rule[0][0]]];
$if_types[$assertion_var_id] = [[$assertion->rule[0][0]]];
}
if ($if_types) {
@ -1030,17 +1107,78 @@ class AssertionFinder
$if_types[$var_id] = [['!' . $assertion->rule[0][0]]];
}
}
} elseif (\is_string($assertion->var_id)
&& (
$expr instanceof PhpParser\Node\Expr\MethodCall
|| $expr instanceof PhpParser\Node\Expr\StaticCall
)
) {
$var_id = $assertion->var_id;
if (strpos($var_id, 'self::') === 0) {
$var_id = $this_class_name . '::' . substr($var_id, 6);
} elseif (\is_string($assertion->var_id)) {
$is_function = substr($assertion->var_id, -2) === '()';
$exploded_id = explode('->', $assertion->var_id);
$var_id = $exploded_id[0] ?? null;
$property = $exploded_id[1] ?? null;
if (is_numeric($var_id) && null !== $property && !$is_function) {
$args = $expr->getArgs();
if (!array_key_exists($var_id, $args)) {
IssueBuffer::accepts(
new InvalidDocblock(
'Variable '.$var_id.' is not an argument so cannot be asserted',
new CodeLocation($source, $expr)
)
);
continue;
}
/** @var PhpParser\Node\Expr\Variable $arg_value */
$arg_value = $args[$var_id]->value;
$arg_var_id = ExpressionIdentifier::getArrayVarId($arg_value, null, $source);
if (null === $arg_var_id) {
IssueBuffer::accepts(
new InvalidDocblock(
'Variable being asserted as argument ' . ($var_id+1) . ' cannot be found
in local scope',
new CodeLocation($source, $expr)
)
);
continue;
}
if (count($exploded_id) === 2) {
$failedMessage = self::isPropertyImmutableOnArgument(
$property,
$source->getNodeTypeProvider(),
$source->getCodebase()->classlike_storage_provider,
$arg_value
);
if (null !== $failedMessage) {
IssueBuffer::accepts(
new InvalidDocblock($failedMessage, new CodeLocation($source, $expr))
);
continue;
}
}
if ('!' === $assertion->rule[0][0][0]) {
$rule = substr($assertion->rule[0][0], 1);
} else {
$rule = '!' . $assertion->rule[0][0];
}
$assertion_var_id = str_replace($var_id, $arg_var_id, $assertion->var_id);
$if_types[$assertion_var_id] = [[$rule]];
} elseif (!$expr instanceof PhpParser\Node\Expr\FuncCall) {
$var_id = $assertion->var_id;
if (strpos($var_id, 'self::') === 0) {
$var_id = $this_class_name.'::'.substr($var_id, 6);
}
$if_types[$var_id] = [['!'.$assertion->rule[0][0]]];
} else {
IssueBuffer::accepts(
new InvalidDocblock(
sprintf('Assertion of variable "%s" cannot be recognized', $assertion->var_id),
new CodeLocation($source, $expr)
)
);
}
$if_types[$var_id] = [['!' . $assertion->rule[0][0]]];
}
if ($if_types) {
@ -3978,4 +4116,46 @@ class AssertionFinder
}
}
}
public static function isPropertyImmutableOnArgument(
string $property,
NodeDataProvider $node_provider,
ClassLikeStorageProvider $class_provider,
PhpParser\Node\Expr\Variable $arg_expr
): ?string {
$type = $node_provider->getType($arg_expr);
/** @var string $name */
$name = $arg_expr->name;
if (null === $type) {
return 'Cannot resolve a type of variable ' . $name;
}
foreach ($type->getAtomicTypes() as $type) {
if (!$type instanceof TNamedObject) {
return 'Variable ' . $name . ' is not an object so assertion cannot be applied';
}
$class_definition = $class_provider->get($type->value);
$property_definition = $class_definition->properties[$property] ?? null;
if (!$property_definition instanceof PropertyStorage) {
return sprintf(
'Property %s is not defined on variable %s so assertion cannot be applied',
$property,
$name
);
}
if (!$property_definition->readonly) {
return sprintf(
'Property %s of variable %s is not read-only/immutable so assertion cannot be applied',
$property,
$name
);
}
}
return null;
}
}

View File

@ -14,6 +14,7 @@ use Psalm\Internal\Type\TemplateResult;
use Psalm\Internal\Type\TemplateStandinTypeReplacer;
use Psalm\Issue\ArgumentTypeCoercion;
use Psalm\Issue\InvalidArgument;
use Psalm\Issue\InvalidDocblock;
use Psalm\Issue\InvalidScalarArgument;
use Psalm\Issue\MixedArgumentTypeCoercion;
use Psalm\Issue\UndefinedFunction;
@ -30,9 +31,11 @@ use function array_map;
use function array_merge;
use function array_unique;
use function count;
use function explode;
use function implode;
use function in_array;
use function is_int;
use function is_numeric;
use function preg_match;
use function preg_replace;
use function str_replace;
@ -617,7 +620,6 @@ class CallAnalyzer
/**
* @param PhpParser\Node\Identifier|PhpParser\Node\Name $expr
* @param \Psalm\Storage\Assertion[] $assertions
* @param string $thisName
* @param list<PhpParser\Node\Arg> $args
* @param array<string, array<string, non-empty-list<TemplateBound>>> $inferred_lower_bounds,
*
@ -663,6 +665,65 @@ class CallAnalyzer
$assertion_var_id = $assertion->var_id;
} elseif (isset($context->vars_in_scope[$assertion->var_id])) {
$assertion_var_id = $assertion->var_id;
} elseif (strpos($assertion->var_id, '->') !== false) {
$exploded = explode('->', $assertion->var_id);
if (count($exploded) < 2) {
IssueBuffer::add(
new InvalidDocblock(
'Assert notation is malformed',
new CodeLocation($statements_analyzer, $expr)
)
);
continue;
}
[$var_id, $property] = $exploded;
$var_id = is_numeric($var_id) ? (int) $var_id : $var_id;
if (!is_int($var_id) || !isset($args[$var_id])) {
IssueBuffer::add(
new InvalidDocblock(
'Variable ' . $var_id . ' is not an argument so cannot be asserted',
new CodeLocation($statements_analyzer, $expr)
)
);
continue;
}
/** @var PhpParser\Node\Expr\Variable $arg_value */
$arg_value = $args[$var_id]->value;
$arg_var_id = ExpressionIdentifier::getArrayVarId($arg_value, null, $statements_analyzer);
if (!$arg_var_id) {
IssueBuffer::add(
new InvalidDocblock(
'Variable being asserted as argument ' . ($var_id+1) . ' cannot be found in local scope',
new CodeLocation($statements_analyzer, $expr)
)
);
continue;
}
if (count($exploded) === 2) {
$failedMessage = AssertionFinder::isPropertyImmutableOnArgument(
$property,
$statements_analyzer->getNodeTypeProvider(),
$statements_analyzer->getCodebase()->classlike_storage_provider,
$arg_value
);
if (null !== $failedMessage) {
IssueBuffer::add(
new InvalidDocblock($failedMessage, new CodeLocation($statements_analyzer, $expr))
);
continue;
}
}
$assertion_var_id = str_replace((string) $var_id, $arg_var_id, $assertion->var_id);
}
$codebase = $statements_analyzer->getCodebase();

View File

@ -31,6 +31,7 @@ use function explode;
use function preg_match;
use function preg_replace;
use function preg_split;
use function str_replace;
use function strlen;
use function strpos;
use function strtolower;
@ -1136,6 +1137,14 @@ class FunctionLikeDocblockScanner
);
continue 2;
}
if (strpos($assertion['param_name'], $param->name.'->') === 0) {
$storage->assertions[] = new \Psalm\Storage\Assertion(
str_replace($param->name, (string) $i, $assertion['param_name']),
[$assertion_type_parts]
);
continue 2;
}
}
$storage->assertions[] = new \Psalm\Storage\Assertion(
@ -1175,6 +1184,14 @@ class FunctionLikeDocblockScanner
);
continue 2;
}
if (strpos($assertion['param_name'], $param->name.'->') === 0) {
$storage->if_true_assertions[] = new \Psalm\Storage\Assertion(
str_replace($param->name, (string) $i, $assertion['param_name']),
[$assertion_type_parts]
);
continue 2;
}
}
$storage->if_true_assertions[] = new \Psalm\Storage\Assertion(
@ -1214,6 +1231,14 @@ class FunctionLikeDocblockScanner
);
continue 2;
}
if (strpos($assertion['param_name'], $param->name.'->') === 0) {
$storage->if_false_assertions[] = new \Psalm\Storage\Assertion(
str_replace($param->name, (string) $i, $assertion['param_name']),
[$assertion_type_parts]
);
continue 2;
}
}
$storage->if_false_assertions[] = new \Psalm\Storage\Assertion(

View File

@ -1533,6 +1533,127 @@ class AssertAnnotationTest extends TestCase
[],
'7.4'
],
'onPropertyOfImmutableArgument' => [
'<?php
/** @psalm-immutable */
class Aclass {
public ?string $b;
public function __construct(?string $b) {
$this->b = $b;
}
}
/** @psalm-assert !null $item->b */
function c(\Aclass $item): void {
if (null === $item->b) {
throw new \InvalidArgumentException("");
}
}
/** @var \Aclass $a */
c($a);
echo strlen($a->b);',
],
'inTrueOnPropertyOfImmutableArgument' => [
'<?php
/** @psalm-immutable */
class A {
public ?int $b;
public function __construct(?int $b) {
$this->b = $b;
}
}
/** @psalm-assert-if-true !null $item->b */
function c(A $item): bool {
return null !== $item->b;
}
function check(int $a): void {}
/** @var A $a */
if (c($a)) {
check($a->b);
}',
],
'inFalseOnPropertyOfAImmutableArgument' => [
'<?php
/** @psalm-immutable */
class A {
public ?int $b;
public function __construct(?int $b) {
$this->b = $b;
}
}
/** @psalm-assert-if-false !null $item->b */
function c(A $item): bool {
return null === $item->b;
}
function check(int $a): void {}
/** @var A $a */
if (!c($a)) {
check($a->b);
}',
],
'ifTrueOnNestedPropertyOfArgument' => [
'<?php
class B {
public ?string $c;
public function __construct(?string $c) {
$this->c = $c;
}
}
/** @psalm-immutable */
class Aclass {
public B $b;
public function __construct(B $b) {
$this->b = $b;
}
}
/** @psalm-assert-if-true !null $item->b->c */
function c(\Aclass $item): bool {
return null !== $item->b->c;
}
$a = new \Aclass(new \B(null));
if (c($a)) {
echo strlen($a->b->c);
}',
],
'ifFalseOnNestedPropertyOfArgument' => [
'<?php
class B {
public ?string $c;
public function __construct(?string $c) {
$this->c = $c;
}
}
/** @psalm-immutable */
class Aclass {
public B $b;
public function __construct(B $b) {
$this->b = $b;
}
}
/** @psalm-assert-if-false !null $item->b->c */
function c(\Aclass $item): bool {
return null !== $item->b->c;
}
$a = new \Aclass(new \B(null));
if (!c($a)) {
echo strlen($a->b->c);
}',
],
];
}
@ -1798,6 +1919,67 @@ class AssertAnnotationTest extends TestCase
}',
'error_message' => 'PossiblyNullReference',
],
'onPropertyOfMutableArgument' => [
'<?php
class Aclass {
public ?string $b;
public function __construct(?string $b) {
$this->b = $b;
}
}
/** @psalm-assert !null $item->b */
function c(\Aclass $item): void {
if (null === $item->b) {
throw new \InvalidArgumentException("");
}
}
/** @var \Aclass $a */
c($a);
echo strlen($a->b);',
'error_message' => 'InvalidDocblock',
],
'ifTrueOnPropertyOfMutableArgument' => [
'<?php
class Aclass {
public ?string $b;
public function __construct(?string $b) {
$this->b = $b;
}
}
/** @psalm-assert-if-true !null $item->b */
function c(\Aclass $item): bool {
return null !== $item->b;
}
/** @var \Aclass $a */
if (c($a)) {
echo strlen($a->b);
}',
'error_message' => 'InvalidDocblock',
],
'ifFalseOnPropertyOfMutableArgument' => [
'<?php
class Aclass {
public ?string $b;
public function __construct(?string $b) {
$this->b = $b;
}
}
/** @psalm-assert-if-false !null $item->b */
function c(\Aclass $item): bool {
return null === $item->b;
}
/** @var \Aclass $a */
if (!c($a)) {
echo strlen($a->b);
}',
'error_message' => 'InvalidDocblock',
],
];
}
}