tool/phan_repl_helpers.php

Summary

Maintainability
D
2 days
Test Coverage
<?php

declare(strict_types=1);

use Phan\CLI;
use Phan\Language\Element\Comment;
use Phan\Language\Element\MarkupDescription;
use Phan\Library\StringUtil;

// On the off chance that php or an extension ever provides a global function called 'help',
// check for this so that other utilities will work.
if (!function_exists('help')) {

/**
 * tool/phan_repl_helpers.php is a utility that can be loaded after `php -a` is started.
 *
 * It provides the following:
 * - A prototype replacement for PHP's code completion, on platforms where readline was installed.
 *   **This does not take advantage of Phan's inference and only reads the last line of multi-line expressions/statements.**
 * - A prototype global function `help()` which will dump information about constants/functions/objects/classes.
 *
 *   The format of this will probably change.
 * - Access to an environment where Phan's bootstrapping and the project's autoloader already ran and Phan's classes can be autoloaded.
 *
 * Examples of how this can be loaded and used from a PHP shell:
 *
 * ```
 * php > require_once 'tool/phan_repl_helpers.php';
 *
 * php > help(\Phan\CLI::class);
 * Help for class Phan\CLI defined at /path/to/phan/src/Phan/CLI.php:79.
 *
 * Contains methods for parsing CLI arguments to Phan,
 * outputting to the CLI, as well as helper methods to retrieve files/folders
 * for the analyzed project.
 *
 * php > help('ast\AST_BINARY_OP');
 * Help for global constant ast\AST_BINARY_OP
 *
 * Value: 520
 *
 * A binary operation of the form `left op right`.
 * The operation is determined by the flags `ast\flags\BINARY_*`
 * (children: left, right)
 * ```
 *
 * tool/phan_repl_helpers.php also replaces the code completion capabilities
 * of `php -a` with an alternative with a different feature set.
 *
 * ```
 * php > require_once 'tool/phan_repl_helpers.php';
 * php > $object = new ArrayObject();
 * php > help($object);
 * Help for class ArrayObject defined by module SPL.
 *
 * This class allows objects to work as arrays.
 *
 * php > $object->a<TAB>
 * append  asort
 * ```
 *
 * @suppress PhanUnreferencedFunction this is meant to be used interactively and is currently untested
 */
    function help($value = "\x00extended_help"): void
    {
        phan_repl_help($value);
    }
} /* End function_exists('help') check */

/**
 * Actual implementation of help()
 */
