phan_client

Summary

Maintainability
Test Coverage
#!/usr/bin/env php
<?php
/**
 * Usage: phan_client -l path/to/file.php
 * Compatible with php 5.6 and php 7.x
 * (The server itself requires a newer php version)
 *
 * See plugins/vim/snippet.vim for an example of a use of this program.
 *
 * Analyzes a single php file.
 * - If it is syntactically valid, scans it with phan, and emits lines beginning with "phan error:"
 * - If it is invalid, emits the output of the PHP syntax checker
 *
 * This is meant to be a self-contained script with no file dependencies.
 *
 * Not tested on windows, probably won't work, but should be easy to add.
 * Enhanced substitute for php -l, when phan daemon is running in the background for that folder.
 *
 * Note: if the daemon is run inside of Docker, one would probably need to change the URL in src/Phan/Daemon/Request.php from 127.0.0.1 to 0.0.0.0,
 * and docker run -p 127.0.0.1:4846:4846 path/to/phan --daemonize-tcp-port 4846 --quick (second port is the docker one)
 *
 * See one of the many dockerized phan instructions, such as https://github.com/cloudflare/docker-phan
 * e.g. https://github.com/cloudflare/docker-phan/blob/master/builder/scripts/mkimage-phan.bash
 * mentions how it installed php-ast, similar steps could be used for other modules.
 * (Install phpVERSION-dev/pecl to install extensions from source/pecl (phpize, configure, make install/pecl install))
 *
 * TODO: tutorial or repo.
 *
 * @phan-file-suppress PhanPartialTypeMismatchArgumentInternal
 * @phan-file-suppress PhanPluginDuplicateConditionalNullCoalescing this can't use the `??` operator because it's compatible with php 5.6
 * @phan-file-suppress PhanPluginCanUseParamType, PhanPluginCanUsePHP71Void, PhanPluginCanUseReturnType
 * @phan-file-suppress PhanPluginRemoveDebugEcho
 */
class PhanPHPLinter
{
    // Wait at most 3 seconds to lint a file.
    const TIMEOUT_MS = 3000;

    /** @var bool - Whether or not this is verbose */
    public static $verbose = false;

    /**
     * @param string $msg
     * @return void
     */
    private static function debugError($msg)
    {
        error_log($msg);
    }

    /**
     * @param string $msg
     * @return void
     */
    private static function debugInfo($msg)
    {
        if (self::$verbose) {
            self::debugError($msg);
        }
    }

