mirror of
https://github.com/danog/psalm.git
synced 2024-12-15 02:47:02 +01:00
7138678c63
Previously Psalm would assume that any variable it sees in the arrow function body is defined (and mixed, if it's not available in the outer scope). This prevented undefined variable detection. Dropping that assumption allows it to work. Fixes vimeo/psalm#5331
338 lines
12 KiB
PHP
338 lines
12 KiB
PHP
<?php
|
|
namespace Psalm\Internal\Analyzer;
|
|
|
|
use PhpParser;
|
|
use Psalm\CodeLocation;
|
|
use Psalm\Context;
|
|
use Psalm\Issue\DuplicateParam;
|
|
use Psalm\Issue\PossiblyUndefinedVariable;
|
|
use Psalm\Issue\UndefinedVariable;
|
|
use Psalm\IssueBuffer;
|
|
use Psalm\Internal\DataFlow\DataFlowNode;
|
|
use Psalm\Type;
|
|
use Psalm\Type\Atomic\TNamedObject;
|
|
use function strpos;
|
|
use function is_string;
|
|
use function in_array;
|
|
use function strtolower;
|
|
use function array_map;
|
|
use function preg_match;
|
|
|
|
/**
|
|
* @internal
|
|
* @extends FunctionLikeAnalyzer<PhpParser\Node\Expr\Closure|PhpParser\Node\Expr\ArrowFunction>
|
|
*/
|
|
class ClosureAnalyzer extends FunctionLikeAnalyzer
|
|
{
|
|
/**
|
|
* @param PhpParser\Node\Expr\Closure|PhpParser\Node\Expr\ArrowFunction $function
|
|
*/
|
|
public function __construct(PhpParser\Node\FunctionLike $function, SourceAnalyzer $source)
|
|
{
|
|
$codebase = $source->getCodebase();
|
|
|
|
$function_id = \strtolower($source->getFilePath())
|
|
. ':' . $function->getLine()
|
|
. ':' . (int)$function->getAttribute('startFilePos')
|
|
. ':-:closure';
|
|
|
|
$storage = $codebase->getClosureStorage($source->getFilePath(), $function_id);
|
|
|
|
parent::__construct($function, $source, $storage);
|
|
}
|
|
|
|
public function getTemplateTypeMap(): ?array
|
|
{
|
|
return $this->source->getTemplateTypeMap();
|
|
}
|
|
|
|
/**
|
|
* @return non-empty-lowercase-string
|
|
*/
|
|
public function getClosureId(): string
|
|
{
|
|
return strtolower($this->getFilePath())
|
|
. ':' . $this->function->getLine()
|
|
. ':' . (int)$this->function->getAttribute('startFilePos')
|
|
. ':-:closure';
|
|
}
|
|
|
|
/**
|
|
* @param PhpParser\Node\Expr\Closure|PhpParser\Node\Expr\ArrowFunction $stmt
|
|
*/
|
|
public static function analyzeExpression(
|
|
StatementsAnalyzer $statements_analyzer,
|
|
PhpParser\Node\FunctionLike $stmt,
|
|
Context $context
|
|
) : bool {
|
|
$closure_analyzer = new ClosureAnalyzer($stmt, $statements_analyzer);
|
|
|
|
if ($stmt instanceof PhpParser\Node\Expr\Closure
|
|
&& self::analyzeClosureUses($statements_analyzer, $stmt, $context) === false
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
$use_context = new Context($context->self);
|
|
|
|
$codebase = $statements_analyzer->getCodebase();
|
|
|
|
if (!$statements_analyzer->isStatic()) {
|
|
if ($context->collect_mutations &&
|
|
$context->self &&
|
|
$codebase->classExtends(
|
|
$context->self,
|
|
(string)$statements_analyzer->getFQCLN()
|
|
)
|
|
) {
|
|
/** @psalm-suppress PossiblyUndefinedStringArrayOffset */
|
|
$use_context->vars_in_scope['$this'] = clone $context->vars_in_scope['$this'];
|
|
} elseif ($context->self) {
|
|
$this_atomic = new TNamedObject($context->self);
|
|
$this_atomic->was_static = true;
|
|
|
|
$use_context->vars_in_scope['$this'] = new Type\Union([$this_atomic]);
|
|
}
|
|
}
|
|
|
|
foreach ($context->vars_in_scope as $var => $type) {
|
|
if (strpos($var, '$this->') === 0) {
|
|
$use_context->vars_in_scope[$var] = clone $type;
|
|
}
|
|
}
|
|
|
|
if ($context->self) {
|
|
$self_class_storage = $codebase->classlike_storage_provider->get($context->self);
|
|
|
|
ClassAnalyzer::addContextProperties(
|
|
$statements_analyzer,
|
|
$self_class_storage,
|
|
$use_context,
|
|
$context->self,
|
|
$statements_analyzer->getParentFQCLN()
|
|
);
|
|
}
|
|
|
|
foreach ($context->vars_possibly_in_scope as $var => $_) {
|
|
if (strpos($var, '$this->') === 0) {
|
|
$use_context->vars_possibly_in_scope[$var] = true;
|
|
}
|
|
}
|
|
|
|
if ($stmt instanceof PhpParser\Node\Expr\Closure) {
|
|
foreach ($stmt->uses as $use) {
|
|
if (!is_string($use->var->name)) {
|
|
continue;
|
|
}
|
|
|
|
$use_var_id = '$' . $use->var->name;
|
|
|
|
// insert the ref into the current context if passed by ref, as whatever we're passing
|
|
// the closure to could execute it straight away.
|
|
if (!$context->hasVariable($use_var_id) && $use->byRef) {
|
|
$context->vars_in_scope[$use_var_id] = Type::getMixed();
|
|
}
|
|
|
|
if ($statements_analyzer->data_flow_graph instanceof \Psalm\Internal\Codebase\VariableUseGraph
|
|
&& $context->hasVariable($use_var_id)
|
|
) {
|
|
$parent_nodes = $context->vars_in_scope[$use_var_id]->parent_nodes;
|
|
|
|
foreach ($parent_nodes as $parent_node) {
|
|
$statements_analyzer->data_flow_graph->addPath(
|
|
$parent_node,
|
|
new DataFlowNode('closure-use', 'closure use', null),
|
|
'closure-use'
|
|
);
|
|
}
|
|
}
|
|
|
|
$use_context->vars_in_scope[$use_var_id] =
|
|
$context->hasVariable($use_var_id) && !$use->byRef
|
|
? clone $context->vars_in_scope[$use_var_id]
|
|
: Type::getMixed();
|
|
|
|
if ($use->byRef) {
|
|
$use_context->vars_in_scope[$use_var_id]->by_ref = true;
|
|
}
|
|
|
|
$use_context->vars_possibly_in_scope[$use_var_id] = true;
|
|
|
|
foreach ($context->vars_in_scope as $var_id => $type) {
|
|
if (preg_match('/^\$' . $use->var->name . '[\[\-]/', $var_id)) {
|
|
$use_context->vars_in_scope[$var_id] = clone $type;
|
|
$use_context->vars_possibly_in_scope[$var_id] = true;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
$traverser = new PhpParser\NodeTraverser;
|
|
|
|
$short_closure_visitor = new \Psalm\Internal\PhpVisitor\ShortClosureVisitor();
|
|
|
|
$traverser->addVisitor($short_closure_visitor);
|
|
$traverser->traverse($stmt->getStmts());
|
|
|
|
foreach ($short_closure_visitor->getUsedVariables() as $use_var_id => $_) {
|
|
if ($context->hasVariable($use_var_id)) {
|
|
$use_context->vars_in_scope[$use_var_id] = clone $context->vars_in_scope[$use_var_id];
|
|
|
|
if ($statements_analyzer->data_flow_graph instanceof \Psalm\Internal\Codebase\VariableUseGraph) {
|
|
$parent_nodes = $context->vars_in_scope[$use_var_id]->parent_nodes;
|
|
|
|
foreach ($parent_nodes as $parent_node) {
|
|
$statements_analyzer->data_flow_graph->addPath(
|
|
$parent_node,
|
|
new DataFlowNode('closure-use', 'closure use', null),
|
|
'closure-use'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
$use_context->vars_possibly_in_scope[$use_var_id] = true;
|
|
}
|
|
}
|
|
|
|
$use_context->calling_method_id = $context->calling_method_id;
|
|
|
|
$closure_analyzer->analyze($use_context, $statements_analyzer->node_data, $context, false);
|
|
|
|
if ($closure_analyzer->inferred_impure
|
|
&& $statements_analyzer->getSource() instanceof \Psalm\Internal\Analyzer\FunctionLikeAnalyzer
|
|
) {
|
|
$statements_analyzer->getSource()->inferred_impure = true;
|
|
}
|
|
|
|
if ($closure_analyzer->inferred_has_mutation
|
|
&& $statements_analyzer->getSource() instanceof \Psalm\Internal\Analyzer\FunctionLikeAnalyzer
|
|
) {
|
|
$statements_analyzer->getSource()->inferred_has_mutation = true;
|
|
}
|
|
|
|
if (!$statements_analyzer->node_data->getType($stmt)) {
|
|
$statements_analyzer->node_data->setType($stmt, Type::getClosure());
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @return false|null
|
|
*/
|
|
public static function analyzeClosureUses(
|
|
StatementsAnalyzer $statements_analyzer,
|
|
PhpParser\Node\Expr\Closure $stmt,
|
|
Context $context
|
|
): ?bool {
|
|
$param_names = array_map(
|
|
function (PhpParser\Node\Param $p) : string {
|
|
if (!$p->var instanceof PhpParser\Node\Expr\Variable
|
|
|| !is_string($p->var->name)
|
|
) {
|
|
return '';
|
|
}
|
|
return $p->var->name;
|
|
},
|
|
$stmt->params
|
|
);
|
|
|
|
foreach ($stmt->uses as $use) {
|
|
if (!is_string($use->var->name)) {
|
|
continue;
|
|
}
|
|
|
|
$use_var_id = '$' . $use->var->name;
|
|
|
|
if (in_array($use->var->name, $param_names)) {
|
|
if (IssueBuffer::accepts(
|
|
new DuplicateParam(
|
|
'Closure use duplicates param name ' . $use_var_id,
|
|
new CodeLocation($statements_analyzer->getSource(), $use->var)
|
|
),
|
|
$statements_analyzer->getSuppressedIssues()
|
|
)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (!$context->hasVariable($use_var_id)) {
|
|
if ($use_var_id === '$argv' || $use_var_id === '$argc') {
|
|
continue;
|
|
}
|
|
|
|
if ($use->byRef) {
|
|
$context->vars_in_scope[$use_var_id] = Type::getMixed();
|
|
$context->vars_possibly_in_scope[$use_var_id] = true;
|
|
|
|
if (!$statements_analyzer->hasVariable($use_var_id)) {
|
|
$statements_analyzer->registerVariable(
|
|
$use_var_id,
|
|
new CodeLocation($statements_analyzer, $use->var),
|
|
null
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
if (!isset($context->vars_possibly_in_scope[$use_var_id])) {
|
|
if ($context->check_variables) {
|
|
if (IssueBuffer::accepts(
|
|
new UndefinedVariable(
|
|
'Cannot find referenced variable ' . $use_var_id,
|
|
new CodeLocation($statements_analyzer->getSource(), $use->var)
|
|
),
|
|
$statements_analyzer->getSuppressedIssues()
|
|
)) {
|
|
return false;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
$first_appearance = $statements_analyzer->getFirstAppearance($use_var_id);
|
|
|
|
if ($first_appearance) {
|
|
if (IssueBuffer::accepts(
|
|
new PossiblyUndefinedVariable(
|
|
'Possibly undefined variable ' . $use_var_id . ', first seen on line ' .
|
|
$first_appearance->getLineNumber(),
|
|
new CodeLocation($statements_analyzer->getSource(), $use->var)
|
|
),
|
|
$statements_analyzer->getSuppressedIssues()
|
|
)) {
|
|
return false;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($context->check_variables) {
|
|
if (IssueBuffer::accepts(
|
|
new UndefinedVariable(
|
|
'Cannot find referenced variable ' . $use_var_id,
|
|
new CodeLocation($statements_analyzer->getSource(), $use->var)
|
|
),
|
|
$statements_analyzer->getSuppressedIssues()
|
|
)) {
|
|
return false;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
} elseif ($use->byRef) {
|
|
$new_type = Type::getMixed();
|
|
$new_type->parent_nodes = $context->vars_in_scope[$use_var_id]->parent_nodes;
|
|
|
|
$context->remove($use_var_id);
|
|
|
|
$context->vars_in_scope[$use_var_id] = $new_type;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|