function phan_repl_help($value = "\x00extended_help"): void
{
    if ($value === "\x00extended_help") {
        echo "Phan " . CLI::PHAN_VERSION . " CLI autocompletion utilities.\n";
        echo "Type help(\$value); or help('function or constant or class name'); for help.\n";
        echo "Type help('help'); for extended help.\n";
        return;
    }
    if ($value instanceof Closure || (is_string($value) && function_exists($value)) || $value instanceof ReflectionFunction) {
        // @phan-suppress-next-line PhanPartialTypeMismatchArgumentInternal not sure why
        $reflection_function = $value instanceof ReflectionFunction ? $value : new ReflectionFunction($value);
        $function_name = $reflection_function->getName();
        $doc_comment = $reflection_function->getDocComment();
        if ($reflection_function->isUserDefined()) {
            $details = 'defined at ' . $reflection_function->getFileName() . ':' . $reflection_function->getStartLine();
        } else {
            $details = 'defined by module ' . $reflection_function->getExtensionName();
        }
        echo "Help for function $function_name $details.\n\n";
        // TODO: Use Phan's stub generation code and handle any issues caused by inheritance?
        // echo "$reflection_function\n";
        $description = '';
        if (is_string($doc_comment)) {
            $description = MarkupDescription::extractDocComment($doc_comment, Comment::ON_FUNCTION);
        }
        if (strlen($description) > 0) {
            echo rtrim($description) . "\n\n";
            return;
        }
        $function_documentation = MarkupDescription::loadFunctionDescriptionMap()[strtolower($function_name)] ?? '';
        if ($function_documentation !== '') {
            echo rtrim($function_documentation) . "\n\n";
            return;
        }
        echo "Could not find info on $function_name\n\n";
        return;
    }
    if (is_object($value) || (is_string($value) && (class_exists($value) || trait_exists($value) || interface_exists($value)))) {
        $class_name = is_string($value) ? $value : get_class($value);
        $reflection_class = new ReflectionClass($class_name);
        $class_name = ltrim($class_name, '\\');
        if ($reflection_class->isUserDefined()) {
            $details = 'defined at ' . $reflection_class->getFileName() . ':' . $reflection_class->getStartLine();
        } else {
            $details = 'defined by module ' . $reflection_class->getExtensionName();
        }
        echo "Help for class $class_name $details.\n\n";
        $doc_comment = $reflection_class->getDocComment();
        $description = '';
        if (is_string($doc_comment)) {
            $description = MarkupDescription::extractDocComment($doc_comment, Comment::ON_CLASS);
        }
        if (strlen($description) > 0) {
            echo rtrim($description) . "\n\n";
            return;
        }
        $class_documentation = MarkupDescription::loadClassDescriptionMap()[strtolower(ltrim($class_name, '\\'))] ?? '';
        if ($class_documentation !== '') {
            echo rtrim($class_documentation) . "\n\n";
            return;
        }
        echo "Could not find info on $class_name\n\n";
        return;
    }
    if (is_string($value) && defined($value)) {
        // TODO: Make this properly case sensitive for names but not namespaces
        // TODO: Support class constants
        echo "Help for global constant $value\n\n";
        echo "Value: " . StringUtil::jsonEncode(constant($value)) . "\n\n";
        $constant_documentation = MarkupDescription::loadConstantDescriptionMap()[strtolower($value)] ?? '';
        if ($constant_documentation !== '') {
            echo rtrim($constant_documentation) . "\n\n";
            return;
        }
        echo "Could not find info on $value\n\n";
        return;
    }
    echo "Unknown value for help(). Value was:\n";
    var_dump($value);
}

/**
 * TODOs:
 * - Take advantage of Phan's static analysis compatibilities for generating
 *   readline suggestions.
 *   Currently, this only reads the last 3 tokens and doesn't take advantage of Phan's inference.
 * - Support help() for remaining element types
 * - Look into alternative approaches
 * - Look into ways to get the previous lines contents when the expression/statement to be evaluated contains newlines.
 * - Integrate with other tools such as tool/phoogle to create a useful debugging environment
 */

// Currently used for signature info
require_once(__DIR__ . '/../src/Phan/Bootstrap.php');

// Phan's error handler terminates the process when there's an unexpected notice. This isn't helpful in an interactive shell.
restore_error_handler();

/**
 * Utilities such as completions to be added to `php -a` after launching it.
 *
 * This is written as a class with public/protected methods to make it easier to extend or to unit test.
 *
 * TODO: When possible, take advantage of the code that already exists
 * in Phan to generate completion for files, keywords, etc.
 *
 * TODO: Add unit tests
 * @phan-file-suppress PhanPluginRemoveDebugAny this is a debugging utility
 * @phan-file-suppress PhanAccessMethodInternal this is bundled with phan
 */
class PhanPhpShellUtils
{
    /** @var bool whether to emit debugging code */
    private $debug;

    public function __construct(bool $debug = false)
    {
        $this->debug = $debug;
    }

    /**
     * Append a line to a logging file
     */
    public function appendToLogFile(string $line): void
    {
        if (!$this->debug) {
            return;
        }
        @file_put_contents('/tmp/phan_repl_helpers.php', $line, FILE_APPEND);
    }