    /**
     * The main function of the phan_client binary.
     * See the doc comment of this file.
     *
     * @return void
     */
    public static function run()
    {
        error_reporting(E_ALL);
        // TODO: check for .phan/lock to see if daemon is running?

        $opts = new PhanPHPLinterOpts();  // parse options, exit on failure.
        self::$verbose = $opts->verbose;

        $failure_code = 0;
        $temporary_file_mapping_contents = [];
        // TODO: Check that path gets defined
        foreach ($opts->file_list as $path) {
            if (isset($opts->temporary_file_map[$path])) {
                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
                $temporary_path = $opts->temporary_file_map[$path];
                $temporary_contents = file_get_contents($temporary_path);
                if ($temporary_contents === false) {
                    self::debugError(sprintf("Could not open temporary input file: %s", $temporary_path));
                    $failure_code = 1;
                    continue;
                }
                $exit_code = 0;
                $output = '';
                if (!$opts->use_fallback_parser) {
                    ob_start();
                    try {
                        system("php -l --no-php-ini " . escapeshellarg($temporary_path), $exit_code);
                    } finally {
                        $output = ob_get_clean();
                    }
                }
                if ($exit_code === 0) {
                    $temporary_file_mapping_contents[$path] = $temporary_contents;
                }
                if ($exit_code !== 0) {
                    echo $output;
                }
            } else {
                // TODO: use popen instead
                // TODO: add option to capture output, suppress "No syntax error"?
                // --no-php-ini is a faster way to parse since php doesn't need to load multiple extensions. Assumes none of the extensions change the way php is parsed.
                $exit_code = 0;
                $output = '';
                if (!$opts->use_fallback_parser) {
                    ob_start();
                    try {
                        system("php -l --no-php-ini " . escapeshellarg($path), $exit_code);
                    } finally {
                        $output = ob_get_clean();
                    }
                }
                if ($exit_code !== 0) {
                    echo $output;
                }
            }
            if ($exit_code !== 0) {
                // The file is syntactically invalid. Or php somehow isn't able to be invoked from this script.
                $failure_code = $exit_code;
            }
        }
        // Exit if any of the requested files are syntactically invalid.
        if ($failure_code !== 0) {
            self::debugError("Files were syntactically invalid\n");
            exit($failure_code);
        }

        if (!isset($path)) {
            self::debugError("Unexpectedly parsed no files\n");
            exit($failure_code);
        }

        // TODO: Check that everything in $this->file_list is in the same path.
        // $path = reset($opts->file_list);
        $real = realpath($path);
        if (!is_string($real)) {
            self::debugError("Could not resolve $path\n");
        }
        // @phan-suppress-next-line PhanPossiblyFalseTypeArgumentInternal
        $dirname = dirname($real);
        $old_dirname = null;
        unset($real);

        // TODO: In another PR, have an alternative way to run the daemon/server on Windows (Serialize and unserialize global state?
        // The server side is unsupported on Windows, due to the `pcntl` extension not being supported.
        $found_phan_config = false;
        while ($dirname !== $old_dirname) {
            if (file_exists($dirname . '/.phan/config.php')) {
                $found_phan_config = true;
                break;
            }
            $old_dirname = $dirname;
            $dirname = dirname($dirname);
        }
        if (!$found_phan_config) {
            self::debugInfo("Not in a Phan project, nothing to do.");
            exit(0);
        }

        $file_mapping = [];
        $real_files = [];
        foreach ($opts->file_list as $path) {
            $real = realpath($path);
            if (!is_string($real)) {
                self::debugInfo("could not find real path to '$path'");
                continue;
            }
            // Convert this to a relative path
            if (in_array(substr($real, 0, strlen($dirname) + 1),
                [$dirname . DIRECTORY_SEPARATOR, $dirname . '/'],
                true
            )) {
                $real = substr($real, strlen($dirname) + 1);
                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable not able to analyze. Not using coalescing because this supports php 5.
                $mapped_path = isset($opts->temporary_file_map[$path]) ? $opts->temporary_file_map[$path] : $path;
                // If we are analyzing a temporary file, but it's within a project, then output the path to a temporary file for consistency.
                // (Tools which pass something a temporary path expect a temporary path in the output.)
                $file_mapping[$real] = $mapped_path;
                $real_files[] = $real;
            } else {
                self::debugInfo("Not in a Phan project, nothing to do.");
            }
        }
        if (count($file_mapping) === 0) {
            self::debugInfo("Not in a real project");
        }
        // The file is syntactically valid. Run phan.
        $request = [
            'method' => 'analyze_files',
            'files' => $real_files,
            'format' => 'json',
        ];
        if ($opts->output_mode) {
            $request['format'] = $opts->output_mode;
            $request['is_user_specified_format'] = true;
        }
        if ($opts->color === null && (!$opts->output_mode || $opts->output_mode === 'text')) {
            $opts->color = self::supportsColor(STDOUT);
        }
        if ($opts->color) {
            $request['color'] = true;
        }

        if (count($temporary_file_mapping_contents) > 0) {
            $request['temporary_file_mapping_contents'] = $temporary_file_mapping_contents;
        }

        $serialized_request = json_encode($request);
        if (!is_string($serialized_request)) {
            self::debugError("Could not serialize this request\n");
            exit(1);
        }

        // TODO: check if the folder is within a folder with subdirectory .phan/config.php
        // TODO: Check if there is a lock before attempting to connect?
        $client = @stream_socket_client($opts->url, $errno, $errstr, 20.0);
        if (!\is_resource($client)) {
            // TODO: This should attempt to start up the phan daemon for the given folder?
            self::debugError("Phan daemon not running on " . ($opts->url));
            exit(0);
        }
        fwrite($client, $serialized_request);
        stream_set_timeout($client, (int)floor(self::TIMEOUT_MS / 1000), 1000 * (self::TIMEOUT_MS % 1000));
        stream_socket_shutdown($client, STREAM_SHUT_WR);
        $response_lines = [];
        while (!feof($client)) {
            $response_lines[] = fgets($client);
        }
        stream_socket_shutdown($client, STREAM_SHUT_RD);
        fclose($client);
        $response_bytes = implode('', $response_lines);
        // This uses the 'phplike' format imitating php's error format. "%s in %s on line %d"
        $response = json_decode($response_bytes, true);
        if (!is_array($response)) {
            self::debugError(sprintf("Invalid response from phan for %s: expected JSON object: %s", $opts->url, $response_bytes));
            return;
        }
        $status = isset($response['status']) ? $response['status'] : null;
        if ($status === 'ok') {
            self::dumpJSONIssues($response, $file_mapping, $request);
        } else {
            self::debugError(sprintf("Invalid response from phan for %s: %s", $opts->url, $response_bytes));
        }
    }

