src/Phan/Config/Initializer.php

Summary

Maintainability
F
4 days
Test Coverage
<?php

declare(strict_types=1);

namespace Phan\Config;

use ast\Node;
use Closure;
use CompileError;
use Composer\Semver\Constraint\ConstraintInterface;
use Composer\Semver\VersionParser;
use ParseError;
use Phan\AST\Parser;
use Phan\AST\TolerantASTConverter\ParseException;
use Phan\CLI;
use Phan\CodeBase;
use Phan\Config;
use Phan\Exception\UsageException;
use Phan\Issue;
use Phan\Language\Context;
use Phan\Library\StringUtil;
use TypeError;

use function count;
use function is_array;
use function is_int;
use function is_null;
use function is_string;

use const EXIT_FAILURE;
use const FILTER_VALIDATE_INT;

/**
 * This class is used by 'phan --init' to generate a phan config for a composer project.
 * @phan-file-suppress PhanPluginDescriptionlessCommentOnPublicMethod
 * @phan-file-suppress PhanPluginRemoveDebugEcho, PhanPluginRemoveDebugCall
 */
class Initializer
{
    /**
     * @param array{init-overwrite?:mixed,init-no-composer?:mixed,init-level?:(int|string)} $opts
     * Returns
     * @throws UsageException with a process exit code for `phan --init`
     */
    public static function initPhanConfig(array $opts): void
    {
        Config::setValue('use_polyfill_parser', true);
        $cwd = \getcwd();

        $config_path = "$cwd/.phan/config.php";
        if (!isset($opts['init-overwrite'])) {
            if (\file_exists($config_path)) {
                throw new UsageException("phan --init refuses to run: The Phan config already exists at '$config_path'(Can pass --init-overwrite to force Phan to overwrite that file)", EXIT_FAILURE, UsageException::PRINT_INIT_ONLY);
            }
        }
        if (isset($opts['init-no-composer'])) {
            $composer_settings = [];
            $vendor_path = null;
        } else {
            $composer_json_path = "$cwd/composer.json";
            if (!\file_exists($composer_json_path)) {
                throw new UsageException("phan --init assumes that there will be a composer.json file (at '$composer_json_path')\n(Can pass --init-no-composer if this is not a composer project)", EXIT_FAILURE, UsageException::PRINT_INIT_ONLY);
            }
            $contents = \file_get_contents($composer_json_path);
            if (!is_string($contents)) {
                throw new UsageException("phan --init failed to read contents of $composer_json_path", EXIT_FAILURE, UsageException::PRINT_INIT_ONLY);
            }
            $composer_settings = \json_decode($contents, true);
            if (!is_array($composer_settings)) {
                throw new UsageException("Failed to load '$composer_json_path'", EXIT_FAILURE, UsageException::PRINT_INIT_ONLY);
            }

            $vendor_path = $composer_settings['config']['vendor-dir'] ?? "$cwd/vendor";

            if (!\is_dir($vendor_path)) {
                throw new UsageException("phan --init assumes that 'composer.phar install' was run already (expected to find '$vendor_path')", EXIT_FAILURE, UsageException::PRINT_INIT_ONLY);
            }
        }
        $phan_settings = self::createPhanSettingsForComposerSettings($composer_settings, $vendor_path, $opts);

        $phan_dir = \dirname($config_path);
        if (!\file_exists($phan_dir)) {
            if (!\mkdir($phan_dir)) {
                throw new UsageException("Failed to create directory '$phan_dir'", EXIT_FAILURE, UsageException::PRINT_INIT_ONLY, true);
            }
        }
        $settings_file_contents = self::generatePhanConfigFileContents($phan_settings);
        \file_put_contents($config_path, $settings_file_contents);
        echo "Successfully initialized '$config_path' with the following contents\n\n";
        echo $settings_file_contents;
    }