    /**
     * Generate completions for the current token
     *
     * @param list<int|string> $candidates
     * @return list<string>
     */
    public function generateCompletionsFromCandidates(array $candidates, string $prefix, string $prefix_to_add_to_completion): array
    {
        $prefix_len = strlen($prefix);
        $completions = [];
        foreach ($candidates as $candidate) {
            if (!is_string($candidate)) {
                continue;
            }
            $candidate_len = strlen($candidate);
            if ($candidate_len >= $prefix_len && strncmp($prefix, $candidate, $prefix_len) === 0) {
                $completions[] = $prefix_to_add_to_completion . $candidate;
            }
        }
        return $completions;
    }

    /**
     * Generate completions for a variable. TODO: Account for local variables
     * @return list<string>
     */
    public function generateVariableCompletions(string $last_token_str): array
    {
        $prefix = ltrim($last_token_str, '${');
        $keys = array_keys($GLOBALS);
        $keys[] = 'GLOBALS';
        $completions = self::generateCompletionsFromCandidates($keys, $prefix, '$');
        $this->appendToLogFile("generateVariableCompletions for $last_token_str = " . StringUtil::jsonEncode($completions) . "\n");

        return $completions;
    }

    /**
     * Convert a token to a string
     * @param array{0:int,1:string,2:int}|string|false $token
     */
    public static function tokenToString($token): string
    {
        return is_array($token) ? $token[1] : (string)$token;
    }

    /**
     * Generate completions for accessing instance property or methods where the object instance is known
     *
     * @return list<string>
     * @suppress PhanCompatibleObjectTypePHP71
     */
    public function generateCompletionsForInstancePropertyOfObject(object $object, string $instance_element_prefix): array
    {
        // Gets the accessible non-static properties of the given object according to scope.
        $property_candidates = array_keys(get_object_vars($object));
        $property_completions = self::generateCompletionsFromCandidates($property_candidates, $instance_element_prefix, '');
        $reflection_object = new ReflectionClass($object);
        $method_candidates = [];
        foreach ($reflection_object->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
            $method_candidates[] = $method->getName(); //  . '('; seems to cause extra whitespace to get added
        }
        $method_completions = self::generateCompletionsFromCandidates($method_candidates, $instance_element_prefix, '');
        $completions = array_merge($property_completions, $method_completions);
        if ($method_completions && !$property_completions) {
            $this->setReadlineConfig('completion_append_character', "(");
        }
        $this->appendToLogFile("generateCompletionsForInstancePropertyOfObject completions = " . StringUtil::jsonEncode($completions) . "\n");
        return $completions;
    }

    /**
     * Generate completions for accessing instance property or methods ($obj->prefix)
     *
     * @param list<array{0:int,1:string,2:int}|string> $tokens
     * @return list<string>
     */
    public function generateInstanceObjectCompletions(array $tokens): array
    {
        $i = count($tokens) - 1;
        $this->appendToLogFile("generateInstanceObjectCompletions tokens = " . StringUtil::jsonEncode($tokens) . "\n");
        while (!is_array($tokens[$i]) || $tokens[$i][0] !== T_OBJECT_OPERATOR) {
            $i--;
            if ($i <= 0) {
                return [];
            }
        }
        $instance_element_prefix = self::tokenToString($tokens[$i + 1] ?? '');
        // Not definitely the expression - tolerant-php-parser would be a better way to fetch this.
        $expression = $tokens[$i - 1];
        $expression_str = self::tokenToString($expression);
        if (is_array($expression) && $expression[0] === T_VARIABLE) {
            $var_name = substr($expression_str, 1);
            $global_var = $GLOBALS[$var_name] ?? null;
            if (!is_object($global_var)) {
                return [];
            }
            return $this->generateCompletionsForInstancePropertyOfObject($global_var, $instance_element_prefix);
        }
        $this->appendToLogFile("instance_element_prefix = '$instance_element_prefix' expression=$expression_str\n");
        return [];
    }