    /**
     * @param array<string,mixed> $response
     * @param string[] $file_mapping
     * @param array<string,mixed> $request
     * @return void
     */
    private static function dumpJSONIssues(array $response, array $file_mapping, array $request)
    {
        $did_debug = false;
        $lines = [];
        // if ($response['issue_count'] > 0)
        $issues = $response['issues'];
        $format = $request['format'];
        if ($format === 'json') {
            if (!\is_array($issues)) {
                if (\is_string($issues)) {
                    self::debugError(sprintf("Invalid issues response from phan: %s\n", $issues));
                } else {
                    self::debugError(sprintf("Invalid type for issues response from phan: %s\n", gettype($issues)));
                }
                return;
            }
            if (isset($request['is_user_specified_format'])) {
                // The user requested the raw JSON, not what `phan_client` converts it to
                echo json_encode($issues) . "\n";
                return;
            }
        } else {
            // When formats other than 'json' are requested, the Phan daemon returns the issues as a raw string.
            // (e.g. codeclimate returns a string with JSON separated by "\x00")
            if (!\is_string($issues)) {
                self::debugError(sprintf("Invalid type for issues response from phan: %s\n", gettype($issues)));
                return;
            }
            echo $issues;
            return;
        }
        foreach ($issues as $issue) {
            if (!is_array($issue)) {
                self::debugError(sprintf("Invalid type for element of issues response from phan: %s\n", gettype($issues)));
                return;
            }
            if ($issue['type'] !== 'issue') {
                continue;
            }
            $pathInProject = $issue['location']['path'];  // relative path
            if (!isset($file_mapping[$pathInProject])) {
                if (!$did_debug) {
                    self::debugInfo(sprintf("Unexpected path for issue (expected %s): %s\n", json_encode($file_mapping) ?: 'invalid', json_encode($issue) ?: 'invalid'));
                }
                $did_debug = true;
                continue;
            }
            $line = $issue['location']['lines']['begin'];
            $description = $issue['description'];
            $parts = explode(' ', $description, 3);
            if (count($parts) === 3 && $parts[1] === $issue['check_name']) {
                $description = implode(': ', $parts);
            }
            if (isset($issue['suggestion'])) {
                $description .= ' (' . $issue['suggestion'] . ')';
            }
            $lines[] = sprintf("Phan error: %s in %s on line %d\n", $description, $file_mapping[$pathInProject], $line);
        }
        // https://github.com/neomake/neomake/issues/153
        echo implode('', $lines);
    }

    /**
     * Returns true if the output stream supports colors
     *
     * This is tricky on Windows, because Cygwin, Msys2 etc emulate pseudo
     * terminals via named pipes, so we can only check the environment.
     *
     * Reference: Composer\XdebugHandler\Process::supportsColor
     * https://github.com/composer/xdebug-handler
     * (This is internal, so it was duplicated in case their API changed)
     *
     * This duplicates CLI::supportsColor() so that phan_client can run as a standalone file
     *
     * @param resource $output A valid CLI output stream
     * @return bool
     * @suppress PhanUndeclaredFunction
     */
    public static function supportsColor($output)
    {
        if ('Hyper' === getenv('TERM_PROGRAM')) {
            return true;
        }
        if (\defined('PHP_WINDOWS_VERSION_BUILD')) {
            return (\function_exists('sapi_windows_vt100_support')
                && \sapi_windows_vt100_support($output))
                || false !== \getenv('ANSICON')
                || 'ON' === \getenv('ConEmuANSI')
                || 'xterm' === \getenv('TERM');
        }

        if (\function_exists('stream_isatty')) {
            return \stream_isatty($output);
        } elseif (\function_exists('posix_isatty')) {
            return \posix_isatty($output);
        }

        $stat = \fstat($output);
        // Check if formatted mode is S_IFCHR
        return $stat ? 0020000 === ($stat['mode'] & 0170000) : false;
    }

}

