src/Phan/IssueFixSuggester.php

Summary

Maintainability
F
5 days
Test Coverage
<?php

declare(strict_types=1);

namespace Phan;

use Closure;
use Phan\Language\Context;
use Phan\Language\Element\ClassConstant;
use Phan\Language\Element\Clazz;
use Phan\Language\Element\Func;
use Phan\Language\Element\MarkupDescription;
use Phan\Language\Element\Method;
use Phan\Language\Element\Property;
use Phan\Language\Element\Variable;
use Phan\Language\FQSEN;
use Phan\Language\FQSEN\FullyQualifiedClassConstantName;
use Phan\Language\FQSEN\FullyQualifiedClassName;
use Phan\Language\FQSEN\FullyQualifiedFunctionName;
use Phan\Language\FQSEN\FullyQualifiedGlobalConstantName;
use Phan\Language\UnionType;

use function count;
use function is_array;
use function is_string;
use function strlen;
use function strpos;
use function strtolower;
use function uksort;

/**
 * Utilities to suggest fixes for emitted Issues
 *
 * Commonly used methods:
 *
 * self::suggestSimilarClass(CodeBase, Context, FullyQualifiedClassName, Closure $filter = null, string $prefix = 'Did you mean')
 * self::suggestSimilarGlobalFunction(
 *     CodeBase $code_base,
 *     Context $context,
 *     FullyQualifiedFunctionName $function_fqsen,
 *     bool $suggest_in_global_namespace = true,
 *     string $prefix = 'Did you mean'
 * )
 * self::suggestVariableTypoFix(CodeBase, Context, string $variable_name, string $prefix = 'Did you mean')
 * self::suggestSimilarMethod(CodeBase, Context, Clazz, string $wanted_method_name, bool $is_static)
 * self::suggestSimilarProperty(CodeBase, Context, Clazz, string $wanted_property_name, bool $is_static)
 * self::suggestSimilarClassConstant(CodeBase, Context, FullyQualifiedClassConstantName)
 * @phan-file-suppress PhanPluginDescriptionlessCommentOnPublicMethod
 */
class IssueFixSuggester
{
    /** @see https://www.php.net/levenshtein - levenshtein warns and returns -1 for longer strings */
    private const MAX_SUGGESTION_NAME_LENGTH = 255;

    /**
     * @param Closure(Clazz):bool $class_closure
     * @return Closure(FullyQualifiedClassName):bool
     */
    public static function createFQSENFilterFromClassFilter(CodeBase $code_base, Closure $class_closure): Closure
    {
        /**
         * @param FullyQualifiedClassName $alternate_fqsen
         */
        return static function (FullyQualifiedClassName $alternate_fqsen) use ($code_base, $class_closure): bool {
            if (!$code_base->hasClassWithFQSEN($alternate_fqsen)) {
                return false;
            }
            return $class_closure($code_base->getClassByFQSEN($alternate_fqsen));
        };
    }

    /**
     * @return Closure(FullyQualifiedClassName):bool
     */
    public static function createFQSENFilterForClasslikeCategories(CodeBase $code_base, bool $allow_class, bool $allow_trait, bool $allow_interface): Closure
    {
        return self::createFQSENFilterFromClassFilter($code_base, static function (Clazz $class) use ($allow_class, $allow_trait, $allow_interface): bool {
            if ($class->isTrait()) {
                return $allow_trait;
            } elseif ($class->isInterface()) {
                return $allow_interface;
            } else {
                return $allow_class;
            }
        });
    }

    /**
     * Returns a suggestion that suggests a similarly spelled class that has a method with name $method_name
     * (Used when trying to invoke a method on a class that does not exist)
     */
    public static function suggestSimilarClassForMethod(CodeBase $code_base, Context $context, FullyQualifiedClassName $class_fqsen, string $method_name, bool $is_static): ?Suggestion
    {
        $filter = null;
        if (strtolower($method_name) === '__construct') {
            // Constructed objects have to be classes
            $filter = self::createFQSENFilterForClasslikeCategories($code_base, true, false, false);
        } elseif ($is_static) {
            // Static methods can be parts of classes or traits, but not interfaces
            $filter = self::createFQSENFilterForClasslikeCategories($code_base, true, true, false);
        }
        return self::suggestSimilarClass($code_base, $context, $class_fqsen, $filter);
    }

