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);