#!/usr/bin/env php 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 $response * @param string[] $file_mapping * @param array $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 - 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 << Unix socket which a Phan daemon is listening for requests on. --daemonize-tcp-port 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 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 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 $opts * @param list $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();