    /**
     * Returns a suggestion for a global function spelled similarly to $function_fqsen (e.g. if $function_fqsen does not exist)
     */
    public static function suggestSimilarGlobalFunction(
        CodeBase $code_base,
        Context $context,
        FullyQualifiedFunctionName $function_fqsen,
        bool $suggest_in_global_namespace = true,
        string $prefix = ""
    ): ?Suggestion {
        if ($prefix === '') {
            $prefix = self::DEFAULT_FUNCTION_SUGGESTION_PREFIX;
        }
        $namespace = $function_fqsen->getNamespace();
        $name = $function_fqsen->getName();
        $suggested_fqsens = \array_merge(
            $code_base->suggestSimilarGlobalFunctionInOtherNamespace($namespace, $name, $context),
            $code_base->suggestSimilarGlobalFunctionInSameNamespace($namespace, $name, $context, $suggest_in_global_namespace),
            $code_base->suggestSimilarNewInAnyNamespace($namespace, $name, $context, $suggest_in_global_namespace),
            $code_base->suggestSimilarGlobalFunctionInNewerVersion($namespace, $name, $context, $suggest_in_global_namespace)
        );
        if (count($suggested_fqsens) === 0) {
            return null;
        }

        /**
         * @param string|FullyQualifiedFunctionName|FullyQualifiedClassName $fqsen
         */
        $generate_type_representation = static function ($fqsen): string {
            if ($fqsen instanceof FullyQualifiedClassName) {
                return "new $fqsen()";
            }
            if (is_string($fqsen) && strpos($fqsen, 'added in PHP') !== false) {
                return $fqsen;
            }
            return $fqsen . '()';
        };
        $suggestion_text = $prefix . ' ' . \implode(' or ', \array_map($generate_type_representation, $suggested_fqsens));

        return Suggestion::fromString($suggestion_text);
    }

    public const DEFAULT_CLASS_SUGGESTION_PREFIX = 'Did you mean';
    public const DEFAULT_FUNCTION_SUGGESTION_PREFIX = 'Did you mean';

    public const CLASS_SUGGEST_ONLY_CLASSES = 0;
    public const CLASS_SUGGEST_CLASSES_AND_TYPES = 1;
    public const CLASS_SUGGEST_CLASSES_AND_TYPES_AND_VOID = 2;

    /**
     * Returns a message suggesting a class name that is similar to the provided undeclared class
     *
     * @param ?Closure(FullyQualifiedClassName):bool $filter
     * @param int $class_suggest_type whether to include non-classes such as 'int', 'callable', etc.
     */
    public static function suggestSimilarClass(
        CodeBase $code_base,
        Context $context,
        FullyQualifiedClassName $class_fqsen,
        ?Closure $filter = null,
        string $prefix = null,
        int $class_suggest_type = self::CLASS_SUGGEST_ONLY_CLASSES
    ): ?Suggestion {
        if (!is_string($prefix) || $prefix === '') {
            $prefix = self::DEFAULT_CLASS_SUGGESTION_PREFIX;
        }
        $suggested_fqsens = \array_merge(
            $code_base->suggestSimilarClassInOtherNamespace($class_fqsen, $context),
            $code_base->suggestSimilarClassInSameNamespace($class_fqsen, $context, $class_suggest_type)
        );
        if ($filter) {
            $suggested_fqsens = \array_filter($suggested_fqsens, $filter);
        }
        $suggested_fqsens = \array_merge(self::suggestStubForClass($class_fqsen), $suggested_fqsens);

        if (count($suggested_fqsens) === 0) {
            return null;
        }

        /**
         * @param FullyQualifiedClassName|string $fqsen
         */
        $generate_type_representation = static function ($fqsen) use ($code_base): string {
            if (is_string($fqsen)) {
                return $fqsen;  // Not a class name, e.g. 'int', 'callable', etc.
            }
            $category = 'classlike';
            if ($code_base->hasClassWithFQSEN($fqsen)) {
                $class = $code_base->getClassByFQSEN($fqsen);
                if ($class->isInterface()) {
                    $category = 'interface';
                } elseif ($class->isTrait()) {
                    $category = 'trait';
                } else {
                    $category = 'class';
                }
            }
            return $category . ' ' . $fqsen->__toString();
        };
        $suggestion_text = $prefix . ' ' . \implode(' or ', \array_map($generate_type_representation, $suggested_fqsens));

        return Suggestion::fromString($suggestion_text);
    }

