1
0
mirror of https://github.com/danog/psalm.git synced 2024-12-03 10:07:52 +01:00
This commit is contained in:
cgocast 2023-07-25 10:33:52 +02:00
commit 9690a44c16
8 changed files with 297 additions and 41 deletions

View File

@ -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
```

View File

@ -10,6 +10,7 @@ use Psalm\Internal\Fork\PsalmRestarter;
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_search;
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 is_string;
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 @@ final class LanguageServer
'find-dead-code',
'help',
'root:',
'map-folder::',
'use-ini-defaults',
'version',
'tcp:',
@ -127,6 +131,14 @@ final class LanguageServer
// 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 @@ final class LanguageServer
-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 @@ final class LanguageServer
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 @@ final class LanguageServer
$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);
}
}

View File

@ -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 @@ class LanguageServer extends Dispatcher
$this->codebase = $codebase;
$this->path_mapper = $path_mapper;
$this->protocolWriter = $writer;
$this->protocolReader = $reader;
@ -240,6 +245,7 @@ class LanguageServer extends Dispatcher
$this->client = new LanguageClient($reader, $writer, $this, $clientConfiguration);
$this->logInfo("Psalm Language Server ".PSALM_VERSION." has started.");
}
@ -250,6 +256,7 @@ class LanguageServer extends Dispatcher
Config $config,
ClientConfiguration $clientConfiguration,
string $base_dir,
PathMapper $path_mapper,
bool $inMemory = false
): void {
$progress = new Progress();
@ -322,6 +329,7 @@ class LanguageServer extends Dispatcher
$codebase,
$clientConfiguration,
$progress,
$path_mapper,
);
Loop::run();
} elseif ($clientConfiguration->TCPServerMode && $clientConfiguration->TCPServerAddress) {
@ -345,6 +353,7 @@ class LanguageServer extends Dispatcher
$codebase,
$clientConfiguration,
$progress,
$path_mapper,
);
Loop::run();
}
@ -358,6 +367,7 @@ class LanguageServer extends Dispatcher
$codebase,
$clientConfiguration,
$progress,
$path_mapper,
);
Loop::run();
}
@ -394,6 +404,12 @@ class LanguageServer extends Dispatcher
$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 () {
@ -948,12 +964,15 @@ class LanguageServer extends Dispatcher
/**
* 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,7 +989,29 @@ class LanguageServer extends Dispatcher
/**
* Transforms URI into file path
*/
public static function uriToPath(string $uri): string
public function uriToPath(string $uri): string
{
$filepath = urldecode($this->getPathPart($uri));
if (strpos($filepath, ':') !== false) {
if ($filepath[0] === '/') {
$filepath = substr($filepath, 1);
}
$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
@ -980,21 +1021,21 @@ class LanguageServer extends Dispatcher
) {
throw new InvalidArgumentException("Not a valid file URI: $uri");
}
return $fragments['path'];
}
$filepath = urldecode($fragments['path']);
// the methods below forward special paths
// like `$/cancelRequest` to `$this->cancelRequest()`
// and `$/a/b/c` to `$this->a->b->c()`
if (strpos($filepath, ':') !== false) {
if ($filepath[0] === '/') {
$filepath = substr($filepath, 1);
}
$filepath = str_replace('/', '\\', $filepath);
}
public function __isset(string $prop_name): bool
{
return $prop_name === '$';
}
$realpath = realpath($filepath);
if ($realpath !== false) {
return $realpath;
}
return $filepath;
/** @return static */
public function __get(string $_prop_name): self
{
return $this;
}
}

View File

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

View File

@ -74,7 +74,7 @@ class TextDocument
['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 @@ class TextDocument
['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 @@ class TextDocument
['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 @@ class TextDocument
['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 @@ class TextDocument
'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 @@ class TextDocument
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 @@ class TextDocument
'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 @@ class TextDocument
'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 @@ class TextDocument
'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 @@ class TextDocument
'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 @@ class TextDocument
/** @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(

View File

@ -63,7 +63,7 @@ class Workspace
$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 @@ class Workspace
}
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 @@ class Workspace
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],

View File

@ -9,6 +9,7 @@ use Psalm\Internal\Analyzer\ProjectAnalyzer;
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\LanguageServer\MockProtocolStream;
use Psalm\Tests\TestConfig;
use function Amp\Promise\wait;
use function getcwd;
use function rand;
class DiagnosticTest extends AsyncTestCase
@ -85,6 +87,7 @@ class DiagnosticTest extends AsyncTestCase
$this->codebase,
$clientConfiguration,
new Progress,
new PathMapper(getcwd(), getcwd()),
);
$write->on('message', function (Message $message) use ($deferred, $server): void {

View File

@ -0,0 +1,75 @@
<?php
namespace Psalm\Tests\LanguageServer;
use PHPUnit\Framework\TestCase;
use Psalm\Internal\LanguageServer\PathMapper;
final class PathMapperTest extends TestCase
{
public function testUsesUpdatedClientRoot(): void
{
$mapper = new PathMapper('/var/www');
$mapper->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<int, array{string, string|null, string, string, string}> */
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"];
}
}