Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modernize LSP progress reporting #10050

Merged
merged 6 commits into from
Jul 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

namespace Psalm\Internal\LanguageServer\Client\Progress;

use LanguageServerProtocol\LogMessage;
use LanguageServerProtocol\MessageType;
use LogicException;
use Psalm\Internal\LanguageServer\ClientHandler;

/** @internal */
final class LegacyProgress implements ProgressInterface
{
private const STATUS_INACTIVE = 'inactive';
private const STATUS_ACTIVE = 'active';
private const STATUS_FINISHED = 'finished';

private string $status = self::STATUS_INACTIVE;

private ClientHandler $handler;
private ?string $title = null;

public function __construct(ClientHandler $handler)
{
$this->handler = $handler;
}

public function begin(string $title, ?string $message = null, ?int $percentage = null): void
{

if ($this->status === self::STATUS_ACTIVE) {
throw new LogicException('Progress has already been started');
}

if ($this->status === self::STATUS_FINISHED) {
throw new LogicException('Progress has already been finished');
}

$this->title = $title;

$this->notify($message);

$this->status = self::STATUS_ACTIVE;
}

public function update(?string $message = null, ?int $percentage = null): void
{
if ($this->status === self::STATUS_FINISHED) {
throw new LogicException('Progress has already been finished');
}

if ($this->status === self::STATUS_INACTIVE) {
throw new LogicException('Progress has not been started yet');
}

$this->notify($message);
}

public function end(?string $message = null): void
{
if ($this->status === self::STATUS_FINISHED) {
throw new LogicException('Progress has already been finished');
}

if ($this->status === self::STATUS_INACTIVE) {
throw new LogicException('Progress has not been started yet');
}

$this->notify($message);

$this->status = self::STATUS_FINISHED;
}

private function notify(?string $message): void
{
$this->handler->notify(
'telemetry/event',
new LogMessage(
MessageType::INFO,
$this->title . (empty($message) ? '' : (': ' . $message)),
),
);
}
}
121 changes: 121 additions & 0 deletions src/Psalm/Internal/LanguageServer/Client/Progress/Progress.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

namespace Psalm\Internal\LanguageServer\Client\Progress;

use LogicException;
use Psalm\Internal\LanguageServer\ClientHandler;

/** @internal */
final class Progress implements ProgressInterface
{
private const STATUS_INACTIVE = 'inactive';
private const STATUS_ACTIVE = 'active';
private const STATUS_FINISHED = 'finished';

private string $status = self::STATUS_INACTIVE;

private ClientHandler $handler;
private string $token;
private bool $withPercentage = false;

public function __construct(ClientHandler $handler, string $token)
{
$this->handler = $handler;
$this->token = $token;
}

public function begin(
string $title,
?string $message = null,
?int $percentage = null
): void {
if ($this->status === self::STATUS_ACTIVE) {
throw new LogicException('Progress has already been started');
}

if ($this->status === self::STATUS_FINISHED) {
throw new LogicException('Progress has already been finished');
}

$notification = [
'token' => $this->token,
'value' => [
'kind' => 'begin',
'title' => $title,
],
];

if ($message !== null) {
$notification['value']['message'] = $message;
}

if ($percentage !== null) {
$notification['value']['percentage'] = $percentage;
$this->withPercentage = true;
}

$this->handler->notify('$/progress', $notification);

$this->status = self::STATUS_ACTIVE;
}

public function end(?string $message = null): void
{
if ($this->status === self::STATUS_FINISHED) {
throw new LogicException('Progress has already been finished');
}

if ($this->status === self::STATUS_INACTIVE) {
throw new LogicException('Progress has not been started yet');
}

$notification = [
'token' => $this->token,
'value' => [
'kind' => 'end',
],
];

if ($message !== null) {
$notification['value']['message'] = $message;
}

$this->handler->notify('$/progress', $notification);

$this->status = self::STATUS_FINISHED;
}

public function update(?string $message = null, ?int $percentage = null): void
{
if ($this->status === self::STATUS_FINISHED) {
throw new LogicException('Progress has already been finished');
}

if ($this->status === self::STATUS_INACTIVE) {
throw new LogicException('Progress has not been started yet');
}

$notification = [
'token' => $this->token,
'value' => [
'kind' => 'report',
],
];

if ($message !== null) {
$notification['value']['message'] = $message;
}

if ($percentage !== null) {
if (!$this->withPercentage) {
throw new LogicException(
'Cannot update percentage for progress '
. 'that was started without percentage',
);
}
$notification['value']['percentage'] = $percentage;
}

$this->handler->notify('$/progress', $notification);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Psalm\Internal\LanguageServer\Client\Progress;

/** @internal */
interface ProgressInterface
{
public function begin(
string $title,
?string $message = null,
?int $percentage = null
): void;

public function update(?string $message = null, ?int $percentage = null): void;
public function end(?string $message = null): void;
}
12 changes: 12 additions & 0 deletions src/Psalm/Internal/LanguageServer/LanguageClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
use JsonMapper;
use LanguageServerProtocol\LogMessage;
use LanguageServerProtocol\LogTrace;
use Psalm\Internal\LanguageServer\Client\Progress\LegacyProgress;
use Psalm\Internal\LanguageServer\Client\Progress\Progress;
use Psalm\Internal\LanguageServer\Client\Progress\ProgressInterface;
use Psalm\Internal\LanguageServer\Client\TextDocument as ClientTextDocument;
use Psalm\Internal\LanguageServer\Client\Workspace as ClientWorkspace;

Expand Down Expand Up @@ -131,6 +134,15 @@ public function event(LogMessage $logMessage): void
);
}