    /**
     * @return array{0?:string} an optional suggestion to enable internal stubs to load that class
     */
    public static function suggestStubForClass(FullyQualifiedClassName $fqsen): array
    {
        $class_key = \strtolower(\ltrim($fqsen->__toString(), '\\'));
        if (\array_key_exists($class_key, self::getKnownClasses())) {
            // Generate the message 'Did you mean "to configure..."'
            $message = "to configure a stub with https://github.com/phan/phan/wiki/How-To-Use-Stubs#internal-stubs or to enable the extension providing the class.";
            $included_extension_subset = Config::getValue('included_extension_subset');
            if (is_array($included_extension_subset) || Config::getValue('autoload_internal_extension_signatures')) {
                $message .= " (are the config settings 'included_extension_subset' and/or 'autoload_internal_extension_signatures' properly set up?)";
            }
            return [$message];
        }
        return [];
    }

    /**
     * @return array<string, string|true> fetches an incomplete list of classes Phan has known signatures/documentation for
     */
    private static function getKnownClasses(): array
    {
        static $known_classes = null;
        if (!is_array($known_classes)) {
            $known_classes = MarkupDescription::loadClassDescriptionMap();
            foreach (UnionType::internalFunctionSignatureMap(Config::get_closest_target_php_version_id()) as $fqsen => $_) {
                $i = strpos($fqsen, '::');
                if ($i === false) {
                    continue;
                }
                $fqsen = strtolower(\substr($fqsen, 0, $i));
                $known_classes[$fqsen] = true;
            }
        }
        return $known_classes;
    }

    /**
     * Returns a suggestion with similar method names to $wanted_method_name in $class (that exist and are accessible from the usage context), or null.
     */
    public static function suggestSimilarMethod(CodeBase $code_base, Context $context, Clazz $class, string $wanted_method_name, bool $is_static): ?Suggestion
    {
        if (Config::getValue('disable_suggestions')) {
            return null;
        }
        $method_set = self::suggestSimilarMethodMap($code_base, $context, $class, $wanted_method_name, $is_static);
        if (count($method_set) === 0) {
            return null;
        }
        uksort($method_set, 'strcmp');
        $suggestions = [];
        foreach ($method_set as $method) {
            // We lose the original casing of the method name in the array keys, so use $method->getName()
            $prefix = $method->isStatic() ? 'expr::' : 'expr->' ;
            $suggestions[] = $prefix . $method->getName() . '()';
        }
        return Suggestion::fromString(
            'Did you mean ' . \implode(' or ', $suggestions)
        );
    }

    /**
     * @return array<string,Method>
     */
    public static function suggestSimilarMethodMap(CodeBase $code_base, Context $context, Clazz $class, string $wanted_method_name, bool $is_static): array
    {
        $methods = $class->getMethodMap($code_base);
        if (count($methods) > Config::getValue('suggestion_check_limit')) {
            return [];
        }
        $usable_methods = self::filterSimilarMethods($code_base, $context, $methods, $is_static);
        return self::getSuggestionsForStringSet($wanted_method_name, $usable_methods);
    }

    /**
     * @internal
     */
    public static function maybeGetClassInCurrentScope(Context $context): ?FullyQualifiedClassName
    {
        if ($context->isInClassScope()) {
            return $context->getClassFQSEN();
        }
        return null;
    }

    /**
     * @param array<string,Method> $methods a list of methods
     * @return array<string,Method> a subset of the methods in $methods that are accessible from the current scope.
     * @internal
     */
    public static function filterSimilarMethods(CodeBase $code_base, Context $context, array $methods, bool $is_static): array
    {
        $class_fqsen_in_current_scope = self::maybeGetClassInCurrentScope($context);

        $candidates = [];
        foreach ($methods as $method_name => $method) {
            if ($is_static && !$method->isStatic()) {
                // Don't suggest instance methods to replace static methods
                continue;
            }
            if (!$method->isAccessibleFromClass($code_base, $class_fqsen_in_current_scope)) {
                // Don't suggest inaccessible private or protected methods.
                continue;
            }
            $candidates[$method_name] = $method;
        }
        return $candidates;
    }

