src/Phan/Analysis/ConditionVisitor.php

Summary

Maintainability
F
1 wk
Test Coverage
<?php

declare(strict_types=1);

namespace Phan\Analysis;

use ast;
use ast\flags;
use ast\Node;
use Closure;
use Phan\Analysis\ConditionVisitor\HasTypeCondition;
use Phan\Analysis\ConditionVisitor\NotHasTypeCondition;
use Phan\AST\ASTReverter;
use Phan\AST\ContextNode;
use Phan\AST\UnionTypeVisitor;
use Phan\AST\Visitor\KindVisitorImplementation;
use Phan\BlockAnalysisVisitor;
use Phan\CodeBase;
use Phan\Config;
use Phan\Exception\FQSENException;
use Phan\Exception\IssueException;
use Phan\Issue;
use Phan\Language\Context;
use Phan\Language\Element\Variable;
use Phan\Language\FQSEN\FullyQualifiedClassName;
use Phan\Language\Type\ArrayType;
use Phan\Language\Type\BoolType;
use Phan\Language\Type\CallableType;
use Phan\Language\Type\ClassStringType;
use Phan\Language\Type\IntType;
use Phan\Language\Type\MixedType;
use Phan\Language\Type\ObjectType;
use Phan\Language\Type\StringType;
use Phan\Language\UnionType;
use Phan\Library\StringUtil;
use ReflectionMethod;

/**
 * A visitor that takes a Context and a Node for a condition and returns a Context that has been updated with that condition.
 *
 * @phan-file-suppress PhanUnusedClosureParameter
 * TODO: if (a || b || c || d) might get really slow, due to creating both ConditionVisitor and NegatedConditionVisitor
 */
class ConditionVisitor extends KindVisitorImplementation implements ConditionVisitorInterface
{
    use ConditionVisitorUtil;

    /** @internal */
    public const CONSTANT_EXISTS_PREFIX = "__phan\x00constant_exists_";

    /**
     * @var Context
     * The context in which the node we're going to be looking
     * at exists.
     */
    protected $context;

    /**
     * @param CodeBase $code_base
     * A code base needs to be passed in because we require
     * it to be initialized before any classes or files are
     * loaded.
     *
     * @param Context $context
     * The context of the parser at the node for which we'd
     * like to determine a type
     */
    public function __construct(
        CodeBase $code_base,
        Context $context
    ) {
        $this->code_base = $code_base;
        $this->context = $context;
    }

    /**
     * Default visitor for node kinds that do not have
     * an overriding method
     *
     * @param Node $node
     * A node to parse
     *
     * @return Context
     * A new or an unchanged context resulting from
     * parsing the node
     */
    public function visit(Node $node): Context
    {
        $this->checkVariablesDefined($node);
        if (Config::getValue('redundant_condition_detection')) {
            $this->checkRedundantOrImpossibleTruthyCondition($node, $this->context, null, false);
        }
        return $this->context;
    }

    /**
     * Check if variables from within a generic condition are defined.
     * @param Node $node
     * A node to parse
     */
    protected function checkVariablesDefined(Node $node): void
    {
        while ($node->kind === ast\AST_UNARY_OP) {
            $node = $node->children['expr'];
            if (!($node instanceof Node)) {
                return;
            }
        }
        // Get the type just to make sure everything
        // is defined.
        UnionTypeVisitor::unionTypeFromNode(
            $this->code_base,
            $this->context,
            $node,
            true
        );
    }

    /**
     * Check if variables from within isset are defined.
     * @param Node $node
     * A node to parse
     */
    private function checkVariablesDefinedInIsset(Node $node): void
    {
        while ($node->kind === ast\AST_UNARY_OP) {
            $node = $node->children['expr'];
            if (!($node instanceof Node)) {
                return;
            }
        }
        if ($node->kind === ast\AST_DIM) {
            $this->checkArrayAccessDefined($node);
            return;
        }
        // Get the type just to make sure everything
        // is defined.
        UnionTypeVisitor::unionTypeFromNode(
            $this->code_base,
            $this->context,
            $node,
            true
        );
    }

    /**
     * Analyzes (isset($x['field']))
     * @return void
     *
     * TODO: Add to NegatedConditionVisitor
     */
    private function checkArrayAccessDefined(Node $node): void
    {
        $code_base = $this->code_base;
        $context = $this->context;

        // TODO: Infer that the offset exists after this check
        UnionTypeVisitor::unionTypeFromNode(
            $code_base,
            $context,
            $node->children['dim'],
            true
        );
        // Check the array type to trigger TypeArraySuspicious
        /* $array_type = */
        UnionTypeVisitor::unionTypeFromNode(
            $code_base,
            $context,
            $node->children['expr'],
            true
        );
    }

