Skip to content

Commit

Permalink
Merge pull request #10033 from weirdan/lsp-container-path-mapping
Browse files Browse the repository at this point in the history
  • Loading branch information
weirdan committed Jul 24, 2023
2 parents c50ae7c + 5c0154c commit be82c3a
Show file tree
Hide file tree
Showing 8 changed files with 279 additions and 38 deletions.
25 changes: 18 additions & 7 deletions docs/running_psalm/language_server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)).

Expand All @@ -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
Expand All @@ -64,7 +66,7 @@ I use the excellent Sublime [LSP plugin](https://github.com/tomv564/LSP) with th
}
```

## Vim & Neovim
### Vim & Neovim

**ALE**

Expand Down Expand Up @@ -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
```
67 changes: 66 additions & 1 deletion src/Psalm/Internal/Cli/LanguageServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -75,6 +78,7 @@ public static function run(array $argv): void
'find-dead-code',
'help',
'root:',
'map-folder::',
'use-ini-defaults',
'version',
'tcp:',
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'])) {
Expand Down Expand Up @@ -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<string,string|false|list<string|false>> $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);
}
}
58 changes: 42 additions & 16 deletions src/Psalm/Internal/LanguageServer/LanguageServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, '/');

Expand All @@ -158,6 +161,8 @@ public function __construct(

$this->codebase = $codebase;

$this->path_mapper = $path_mapper;

$this->protocolWriter = $writer;

$this->protocolReader = $reader;
Expand Down Expand Up @@ -240,6 +245,7 @@ function (): void {

$this->client = new LanguageClient($reader, $writer, $this, $clientConfiguration);


$this->logInfo("Psalm Language Server ".PSALM_VERSION." has started.");
}

Expand All @@ -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();
Expand Down Expand Up @@ -322,6 +329,7 @@ public static function run(
$codebase,
$clientConfiguration,
$progress,
$path_mapper,
);
Loop::run();
} elseif ($clientConfiguration->TCPServerMode && $clientConfiguration->TCPServerAddress) {
Expand All @@ -345,6 +353,7 @@ public static function run(
$codebase,
$clientConfiguration,
$progress,
$path_mapper,
);
Loop::run();
}
Expand All @@ -358,6 +367,7 @@ public static function run(
$codebase,
$clientConfiguration,
$progress,
$path_mapper,
);
Loop::run();
}
Expand Down Expand Up @@ -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<int, true, mixed, InitializeResult> */
function () {
Expand Down Expand Up @@ -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);
Expand All @@ -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] === '/') {
Expand All @@ -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;
Expand All @@ -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()`
Expand Down
61 changes: 61 additions & 0 deletions src/Psalm/Internal/LanguageServer/PathMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace Psalm\Internal\LanguageServer;

use function rtrim;
use function strlen;
use function substr;

/** @internal */
final class PathMapper
{
private string $server_root;
private ?string $client_root;

public function __construct(string $server_root, ?string $client_root = null)
{
$this->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, '/');
}
}

0 comments on commit be82c3a

Please sign in to comment.