    /**
     * @param ?\Closure(FullyQualifiedClassName):bool $filter
     */
    public static function suggestSimilarClassForGenericFQSEN(CodeBase $code_base, Context $context, FQSEN $fqsen, ?Closure $filter = null, string $prefix = 'Did you mean'): ?Suggestion
    {
        if (Config::getValue('disable_suggestions')) {
            return null;
        }
        if (!($fqsen instanceof FullyQualifiedClassName)) {
            return null;
        }
        return self::suggestSimilarClass($code_base, $context, $fqsen, $filter, $prefix);
    }

    /**
     * Generate a suggestion with similar suggestions (properties or otherwise) to a missing property with class $class and name $wanted_property_name.
     */
    public static function suggestSimilarProperty(CodeBase $code_base, Context $context, Clazz $class, string $wanted_property_name, bool $is_static): ?Suggestion
    {
        if (Config::getValue('disable_suggestions')) {
            return null;
        }
        if (strlen($wanted_property_name) <= 1) {
            return null;
        }
        $property_set = self::suggestSimilarPropertyMap($code_base, $context, $class, $wanted_property_name, $is_static);
        $suggestions = [];
        if ($is_static) {
            if ($class->hasConstantWithName($code_base, $wanted_property_name)) {
                $suggestions[] = $class->getFQSEN() . '::' . $wanted_property_name;
            }
        }
        if ($class->hasMethodWithName($code_base, $wanted_property_name, true)) {
            $method = $class->getMethodByName($code_base, $wanted_property_name);
            $suggestions[] = $class->getFQSEN() . ($method->isStatic() ? '::' : '->') . $wanted_property_name . '()';
        }
        foreach ($property_set as $property_name => $_) {
            $prefix = $is_static ? 'expr::$' : 'expr->' ;
            $suggestions[] = $prefix . $property_name;
        }
        foreach (self::getVariableNamesInScopeWithSimilarName($context, $wanted_property_name) as $variable_name) {
            $suggestions[] = $variable_name;
        }

        if (count($suggestions) === 0) {
            return null;
        }
        uksort($suggestions, 'strcmp');
        return Suggestion::fromString(
            'Did you mean ' . \implode(' or ', $suggestions)
        );
    }

    /**
     * @return array<string,Property>
     */
    public static function suggestSimilarPropertyMap(CodeBase $code_base, Context $context, Clazz $class, string $wanted_property_name, bool $is_static): array
    {
        $property_map = $class->getPropertyMap($code_base);
        if (count($property_map) > Config::getValue('suggestion_check_limit')) {
            return [];
        }
        $usable_property_map = self::filterSimilarProperties($code_base, $context, $property_map, $is_static);
        return self::getSuggestionsForStringSet($wanted_property_name, $usable_property_map);
    }

    /**
     * @param array<string,Property> $property_map
     * @return array<string,Property> a subset of $property_map that is accessible from the current scope.
     * @internal
     */
    public static function filterSimilarProperties(CodeBase $code_base, Context $context, array $property_map, bool $is_static): array
    {
        $class_fqsen_in_current_scope = self::maybeGetClassInCurrentScope($context);
        $candidates = [];
        foreach ($property_map as $property_name => $property) {
            if ($is_static !== $property->isStatic()) {
                // Don't suggest instance properties to replace static properties
                continue;
            }
            if (!$property->isAccessibleFromClass($code_base, $class_fqsen_in_current_scope)) {
                // Don't suggest inaccessible private or protected properties.
                continue;
            }
            // TODO: Check for access to protected outside of a class
            if ($property->isDynamicProperty()) {
                // Skip dynamically added properties
                continue;
            }
            $candidates[$property_name] = $property;
        }
        return $candidates;
    }