    /**
     * @param Node $node
     * A node to parse
     *
     * @return Context
     * A new or an unchanged context resulting from
     * parsing the node
     */
    public function visitBinaryOp(Node $node): Context
    {
        $flags = $node->flags;
        switch ($flags) {
            case flags\BINARY_BOOL_AND:
                return $this->analyzeShortCircuitingAnd($node->children['left'], $node->children['right']);
            case flags\BINARY_BOOL_OR:
                return $this->analyzeShortCircuitingOr($node->children['left'], $node->children['right']);
            case flags\BINARY_IS_IDENTICAL:
                $this->checkVariablesDefined($node);
                return $this->analyzeAndUpdateToBeIdentical($node->children['left'], $node->children['right']);
            case flags\BINARY_IS_EQUAL:
                // TODO: Could be more precise, and preserve 0, [], etc. for `$x == null`
                $this->checkVariablesDefined($node);
                return $this->analyzeAndUpdateToBeEqual($node->children['left'], $node->children['right']);
            case flags\BINARY_IS_NOT_IDENTICAL:
                $this->checkVariablesDefined($node);
                return $this->analyzeAndUpdateToBeNotIdentical($node->children['left'], $node->children['right']);
            case flags\BINARY_IS_NOT_EQUAL:
                return $this->analyzeAndUpdateToBeNotEqual($node->children['left'], $node->children['right']);
            case flags\BINARY_IS_GREATER:
            case flags\BINARY_IS_GREATER_OR_EQUAL:
            case flags\BINARY_IS_SMALLER:
            case flags\BINARY_IS_SMALLER_OR_EQUAL:
                $this->checkVariablesDefined($node);
                return $this->analyzeAndUpdateToBeCompared($node->children['left'], $node->children['right'], $flags);
            default:
                $this->checkVariablesDefined($node);
                return $this->context;
        }
    }

    /**
     * @param Node|string|int|float $left
     * a Node or non-node to parse (possibly an AST literal)
     *
     * @param Node|string|int|float $right
     * a Node or non-node to parse (possibly an AST literal)
     *
     * @return Context
     * A new or an unchanged context resulting from
     * analyzing the short-circuiting logical and.
     */
    private function analyzeShortCircuitingAnd($left, $right): Context
    {
        // Aside: If left/right is not a node, left/right is a literal such as a number/string, and is either always truthy or always falsey.
        // Inside of this conditional may be dead or redundant code.
        if ($left instanceof Node) {
            $this->context = $this->__invoke($left);
        }
        // TODO: Warn if !$left
        if ($right instanceof Node) {
            return $this->__invoke($right);
        }
        return $this->context;
    }

    /**
     * @param Node|string|int|float $left
     * a Node or non-node to parse (possibly an AST literal)
     *
     * @param Node|string|int|float $right
     * a Node or non-node to parse (possibly an AST literal)
     *
     * @return Context
     * A new or an unchanged context resulting from
     * analyzing the short-circuiting logical or.
     * @suppress PhanSuspiciousTruthyString deliberate check
     */
    private function analyzeShortCircuitingOr($left, $right): Context
    {
        // Aside: If left/right is not a node, left/right is a literal such as a number/string, and is either always truthy or always falsey.
        // Inside of this conditional may be dead or redundant code.
        if (!($left instanceof Node)) {
            if ($left) {
                return $this->context;
            }
            if (!($right instanceof Node)) {
                return $this->context;
            }
            return $this->__invoke($right);
        }
        if (!($right instanceof Node)) {
            if ($right) {
                return $this->context;
            }
            return $this->__invoke($left);
        }
        $code_base = $this->code_base;
        $context = $this->context;
        $left_false_context = (new NegatedConditionVisitor($code_base, $context))->__invoke($left);
        $left_true_context = (new ConditionVisitor($code_base, $context))->__invoke($left);
        // We analyze the right-hand side of `cond($x) || cond2($x)` as if `cond($x)` was false.
        $right_true_context = (new ConditionVisitor($code_base, $left_false_context))->__invoke($right);
        // When the ConditionVisitor is true, at least one of the left or right contexts must be true.
        return (new ContextMergeVisitor($context, [$left_true_context, $right_true_context]))->combineChildContextList();
    }

    /**
     * @param Node $node
     * A node to parse
     *
     * @return Context
     * A new or an unchanged context resulting from
     * parsing the node
     */
    public function visitUnaryOp(Node $node): Context
    {
        $expr_node = $node->children['expr'];
        $flags = $node->flags;
        if ($flags !== flags\UNARY_BOOL_NOT) {
            if (Config::getValue('redundant_condition_detection')) {
                $this->checkRedundantOrImpossibleTruthyCondition($node, $this->context, null, false);
            }
            // TODO: Emit dead code issue for non-nodes
            if ($expr_node instanceof Node) {
                if ($flags === flags\UNARY_SILENCE) {
                    return $this->__invoke($expr_node);
                }
                $this->checkVariablesDefined($expr_node);
            }
            return $this->context;
        }
        // TODO: Emit dead code issue for non-nodes
        if ($expr_node instanceof Node) {
            return (new NegatedConditionVisitor($this->code_base, $this->context))->__invoke($expr_node);
        } elseif (Config::getValue('redundant_condition_detection')) {
            // Check `scalar` of `if (!scalar)`
            $this->checkRedundantOrImpossibleTruthyCondition($expr_node, $this->context, null, true);
        }
        return $this->context;
    }

    /**
     * @param Node $node
     * A node to parse
     *
     * @return Context
     * A new or an unchanged context resulting from
     * parsing the node
     */
    public function visitIsset(Node $node): Context
    {
        $var_node = $node->children['var'];
        if (!($var_node instanceof Node)) {
            return $this->context;
        }
        if ($var_node->kind !== ast\AST_VAR) {
            return $this->checkComplexIsset($var_node);
        }

        $var_name = $var_node->children['name'];
        if (!\is_string($var_name)) {
            $this->checkVariablesDefinedInIsset($var_node);
            return $this->context;
        }
        return $this->withSetVariable($var_name, $var_node, $var_node);
    }

    public const ACCESS_IS_OBJECT = 1;
    public const ACCESS_ARRAY_KEY_EXISTS = 2;
    public const ACCESS_IS_SET = 3;
    public const ACCESS_DIM_SET = 4;
    public const ACCESS_STRING_DIM_SET = 5;