public function makeProgress(string $token): ProgressInterface
{
if ($this->server->clientCapabilities->window->workDoneProgress ?? false) {
return new Progress($this->handler, $token);
} else {
return new LegacyProgress($this->handler);
}
}

/**
* Configuration Refreshed from Client
*
Expand Down
31 changes: 12 additions & 19 deletions src/Psalm/Internal/LanguageServer/LanguageServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
use function strpos;
use function substr;
use function trim;
use function uniqid;
use function urldecode;

use const JSON_PRETTY_PRINT;
Expand Down Expand Up @@ -377,29 +378,19 @@ public static function run(
* 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 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.
* @param ClientInfo|null $clientInfo Information about the client
* @param string|null $locale The locale the client is currently showing the user interface
* in. This must not necessarily be the locale of the operating
* system.
* @param string|null $rootPath The rootPath of the workspace. Is null if no folder is open.
* @param mixed $initializationOptions
* @param string|null $trace The initial trace setting. If omitted trace is disabled ('off').
* @param string|null $workDoneToken The token to be used to report progress during init.
* @psalm-return Promise<InitializeResult>
* @psalm-suppress PossiblyUnusedParam
*/
public function initialize(
ClientCapabilities $capabilities,
?int $processId = null,
?ClientInfo $clientInfo = null,
?string $locale = null,
?string $rootPath = null,
?string $rootUri = null,
$initializationOptions = null,
?string $trace = null
//?array $workspaceFolders = null //error in json-dispatcher
?string $trace = null,
?string $workDoneToken = null
): Promise {
$this->clientInfo = $clientInfo;
$this->clientCapabilities = $capabilities;
Expand All @@ -412,9 +403,11 @@ public function initialize(

return call(
/** @return Generator<int, true, mixed, InitializeResult> */
function () {
function () use ($workDoneToken) {
$progress = $this->client->makeProgress($workDoneToken ?? uniqid('tkn', true));

$this->logInfo("Initializing...");
$this->clientStatus('initializing');
$progress->begin('Psalm', 'initializing');

// Eventually, this might block on something. Leave it as a generator.
/** @psalm-suppress TypeDoesNotContainType */
Expand All @@ -425,14 +418,14 @@ function () {
$this->project_analyzer->serverMode($this);

$this->logInfo("Initializing: Getting code base...");
$this->clientStatus('initializing', 'getting code base');
$progress->update('getting code base');

$this->logInfo("Initializing: Scanning files ({$this->project_analyzer->threads} Threads)...");
$this->clientStatus('initializing', 'scanning files');
$progress->update('scanning files');
$this->codebase->scanFiles($this->project_analyzer->threads);

$this->logInfo("Initializing: Registering stub files...");
$this->clientStatus('initializing', 'registering stub files');
$progress->update('registering stub files');
$this->codebase->config->visitStubFiles($this->codebase, $this->project_analyzer->progress);

if ($this->textDocument === null) {
Expand Down Expand Up @@ -572,7 +565,7 @@ function () {
}

$this->logInfo("Initializing: Complete.");
$this->clientStatus('initialized');
$progress->end('initialized');

/**
* Information about the server.
Expand Down
4 changes: 1 addition & 3 deletions src/Psalm/Internal/LanguageServer/Server/TextDocument.php
Original file line number Diff line number Diff line change
Expand Up @@ -398,10 +398,8 @@ public function signatureHelp(TextDocumentIdentifier $textDocument, Position $po
* The code action request is sent from the client to the server to compute commands
* for a given text document and range. These commands are typically code fixes to
* either fix problems or to beautify/refactor code.
*
* @psalm-suppress PossiblyUnusedParam
*/
public function codeAction(TextDocumentIdentifier $textDocument, Range $range, CodeActionContext $context): Promise
public function codeAction(TextDocumentIdentifier $textDocument, CodeActionContext $context): Promise
{
if (!$this->server->client->clientConfiguration->provideCodeActions) {
return new Success(null);
Expand Down
5 changes: 2 additions & 3 deletions src/Psalm/Internal/LanguageServer/Server/Workspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,9 @@ public function didChangeWatchedFiles(array $changes): void
/**
* A notification sent from the client to the server to signal the change of configuration settings.
*
* @param mixed $settings
* @psalm-suppress PossiblyUnusedMethod, PossiblyUnusedParam
* @psalm-suppress PossiblyUnusedMethod
*/
public function didChangeConfiguration($settings): void
public function didChangeConfiguration(): void
{
$this->server->logDebug(
'workspace/didChangeConfiguration',
Expand Down
15 changes: 13 additions & 2 deletions tests/LanguageServer/DiagnosticTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,21 @@ public function testSnippetSupportDisabled(): void
);

$write->on('message', function (Message $message) use ($deferred, $server): void {
/** @psalm-suppress PossiblyNullPropertyFetch,UndefinedPropertyFetch,MixedPropertyFetch */
if ($message->body->method === 'telemetry/event' && $message->body->params->message === 'initialized') {
/** @psalm-suppress NullPropertyFetch,PossiblyNullPropertyFetch,UndefinedPropertyFetch */
if ($message->body->method === 'telemetry/event' && ($message->body->params->message ?? null) === 'initialized') {
$this->assertFalse($server->clientCapabilities->textDocument->completion->completionItem->snippetSupport);
$deferred->resolve(null);
return;
}

/** @psalm-suppress NullPropertyFetch,PossiblyNullPropertyFetch */
if ($message->body->method === '$/progress'
&& ($message->body->params->value->kind ?? null) === 'end'
&& ($message->body->params->value->message ?? null) === 'initialized'
) {
$this->assertFalse($server->clientCapabilities->textDocument->completion->completionItem->snippetSupport);
$deferred->resolve(null);
return;
}
});

Expand Down