    /**
     * Generate completions for SomeClass::$prefix or SomeClass::prefix
     * @return list<string>
     */
    public function generateStaticElementSuggestionsForClass(string $class, string $instance_element_prefix): array
    {
        // TODO support ::class
        if (!class_exists($class)) {
            return [];
        }
        $reflection_class = new ReflectionClass($class);
        $property_completions = [];
        if (($instance_element_prefix[0] ?? '$') === '$') {
            // Generate completions for static properties
            $property_candidates = [];
            foreach ($reflection_class->getProperties(ReflectionProperty::IS_STATIC | ReflectionProperty::IS_PUBLIC) as $prop) {
                $property_candidates[] = '$' . $prop->getName();
            }
            $property_completions = $this->generateCompletionsFromCandidates($property_candidates, $instance_element_prefix, '');
            if ($instance_element_prefix !== '') {
                return $property_completions;
            }
        }
        // TODO: PHP adds filtering by ReflectionClassConstant::IS_PUBLIC in 8.0
        // TODO: Make some of these case insensitive?

        $constant_candidates = ['class'];
        foreach ($reflection_class->getReflectionConstants() as $reflection_constant) {
            if (!$reflection_constant->isPublic()) {
                continue;
            }
            $constant_candidates[] = $reflection_constant->getName();
        }
        $constant_completions = $this->generateCompletionsFromCandidates($constant_candidates, $instance_element_prefix, '');

        $method_candidates = [];
        foreach ($reflection_class->getMethods(ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC) as $reflection_method) {
            if (!$reflection_method->isPublic()) {
                continue;
            }
            $method_candidates[] = $reflection_method->getName();
        }
        $method_completions = $this->generateCompletionsFromCandidates($method_candidates, $instance_element_prefix, '');

        return array_merge(
            $property_completions,
            $constant_completions,
            $method_completions
        );
    }

    /**
     * Generate completions for accessing class constants, static properties or methods ($obj::prefix)
     *
     * @param list<array{0:int,1:string,2:int}|string> $tokens
     * @param string $completed_text this is the `Foo::prefix` that returned values need to begin with
     * @return list<string>
     */
    public function generateStaticObjectCompletions(array $tokens, string $completed_text): array
    {
        $i = count($tokens) - 1;
        $this->appendToLogFile("generateStaticObjectCompletions tokens = " . StringUtil::jsonEncode($tokens) . "\n");
        while (!is_array($tokens[$i]) || $tokens[$i][0] !== T_DOUBLE_COLON) {
            $i--;
            if ($i <= 0) {
                return [];
            }
        }
        $instance_element_prefix = self::tokenToString($tokens[$i + 1] ?? '');
        // Not definitely the expression - tolerant-php-parser would be a better way to fetch this.
        $expression = $tokens[$i - 1];
        $expression_str = self::tokenToString($expression);
        if (is_array($expression) && $expression[0] === T_STRING) {
            // TODO: Check if this snippet is within a namespace block with uses, etc.
            // Or just reuse Phan's real completion abilities.
            $class_name = $expression[1];
            $pos = strrpos($completed_text, '::');
            if (!is_int($pos)) {
                return [];
            }
            $new_prefix = substr($completed_text, 0, $pos + 2);
            $completions = [];
            foreach ($this->generateStaticElementSuggestionsForClass($class_name, $instance_element_prefix) as $element_name) {
                $completions[] = $new_prefix . $element_name;
            }
            return $completions;
        }
        $this->appendToLogFile("instance_element_prefix = '$instance_element_prefix' expression=$expression_str\n");
        return [];
    }