/**
 * This represents the CLI options for Phan
 * (and the logic to parse them and generate usage messages)
 */
class PhanPHPLinterOpts
{
    /** @var string tcp:// or unix:// socket URL of the daemon. */
    public $url;

    /** @var list<string> - file list */
    public $file_list = [];

    /** @var string[]|null - optional, maps original files to temporary file path to use as a substitute. */
    public $temporary_file_map = null;

    /** @var bool if true, enable verbose output. */
    public $verbose = false;

    /** @var bool should this client request analysis from the Phan server when the file has syntax errors */
    public $use_fallback_parser;

    /** @var ?string the output mode to use. If null, use the default from the daemon */
    public $output_mode = null;

    /** @var ?bool whether to color the output on the client */
    public $color = null;

    /**
     * @var bool should this client print a usage text if an **unexpected** error occurred.
     */
    private $print_usage_on_error = true;

    /**
     * @param string $msg - optional message
     * @param int $exit_code - process exit code.
     * @return void - exits with $exit_code
     */
    public function usage($msg = '', $exit_code = 0)
    {
        if (strlen($msg) > 0 || $this->print_usage_on_error) {
            global $argv;
            if (!empty($msg)) {
                echo "$msg\n";
            }

            // TODO: Add an option to autostart the daemon if user also has global configuration to allow it for a given project folder. ($HOME/.phanconfig)
            // TODO: Allow changing (adding/removing) issue suppression types for the analysis phase (would not affect the parse phase)

            echo <<<EOB
Usage: {$argv[0]} [options] -l file.php [ -l file2.php]
 --daemonize-socket </path/to/file.sock>
  Unix socket which a Phan daemon is listening for requests on.

 --daemonize-tcp-port <default|1024-65535>
  TCP port which a Phan daemon is listening for JSON requests on, in daemon mode. (E.g. 'default', which is an alias for port 4846)
  If no option is specified for the daemon's address, phan_client defaults to connecting on port 4846.

 --use-fallback-parser
  Skip the local PHP syntax check.
  Use this if the daemon is also executing with --use-fallback-parser, or if the daemon runs a different PHP version from the default.
  Useful if you wish to report errors while editing the file, even if the file is currently syntactically invalid.

 -l, --syntax-check <file.php>
  Syntax check, and if the Phan daemon is running, analyze the following file (absolute path or relative to current working directory)
  This will only analyze the file if a full phan check (with .phan/config.php) would analyze the file.

 -m, --output-mode <mode>
  Output mode from 'phan_client' (default), 'text', 'json', 'csv', 'codeclimate', 'checkstyle', or 'pylint'

 -t, --temporary-file-map '{"file.php":"/path/to/tmp/file_copy.php"}'
  A json mapping from original path to absolute temporary path (E.g. of a file that is still being edited)

 -f, --flycheck-file '/path/to/tmp/file_copy.php'
  A simpler way to specify a file mapping when checking a single files.
  Pass this after the only occurrence of --syntax-check.

 -d, --disable-usage-on-error
  If this option is set, don't print full usage messages for missing/inaccessible files or inaccessible daemons.
  (Continue printing usage messages for invalid combinations of options.)

 -v, --verbose
  Whether to emit debugging output of this client.

 --color, --no-color
  Whether or not to colorize reported issues.
  The default and text output modes are colorized by default, if the terminal supports it.

 -h, --help
  This help information

EOB;
        }
        exit($exit_code);
    }

    const GETOPT_SHORT_OPTIONS = 's:p:l:t:f:m:vhd';

    const GETOPT_LONG_OPTIONS = [
        'help',
        'daemonize-socket:',
        'daemonize-tcp-port:',
        'disable-usage-on-error',
        'syntax-check:',
        'temporary-file-map:',
        'use-fallback-parser',
        'flycheck-file:',
        'output-mode:',
        'color',
        'no-color',
        'verbose',
    ];

