Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
klimick committed Aug 15, 2023
1 parent 1539518 commit 251f5b5
Show file tree
Hide file tree
Showing 9 changed files with 299 additions and 716 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Psalm\Internal\DataFlow\TaintSink;
use Psalm\Internal\MethodIdentifier;
use Psalm\Internal\Stubs\Generator\StubsGenerator;
use Psalm\Internal\Type\Comparator\CallableTypeComparator;
use Psalm\Internal\Type\Comparator\UnionTypeComparator;
use Psalm\Internal\Type\TemplateInferredTypeReplacer;
use Psalm\Internal\Type\TemplateResult;
Expand Down Expand Up @@ -196,17 +197,34 @@ public static function analyze(
}

$high_order_template_result = null;
$high_order_callable_info = $param
? HighOrderFunctionArgHandler::getCallableArgInfo($context, $arg->value, $statements_analyzer, $param)
: null;
$inferred_first_class_callable_type = null;

if ($param && $high_order_callable_info) {
$high_order_template_result = HighOrderFunctionArgHandler::remapLowerBounds(
$statements_analyzer,
$template_result ?? new TemplateResult([], []),
$high_order_callable_info,
$param->type ?? Type::getMixed(),
);
if (($arg->value instanceof PhpParser\Node\Expr\FuncCall
|| $arg->value instanceof PhpParser\Node\Expr\MethodCall
|| $arg->value instanceof PhpParser\Node\Expr\StaticCall)
&& $param
&& $function_storage = self::getHighOrderFuncStorage($context, $statements_analyzer, $arg->value)
) {
if (!$arg->value->isFirstClassCallable()) {
$high_order_template_result = self::handleHighOrderFuncCallArg(
$statements_analyzer,
$template_result ?? new TemplateResult([], []),
$function_storage,
$param,
);
} else {
$inferred_first_class_callable_type = self::handleFirstClassCallableCallArg(
$statements_analyzer->getCodebase(),
$template_result ?? new TemplateResult([], []),
new TClosure(
'Closure',
$function_storage->params,
$function_storage->return_type,
$function_storage->pure,
),
$param,
);
}
} elseif (($arg->value instanceof PhpParser\Node\Expr\Closure
|| $arg->value instanceof PhpParser\Node\Expr\ArrowFunction)
&& $param
Expand All @@ -227,7 +245,9 @@ public static function analyze(
$was_inside_call = $context->inside_call;
$context->inside_call = true;

if (ExpressionAnalyzer::analyze(
if ($inferred_first_class_callable_type) {
$statements_analyzer->node_data->setType($arg->value, $inferred_first_class_callable_type);
} elseif (ExpressionAnalyzer::analyze(
$statements_analyzer,
$arg->value,
$context,
Expand All @@ -243,16 +263,6 @@ public static function analyze(

$context->inside_call = $was_inside_call;

if ($high_order_callable_info && $high_order_template_result) {
HighOrderFunctionArgHandler::enhanceCallableArgType(
$context,
$arg->value,
$statements_analyzer,
$high_order_callable_info,
$high_order_template_result,
);
}

if (($argument_offset === 0 && $method_id === 'array_filter' && count($args) === 2)
|| ($argument_offset > 0 && $method_id === 'array_map' && count($args) >= 2)
) {
Expand Down Expand Up @@ -306,6 +316,264 @@ public static function analyze(
return null;
}

private static function getHighOrderFuncStorage(
Context $context,
StatementsAnalyzer $statements_analyzer,
PhpParser\Node\Expr\CallLike $function_like_call
): ?FunctionLikeStorage {
$codebase = $statements_analyzer->getCodebase();

try {
if ($function_like_call instanceof PhpParser\Node\Expr\FuncCall) {
$function_id = strtolower((string) $function_like_call->name->getAttribute('resolvedName'));

if (empty($function_id)) {
return null;
}

if ($codebase->functions->dynamic_storage_provider->has($function_id)) {
return $codebase->functions->dynamic_storage_provider->getFunctionStorage(
$function_like_call,
$statements_analyzer,
$function_id,
$context,
new CodeLocation($statements_analyzer, $function_like_call),
);
}

return $codebase->functions->getStorage($statements_analyzer, $function_id);
}

if ($function_like_call instanceof PhpParser\Node\Expr\MethodCall &&
$function_like_call->var instanceof PhpParser\Node\Expr\Variable &&
$function_like_call->name instanceof PhpParser\Node\Identifier &&
is_string($function_like_call->var->name) &&
isset($context->vars_in_scope['$' . $function_like_call->var->name])
) {
$lhs_type = $context->vars_in_scope['$' . $function_like_call->var->name]->getSingleAtomic();

if (!$lhs_type instanceof Type\Atomic\TNamedObject) {
return null;
}

$method_id = new MethodIdentifier(
$lhs_type->value,
strtolower((string)$function_like_call->name),
);

return $codebase->methods->getStorage($method_id);
}

if ($function_like_call instanceof PhpParser\Node\Expr\StaticCall &&
$function_like_call->name instanceof PhpParser\Node\Identifier
) {
$method_id = new MethodIdentifier(
(string)$function_like_call->class->getAttribute('resolvedName'),
strtolower($function_like_call->name->name),
);

return $codebase->methods->getStorage($method_id);
}
} catch (UnexpectedValueException $e) {
return null;
}

return null;
}

/**
* Infers type for first-class-callable call.
*/
private static function handleFirstClassCallableCallArg(
Codebase $codebase,
TemplateResult $inferred_template_result,
TClosure $input_first_class_callable,
FunctionLikeParameter $container_callable_param
): ?Union {
$container_callable_atomic = $container_callable_param->type && $container_callable_param->type->isSingle()
? $container_callable_param->type->getSingleAtomic()
: null;

if (!$container_callable_atomic instanceof TClosure && !$container_callable_atomic instanceof TCallable) {
return null;
}

// Has no sense to analyse 'input' function
// when 'container' function has more arguments than 'input'
if (count($container_callable_atomic->params ?? []) < count($input_first_class_callable->params ?? [])) {
return null;
}

$remapped_lower_bounds = [];

// Traverse side by side 'container' params and 'input' params.
// This maps 'input' templates to 'container' templates.
//
// Example:
// 'input' => Closure(C:Bar, D:Bar): array{C:Bar, D:Bar}
// 'container' => Closure(A:Foo, B:Foo): array{A:Foo, B:Foo}
//
// $remapped_lower_bounds will be: [
// 'C' => ['Bar' => ['A:Foo']],
// 'D' => ['Bar' => ['B:Foo']]
// ].
foreach ($container_callable_atomic->params ?? [] as $offset => $container_param) {
if (!isset($input_first_class_callable->params[$offset])) {
continue;
}

$input_param_type = $input_first_class_callable->params[$offset]->type ?? Type::getMixed();
$container_param_type = $container_param->type ?? Type::getMixed();

foreach ($input_param_type->getTemplateTypes() as $input_atomic) {
foreach ($container_param_type->getTemplateTypes() as $container_atomic) {
$inferred_lower_bounds = $inferred_template_result->lower_bounds
[$container_atomic->param_name]
[$container_atomic->defining_class] ?? [];

foreach ($inferred_lower_bounds as $lower_bound) {
// Check template constraint of input first-class-callable.
// Correct type cannot be inferred if constraint check failed.
if (!$codebase->isTypeContainedByType($lower_bound->type, $input_atomic->as)) {
return null;
}
}

$remapped_lower_bounds
[$input_atomic->param_name]
[$input_atomic->defining_class] = new Union([$container_atomic]);
}
}
}

// Turns Closure(C:Bar, D:Bar): array{C:Bar, D:Bar}
// to Closure(A:Foo, B:Foo): array{A:Foo, B:Foo}
$remapped_first_class_callable = TemplateInferredTypeReplacer::replace(
new Union([$input_first_class_callable]),
new TemplateResult($inferred_template_result->template_types, $remapped_lower_bounds),
$codebase,
);

// Turns Closure(A:Foo, B:Foo): array{A:Foo, B:Foo}
// to fully inferred Closure (thanks to $inferred_template_result)
return TemplateInferredTypeReplacer::replace(
$remapped_first_class_callable,
$inferred_template_result,
$codebase,
);
}

/**
* Compiles TemplateResult for high-order functions ($func_call)
* by previous template args ($inferred_template_result).
*
* It's need for proper template replacement:
*
* ```
* * template T
* * return Closure(T): T
* function id(): Closure { ... }
*
* * template A
* * template B
* *
* * param list<A> $_items
* * param callable(A): B $_ab
* * return list<B>
* function map(array $items, callable $ab): array { ... }
*
* // list<int>
* $numbers = [1, 2, 3];
*
* $result = map($numbers, id());
* // $result is list<int> because template T of id() was inferred by previous arg.
* ```
*/
private static function handleHighOrderFuncCallArg(
StatementsAnalyzer $statements_analyzer,
TemplateResult $inferred_template_result,
FunctionLikeStorage $storage,
FunctionLikeParameter $actual_func_param
): ?TemplateResult {
$codebase = $statements_analyzer->getCodebase();

$input_hof_atomic = $storage->return_type && $storage->return_type->isSingle()
? $storage->return_type->getSingleAtomic()
: null;

// Try upcast invokable to callable type.
if ($input_hof_atomic instanceof Type\Atomic\TNamedObject &&
$input_hof_atomic->value !== 'Closure' &&
$codebase->classExists($input_hof_atomic->value)
) {
$callable_from_invokable = CallableTypeComparator::getCallableFromAtomic(
$codebase,
$input_hof_atomic,
);

if ($callable_from_invokable) {
$invoke_id = new MethodIdentifier($input_hof_atomic->value, '__invoke');
$declaring_invoke_id = $codebase->methods->getDeclaringMethodId($invoke_id);

$storage = $codebase->methods->getStorage($declaring_invoke_id ?? $invoke_id);
$input_hof_atomic = $callable_from_invokable;
}
}

if (!$input_hof_atomic instanceof TClosure && !$input_hof_atomic instanceof TCallable) {
return null;
}

$container_hof_atomic = $actual_func_param->type && $actual_func_param->type->isSingle()
? $actual_func_param->type->getSingleAtomic()
: null;

if (!$container_hof_atomic instanceof TClosure && !$container_hof_atomic instanceof TCallable) {
return null;
}

$replaced_container_hof_atomic = new Union([$container_hof_atomic]);

// Replaces all input args in container function.
//
// For example:
// The map function expects callable(A):B as second param
// We know that previous arg type is list<int> where the int is the A template.
// Then we can replace callable(A): B to callable(int):B using $inferred_template_result.
$replaced_container_hof_atomic = TemplateInferredTypeReplacer::replace(
$replaced_container_hof_atomic,
$inferred_template_result,
$codebase,
);

/** @var TClosure|TCallable $container_hof_atomic */
$container_hof_atomic = $replaced_container_hof_atomic->getSingleAtomic();
$high_order_template_result = new TemplateResult($storage->template_types ?: [], []);

// We can replace each templated param for the input function.
// Example:
// map($numbers, id());
// We know that map expects callable(int):B because the $numbers is list<int>.
// We know that id() returns callable(T):T.
// Then we can replace templated params sequentially using the expected callable(int):B.
foreach ($input_hof_atomic->params ?? [] as $offset => $actual_func_param) {
if ($actual_func_param->type &&
$actual_func_param->type->getTemplateTypes() &&
isset($container_hof_atomic->params[$offset])
) {
TemplateStandinTypeReplacer::fillTemplateResult(
$actual_func_param->type,
$high_order_template_result,
$codebase,
null,
$container_hof_atomic->params[$offset]->type,
);
}
}

return $high_order_template_result;
}

private static function handleArrayMapFilterArrayArg(
StatementsAnalyzer $statements_analyzer,
string $method_id,
Expand Down

0 comments on commit 251f5b5

Please sign in to comment.