    /** @internal */
    public const DEFAULTS_FOR_ACCESS_TYPE = [
        self::ACCESS_IS_OBJECT => 'object',
        self::ACCESS_ARRAY_KEY_EXISTS => 'non-empty-array|object',
        self::ACCESS_IS_SET => 'int|string|float|bool|non-empty-array|object|resource',
        self::ACCESS_DIM_SET => 'string|non-empty-array|object',
        self::ACCESS_STRING_DIM_SET => 'non-empty-array|object',
    ];

    /**
     * From isset($var), infer that $var is non-null
     * From isset($obj->prop['field']), infer that $obj is non-null
     * Also infer that $obj is an object (don't do that for $obj['field']->prop)
     */
    private function withSetVariable(string $var_name, Node $var_node, Node $ancestor_node): Context
    {
        $context = $this->context;
        $is_object = $var_node->kind === ast\AST_PROP;

        $scope = $context->getScope();
        if (!$scope->hasVariableWithName($var_name)) {
            $new_type = Variable::getUnionTypeOfHardcodedVariableInScopeWithName($var_name, $context->isInGlobalScope());
            if (!$new_type || ($is_object && !$new_type->hasObjectTypes())) {
                $new_type = $is_object ? ObjectType::instance(false)->asRealUnionType() : UnionType::empty();
            }
            // Support analyzing cases such as `if (isset($x)) { use($x); }`, or `assert(isset($x))`
            return $context->withScopeVariable(new Variable(
                $context->withLineNumberStart($var_node->lineno ?? 0),
                $var_name,
                $new_type,
                0
            ));
        }
        if ($is_object) {
            $variable = clone($context->getScope()->getVariableByName($var_name));
            $this->analyzeIsObjectAssertion($variable);
            $context = $this->modifyPropertySimple($var_node, static function (UnionType $type): UnionType {
                return $type->nonNullableClone();
            }, $context);
            if ($ancestor_node !== $var_node && self::isThisVarNode($var_node->children['expr'])) {
                $old_type = UnionTypeVisitor::unionTypeFromNode($this->code_base, $context, $ancestor_node);
                $context = (new AssignmentVisitor(
                    $this->code_base,
                    // We clone the original context to avoid affecting the original context for the elseif.
                    // AssignmentVisitor modifies the provided context in place.
                    //
                    // There is a difference between `if (is_string($x['field']))` and `$x['field'] = (some string)` for the way the `elseif` should be analyzed.
                    $context->withClonedScope(),
                    $ancestor_node,
                    $old_type->nonNullableClone()
                ))->__invoke($ancestor_node);
            }
            return $context->withScopeVariable($variable);
        }
        if ($var_node !== $ancestor_node) {
            return $this->removeTypesNotSupportingAccessFromVariable(
                $var_node,
                $context,
                $ancestor_node->kind === ast\AST_PROP ? self::ACCESS_IS_OBJECT : self::ACCESS_DIM_SET
            );
        }
        return $this->removeNullFromVariable($var_node, $context, true);
    }

    /**
     * @param Node $node a node that is NOT of type ast\AST_VAR
     */
    private function checkComplexIsset(Node $node): Context
    {
        // Loop to support getting the var name in is_array($x['field'][0])
        $has_prop_access = false;
        $context = $this->context;
        $var_node = $node;
        $parent_node = $node;
        while (true) {
            $kind = $var_node->kind;
            if ($kind === ast\AST_VAR) {
                break;
            }
            $parent_node = $var_node;
            if ($kind === ast\AST_DIM) {
                $var_node = $var_node->children['expr'];
                if (!$var_node instanceof Node) {
                    return $context;
                }
                continue;
            } elseif ($kind === ast\AST_PROP) {
                // TODO modify this pseudo-variable for $this->prop
                $has_prop_access = true;
                $var_node = $var_node->children['expr'];
                if (!$var_node instanceof Node) {
                    return $context;
                }
                continue;
            }

            // TODO: Handle more than one level of nesting
            return $context;
        }
        $var_name = $var_node->children['name'];
        if (!\is_string($var_name)) {
            return $context;
        }
        if ($has_prop_access) {
            // For `$x->prop['field'][0]`, $parent_node would be `$x->prop`.
            // And for that expression, phan would infer that $var_name was non-null AND an object.
            return $this->withSetVariable($var_name, $parent_node, $node);
        }

        // This is $x['field'] or $x[$i][something]

        if (!$context->getScope()->hasVariableWithName($var_name)) {
            $new_type = Variable::getUnionTypeOfHardcodedVariableInScopeWithName($var_name, $context->isInGlobalScope());
            if (!$new_type || !$new_type->hasArrayLike()) {
                $new_type = ArrayType::instance(false)->asPHPDocUnionType();
            }
            // Support analyzing cases such as `if (isset($x['key'])) { use($x); }`, or `assert(isset($x['key']))`
            return $context->withScopeVariable(new Variable(
                $context->withLineNumberStart($node->lineno ?? 0),
                $var_name,
                $new_type,  // can be array or (unlikely) ArrayAccess
                0
            ));
        }
        if ($var_node === $node) {
            $context = $this->removeNullFromVariable($var_node, $context, true);
        } else {
            if ($parent_node->kind === ast\AST_PROP) {
                // `isset($x->prop)` implies $x is an object
                $access_kind = self::ACCESS_IS_OBJECT;
            } else {
                // Allow `isset($x[0])` to imply $x can be a string, but not `isset($x['field'])`
                $dim = $node->children['dim'] ?? null;
                if (\is_string($dim) && \filter_var($dim, \FILTER_VALIDATE_INT) === false) {
                    $access_kind = self::ACCESS_STRING_DIM_SET;
                } else {
                    $access_kind = self::ACCESS_DIM_SET;
                }
            }
            $context = $this->removeTypesNotSupportingAccessFromVariable(
                $var_node,
                $context,
                $access_kind
            );
        }

        $variable = $context->getScope()->getVariableByName($var_name);
        $var_node_union_type = $variable->getUnionType();

        if ($var_node_union_type->hasTopLevelArrayShapeTypeInstances()) {
            $new_union_type = $this->withSetArrayShapeTypes($var_node_union_type, $parent_node->children['dim'], $context, true);
            if ($new_union_type !== $var_node_union_type) {
                $variable = clone($variable);
                $variable->setUnionType($new_union_type);
                $context = $context->withScopeVariable($variable);
            }
        }
        $this->context = $context;
        return $context;
    }

