797 lines
33 KiB
PHP
Executable File
797 lines
33 KiB
PHP
Executable File
#!/usr/bin/env php
|
|
<?php
|
|
/**
|
|
* Usage: phan_client -l path/to/file.php
|
|
* Compatible with php 5.6-8.1
|
|
* (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);
|
|
// NOTE: Some future release of php may change stream_socket_client to return an object on success.
|
|
if (!$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 never - 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);
|
|
}
|
|
$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);
|
|
}
|
|
$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);
|
|
}
|
|
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);
|
|
}
|
|
$this->file_list[] = $path;
|
|
break;
|
|
case 'h':
|
|
case 'help':
|
|
// never returns
|
|
$this->usage();
|
|
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);
|
|
}
|
|
if ($value === 'phan_client') {
|
|
// We're requesting the default
|
|
break;
|
|
}
|
|
$this->output_mode = $value;
|
|
break;
|
|
default:
|
|
$this->usage("Unknown option '-$key'", 1);
|
|
}
|
|
}
|
|
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();
|