1
0
mirror of https://github.com/danog/psalm.git synced 2024-11-30 04:39:00 +01:00

Add server mode support with error reporting only

This commit is contained in:
Brown 2018-10-17 15:52:26 -04:00 committed by Matthew Brown
parent 47e987ccf8
commit 54fdda651b
28 changed files with 1273 additions and 21 deletions

View File

@ -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",

View File

@ -15,5 +15,9 @@
<exclude-pattern>tests/stubs/</exclude-pattern>
<rule ref="Generic.Files.LineLength">
<exclude-pattern>tests</exclude-pattern>
</rule>
</rule>
<rule ref="PSR2.Namespaces.UseDeclaration">
<exclude-pattern>*</exclude-pattern>
</rule>
</ruleset>

View File

@ -18,6 +18,7 @@
<exclude>
<directory suffix=".php">src/Psalm/Issue/</directory>
<directory suffix=".php">src/Psalm/Stubs/</directory>
<directory suffix=".php">src/Psalm/LanguageServer/</directory>
<file>src/command_functions.php</file>
<file>src/psalm.php</file>
<file>src/psalter.php</file>

View File

@ -80,6 +80,7 @@
<PossiblyUnusedProperty>
<errorLevel type="info">
<file name="src/Psalm/LanguageServer/LanguageClient.php" />
<file name="src/Psalm/Storage/FunctionLikeStorage.php" />
</errorLevel>
</PossiblyUnusedProperty>
@ -90,6 +91,8 @@
<errorLevel type="suppress">
<directory name="tests" />
<file name="src/Psalm/Plugin.php" />
<file name="src/Psalm/LanguageServer/Client/TextDocument.php" />
<file name="src/Psalm/LanguageServer/Server/TextDocument.php" />
<referencedMethod name="Psalm\Codebase::getParentInterfaces" />
<referencedMethod name="Psalm\Codebase::getMethodParams" />
<referencedMethod name="Psalm\Codebase::getMethodReturnType" />

View File

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

View File

@ -1,9 +1,11 @@
<?php
namespace Psalm\Checker;
use JsonRPC\Server;
use Psalm\Codebase;
use Psalm\Config;
use Psalm\Context;
use Psalm\LanguageServer\{LanguageServer, ProtocolStreamReader, ProtocolStreamWriter};
use Psalm\Provider\ClassLikeStorageCacheProvider;
use Psalm\Provider\ClassLikeStorageProvider;
use Psalm\Provider\FileProvider;
@ -228,6 +230,121 @@ class ProjectChecker
self::$instance = $this;
}
/**
* @param string $base_dir
* @param string|null $address
* @param bool $server_mode
* @return void
*/
public function server($address = '127.0.0.1:12345', $server_mode = true)
{
$this->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!');
}
}

View File

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

View File

