internal/lib/IncompatibleStubsSignatureDetector.php

Summary

Maintainability
F
1 wk
Test Coverage
<?php

declare(strict_types=1);

use Phan\Analysis;
use Phan\CodeBase;
use Phan\Config;
use Phan\Exception\FQSENException;
use Phan\Language\Element\ClassConstant;
use Phan\Language\Element\Clazz;
use Phan\Language\Element\Func;
use Phan\Language\Element\GlobalConstant;
use Phan\Language\Element\MarkupDescription;
use Phan\Language\Element\Method;
use Phan\Language\Element\Property;
use Phan\Language\FQSEN\FullyQualifiedClassName;
use Phan\Language\FQSEN\FullyQualifiedFunctionName;
use Phan\Language\FQSEN\FullyQualifiedMethodName;
use Phan\Library\StringUtil;
use Phan\Output\Collector\BufferingCollector;
use Phan\Phan;

require_once __DIR__ . '/IncompatibleSignatureDetectorBase.php';

/**
 * This reads from a folder containing PHP stub files documenting internal extensions (e.g. those from PHPStorm)
 * to check if Phan's function signature map are up to date.
 *
 * `php-ast` does not differentiate between echo statements and inline html.
 *
 * @phan-file-suppress PhanPluginRemoveDebugAny
 */
class IncompatibleStubsSignatureDetector extends IncompatibleSignatureDetectorBase
{
    /** @var string a directory which contains stubs written in PHP for classes, functions, etc. of PHP modules (extensions)  */
    private $directory;

    /** @var CodeBase The code base within which we're operating */
    private $code_base;

    public function __construct(string $dir)
    {
        if (!file_exists($dir)) {
            echo "Could not find '$dir'\n";
            static::printUsageAndExit();
        }
        Phan::setIssueCollector(new BufferingCollector());
        // Disable Phan's own internal stubs, they interfere with loading stubs in the provided directories.
        Config::setValue('autoload_internal_extension_signatures', []);

        $realpath = realpath($dir);
        if (!is_string($realpath)) {
            echo "Could not find realpath of '$dir'\n";
            static::printUsageAndExit();
            return;
        }
        $this->directory = $realpath;

        // TODO: Change to a more suitable configuration?
        $this->code_base = require(dirname(__DIR__) . '/../src/codebase.php');
        $this->initStubs();
    }

    /**
     * Check that this extracts the correct signature types from the folder.
     * @suppress PhanPluginMixedKeyNoKey
     */
    public function selfTest(): void
    {
        fwrite(STDERR, "Running a test that this directory contains commonly used signature - ignore this if this is only for a single extension\n");
        $failures = 0;
        $failures += $this->expectFunctionLikeSignaturesMatch('strlen', ['int', 'string' => 'string']);
        // $failures += $this->expectFunctionLikeSignaturesMatch('ob_clean', ['void']);
        $failures += $this->expectFunctionLikeSignaturesMatch('intdiv', ['int', 'numerator' => 'int', 'divisor' => 'int']);
        $failures += $this->expectFunctionLikeSignaturesMatch('ArrayIterator::seek', ['void', 'position' => 'int']);
        if ($failures) {
            fwrite(STDERR, "Saw $failures incorrect or missing signatures\n");
        }
        // $failures += $this->expectFunctionLikeSignaturesMatch('Redis::hGet', ['string', 'key' => 'string', 'hashKey' => 'string']);
        /*
        if ($failures > 1) {
            exit(1);
        }
         */
    }

    /**
     * @param array<int|string,string> $expected the Phan signature information in the stubs
     */
    private function expectFunctionLikeSignaturesMatch(string $function_name, array $expected): int
    {
        $actual = $this->parseFunctionLikeSignature($function_name);
        if ($expected !== $actual) {
            fprintf(STDERR, "Extraction failed for %s\nExpected: %s\nActual:   %s\n", $function_name, json_encode($expected) ?: 'invalid', json_encode($actual) ?: 'invalid');
            return 1;
        }
        return 0;
    }

    /** @var bool has this initialized and parsed all of the stubs yet? */
    private $initialized = false;