    /**
     * Returns a suggestion with class constants with a similar name to $class_constant_fqsen in the same class, or null
     */
    public static function suggestSimilarClassConstant(CodeBase $code_base, Context $context, FullyQualifiedClassConstantName $class_constant_fqsen): ?Suggestion
    {
        if (Config::getValue('disable_suggestions')) {
            return null;
        }
        $constant_name = $class_constant_fqsen->getName();
        if (strlen($constant_name) <= 1) {
            return null;
        }
        $class_fqsen = $class_constant_fqsen->getFullyQualifiedClassName();
        if (!$code_base->hasClassWithFQSEN($class_fqsen)) {
            return null;
        }
        $class = $code_base->getClassByFQSEN($class_fqsen);
        $class_constant_map = self::suggestSimilarClassConstantMap($code_base, $context, $class, $constant_name);
        $property_map = self::suggestSimilarPropertyMap($code_base, $context, $class, $constant_name, true);
        $method_map = self::suggestSimilarMethodMap($code_base, $context, $class, $constant_name, true);
        if (count($class_constant_map) + count($property_map) + count($method_map) === 0) {
            return null;
        }
        uksort($class_constant_map, 'strcmp');
        uksort($property_map, 'strcmp');
        uksort($method_map, 'strcmp');
        $suggestions = [];
        foreach ($class_constant_map as $constant_name => $_) {
            $suggestions[] = $class_fqsen . '::' . $constant_name;
        }
        foreach ($property_map as $property_name => $_) {
            $suggestions[] = $class_fqsen . '::$' . $property_name;
        }
        foreach ($method_map as $method) {
            $suggestions[] = $class_fqsen . '::' . $method->getName() . '()';
        }
        return Suggestion::fromString(
            'Did you mean ' . \implode(' or ', $suggestions)
        );
    }

    /**
     * @return ?Suggestion with values similar to the given constant
     */
    public static function suggestSimilarGlobalConstant(CodeBase $code_base, Context $context, FullyQualifiedGlobalConstantName $fqsen): ?Suggestion
    {
        if (Config::getValue('disable_suggestions')) {
            return null;
        }
        $constant_name = $fqsen->getName();
        if (strlen($constant_name) <= 1) {
            return null;
        }
        $namespace = $fqsen->getNamespace();
        $suggestions = \array_merge(
            self::suggestSimilarFunctionsToConstant($code_base, $context, $fqsen),
            self::suggestSimilarClassConstantsToGlobalConstant($code_base, $context, $fqsen),
            self::suggestSimilarClassPropertiesToGlobalConstant($code_base, $context, $fqsen),
            $code_base->suggestSimilarConstantsToConstant($constant_name),
            $code_base->suggestSimilarGlobalConstantForNamespaceAndName($namespace, $constant_name),
            $namespace !== '\\' ? $code_base->suggestSimilarGlobalConstantForNamespaceAndName('\\', $constant_name) : [],
            self::suggestSimilarVariablesToGlobalConstant($context, $fqsen)
        );
        if (count($suggestions) === 0) {
            return null;
        }
        $suggestions = self::deduplicateSuggestions(\array_map('strval', $suggestions));
        \sort($suggestions, \SORT_STRING);
        return Suggestion::fromString(
            'Did you mean ' . \implode(' or ', $suggestions)
        );
    }

    /**
     * @template T
     * @param T[] $suggestions
     * @return list<T>
     */
    private static function deduplicateSuggestions(array $suggestions): array
    {
        $result = [];
        foreach ($suggestions as $suggestion) {
            $result[(string)$suggestion] = $suggestion;
        }
        return \array_values($result);
    }

    /**
     * @return list<string>
     */
    private static function suggestSimilarFunctionsToConstant(CodeBase $code_base, Context $context, FullyQualifiedGlobalConstantName $fqsen): array
    {
        $suggested_fqsens = $code_base->suggestSimilarGlobalFunctionInOtherNamespace(
            $fqsen->getNamespace(),
            $fqsen->getName(),
            $context,
            true
        );
        return \array_map(static function (FullyQualifiedFunctionName $fqsen): string {
            return $fqsen . '()';
        }, $suggested_fqsens);
    }

    /**
     * Suggests accessible class constants of the current class that are similar to the passed in global constant FQSEN
     * @return list<string>
     */
    private static function suggestSimilarClassConstantsToGlobalConstant(CodeBase $code_base, Context $context, FullyQualifiedGlobalConstantName $fqsen): array
    {
        if (!$context->isInClassScope()) {
            return [];
        }
        if (\ltrim($fqsen->getNamespace(), '\\') !== '') {
            return [];
        }
        try {
            $class = $context->getClassInScope($code_base);
            $name = $fqsen->getName();
            if ($class->hasConstantWithName($code_base, $name)) {
                return ["self::$name"];
            }
        } catch (\Exception $_) {
            // ignore
        }
        return [];
    }

