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

Automatic LSP container path mapping #10033

Merged
merged 11 commits into from
Jul 24, 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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,11 +1000,27 @@ 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;
}

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'];
}
}
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, '/');
}
}