    /**
     * @param UnionType $union_type the type being modified by inferences from isset or array_key_exists
     * @param Node|string|float|int|bool $dim_node represents the dimension being accessed. (E.g. can be a literal or an AST_CONST, etc.
     * @param Context $context the context with inferences made prior to this condition
     *
     * @param bool $non_nullable if an offset is created, will it be non-nullable?
     */
    private function withSetArrayShapeTypes(UnionType $union_type, $dim_node, Context $context, bool $non_nullable): UnionType
    {
        $dim_value = $dim_node instanceof Node ? (new ContextNode($this->code_base, $context, $dim_node))->getEquivalentPHPScalarValue() : $dim_node;
        // TODO: detect and warn about null
        if (!\is_scalar($dim_value)) {
            return $union_type;
        }

        $dim_union_type = UnionTypeVisitor::resolveArrayShapeElementTypesForOffset($union_type, $dim_value);
        if (!$dim_union_type) {
            // There are other types, this dimension does not exist yet
            if (!$union_type->hasTopLevelArrayShapeTypeInstances()) {
                return $union_type;
            }
            return ArrayType::combineArrayShapeTypesWithField($union_type, $dim_value, MixedType::instance(false)->asPHPDocUnionType());
        } elseif ($dim_union_type->containsNullableOrUndefined()) {
            if (!$non_nullable && !$dim_union_type->isPossiblyUndefined()) {
                // The offset in question already exists in the array shape type, and we won't be changing it.
                // (E.g. array_key_exists('key', $x) where $x is array{key:?int,other:string})
                return $union_type;
            }

            return ArrayType::combineArrayShapeTypesWithField(
                $union_type,
                $dim_value,
                $dim_union_type->nonNullableClone()->withIsPossiblyUndefined(false)
            );
        }
        return $union_type;
    }

    /**
     * @param Node $node
     * A node to parse, with kind ast\AST_VAR
     *
     * @return Context
     * A new or an unchanged context resulting from
     * parsing the node
     */
    public function visitVar(Node $node): Context
    {
        $this->checkVariablesDefined($node);
        return $this->removeFalseyFromVariable($node, $this->context, false);
    }

    public function visitNullsafeProp(Node $node): Context
    {
        return $this->visitProp($node);
    }

    /**
     * @param Node $node
     * A node to parse, with kind ast\AST_PROP (e.g. `if ($this->prop_name)`)
     *
     * @return Context
     * A new or an unchanged context resulting from
     * parsing the node
     */
    public function visitProp(Node $node): Context
    {
        // TODO: Make this imply $expr_node is an object?
        $expr_node = $node->children['expr'];
        if (!($expr_node instanceof Node)) {
            return $this->context;
        }
        if ($expr_node->kind !== ast\AST_VAR || $expr_node->children['name'] !== 'this') {
            return $this->context;
        }
        if (!\is_string($node->children['prop'])) {
            return $this->context;
        }
        return $this->modifyPropertyOfThisSimple(
            $node,
            function (UnionType $type) use ($node): UnionType {
                if (Config::getValue('error_prone_truthy_condition_detection')) {
                    $this->checkErrorProneTruthyCast($node, $this->context, $type);
                }
                return $type->nonFalseyClone();
            },
            $this->context
        );
    }

    /**
     * @param Node $node
     * A node to parse
     *
     * @return Context
     * A new or an unchanged context resulting from
     * parsing the node
     */
    public function visitInstanceof(Node $node): Context
    {
        //$this->checkVariablesDefined($node);
        // Only look at things of the form
        // `$variable instanceof ClassName`
        $context = $this->context;
        $class_node = $node->children['class'];
        if (!($class_node instanceof Node)) {
            return $context;
        }
        $expr_node = $node->children['expr'];
        if (!($expr_node instanceof Node)) {
            return $context;
        }
        if ($expr_node->kind !== ast\AST_VAR) {
            return $this->modifyComplexExpression(
                $expr_node,
                /**
                 * @param list<mixed> $args
                 */
                function (CodeBase $code_base, Context $context, Variable $variable, array $args) use ($class_node): void {
                    $this->setInstanceofVariableType($variable, $class_node);
                },
                $context,
                []
            );
        }

        try {
            // Get the variable we're operating on
            $variable = $this->getVariableFromScope($expr_node, $context);
            if (\is_null($variable)) {
                return $context;
            }
            // Make a copy of the variable
            $variable = clone($variable);
            $this->setInstanceofVariableType($variable, $class_node);
            // Overwrite the variable with its new type
            $context = $context->withScopeVariable(
                $variable
            );
        } catch (IssueException $exception) {
            Issue::maybeEmitInstance($this->code_base, $context, $exception->getIssueInstance());
        } catch (\Exception $_) {
            // Swallow it
        }

        return $context;
    }