    /**
     * @return list<string> a list of completions for a generic identifier
     */
    public function generateCompletionsForGlobalName(string $prefix): array
    {
        $function_candidates = array_values(array_merge(...array_values(get_defined_functions(true))));
        $function_completions = $this->generateCompletionsFromCandidates($function_candidates, $prefix, '');
        // @phan-suppress-next-line PhanRedundantArrayValuesCall
        $other_candidates = array_values(array_merge(
            get_declared_classes(),
            get_declared_traits(),
            get_declared_interfaces(),
            array_keys(get_defined_constants())
        ));
        $other_completions = $this->generateCompletionsFromCandidates($other_candidates, $prefix, '');
        if ($function_completions && !$other_completions) {
            $this->setReadlineConfig('completion_append_character', "(");
        }
        $result = array_merge($function_completions, $other_completions);
        $prefix_len = strlen($prefix);
        foreach ($result as &$val) {
            $i = strrpos(substr($val, 0, $prefix_len), '\\');
            if ($i !== false) {
                $val = substr($val, $i + 1);
            }
        }
        return $result;
    }

    /**
     * @param string|bool|int $value
     */
    protected function setReadlineConfig(string $key, $value): void
    {
        readline_info($key, $value);
    }

    /** Workaround to make readline not print any suggestions. Not sure how if this will work on all versions. */
    public const NO_AVAILABLE_COMPLETIONS = [''];

    /**
     * Generate completion for any token
     * @return list<string>
     */
    public function generateCompletions(string $text, int $start, int $end): array
    {
        $this->setReadlineConfig('completion_append_character', "\x00");
        try {
            // TODO: PHP's API only allows us to fetch the most recent line.
            $line_buffer = readline_info('line_buffer');
            $tokens = (@token_get_all('<' . '?php ' . $line_buffer)) ?: [''];  // Split up to fix vim syntax highlighting.
            $last_token = end($tokens);
            $last_token_str = self::tokenToString($last_token);
            $this->appendToLogFile("text='''$text''' start=$start end=$end line_buffer='''$line_buffer''' last_token_str='''$last_token_str'''\n");
            $c = $last_token_str[0] ?? '';
            $prev_token = prev($tokens);
            $prev_token_str = self::tokenToString($prev_token);
            if ($last_token_str === '::' || $prev_token_str === '::') {
                // Complete static members
                // (Must check if this is completing a static property instead of a variable)
                return $this->generateStaticObjectCompletions($tokens, $text) ?: self::NO_AVAILABLE_COMPLETIONS;
            } elseif ($c === '$') {
                return $this->generateVariableCompletions($last_token_str) ?: self::NO_AVAILABLE_COMPLETIONS;
            } elseif ($last_token_str === '->' || $prev_token_str === '->') {
                // TODO: Actually infer types for expressions other than variables
                return $this->generateInstanceObjectCompletions($tokens) ?: self::NO_AVAILABLE_COMPLETIONS;
            }
            if ($last_token_str === '\\') {
                if (is_array($prev_token)) {
                    $prev_token_kind = $prev_token[0];
                    // TODO: T_NAME_RELATIVE for namespace\
                    // @phan-suppress-next-line PhanUndeclaredConstant
                    if ($prev_token_kind === T_STRING || PHP_VERSION_ID >= 80000 && in_array($prev_token_kind, [T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED], true)) {
                        $last_token_str = ltrim($prev_token[1], '\\') . $last_token_str;
                    }
                }
            }

            // TODO: Handle completions when the text is incorrectly tokenized (e.g. 'ast\parse_')
            // That would benefit from using tolerant-php-parser to identify identifiers that contain multiple tokens (e.g. `$x = ast\parse_<TAB>`)
            // Alternately, just look for T_STRING and T_BACKSLASH and T_WHITESPACE combinations
            return $this->generateCompletionsForGlobalName($last_token_str) ?: self::NO_AVAILABLE_COMPLETIONS;
            // TODO: $c === '#' for ini completions for ini_set().
        } catch (Throwable $e) {
            $this->appendToLogFile("Caught $e");
        }
        return self::NO_AVAILABLE_COMPLETIONS;
    }
}

if (function_exists('readline_completion_function')) {
    readline_completion_function([new PhanPhpShellUtils(true), 'generateCompletions']);
} else {
    echo __FILE__ . "could not install a readline_completion_function - the readline extension is unavailable\n";
}