    /**
     * @suppress PhanParamTooManyInternal - `getopt` added an optional third parameter in php 7.1
     * @suppress UnusedSuppression
     */
    public function __construct()
    {
        global $argv;

        // Parse command line args
        $optind = 0;
        $getopt_reflection = new ReflectionFunction('getopt');
        if ($getopt_reflection->getNumberOfParameters() >= 3) {
            // optind support is only in php 7.1+.
            // hhvm doesn't expect a third parameter, but reports a version of php 7.1, even in the latest version.
            $opts = getopt(self::GETOPT_SHORT_OPTIONS, self::GETOPT_LONG_OPTIONS, $optind);
        } else {
            $opts = getopt(self::GETOPT_SHORT_OPTIONS, self::GETOPT_LONG_OPTIONS);
        }
        if (PHP_VERSION_ID >= 70100 && $optind < count($argv)) {
            $this->usage(sprintf("Unexpected parameter %s", json_encode($argv[$optind]) ?: var_export($argv[$optind], true)));
        }

        // Check for this first, since the option parser may also emit debug output in the future.
        if (in_array('-v', $argv, true) || in_array('--verbose', $argv, true)) {
            PhanPHPLinter::$verbose = true;
            $this->verbose = true;
        }
        $print_usage_on_error = true;

        if (!is_array($opts)) {
            $opts = [];
        }
        foreach ($opts as $key => $value) {
            switch ($key) {
                case 's':
                case 'daemonize-socket':
                    $this->checkCanConnectToDaemon('unix');
                    if ($this->url !== null) {
                        $this->usage('Can specify --daemonize-socket or --daemonize-tcp-port only once', 1);
                    }
                    // Check if the socket is valid after parsing the file list.
                    // @phan-suppress-next-line PhanPossiblyFalseTypeArgumentInternal
                    $socket_dirname = dirname(realpath($value));
                    if (!is_string($socket_dirname) || !file_exists($socket_dirname) || !is_dir($socket_dirname)) {
                        // The client doesn't require that the file exists if the daemon isn't running, but we do require that the folder exists.
                        $msg = sprintf('Configured to connect to Unix socket server at socket %s, but folder %s does not exist', json_encode($value) ?: 'invalid', json_encode($socket_dirname) ?: 'invalid');
                        $this->usage($msg, 1);
                    } else {
                        $this->url = sprintf('unix://%s/%s', $socket_dirname, basename($value));
                    }
                    break;
                case 'use-fallback-parser':
                    $this->use_fallback_parser = true;
                    break;
                case 'f':
                case 'flycheck-file':
                    // Add alias, for use in flycheck
                    if (\is_array($this->temporary_file_map)) {
                        $this->usage('--flycheck-file should be specified only once.', 1);
                    }
                    if (!\is_array($this->file_list) || count($this->file_list) !== 1) {
                        $this->usage('--flycheck-file should be specified after the first occurrence of -l.', 1);
                    }
                    if (!is_string($value)) {
                        $this->usage('--flycheck-file should be passed a string value', 1);
                        break;  // unreachable
                    }
                    $this->temporary_file_map = [$this->file_list[0] => $value];
                    break;
                case 't':
                case 'temporary-file-map':
                    if (\is_array($this->temporary_file_map)) {
                        $this->usage('--temporary-file-map should be specified only once.', 1);
                    }
                    $mapping = json_decode($value, true);
                    if (!\is_array($mapping)) {
                        $this->usage('--temporary-file-map should be a JSON encoded map from source file to temporary file to analyze instead', 1);
                        break;  // unreachable
                    }
                    $this->temporary_file_map = $mapping;

                    break;
                case 'p':
                case 'daemonize-tcp-port':
                    $this->checkCanConnectToDaemon('tcp');
                    if (strcasecmp($value, 'default') === 0) {
                        $port = 4846;
                    } else {
                        $port = filter_var($value, FILTER_VALIDATE_INT);
                    }
                    if ($port >= 1024 && $port <= 65535) {
                        $this->url = sprintf('tcp://127.0.0.1:%d', $port);
                    } else {
                        $this->usage("daemonize-tcp-port must be the string 'default' or an integer between 1024 and 65535, got '$value'", 1);
                    }
                    break;
                case 'l':
                case 'syntax-check':
                    $path = $value;
                    if (!is_string($path)) {
                        $this->print_usage_on_error = $print_usage_on_error;
                        $this->usage(sprintf("Error: asked to analyze path %s which is not a string", json_encode($path) ?: 'invalid'), 1);
                        exit(1);
                    }
                    if (!file_exists($path)) {
                        $this->print_usage_on_error = $print_usage_on_error;
                        $this->usage(sprintf("Error: asked to analyze file %s which does not exist", json_encode($path) ?: 'invalid'), 1);
                        exit(1);
                    }
                    $this->file_list[] = $path;
                    break;
                case 'h':
                case 'help':
                    $this->usage();
                    break;
                case 'd':
                case 'disable-usage-on-error':
                    $print_usage_on_error = false;
                    break;
                case 'v':
                case 'verbose':
                    break;  // already parsed.
                case 'color':
                    $this->color = true;
                    break;
                case 'no-color':
                    $this->color = false;
                    break;
                case 'm':
                case 'output-mode':
                    if (!is_string($value) || !in_array($value, ['text', 'json', 'csv', 'codeclimate', 'checkstyle', 'pylint', 'phan_client'], true)) {
                        $this->usage("Expected --output-mode {text,json,csv,codeclimate,checkstyle,pylint}, but got " . json_encode($value), 1);
                        break;  // unreachable
                    }
                    if ($value === 'phan_client') {
                        // We're requesting the default
                        break;
                    }
                    $this->output_mode = $value;
                    break;
                default:
                    $this->usage("Unknown option '-$key'", 1);
                    break;
            }
        }
        try {
            self::checkAllArgsUsed($opts, $argv);
        } catch (InvalidArgumentException $e) {
            $this->usage($e->getMessage(), 1);
        }

        if (count($this->file_list) === 0) {
            // Invalid invocation, always print this message
            $this->usage("This requires at least one file to analyze (with -l path/to/file", 1);
        }
        if (\is_array($this->temporary_file_map)) {
            foreach ($this->temporary_file_map as $original_path => $unused_temporary_path) {
                if (!in_array($original_path, $this->file_list, true)) {
                    $this->usage("Need to specify -l '$original_path' if a mapping is included", 1);
                }
            }
        }
        if ($this->url === null) {
            $this->url = 'tcp://127.0.0.1:4846';
        }
        // In the majority of cases, apply this **after** checking sanity of CLI options
        // (without actually starting the analysis).
        $this->print_usage_on_error = $print_usage_on_error;
    }