    /**
     * @return array<string,string[]> maps a config name to a list of comment lines about that config
     */
    public static function computeCommentNameDocumentationMap(): array
    {
        // Hackish way of extracting comment lines from Config::DEFAULT_CONFIGURATION
        // @phan-suppress-next-line PhanPossiblyFalseTypeArgumentInternal
        $config_file_lines = \explode("\n", \file_get_contents(\dirname(__DIR__) . '/Config.php'));
        $prev_lines = [];
        $result = [];
        foreach ($config_file_lines as $line) {
            if (\preg_match("/^        (['\"])([a-z0-9A-Z_]+)\\1\s*=>/", $line, $matches)) {
                $config_name = $matches[2];
                if (count($prev_lines) > 0) {
                    $result[$config_name] = $prev_lines;
                }
                $prev_lines = [];
                continue;
            }
            if (\preg_match('@^\s*//@', $line)) {
                $prev_lines[] = \trim($line);
            } else {
                $prev_lines = [];
            }
        }
        return $result;
    }

    /**
     * Returns indented PHP comment lines to use for the comment on $setting_name.
     * Returns the empty string if nothing could be generated.
     */
    public static function generateCommentForSetting(string $setting_name): string
    {
        static $comment_source = null;
        if (is_null($comment_source)) {
            $comment_source = self::computeCommentNameDocumentationMap();
        }
        $lines = $comment_source[$setting_name] ?? null;
        if ($lines === null) {
            return '';
        }
        return \implode('', \array_map(static function (string $line): string {
            return "    $line\n";
        }, $lines));
    }

    /**
     * @param string $setting_name
     * @param string|int|float|bool|array|null $setting_value
     * @param list<string> $additional_comment_lines
     */
    public static function generateEntrySnippetForSetting(string $setting_name, $setting_value, array $additional_comment_lines): string
    {
        $source = self::generateCommentForSetting($setting_name);
        foreach ($additional_comment_lines as $line) {
            $source .= "    // $line\n";
        }
        $source .= '    ';
        $source .= \var_export($setting_name, true) . ' => ';
        if (is_array($setting_value)) {
            if (count($setting_value) > 0) {
                $source .= "[\n";
                foreach ($setting_value as $key => $element) {
                    if (!is_int($key)) {
                        throw new TypeError("Expected setting default for $setting_name to have consecutive integer keys");
                    }
                    $source .= '        ' . StringUtil::varExportPretty($element) . ",\n";
                }
                $source .= "    ],\n";
            } else {
                $source .= "[],\n";
            }
        } else {
            $encoded_value = StringUtil::varExportPretty($setting_value);
            if ($setting_name === 'minimum_severity') {
                switch ($setting_value) {
                    case Issue::SEVERITY_LOW:
                        $encoded_value = 'Issue::SEVERITY_LOW';
                        break;
                    case Issue::SEVERITY_NORMAL:
                        $encoded_value = 'Issue::SEVERITY_NORMAL';
                        break;
                    case Issue::SEVERITY_CRITICAL:
                        $encoded_value = 'Issue::SEVERITY_CRITICAL';
                        break;
                }
            }

            $source .= "$encoded_value,\n";
        }
        return $source;
    }

