diff --git a/src/Psalm/Internal/Analyzer/IssueData.php b/src/Psalm/Internal/Analyzer/IssueData.php index 5c83ff850..bef7e1820 100644 --- a/src/Psalm/Internal/Analyzer/IssueData.php +++ b/src/Psalm/Internal/Analyzer/IssueData.php @@ -98,6 +98,12 @@ class IssueData */ public $error_level; + /** + * @var int + * @readonly + */ + public $shortcode; + /** * @var string * @readonly @@ -157,6 +163,7 @@ class IssueData $this->snippet_to = $snippet_to; $this->column_from = $column_from; $this->column_to = $column_to; + $this->shortcode = $shortcode; $this->error_level = $error_level; $this->link = 'https://psalm.dev/' . \str_pad((string) $shortcode, 3, "0", \STR_PAD_LEFT); } diff --git a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php index e74f8a706..c07f753e6 100644 --- a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php @@ -215,6 +215,20 @@ class ProjectAnalyzer UnnecessaryVarAnnotation::class, ]; + /** + * When this is true, the language server will send the diagnostic code with a help link. + * + * @var bool + */ + public $language_server_use_extended_diagnostic_codes = false; + + /** + * If this is true then the language server will send log messages to the client with additional information. + * + * @var bool + */ + public $language_server_verbose = false; + /** * @param array $generated_report_options * @param int $threads @@ -367,7 +381,7 @@ class ProjectAnalyzer $this->checkDirWithConfig($dir_name, $this->config); } - @cli_set_process_title('Psalm PHP Language Server'); + @cli_set_process_title('Psalm ' . PSALM_VERSION . ' - PHP Language Server'); if (!$socket_server_mode && $address) { // Connect to a TCP server diff --git a/src/Psalm/Internal/LanguageServer/LanguageClient.php b/src/Psalm/Internal/LanguageServer/LanguageClient.php index b1d7b99a3..3f0ec2db7 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageClient.php +++ b/src/Psalm/Internal/LanguageServer/LanguageClient.php @@ -2,6 +2,7 @@ declare(strict_types = 1); namespace Psalm\Internal\LanguageServer; +use Amp\Promise; use JsonMapper; /** @@ -16,11 +17,48 @@ class LanguageClient */ public $textDocument; + /** + * The client handler + * + * @var ClientHandler + */ + private $handler; + public function __construct(ProtocolReader $reader, ProtocolWriter $writer) { - $handler = new ClientHandler($reader, $writer); + $this->handler = new ClientHandler($reader, $writer); $mapper = new JsonMapper; - $this->textDocument = new Client\TextDocument($handler, $mapper); + $this->textDocument = new Client\TextDocument($this->handler, $mapper); + } + + /** + * Send a log message to the client. + * + * @param string $message The message to send to the client. + * @psalm-param 1|2|3|4 $type + * @param integer $type The log type: + * - 1 = Error + * - 2 = Warning + * - 3 = Info + * - 4 = Log + + * @return Promise + */ + public function logMessage(string $message, int $type = 4, string $method = 'window/logMessage'): Promise + { + // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_logMessage + + if ($type < 1 || $type > 4) { + $type = 4; + } + + return $this->handler->notify( + $method, + [ + 'type' => $type, + 'message' => $message + ] + ); } } diff --git a/src/Psalm/Internal/LanguageServer/LanguageServer.php b/src/Psalm/Internal/LanguageServer/LanguageServer.php index e123ecd20..0035667ee 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageServer.php +++ b/src/Psalm/Internal/LanguageServer/LanguageServer.php @@ -179,6 +179,8 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher ); $this->client = new LanguageClient($reader, $writer); + + $this->verboseLog("Language server has started."); } /** @@ -200,16 +202,25 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher return call( /** @return \Generator */ function () use ($capabilities, $rootPath, $processId) { + $this->verboseLog("Initializing..."); + $this->clientStatus('initializing'); + // Eventually, this might block on something. Leave it as a generator. /** @psalm-suppress TypeDoesNotContainType */ if (false) { yield true; } + $this->verboseLog("Initializing: Getting code base..."); + $this->clientStatus('initializing', 'getting code base'); $codebase = $this->project_analyzer->getCodebase(); + $this->verboseLog("Initializing: Scanning files..."); + $this->clientStatus('initializing', 'scanning files'); $codebase->scanFiles($this->project_analyzer->threads); + $this->verboseLog("Initializing: Registering stub files..."); + $this->clientStatus('initializing', 'registering stub files'); $codebase->config->visitStubFiles($codebase, null); if ($this->textDocument === null) { @@ -257,6 +268,8 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher $serverCapabilities->xdefinitionProvider = false; $serverCapabilities->dependenciesProvider = false; + $this->verboseLog("Initializing: Complete."); + $this->clientStatus('initialized'); return new InitializeResult($serverCapabilities); } ); @@ -269,6 +282,7 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher */ public function initialized() { + $this->clientStatus('running'); } /** @@ -292,30 +306,39 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher */ public function doAnalysis() { - $codebase = $this->project_analyzer->getCodebase(); + $this->clientStatus('analyzing'); - $all_files_to_analyze = $this->onchange_paths_to_analyze + $this->onsave_paths_to_analyze; + try { + $codebase = $this->project_analyzer->getCodebase(); - if (!$all_files_to_analyze) { - return; + $all_files_to_analyze = $this->onchange_paths_to_analyze + $this->onsave_paths_to_analyze; + + if (!$all_files_to_analyze) { + return; + } + + if ($this->onsave_paths_to_analyze) { + $codebase->reloadFiles($this->project_analyzer, array_keys($this->onsave_paths_to_analyze)); + } + + if ($this->onchange_paths_to_analyze) { + $codebase->reloadFiles($this->project_analyzer, array_keys($this->onchange_paths_to_analyze)); + } + + $all_file_paths_to_analyze = array_keys($all_files_to_analyze); + $codebase->analyzer->addFilesToAnalyze( + array_combine($all_file_paths_to_analyze, $all_file_paths_to_analyze) + ); + $codebase->analyzer->analyzeFiles($this->project_analyzer, 1, false); + + $this->emitIssues($all_files_to_analyze); + + $this->onchange_paths_to_analyze = []; + $this->onsave_paths_to_analyze = []; + } finally { + // we are done, so set the status back to running + $this->clientStatus('running'); } - - if ($this->onsave_paths_to_analyze) { - $codebase->reloadFiles($this->project_analyzer, array_keys($this->onsave_paths_to_analyze)); - } - - if ($this->onchange_paths_to_analyze) { - $codebase->reloadFiles($this->project_analyzer, array_keys($this->onchange_paths_to_analyze)); - } - - $all_file_paths_to_analyze = array_keys($all_files_to_analyze); - $codebase->analyzer->addFilesToAnalyze(array_combine($all_file_paths_to_analyze, $all_file_paths_to_analyze)); - $codebase->analyzer->analyzeFiles($this->project_analyzer, 1, false); - - $this->emitIssues($all_files_to_analyze); - - $this->onchange_paths_to_analyze = []; - $this->onsave_paths_to_analyze = []; } /** @@ -352,14 +375,35 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher $diagnostic_severity = DiagnosticSeverity::ERROR; break; } - // TODO: copy issue code in 'json' format - return new Diagnostic( + $diagnostic = new Diagnostic( $description, $range, null, $diagnostic_severity, 'Psalm' ); + + //$code = 'PS' . \str_pad((string) $issue_data->shortcode, 3, "0", \STR_PAD_LEFT); + $code = $issue_data->link; + + if ($this->project_analyzer->language_server_use_extended_diagnostic_codes) { + // Added in VSCode 1.43.0 and will be part of the LSP 3.16.0 standard. + // Since this new functionality is not backwards compatible, we use a + // configuration option so the end user must opt in to it using the cli argument. + // https://github.com/microsoft/vscode/blob/1.43.0/src/vs/vscode.d.ts#L4688-L4699 + + /** @psalm-suppress InvalidPropertyAssignmentValue */ + $diagnostic->code = [ + "value" => $code, + "target" => $issue_data->link, + ]; + } else { + // the Diagnostic constructor only takes `int` for the code, but the property can be + // `int` or `string`, so we set the property directly because we want to use a `string` + $diagnostic->code = $code; + } + + return $diagnostic; }, $data[$file_path] ?? [] ); @@ -377,12 +421,15 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher */ public function shutdown() { + $this->clientStatus('closing'); + $this->verboseLog("Shutting down..."); $codebase = $this->project_analyzer->getCodebase(); $scanned_files = $codebase->scanner->getScannedFiles(); $codebase->file_reference_provider->updateReferenceCache( $codebase, $scanned_files ); + $this->clientStatus('closed'); return new Success(null); } @@ -396,6 +443,57 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher exit(0); } + + /** + * Send log message to the client + * + * @param string $message The log message to send to the client. + * @psalm-param 1|2|3|4 $type + * @param integer $type The log type: + * - 1 = Error + * - 2 = Warning + * - 3 = Info + * - 4 = Log + * @return Promise + */ + private function verboseLog(string $message, int $type = 4): Promise + { + if ($this->project_analyzer->language_server_verbose) { + try { + return $this->client->logMessage( + '[Psalm ' .PSALM_VERSION. ' - PHP Language Server] ' . $message, + $type + ); + } catch (\Throwable $err) { + // do nothing + } + } + return new Success(null); + } + + /** + * Send status message to client. This is the same as sending a log message, + * except this is meant for parsing by the client to present status updates in a UI. + * + * @param string $status The log message to send to the client. Should not contain colons `:`. + * @param string|null $additional_info This is additional info that the client + * can use as part of the display message. + * @return Promise + */ + private function clientStatus(string $status, string $additional_info = null): Promise + { + try { + // here we send a notification to the client using the telemetry notification method + return $this->client->logMessage( + $status . (!empty($additional_info) ? ': ' . $additional_info : ''), + 3, + 'telemetry/event' + ); + } catch (\Throwable $err) { + return new Success(null); + } + } + /** * Transforms an absolute file path into a URI as used by the language server protocol. * diff --git a/src/psalm-language-server.php b/src/psalm-language-server.php index 0de70d77d..2d4cbf859 100644 --- a/src/psalm-language-server.php +++ b/src/psalm-language-server.php @@ -30,6 +30,8 @@ $valid_long_options = [ 'tcp-server', 'disable-on-change::', 'enable-autocomplete', + 'use-extended-diagnostic-codes', + 'verbose' ]; $args = array_slice($argv, 1); @@ -146,11 +148,22 @@ Options: --enable-autocomplete[=BOOL] Enables or disables autocomplete on methods and properties. Default is true. + + --use-extended-diagnostic-codes + Enables sending help uri links with the code in diagnostic messages. + + --verbose + Will send log messages to the client with information. HELP; exit; } +if (array_key_exists('v', $options)) { + echo 'Psalm ' . PSALM_VERSION . PHP_EOL; + exit; +} + if (getcwd() === false) { fwrite(STDERR, 'Cannot get current working directory' . PHP_EOL); exit(1); @@ -180,11 +193,6 @@ $vendor_dir = getVendorDir($current_dir); $first_autoloader = requireAutoloaders($current_dir, isset($options['r']), $vendor_dir); -if (array_key_exists('v', $options)) { - echo 'Psalm ' . PSALM_VERSION . PHP_EOL; - exit; -} - $ini_handler = new \Psalm\Internal\Fork\PsalmRestarter('PSALM'); $ini_handler->disableExtension('grpc'); @@ -250,4 +258,12 @@ if ($find_dead_code) { $project_analyzer->getCodebase()->reportUnusedCode(); } +if (isset($options['use-extended-diagnostic-codes'])) { + $project_analyzer->language_server_use_extended_diagnostic_codes = true; +} + +if (isset($options['verbose'])) { + $project_analyzer->language_server_verbose = true; +} + $project_analyzer->server($options['tcp'] ?? null, isset($options['tcp-server']) ? true : false); diff --git a/tests/JsonOutputTest.php b/tests/JsonOutputTest.php index bae1df172..ff0d990c8 100644 --- a/tests/JsonOutputTest.php +++ b/tests/JsonOutputTest.php @@ -115,6 +115,7 @@ echo $a;'; 'column_from' => 10, 'column_to' => 17, 'error_level' => -1, + 'shortcode' => 24, 'link' => 'https://psalm.dev/024' ], [ @@ -134,6 +135,7 @@ echo $a;'; 'column_from' => 29, 'column_to' => 39, 'error_level' => -2, + 'shortcode' => 135, 'link' => 'https://psalm.dev/135' ], [ @@ -153,6 +155,7 @@ echo $a;'; 'column_from' => 42, 'column_to' => 49, 'error_level' => 1, + 'shortcode' => 47, 'link' => 'https://psalm.dev/047' ], [ @@ -172,6 +175,7 @@ echo $a;'; 'column_from' => 6, 'column_to' => 15, 'error_level' => -1, + 'shortcode' => 20, 'link' => 'https://psalm.dev/020' ], [ @@ -191,6 +195,7 @@ echo $a;'; 'column_from' => 6, 'column_to' => 8, 'error_level' => 3, + 'shortcode' => 126, 'link' => 'https://psalm.dev/126' ], ], diff --git a/tests/ReportOutputTest.php b/tests/ReportOutputTest.php index d114b7648..fa8236330 100644 --- a/tests/ReportOutputTest.php +++ b/tests/ReportOutputTest.php @@ -125,6 +125,7 @@ echo $a;'; 'column_from' => 10, 'column_to' => 17, 'error_level' => -1, + 'shortcode' => 24, 'link' => 'https://psalm.dev/024' ], [ @@ -144,6 +145,7 @@ echo $a;'; 'column_from' => 42, 'column_to' => 49, 'error_level' => 1, + 'shortcode' => 47, 'link' => 'https://psalm.dev/047' ], [ @@ -163,6 +165,7 @@ echo $a;'; 'column_from' => 6, 'column_to' => 15, 'error_level' => -1, + 'shortcode' => 20, 'link' => 'https://psalm.dev/020' ], [ @@ -182,6 +185,7 @@ echo $a;'; 'column_from' => 6, 'column_to' => 8, 'error_level' => 3, + 'shortcode' => 126, 'link' => 'https://psalm.dev/126' ], ];