    /**
     * prints error message if php doesn't support connecting to a daemon with a given protocol.
     * @param string $protocol
     * @return void
     */
    private function checkCanConnectToDaemon($protocol)
    {
        $opt = $protocol === 'unix' ? '--daemonize-socket' : '--daemonize-tcp-port';
        if (!in_array($protocol, stream_get_transports(), true)) {
            $this->usage("The $protocol:///path/to/file schema is not supported on this system, cannot connect to a daemon with $opt", 1);
        }
        if ($this->url !== null) {
            $this->usage('Can specify --daemonize-socket or --daemonize-tcp-port only once', 1);
        }
    }

    /**
     * Deliberately duplicating CLI::checkAllArgsUsed()
     *
     * @param array<string,mixed> $opts
     * @param list<string> $argv
     * @return void
     * @throws InvalidArgumentException
     */
    private static function checkAllArgsUsed(array $opts, array &$argv)
    {
        $pruneargv = [];
        foreach ($opts as $opt => $value) {
            foreach ($argv as $key => $chunk) {
                $regex = '/^' . (isset($opt[1]) ? '--' : '-') . \preg_quote((string) $opt, '/') . '/';

                if (in_array($chunk, is_array($value) ? $value : [$value], true)
                    && $argv[$key - 1][0] === '-'
                    || \preg_match($regex, $chunk)
                ) {
                    $pruneargv[] = $key;
                }
            }
        }

        while (count($pruneargv) > 0) {
            $key = \array_pop($pruneargv);
            unset($argv[$key]);
        }

        foreach ($argv as $arg) {
            if ($arg[0] === '-') {
                $parts = \explode('=', $arg, 2);
                $key = $parts[0];
                $value = isset($parts[1]) ? $parts[1] : '';  // php getopt() treats --processes and --processes= the same way
                $key = \preg_replace('/^--?/', '', $key);
                if ($value === '') {
                    if (in_array($key . ':', self::GETOPT_LONG_OPTIONS, true)) {
                        throw new InvalidArgumentException("Missing required value for '$arg'");
                    }
                    if (strlen($key) === 1 && strlen($parts[0]) === 2) {
                        // @phan-suppress-next-line PhanParamSuspiciousOrder this is deliberate
                        if (\strpos(self::GETOPT_SHORT_OPTIONS, "$key:") !== false) {
                            throw new InvalidArgumentException("Missing required value for '-$key'");
                        }
                    }
                }
                throw new InvalidArgumentException("Unknown option '$arg'" . self::getFlagSuggestionString($key), 1);
            }
        }
    }