    /**
     * Returns a string containing the full source to use for the generated `.phan/config.php`
     */
    public static function generatePhanConfigFileContents(InitializedSettings $settings_object): string
    {
        $phan_settings = $settings_object->settings;
        $init_level = $settings_object->init_level;
        $comment_lines = $settings_object->comment_lines;

        $source = <<<EOT
<?php

use Phan\Issue;

/**
 * This configuration file was automatically generated by 'phan --init --init-level=$init_level'
 *
 * TODOs (added by 'phan --init'):
 *
 * - Go through this file and verify that there are no missing/unnecessary files/directories.
 *   (E.g. this only includes direct composer dependencies - You may have to manually add indirect composer dependencies to 'directory_list')
 * - Look at 'plugins' and add or remove plugins if appropriate (see https://github.com/phan/phan/tree/master/.phan/plugins#plugins)
 * - Add global suppressions for pre-existing issues to suppress_issue_types (https://github.com/phan/phan/wiki/Tutorial-for-Analyzing-a-Large-Sloppy-Code-Base)
 *   - Consider setting up a baseline if there are a large number of pre-existing issues (see `phan --extended-help`)
 *
 * This configuration will be read and overlaid on top of the
 * default configuration. Command line arguments will be applied
 * after this file is read.
 *
 * @see https://github.com/phan/phan/wiki/Phan-Config-Settings for all configurable options
 * @see https://github.com/phan/phan/tree/master/src/Phan/Config.php
 *
 * A Note About Paths
 * ==================
 *
 * Files referenced from this file should be defined as
 *
 * ```
 *   Config::projectPath('relative_path/to/file')
 * ```
 *
 * where the relative path is relative to the root of the
 * project which is defined as either the working directory
 * of the phan executable or a path passed in via the CLI
 * '-d' flag.
 */
return [

EOT;
        foreach ($phan_settings as $setting_name => $setting_value) {
            $source .= "\n";
            $source .= self::generateEntrySnippetForSetting($setting_name, $setting_value, $comment_lines[$setting_name] ?? []);
        }
        $source .= "];\n";
        return $source;
    }

    public const LEVEL_MAP = [
        'strict'  => 1,
        'strong'  => 2,
        'average' => 3,
        'normal'  => 3,
        'weak'    => 4,
        'weakest' => 5,
    ];

    /**
     * @param array<string,mixed> $composer_settings (can be empty for --init-no-composer)
     * @param ?string $vendor_path (can be null for --init-no-composer)
     * @param array{init-analyze-file?:string,init-overwrite?:mixed,init-no-composer?:mixed,init-level?:(int|string)} $opts parsed from getopt
     * @throws UsageException if provided settings are invalid
     * @internal
     */
    public static function createPhanSettingsForComposerSettings(array $composer_settings, ?string $vendor_path, array $opts): InitializedSettings
    {
        $level = $opts['init-level'] ?? 3;
        $level = self::LEVEL_MAP[\strtolower((string)$level)] ?? $level;
        if (\filter_var($level, FILTER_VALIDATE_INT) === false) {
            throw new UsageException("Invalid --init-level=$level", EXIT_FAILURE, UsageException::PRINT_INIT_ONLY);
        }
        $level = \max(1, \min(5, (int)$level));
        $is_strongest_level = $level === 1;
        $is_strong_or_weaker_level = $level >= 2;
        $is_average_level = $level >= 3;
        $is_weak_level    = $level >= 4;
        $is_weakest_level = $level >= 5;

        $cwd = \getcwd();
        [$project_directory_list, $project_file_list] = self::extractAutoloadFilesAndDirectories('', $composer_settings);
        $minimum_severity = $is_weak_level ? Issue::SEVERITY_NORMAL : Issue::SEVERITY_LOW;
        if ($is_weakest_level) {
            $plugins = [];
        } elseif ($is_average_level) {
            $plugins = [
                'AlwaysReturnPlugin',
                'PregRegexCheckerPlugin',
                'UnreachableCodePlugin',
            ];
        } else {
            $plugins = [
                'AlwaysReturnPlugin',
                'DollarDollarPlugin',
                'DuplicateArrayKeyPlugin',
                'DuplicateExpressionPlugin',
                'PregRegexCheckerPlugin',
                'PrintfCheckerPlugin',
                'SleepCheckerPlugin',
                'UnreachableCodePlugin',
                'UseReturnValuePlugin',
                'EmptyStatementListPlugin',
            ];
        }
        if ($is_strongest_level) {
            $plugins[] = 'StrictComparisonPlugin';
            $plugins[] = 'LoopVariableReusePlugin';
        }

        $comments = [];
        [$target_php_version, $comments['target_php_version']] = self::determineTargetPHPVersion($composer_settings);

        $phan_settings = [
            'target_php_version'       => $target_php_version,
            'allow_missing_properties' => $is_weak_level,
            'null_casts_as_any_type'   => $is_weak_level,
            'null_casts_as_array'      => $is_average_level,
            'array_casts_as_null'      => $is_average_level,
            'scalar_implicit_cast'     => $is_weak_level,
            'scalar_array_key_cast'    => $is_average_level,
            // TODO: Migrate to a smaller subset scalar_implicit_partial as analysis gets stricter?
            'scalar_implicit_partial'  => [],
            'strict_method_checking'   => !$is_average_level,
            // strict param/return checking has a lot of false positives. Limit it to the strongest analysis level.
            'strict_object_checking' => $is_strongest_level,
            'strict_param_checking'    => $is_strongest_level,
            'strict_property_checking' => $is_strongest_level,
            'strict_return_checking'   => $is_strongest_level,
            'ignore_undeclared_variables_in_global_scope' => $is_average_level,
            'ignore_undeclared_functions_with_known_signatures' => $is_strong_or_weaker_level,
            'backward_compatibility_checks' => false,  // this is only useful for migrating from php5
            'check_docblock_signature_return_type_match' => !$is_average_level,
            'phpdoc_type_mapping' => [],
            'dead_code_detection' => false,  // this is slow
            'unused_variable_detection' => !$is_average_level,
            'redundant_condition_detection' => !$is_average_level,
            'assume_real_types_for_internal_functions' => !$is_average_level,
            'quick_mode' => $is_weakest_level,
            'globals_type_map' => [],
            'minimum_severity' => $minimum_severity,
            'suppress_issue_types' => [],
            'exclude_file_regex' => $vendor_path !== null ? '@^vendor/.*/(tests?|Tests?)/@' : null,
            'exclude_file_list' => [],
            'exclude_analysis_directory_list' => $vendor_path !== null ? [
                'vendor/'
            ] : [],
            'enable_include_path_checks' => !$is_weak_level,
            'processes' => 1,
            'analyzed_file_extensions' => ['php'],
            'autoload_internal_extension_signatures' => [],
            'plugins' => $plugins,
        ];

        $phan_directory_list = $project_directory_list;
        $phan_file_list = $project_file_list;

        // TODO: Figure out which require-dev directories can be skipped
        $require_directories = $composer_settings['require'] ?? [];
        $require_dev_directories = $composer_settings['require-dev'] ?? [];
        foreach (\array_merge($require_directories, $require_dev_directories) as $requirement => $_) {
            if (\substr_count($requirement, '/') !== 1) {
                // e.g. ext-ast, php >= 7.0, etc.
                continue;
            }
            $path_to_require = "$vendor_path/$requirement";
            if (!\is_dir($path_to_require)) {
                $requirement = \strtolower($requirement);
                $path_to_require = "$vendor_path/$requirement";
                if (!\is_dir($path_to_require)) {
                    echo CLI::colorizeHelpSectionIfSupported("WARNING: ") . "Directory $path_to_require does not exist, continuing\n";
                    continue;
                }
            }
            $path_to_composer_json = "$path_to_require/composer.json";
            if (!\file_exists($path_to_composer_json)) {
                echo CLI::colorizeHelpSectionIfSupported("WARNING: ") . "$path_to_composer_json does not exist, continuing\n";
                continue;
            }
            // @phan-suppress-next-line PhanPossiblyFalseTypeArgumentInternal
            $library_composer_settings = \json_decode(\file_get_contents($path_to_composer_json), true);
            if (!is_array($library_composer_settings)) {
                echo CLI::colorizeHelpSectionIfSupported("WARNING: ") . "$path_to_composer_json contains invalid JSON, continuing\n";
                continue;
            }

            [$library_directory_list, $library_file_list] = self::extractAutoloadFilesAndDirectories("vendor/$requirement", $library_composer_settings);
            $phan_directory_list = \array_merge($phan_directory_list, $library_directory_list);
            $phan_file_list = \array_merge($phan_file_list, $library_file_list);
        }
        foreach (self::getArrayOption($opts, 'init-analyze-dir') as $extra_dir) {
            $path_to_require = "$cwd/$extra_dir";
            if (!\is_dir($path_to_require)) {
                throw new UsageException("phan --init-analyze-dir was given a missing/invalid relative directory '$extra_dir'", EXIT_FAILURE, UsageException::PRINT_INIT_ONLY);
            }
            $phan_directory_list[] = $extra_dir;
        }

        foreach ($composer_settings['bin'] ?? [] as $relative_path_to_binary) {
            if (self::isPHPBinary($relative_path_to_binary)) {
                $phan_file_list[] = $relative_path_to_binary;
            }
        }
        foreach (self::getArrayOption($opts, 'init-analyze-file') as $extra_file) {
            $path_to_require = "$cwd/$extra_file";
            if (!\is_file($path_to_require)) {
                throw new UsageException("phan --init-analyze-file was given a missing/invalid relative file '$extra_file'", EXIT_FAILURE, UsageException::PRINT_INIT_ONLY);
            }
            $phan_file_list[] = $extra_file;
        }
        if ($vendor_path !== null && count($project_directory_list) === 0 && count($project_file_list) === 0 && count($phan_file_list) === 0 && count($phan_directory_list) === 0) {
            throw new UsageException('phan --init expects composer.json to contain "autoload" psr-4 directories (and could not determine any directories or files to analyze)', EXIT_FAILURE, UsageException::PRINT_INIT_ONLY);
        }

        if (count($phan_file_list) === 0 && count($phan_directory_list) === 0) {
            throw new UsageException("phan --init failed to find any directories or files to analyze, giving up.", EXIT_FAILURE, UsageException::PRINT_INIT_ONLY);
        }
        \sort($phan_directory_list);
        \sort($phan_file_list);

        $phan_settings['directory_list'] = \array_unique($phan_directory_list);
        $phan_settings['file_list'] = \array_unique($phan_file_list);
        return new InitializedSettings($phan_settings, $comments, $level);
    }

    /**
     * @param array<string,mixed> $composer_settings parsed from composer.json
     * @return array{0:?string,1:list<string>}
     */
    public static function determineTargetPHPVersion(array $composer_settings): array
    {
        $php_version_constraint = $composer_settings['require']['php'] ?? null;
        if (!$php_version_constraint || !is_string($php_version_constraint)) {
            return [null, ['TODO: Choose a target_php_version for this project, or leave as null and remove this comment']];
        }
        try {
            $version_constraint = self::parseConstraintsForRange($php_version_constraint);
        } catch (\UnexpectedValueException $_) {
            return [null, ['TODO: Choose a target_php_version for this project, or leave as null and remove this comment']];
        }
        // Not going to suggest 5.6 - analyzing with 7.0 might detect some functions that were removed
        if ($version_constraint->matches(self::parseConstraintsForRange('<7.1-dev'))) {
            $version_guess = '7.0';
        } elseif ($version_constraint->matches(self::parseConstraintsForRange('<7.2-dev'))) {
            $version_guess = '7.1';
        } elseif ($version_constraint->matches(self::parseConstraintsForRange('<7.3-dev'))) {
            $version_guess = '7.2';
        } elseif ($version_constraint->matches(self::parseConstraintsForRange('<7.4-dev'))) {
            $version_guess = '7.3';
        } elseif ($version_constraint->matches(self::parseConstraintsForRange('<8.0-dev'))) {
            $version_guess = '7.4';
        } elseif ($version_constraint->matches(self::parseConstraintsForRange('>=8.0-dev'))) {
            $version_guess = '8.0';
        } else {
            return [null, ['TODO: Choose a target_php_version for this project, or leave as null and remove this comment']];
        }
        return [$version_guess, ['Automatically inferred from composer.json requirement for "php" of ' . \json_encode($php_version_constraint)]];
    }

    private static function parseConstraintsForRange(string $constraints): ConstraintInterface
    {
        return (new VersionParser())->parseConstraints($constraints);
    }

    /**
     * @param array<string,mixed> $composer_settings settings parsed from composer.json
     * @return list<list<string>> [$directory_list, $file_list]
     */
    private static function extractAutoloadFilesAndDirectories(string $relative_dir, array $composer_settings): array
    {
        $directory_list = [];
        $file_list = [];
        $autoload_setting = $composer_settings['autoload'] ?? [];
        $autoload_directories = \array_merge(
            $autoload_setting['psr-4'] ?? [],
            $autoload_setting['psr-0'] ?? [],
            $autoload_setting['classmap'] ?? []
        );

        foreach ($autoload_directories as $lib_list) {
            if (is_string($lib_list)) {
                $lib_list = [$lib_list];
            }
            foreach ($lib_list as $lib) {
                if (!is_string($lib)) {
                    echo CLI::colorizeHelpSectionIfSupported("WARNING: ") . "unexpected autoload field in '$relative_dir/composer.json'\n";
                    continue;
                }
                $composer_lib_relative_path = "$relative_dir/$lib";
                $composer_lib_absolute_path = \getcwd() . "/$composer_lib_relative_path";
                if (!\file_exists($composer_lib_absolute_path)) {
                    echo CLI::colorizeHelpSectionIfSupported("WARNING: ") . "could not find '$composer_lib_relative_path'\n";
                    continue;
                }
                $composer_lib_relative_path = \trim(\str_replace(\DIRECTORY_SEPARATOR, '/', $composer_lib_relative_path), '/');

                $composer_lib_relative_path = \preg_replace('@(/+\.)+$@D', '', $composer_lib_relative_path);
                if (\is_dir($composer_lib_absolute_path)) {
                    $directory_list[] = \trim($composer_lib_relative_path, '/');
                } elseif (\is_file($composer_lib_relative_path)) {
                    $file_list[] = \trim($composer_lib_relative_path, '/');
                }
            }
        }
        return self::filterDirectoryAndFileList($directory_list, $file_list);
    }

    /**
     * Sort and return the unique directories and files to be added to the Phan config.
     * (don't return directories/files within other directories)
     *
     * @param list<string> $directory_list
     * @param list<string> $file_list
     * @return list<list<string>> [$directory_list, $file_list]
     */
    public static function filterDirectoryAndFileList(array $directory_list, array $file_list): array
    {
        \sort($directory_list);
        \sort($file_list);
        if (count($directory_list) > 0) {
            $filter = self::createNotInDirectoryFilter($directory_list);
            $directory_list = \array_filter($directory_list, $filter);
            $file_list = \array_filter($file_list, $filter);
        }
        return [
            \array_values(\array_unique($directory_list)),
            \array_values(\array_unique($file_list))
        ];
    }

    /**
     * @param string[] $directory_list
     * @return Closure(string):bool a closure that returns true if the passed in file is not within any folders in $directory_list
     */
    private static function createNotInDirectoryFilter(array $directory_list): Closure
    {
        $parts = \array_map(static function (string $path): string {
            if ($path === '.') {
                // Probably unnecessary to try to handle absolute paths and ../ in composer libraries.
                return '((?!(/|\.\.[/\\\\]|\w:\\\\)).*)';
            }
            return \preg_quote($path, '@');
        }, $directory_list);
        $prefix_filter = '@^(' . \implode('|', $parts) . ')[\\\\/]@';
        return static function (string $path) use ($prefix_filter): bool {
            return !\preg_match($prefix_filter, $path);
        };
    }

    /**
     * @param array<string,mixed> $opts
     * @return list<string>
     */
    private static function getArrayOption(array $opts, string $key): array
    {
        $values = $opts[$key] ?? [];
        if (is_string($values)) {
            return [$values];
        }
        return is_array($values) ? $values : [];
    }

    /**
     * Returns true if there is at least one statement that is parseable and not an inline HTML echo statement.
     *
     * This indicates that $relative_path points to a PHP binary file that should be analyzed.
     */
    public static function isPHPBinary(string $relative_path): bool
    {
        $cwd = \getcwd();
        $absolute_path = "$cwd/$relative_path";
        if (!\file_exists($absolute_path)) {
            \printf("Failed to find '%s', continuing\n", $absolute_path);
            return false;
        }
        $contents = \file_get_contents($absolute_path);
        if (!is_string($contents)) {
            \printf("Failed to read '%s', continuing\n", $absolute_path);
            return false;
        }
        try {
            // PHP binaries can have many forms, may begin with #/usr/bin/env php.
            // We assume that if it's parsable and contains at least one PHP executable line, it's valid.
            $ast = Parser::parseCode(
                new CodeBase([], [], [], [], []),
                new Context(),
                null,
                $relative_path,
                $contents,
                true
            );
            $child_nodes = $ast->children;
            if (count($child_nodes) !== 1) {
                return true;
            }
            $node = $child_nodes[0];
            if (!$node instanceof Node) {
                // e.g. <?php 'literal';
                return true;
            }
            return $node->kind !== \ast\AST_ECHO || !is_string($node->children['expr']);
        } catch (ParseError | CompileError | ParseException $_) {
            return false;
        }
    }
}