@ -493,6 +493,19 @@ class IssueBuffer
self::$console_issues = [];
}
/**
* @return array<int, array{severity: string, line_from: int, line_to: int, type: string, message: string,
* file_name: string, file_path: string, snippet: string, from: int, to: int, snippet_from: int, snippet_to: int,
* column_from: int, column_to: int}>
*/
public static function clear()
{
$current_data = self::$issues_data;
self::$issues_data = [];
self::$emitted = [];
return $current_data;
}
/**
* @return bool
*/

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types = 1);
namespace Psalm\LanguageServer\Client;
use Psalm\LanguageServer\ClientHandler;
use LanguageServerProtocol\{Diagnostic, TextDocumentItem, TextDocumentIdentifier};
use Sabre\Event\Promise;
use JsonMapper;
/**
* Provides method handlers for all textDocument/* methods
*/
class TextDocument
{
/**
* @var ClientHandler
*/
private $handler;
/**
* @var JsonMapper
*/
private $mapper;
public function __construct(ClientHandler $handler, JsonMapper $mapper)
{
$this->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 <void>
*/
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 <TextDocumentItem> 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);
}
);
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types = 1);
namespace Psalm\LanguageServer;
use AdvancedJsonRpc;
use Sabre\Event\Promise;
class ClientHandler
{
/**
* @var ProtocolReader
*/
public $protocolReader;
/**
* @var ProtocolWriter
*/
public $protocolWriter;
/**
* @var IdGenerator
*/
public $idGenerator;
public function __construct(ProtocolReader $protocolReader, ProtocolWriter $protocolWriter)
{
$this->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 <mixed> 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 <null> 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)
)
);
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types = 1);
namespace Psalm\LanguageServer;
/**
* Generates unique, incremental IDs for use as request IDs
*/
class IdGenerator
{
/**
* @var int
*/
public $counter = 1;
/**
* Returns a unique ID
*
* @return int
*/
public function generate()
{
return $this->counter++;
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types = 1);
namespace Psalm\LanguageServer;
use JsonMapper;
class LanguageClient
{
/**
* Handles textDocument/* methods
*
* @var Client\TextDocument
*/
public $textDocument;
public function __construct(ProtocolReader $reader, ProtocolWriter $writer)
{
$handler = new ClientHandler($reader, $writer);
$mapper = new JsonMapper;
$this->textDocument = new Client\TextDocument($handler, $mapper);
}
}

View File

@ -0,0 +1,368 @@
<?php
declare(strict_types = 1);
namespace Psalm\LanguageServer;
use Psalm\Checker\ProjectChecker;
use Psalm\Config;
use LanguageServerProtocol\{
ServerCapabilities,
ClientCapabilities,
TextDocumentSyncKind,
InitializeResult,
CompletionOptions,
SignatureHelpOptions
};
use Psalm\LanguageServer\FilesFinder\{FilesFinder, ClientFilesFinder, FileSystemFilesFinder};
use Psalm\LanguageServer\ContentRetriever\{ContentRetriever, ClientContentRetriever, FileSystemContentRetriever};
use Psalm\LanguageServer\Index\{DependenciesIndex, GlobalIndex, Index, ProjectIndex, StubsIndex};
use Psalm\LanguageServer\Cache\{FileSystemCache, ClientCache};
use Psalm\LanguageServer\Server\TextDocument;
use LanguageServerProtocol\{Range, Position, Diagnostic, DiagnosticSeverity};
use AdvancedJsonRpc;
use Sabre\Event\Loop;
use Sabre\Event\Promise;
use function Sabre\Event\coroutine;
use Throwable;
use Webmozart\PathUtil\Path;
class LanguageServer extends AdvancedJsonRpc\Dispatcher
{
/**
* Handles textDocument/* method calls
*
* @var ?Server\TextDocument
*/
public $textDocument;
/**
* @var ProtocolReader
*/
protected $protocolReader;
/**
* @var ProtocolWriter
*/
protected $protocolWriter;
/**
* @var LanguageClient
*/
protected $client;
/**
* @var ProjectChecker
*/
protected $project_checker;
/**
* @param ProtocolReader $reader
* @param ProtocolWriter $writer
*/
public function __construct(
ProtocolReader $reader,
ProtocolWriter $writer,
ProjectChecker $project_checker
) {
parent::__construct($this, '/');
$this->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<int, Promise, mixed, void> */
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 <InitializeResult>
* @psalm-suppress PossiblyUnusedMethod
*/
public function initialize(
ClientCapabilities $capabilities,
string $rootPath = null,
int $processId = null
): Promise {
return coroutine(
/** @return \Generator<int, true, mixed, InitializeResult> */
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;
}
);
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types = 1);
namespace Psalm\LanguageServer;
use AdvancedJsonRpc\Message as MessageBody;
class Message
{
/**
* @var ?\AdvancedJsonRpc\Message
*/
public $body;
/**
* @var string[]
*/
public $headers;
/**
* Parses a message
*
* @param string $msg
* @return Message
* @psalm-suppress UnusedMethod
*/
public static function parse(string $msg): Message
{
$obj = new self;
$parts = explode("\r\n", $msg);
$obj->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;
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types = 1);
namespace Psalm\LanguageServer;
use Sabre\Event\EmitterInterface;
/**
* Must emit a "message" event with a Message object as parameter
* when a message comes in
*
* Must emit a "close" event when the stream closes
*/
interface ProtocolReader extends EmitterInterface
{
}

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types = 1);
namespace Psalm\LanguageServer;
use Psalm\LanguageServer\Message;
use AdvancedJsonRpc\Message as MessageBody;
use Sabre\Event\{Loop, Emitter};
class ProtocolStreamReader extends Emitter implements ProtocolReader
{
const PARSE_HEADERS = 1;
const PARSE_BODY = 2;
/** @var resource */
private $input;
/** @var int */
private $parsingMode = self::PARSE_HEADERS;
/** @var string */
private $buffer = '';
/** @var array<string, string> */
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;
}
}
}
);
}
}

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types = 1);
namespace Psalm\LanguageServer;
use Psalm\LanguageServer\Message;
use Sabre\Event\{
Loop,
Promise
};
use RuntimeException;
class ProtocolStreamWriter implements ProtocolWriter
{
/**
* @var resource $output
*/
private $output;
/**
* @var array<int, array{message: string, promise: Promise}> $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;
}
}
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types = 1);
namespace Psalm\LanguageServer;
use Psalm\LanguageServer\Message;
use Sabre\Event\Promise;
interface ProtocolWriter
{
/**
* Sends a Message to the client
*
* @param Message $msg
* @return Promise Resolved when the message has been fully written out to the output stream
*/
public function write(Message $msg): Promise;
}

