src/Phan/Analysis/NegatedConditionVisitor.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\AST\ContextNode;
use Phan\AST\UnionTypeVisitor;
use Phan\AST\Visitor\KindVisitorImplementation;
use Phan\BlockAnalysisVisitor;
use Phan\CodeBase;
use Phan\Config;
use Phan\Exception\IssueException;
use Phan\Issue;
use Phan\Language\Context;
use Phan\Language\Element\Variable;
use Phan\Language\Type;
use Phan\Language\Type\ArrayType;
use Phan\Language\Type\FloatType;
use Phan\Language\Type\IntType;
use Phan\Language\Type\IterableType;
use Phan\Language\Type\MixedType;
use Phan\Language\Type\NullType;
use Phan\Language\Type\ResourceType;
use Phan\Language\Type\ScalarType;
use Phan\Language\Type\StringType;
use Phan\Language\UnionType;
use Phan\Language\UnionTypeBuilder;

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

    /**
     * @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, true);
        }
        return $this->context;
    }

    /**
     * Check if variables from within a generic condition are defined.
     * @param Node $node
     * A node to parse
     */
    private 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
        );
    }

    /**
     * @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 ?? 0;
        switch ($flags) {
            case flags\BINARY_BOOL_OR:
                return $this->analyzeShortCircuitingOr($node->children['left'], $node->children['right']);
            case flags\BINARY_BOOL_AND:
                return $this->analyzeShortCircuitingAnd($node->children['left'], $node->children['right']);
            case flags\BINARY_IS_IDENTICAL:
                $this->checkVariablesDefined($node);
                return $this->analyzeAndUpdateToBeNotIdentical($node->children['left'], $node->children['right']);
            case flags\BINARY_IS_EQUAL:
                $this->checkVariablesDefined($node);
                return $this->analyzeAndUpdateToBeNotEqual($node->children['left'], $node->children['right']);
            case flags\BINARY_IS_NOT_IDENTICAL:
                $this->checkVariablesDefined($node);
                return $this->analyzeAndUpdateToBeIdentical($node->children['left'], $node->children['right']);
            case flags\BINARY_IS_NOT_EQUAL:
                $this->checkVariablesDefined($node);
                return $this->analyzeAndUpdateToBeEqual($node->children['left'], $node->children['right']);
            case flags\BINARY_IS_GREATER:
                $this->checkVariablesDefined($node);
                return $this->analyzeAndUpdateToBeCompared($node->children['left'], $node->children['right'], flags\BINARY_IS_SMALLER_OR_EQUAL);
            case flags\BINARY_IS_GREATER_OR_EQUAL:
                $this->checkVariablesDefined($node);
                return $this->analyzeAndUpdateToBeCompared($node->children['left'], $node->children['right'], flags\BINARY_IS_SMALLER);
            case flags\BINARY_IS_SMALLER:
                $this->checkVariablesDefined($node);
                return $this->analyzeAndUpdateToBeCompared($node->children['left'], $node->children['right'], flags\BINARY_IS_GREATER_OR_EQUAL);
            case flags\BINARY_IS_SMALLER_OR_EQUAL:
                $this->checkVariablesDefined($node);
                return $this->analyzeAndUpdateToBeCompared($node->children['left'], $node->children['right'], flags\BINARY_IS_GREATER);
            default:
                $this->checkVariablesDefined($node);
                return $this->context;
        }
    }

    /**
     * Helper method
     * @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 negation of the short-circuiting and.
     *
     * @suppress PhanSuspiciousTruthyString deliberate cast of literal to boolean
     */
    private function analyzeShortCircuitingAnd($left, $right): Context
    {
        // Analyze expressions such as if (!(is_string($x) || is_int($x)))
        // which would be equivalent to if (!is_string($x)) { if (!is_int($x)) { ... }}

        // 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.

        // 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($right);
        }
        if (!($right instanceof Node)) {
            if (!$right) {
                return $this->context;
            }
            return $this($left);
        }
        $code_base = $this->code_base;
        $context = $this->context;
        $left_false_context = (new NegatedConditionVisitor($code_base, $context))($left);
        $left_true_context = (new ConditionVisitor($code_base, $context))($left);
        // We analyze the right-hand side of `cond($x) && cond2($x)` as if `cond($x)` was true.
        $right_false_context = (new NegatedConditionVisitor($code_base, $left_true_context))($right);
        // When the NegatedConditionVisitor is false, at least one of the left or right contexts must be false.
        // (NegatedConditionVisitor returns a context for when the input Node's value was falsey)
        return (new ContextMergeVisitor($context, [$left_false_context, $right_false_context]))->combineChildContextList();
    }

    /**
     * @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 negation of the short-circuiting or.
     */
    private function analyzeShortCircuitingOr($left, $right): Context
    {
        // Analyze expressions such as if (!(is_string($x) || is_int($x)))
        // which would be equivalent to if (!is_string($x)) { if (!is_int($x)) { ... }}

        // 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($left);
        }
        if ($right instanceof Node) {
            return $this($right);
        }
        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 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, true);
            }
            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) {
            // The negated version of a NegatedConditionVisitor is a ConditionVisitor.
            return (new ConditionVisitor($this->code_base, $this->context))($expr_node);
        } elseif (Config::getValue('redundant_condition_detection')) {
            // Check `scalar` of `if (!scalar)`
            $this->checkRedundantOrImpossibleTruthyCondition($expr_node, $this->context, null, false);
        }
        return $this->context;
    }

    /**
     * Look at elements of the form `is_array($v)` and modify
     * the type of the variable to negate that check.
     *
     * @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
    {
        $raw_function_name = self::getFunctionName($node);
        if (!\is_string($raw_function_name)) {
            return $this->context;
        }
        $args = $node->children['args']->children;

        $function_name = \strtolower(\ltrim($raw_function_name, '\\'));
        if ($function_name === 'array_key_exists') {
            // @phan-suppress-next-line PhanPartialTypeMismatchArgument
            return $this->analyzeArrayKeyExistsNegation($args);
        }
        static $map;
        if ($map === null) {
            $map = self::createNegationCallbackMap();
        }
        $type_modification_callback = $map[$function_name] ?? null;
        if ($type_modification_callback === null) {
            return $this->context;
        }
        $first_arg = $args[0] ?? null;
        if (!($first_arg instanceof Node && $first_arg->kind === ast\AST_VAR)) {
            // @phan-suppress-next-line PhanPartialTypeMismatchArgument, PhanTypeMismatchArgumentNullable
            return $this->modifyComplexExpression($first_arg, $type_modification_callback, $this->context, $args);
        }

        $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;
    }

    public function visitVar(Node $node): Context
    {
        $this->checkVariablesDefined($node);
        return $this->removeTruthyFromVariable($node, $this->context, false, false);
    }

    /**
     * @param Node $node
     * A node to parse, with kind ast\AST_NULLABLE_PROP (e.g. `if (!$this?->prop_name)`)
     *
     * @return Context
     * A new or an unchanged context resulting from
     * parsing the node
     */
    public function visitNullsafeProp(Node $node): Context
    {
        // TODO: Adjust this for values other than $this, e.g. to imply the expression is null or an object
        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
    {
        $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->nonTruthyClone();
            },
            $this->context
        );
    }

    /**
     * @param list<Node|string|int|float> $args
     */
    private function analyzeArrayKeyExistsNegation(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()) {
                    return $this->withNullOrUnsetArrayShapeTypes($type, $args[0], $this->context, true);
                }
                return $type;
            },
            true,
            false
        );
    }

    // TODO: empty, isset

    /**
     * @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`
        $expr_node = $node->children['expr'];
        $context = $this->context;
        if (!($expr_node instanceof Node)) {
            return $context;
        }
        $class_node = $node->children['class'];
        if (!($class_node instanceof Node)) {
            return $context;
        }
        if ($expr_node->kind !== ast\AST_VAR) {
            return $this->modifyComplexExpression(
                $expr_node,
                /**
                 * @param list<mixed> $args
                 * @suppress PhanUnusedClosureParameter
                 */
                function (CodeBase $code_base, Context $context, Variable $variable, array $args) use ($class_node): void {
                    $union_type = $this->computeNegatedInstanceofType($variable->getUnionType(), $class_node);
                    if ($union_type) {
                        $variable->setUnionType($union_type);
                    }
                },
                $context,
                []
            );
        }

        $code_base = $this->code_base;

        try {
            // Get the variable we're operating on
            $variable = $this->getVariableFromScope($expr_node, $context);
            if (\is_null($variable)) {
                return $context;
            }

            // Get the type that we're checking it against
            $new_variable_type = $this->computeNegatedInstanceofType($variable->getUnionType(), $class_node);
            if (!$new_variable_type) {
                // We don't know what it asserted it wasn't.
                return $context;
            }

            // TODO: Assert that instanceof right-hand type is valid in NegatedConditionVisitor as well

            // Make a copy of the variable
            $variable = clone($variable);
            // See https://secure.php.net/instanceof -
            $variable->setUnionType($new_variable_type);

            // Overwrite the variable with its new type
            $context = $context->withScopeVariable(
                $variable
            );
        } catch (IssueException $exception) {
            Issue::maybeEmitInstance($code_base, $context, $exception->getIssueInstance());
        } catch (\Exception $_) {
            // Swallow it
        }

        return $context;
    }

    /**
     * Compute the type of $union_type after asserting `!(expr instanceof $class_node)`
     * @param Node|string|int|float $class_node
     */
    private function computeNegatedInstanceofType(UnionType $union_type, $class_node): ?UnionType
    {
        $right_hand_union_type = UnionTypeVisitor::unionTypeFromNode(
            $this->code_base,
            $this->context,
            $class_node
        )->objectTypes();

        if ($right_hand_union_type->typeCount() !== 1) {
            return null;
        }
        $right_hand_type = $right_hand_union_type->getTypeSet()[0];
        if (!$right_hand_type->isObjectWithKnownFQSEN()) {
            return null;
        }
        return $union_type->withoutSubclassesOf($this->code_base, $right_hand_type);
    }

    /*
    private function analyzeNegationOfVariableIsA(array $args, Context $context) : Context
    {
        // TODO: implement
        return $context;
    }
     */

    /**
     * @return array<string,Closure> (NegatedConditionVisitor $cv, Node $var_node, Context $context) -> Context
     * @phan-return array<string,Closure(CodeBase, Context, Variable, array):void>
     */
    private static function createNegationCallbackMap(): array
    {
        /** @param list<Node|mixed> $unused_args */
        $remove_null_cb = static function (CodeBase $unused_code_base, Context $unused_context, Variable $variable, array $unused_args): void {
            $variable->setUnionType($variable->getUnionType()->nonNullableClone());
        };

        // Remove any Types from UnionType that are subclasses of $base_class_name
        $make_basic_negated_assertion_callback = static function (string $base_class_name): Closure {
            /**
             * @param list<Node|mixed> $unused_args
             */
            return static function (CodeBase $unused_code_base, Context $unused_context, Variable $variable, array $unused_args) use ($base_class_name): void {
                $variable->setUnionType($variable->getUnionType()->asMappedListUnionType(/** @return list<Type> */ static function (Type $type) use ($base_class_name): array {
                    if ($type instanceof $base_class_name) {
                        // This is the type we don't want
                        if ($type->isNullable()) {
                            static $null_type_set;
                            return $null_type_set ?? ($null_type_set = UnionType::typeSetFromString('null'));
                        }
                        return [];
                    }
                    return [$type];
                })->asNormalizedTypes());
            };
        };
        $remove_float_callback = $make_basic_negated_assertion_callback(FloatType::class);
        $remove_int_callback = $make_basic_negated_assertion_callback(IntType::class);
        /**
         * @param Closure(Type):bool $type_filter
         * @return Closure(CodeBase, Context, Variable, array):void
         */
        $remove_conditional_function_callback = static function (Closure $type_filter): Closure {
            /**
             * @param list<Node|mixed> $unused_args
             */
            return static function (CodeBase $unused_code_base, Context $unused_context, Variable $variable, array $unused_args) use ($type_filter): void {
                $union_type = $variable->getUnionType();
                if (!$union_type->hasTypeMatchingCallback($type_filter)) {
                    return;
                }
                $new_type_builder = new UnionTypeBuilder();
                $has_null = false;
                $has_other_nullable_types = false;
                // Add types which are not scalars
                foreach ($union_type->getTypeSet() as $type) {
                    if ($type_filter($type)) {
                        // e.g. mixed|SomeClass can be null because mixed can be null.
                        $has_null = $has_null || $type->isNullable();
                        continue;
                    }
                    $has_other_nullable_types = $has_other_nullable_types || $type->isNullable();
                    $new_type_builder->addType($type);
                }
                // Add Null if some of the rejected types were were nullable, and none of the accepted types were nullable
                if ($has_null && !$has_other_nullable_types) {
                    $new_type_builder->addType(NullType::instance(false));
                }
                // TODO: Infer real type sets as well?
                $variable->setUnionType($new_type_builder->getPHPDocUnionType());
            };
        };
        $remove_scalar_callback = $remove_conditional_function_callback(static function (Type $type): bool {
            return $type instanceof ScalarType && !($type instanceof NullType);
        });
        $remove_numeric_callback = $remove_conditional_function_callback(static function (Type $type): bool {
            return $type instanceof IntType || $type instanceof FloatType;
        });
        $remove_bool_callback = $remove_conditional_function_callback(static function (Type $type): bool {
            return $type->isInBoolFamily();
        });
        /** @param list<Node|mixed> $unused_args */
        $remove_callable_callback = static function (CodeBase $unused_code_base, Context $unused_context, Variable $variable, array $unused_args): void {
            $variable->setUnionType($variable->getUnionType()->asMappedListUnionType(/** @return list<Type> */ static function (Type $type): array {
                if ($type->isCallable()) {
                    if ($type->isNullable()) {
                        static $null_type_set;
                        return $null_type_set ?? ($null_type_set = UnionType::typeSetFromString('null'));
                    }
                    return [];
                }
                return [$type];
            })->asNormalizedTypes());
        };
        // TODO: Would withStaticResolvedInContext make sense for ruling out self in Countable?
        /** @param list<Node|mixed> $unused_args */
        $remove_countable_callback = static function (CodeBase $code_base, Context $unused_context, Variable $variable, array $unused_args): void {
            $variable->setUnionType($variable->getUnionType()->asMappedListUnionType(/** @return list<Type> */ static function (Type $type) use ($code_base): array {
                if ($type->isCountable($code_base)) {
                    if ($type->isNullable()) {
                        static $null_type_set;
                        return $null_type_set ?? ($null_type_set = UnionType::typeSetFromString('null'));
                    }
                    return [];
                }
                return [$type];
            })->asNormalizedTypes());
        };
        /** @param list<Node|mixed> $unused_args */
        $zero_count_callback = static function (CodeBase $unused_code_base, Context $unused_context, Variable $variable, array $unused_args): void {
            $variable->setUnionType($variable->getUnionType()->asMappedListUnionType(/** @return list<Type> */ static function (Type $type): array {
                if ($type->isPossiblyObject()) {
                    // TODO: Could cast iterable to Traversable|array{}
                    return [$type];
                }
                if (!$type->isPossiblyFalsey()) {
                    return [];
                }
                return [$type->asNonTruthyType()];
            })->asNormalizedTypes());
        };
        /** @param list<Node|mixed> $unused_args */
        $remove_array_callback = static function (CodeBase $unused_code_base, Context $unused_context, Variable $variable, array $unused_args): void {
            $union_type = $variable->getUnionType();
            $variable->setUnionType(UnionType::of(
                self::filterNonArrayTypes($union_type->getTypeSet()),
                self::filterNonArrayTypes($union_type->getRealTypeSet())
            ));
        };
        /** @param list<Node|mixed> $unused_args */
        $remove_object_callback = static function (CodeBase $unused_code_base, Context $unused_context, Variable $variable, array $unused_args): void {
            $variable->setUnionType($variable->getUnionType()->asMappedListUnionType(/** @return list<Type> */ static function (Type $type): array {
                if ($type->isObject()) {
                    if ($type->isNullable()) {
                        static $null_type_set;
                        return $null_type_set ?? ($null_type_set = UnionType::typeSetFromString('null'));
                    }
                    return [];
                }

                if (\get_class($type) === IterableType::class) {
                    // An iterable that is not an array must be a Traversable
                    return [ArrayType::instance($type->isNullable())];
                }
                return [$type];
            })->asNormalizedTypes());
        };

        return [
            'count' => $zero_count_callback,
            'is_null' => $remove_null_cb,
            'is_array' => $remove_array_callback,
            'is_bool' => $remove_bool_callback,
            'is_callable' => $remove_callable_callback,
            'is_countable' => $remove_countable_callback,
            'is_double' => $remove_float_callback,
            'is_float' => $remove_float_callback,
            'is_int' => $remove_int_callback,
            'is_integer' => $remove_int_callback,
            'is_iterable' => $make_basic_negated_assertion_callback(IterableType::class),  // TODO: Could keep basic array types and classes extending iterable
            'is_long' => $remove_int_callback,
            'is_numeric' => $remove_numeric_callback,
            'is_object' => $remove_object_callback,
            'is_real' => $remove_float_callback,
            'is_resource' => $make_basic_negated_assertion_callback(ResourceType::class),
            'is_scalar' => $remove_scalar_callback,
            'is_string' => $make_basic_negated_assertion_callback(StringType::class),
        ];
    }

    /**
     * @param list<Type> $type_set
     * @return list<Type> which may contain duplicates
     */
    private static function filterNonArrayTypes(array $type_set): array
    {
        $new_types = [];
        $has_null = false;
        $has_other_nullable_types = false;
        // Add types which are not callable
        foreach ($type_set as $type) {
            if ($type instanceof ArrayType) {
                $has_null = $has_null || $type->isNullable();
                continue;
            }

            $has_other_nullable_types = $has_other_nullable_types || $type->isNullable();

            if (\get_class($type) === IterableType::class) {
                // An iterable that is not an object must be an array
                $new_types[] = Type::traversableInstance()->withIsNullable($type->isNullable());
                continue;
            }
            $new_types[] = $type;
        }
        // Add Null if some of the rejected types were were nullable, and none of the accepted types were nullable
        if ($has_null && !$has_other_nullable_types) {
            $new_types[] = NullType::instance(false);
        }
        return $new_types;
    }

    /**
     * @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 ?? null) !== ast\AST_VAR) {
            return $this->checkComplexIsset($var_node);
        }
        // if (!isset($x)) means that $x is definitely null
        return $this->updateVariableWithNewType($var_node, $this->context, NullType::instance(false)->asRealUnionType(), true, false);
    }

    /**
     * Analyze expressions such as $x['offset'] inside of a negated isset type check
     */
    public function checkComplexIsset(Node $var_node): Context
    {
        $context = $this->context;
        if ($var_node->kind === ast\AST_DIM) {
            $expr_node = $var_node;
            do {
                $parent_node = $expr_node;
                $expr_node = $expr_node->children['expr'];
                if (!($expr_node instanceof Node)) {
                    return $context;
                }
            } while ($expr_node->kind === ast\AST_DIM);

            if ($expr_node->kind === ast\AST_VAR) {
                $var_name = $expr_node->children['name'];
                if (!\is_string($var_name)) {
                    return $context;
                }
                if ($context->getScope()->hasVariableWithName($var_name)) {
                    $variable = $context->getScope()->getVariableByName($var_name);
                } else {
                    $new_type = Variable::getUnionTypeOfHardcodedVariableInScopeWithName($var_name, $context->isInGlobalScope());
                    if (!$new_type) {
                        // e.g. assert(!isset($x['key'])) - $x may still be undefined.
                        return $context;
                    }
                    $variable = new Variable(
                        $context->withLineNumberStart($var_node->lineno),
                        $var_name,
                        $new_type,
                        0
                    );
                    $context->getScope()->addVariable($variable);
                }
                $var_node_union_type = $variable->getUnionType();

                if ($var_node_union_type->hasTopLevelArrayShapeTypeInstances()) {
                    $new_union_type = $this->withNullOrUnsetArrayShapeTypes($var_node_union_type, $parent_node->children['dim'], $context, false);
                    if ($new_union_type !== $var_node_union_type) {
                        $variable = clone($variable);
                        $variable->setUnionType($new_union_type);
                        $context = $context->withScopeVariable($variable);
                    }
                    $this->context = $context;
                }
            }
        } elseif ($var_node->kind === ast\AST_PROP) {
            $context = $this->modifyPropertySimple($var_node, static function (UnionType $_): UnionType {
                return NullType::instance(false)->asPHPDocUnionType();
            }, $context);
        }
        return $context;
    }

    /**
     * @param UnionType $union_type the union type being modified by inferences from negated 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
     */
    private function withNullOrUnsetArrayShapeTypes(UnionType $union_type, $dim_node, Context $context, bool $remove_offset): 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.
            // Whether or not the union type already has array shape types, don't change the type
            return $union_type;
        }
        if ($remove_offset) {
            return $union_type->withoutArrayShapeField($dim_value);
        } else {
            static $null_and_possibly_undefined = null;
            if ($null_and_possibly_undefined === null) {
                $null_and_possibly_undefined = NullType::instance(false)->asPHPDocUnionType()->withIsPossiblyUndefined(true);
            }

            return ArrayType::combineArrayShapeTypesWithField($union_type, $dim_value, $null_and_possibly_undefined);
        }
    }

    /**
     * @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
    {
        $context = $this->context;
        $var_node = $node->children['expr'];
        if (!($var_node instanceof Node)) {
            return $context;
        }
        // e.g. if (!empty($x))
        if ($var_node->kind === ast\AST_VAR) {
            // Don't check if variables are defined - don't emit notices for if (!empty($x)) {}, etc.
            $var_name = $var_node->children['name'];
            if (\is_string($var_name)) {
                if (!$context->getScope()->hasVariableWithName($var_name)) {
                    $new_type = Variable::getUnionTypeOfHardcodedVariableInScopeWithName($var_name, $context->isInGlobalScope());
                    if ($new_type) {
                        $new_type = $new_type->nonFalseyClone();
                    } else {
                        $new_type = UnionType::empty();
                    }
                    // Support analyzing cases such as `if (!empty($x)) { use($x); }`, or `assert(!empty($x))`
                    // (In the PHP language, empty($x) is equivalent to (!isset($x) || !$x))
                    $context->setScope($context->getScope()->withVariable(new Variable(
                        $context->withLineNumberStart($var_node->lineno ?? 0),
                        $var_name,
                        $new_type,
                        0
                    )));
                }
                return $this->removeFalseyFromVariable($var_node, $context, true);
            }
        } elseif ($var_node->kind === ast\AST_PROP) {
            // e.g. $var_node is the representation of $this->prop or $x->prop.
            $context = $this->removeFalseyFromVariable($var_node, $context, true);
            $expr = $var_node->children['expr'];
            if ($expr instanceof Node) {
                // Also imply $x is an object after !empty($x->prop)
                return $this->removeTypesNotSupportingAccessFromVariable($expr, $context, ConditionVisitor::ACCESS_IS_OBJECT);
            }
            return $context;
        } else {
            $context = $this->checkComplexNegatedEmpty($var_node);
        }
        $this->checkVariablesDefined($node);
        return $context;
    }

    private function checkComplexNegatedEmpty(Node $var_node): Context
    {
        $context = $this->context;
        // TODO: !empty($obj->prop['offset']) should imply $obj is not null (removeNullFromVariable)
        if ($var_node->kind === ast\AST_DIM) {
            $expr_node = $var_node;
            do {
                $parent_node = $expr_node;
                $expr_node = $expr_node->children['expr'];
                if (!($expr_node instanceof Node)) {
                    return $context;
                }
            } while ($expr_node->kind === ast\AST_DIM);

            if ($expr_node->kind === ast\AST_VAR) {
                $var_name = $expr_node->children['name'];
                if (!\is_string($var_name)) {
                    return $context;
                }
                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();
                    }
                    $new_type = $new_type->nonFalseyClone();
                    // Support analyzing cases such as `if (!empty($x['key'])) { use($x); }`, or `assert(!empty($x['key']))`
                    // (Assume that this is an array, not ArrayAccess or a string, as a heuristic)
                    $context->setScope($context->getScope()->withVariable(new Variable(
                        $context->withLineNumberStart($expr_node->lineno ?? 0),
                        $var_name,
                        $new_type,
                        0
                    )));
                    return $context;
                }
                $context = $this->removeFalseyFromVariable($expr_node, $context, true);

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

                if ($var_node_union_type->hasTopLevelArrayShapeTypeInstances()) {
                    $context = $this->withNonFalseyArrayShapeTypes($variable, $parent_node->children['dim'], $context, true);
                }
                $this->context = $context;
            }
        }
        return $this->context;
    }

    /**
     * @param Variable $variable the variable being modified by inferences from !empty
     * @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 withNonFalseyArrayShapeTypes(Variable $variable, $dim_node, Context $context, bool $non_nullable): Context
    {
        $dim_value = $dim_node instanceof Node ? (new ContextNode($this->code_base, $this->context, $dim_node))->getEquivalentPHPScalarValue() : $dim_node;
        // TODO: detect and warn about null
        if (!\is_scalar($dim_value)) {
            return $context;
        }

        $union_type = $variable->getUnionType();
        $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 $context;
            }
            $new_union_type = ArrayType::combineArrayShapeTypesWithField($union_type, $dim_value, MixedType::instance(false)->asPHPDocUnionType());
            $variable = clone($variable);
            $variable->setUnionType($new_union_type);
            return $context->withScopeVariable(
                $variable
            );
            // TODO finish
        } elseif ($dim_union_type->containsNullableOrUndefined() || $dim_union_type->containsFalsey()) {
            if (!$non_nullable) {
                // 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 $context;
            }

            $variable = clone($variable);

            $variable->setUnionType(
                ArrayType::combineArrayShapeTypesWithField($union_type, $dim_value, $dim_union_type->nonFalseyClone())
            );

            // Overwrite the variable with its new type in this
            // scope without overwriting other scopes
            return $context->withScopeVariable(
                $variable
            );
            // TODO finish
        }
        return $context;
    }

    /**
     * @param Node $node
     * A node to parse
     * (Should be useful when analyzing for loops with no breaks (`for (; !is_string($x); ){...}, in the future))
     *
     * @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 (negation of) conditions.
        $last_expression = \end($node->children);
        if ($last_expression instanceof Node) {
            return $this($last_expression);
        } else {
            // TODO: emit no-op warning
            return $this->context;
        }
    }

    /**
     * Useful for analyzing `if ($x = foo() && $x->method())`
     *
     * TODO: Convert $x to empty/false/null types
     *
     * @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: Convert $x to empty/false/null types
     * @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)) {
            // Other code should warn about this invalid AST
            return $context;
        }
        return (new self($this->code_base, $context))->__invoke($left);
    }
}