From acdb11e6792fea8a892a5ad7dcb24484df7f8296 Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Fri, 21 Jul 2023 02:06:08 +0200 Subject: [PATCH 01/11] Use instance methods instead of static --- .../LanguageServer/LanguageServer.php | 4 ++-- .../LanguageServer/Server/TextDocument.php | 22 +++++++++---------- .../LanguageServer/Server/Workspace.php | 6 ++--- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Psalm/Internal/LanguageServer/LanguageServer.php b/src/Psalm/Internal/LanguageServer/LanguageServer.php index 54009b9cc5a..07990378bd2 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageServer.php +++ b/src/Psalm/Internal/LanguageServer/LanguageServer.php @@ -951,7 +951,7 @@ private function clientStatus(string $status, ?string $additional_info = null): * * @psalm-pure */ - public static function pathToUri(string $filepath): string + public function pathToUri(string $filepath): string { $filepath = trim(str_replace('\\', '/', $filepath), '/'); $parts = explode('/', $filepath); @@ -970,7 +970,7 @@ public static function pathToUri(string $filepath): string /** * Transforms URI into file path */ - public static function uriToPath(string $uri): string + public function uriToPath(string $uri): string { $fragments = parse_url($uri); if ($fragments === false diff --git a/src/Psalm/Internal/LanguageServer/Server/TextDocument.php b/src/Psalm/Internal/LanguageServer/Server/TextDocument.php index 1ea170a86f0..b24862e7460 100644 --- a/src/Psalm/Internal/LanguageServer/Server/TextDocument.php +++ b/src/Psalm/Internal/LanguageServer/Server/TextDocument.php @@ -74,7 +74,7 @@ public function didOpen(TextDocumentItem $textDocument): void ['version' => $textDocument->version, 'uri' => $textDocument->uri], ); - $file_path = LanguageServer::uriToPath($textDocument->uri); + $file_path = $this->server->uriToPath($textDocument->uri); $this->codebase->removeTemporaryFileChanges($file_path); $this->codebase->file_provider->openFile($file_path); @@ -97,7 +97,7 @@ public function didSave(TextDocumentIdentifier $textDocument, ?string $text = nu ['uri' => (array) $textDocument], ); - $file_path = LanguageServer::uriToPath($textDocument->uri); + $file_path = $this->server->uriToPath($textDocument->uri); // reopen file $this->codebase->removeTemporaryFileChanges($file_path); @@ -119,7 +119,7 @@ public function didChange(VersionedTextDocumentIdentifier $textDocument, array $ ['version' => $textDocument->version, 'uri' => $textDocument->uri], ); - $file_path = LanguageServer::uriToPath($textDocument->uri); + $file_path = $this->server->uriToPath($textDocument->uri); if (count($contentChanges) === 1 && isset($contentChanges[0]) && $contentChanges[0]->range === null) { $new_content = $contentChanges[0]->text; @@ -154,7 +154,7 @@ public function didClose(TextDocumentIdentifier $textDocument): void ['uri' => $textDocument->uri], ); - $file_path = LanguageServer::uriToPath($textDocument->uri); + $file_path = $this->server->uriToPath($textDocument->uri); $this->codebase->file_provider->closeFile($file_path); $this->server->client->textDocument->publishDiagnostics($textDocument->uri, []); @@ -178,7 +178,7 @@ public function definition(TextDocumentIdentifier $textDocument, Position $posit 'textDocument/definition', ); - $file_path = LanguageServer::uriToPath($textDocument->uri); + $file_path = $this->server->uriToPath($textDocument->uri); //This currently doesnt work right with out of project files if (!$this->codebase->config->isInProjectDirs($file_path)) { @@ -205,7 +205,7 @@ public function definition(TextDocumentIdentifier $textDocument, Position $posit return new Success( new Location( - LanguageServer::pathToUri($code_location->file_path), + $this->server->pathToUri($code_location->file_path), new Range( new Position($code_location->getLineNumber() - 1, $code_location->getColumn() - 1), new Position($code_location->getEndLineNumber() - 1, $code_location->getEndColumn() - 1), @@ -232,7 +232,7 @@ public function hover(TextDocumentIdentifier $textDocument, Position $position): 'textDocument/hover', ); - $file_path = LanguageServer::uriToPath($textDocument->uri); + $file_path = $this->server->uriToPath($textDocument->uri); //This currently doesnt work right with out of project files if (!$this->codebase->config->isInProjectDirs($file_path)) { @@ -288,7 +288,7 @@ public function completion(TextDocumentIdentifier $textDocument, Position $posit 'textDocument/completion', ); - $file_path = LanguageServer::uriToPath($textDocument->uri); + $file_path = $this->server->uriToPath($textDocument->uri); //This currently doesnt work right with out of project files if (!$this->codebase->config->isInProjectDirs($file_path)) { @@ -356,7 +356,7 @@ public function signatureHelp(TextDocumentIdentifier $textDocument, Position $po 'textDocument/signatureHelp', ); - $file_path = LanguageServer::uriToPath($textDocument->uri); + $file_path = $this->server->uriToPath($textDocument->uri); //This currently doesnt work right with out of project files if (!$this->codebase->config->isInProjectDirs($file_path)) { @@ -411,7 +411,7 @@ public function codeAction(TextDocumentIdentifier $textDocument, Range $range, C 'textDocument/codeAction', ); - $file_path = LanguageServer::uriToPath($textDocument->uri); + $file_path = $this->server->uriToPath($textDocument->uri); //Don't report code actions for files we arent watching if (!$this->codebase->config->isInProjectDirs($file_path)) { @@ -427,7 +427,7 @@ public function codeAction(TextDocumentIdentifier $textDocument, Range $range, C /** @var array{type: string, snippet: string, line_from: int, line_to: int} */ $data = (array)$diagnostic->data; - //$file_path = LanguageServer::uriToPath($textDocument->uri); + //$file_path = $this->server->uriToPath($textDocument->uri); //$contents = $this->codebase->file_provider->getContents($file_path); $snippetRange = new Range( diff --git a/src/Psalm/Internal/LanguageServer/Server/Workspace.php b/src/Psalm/Internal/LanguageServer/Server/Workspace.php index af49619c356..113a8f17974 100644 --- a/src/Psalm/Internal/LanguageServer/Server/Workspace.php +++ b/src/Psalm/Internal/LanguageServer/Server/Workspace.php @@ -63,7 +63,7 @@ public function didChangeWatchedFiles(array $changes): void $realFiles = array_filter( array_map(function (FileEvent $change) { try { - return LanguageServer::uriToPath($change->uri); + return $this->server->uriToPath($change->uri); } catch (InvalidArgumentException $e) { return null; } @@ -79,7 +79,7 @@ public function didChangeWatchedFiles(array $changes): void } foreach ($changes as $change) { - $file_path = LanguageServer::uriToPath($change->uri); + $file_path = $this->server->uriToPath($change->uri); if ($composerLockFile === $file_path) { continue; @@ -140,7 +140,7 @@ public function executeCommand(string $command, $arguments): Promise case 'psalm.analyze.uri': /** @var array{uri: string} */ $arguments = (array) $arguments; - $file = LanguageServer::uriToPath($arguments['uri']); + $file = $this->server->uriToPath($arguments['uri']); $this->codebase->reloadFiles( $this->project_analyzer, [$file], From c44b9f5c5e5739f1a59a3b7f4334645f9c1fd66c Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Fri, 21 Jul 2023 18:22:29 +0200 Subject: [PATCH 02/11] Map LSP paths --- .../LanguageServer/LanguageServer.php | 57 ++++++++++++++----- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/src/Psalm/Internal/LanguageServer/LanguageServer.php b/src/Psalm/Internal/LanguageServer/LanguageServer.php index 07990378bd2..c23e0ef4d6b 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageServer.php +++ b/src/Psalm/Internal/LanguageServer/LanguageServer.php @@ -142,6 +142,8 @@ class LanguageServer extends Dispatcher */ protected JsonMapper $mapper; + protected ?string $clientRootPath = null; + public function __construct( ProtocolReader $reader, ProtocolWriter $writer, @@ -394,6 +396,11 @@ public function initialize( $this->clientInfo = $clientInfo; $this->clientCapabilities = $capabilities; $this->trace = $trace; + + if ($rootUri !== null) { + $this->clientRootPath = $this->getPathPart($rootUri); + } + return call( /** @return Generator */ function () { @@ -948,12 +955,22 @@ private function clientStatus(string $status, ?string $additional_info = null): /** * Transforms an absolute file path into a URI as used by the language server protocol. - * - * @psalm-pure */ public function pathToUri(string $filepath): string { - $filepath = trim(str_replace('\\', '/', $filepath), '/'); + $filepath = str_replace('\\', '/', $filepath); + + if ($this->clientRootPath !== null) { + $oldpath = $filepath; + $filepath = str_replace( + rtrim($this->codebase->config->base_dir, '/') . '/', + rtrim($this->clientRootPath, '/') . '/', + $filepath + ); + $this->logDebug('Translated path to URI', ['from' => $oldpath, 'to' => $filepath]); + } + + $filepath = trim($filepath, '/'); $parts = explode('/', $filepath); // Don't %-encode the colon after a Windows drive letter $first = array_shift($parts); @@ -972,16 +989,7 @@ public function pathToUri(string $filepath): string */ public function uriToPath(string $uri): string { - $fragments = parse_url($uri); - if ($fragments === false - || !isset($fragments['scheme']) - || $fragments['scheme'] !== 'file' - || !isset($fragments['path']) - ) { - throw new InvalidArgumentException("Not a valid file URI: $uri"); - } - - $filepath = urldecode($fragments['path']); + $filepath = urldecode($this->getPathPart($uri)); if (strpos($filepath, ':') !== false) { if ($filepath[0] === '/') { @@ -990,6 +998,16 @@ public function uriToPath(string $uri): string $filepath = str_replace('/', '\\', $filepath); } + if ($this->clientRootPath !== null) { + $oldpath = $filepath; + $filepath = str_replace( + rtrim($this->clientRootPath, '/') . '/', + rtrim($this->codebase->config->base_dir, '/') . '/', + $filepath + ); + $this->logDebug('Translated URI to path', ['from' => $oldpath, 'to' => $filepath]); + } + $realpath = realpath($filepath); if ($realpath !== false) { return $realpath; @@ -997,4 +1015,17 @@ public function uriToPath(string $uri): string return $filepath; } + + private function getPathPart(string $uri): string + { + $fragments = parse_url($uri); + if ($fragments === false + || !isset($fragments['scheme']) + || $fragments['scheme'] !== 'file' + || !isset($fragments['path']) + ) { + throw new InvalidArgumentException("Not a valid file URI: $uri"); + } + return $fragments['path']; + } } From f634a0047a631ffe59656457fad04f94db833f21 Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Fri, 21 Jul 2023 18:31:47 +0200 Subject: [PATCH 03/11] CS fix --- src/Psalm/Internal/LanguageServer/LanguageServer.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Psalm/Internal/LanguageServer/LanguageServer.php b/src/Psalm/Internal/LanguageServer/LanguageServer.php index c23e0ef4d6b..5ea1ea8ef6b 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageServer.php +++ b/src/Psalm/Internal/LanguageServer/LanguageServer.php @@ -80,6 +80,7 @@ use function parse_url; use function rawurlencode; use function realpath; +use function rtrim; use function str_replace; use function stream_set_blocking; use function stream_socket_accept; @@ -965,7 +966,7 @@ public function pathToUri(string $filepath): string $filepath = str_replace( rtrim($this->codebase->config->base_dir, '/') . '/', rtrim($this->clientRootPath, '/') . '/', - $filepath + $filepath, ); $this->logDebug('Translated path to URI', ['from' => $oldpath, 'to' => $filepath]); } @@ -1003,7 +1004,7 @@ public function uriToPath(string $uri): string $filepath = str_replace( rtrim($this->clientRootPath, '/') . '/', rtrim($this->codebase->config->base_dir, '/') . '/', - $filepath + $filepath, ); $this->logDebug('Translated URI to path', ['from' => $oldpath, 'to' => $filepath]); } From a68c4804f4b8ef14fcaa426712f8a95a59afb414 Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Sun, 23 Jul 2023 00:04:25 +0200 Subject: [PATCH 04/11] Add path mapper --- .../Internal/LanguageServer/PathMapper.php | 57 ++++++++++++++ tests/LanguageServer/PathMapperTest.php | 75 +++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/Psalm/Internal/LanguageServer/PathMapper.php create mode 100644 tests/LanguageServer/PathMapperTest.php diff --git a/src/Psalm/Internal/LanguageServer/PathMapper.php b/src/Psalm/Internal/LanguageServer/PathMapper.php new file mode 100644 index 00000000000..8d9a09bdb89 --- /dev/null +++ b/src/Psalm/Internal/LanguageServer/PathMapper.php @@ -0,0 +1,57 @@ +serverRoot = $this->sanitizeFolderPath($serverRoot); + $this->clientRoot = $this->sanitizeFolderPath($clientRoot); + } + + public function configureClientRoot(string $clientRoot): void + { + // ignore if preconfigured + if ($this->clientRoot === null) { + $this->clientRoot = $this->sanitizeFolderPath($clientRoot); + } + } + + public function mapClientToServer(string $clientPath): string + { + if ($this->clientRoot === null) { + return $clientPath; + } + + if (substr($clientPath, 0, strlen($this->clientRoot)) === $this->clientRoot) { + return $this->serverRoot . substr($clientPath, strlen($this->clientRoot)); + } + + return $clientPath; + } + + public function mapServerToClient(string $serverPath): string + { + if ($this->clientRoot === null) { + return $serverPath; + } + if (substr($serverPath, 0, strlen($this->serverRoot)) === $this->serverRoot) { + return $this->clientRoot . substr($serverPath, strlen($this->serverRoot)); + } + return $serverPath; + } + + /** @return ($path is null ? null : string) */ + private function sanitizeFolderPath(?string $path): ?string + { + if ($path === null) { + return $path; + } + return rtrim($path, '/'); + } +} diff --git a/tests/LanguageServer/PathMapperTest.php b/tests/LanguageServer/PathMapperTest.php new file mode 100644 index 00000000000..c6a45a1b759 --- /dev/null +++ b/tests/LanguageServer/PathMapperTest.php @@ -0,0 +1,75 @@ +configureClientRoot('/home/user/src/project'); + $this->assertSame( + '/home/user/src/project/filename.php', + $mapper->mapServerToClient('/var/www/filename.php') + ); + } + + public function testIgnoresClientRootIfItWasPreconfigures(): void + { + $mapper = new PathMapper('/var/www', '/home/user/src/project'); + // this will be ignored + $mapper->configureClientRoot('/home/anotheruser/Projects/project'); + + $this->assertSame( + '/home/user/src/project/filename.php', + $mapper->mapServerToClient('/var/www/filename.php') + ); + } + + /** + * @dataProvider mappingProvider + */ + public function testMapsClientToServer( + string $serverRoot, + ?string $clientRootPreconfigured, + string $clientRootProvidedLater, + string $clientPath, + string $serverPath + ): void { + $mapper = new PathMapper($serverRoot, $clientRootPreconfigured); + $mapper->configureClientRoot($clientRootProvidedLater); + $this->assertSame( + $serverPath, + $mapper->mapClientToServer($clientPath) + ); + } + + /** @dataProvider mappingProvider */ + public function testMapsServerToClient( + string $serverRoot, + ?string $clientRootPreconfigured, + string $clientRootProvidedLater, + string $clientPath, + string $serverPath + ): void { + $mapper = new PathMapper($serverRoot, $clientRootPreconfigured); + $mapper->configureClientRoot($clientRootProvidedLater); + $this->assertSame( + $clientPath, + $mapper->mapServerToClient($serverPath) + ); + } + + /** @return iterable */ + public static function mappingProvider(): iterable + { + yield ["/var/a", null, "/user/project", "/user/project/filename.php", "/var/a/filename.php"]; + yield ["/var/a", "/user/project", "/whatever", "/user/project/filename.php", "/var/a/filename.php"]; + yield ["/var/a/", "/user/project", "/whatever", "/user/project/filename.php", "/var/a/filename.php"]; + yield ["/var/a", "/user/project/", "/whatever", "/user/project/filename.php", "/var/a/filename.php"]; + yield ["/var/a/", "/user/project/", "/whatever", "/user/project/filename.php", "/var/a/filename.php"]; + } +} From 389aa7965f12d8709f65f00dffe2c7b9cbd153a3 Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Sun, 23 Jul 2023 00:56:06 +0200 Subject: [PATCH 05/11] Use PathMapper to map paths --- .../LanguageServer/LanguageServer.php | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/Psalm/Internal/LanguageServer/LanguageServer.php b/src/Psalm/Internal/LanguageServer/LanguageServer.php index 5ea1ea8ef6b..3ea4b50465f 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageServer.php +++ b/src/Psalm/Internal/LanguageServer/LanguageServer.php @@ -145,6 +145,8 @@ class LanguageServer extends Dispatcher protected ?string $clientRootPath = null; + protected PathMapper $path_mapper; + public function __construct( ProtocolReader $reader, ProtocolWriter $writer, @@ -243,6 +245,8 @@ function (): void { $this->client = new LanguageClient($reader, $writer, $this, $clientConfiguration); + $this->path_mapper = new PathMapper($codebase->config->base_dir, null); + $this->logInfo("Psalm Language Server ".PSALM_VERSION." has started."); } @@ -398,8 +402,9 @@ public function initialize( $this->clientCapabilities = $capabilities; $this->trace = $trace; + if ($rootUri !== null) { - $this->clientRootPath = $this->getPathPart($rootUri); + $this->path_mapper->configureClientRoot($this->getPathPart($rootUri)); } return call( @@ -961,15 +966,8 @@ public function pathToUri(string $filepath): string { $filepath = str_replace('\\', '/', $filepath); - if ($this->clientRootPath !== null) { - $oldpath = $filepath; - $filepath = str_replace( - rtrim($this->codebase->config->base_dir, '/') . '/', - rtrim($this->clientRootPath, '/') . '/', - $filepath, - ); - $this->logDebug('Translated path to URI', ['from' => $oldpath, 'to' => $filepath]); - } + $filepath = $this->path_mapper->mapServerToClient($oldpath = $filepath); + $this->logDebug('Translated path to URI', ['from' => $oldpath, 'to' => $filepath]); $filepath = trim($filepath, '/'); $parts = explode('/', $filepath); @@ -999,15 +997,8 @@ public function uriToPath(string $uri): string $filepath = str_replace('/', '\\', $filepath); } - if ($this->clientRootPath !== null) { - $oldpath = $filepath; - $filepath = str_replace( - rtrim($this->clientRootPath, '/') . '/', - rtrim($this->codebase->config->base_dir, '/') . '/', - $filepath, - ); - $this->logDebug('Translated URI to path', ['from' => $oldpath, 'to' => $filepath]); - } + $filepath = $this->path_mapper->mapClientToServer($oldpath = $filepath); + $this->logDebug('Translated URI to path', ['from' => $oldpath, 'to' => $filepath]); $realpath = realpath($filepath); if ($realpath !== false) { From 6b9d9805b12f11bf02f0a0c32324c667bed9d1e2 Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Sun, 23 Jul 2023 01:13:37 +0200 Subject: [PATCH 06/11] Account for `-r` option --- src/Psalm/Internal/Cli/LanguageServer.php | 4 +++- src/Psalm/Internal/LanguageServer/LanguageServer.php | 10 +++++++--- tests/LanguageServer/DiagnosticTest.php | 1 + 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Psalm/Internal/Cli/LanguageServer.php b/src/Psalm/Internal/Cli/LanguageServer.php index 429144b6808..cc52be7a7d4 100644 --- a/src/Psalm/Internal/Cli/LanguageServer.php +++ b/src/Psalm/Internal/Cli/LanguageServer.php @@ -254,6 +254,8 @@ static function (string $arg) use ($valid_long_options): void { $current_dir = $root_path . DIRECTORY_SEPARATOR; } + $server_start_dir = $current_dir; + $vendor_dir = CliUtils::getVendorDir($current_dir); $include_collector = new IncludeCollector(); @@ -394,6 +396,6 @@ static function (string $arg) use ($valid_long_options): void { $clientConfiguration->TCPServerAddress = $options['tcp'] ?? null; $clientConfiguration->TCPServerMode = isset($options['tcp-server']); - LanguageServerLanguageServer::run($config, $clientConfiguration, $current_dir, $inMemory); + LanguageServerLanguageServer::run($config, $clientConfiguration, $current_dir, $server_start_dir, $inMemory); } } diff --git a/src/Psalm/Internal/LanguageServer/LanguageServer.php b/src/Psalm/Internal/LanguageServer/LanguageServer.php index 3ea4b50465f..4ffe03af380 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageServer.php +++ b/src/Psalm/Internal/LanguageServer/LanguageServer.php @@ -80,7 +80,6 @@ use function parse_url; use function rawurlencode; use function realpath; -use function rtrim; use function str_replace; use function stream_set_blocking; use function stream_socket_accept; @@ -153,7 +152,8 @@ public function __construct( ProjectAnalyzer $project_analyzer, Codebase $codebase, ClientConfiguration $clientConfiguration, - Progress $progress + Progress $progress, + string $server_start_dir ) { parent::__construct($this, '/'); @@ -245,7 +245,7 @@ function (): void { $this->client = new LanguageClient($reader, $writer, $this, $clientConfiguration); - $this->path_mapper = new PathMapper($codebase->config->base_dir, null); + $this->path_mapper = new PathMapper($server_start_dir, null); $this->logInfo("Psalm Language Server ".PSALM_VERSION." has started."); } @@ -257,6 +257,7 @@ public static function run( Config $config, ClientConfiguration $clientConfiguration, string $base_dir, + string $server_start_dir, bool $inMemory = false ): void { $progress = new Progress(); @@ -329,6 +330,7 @@ public static function run( $codebase, $clientConfiguration, $progress, + $server_start_dir, ); Loop::run(); } elseif ($clientConfiguration->TCPServerMode && $clientConfiguration->TCPServerAddress) { @@ -352,6 +354,7 @@ public static function run( $codebase, $clientConfiguration, $progress, + $server_start_dir, ); Loop::run(); } @@ -365,6 +368,7 @@ public static function run( $codebase, $clientConfiguration, $progress, + $server_start_dir, ); Loop::run(); } diff --git a/tests/LanguageServer/DiagnosticTest.php b/tests/LanguageServer/DiagnosticTest.php index 690c008ea95..ea480138c20 100644 --- a/tests/LanguageServer/DiagnosticTest.php +++ b/tests/LanguageServer/DiagnosticTest.php @@ -85,6 +85,7 @@ public function testSnippetSupportDisabled(): void $this->codebase, $clientConfiguration, new Progress, + getcwd(), ); $write->on('message', function (Message $message) use ($deferred, $server): void { From 8a51aaedd489953026f4cda4e3e4a3d42393e1eb Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Sun, 23 Jul 2023 01:19:39 +0200 Subject: [PATCH 07/11] CS fix --- .../Internal/LanguageServer/PathMapper.php | 40 +++++++++---------- tests/LanguageServer/PathMapperTest.php | 36 ++++++++--------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/Psalm/Internal/LanguageServer/PathMapper.php b/src/Psalm/Internal/LanguageServer/PathMapper.php index 8d9a09bdb89..88ff1661d2c 100644 --- a/src/Psalm/Internal/LanguageServer/PathMapper.php +++ b/src/Psalm/Internal/LanguageServer/PathMapper.php @@ -5,45 +5,45 @@ /** @internal */ final class PathMapper { - private string $serverRoot; - private ?string $clientRoot; + private string $server_root; + private ?string $client_root; - public function __construct(string $serverRoot, ?string $clientRoot = null) + public function __construct(string $server_root, ?string $client_root = null) { - $this->serverRoot = $this->sanitizeFolderPath($serverRoot); - $this->clientRoot = $this->sanitizeFolderPath($clientRoot); + $this->server_root = $this->sanitizeFolderPath($server_root); + $this->client_root = $this->sanitizeFolderPath($client_root); } - public function configureClientRoot(string $clientRoot): void + public function configureClientRoot(string $client_root): void { // ignore if preconfigured - if ($this->clientRoot === null) { - $this->clientRoot = $this->sanitizeFolderPath($clientRoot); + if ($this->client_root === null) { + $this->client_root = $this->sanitizeFolderPath($client_root); } } - public function mapClientToServer(string $clientPath): string + public function mapClientToServer(string $client_path): string { - if ($this->clientRoot === null) { - return $clientPath; + if ($this->client_root === null) { + return $client_path; } - if (substr($clientPath, 0, strlen($this->clientRoot)) === $this->clientRoot) { - return $this->serverRoot . substr($clientPath, strlen($this->clientRoot)); + if (substr($client_path, 0, strlen($this->client_root)) === $this->client_root) { + return $this->server_root . substr($client_path, strlen($this->client_root)); } - return $clientPath; + return $client_path; } - public function mapServerToClient(string $serverPath): string + public function mapServerToClient(string $server_path): string { - if ($this->clientRoot === null) { - return $serverPath; + if ($this->client_root === null) { + return $server_path; } - if (substr($serverPath, 0, strlen($this->serverRoot)) === $this->serverRoot) { - return $this->clientRoot . substr($serverPath, strlen($this->serverRoot)); + if (substr($server_path, 0, strlen($this->server_root)) === $this->server_root) { + return $this->client_root . substr($server_path, strlen($this->server_root)); } - return $serverPath; + return $server_path; } /** @return ($path is null ? null : string) */ diff --git a/tests/LanguageServer/PathMapperTest.php b/tests/LanguageServer/PathMapperTest.php index c6a45a1b759..ac6e1fd5c32 100644 --- a/tests/LanguageServer/PathMapperTest.php +++ b/tests/LanguageServer/PathMapperTest.php @@ -33,33 +33,33 @@ public function testIgnoresClientRootIfItWasPreconfigures(): void * @dataProvider mappingProvider */ public function testMapsClientToServer( - string $serverRoot, - ?string $clientRootPreconfigured, - string $clientRootProvidedLater, - string $clientPath, - string $serverPath + string $server_root, + ?string $client_root_reconfigured, + string $client_root_provided_later, + string $client_path, + string $server_ath ): void { - $mapper = new PathMapper($serverRoot, $clientRootPreconfigured); - $mapper->configureClientRoot($clientRootProvidedLater); + $mapper = new PathMapper($server_root, $client_root_reconfigured); + $mapper->configureClientRoot($client_root_provided_later); $this->assertSame( - $serverPath, - $mapper->mapClientToServer($clientPath) + $server_ath, + $mapper->mapClientToServer($client_path) ); } /** @dataProvider mappingProvider */ public function testMapsServerToClient( - string $serverRoot, - ?string $clientRootPreconfigured, - string $clientRootProvidedLater, - string $clientPath, - string $serverPath + string $server_root, + ?string $client_root_preconfigured, + string $client_root_provided_later, + string $client_path, + string $server_path ): void { - $mapper = new PathMapper($serverRoot, $clientRootPreconfigured); - $mapper->configureClientRoot($clientRootProvidedLater); + $mapper = new PathMapper($server_root, $client_root_preconfigured); + $mapper->configureClientRoot($client_root_provided_later); $this->assertSame( - $clientPath, - $mapper->mapServerToClient($serverPath) + $client_path, + $mapper->mapServerToClient($server_path) ); } From 6eb7a688d1e682753cf5ea5ba778ebd817612564 Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Sun, 23 Jul 2023 02:10:49 +0200 Subject: [PATCH 08/11] Introduce `--map-folder` switch And create PathMapper based on its value --- src/Psalm/Internal/Cli/LanguageServer.php | 67 ++++++++++++++++++- .../LanguageServer/LanguageServer.php | 13 ++-- tests/LanguageServer/DiagnosticTest.php | 3 +- 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/Psalm/Internal/Cli/LanguageServer.php b/src/Psalm/Internal/Cli/LanguageServer.php index cc52be7a7d4..026d67efda5 100644 --- a/src/Psalm/Internal/Cli/LanguageServer.php +++ b/src/Psalm/Internal/Cli/LanguageServer.php @@ -10,6 +10,7 @@ use Psalm\Internal\IncludeCollector; use Psalm\Internal\LanguageServer\ClientConfiguration; use Psalm\Internal\LanguageServer\LanguageServer as LanguageServerLanguageServer; +use Psalm\Internal\LanguageServer\PathMapper; use Psalm\Report; use function array_key_exists; @@ -75,6 +76,7 @@ public static function run(array $argv): void 'find-dead-code', 'help', 'root:', + 'map-folder::', 'use-ini-defaults', 'version', 'tcp:', @@ -127,6 +129,14 @@ static function (string $arg) use ($valid_long_options): void { // get options from command line $options = getopt(implode('', $valid_short_options), $valid_long_options); + if ($options === false) { + // shouldn't really happen, but just in case + fwrite( + STDERR, + 'Failed to get CLI args' . PHP_EOL, + ); + exit(1); + } if (!array_key_exists('use-ini-defaults', $options)) { ini_set('display_errors', '1'); @@ -169,6 +179,14 @@ static function (string $arg) use ($valid_long_options): void { -r, --root If running Psalm globally you'll need to specify a project root. Defaults to cwd + --map-folder[=SERVER_FOLDER:CLIENT_FOLDER] + Specify folder to map between the client and the server. Use this when the client + and server have different views of the filesystem (e.g. in a docker container). + Defaults to mapping the rootUri provided by the client to the server's cwd, + or `-r` if provided. + + No mapping is done when this option is not specified. + --find-dead-code Look for dead code @@ -254,8 +272,6 @@ static function (string $arg) use ($valid_long_options): void { $current_dir = $root_path . DIRECTORY_SEPARATOR; } - $server_start_dir = $current_dir; - $vendor_dir = CliUtils::getVendorDir($current_dir); $include_collector = new IncludeCollector(); @@ -293,6 +309,8 @@ static function (string $arg) use ($valid_long_options): void { setlocale(LC_CTYPE, 'C'); + $path_mapper = self::createPathMapper($options, $current_dir); + $path_to_config = CliUtils::getPathToConfig($options); if (isset($options['tcp'])) { @@ -396,6 +414,49 @@ static function (string $arg) use ($valid_long_options): void { $clientConfiguration->TCPServerAddress = $options['tcp'] ?? null; $clientConfiguration->TCPServerMode = isset($options['tcp-server']); - LanguageServerLanguageServer::run($config, $clientConfiguration, $current_dir, $server_start_dir, $inMemory); + LanguageServerLanguageServer::run($config, $clientConfiguration, $current_dir, $path_mapper, $inMemory); + } + + /** @param array> $options */ + private static function createPathMapper(array $options, string $server_start_dir): PathMapper + { + if (!isset($options['map-folder'])) { + // dummy no-op mapper + return new PathMapper('/', '/'); + } + + $map_folder = $options['map-folder']; + + if ($map_folder === false) { + // autoconfigured mapper + return new PathMapper($server_start_dir, null); + } + + if (is_string($map_folder)) { + if (strpos($map_folder, ':') === false) { + fwrite( + STDERR, + 'invalid format for --map-folder option' . PHP_EOL, + ); + exit(1); + } + /** @psalm-suppress PossiblyUndefinedArrayOffset we just checked that we have the separator*/ + [$server_dir, $client_dir] = explode(':', $map_folder, 2); + if (!strlen($server_dir) || !strlen($client_dir)) { + fwrite( + STDERR, + 'invalid format for --map-folder option, ' + . 'neither SERVER_FOLDER nor CLIENT_FOLDER can be empty' . PHP_EOL, + ); + exit(1); + } + return new PathMapper($server_dir, $client_dir); + } + + fwrite( + STDERR, + '--map-folder option can only be specified once' . PHP_EOL, + ); + exit(1); } } diff --git a/src/Psalm/Internal/LanguageServer/LanguageServer.php b/src/Psalm/Internal/LanguageServer/LanguageServer.php index 4ffe03af380..13dc3c993d7 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageServer.php +++ b/src/Psalm/Internal/LanguageServer/LanguageServer.php @@ -153,7 +153,7 @@ public function __construct( Codebase $codebase, ClientConfiguration $clientConfiguration, Progress $progress, - string $server_start_dir + PathMapper $path_mapper ) { parent::__construct($this, '/'); @@ -163,6 +163,8 @@ public function __construct( $this->codebase = $codebase; + $this->path_mapper = $path_mapper; + $this->protocolWriter = $writer; $this->protocolReader = $reader; @@ -245,7 +247,6 @@ function (): void { $this->client = new LanguageClient($reader, $writer, $this, $clientConfiguration); - $this->path_mapper = new PathMapper($server_start_dir, null); $this->logInfo("Psalm Language Server ".PSALM_VERSION." has started."); } @@ -257,7 +258,7 @@ public static function run( Config $config, ClientConfiguration $clientConfiguration, string $base_dir, - string $server_start_dir, + PathMapper $path_mapper, bool $inMemory = false ): void { $progress = new Progress(); @@ -330,7 +331,7 @@ public static function run( $codebase, $clientConfiguration, $progress, - $server_start_dir, + $path_mapper, ); Loop::run(); } elseif ($clientConfiguration->TCPServerMode && $clientConfiguration->TCPServerAddress) { @@ -354,7 +355,7 @@ public static function run( $codebase, $clientConfiguration, $progress, - $server_start_dir, + $path_mapper, ); Loop::run(); } @@ -368,7 +369,7 @@ public static function run( $codebase, $clientConfiguration, $progress, - $server_start_dir, + $path_mapper, ); Loop::run(); } diff --git a/tests/LanguageServer/DiagnosticTest.php b/tests/LanguageServer/DiagnosticTest.php index ea480138c20..470bc68199e 100644 --- a/tests/LanguageServer/DiagnosticTest.php +++ b/tests/LanguageServer/DiagnosticTest.php @@ -9,6 +9,7 @@ use Psalm\Internal\LanguageServer\ClientConfiguration; use Psalm\Internal\LanguageServer\LanguageServer; use Psalm\Internal\LanguageServer\Message; +use Psalm\Internal\LanguageServer\PathMapper; use Psalm\Internal\LanguageServer\Progress; use Psalm\Internal\Provider\FakeFileProvider; use Psalm\Internal\Provider\Providers; @@ -85,7 +86,7 @@ public function testSnippetSupportDisabled(): void $this->codebase, $clientConfiguration, new Progress, - getcwd(), + new PathMapper(getcwd(), getcwd()), ); $write->on('message', function (Message $message) use ($deferred, $server): void { From 0a2a0feaf233df92253e8b3725825a5b3309091e Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Sun, 23 Jul 2023 02:16:44 +0200 Subject: [PATCH 09/11] CS fix --- src/Psalm/Internal/Cli/LanguageServer.php | 2 ++ src/Psalm/Internal/LanguageServer/PathMapper.php | 4 ++++ tests/LanguageServer/DiagnosticTest.php | 1 + tests/LanguageServer/PathMapperTest.php | 8 ++++---- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Psalm/Internal/Cli/LanguageServer.php b/src/Psalm/Internal/Cli/LanguageServer.php index 026d67efda5..07d5e6f93e8 100644 --- a/src/Psalm/Internal/Cli/LanguageServer.php +++ b/src/Psalm/Internal/Cli/LanguageServer.php @@ -19,6 +19,7 @@ use function array_slice; use function chdir; use function error_log; +use function explode; use function fwrite; use function gc_disable; use function getcwd; @@ -32,6 +33,7 @@ use function preg_replace; use function realpath; use function setlocale; +use function strlen; use function strpos; use function strtolower; use function substr; diff --git a/src/Psalm/Internal/LanguageServer/PathMapper.php b/src/Psalm/Internal/LanguageServer/PathMapper.php index 88ff1661d2c..bd4b815bc94 100644 --- a/src/Psalm/Internal/LanguageServer/PathMapper.php +++ b/src/Psalm/Internal/LanguageServer/PathMapper.php @@ -2,6 +2,10 @@ namespace Psalm\Internal\LanguageServer; +use function rtrim; +use function strlen; +use function substr; + /** @internal */ final class PathMapper { diff --git a/tests/LanguageServer/DiagnosticTest.php b/tests/LanguageServer/DiagnosticTest.php index 470bc68199e..b40ef38ace3 100644 --- a/tests/LanguageServer/DiagnosticTest.php +++ b/tests/LanguageServer/DiagnosticTest.php @@ -23,6 +23,7 @@ use Psalm\Tests\TestConfig; use function Amp\Promise\wait; +use function getcwd; use function rand; class DiagnosticTest extends AsyncTestCase diff --git a/tests/LanguageServer/PathMapperTest.php b/tests/LanguageServer/PathMapperTest.php index ac6e1fd5c32..2e64b356399 100644 --- a/tests/LanguageServer/PathMapperTest.php +++ b/tests/LanguageServer/PathMapperTest.php @@ -13,7 +13,7 @@ public function testUsesUpdatedClientRoot(): void $mapper->configureClientRoot('/home/user/src/project'); $this->assertSame( '/home/user/src/project/filename.php', - $mapper->mapServerToClient('/var/www/filename.php') + $mapper->mapServerToClient('/var/www/filename.php'), ); } @@ -25,7 +25,7 @@ public function testIgnoresClientRootIfItWasPreconfigures(): void $this->assertSame( '/home/user/src/project/filename.php', - $mapper->mapServerToClient('/var/www/filename.php') + $mapper->mapServerToClient('/var/www/filename.php'), ); } @@ -43,7 +43,7 @@ public function testMapsClientToServer( $mapper->configureClientRoot($client_root_provided_later); $this->assertSame( $server_ath, - $mapper->mapClientToServer($client_path) + $mapper->mapClientToServer($client_path), ); } @@ -59,7 +59,7 @@ public function testMapsServerToClient( $mapper->configureClientRoot($client_root_provided_later); $this->assertSame( $client_path, - $mapper->mapServerToClient($server_path) + $mapper->mapServerToClient($server_path), ); } From bb102760ea2bb901af3927d1a211171a0715c7a7 Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Sun, 23 Jul 2023 02:18:56 +0200 Subject: [PATCH 10/11] Drop unused property --- src/Psalm/Internal/LanguageServer/LanguageServer.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Psalm/Internal/LanguageServer/LanguageServer.php b/src/Psalm/Internal/LanguageServer/LanguageServer.php index 13dc3c993d7..628fb9cc253 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageServer.php +++ b/src/Psalm/Internal/LanguageServer/LanguageServer.php @@ -142,8 +142,6 @@ class LanguageServer extends Dispatcher */ protected JsonMapper $mapper; - protected ?string $clientRootPath = null; - protected PathMapper $path_mapper; public function __construct( From 5c0154c422a5207115cef95c912d289eab721057 Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Sun, 23 Jul 2023 02:38:07 +0200 Subject: [PATCH 11/11] Added docs on running LS in a container --- docs/running_psalm/language_server.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/running_psalm/language_server.md b/docs/running_psalm/language_server.md index 47504068898..3d80a91c9d6 100644 --- a/docs/running_psalm/language_server.md +++ b/docs/running_psalm/language_server.md @@ -6,7 +6,9 @@ It currently supports diagnostics (i.e. finding errors and warnings), go-to-defi It works well in a variety of editors (listed alphabetically): -## Emacs +## Client configuration + +### Emacs I got it working with [eglot](https://github.com/joaotavora/eglot) @@ -27,13 +29,13 @@ This is the config I used: ) ``` -## PhpStorm +### PhpStorm -### Native Support +#### Native Support As of PhpStorm 2020.3 support for psalm is supported and on by default, you can read more about that [here](https://www.jetbrains.com/help/phpstorm/using-psalm.html) -### With LSP +#### With LSP Alternatively, psalm works with `gtache/intellij-lsp` plugin ([Jetbrains-approved version](https://plugins.jetbrains.com/plugin/10209-lsp-support), [latest version](https://github.com/gtache/intellij-lsp/releases/tag/v1.6.0)). @@ -51,7 +53,7 @@ In the "Server definitions" tab you should add a definition for Psalm: In the "Timeouts" tab you can adjust the initialization timeout. This is important if you have a large project. You should set the "Init" value to the number of milliseconds you allow Psalm to scan your entire project and your project's dependencies. For opening a couple of projects that use large PHP frameworks, on a high-end business laptop, try `240000` milliseconds for Init. -## Sublime Text +### Sublime Text I use the excellent Sublime [LSP plugin](https://github.com/tomv564/LSP) with the following config(Package Settings > LSP > Settings): ```json @@ -64,7 +66,7 @@ I use the excellent Sublime [LSP plugin](https://github.com/tomv564/LSP) with th } ``` -## Vim & Neovim +### Vim & Neovim **ALE** @@ -105,6 +107,15 @@ Add settings to `coc-settings.json`: } ``` -## VS Code +### VS Code [Get the Psalm plugin here](https://marketplace.visualstudio.com/items?itemName=getpsalm.psalm-vscode-plugin) (Requires VS Code 1.26+): + +## Running the server in a docker container + +Make sure you use `--map-folder` option. Using it without argument will map the server's CWD to the host's project root folder. You can also specify a custom mapping. For example: +```bash +docker-compose exec php /usr/share/php/psalm/psalm-language-server \ + -r=/var/www/html \ + --map-folder=/var/www/html:$PWD +```