View File

@ -0,0 +1,117 @@
<?php
declare(strict_types = 1);
namespace Psalm\LanguageServer\Server;
use PhpParser\PrettyPrinter\Standard as PrettyPrinter;
use PhpParser\{
Node,
NodeTraverser
};
use Psalm\LanguageServer\{
LanguageServer,
LanguageClient,
PhpDocumentLoader,
PhpDocument,
DefinitionResolver,
CompletionProvider
};
use Psalm\LanguageServer\NodeVisitor\VariableReferencesCollector;
use LanguageServerProtocol\{
SymbolLocationInformation,
SymbolDescriptor,
TextDocumentItem,
TextDocumentIdentifier,
VersionedTextDocumentIdentifier,
Position,
Range,
FormattingOptions,
TextEdit,
Location,
SymbolInformation,
ReferenceContext,
Hover,
MarkedString,
SymbolKind,
CompletionItem,
CompletionItemKind
};
use Psalm\Codebase;
use Psalm\LanguageServer\Index\ReadableIndex;
use Psalm\Checker\FileChecker;
use Psalm\Checker\ClassLikeChecker;
use Sabre\Event\Promise;
use Sabre\Uri;
use function Sabre\Event\coroutine;
use function Psalm\LanguageServer\{waitForEvent, isVendored};
/**
* Provides method handlers for all textDocument/* methods
*/
class TextDocument
{
/**
* @var LanguageServer
*/
protected $server;
public function __construct(
LanguageServer $server
) {
$this->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)
{
}
}

View File

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

View File

@ -1,6 +1,9 @@
<?php
namespace Psalm\Provider;
use PhpParser;
use Psalm\Checker\ProjectChecker;
class FileProvider
{
/**

View File

@ -20,6 +20,8 @@ use Psalm\Config;
* column_from: int,
* column_to: int
* }
*
* @psalm-type TaggedCodeType = array<int, array{0: int, 1: string}>
*/
/**
* 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

View File

@ -397,10 +397,16 @@ class FileReferenceProvider
/**
* @param string $file_path
* @param IssueData $issue
* @return void
*/
public function addIssue($file_path, array $issue)
{
// dont 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 {

View File

@ -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) {

View File

@ -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<int, \PhpParser\Node\Stmt>
@ -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<int, \PhpParser\Node\Stmt> */
self::$node_traverser->traverse($stmts);
return $stmts;

View File

@ -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;
}
}

View File

@ -3,6 +3,7 @@ namespace Psalm\Storage;
use Psalm\CodeLocation;
use Psalm\Type;
use Psalm\Checker\ClassLikeChecker;
class PropertyStorage
{

View File

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