    /**
     * @return array<int,string>
     */
    private function getFileList(): array
    {
        if (is_file($this->directory)) {
            return [$this->directory];
        }
        $iterator = new \CallbackFilterIterator(
            new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator(
                    $this->directory,
                    \RecursiveDirectoryIterator::FOLLOW_SYMLINKS
                )
            ),
            static function (SplFileInfo $file_info): bool {
                if ($file_info->getExtension() !== 'php') {
                    return false;
                }

                if (!$file_info->isFile() || !$file_info->isReadable()) {
                    $file_path = $file_info->getRealPath();
                    error_log("Unable to read file {$file_path}");
                    return false;
                }

                return true;
            }
        );

        // @phan-suppress-next-line PhanPartialTypeMismatchReturn
        return array_keys(iterator_to_array($iterator));
    }

    /**
     * Initialize the stub information to write by parsing the folder with Phan.
     */
    public function initStubs(): void
    {
        if ($this->initialized) {
            return;
        }
        $this->initialized = true;

        $file_list = $this->getFileList();
        if (count($file_list) === 0) {
            fwrite(STDERR, "Could not find any files in $this->directory");
            static::printUsageAndExit();
        }
        sort($file_list);

        // TODO: Load without internal signatures
        $code_base = $this->code_base;
        foreach ($file_list as $path_to_stub) {
            fwrite(STDERR, "Loading stub $path_to_stub\n");
            try {
                Analysis::parseFile($code_base, $path_to_stub, false, null, /* is_php_internal_stub = false, so that we actually parse phpdoc */ false);
            } catch (Exception $e) {
                fprintf(STDERR, "Caught exception parsing %s: %s: %s\n", $path_to_stub, get_class($e), $e->getMessage());
                // throw $e;
            }
        }
        Analysis::analyzeFunctions($code_base);
    }

    /**
     * @return ?array<mixed,string>
     * @throws FQSENException if signature map is invalid
     */
    public function parseMethodSignature(string $class_name, string $method_name): ?array
    {
        $this->initStubs();
        if ($class_name[0] !== '\\') {
            $class_name = '\\' . $class_name;
        }

        $code_base = $this->code_base;
        $class_fqsen = FullyQualifiedClassName::fromFullyQualifiedString($class_name);
        if (!$code_base->hasClassWithFQSEN($class_fqsen)) {
            static::debug("Could not find $class_name\n");
            return null;
        }
        $class = $code_base->getClassByFQSEN($class_fqsen);
        for ($alternate_id = 1; $class->isPHPInternal(); $alternate_id++) {
            $alternate_class_fqsen = $class_fqsen->withAlternateId($alternate_id);
            if (!$code_base->hasClassWithFQSEN($alternate_class_fqsen)) {
                break;
            }
            $class = $code_base->getClassByFQSEN($alternate_class_fqsen);
        }
        if ($class->isPHPInternal()) {
            static::debug("Could not find $class_name except from reflection\n");
            return null;
        }

        $method_fqsen = FullyQualifiedMethodName::make($class_fqsen, $method_name);
        if (!$code_base->hasMethodWithFQSEN($method_fqsen)) {
            static::debug("Could not find $method_fqsen\n");
            return null;
        }
        $method = $code_base->getMethodByFQSEN($method_fqsen);
        // echo "Found $method_fqsen at " . $method->getFileRef()->getFile() . "\n";

        $method->ensureScopeInitialized($code_base);
        return $method->toFunctionSignatureArray();
    }

    /**
     * @return ?array<mixed,string>
     * @throws FQSENException if $function_name is invalid
     */
    public function parseFunctionSignature(string $function_name): ?array
    {
        $this->initStubs();
        $function_fqsen = FullyQualifiedFunctionName::fromFullyQualifiedString($function_name);
        $code_base = $this->code_base;
        if (!$code_base->hasFunctionWithFQSEN($function_fqsen)) {
            static::debug("Could not find $function_name\n");
            return null;
        }
        $function = $code_base->getFunctionByFQSEN($function_fqsen);
        $function->ensureScopeInitialized($code_base);
        for ($alternate_id = 1; $function->isPHPInternal(); $alternate_id++) {
            $alternate_fqsen = $function_fqsen->withAlternateId($alternate_id);
            if (!$code_base->hasFunctionWithFQSEN($alternate_fqsen)) {
                break;
            }
            $function = $code_base->getFunctionByFQSEN($alternate_fqsen);
        }
        if ($function->isPHPInternal()) {
            static::debug("Could not find $function_name except from reflection\n");
            return null;
        }
        return $function->toFunctionSignatureArray();
    }

    /**
     * @return array<string,array<int|string,string>>
     * @override
     */
    public function getAvailableGlobalFunctionSignatures(): array
    {
        return $this->memoize(__METHOD__, /** @return array<string,array<int|string,string>> */ function (): array {
            $code_base = $this->code_base;
            $function_name_map = [];
            foreach ($code_base->getFunctionMap() as $func) {
                if (!($func instanceof Func)) {
                    throw new AssertionError('expected $func to be a Func');
                }
                $function_name = $func->getFQSEN()->getNamespacedName();
                $func->ensureScopeInitialized($code_base);
                $function_name_map[$function_name] = $func->toFunctionSignatureArray();
            }
            return $function_name_map;
        });
    }

    /**
     * @return array<string,array<int|string,string>>
     * @override
     */
    public function getAvailableMethodSignatures(): array
    {
        return $this->memoize(__METHOD__, /** @return array<string,array<int|string,string>> */ function (): array {
            $code_base = $this->code_base;
            $function_name_map = [];
            foreach ($code_base->getMethodSet() as $method) {
                if (!($method instanceof Method)) {
                    throw new AssertionError('expected $method to be a Method');
                }
                $function_name = $method->getClassFQSEN()->getNamespacedName() . '::' . $method->getName();
                $method->ensureScopeInitialized($code_base);
                $function_name_map[$function_name] = $method->toFunctionSignatureArray();
            }
            return $function_name_map;
        });
    }

    /**
     * @return array<string,string>
     */
    protected function getAvailablePropertyPHPDocSummaries(): array
    {
        return $this->memoize(__METHOD__, /** @return array<string,string> */ function (): array {
            $code_base = $this->code_base;
            $map = [];
            $classes = array_merge(
                iterator_to_array($code_base->getInternalClassMap(), false),
                iterator_to_array($code_base->getUserDefinedClassMap(), false)
            );
            foreach ($classes as $class) {
                foreach ($class->getPropertyMap($code_base) as $property) {
                    if ($property->getFQSEN() !== $property->getDefiningFQSEN()) {
                        // Skip this, Phan should be able to inherit this long term
                        continue;
                    }
                    if (!($property instanceof Property)) {
                        throw new AssertionError('expected $property to be a Property');
                    }
                    $description = (string)MarkupDescription::extractDescriptionFromDocComment($property, null);
                    $description = preg_replace('(^`@var [^`]*`\s*)', '', $description);
                    $description = self::removeBoilerplateFromDescription($description);
                    if (strlen($description) === 0) {
                        continue;
                    }
                    $property_name = ltrim((string)$property->getFQSEN(), "\\");
                    if (preg_match(self::FUNCTIONLIKE_BLACKLIST, $property_name)) {
                        continue;
                    }
                    echo "$property_name: $description\n";
                    $map[$property_name] = $description;
                }
            }
            return $map;
        });
    }

    /**
     * @return array<string,string>
     */
    protected function getAvailableClassPHPDocSummaries(): array
    {
        return $this->memoize(__METHOD__, /** @return array<string,string> */ function (): array {
            $code_base = $this->code_base;
            $map = [];
            $classes = array_merge(
                iterator_to_array($code_base->getInternalClassMap(), false),
                iterator_to_array($code_base->getUserDefinedClassMap(), false)
            );
            foreach ($classes as $class) {
                echo "Looking at {$class->getFQSEN()}\n";
                if (!($class instanceof Clazz)) {
                    throw new AssertionError('expected $class to be a Clazz');
                }
                $description = (string)MarkupDescription::extractDescriptionFromDocComment($class, null);
                $description = self::removeBoilerplateFromDescription($description);
                if (strlen($description) === 0) {
                    continue;
                }
                $class_name = ltrim((string)$class->getFQSEN(), "\\");
                if (preg_match(self::FUNCTIONLIKE_BLACKLIST, $class_name)) {
                    continue;
                }
                echo "$class_name: $description\n";
                $map[$class_name] = $description;
            }
            return $map;
        });
    }

    /**
     * Removes boilerplate such as minimum PHP versions from summary text
     */
    public static function removeBoilerplateFromDescription(string $description): string
    {
        return preg_replace('@\((PECL|PHP|No version information)[^)]*\)\s*<br/>\s*@im', '', $description);
    }

    /**
     * @return array<string,string>
     */
    protected function getAvailableConstantPHPDocSummaries(): array
    {
        return $this->memoize(__METHOD__, /** @return array<string,string> */ function (): array {
            $code_base = $this->code_base;
            $map = [];
            foreach ($code_base->getGlobalConstantMap() as $const) {
                if (!($const instanceof GlobalConstant)) {
                    throw new AssertionError('expected $const to be a GlobalConstant');
                }
                $description = (string)MarkupDescription::extractDescriptionFromDocComment($const, null);
                $description = self::removeBoilerplateFromDescription($description);
                if (strlen($description) === 0) {
                    continue;
                }
                $const_name = ltrim((string)$const->getFQSEN(), "\\");
                if (preg_match(self::FUNCTIONLIKE_BLACKLIST, $const_name)) {
                    continue;
                }
                echo "$const_name: $description\n";
                $map[$const_name] = $description;
            }
            foreach ($code_base->getClassMapMap() as $class_map) {
                foreach ($class_map->getClassConstantMap() as $const) {
                    if (!($const instanceof ClassConstant)) {
                        throw new AssertionError('expected $const to be a ClassConstant');
                    }
                    $description = (string)MarkupDescription::extractDescriptionFromDocComment($const, null);
                    // Remove the markup added by MarkdupDescription
                    $description = preg_replace('(^`@var [^`]*`\s*)', '', $description);

                    $description = self::removeBoilerplateFromDescription($description);
                    if (strlen($description) === 0) {
                        continue;
                    }

                    $const_name = ltrim((string)$const->getFQSEN(), "\\");
                    if (preg_match(self::FUNCTIONLIKE_BLACKLIST, $const_name)) {
                        continue;
                    }
                    echo "$const_name: $description\n";
                    $map[$const_name] = $description;
                }
            }
            return $map;
        });
    }

    /**
     * Get available function and method summaries from the stubs directory.
     *
     * @return array<string,string>
     */
    protected function getAvailableMethodPHPDocSummaries(): array
    {
        return $this->memoize(__METHOD__, /** @return array<string,string> */ function (): array {
            $code_base = $this->code_base;
            $function_name_map = [];
            foreach ($code_base->getMethodSet() as $method) {
                if (!($method instanceof Method)) {
                    throw new AssertionError('expected $method to be a Method');
                }
                $description = (string)MarkupDescription::extractDescriptionFromDocComment($method, null);
                $description = self::removeBoilerplateFromDescription($description);
                if (strlen($description) === 0) {
                    continue;
                }
                $function_name = $method->getClassFQSEN()->getNamespacedName() . '::' . $method->getName();
                if (preg_match(self::FUNCTIONLIKE_BLACKLIST, $function_name)) {
                    continue;
                }
                echo "$function_name: $description\n";
                $function_name_map[$function_name] = $description;
            }
            foreach ($code_base->getFunctionMap() as $function) {
                if (!($function instanceof Func)) {
                    throw new AssertionError('expected $function to be a Func');
                }
                $description = MarkupDescription::extractDescriptionFromDocComment($function, null);
                if (!StringUtil::isNonZeroLengthString($description)) {
                    continue;
                }
                $function_name = ltrim((string)$function->getFQSEN(), "\\");
                if (preg_match(self::FUNCTIONLIKE_BLACKLIST, $function_name)) {
                    continue;
                }
                echo "$function_name: $description\n";
                $function_name_map[$function_name] = $description;
            }
            return $function_name_map;
        });
    }
}