    /**
     * Modifies the union type of $variable in place
     */
    private function setInstanceofVariableType(Variable $variable, Node $class_node): void
    {
        // Get the type that we're checking it against
        $type = UnionTypeVisitor::unionTypeFromNode(
            $this->code_base,
            $this->context,
            $class_node
        );
        $object_types = $type->objectTypesStrict();
        if (!$object_types->isEmpty()) {
            // We know that the variable is the provided object type (or a subclass)
            // See https://secure.php.net/instanceof -

            // Add the type to the variable
            $variable->setUnionType(self::calculateNarrowedUnionType($this->code_base, $this->context, $variable->getUnionType(), $object_types));
        } else {
            // We know that variable is some sort of object if this condition is true.
            if ($class_node->kind !== ast\AST_NAME &&
                    !$type->canCastToUnionType(StringType::instance(false)->asPHPDocUnionType())) {
                Issue::maybeEmit(
                    $this->code_base,
                    $this->context,
                    Issue::TypeInvalidInstanceof,
                    $this->context->getLineNumberStart(),
                    ASTReverter::toShortString($class_node),
                    (string)$type->asNonLiteralType()
                );
            }
            self::analyzeIsObjectAssertion($variable);
        }
    }

    /**
     * E.g. Given subclass1|subclass2|false and base_class/base_interface, returns subclass1|subclass2
     * E.g. Given subclass1|mixed|false and base_class/base_interface, returns base_class/base_interface
     */
    private static function calculateNarrowedUnionType(CodeBase $code_base, Context $context, UnionType $old_type, UnionType $asserted_object_type): UnionType
    {
        $new_type_set = [];
        foreach ($old_type->getTypeSet() as $type) {
            if ($type instanceof MixedType) {
                // MixedType can cast to other types
                return $asserted_object_type;
            }
            if (!$type->isObject()) {
                // ignore non-object types
                continue;
            }
            if (!$type->isObjectWithKnownFQSEN()) {
                // Anything that can cast to $asserted_object_type should become $asserted_object_type
                // TODO: Handle isPossiblyObject/iterable
                return $asserted_object_type;
            }
            $type = $type->withIsNullable(false);
            if (!$type->asPHPDocUnionType()->canCastToDeclaredType($code_base, $context, $asserted_object_type)) {
                // This isn't on a common type hierarchy
                continue;
            }
            if (!$type->asExpandedTypes($code_base)->canCastToUnionType($asserted_object_type)) {
                // The variable includes a base class of the asserted type.
                return $asserted_object_type;
            }
            $new_type_set[] = $type;
        }
        if (!$new_type_set) {
            return $asserted_object_type;
        }
        if (!$asserted_object_type->hasRealTypeSet()) {
            return UnionType::of($new_type_set, $old_type->getRealTypeSet());
        }
        $new_real_type_set = [];
        foreach ($old_type->getRealTypeSet() as $type) {
            if ($type instanceof MixedType) {
                // MixedType can cast to other types
                return UnionType::of($new_type_set, $old_type->getRealTypeSet());
            }
            if (!$type->isObject()) {
                // ignore non-object types
                continue;
            }
            if (!$type->isObjectWithKnownFQSEN()) {
                // Anything that can cast to $asserted_object_type should become $asserted_object_type
                // TODO: Handle isPossiblyObject/iterable
                return UnionType::of($new_type_set, $old_type->getRealTypeSet());
            }
            $type = $type->withIsNullable(false);
            if (!$type->asExpandedTypes($code_base)->canCastToUnionType($asserted_object_type)) {
                continue;
            }
            $new_real_type_set[] = $type;
        }
        return UnionType::of($new_type_set, $new_real_type_set ?: $asserted_object_type->getRealTypeSet());
    }

    /**
     * @param Variable $variable (Node argument in a call to is_object)
     */
    private static function analyzeIsObjectAssertion(Variable $variable): void
    {
        // Change the type to match is_object relationship
        // If we already have the `object` type or generic object types, then keep those
        // (E.g. T|false becomes T, T[]|iterable|null becomes Traversable, object|bool becomes object)
        $variable->setUnionType($variable->getUnionType()->objectTypesStrict());
    }

