From 54fdda651b9af7a796436134610cfca814ea2bf2 Mon Sep 17 00:00:00 2001 From: Brown Date: Wed, 17 Oct 2018 15:52:26 -0400 Subject: [PATCH] Add server mode support with error reporting only --- composer.json | 13 +- phpcs.xml | 6 +- phpunit.xml.dist | 1 + psalm.xml.dist | 3 + src/Psalm/Checker/FileChecker.php | 17 +- src/Psalm/Checker/ProjectChecker.php | 173 ++++++++ src/Psalm/Codebase.php | 13 +- src/Psalm/IssueBuffer.php | 13 + .../LanguageServer/Client/TextDocument.php | 69 ++++ src/Psalm/LanguageServer/ClientHandler.php | 97 +++++ src/Psalm/LanguageServer/IdGenerator.php | 25 ++ src/Psalm/LanguageServer/LanguageClient.php | 24 ++ src/Psalm/LanguageServer/LanguageServer.php | 368 ++++++++++++++++++ src/Psalm/LanguageServer/Message.php | 65 ++++ src/Psalm/LanguageServer/ProtocolReader.php | 17 + .../LanguageServer/ProtocolStreamReader.php | 83 ++++ .../LanguageServer/ProtocolStreamWriter.php | 92 +++++ src/Psalm/LanguageServer/ProtocolWriter.php | 18 + .../LanguageServer/Server/TextDocument.php | 117 ++++++ .../ClassLikeStorageCacheProvider.php | 4 +- src/Psalm/Provider/FileProvider.php | 3 + .../Provider/FileReferenceCacheProvider.php | 3 + src/Psalm/Provider/FileReferenceProvider.php | 6 + .../Provider/FileStorageCacheProvider.php | 1 + src/Psalm/Provider/StatementsProvider.php | 19 +- src/Psalm/Storage/FunctionLikeStorage.php | 33 ++ src/Psalm/Storage/PropertyStorage.php | 1 + src/psalm.php | 10 +- 28 files changed, 1273 insertions(+), 21 deletions(-) create mode 100644 src/Psalm/LanguageServer/Client/TextDocument.php create mode 100644 src/Psalm/LanguageServer/ClientHandler.php create mode 100644 src/Psalm/LanguageServer/IdGenerator.php create mode 100644 src/Psalm/LanguageServer/LanguageClient.php create mode 100644 src/Psalm/LanguageServer/LanguageServer.php create mode 100644 src/Psalm/LanguageServer/Message.php create mode 100644 src/Psalm/LanguageServer/ProtocolReader.php create mode 100644 src/Psalm/LanguageServer/ProtocolStreamReader.php create mode 100644 src/Psalm/LanguageServer/ProtocolStreamWriter.php create mode 100644 src/Psalm/LanguageServer/ProtocolWriter.php create mode 100644 src/Psalm/LanguageServer/Server/TextDocument.php diff --git a/composer.json b/composer.json index 2413b7828..f061e868d 100644 --- a/composer.json +++ b/composer.json @@ -11,11 +11,18 @@ ], "require": { "php": "^7.0", - "nikic/PHP-Parser": "^4.0", + "nikic/PHP-Parser": "dev-master", "openlss/lib-array2xml": "^0.0.10||^0.5.1", "muglug/package-versions-56": "1.2.4", "php-cs-fixer/diff": "^1.2", - "composer/xdebug-handler": "^1.1" + "composer/xdebug-handler": "^1.1", + "felixfbecker/language-server-protocol": "^1.2", + "felixfbecker/advanced-json-rpc": "^3.0.3", + "netresearch/jsonmapper": "^1.0", + "sabre/event": "^5.0.1", + "sabre/uri": "^2.0", + "webmozart/glob": "^4.1", + "webmozart/path-util": "^2.3" }, "bin": ["psalm", "psalter"], "autoload": { @@ -31,6 +38,8 @@ "config": { "optimize-autoloader": true }, + "minimum-stability": "dev", + "prefer-stable": true, "require-dev": { "phpunit/phpunit": "^6.0 || ^7.0", "squizlabs/php_codesniffer": "^3.0", diff --git a/phpcs.xml b/phpcs.xml index e79dc9453..b6d292748 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -15,5 +15,9 @@ tests/stubs/ tests - + + + + * + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9ebf07b49..2ccef7c06 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -18,6 +18,7 @@ src/Psalm/Issue/ src/Psalm/Stubs/ + src/Psalm/LanguageServer/ src/command_functions.php src/psalm.php src/psalter.php diff --git a/psalm.xml.dist b/psalm.xml.dist index 5b4e93aac..d5cc2eff6 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -80,6 +80,7 @@ + @@ -90,6 +91,8 @@ + + diff --git a/src/Psalm/Checker/FileChecker.php b/src/Psalm/Checker/FileChecker.php index 496a497b9..3e2ddf347 100644 --- a/src/Psalm/Checker/FileChecker.php +++ b/src/Psalm/Checker/FileChecker.php @@ -79,7 +79,6 @@ class FileChecker extends SourceChecker implements StatementsSource public $project_checker; /** - * @param ProjectChecker $project_checker * @param string $file_path * @param string $file_name */ @@ -95,13 +94,16 @@ class FileChecker extends SourceChecker implements StatementsSource * * @return void */ - public function analyze(Context $file_context = null, $preserve_checkers = false, Context $global_context = null) - { + public function analyze( + Context $file_context = null, + $preserve_checkers = false, + Context $global_context = null + ) { $codebase = $this->project_checker->codebase; $file_storage = $codebase->file_storage_provider->get($this->file_path); - if (!$file_storage->deep_scan) { + if (!$file_storage->deep_scan && !$codebase->server_mode) { throw new UnpreparedAnalysisException('File ' . $this->file_path . ' has not been properly scanned'); } @@ -120,7 +122,11 @@ class FileChecker extends SourceChecker implements StatementsSource $this->context->is_global = true; - $stmts = $codebase->getStatementsForFile($this->file_path); + try { + $stmts = $codebase->getStatementsForFile($this->file_path); + } catch (PhpParser\Error $e) { + return; + } $statements_checker = new StatementsChecker($this); @@ -138,6 +144,7 @@ class FileChecker extends SourceChecker implements StatementsSource } // check any leftover classes not already evaluated + foreach ($this->class_checkers_to_analyze as $class_checker) { $class_checker->analyze(null, $this->context); } diff --git a/src/Psalm/Checker/ProjectChecker.php b/src/Psalm/Checker/ProjectChecker.php index 7b2d37de3..ca22810b9 100644 --- a/src/Psalm/Checker/ProjectChecker.php +++ b/src/Psalm/Checker/ProjectChecker.php @@ -1,9 +1,11 @@ file_reference_provider->loadReferenceCache(); + $this->codebase->enterServerMode(); + + $cwd = getcwd(); + + if (!$cwd) { + throw new \InvalidArgumentException('Cannot work with empty cwd'); + } + + $cpu_count = self::getCpuCount(); + + // let's not go crazy + $usable_cpus = $cpu_count - 2; + + if ($usable_cpus > 1) { + $this->threads = $usable_cpus; + } + + $this->config->initializePlugins($this); + + foreach ($this->config->getProjectDirectories() as $dir_name) { + $this->checkDirWithConfig($dir_name, $this->config); + } + + $this->output_format = self::TYPE_JSON; + + @cli_set_process_title('Psalm PHP Language Server'); + + if (!$server_mode && $address) { + // Connect to a TCP server + $socket = stream_socket_client('tcp://' . $address, $errno, $errstr); + if ($socket === false) { + fwrite(STDERR, "Could not connect to language client. Error $errno\n$errstr"); + exit(1); + } + stream_set_blocking($socket, false); + new LanguageServer( + new ProtocolStreamReader($socket), + new ProtocolStreamWriter($socket), + $this + ); + Loop\run(); + } elseif ($server_mode && $address) { + // Run a TCP Server + $tcpServer = stream_socket_server('tcp://' . $address, $errno, $errstr); + if ($tcpServer === false) { + fwrite(STDERR, "Could not listen on $address. Error $errno\n$errstr"); + exit(1); + } + fwrite(STDOUT, "Server listening on $address\n"); + if (!extension_loaded('pcntl')) { + fwrite(STDERR, "PCNTL is not available. Only a single connection will be accepted\n"); + } + while ($socket = stream_socket_accept($tcpServer, -1)) { + fwrite(STDOUT, "Connection accepted\n"); + stream_set_blocking($socket, false); + if (extension_loaded('pcntl')) { + // If PCNTL is available, fork a child process for the connection + // An exit notification will only terminate the child process + $pid = pcntl_fork(); + if ($pid === -1) { + fwrite(STDERR, "Could not fork\n"); + exit(1); + } + + if ($pid === 0) { + // Child process + $reader = new ProtocolStreamReader($socket); + $reader->on( + 'close', + /** @return void */ + function () { + fwrite(STDOUT, "Connection closed\n"); + } + ); + new LanguageServer( + $reader, + new ProtocolStreamWriter($socket), + $this + ); + Loop\run(); + // Just for safety + exit(0); + } + } else { + // If PCNTL is not available, we only accept one connection. + // An exit notification will terminate the server + new LanguageServer( + new ProtocolStreamReader($socket), + new ProtocolStreamWriter($socket), + $this + ); + Loop\run(); + } + } + } else { + // Use STDIO + stream_set_blocking(STDIN, false); + new LanguageServer( + new ProtocolStreamReader(STDIN), + new ProtocolStreamWriter(STDOUT), + $this + ); + Loop\run(); + } + } + /** * @return self */ @@ -743,4 +860,60 @@ class ProjectChecker $file_checker->getMethodMutations($appearing_method_id, $this_context); } + + /** + * Adapted from https://gist.github.com/divinity76/01ef9ca99c111565a72d3a8a6e42f7fb + * returns number of cpu cores + * Copyleft 2018, license: WTFPL + * @throws \RuntimeException + * @throws \LogicException + * @return int + * @psalm-suppress ForbiddenCode + */ + private function getCpuCount(): int + { + if (defined('PHP_WINDOWS_VERSION_MAJOR')) { + /* + $str = trim((string) shell_exec('wmic cpu get NumberOfCores 2>&1')); + if (!preg_match('/(\d+)/', $str, $matches)) { + throw new \RuntimeException('wmic failed to get number of cpu cores on windows!'); + } + return ((int) $matches [1]); + */ + return 1; + } + + $has_nproc = trim((string) @shell_exec('command -v nproc')); + if ($has_nproc) { + $ret = @shell_exec('nproc'); + if (is_string($ret)) { + $ret = trim($ret); + /** @var int|false */ + $tmp = filter_var($ret, FILTER_VALIDATE_INT); + if (is_int($tmp)) { + return $tmp; + } + } + } + + $ret = @shell_exec('sysctl -n hw.ncpu'); + if (is_string($ret)) { + $ret = trim($ret); + /** @var int|false */ + $tmp = filter_var($ret, FILTER_VALIDATE_INT); + if (is_int($tmp)) { + return $tmp; + } + } + + if (is_readable('/proc/cpuinfo')) { + $cpuinfo = file_get_contents('/proc/cpuinfo'); + $count = substr_count($cpuinfo, 'processor'); + if ($count > 0) { + return $count; + } + } + + throw new \LogicException('failed to detect number of CPUs!'); + } } diff --git a/src/Psalm/Codebase.php b/src/Psalm/Codebase.php index bb47bdca5..e9eb984e2 100644 --- a/src/Psalm/Codebase.php +++ b/src/Psalm/Codebase.php @@ -136,6 +136,11 @@ class Codebase */ public $populator; + /** + * @var bool + */ + public $server_mode = false; + /** * @param bool $collect_references * @param bool $debug_output @@ -272,6 +277,12 @@ class Codebase $this->populator->populateCodebase($this); } + /** @return void */ + public function enterServerMode() + { + $this->server_mode = true; + } + /** * @return void */ @@ -771,7 +782,7 @@ class Codebase * * @return void */ - private function invalidateInformationForFile($file_path) + private function invalidateInformationForFile(string $file_path) { $this->scanner->removeFile($file_path); diff --git a/src/Psalm/IssueBuffer.php b/src/Psalm/IssueBuffer.php index 164f3f593..3f3da444e 100644 --- a/src/Psalm/IssueBuffer.php +++ b/src/Psalm/IssueBuffer.php @@ -493,6 +493,19 @@ class IssueBuffer self::$console_issues = []; } + /** + * @return array + */ + public static function clear() + { + $current_data = self::$issues_data; + self::$issues_data = []; + self::$emitted = []; + return $current_data; + } + /** * @return bool */ diff --git a/src/Psalm/LanguageServer/Client/TextDocument.php b/src/Psalm/LanguageServer/Client/TextDocument.php new file mode 100644 index 000000000..a16a27b95 --- /dev/null +++ b/src/Psalm/LanguageServer/Client/TextDocument.php @@ -0,0 +1,69 @@ +handler = $handler; + $this->mapper = $mapper; + } + + /** + * Diagnostics notification are sent from the server to the client to signal results of validation runs. + * + * @param string $uri + * @param Diagnostic[] $diagnostics + * @return Promise + */ + public function publishDiagnostics(string $uri, array $diagnostics): Promise + { + return $this->handler->notify('textDocument/publishDiagnostics', [ + 'uri' => $uri, + 'diagnostics' => $diagnostics + ]); + } + + /** + * The content request is sent from a server to a client + * to request the current content of a text document identified by the URI + * + * @param TextDocumentIdentifier $textDocument The document to get the content for + * @return Promise The document's current content + */ + public function xcontent(TextDocumentIdentifier $textDocument): Promise + { + return $this->handler->request( + 'textDocument/xcontent', + ['textDocument' => $textDocument] + )->then( + /** + * @param object $result + * @return object + */ + function ($result) { + return $this->mapper->map($result, new TextDocumentItem); + } + ); + } +} diff --git a/src/Psalm/LanguageServer/ClientHandler.php b/src/Psalm/LanguageServer/ClientHandler.php new file mode 100644 index 000000000..5d983ca2a --- /dev/null +++ b/src/Psalm/LanguageServer/ClientHandler.php @@ -0,0 +1,97 @@ +protocolReader = $protocolReader; + $this->protocolWriter = $protocolWriter; + $this->idGenerator = new IdGenerator; + } + + /** + * Sends a request to the client and returns a promise that is resolved with the result or rejected with the error + * + * @param string $method The method to call + * @param array|object $params The method parameters + * @return Promise Resolved with the result of the request or rejected with an error + */ + public function request(string $method, $params): Promise + { + $id = $this->idGenerator->generate(); + return $this->protocolWriter->write( + new Message( + new AdvancedJsonRpc\Request($id, $method, (object)$params) + ) + )->then( + /** + * @return Promise + */ + function () use ($id) { + $promise = new Promise; + $listener = + /** + * @param callable $listener + * @return void + */ + function (Message $msg) use ($id, $promise, &$listener) { + /** + * @psalm-suppress UndefinedPropertyFetch + * @psalm-suppress MixedArgument + */ + if ($msg->body + && AdvancedJsonRpc\Response::isResponse($msg->body) + && $msg->body->id === $id + ) { + // Received a response + $this->protocolReader->removeListener('message', $listener); + if (AdvancedJsonRpc\SuccessResponse::isSuccessResponse($msg->body)) { + $promise->fulfill($msg->body->result); + } else { + $promise->reject($msg->body->error); + } + } + }; + $this->protocolReader->on('message', $listener); + return $promise; + } + ); + } + + /** + * Sends a notification to the client + * + * @param string $method The method to call + * @param array|object $params The method parameters + * @return Promise Will be resolved as soon as the notification has been sent + */ + public function notify(string $method, $params): Promise + { + return $this->protocolWriter->write( + new Message( + new AdvancedJsonRpc\Notification($method, (object)$params) + ) + ); + } +} diff --git a/src/Psalm/LanguageServer/IdGenerator.php b/src/Psalm/LanguageServer/IdGenerator.php new file mode 100644 index 000000000..fd112b2e7 --- /dev/null +++ b/src/Psalm/LanguageServer/IdGenerator.php @@ -0,0 +1,25 @@ +counter++; + } +} diff --git a/src/Psalm/LanguageServer/LanguageClient.php b/src/Psalm/LanguageServer/LanguageClient.php new file mode 100644 index 000000000..2c5ab83b8 --- /dev/null +++ b/src/Psalm/LanguageServer/LanguageClient.php @@ -0,0 +1,24 @@ +textDocument = new Client\TextDocument($handler, $mapper); + } +} diff --git a/src/Psalm/LanguageServer/LanguageServer.php b/src/Psalm/LanguageServer/LanguageServer.php new file mode 100644 index 000000000..d1f2404ac --- /dev/null +++ b/src/Psalm/LanguageServer/LanguageServer.php @@ -0,0 +1,368 @@ +project_checker = $project_checker; + + $this->protocolWriter = $writer; + + $this->protocolReader = $reader; + $this->protocolReader->on( + 'close', + /** + * @return void + */ + function () { + $this->shutdown(); + $this->exit(); + } + ); + $this->protocolReader->on( + 'message', + /** @return void */ + function (Message $msg) { + coroutine( + /** @return \Generator */ + function () use ($msg) { + if (!$msg->body) { + return; + } + + // Ignore responses, this is the handler for requests and notifications + if (AdvancedJsonRpc\Response::isResponse($msg->body)) { + return; + } + $result = null; + $error = null; + try { + // Invoke the method handler to get a result + /** + * @var Promise + * @psalm-suppress UndefinedClass + */ + $dispatched = $this->dispatch($msg->body); + $result = yield $dispatched; + } catch (AdvancedJsonRpc\Error $e) { + // If a ResponseError is thrown, send it back in the Response + $error = $e; + } catch (Throwable $e) { + // If an unexpected error occurred, send back an INTERNAL_ERROR error response + $error = new AdvancedJsonRpc\Error( + (string)$e, + AdvancedJsonRpc\ErrorCode::INTERNAL_ERROR, + null, + $e + ); + } + // Only send a Response for a Request + // Notifications do not send Responses + /** + * @psalm-suppress UndefinedPropertyFetch + * @psalm-suppress MixedArgument + */ + if (AdvancedJsonRpc\Request::isRequest($msg->body)) { + if ($error !== null) { + $responseBody = new AdvancedJsonRpc\ErrorResponse($msg->body->id, $error); + } else { + $responseBody = new AdvancedJsonRpc\SuccessResponse($msg->body->id, $result); + } + $this->protocolWriter->write(new Message($responseBody)); + } + } + )->otherwise('\Psalm\LanguageServer\LanguageServer::crash'); + } + ); + + $this->client = new LanguageClient($reader, $writer); + } + + /** + * The initialize request is sent as the first request from the client to the server. + * + * @param ClientCapabilities $capabilities The capabilities provided by the client (editor) + * @param string|null $rootPath The rootPath of the workspace. Is null if no folder is open. + * @param int|null $processId The process Id of the parent process that started the server. + * Is null if the process has not been started by another process. If the parent process is + * not alive then the server should exit (see exit notification) its process. + * @return Promise + * @psalm-suppress PossiblyUnusedMethod + */ + public function initialize( + ClientCapabilities $capabilities, + string $rootPath = null, + int $processId = null + ): Promise { + return coroutine( + /** @return \Generator */ + function () use ($capabilities, $rootPath, $processId) { + // Eventually, this might block on something. Leave it as a generator. + if (false) { + yield true; + } + + $this->project_checker->codebase->scanFiles($this->project_checker->threads); + + if ($this->textDocument === null) { + $this->textDocument = new TextDocument( + $this + ); + } + + $serverCapabilities = new ServerCapabilities(); + + $serverCapabilities->textDocumentSync = TextDocumentSyncKind::FULL; + + // Support "Find all symbols" + $serverCapabilities->documentSymbolProvider = false; + // Support "Find all symbols in workspace" + $serverCapabilities->workspaceSymbolProvider = false; + // Support "Go to definition" + $serverCapabilities->definitionProvider = false; + // Support "Find all references" + $serverCapabilities->referencesProvider = false; + // Support "Hover" + $serverCapabilities->hoverProvider = false; + // Support "Completion" + /* + $serverCapabilities->completionProvider = new CompletionOptions; + $serverCapabilities->completionProvider->resolveProvider = true; + $serverCapabilities->completionProvider->triggerCharacters = ['$', '>']; + */ + + /* + $serverCapabilities->signatureHelpProvider = new SignatureHelpOptions(); + $serverCapabilities->signatureHelpProvider->triggerCharacters = ['(', ',']; + */ + + // Support global references + $serverCapabilities->xworkspaceReferencesProvider = false; + $serverCapabilities->xdefinitionProvider = false; + $serverCapabilities->dependenciesProvider = false; + + return new InitializeResult($serverCapabilities); + } + ); + } + + /** + * @psalm-suppress PossiblyUnusedMethod + * @return void + */ + public function initialized() + { + } + + /** + * @return void + */ + public function invalidateFileAndDependents(string $uri) + { + $file_path = self::uriToPath($uri); + $this->project_checker->codebase->reloadFiles($this->project_checker, [$file_path]); + } + + /** + * @return void + */ + public function analyzePath(string $file_path) + { + $codebase = $this->project_checker->codebase; + + $codebase->addFilesToAnalyze([$file_path => $file_path]); + $codebase->analyzer->analyzeFiles($this->project_checker, 1, false); + } + + /** + * @return void + */ + public function emitIssues(string $uri) + { + $data = \Psalm\IssueBuffer::clear(); + + $file_path = self::uriToPath($uri); + + $data = array_values(array_filter( + $data, + function (array $issue_data) use ($file_path) : bool { + return $issue_data['file_path'] === $file_path; + } + )); + + $diagnostics = array_map( + /** + * @param array{ + * severity: string, + * message: string, + * line_from: int, + * line_to: int, + * column_from: int, + * column_to: int + * } $issue_data + */ + function (array $issue_data) use ($file_path) : Diagnostic { + //$check_name = $issue['check_name']; + $description = $issue_data['message']; + $severity = $issue_data['severity']; + + $start_line = max($issue_data['line_from'], 1); + $end_line = $issue_data['line_to']; + $start_column = $issue_data['column_from']; + $end_column = $issue_data['column_to']; + // Language server has 0 based lines and columns, phan has 1-based lines and columns. + $range = new Range( + new Position($start_line - 1, $start_column - 1), + new Position($end_line - 1, $end_column - 1) + ); + switch ($severity) { + case \Psalm\Config::REPORT_INFO: + $diagnostic_severity = DiagnosticSeverity::WARNING; + break; + case \Psalm\Config::REPORT_ERROR: + default: + $diagnostic_severity = DiagnosticSeverity::ERROR; + break; + } + // TODO: copy issue code in 'json' format + return new Diagnostic( + $description, + $range, + null, + $diagnostic_severity, + 'Psalm' + ); + }, + $data + ); + + $this->client->textDocument->publishDiagnostics($uri, $diagnostics); + } + + /** + * The shutdown request is sent from the client to the server. It asks the server to shut down, + * but to not exit (otherwise the response might not be delivered correctly to the client). + * There is a separate exit notification that asks the server to exit. + * + * @return void + */ + public function shutdown() + { + $scanned_files = $this->project_checker->codebase->scanner->getScannedFiles(); + $this->project_checker->file_reference_provider->updateReferenceCache( + $this->project_checker->codebase, + $scanned_files + ); + } + + /** + * A notification to ask the server to exit its process. + * + * @return void + */ + public function exit() + { + exit(0); + } + + /** + * Transforms URI into file path + * + * @param string $uri + * @return string + */ + public static function uriToPath(string $uri) + { + $fragments = parse_url($uri); + if ($fragments === false || !isset($fragments['scheme']) || $fragments['scheme'] !== 'file') { + throw new \InvalidArgumentException("Not a valid file URI: $uri"); + } + $filepath = urldecode((string) $fragments['path']); + if (strpos($filepath, ':') !== false) { + if ($filepath[0] === '/') { + $filepath = substr($filepath, 1); + } + $filepath = str_replace('/', '\\', $filepath); + } + return $filepath; + } + + /** + * Throws an exception on the next tick. + * Useful for letting a promise crash the process on rejection. + * + * @param Throwable $err + * @return void + * @psalm-suppress PossiblyUnusedMethod + */ + public static function crash(Throwable $err) + { + Loop\nextTick( + /** @return void */ + function () use ($err) { + throw $err; + } + ); + } +} diff --git a/src/Psalm/LanguageServer/Message.php b/src/Psalm/LanguageServer/Message.php new file mode 100644 index 000000000..797f4a74b --- /dev/null +++ b/src/Psalm/LanguageServer/Message.php @@ -0,0 +1,65 @@ +body = MessageBody::parse(array_pop($parts)); + foreach ($parts as $line) { + if ($line) { + $pair = explode(': ', $line); + $obj->headers[$pair[0]] = $pair[1]; + } + } + return $obj; + } + + /** + * @param \AdvancedJsonRpc\Message $body + * @param string[] $headers + */ + public function __construct(MessageBody $body = null, array $headers = []) + { + $this->body = $body; + if (!isset($headers['Content-Type'])) { + $headers['Content-Type'] = 'application/vscode-jsonrpc; charset=utf8'; + } + $this->headers = $headers; + } + + public function __toString(): string + { + $body = (string)$this->body; + $contentLength = strlen($body); + $this->headers['Content-Length'] = (string) $contentLength; + $headers = ''; + foreach ($this->headers as $name => $value) { + $headers .= "$name: $value\r\n"; + } + return $headers . "\r\n" . $body; + } +} diff --git a/src/Psalm/LanguageServer/ProtocolReader.php b/src/Psalm/LanguageServer/ProtocolReader.php new file mode 100644 index 000000000..8b52b704c --- /dev/null +++ b/src/Psalm/LanguageServer/ProtocolReader.php @@ -0,0 +1,17 @@ + */ + private $headers = []; + + /** @var ?int */ + private $contentLength; + + /** + * @param resource $input + */ + public function __construct($input) + { + $this->input = $input; + + $this->on( + 'close', + /** @return void */ + function () { + Loop\removeReadStream($this->input); + } + ); + + Loop\addReadStream( + $this->input, + /** @return void */ + function () { + if (feof($this->input)) { + // If stream_select reported a status change for this stream, + // but the stream is EOF, it means it was closed. + $this->emit('close'); + return; + } + while (($c = fgetc($this->input)) !== false && $c !== '') { + $this->buffer .= $c; + switch ($this->parsingMode) { + case self::PARSE_HEADERS: + if ($this->buffer === "\r\n") { + $this->parsingMode = self::PARSE_BODY; + $this->contentLength = (int)$this->headers['Content-Length']; + $this->buffer = ''; + } elseif (substr($this->buffer, -2) === "\r\n") { + $parts = explode(':', $this->buffer); + $this->headers[$parts[0]] = trim($parts[1]); + $this->buffer = ''; + } + break; + case self::PARSE_BODY: + if (strlen($this->buffer) === $this->contentLength) { + $msg = new Message(MessageBody::parse($this->buffer), $this->headers); + $this->emit('message', [$msg]); + $this->parsingMode = self::PARSE_HEADERS; + $this->headers = []; + $this->buffer = ''; + } + break; + } + } + } + ); + } +} diff --git a/src/Psalm/LanguageServer/ProtocolStreamWriter.php b/src/Psalm/LanguageServer/ProtocolStreamWriter.php new file mode 100644 index 000000000..5794f8253 --- /dev/null +++ b/src/Psalm/LanguageServer/ProtocolStreamWriter.php @@ -0,0 +1,92 @@ + $messages + */ + private $messages = []; + + /** + * @param resource $output + */ + public function __construct($output) + { + $this->output = $output; + } + + /** + * {@inheritdoc} + */ + public function write(Message $msg): Promise + { + // if the message queue is currently empty, register a write handler. + if (empty($this->messages)) { + Loop\addWriteStream( + $this->output, + /** @return void */ + function () { + $this->flush(); + } + ); + } + + $promise = new Promise(); + $this->messages[] = [ + 'message' => (string)$msg, + 'promise' => $promise + ]; + return $promise; + } + + /** + * Writes pending messages to the output stream. + * + * @return void + */ + private function flush() + { + $keepWriting = true; + while ($keepWriting) { + $message = $this->messages[0]['message']; + $promise = $this->messages[0]['promise']; + + $bytesWritten = @fwrite($this->output, $message); + + if ($bytesWritten > 0) { + $message = substr($message, $bytesWritten); + } + + // Determine if this message was completely sent + if (strlen($message) === 0) { + array_shift($this->messages); + + // This was the last message in the queue, remove the write handler. + if (count($this->messages) === 0) { + Loop\removeWriteStream($this->output); + $keepWriting = false; + } + + $promise->fulfill(); + } else { + $this->messages[0]['message'] = $message; + $keepWriting = false; + } + } + } +} diff --git a/src/Psalm/LanguageServer/ProtocolWriter.php b/src/Psalm/LanguageServer/ProtocolWriter.php new file mode 100644 index 000000000..d19092306 --- /dev/null +++ b/src/Psalm/LanguageServer/ProtocolWriter.php @@ -0,0 +1,18 @@ +server = $server; + } + + /** + * The document open notification is sent from the client to the server to signal newly opened text documents. The + * document's truth is now managed by the client and the server must not try to read the document's truth using the + * document's uri. + * + * @param \LanguageServerProtocol\TextDocumentItem $textDocument The document that was opened. + * @return void + */ + public function didOpen(TextDocumentItem $textDocument) + { + $file_path = LanguageServer::uriToPath($textDocument->uri); + + $this->server->invalidateFileAndDependents($textDocument->uri); + + $this->server->analyzePath($file_path); + $this->server->emitIssues($textDocument->uri); + } + + /** + * @return void + */ + public function didSave(TextDocumentItem $textDocument) + { + $file_path = LanguageServer::uriToPath($textDocument->uri); + + $this->server->invalidateFileAndDependents($textDocument->uri); + + $this->server->analyzePath($file_path); + $this->server->emitIssues($textDocument->uri); + } + + /** + * The document change notification is sent from the client to the server to signal changes to a text document. + * + * @param \LanguageServerProtocol\VersionedTextDocumentIdentifier $textDocument + * @param \LanguageServerProtocol\TextDocumentContentChangeEvent[] $contentChanges + * @return void + */ + public function didChange(VersionedTextDocumentIdentifier $textDocument, array $contentChanges) + { + } + + /** + * The document close notification is sent from the client to the server when the document got closed in the client. + * The document's truth now exists where the document's uri points to (e.g. if the document's uri is a file uri the + * truth now exists on disk). + * + * @param \LanguageServerProtocol\TextDocumentIdentifier $textDocument The document that was closed + * @return void + */ + public function didClose(TextDocumentIdentifier $textDocument) + { + } +} diff --git a/src/Psalm/Provider/ClassLikeStorageCacheProvider.php b/src/Psalm/Provider/ClassLikeStorageCacheProvider.php index 3b14d553f..642f17112 100644 --- a/src/Psalm/Provider/ClassLikeStorageCacheProvider.php +++ b/src/Psalm/Provider/ClassLikeStorageCacheProvider.php @@ -77,7 +77,7 @@ class ClassLikeStorageCacheProvider $cached_value = $this->loadFromCache($fq_classlike_name_lc, $file_path); if (!$cached_value) { - throw new \UnexpectedValueException('Should be in cache'); + throw new \UnexpectedValueException($fq_classlike_name_lc . ' should be in cache'); } $cache_hash = $this->getCacheHash($file_path, $file_contents); @@ -88,7 +88,7 @@ class ClassLikeStorageCacheProvider ) { unlink($this->getCacheLocationForClass($fq_classlike_name_lc, $file_path)); - throw new \UnexpectedValueException('Should not be outdated'); + throw new \UnexpectedValueException($fq_classlike_name_lc . ' should not be outdated'); } return $cached_value; diff --git a/src/Psalm/Provider/FileProvider.php b/src/Psalm/Provider/FileProvider.php index 85bae3a00..ba97668f2 100644 --- a/src/Psalm/Provider/FileProvider.php +++ b/src/Psalm/Provider/FileProvider.php @@ -1,6 +1,9 @@ */ /** * Used to determine which files reference other files, necessary for using the --diff @@ -31,6 +33,7 @@ class FileReferenceCacheProvider const CORRECT_METHODS_CACHE_NAME = 'correct_methods'; const CLASS_METHOD_CACHE_NAME = 'class_method_references'; const ISSUES_CACHE_NAME = 'issues'; + const FILE_MAPS_CACHE_NAME = 'file_maps'; /** * @var Config diff --git a/src/Psalm/Provider/FileReferenceProvider.php b/src/Psalm/Provider/FileReferenceProvider.php index 6d3a789dc..28af9ed04 100644 --- a/src/Psalm/Provider/FileReferenceProvider.php +++ b/src/Psalm/Provider/FileReferenceProvider.php @@ -397,10 +397,16 @@ class FileReferenceProvider /** * @param string $file_path + * @param IssueData $issue * @return void */ public function addIssue($file_path, array $issue) { + // don’t save parse errors ever, as they're not responsive to AST diffing + if ($issue['type'] === 'ParseError') { + return; + } + if (!isset(self::$issues[$file_path])) { self::$issues[$file_path] = [$issue]; } else { diff --git a/src/Psalm/Provider/FileStorageCacheProvider.php b/src/Psalm/Provider/FileStorageCacheProvider.php index d1da766f8..22e05b906 100644 --- a/src/Psalm/Provider/FileStorageCacheProvider.php +++ b/src/Psalm/Provider/FileStorageCacheProvider.php @@ -74,6 +74,7 @@ class FileStorageCacheProvider */ public function getLatestFromCache($file_path, $file_contents) { + $file_path = strtolower($file_path); $cached_value = $this->loadFromCache($file_path); if (!$cached_value) { diff --git a/src/Psalm/Provider/StatementsProvider.php b/src/Psalm/Provider/StatementsProvider.php index 0483394c7..4f0d6747d 100644 --- a/src/Psalm/Provider/StatementsProvider.php +++ b/src/Psalm/Provider/StatementsProvider.php @@ -2,6 +2,7 @@ namespace Psalm\Provider; use PhpParser; +use Psalm\Checker\ProjectChecker; class StatementsProvider { @@ -86,7 +87,9 @@ class StatementsProvider echo 'Parsing ' . $file_path . "\n"; } - return self::parseStatements($file_contents, $file_path) ?: []; + $stmts = self::parseStatements($file_contents, $file_path); + + return $stmts ?: []; } $file_content_hash = md5($version . $file_contents); @@ -275,7 +278,8 @@ class StatementsProvider } /** - * @param string $file_contents + * @param string $file_contents + * @param bool $server_mode * @param string $file_path * * @return array @@ -283,11 +287,11 @@ class StatementsProvider public static function parseStatements($file_contents, $file_path = null) { if (!self::$parser) { - $lexer = new PhpParser\Lexer([ - 'usedAttributes' => [ - 'comments', 'startLine', 'startFilePos', 'endFilePos', - ], - ]); + $attributes = [ + 'comments', 'startLine', 'startFilePos', 'endFilePos', + ]; + + $lexer = new PhpParser\Lexer\Emulative([ 'usedAttributes' => $attributes ]); self::$parser = (new PhpParser\ParserFactory())->create(PhpParser\ParserFactory::PREFER_PHP7, $lexer); } @@ -323,7 +327,6 @@ class StatementsProvider } } - /** @var array */ self::$node_traverser->traverse($stmts); return $stmts; diff --git a/src/Psalm/Storage/FunctionLikeStorage.php b/src/Psalm/Storage/FunctionLikeStorage.php index eaf246dc3..c2f844789 100644 --- a/src/Psalm/Storage/FunctionLikeStorage.php +++ b/src/Psalm/Storage/FunctionLikeStorage.php @@ -3,6 +3,7 @@ namespace Psalm\Storage; use Psalm\CodeLocation; use Psalm\Type; +use Psalm\Checker\ClassLikeChecker; class FunctionLikeStorage { @@ -140,4 +141,36 @@ class FunctionLikeStorage * @var string|null */ public $return_type_description; + + public function __toString() + { + $symbol_text = 'function ' . $this->cased_name . '(' . implode( + ', ', + array_map( + function (FunctionLikeParameter $param) : string { + return ($param->type ?: 'mixed') . ' $' . $param->name; + }, + $this->params + ) + ) . ') : ' . ($this->return_type ?: 'mixed'); + + if (!$this instanceof MethodStorage) { + return $symbol_text; + } + + switch ($this->visibility) { + case ClassLikeChecker::VISIBILITY_PRIVATE: + $visibility_text = 'private'; + break; + + case ClassLikeChecker::VISIBILITY_PROTECTED: + $visibility_text = 'protected'; + break; + + default: + $visibility_text = 'public'; + } + + return $visibility_text . ' ' . $symbol_text; + } } diff --git a/src/Psalm/Storage/PropertyStorage.php b/src/Psalm/Storage/PropertyStorage.php index 60787a3d9..3ad280b2b 100644 --- a/src/Psalm/Storage/PropertyStorage.php +++ b/src/Psalm/Storage/PropertyStorage.php @@ -3,6 +3,7 @@ namespace Psalm\Storage; use Psalm\CodeLocation; use Psalm\Type; +use Psalm\Checker\ClassLikeChecker; class PropertyStorage { diff --git a/src/psalm.php b/src/psalm.php index 6643117c6..35908c880 100644 --- a/src/psalm.php +++ b/src/psalm.php @@ -43,6 +43,7 @@ $valid_long_options = [ 'use-ini-defaults', 'version', 'diff-methods', + 'server', ]; $args = array_slice($argv, 1); @@ -190,6 +191,9 @@ Options: --disable-extension=[extension] Used to disable certain extensions while Psalm is running. + --server=[url] + Start Psalm in server mode with optional TCP url (by default it uses stdio) + HELP; exit; @@ -426,7 +430,7 @@ $project_checker = new ProjectChecker( !isset($options['show-snippet']) || $options['show-snippet'] !== "false" ); -$project_checker->diff_methods = isset($options['diff-methods']); +$project_checker->diff_methods = isset($options['diff-methods']) || isset($options['server']); $start_time = microtime(true); @@ -459,7 +463,9 @@ foreach ($plugins as $plugin_path) { Config::getInstance()->addPluginPath($current_dir . DIRECTORY_SEPARATOR . $plugin_path); } -if ($paths_to_check === null) { +if (isset($options['server'])) { + $project_checker->server(is_string($options['server']) ? $options['server'] : null); +} elseif ($paths_to_check === null) { $project_checker->check($current_dir, $is_diff); } elseif ($paths_to_check) { $project_checker->checkPaths($paths_to_check);