Skip to content

Commit

Permalink
Infer object shape when array or scalar is cast to object
Browse files Browse the repository at this point in the history
Also detect redundant object casts.

Fixes #7916, fixes #7934
  • Loading branch information
theodorejb committed May 9, 2022
1 parent 6f3ceea commit ed82443
Show file tree
Hide file tree
Showing 4 changed files with 101 additions and 15 deletions.
57 changes: 44 additions & 13 deletions src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php
Expand Up @@ -183,22 +183,44 @@ public static function analyze(
}

if ($stmt instanceof PhpParser\Node\Expr\Cast\Object_) {
$was_inside_general_use = $context->inside_general_use;
$context->inside_general_use = true;
if (ExpressionAnalyzer::analyze($statements_analyzer, $stmt->expr, $context) === false) {
$context->inside_general_use = $was_inside_general_use;
$result = self::checkExprGeneralUse($statements_analyzer, $stmt, $context);

if (!$result) {
return false;
}
$context->inside_general_use = $was_inside_general_use;

$type = new Union([new TNamedObject('stdClass')]);
$permissible_atomic_types = [];
$all_permissible = false;

$maybe_type = $statements_analyzer->node_data->getType($stmt->expr);
if ($stmt_expr_type = $statements_analyzer->node_data->getType($stmt->expr)) {
if ($stmt_expr_type->isObjectType()) {
self::handleRedundantCast($stmt_expr_type, $statements_analyzer, $stmt);
}

$all_permissible = true;

foreach ($stmt_expr_type->getAtomicTypes() as $type) {
if ($type instanceof Scalar) {
$objWithProps = new TObjectWithProperties(['scalar' => new Union([$type])]);
$permissible_atomic_types[] = $objWithProps;
} elseif ($type instanceof TKeyedArray) {
$permissible_atomic_types[] = new TObjectWithProperties($type->properties);
} else {
$all_permissible = false;
break;
}
}
}

if ($permissible_atomic_types && $all_permissible) {
$type = TypeCombiner::combine($permissible_atomic_types);
} else {
$type = Type::getObject();
}

if ($statements_analyzer->data_flow_graph instanceof VariableUseGraph
) {
$type->parent_nodes = $maybe_type->parent_nodes ?? [];
$type->parent_nodes = $stmt_expr_type->parent_nodes ?? [];
}

$statements_analyzer->node_data->setType($stmt, $type);
Expand All @@ -207,14 +229,11 @@ public static function analyze(
}

if ($stmt instanceof PhpParser\Node\Expr\Cast\Array_) {
$was_inside_general_use = $context->inside_general_use;
$context->inside_general_use = true;
if (ExpressionAnalyzer::analyze($statements_analyzer, $stmt->expr, $context) === false) {
$context->inside_general_use = $was_inside_general_use;
$result = self::checkExprGeneralUse($statements_analyzer, $stmt, $context);

if (!$result) {
return false;
}
$context->inside_general_use = $was_inside_general_use;

$permissible_atomic_types = [];
$all_permissible = false;
Expand Down Expand Up @@ -457,6 +476,18 @@ public static function castStringAttempt(
return $str_type;
}

private static function checkExprGeneralUse(
StatementsAnalyzer $statements_analyzer,
PhpParser\Node\Expr\Cast $stmt,
Context $context
): bool {
$was_inside_general_use = $context->inside_general_use;
$context->inside_general_use = true;
$retVal = ExpressionAnalyzer::analyze($statements_analyzer, $stmt->expr, $context);
$context->inside_general_use = $was_inside_general_use;
return $retVal;
}

private static function handleRedundantCast(
Union $maybe_type,
StatementsAnalyzer $statements_analyzer,
Expand Down
13 changes: 13 additions & 0 deletions tests/ArgTest.php
Expand Up @@ -702,6 +702,19 @@ function takesObject($_o): void {}
',
'error_message' => 'ArgumentTypeCoercion',
],
'objectRedundantCast' => [
'<?php
function makeObj(): object {
return (object)["a" => 42];
}
function takesObject(object $_o): void {}
takesObject((object)makeObj()); // expected: RedundantCast
',
'error_message' => 'RedundantCast',
],
'MissingMandatoryParamWithNamedParams' => [
'<?php
class User
Expand Down
8 changes: 6 additions & 2 deletions tests/ReturnTypeProvider/GetObjectVarsTest.php
Expand Up @@ -90,10 +90,14 @@ function f(object $p): array {
[],
];

yield 'propertiesOfCastScalar' => [
'<?php $ret = get_object_vars((object)true);',
['$ret' => 'array{scalar: true}'],
];

yield 'propertiesOfPOPO' => [
// todo: fix object cast so that it results in `object{a:1}` instead
'<?php $ret = get_object_vars((object)["a" => 1]);',
['$ret' => 'array<string, mixed>'],
['$ret' => 'array{a: int}'],
];
}
}
38 changes: 38 additions & 0 deletions tests/ReturnTypeTest.php
Expand Up @@ -855,6 +855,33 @@ function map(callable $predicate): callable {
'$res' => 'iterable<int, numeric-string>',
],
],
'infersObjectShapeOfCastScalar' => [
'<?php
function returnsInt(): int {
return 1;
}
$obj = (object)returnsInt();
',
'assertions' => [
'$obj' => 'object{scalar:int}',
],
],
'infersObjectShapeOfCastArray' => [
'<?php
/**
* @return array{a:1}
*/
function returnsArray(): array {
return ["a" => 1];
}
$obj = (object)returnsArray();
',
'assertions' => [
'$obj' => 'object{a:int}',
],
],
'mixedAssignmentWithUnderscore' => [
'<?php
$gen = (function (): Generator {
Expand Down Expand Up @@ -1542,6 +1569,17 @@ function f(): object {
',
'error_message' => 'LessSpecificReturnStatement',
],
'objectCastFromArrayWithMissingKey' => [
'<?php
/** @return object{status: string} */
function foo(): object {
return (object) [
"notstatus" => "failed",
];
}
',
'error_message' => 'InvalidReturnStatement',
],
'lessSpecificImplementedReturnTypeFromTemplatedTraitMethod' => [
'<?php
/** @template T */
Expand Down

0 comments on commit ed82443

Please sign in to comment.