    /**
     * Finds potentially misspelled flags and returns them as a string
     *
     * This will use levenshtein distance, showing the first one or two flags
     * which match with a distance of <= 5
     *
     * @param string $key Misspelled key to attempt to correct
     * @return string
     * @internal
     */
    public static function getFlagSuggestionString(
        $key
    ) {
        /**
         * @param string $s
         * @return string
         */
        $trim = static function ($s) {
            return \rtrim($s, ':');
        };
        /**
         * @param string $suggestion
         * @return string
         */
        $generate_suggestion = static function ($suggestion) {
            return (strlen($suggestion) === 1 ? '-' : '--') . $suggestion;
        };
        /**
         * @param string $suggestion
         * @param string ...$other_suggestions
         * @return string
         */
        $generate_suggestion_text = static function ($suggestion, ...$other_suggestions) use ($generate_suggestion) {
            $suggestions = \array_merge([$suggestion], $other_suggestions);
            return ' (did you mean ' . \implode(' or ', array_map($generate_suggestion, $suggestions)) . '?)';
        };
        $short_options = \array_filter(array_map($trim, \str_split(self::GETOPT_SHORT_OPTIONS)));
        if (strlen($key) === 1) {
            $alternate = \ctype_lower($key) ? \strtoupper($key) : \strtolower($key);
            if (in_array($alternate, $short_options, true)) {
                return $generate_suggestion_text($alternate);
            }
            return '';
        } elseif ($key === '') {
            return '';
        } elseif (strlen($key) > 255) {
            // levenshtein refuses to run for longer keys
            return '';
        }
        // include short options in case a typo is made like -aa instead of -a
        $known_flags = \array_merge(self::GETOPT_LONG_OPTIONS, $short_options);

        $known_flags = array_map($trim, $known_flags);

        $similarities = [];

        $key_lower = \strtolower($key);
        foreach ($known_flags as $flag) {
            if (strlen($flag) === 1 && \stripos($key, $flag) === false) {
                // Skip over suggestions of flags that have no common characters
                continue;
            }
            $distance = \levenshtein($key_lower, \strtolower($flag));
            // distance > 5 is too far off to be a typo
            // Make sure that if two flags have the same distance, ties are sorted alphabetically
            if ($distance > 5) {
                continue;
            }
            if ($key === $flag) {
                if (in_array($key . ':', self::GETOPT_LONG_OPTIONS, true)) {
                    return " (This option is probably missing the required value. Or this option may not apply to a regular Phan analysis, and/or it may be unintentionally unhandled in \Phan\CLI::__construct())";
                } else {
                    return " (This option may not apply to a regular Phan analysis, and/or it may be unintentionally unhandled in \Phan\CLI::__construct())";
                }
            }
            $similarities[$flag] = [$distance, "x" . \strtolower($flag), $flag];
        }

        \asort($similarities); // retain keys and sort descending
        $similarity_values = \array_values($similarities);

        if (count($similarity_values) >= 2 && ($similarity_values[1][0] <= $similarity_values[0][0] + 1)) {
            // If the next-closest suggestion isn't close to as similar as the closest suggestion, just return the closest suggestion
            return $generate_suggestion_text($similarity_values[0][2], $similarity_values[1][2]);
        } elseif (count($similarity_values) >= 1) {
            return $generate_suggestion_text($similarity_values[0][2]);
        }
        return '';
    }
}
PhanPHPLinter::run();