phan_client
#!/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();