    /**
     * Suggests accessible class properties of the current class that are similar to the passed in global constant FQSEN
     * @return list<string>
     */
    private static function suggestSimilarClassPropertiesToGlobalConstant(CodeBase $code_base, Context $context, FullyQualifiedGlobalConstantName $fqsen): array
    {
        if (!$context->isInClassScope()) {
            return [];
        }
        if (\ltrim($fqsen->getNamespace(), '\\') !== '') {
            return [];
        }
        $name = $fqsen->getName();
        try {
            $class = $context->getClassInScope($code_base);
            if (!$class->hasPropertyWithName($code_base, $name)) {
                return [];
            }
            $property = $class->getPropertyByName($code_base, $name);
            if (!$property->isAccessibleFromClass($code_base, $class->getFQSEN())) {
                return [];
            }
            if ($property->isStatic()) {
                return ['self::$' . $name];
            } elseif ($context->isInFunctionLikeScope()) {
                $current_function = $context->getFunctionLikeInScope($code_base);
                if (!$current_function->isStatic()) {
                    return ['$this->' . $name];
                }
            }
        } catch (\Exception $_) {
            // ignore
        }
        return [];
    }

    /**
     * @return list<string> returns array variable names prefixed with '$' with a similar name, or an empty array if that wouldn't make sense or there would be too many suggestions
     */
    private static function suggestSimilarVariablesToGlobalConstant(Context $context, FullyQualifiedGlobalConstantName $fqsen): array
    {
        if ($context->isInGlobalScope()) {
            return [];
        }
        if (\ltrim($fqsen->getNamespace(), '\\') !== '') {
            // Give up if requesting a namespaced constant
            // TODO: Better heuristics
            return [];
        }
        return self::getVariableNamesInScopeWithSimilarName($context, $fqsen->getName());
    }

    /**
     * @return array<string,ClassConstant>
     */
    private static function suggestSimilarClassConstantMap(CodeBase $code_base, Context $context, Clazz $class, string $constant_name): array
    {
        $constant_map = $class->getConstantMap($code_base);
        if (count($constant_map) > Config::getValue('suggestion_check_limit')) {
            return [];
        }
        $usable_constant_map = self::filterSimilarConstants($code_base, $context, $constant_map);
        $result = self::getSuggestionsForStringSet($constant_name, $usable_constant_map);
        return $result;
    }

    /**
     * @param array<string,ClassConstant> $constant_map
     * @return array<string,ClassConstant> a subset of those class constants that are accessible from the current scope.
     * @internal
     */
    public static function filterSimilarConstants(CodeBase $code_base, Context $context, array $constant_map): array
    {
        $class_fqsen_in_current_scope = self::maybeGetClassInCurrentScope($context);

        $candidates = [];
        foreach ($constant_map as $constant_name => $constant) {
            if (!$constant->isAccessibleFromClass($code_base, $class_fqsen_in_current_scope)) {
                // Don't suggest inherited private properties
                continue;
            }
            // TODO: Check for access to protected outside of a class
            $candidates[$constant_name] = $constant;
        }
        return $candidates;
    }

    /**
     * Return a suggestion for variables with similar spellings to $variable_name (which may or may not exist or be used elsewhere), or null.
     *
     * Suggestions also include accessible properties with similar names (e.g. suggest `$this->context` if `$context` is not declared)
     */
    public static function suggestVariableTypoFix(CodeBase $code_base, Context $context, string $variable_name, string $prefix = 'Did you mean'): ?Suggestion
    {
        if (Config::getValue('disable_suggestions')) {
            return null;
        }
        if ($variable_name === '') {
            return null;
        }
        if (!$context->isInFunctionLikeScope()) {
            // Don't bother suggesting globals for now
            return null;
        }
        // Suggest similar variable names in the current scope.
        $suggestions = self::getVariableNamesInScopeWithSimilarName($context, $variable_name);
        // Suggest instance or static properties of the same name, if accessible
        if ($context->isInClassScope()) {
            // TODO: Does this need to check for static closures
            $class_in_scope = $context->getClassInScope($code_base);
            if ($class_in_scope->hasPropertyWithName($code_base, $variable_name)) {
                $property = $class_in_scope->getPropertyByName($code_base, $variable_name);
                if (self::shouldSuggestProperty($context, $class_in_scope, $property)) {
                    if ($property->isStatic()) {
                        $suggestions[] = 'self::$' . $variable_name;
                    } elseif ($context->isInFunctionLikeScope()) {
                        $current_function = $context->getFunctionLikeInScope($code_base);
                        if (!$current_function->isStatic()) {
                            $suggestions[] = '$this->' . $variable_name;
                        }
                    }
                }
            }
        }
        // Suggest using the variable if it is defined but not used by the current closure.
        $scope = $context->getScope();
        $did_suggest_use_in_closure = false;
        while ($scope->isInFunctionLikeScope()) {
            $function = $context->withScope($scope)->getFunctionLikeInScope($code_base);
            if (!($function instanceof Func) || !$function->isClosure()) {
                break;
            }
            $scope = $function->getContext()->getScope()->getParentScope();
            if ($scope->hasVariableWithName($variable_name)) {
                $did_suggest_use_in_closure = true;
                $suggestions[] = "(use(\$$variable_name) for {$function->getNameForIssue()} at line {$function->getContext()->getLineNumberStart()})";
                break;
            }
        }
        if (!$did_suggest_use_in_closure && Variable::isHardcodedGlobalVariableWithName($variable_name)) {
            $suggestions[] = "(global \$$variable_name)";
        }
        if (count($suggestions) === 0) {
            return null;
        }
        \sort($suggestions);

        return Suggestion::fromString(
            $prefix . ' ' . \implode(' or ', $suggestions)
        );
    }