    /**
     * This function is called once, and returns closures to modify the types of variables.
     *
     * This contains Phan's logic for inferring the resulting union types of variables, e.g. in \is_array($x).
     *
     * @return array<string,Closure> - The closures to call for a given global function
     * @phan-return array<string,Closure(CodeBase, Context, Variable, array):void>
     */
    private static function initTypeModifyingClosuresForVisitCall(): array
    {
        $make_direct_assertion_callback = static function (string $union_type_string): Closure {
            $asserted_union_type = UnionType::fromFullyQualifiedRealString(
                $union_type_string
            );
            /**
             * @param list<Node|string|int|float> $args
             */
            return static function (CodeBase $unused_code_base, Context $unused_context, Variable $variable, array $args) use ($asserted_union_type): void {
                // Otherwise, overwrite the type for any simple
                // primitive types.
                $variable->setUnionType($asserted_union_type);
            };
        };

        /**
         * @param list<Node|mixed> $args
         */
        $array_callback = static function (CodeBase $code_base, Context $context, Variable $variable, array $args): void {
            // Change the type to match the is_array relationship
            // If we already have generic array types, then keep those
            // (E.g. T[]|false becomes T[], ?array|null becomes array, callable becomes callable-array)
            $variable->setUnionType($variable->getUnionType()->arrayTypesStrictCast());
        };

        /**
         * @param list<Node|mixed> $args
         */
        $object_callback = static function (CodeBase $unused_code_base, Context $unused_context, Variable $variable, array $args): void {
            self::analyzeIsObjectAssertion($variable);
        };
        /**
         * @param list<Node|mixed> $args
         */
        $is_a_callback = static function (CodeBase $code_base, Context $context, Variable $variable, array $args) use ($object_callback): void {
            $real_class_name = $args[1] ?? null;
            if ($real_class_name instanceof Node) {
                $class_name = UnionTypeVisitor::unionTypeFromNode($code_base, $context, $real_class_name)->asSingleScalarValueOrNull();
            } else {
                $class_name = $real_class_name;
            }
            if (!\is_string($class_name)) {
                // Limit the types of $variable to an object if we can't infer the class name.
                $object_callback($code_base, $context, $variable, $args);
                return;
            }
            try {
                $fqsen = FullyQualifiedClassName::fromFullyQualifiedString($class_name);
            } catch (FQSENException $_) {
                throw new IssueException(Issue::fromType(Issue::TypeComparisonToInvalidClass)(
                    $context->getFile(),
                    $context->getLineNumberStart(),
                    [StringUtil::encodeValue($class_name)]
                ));
            }
            // TODO: validate argument
            $class_type = \is_string($real_class_name) ? $fqsen->asType()->asRealUnionType() : $fqsen->asType()->asPHPDocUnionType();
            $variable->setUnionType(self::calculateNarrowedUnionType($code_base, $context, $variable->getUnionType(), $class_type));
        };

        /**
         * @param string $extract_types
         * @param UnionType $default_if_empty
         * @return Closure(CodeBase,Context,Variable,array):void
         */
        $make_callback = static function (string $extract_types, UnionType $default_if_empty, bool $allow_undefined = false): Closure {
            $method = new ReflectionMethod(UnionType::class, $extract_types);
            /**
             * @param list<Node|mixed> $args
             * @suppress PhanPluginUnknownObjectMethodCall can't analye ReflectionMethod
             */
            return static function (CodeBase $code_base, Context $context, Variable $variable, array $args) use ($method, $default_if_empty, $allow_undefined): void {
                // Change the type to match the is_a relationship
                // If we already have possible callable types, then keep those
                // (E.g. Closure|false becomes Closure)
                $union_type = $variable->getUnionType();
                $new_type = $method->invoke($union_type);
                if ($new_type->isEmpty()) {
                    // If there are no inferred types, or the only type we saw was 'null',
                    // assume there this can be any possible scalar.
                    // (Excludes `resource`, which is technically a scalar)
                    //
                    // FIXME move this to PostOrderAnalysisVisitor so that all expressions can be analyzed, not just variables?
                    $new_type = $default_if_empty;
                } else {
                    // Add the missing type set before making the non-nullable clone.
                    // Otherwise, it'd have the real type set non-null-mixed.
                    if (!$new_type->hasRealTypeSet()) {
                        $new_type = $new_type->withRealTypeSet($default_if_empty->getRealTypeSet());
                    }
                    $new_type = $new_type->nonNullableClone();
                    if (!$allow_undefined) {
                        $new_type = $new_type->withIsPossiblyUndefined(false);
                    }
                }
                $variable->setUnionType($new_type);
            };
        };

        /**
         * @param list<Node|mixed> $args
         */
        $iterable_callback = static function (CodeBase $code_base, Context $context, Variable $variable, array $args): void {
            // Change the type to match the is_iterable relationship
            // If we already have generic array types or Traversable, then keep those
            // (E.g. T[]|false becomes T[], ?array|null becomes array, callable becomes iterable, object becomes \Traversable)
            $variable->setUnionType($variable->getUnionType()->withStaticResolvedInContext($context)->iterableTypesStrictCast($code_base));
        };
        /**
         * @param list<Node|mixed> $args
         */
        $countable_callback = static function (CodeBase $code_base, Context $context, Variable $variable, array $args): void {
            // Change the type to match the is_countable relationship
            // If we already have possible countable types, then keep those
            // (E.g. ?ArrayObject|false becomes ArrayObject)
            $variable->setUnionType($variable->getUnionType()->withStaticResolvedInContext($context)->countableTypesStrictCast($code_base));
        };
        /**
         * @param list<Node|mixed> $args
         */
        $has_count_callback = static function (CodeBase $code_base, Context $context, Variable $variable, array $args): void {
            // Change the type to match the is_countable relationship
            // If we already have possible countable types, then keep those
            // (E.g. ?ArrayObject|false becomes ArrayObject)
            $variable->setUnionType(
                $variable->getUnionType()
                ->withStaticResolvedInContext($context)
                ->countableTypesStrictCast($code_base)
                ->nonFalseyClone()
            );
        };
        $class_exists_callback = $make_callback('classStringTypes', ClassStringType::instance(false)->asRealUnionType());
        $method_exists_callback = $make_callback('classStringOrObjectTypes', UnionType::fromFullyQualifiedRealString('class-string|object'));
        /** @return void */
        $callable_callback = $make_callback('callableTypes', CallableType::instance(false)->asRealUnionType());
        $bool_callback = $make_callback('boolTypes', BoolType::instance(false)->asRealUnionType());
        $int_callback = $make_callback('intTypes', IntType::instance(false)->asRealUnionType());
        $string_callback = $make_callback('stringTypes', StringType::instance(false)->asRealUnionType());
        $numeric_callback = $make_callback('numericTypes', UnionType::fromFullyQualifiedRealString('string|int|float'));
        $scalar_callback = $make_callback('scalarTypesStrict', UnionType::fromFullyQualifiedRealString('string|int|float|bool'));

        // Note: LiteralIntType exists, but LiteralFloatType doesn't, which is why these are different.
        $float_callback = $make_direct_assertion_callback('float');
        $null_callback = $make_direct_assertion_callback('null');
        // Note: isset() is handled in visitIsset()

        return [
            'class_exists' => $class_exists_callback,
            'count' => $has_count_callback,  // handle `if (count($x))` but not yet `if (count($x) > 0)`
            'interface_exists' => $class_exists_callback,  // Currently, there's just class-string, not trait-string or interface-string.
            'trait_exists' => $class_exists_callback,
            'method_exists' => $method_exists_callback,
            'is_a' => $is_a_callback,
            'is_array' => $array_callback,
            'is_bool' => $bool_callback,
            'is_callable' => $callable_callback,
            'is_countable' => $countable_callback,
            'is_double' => $float_callback,
            'is_float' => $float_callback,
            'is_int' => $int_callback,
            'is_integer' => $int_callback,
            'is_iterable' => $iterable_callback,  // TODO: Could keep basic array types and classes extending iterable
            'is_long' => $int_callback,
            'is_null' => $null_callback,
            'is_numeric' => $numeric_callback,
            'is_object' => $object_callback,
            'is_real' => $float_callback,
            'is_resource' => $make_direct_assertion_callback('resource'),
            'is_scalar' => $scalar_callback,
            'is_string' => $string_callback,
        ];
    }

