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 +``` diff --git a/src/Psalm/Internal/Cli/LanguageServer.php b/src/Psalm/Internal/Cli/LanguageServer.php index 429144b6808..07d5e6f93e8 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; @@ -18,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; @@ -31,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; @@ -75,6 +78,7 @@ public static function run(array $argv): void 'find-dead-code', 'help', 'root:', + 'map-folder::', 'use-ini-defaults', 'version', 'tcp:', @@ -127,6 +131,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 +181,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 @@ -291,6 +311,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'])) { @@ -394,6 +416,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, $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 4edcc7449aa..3fa4261c8d3 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageServer.php +++ b/src/Psalm/Internal/LanguageServer/LanguageServer.php @@ -142,13 +142,16 @@ class LanguageServer extends Dispatcher */ protected JsonMapper $mapper; + protected PathMapper $path_mapper; + public function __construct( ProtocolReader $reader, ProtocolWriter $writer, ProjectAnalyzer $project_analyzer, Codebase $codebase, ClientConfiguration $clientConfiguration, - Progress $progress + Progress $progress, + PathMapper $path_mapper ) { parent::__construct($this, '/'); @@ -158,6 +161,8 @@ public function __construct( $this->codebase = $codebase; + $this->path_mapper = $path_mapper; + $this->protocolWriter = $writer; $this->protocolReader = $reader; @@ -240,6 +245,7 @@ function (): void { $this->client = new LanguageClient($reader, $writer, $this, $clientConfiguration); + $this->logInfo("Psalm Language Server ".PSALM_VERSION." has started."); } @@ -250,6 +256,7 @@ public static function run( Config $config, ClientConfiguration $clientConfiguration, string $base_dir, + PathMapper $path_mapper, bool $inMemory = false ): void { $progress = new Progress(); @@ -322,6 +329,7 @@ public static function run( $codebase, $clientConfiguration, $progress, + $path_mapper, ); Loop::run(); } elseif ($clientConfiguration->TCPServerMode && $clientConfiguration->TCPServerAddress) { @@ -345,6 +353,7 @@ public static function run( $codebase, $clientConfiguration, $progress, + $path_mapper, ); Loop::run(); } @@ -358,6 +367,7 @@ public static function run( $codebase, $clientConfiguration, $progress, + $path_mapper, ); Loop::run(); } @@ -394,6 +404,12 @@ public function initialize( $this->clientInfo = $clientInfo; $this->clientCapabilities = $capabilities; $this->trace = $trace; + + + if ($rootUri !== null) { + $this->path_mapper->configureClientRoot($this->getPathPart($rootUri)); + } + return call( /** @return Generator */ function () { @@ -948,12 +964,15 @@ 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 static function pathToUri(string $filepath): string + public function pathToUri(string $filepath): string { - $filepath = trim(str_replace('\\', '/', $filepath), '/'); + $filepath = str_replace('\\', '/', $filepath); + + $filepath = $this->path_mapper->mapServerToClient($oldpath = $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); @@ -970,18 +989,9 @@ 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 - || !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 +1000,9 @@ public static function uriToPath(string $uri): string $filepath = str_replace('/', '\\', $filepath); } + $filepath = $this->path_mapper->mapClientToServer($oldpath = $filepath); + $this->logDebug('Translated URI to path', ['from' => $oldpath, 'to' => $filepath]); + $realpath = realpath($filepath); if ($realpath !== false) { return $realpath; @@ -998,6 +1011,19 @@ public static 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']; + } + // the methods below forward special paths // like `$/cancelRequest` to `$this->cancelRequest()` // and `$/a/b/c` to `$this->a->b->c()` diff --git a/src/Psalm/Internal/LanguageServer/PathMapper.php b/src/Psalm/Internal/LanguageServer/PathMapper.php new file mode 100644 index 00000000000..bd4b815bc94 --- /dev/null +++ b/src/Psalm/Internal/LanguageServer/PathMapper.php @@ -0,0 +1,61 @@ +server_root = $this->sanitizeFolderPath($server_root); + $this->client_root = $this->sanitizeFolderPath($client_root); + } + + public function configureClientRoot(string $client_root): void + { + // ignore if preconfigured + if ($this->client_root === null) { + $this->client_root = $this->sanitizeFolderPath($client_root); + } + } + + public function mapClientToServer(string $client_path): string + { + if ($this->client_root === null) { + return $client_path; + } + + if (substr($client_path, 0, strlen($this->client_root)) === $this->client_root) { + return $this->server_root . substr($client_path, strlen($this->client_root)); + } + + return $client_path; + } + + public function mapServerToClient(string $server_path): string + { + if ($this->client_root === null) { + return $server_path; + } + if (substr($server_path, 0, strlen($this->server_root)) === $this->server_root) { + return $this->client_root . substr($server_path, strlen($this->server_root)); + } + return $server_path; + } + + /** @return ($path is null ? null : string) */ + private function sanitizeFolderPath(?string $path): ?string + { + if ($path === null) { + return $path; + } + return rtrim($path, '/'); + } +} 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], diff --git a/tests/LanguageServer/DiagnosticTest.php b/tests/LanguageServer/DiagnosticTest.php index 690c008ea95..b40ef38ace3 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; @@ -22,6 +23,7 @@ use Psalm\Tests\TestConfig; use function Amp\Promise\wait; +use function getcwd; use function rand; class DiagnosticTest extends AsyncTestCase @@ -85,6 +87,7 @@ public function testSnippetSupportDisabled(): void $this->codebase, $clientConfiguration, new Progress, + new PathMapper(getcwd(), getcwd()), ); $write->on('message', function (Message $message) use ($deferred, $server): void { diff --git a/tests/LanguageServer/PathMapperTest.php b/tests/LanguageServer/PathMapperTest.php new file mode 100644 index 00000000000..2e64b356399 --- /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 $server_root, + ?string $client_root_reconfigured, + string $client_root_provided_later, + string $client_path, + string $server_ath + ): void { + $mapper = new PathMapper($server_root, $client_root_reconfigured); + $mapper->configureClientRoot($client_root_provided_later); + $this->assertSame( + $server_ath, + $mapper->mapClientToServer($client_path), + ); + } + + /** @dataProvider mappingProvider */ + public function testMapsServerToClient( + string $server_root, + ?string $client_root_preconfigured, + string $client_root_provided_later, + string $client_path, + string $server_path + ): void { + $mapper = new PathMapper($server_root, $client_root_preconfigured); + $mapper->configureClientRoot($client_root_provided_later); + $this->assertSame( + $client_path, + $mapper->mapServerToClient($server_path), + ); + } + + /** @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"]; + } +}