1
0
mirror of https://github.com/danog/psalm.git synced 2024-12-04 10:38:49 +01:00
psalm/src/Psalm/Context.php
2023-10-19 14:00:08 +02:00

859 lines
27 KiB
PHP

<?php
declare(strict_types=1);
namespace Psalm;
use InvalidArgumentException;
use Psalm\Internal\Analyzer\StatementsAnalyzer;
use Psalm\Internal\Clause;
use Psalm\Internal\ReferenceConstraint;
use Psalm\Internal\Scope\CaseScope;
use Psalm\Internal\Scope\FinallyScope;
use Psalm\Internal\Scope\LoopScope;
use Psalm\Internal\Type\AssertionReconciler;
use Psalm\Storage\FunctionLikeStorage;
use Psalm\Type\Atomic\DependentType;
use Psalm\Type\Atomic\TIntRange;
use Psalm\Type\Atomic\TNull;
use Psalm\Type\Union;
use RuntimeException;
use function array_keys;
use function array_search;
use function array_shift;
use function assert;
use function count;
use function in_array;
use function is_int;
use function json_encode;
use function preg_match;
use function preg_quote;
use function preg_replace;
use function str_contains;
use function strpos;
use function strtolower;
use const JSON_THROW_ON_ERROR;
final class Context
{
/**
* @var array<string, Union>
*/
public array $vars_in_scope = [];
/**
* @var array<string, bool>
*/
public array $vars_possibly_in_scope = [];
/**
* Keeps track of how many times a var_in_scope has been referenced. May not be set for all $vars_in_scope.
*
* @var array<string, int<0, max>>
*/
public array $referenced_counts = [];
/**
* Maps references to referenced variables for the current scope.
* With `$b = &$a`, this will contain `['$b' => '$a']`.
*
* All keys and values in this array are guaranteed to be set in $vars_in_scope.
*
* To check if a variable was passed or returned by reference, or
* references an object property or array item, see Union::$by_ref.
*
* @var array<string, string>
*/
public array $references_in_scope = [];
/**
* Set of references to variables in another scope. These references will be marked as used if they are assigned to.
*
* @var array<string, true>
*/
public array $references_to_external_scope = [];
/**
* A set of globals that are referenced somewhere.
*
* @var array<string, true>
*/
public array $referenced_globals = [];
/**
* A set of references that might still be in scope from a scope likely to cause confusion. This applies
* to references set inside a loop or if statement, since it's easy to forget about PHP's weird scope
* rules, and assinging to a reference will change the referenced variable rather than shadowing it.
*
* @var array<string, CodeLocation>
*/
public array $references_possibly_from_confusing_scope = [];
/**
* Whether or not we're inside the conditional of an if/where etc.
*
* This changes whether or not the context is cloned
*/
public bool $inside_conditional = false;
/**
* Whether or not we're inside an isset call
*
* Inside issets Psalm is more lenient about certain things
*/
public bool $inside_isset = false;
/**
* Whether or not we're inside an unset call, where
* we don't care about possibly undefined variables
*/
public bool $inside_unset = false;
/**
* Whether or not we're inside an class_exists call, where
* we don't care about possibly undefined classes
*/
public bool $inside_class_exists = false;
/**
* Whether or not we're inside a function/method call
*/
public bool $inside_call = false;
/**
* Whether or not we're inside any other situation that treats a variable as used
*/
public bool $inside_general_use = false;
/**
* Whether or not we're inside a return expression
*/
public bool $inside_return = false;
/**
* Whether or not we're inside a throw
*/
public bool $inside_throw = false;
/**
* Whether or not we're inside an assignment
*/
public bool $inside_assignment = false;
/**
* Whether or not we're inside a try block.
*/
public bool $inside_try = false;
public ?CodeLocation $include_location = null;
public ?string $parent = null;
public bool $check_classes = true;
public bool $check_variables = true;
public bool $check_methods = true;
public bool $check_consts = true;
public bool $check_functions = true;
/**
* A list of classes checked with class_exists
*
* @var array<lowercase-string,true>
*/
public array $phantom_classes = [];
/**
* A list of files checked with file_exists
*
* @var array<string,bool>
*/
public array $phantom_files = [];
/**
* A list of clauses in Conjunctive Normal Form
*
* @var list<Clause>
*/
public array $clauses = [];
/**
* A list of hashed clauses that have already been factored in
*
* @var list<string|int>
*/
public array $reconciled_expression_clauses = [];
/**
* Whether or not to do a deep analysis and collect mutations to this context
*/
public bool $collect_mutations = false;
/**
* Whether or not to do a deep analysis and collect initializations from private or final methods
*/
public bool $collect_initializations = false;
/**
* Whether or not to do a deep analysis and collect initializations from public non-final methods
*/
public bool $collect_nonprivate_initializations = false;
/**
* Stored to prevent re-analysing methods when checking for initialised properties
*
* @var array<string, bool>|null
*/
public ?array $initialized_methods = null;
/**
* @var array<string, Union>
*/
public array $constants = [];
/**
* Whether or not to track exceptions
*/
public bool $collect_exceptions = false;
/**
* A list of variables that have been referenced in conditionals
*
* @var array<string, bool>
*/
public array $cond_referenced_var_ids = [];
/**
* A list of variables that have been passed by reference (where we know their type)
*
* @var array<string, ReferenceConstraint>
*/
public array $byref_constraints = [];
/**
* A list of vars that have been assigned to
*
* @var array<string, int>
*/
public array $assigned_var_ids = [];
/**
* A list of vars that have been may have been assigned to
*
* @var array<string, bool>
*/
public array $possibly_assigned_var_ids = [];
/**
* A list of classes or interfaces that may have been thrown
*
* @var array<string, array<array-key, CodeLocation>>
*/
public array $possibly_thrown_exceptions = [];
public bool $is_global = false;
/**
* @var array<string, bool>
*/
public array $protected_var_ids = [];
/**
* If we've branched from the main scope, a byte offset for where that branch happened
*/
public ?int $branch_point = null;
/**
* What does break mean in this context?
*
* 'loop' means we're breaking out of a loop,
* 'switch' means we're breaking out of a switch
*
* @var list<'loop'|'switch'>
*/
public array $break_types = [];
public bool $inside_loop = false;
public ?LoopScope $loop_scope = null;
public ?CaseScope $case_scope = null;
public ?FinallyScope $finally_scope = null;
public ?Context $if_body_context = null;
public bool $strict_types = false;
public ?string $calling_function_id = null;
/**
* @var lowercase-string|null
*/
public ?string $calling_method_id = null;
public bool $inside_negation = false;
public bool $ignore_variable_property = false;
public bool $ignore_variable_method = false;
public bool $pure = false;
/**
* @var bool
* Set by @psalm-immutable
*/
public bool $mutation_free = false;
/**
* @var bool
* Set by @psalm-external-mutation-free
*/
public bool $external_mutation_free = false;
public bool $error_suppressing = false;
public bool $has_returned = false;
/**
* @var array<string, true>
*/
public array $parent_remove_vars = [];
/** @internal */
public function __construct(
/**
* @var string|null
* The name of the current class. Null if outside a class.
*/
public ?string $self = null,
) {
}
public function __destruct()
{
$this->case_scope = null;
}
/**
* Updates the parent context, looking at the changes within a block and then applying those changes, where
* necessary, to the parent context
*
* @param bool $has_leaving_statements whether or not the parent scope is abandoned between
* $start_context and $end_context
* @param array<string, bool> $updated_vars
*/
public function update(
Context $start_context,
Context $end_context,
bool $has_leaving_statements,
array $vars_to_update,
array &$updated_vars,
): void {
foreach ($start_context->vars_in_scope as $var_id => $old_type) {
// this is only true if there was some sort of type negation
if (in_array($var_id, $vars_to_update, true)) {
// if we're leaving, we're effectively deleting the possibility of the if types
$new_type = !$has_leaving_statements && $end_context->hasVariable($var_id)
? $end_context->vars_in_scope[$var_id]
: null;
$existing_type = $this->vars_in_scope[$var_id] ?? null;
if (!$existing_type) {
if ($new_type) {
$this->vars_in_scope[$var_id] = $new_type;
$updated_vars[$var_id] = true;
}
continue;
}
// if the type changed within the block of statements, process the replacement
// also never allow ourselves to remove all types from a union
if ((!$new_type || !$old_type->equals($new_type))
&& ($new_type || count($existing_type->getAtomicTypes()) > 1)
) {
$existing_type = $existing_type
->getBuilder()
->substitute($old_type, $new_type);
if ($new_type && $new_type->from_docblock) {
$existing_type = $existing_type->setFromDocblock();
}
$existing_type = $existing_type->freeze();
$updated_vars[$var_id] = true;
}
$this->vars_in_scope[$var_id] = $existing_type;
}
}
}
/**
* Updates the list of possible references from a confusing scope,
* such as a reference created in an if that might later be reused.
*/
public function updateReferencesPossiblyFromConfusingScope(
Context $confusing_scope_context,
StatementsAnalyzer $statements_analyzer,
): void {
$references = $confusing_scope_context->references_in_scope
+ $confusing_scope_context->references_to_external_scope;
foreach ($references as $reference_id => $_) {
if (!isset($this->references_in_scope[$reference_id])
&& !isset($this->references_to_external_scope[$reference_id])
&& $reference_location = $statements_analyzer->getFirstAppearance($reference_id)
) {
$this->references_possibly_from_confusing_scope[$reference_id] = $reference_location;
}
}
$this->references_possibly_from_confusing_scope +=
$confusing_scope_context->references_possibly_from_confusing_scope;
}
/**
* @param array<string, Union> $new_vars_in_scope
* @return array<string, Union>
*/
public function getRedefinedVars(array $new_vars_in_scope, bool $include_new_vars = false): array
{
$redefined_vars = [];
foreach ($this->vars_in_scope as $var_id => $this_type) {
if (!isset($new_vars_in_scope[$var_id])) {
if ($include_new_vars) {
$redefined_vars[$var_id] = $this_type;
}
continue;
}
$new_type = $new_vars_in_scope[$var_id];
if (!$this_type->equals(
$new_type,
true,
!($this_type->propagate_parent_nodes || $new_type->propagate_parent_nodes),
)
) {
$redefined_vars[$var_id] = $this_type;
}
}
return $redefined_vars;
}
/**
* @return list<string>
*/
public static function getNewOrUpdatedVarIds(Context $original_context, Context $new_context): array
{
$redefined_var_ids = [];
foreach ($new_context->vars_in_scope as $var_id => $context_type) {
if (!isset($original_context->vars_in_scope[$var_id])
|| ($original_context->assigned_var_ids[$var_id] ?? 0)
!== ($new_context->assigned_var_ids[$var_id] ?? 0)
|| !$original_context->vars_in_scope[$var_id]->equals($context_type)
) {
$redefined_var_ids[] = $var_id;
}
}
return $redefined_var_ids;
}
public function remove(string $remove_var_id, bool $removeDescendents = true): void
{
if (isset($this->vars_in_scope[$remove_var_id])) {
$existing_type = $this->vars_in_scope[$remove_var_id];
unset($this->vars_in_scope[$remove_var_id]);
if ($removeDescendents) {
$this->removeDescendents($remove_var_id, $existing_type);
}
}
$this->removePossibleReference($remove_var_id);
unset($this->vars_possibly_in_scope[$remove_var_id]);
}
/**
* Remove a variable from the context which might be a reference to another variable, or
* referenced by another variable. Leaves the variable as possibly-in-scope, unlike remove().
*/
public function removePossibleReference(string $remove_var_id): void
{
if (isset($this->referenced_counts[$remove_var_id]) && $this->referenced_counts[$remove_var_id] > 0) {
// If a referenced variable goes out of scope, we need to update the references.
// All of the references to this variable are still references to the same value,
// so we pick the first one and make the rest of the references point to it.
$references = [];
foreach ($this->references_in_scope as $reference => $referenced) {
if ($referenced === $remove_var_id) {
$references[] = $reference;
unset($this->references_in_scope[$reference]);
}
}
assert(!empty($references));
$first_reference = array_shift($references);
if (!empty($references)) {
$this->referenced_counts[$first_reference] = count($references);
foreach ($references as $reference) {
$this->references_in_scope[$reference] = $first_reference;
}
}
}
if (isset($this->references_in_scope[$remove_var_id])) {
$this->decrementReferenceCount($remove_var_id);
}
unset(
$this->vars_in_scope[$remove_var_id],
$this->cond_referenced_var_ids[$remove_var_id],
$this->referenced_counts[$remove_var_id],
$this->references_in_scope[$remove_var_id],
$this->references_to_external_scope[$remove_var_id],
);
}
/**
* Decrement the reference count of the variable that $ref_id is referring to. This needs to
* be done before $ref_id is changed to no longer reference its currently referenced variable,
* for example by unsetting, reassigning to another reference, or being shadowed by a global.
*/
public function decrementReferenceCount(string $ref_id): void
{
if (!isset($this->referenced_counts[$this->references_in_scope[$ref_id]])) {
throw new InvalidArgumentException("$ref_id is not a reference");
}
$reference_count = $this->referenced_counts[$this->references_in_scope[$ref_id]];
if ($reference_count < 1) {
throw new RuntimeException("Incorrect referenced count found");
}
--$reference_count;
$this->referenced_counts[$this->references_in_scope[$ref_id]] = $reference_count;
}
/**
* @param Clause[] $clauses
* @param array<string, bool> $changed_var_ids
* @return array{list<Clause>, list<Clause>}
* @psalm-pure
*/
public static function removeReconciledClauses(array $clauses, array $changed_var_ids): array
{
$included_clauses = [];
$rejected_clauses = [];
foreach ($clauses as $c) {
if ($c->wedge) {
$included_clauses[] = $c;
continue;
}
foreach ($c->possibilities as $key => $_) {
if (isset($changed_var_ids[$key])) {
$rejected_clauses[] = $c;
continue 2;
}
}
$included_clauses[] = $c;
}
return [$included_clauses, $rejected_clauses];
}
/**
* @param Clause[] $clauses
* @return list<Clause>
*/
public static function filterClauses(
string $remove_var_id,
array $clauses,
?Union $new_type = null,
?StatementsAnalyzer $statements_analyzer = null,
): array {
$new_type_string = $new_type ? $new_type->getId() : '';
$clauses_to_keep = [];
foreach ($clauses as $clause) {
$clause = $clause->calculateNegation();
$quoted_remove_var_id = preg_quote($remove_var_id, '/');
foreach ($clause->possibilities as $var_id => $_) {
if (preg_match('/' . $quoted_remove_var_id . '[\]\[\-]/', $var_id)) {
break 2;
}
}
if (!isset($clause->possibilities[$remove_var_id])
|| (count($clause->possibilities[$remove_var_id]) === 1
&& array_keys($clause->possibilities[$remove_var_id])[0] === $new_type_string)
) {
$clauses_to_keep[] = $clause;
} elseif ($statements_analyzer &&
$new_type &&
!$new_type->hasMixed()
) {
$type_changed = false;
// if the clause contains any possibilities that would be altered
// by the new type
foreach ($clause->possibilities[$remove_var_id] as $assertion) {
// if we're negating a type, we generally don't need the clause anymore
if ($assertion->isNegation()) {
$type_changed = true;
break;
}
$result_type = AssertionReconciler::reconcile(
$assertion,
$new_type,
null,
$statements_analyzer,
false,
[],
null,
[],
$failed_reconciliation,
);
if ($result_type->getId() !== $new_type_string) {
$type_changed = true;
break;
}
}
if (!$type_changed) {
$clauses_to_keep[] = $clause;
}
}
}
return $clauses_to_keep;
}
public function removeVarFromConflictingClauses(
string $remove_var_id,
?Union $new_type = null,
?StatementsAnalyzer $statements_analyzer = null,
): void {
$this->clauses = self::filterClauses($remove_var_id, $this->clauses, $new_type, $statements_analyzer);
$this->parent_remove_vars[$remove_var_id] = true;
}
/**
* This method is used after assignments to variables to remove any existing
* items in $vars_in_scope that are now made redundant by an update to some data
*/
public function removeDescendents(
string $remove_var_id,
Union $existing_type,
?Union $new_type = null,
?StatementsAnalyzer $statements_analyzer = null,
): void {
$this->removeVarFromConflictingClauses(
$remove_var_id,
$existing_type->hasMixed()
|| ($new_type && $existing_type->from_docblock !== $new_type->from_docblock)
? null
: $new_type,
$statements_analyzer,
);
foreach ($this->vars_in_scope as $var_id => &$type) {
if (preg_match('/' . preg_quote($remove_var_id, '/') . '[\]\[\-]/', $var_id)) {
$this->remove($var_id, false);
}
$builder = null;
foreach ($type->getAtomicTypes() as $atomic_type) {
if ($atomic_type instanceof DependentType
&& $atomic_type->getVarId() === $remove_var_id
) {
$builder ??= $type->getBuilder();
$builder->addType($atomic_type->getReplacement());
}
}
if ($builder) {
$type = $builder->freeze();
}
}
}
public function removeMutableObjectVars(bool $methods_only = false): void
{
$vars_to_remove = [];
foreach ($this->vars_in_scope as $var_id => $type) {
if ($type->has_mutations
&& (str_contains($var_id, '->') || str_contains($var_id, '::'))
&& (!$methods_only || strpos($var_id, '()'))
) {
$vars_to_remove[] = $var_id;
}
}
if (!$vars_to_remove) {
return;
}
foreach ($vars_to_remove as $var_id) {
$this->remove($var_id, false);
}
$clauses_to_keep = [];
foreach ($this->clauses as $clause) {
$abandon_clause = false;
foreach (array_keys($clause->possibilities) as $key) {
if ((str_contains($key, '->') || str_contains($key, '::'))
&& (!$methods_only || strpos($key, '()'))
) {
$abandon_clause = true;
break;
}
}
if (!$abandon_clause) {
$clauses_to_keep[] = $clause;
}
}
$this->clauses = $clauses_to_keep;
}
public function updateChecks(Context $op_context): void
{
$this->check_classes = $this->check_classes && $op_context->check_classes;
$this->check_variables = $this->check_variables && $op_context->check_variables;
$this->check_methods = $this->check_methods && $op_context->check_methods;
$this->check_functions = $this->check_functions && $op_context->check_functions;
$this->check_consts = $this->check_consts && $op_context->check_consts;
}
public function isPhantomClass(string $class_name): bool
{
return isset($this->phantom_classes[strtolower($class_name)]);
}
public function hasVariable(string $var_name): bool
{
if (!$var_name) {
return false;
}
$stripped_var = (string) preg_replace('/(->|\[).*$/', '', $var_name, 1);
if ($stripped_var !== '$this' || $var_name !== $stripped_var) {
$this->cond_referenced_var_ids[$var_name] = true;
}
return isset($this->vars_in_scope[$var_name]);
}
public function getScopeSummary(): string
{
$summary = [];
foreach ($this->vars_possibly_in_scope as $k => $_) {
$summary[$k] = true;
}
foreach ($this->vars_in_scope as $k => $v) {
$summary[$k] = $v->getId();
}
return json_encode($summary, JSON_THROW_ON_ERROR);
}
public function defineGlobals(): void
{
$globals = [
// not sure why this is declared here again, see VariableFetchAnalyzer
'$argv' => new Union([
Type::getNonEmptyListAtomic(Type::getString()),
new TNull(),
], [
'ignore_nullable_issues' => true,
]),
'$argc' => new Union([
new TIntRange(1, null),
new TNull(),
], [
'ignore_nullable_issues' => true,
]),
];
$config = Config::getInstance();
foreach ($config->globals as $global_id => $type_string) {
$globals[$global_id] = Type::parseString($type_string);
}
foreach ($globals as $global_id => $type) {
$this->vars_in_scope[$global_id] = $type;
$this->vars_possibly_in_scope[$global_id] = true;
}
}
public function mergeExceptions(Context $other_context): void
{
foreach ($other_context->possibly_thrown_exceptions as $possibly_thrown_exception => $codelocations) {
foreach ($codelocations as $hash => $codelocation) {
$this->possibly_thrown_exceptions[$possibly_thrown_exception][$hash] = $codelocation;
}
}
}
public function isSuppressingExceptions(StatementsAnalyzer $statements_analyzer): bool
{
if (!$this->collect_exceptions) {
return true;
}
$issue_type = $this->is_global ? 'UncaughtThrowInGlobalScope' : 'MissingThrowsDocblock';
$suppressed_issues = $statements_analyzer->getSuppressedIssues();
$suppressed_issue_position = array_search($issue_type, $suppressed_issues, true);
if ($suppressed_issue_position !== false) {
if (is_int($suppressed_issue_position)) {
$file = $statements_analyzer->getFileAnalyzer()->getFilePath();
IssueBuffer::addUsedSuppressions([
$file => [$suppressed_issue_position => true],
]);
}
return true;
}
return false;
}
public function mergeFunctionExceptions(
FunctionLikeStorage $function_storage,
CodeLocation $codelocation,
): void {
$hash = $codelocation->getHash();
foreach ($function_storage->throws as $possibly_thrown_exception => $_) {
$this->possibly_thrown_exceptions[$possibly_thrown_exception][$hash] = $codelocation;
}
}
public function insideUse(): bool
{
return $this->inside_assignment
|| $this->inside_return
|| $this->inside_call
|| $this->inside_general_use
|| $this->inside_conditional
|| $this->inside_throw
|| $this->inside_isset;
}
}