    /**
     * Look at elements of the form `is_array($v)` and modify
     * the type of the variable.
     *
     * @param Node $node
     * A node to parse
     *
     * @return Context
     * A new or an unchanged context resulting from
     * parsing the node
     */
    public function visitCall(Node $node): Context
    {
        // Analyze the call to the node, in case it modifies any variables (e.g. count($x = new_value()), if (preg_match(..., $matches), etc.
        // TODO: Limit this to nodes which actually contain variables or properties?
        // TODO: Only call this if the caller is also a ConditionVisitor, since BlockAnalysisVisitor would call this for ternaries and if statements already.
        // TODO: Also implement this for visitStaticCall, visitMethodCall, etc?
        $this->context = (new BlockAnalysisVisitor($this->code_base, $this->context))->__invoke($node);

        $raw_function_name = self::getFunctionName($node);
        if (!\is_string($raw_function_name)) {
            return $this->context;
        }
        $args = $node->children['args']->children;
        $first_arg = $args[0] ?? null;

        // Translate the function name into the UnionType it asserts
        static $map = null;

        if ($map === null) {
            $map = self::initTypeModifyingClosuresForVisitCall();
        }
        // TODO: Check if the return value of the function is void/always truthy (e.g. object)

        switch (\strtolower($raw_function_name)) {
            case 'array_key_exists':
                // @phan-suppress-next-line PhanPartialTypeMismatchArgument
                return $this->analyzeArrayKeyExists($args);
            case 'defined':
                // @phan-suppress-next-line PhanPartialTypeMismatchArgument
                return $this->analyzeDefined($args);
        }

        // Only look at things of the form
        // `\is_string($variable)`
        if (!($first_arg instanceof Node && $first_arg->kind === ast\AST_VAR)) {
            $type_modification_callback = $map[\strtolower($raw_function_name)] ?? null;
            if (!$type_modification_callback) {
                if (Config::getValue('redundant_condition_detection')) {
                    $this->checkRedundantOrImpossibleTruthyCondition($node, $this->context, null, false);
                }
                return $this->context;
            }
            // @phan-suppress-next-line PhanPartialTypeMismatchArgument, PhanTypeMismatchArgumentNullable
            return $this->modifyComplexExpression($first_arg, $type_modification_callback, $this->context, $args);
        }

        $function_name = \strtolower($raw_function_name);
        if (\count($args) !== 1) {
            if (!(\count($args) === 2 && \in_array($function_name, ['is_a', 'class_exists', 'method_exists'], true))) {
                if (Config::getValue('redundant_condition_detection')) {
                    $this->checkRedundantOrImpossibleTruthyCondition($node, $this->context, null, false);
                }
                return $this->context;
            }
        }

        $type_modification_callback = $map[$function_name] ?? null;
        if ($type_modification_callback === null) {
            if (Config::getValue('redundant_condition_detection')) {
                $this->checkRedundantOrImpossibleTruthyCondition($node, $this->context, null, false);
            }
            return $this->context;
        }

        $context = $this->context;

        try {
            // Get the variable we're operating on
            $variable = $this->getVariableFromScope($first_arg, $context);

            if (\is_null($variable)) {
                return $context;
            }

            // Make a copy of the variable
            $variable = clone($variable);

            // Modify the types of that variable.
            $type_modification_callback($this->code_base, $context, $variable, $args);

            // Overwrite the variable with its new type in this
            // scope without overwriting other scopes
            $context = $context->withScopeVariable(
                $variable
            );
        } catch (IssueException $exception) {
            Issue::maybeEmitInstance($this->code_base, $context, $exception->getIssueInstance());
        } catch (\Exception $_) {
            // Swallow it (E.g. IssueException for undefined variable)
        }

        return $context;
    }