    private static function shouldSuggestProperty(Context $context, Clazz $class_in_scope, Property $property): bool
    {
        if ($property->isDynamicProperty()) {
            // Don't suggest properties that weren't declared.
            return false;
        }
        if ($property->isPrivate() && $property->getDefiningClassFQSEN() !== $class_in_scope->getFQSEN()) {
            // Don't suggest inherited private properties that can't be accessed
            // - This doesn't need to be checking if the visibility is protected,
            //   because it's looking for properties of the current class
            return false;
        }
        if ($property->isStatic()) {
            if (!$context->getScope()->hasVariableWithName('this')) {
                // Don't suggest $this->prop from a static method or a static closure.
                return false;
            }
        }
        return true;
    }

    /**
     * @return list<string> Suggestions for variable names, prefixed with "$"
     */
    private static function getVariableNamesInScopeWithSimilarName(Context $context, string $variable_name): array
    {
        $suggestions = [];
        $variable_candidates = $context->getScope()->getVariableMap();
        if (count($variable_candidates) <= Config::getValue('suggestion_check_limit')) {
            $variable_candidates = \array_merge($variable_candidates, Variable::_BUILTIN_SUPERGLOBAL_TYPES);
            $variable_suggestions = self::getSuggestionsForStringSet($variable_name, $variable_candidates);

            foreach ($variable_suggestions as $suggested_variable_name => $_) {
                $suggestions[] = '$' . $suggested_variable_name;
            }
        }
        return $suggestions;
    }
    /**
     * A very simple way to get the closest case-insensitive string matches.
     *
     * @param array<string,mixed> $potential_candidates
     * @return array<string,mixed> a subset of $potential_candidates
     */
    public static function getSuggestionsForStringSet(string $target, array $potential_candidates): array
    {
        if (count($potential_candidates) === 0) {
            return [];
        }
        $search_name = strtolower($target);
        $target_length = strlen($search_name);
        if ($target_length > self::MAX_SUGGESTION_NAME_LENGTH) {
            return [];
        }
        $max_levenshtein_distance = (int)(1 + strlen($search_name) / 6);
        $best_matches = [];
        $min_found_distance = $max_levenshtein_distance;

        foreach ($potential_candidates as $name => $value) {
            $name = (string)$name;
            if (\strncasecmp($name, $target, $target_length) === 0) {
                // If this has $target as a case-insensitive prefix, then treat it as a fairly good match
                // (included with single-character edits)
                $distance = $target_length !== strlen($name) ? 1 : 0;
            } elseif ($target_length >= 1) {
                if (strlen($name) > self::MAX_SUGGESTION_NAME_LENGTH || \abs(strlen($name) - $target_length) > $max_levenshtein_distance) {
                    continue;
                }
                $distance = \levenshtein(strtolower($name), $search_name);
            } else {
                continue;
            }
            if ($distance <= $min_found_distance) {
                if ($distance < $min_found_distance) {
                    $min_found_distance = $distance;
                    $best_matches = [];
                }
                $best_matches[$name] = $value;
            }
        }
        return $best_matches;
    }
}