    /**
     * @param list<Node|string|int|float> $args
     */
    private function analyzeArrayKeyExists(array $args): Context
    {
        if (\count($args) !== 2) {
            return $this->context;
        }
        $var_node = $args[1];
        if (!($var_node instanceof Node)) {
            return $this->context;
        }
        return $this->updateVariableWithConditionalFilter(
            $var_node,
            $this->context,
            static function (UnionType $_): bool {
                return true;
            },
            function (UnionType $type) use ($args): UnionType {
                if ($type->hasTopLevelArrayShapeTypeInstances()) {
                    $type = $this->withSetArrayShapeTypes($type, $args[0], $this->context, false);
                }
                return $this->asTypeSupportingAccess($type, self::ACCESS_ARRAY_KEY_EXISTS);
            },
            true,
            false
        );
    }

    /**
     * @param list<Node|string|int|float> $args
     */
    private function analyzeDefined(array $args): Context
    {
        if (\count($args) !== 1) {
            return $this->context;
        }
        $constant_name = $args[0];
        if ($constant_name instanceof Node) {
            $constant_name = UnionTypeVisitor::unionTypeFromNode($this->code_base, $this->context, $constant_name)->asSingleScalarValueOrNullOrSelf();
        }
        if (!\is_string($constant_name)) {
            return $this->context;
        }
        $context = $this->context->withClonedScope();
        $context->addScopeVariable(new Variable(
            $context,
            self::CONSTANT_EXISTS_PREFIX . \ltrim($constant_name, '\\'),
            UnionType::empty(),
            0
        ));
        return $context;
    }

    /**
     * @param Node $node
     * A node to parse
     *
     * @return Context
     * A new or an unchanged context resulting from
     * parsing the node
     */
    public function visitEmpty(Node $node): Context
    {
        $var_node = $node->children['expr'];
        if (!($var_node instanceof Node)) {
            return $this->context;
        }
        // Should always be a node for valid ASTs, tolerant-php-parser may produce invalid nodes
        if (\in_array($var_node->kind, [ast\AST_VAR, ast\AST_PROP, ast\AST_DIM], true)) {
            // Don't emit notices for if (empty($x)) {}, etc. We already do that in RedundantConditionPlugin.
            return $this->removeTruthyFromVariable($var_node, $this->context, true, true);
        }
        $this->checkVariablesDefinedInIsset($var_node);
        return $this->context;
    }

    /**
     * @param Node $node
     * A node to parse
     *
     * @return Context
     * A new or an unchanged context resulting from
     * parsing the node
     */
    public function visitExprList(Node $node): Context
    {
        $children = $node->children;
        $count = \count($children);
        if ($count > 1) {
            foreach ($children as $sub_node) {
                --$count;
                if ($count > 0 && $sub_node instanceof Node) {
                    $this->checkVariablesDefined($sub_node);
                }
            }
        }
        // Only analyze the last expression in the expression list for conditions.
        $last_expression = \end($node->children);
        if ($last_expression instanceof Node) {
            return $this->__invoke($last_expression);
        } else {
            // Other code should warn about this invalid AST
            return $this->context;
        }
    }

    /**
     * Useful for analyzing `if ($x = foo() && $x->method())`
     * TODO: Remove empty/false/null types from $x
     *
     * @param Node $node
     * A node to parse
     *
     * @return Context
     * A new or an unchanged context resulting from
     * parsing the node
     */
    public function visitAssign(Node $node): Context
    {
        $context = (new BlockAnalysisVisitor($this->code_base, $this->context))->visitAssign($node);
        $left = $node->children['var'];
        if (!($left instanceof Node)) {
            // Other code should warn about this invalid AST
            return $context;
        }
        if ($left->kind === ast\AST_ARRAY) {
            $expr_node = $node->children['expr'];
            if ($expr_node instanceof Node) {
                return (new self($this->code_base, $context))->__invoke($expr_node);
            }
            return $context;
        }
        return (new self($this->code_base, $context))->__invoke($left);
    }

    /**
     * Useful for analyzing `if ($x = foo() && $x->method())`
     * TODO: Remove empty/false/null types from $x
     * @param Node $node
     * A node to parse
     *
     * @return Context
     * A new or an unchanged context resulting from
     * parsing the node
     */
    public function visitAssignRef(Node $node): Context
    {
        $context = (new BlockAnalysisVisitor($this->code_base, $this->context))->visitAssignRef($node);
        $left = $node->children['var'];
        if (!($left instanceof Node)) {
            // TODO: Ensure this always warns
            return $context;
        }
        return (new self($this->code_base, $context))->__invoke($left);
    }

    /**
     * Update the variable represented by $expression to have the type $type.
     */
    public static function updateToHaveType(CodeBase $code_base, Context $context, Node $expression, UnionType $type): Context
    {
        $cv = new ConditionVisitor($code_base, $context);
        return $cv->analyzeBinaryConditionPattern(
            $expression,
            0,
            new HasTypeCondition($type)
        );
    }

    /**
     * Update the variable represented by $expression to not have the type $type.
     */
    public static function updateToNotHaveType(CodeBase $code_base, Context $context, Node $expression, UnionType $type): Context
    {
        $cv = new ConditionVisitor($code_base, $context);
        return $cv->analyzeBinaryConditionPattern(
            $expression,
            0,
            new NotHasTypeCondition($type)
        );
    }
}