1
0
mirror of https://github.com/danog/psalm.git synced 2024-11-30 04:39:00 +01:00

Merge branch 'amp_v3' into strict_types

This commit is contained in:
Daniil Gentili 2023-07-26 10:57:28 +02:00
commit dea38564a9
Signed by: danog
GPG Key ID: 8C1BE3B34B230CA7
11 changed files with 300 additions and 45 deletions

View File

@ -1,6 +1,8 @@
# Upgrading from Psalm 5 to Psalm 6
## Changed
- The minimum PHP version was raised to PHP 8.1.17.
- [BC] Switched the internal representation of `list<T>` and `non-empty-list<T>` from the TList and TNonEmptyList classes to an unsealed list shape: the TList, TNonEmptyList and TCallableList classes were removed.
Nothing will change for users: the `list<T>` and `non-empty-list<T>` syntax will remain supported and its semantics unchanged.
Psalm 5 already deprecates the `TList`, `TNonEmptyList` and `TCallableList` classes: use `\Psalm\Type::getListAtomic`, `\Psalm\Type::getNonEmptyListAtomic` and `\Psalm\Type::getCallableListAtomic` to instantiate list atomics, or directly instantiate TKeyedArray objects with `is_list=true` where appropriate.
@ -9,6 +11,8 @@
- [BC] The `TDependentListKey` type was removed and replaced with an optional property of the `TIntRange` type.
- [BC] The return type of `Psalm\Internal\LanguageServer\ProtocolWriter#write() changed from `Amp\Promise` to `void` due to the switch to Amp v3
# Upgrading from Psalm 4 to Psalm 5
## Changed

View File

@ -15,7 +15,7 @@
}
],
"require": {
"php": "~8.1.0 || ~8.2.0",
"php": "~8.1.17 || ~8.2.4",
"ext-SimpleXML": "*",
"ext-ctype": "*",
"ext-dom": "*",

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

@ -181,9 +181,6 @@ class ConcatAnalyzer
}
if ($literal_concat) {
// Bypass opcache bug: https://github.com/php/php-src/issues/10635
(function (int $_): void {
})($combinations);
if (count($result_type_parts) === 0) {
throw new AssertionError("The number of parts cannot be 0!");
}

View File

@ -12,6 +12,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;
@ -20,6 +21,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;
@ -33,6 +35,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;
@ -77,6 +80,7 @@ final class LanguageServer
'find-dead-code',
'help',
'root:',
'map-folder::',
'use-ini-defaults',
'version',
'tcp:',
@ -129,6 +133,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');
@ -171,6 +183,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
@ -293,6 +313,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'])) {
@ -396,6 +418,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

@ -137,6 +137,8 @@ class LanguageServer extends Dispatcher
*/
protected JsonMapper $mapper;
protected PathMapper $path_mapper;
public function __construct(
ProtocolReader $reader,
ProtocolWriter $writer,
@ -144,6 +146,7 @@ class LanguageServer extends Dispatcher
Codebase $codebase,
ClientConfiguration $clientConfiguration,
Progress $progress,
PathMapper $path_mapper
) {
parent::__construct($this, '/');
@ -153,6 +156,8 @@ class LanguageServer extends Dispatcher
$this->codebase = $codebase;
$this->path_mapper = $path_mapper;
$this->protocolWriter = $writer;
$this->protocolReader = $reader;
@ -222,6 +227,7 @@ class LanguageServer extends Dispatcher
$this->client = new LanguageClient($reader, $writer, $this, $clientConfiguration);
$this->logInfo("Psalm Language Server ".PSALM_VERSION." has started.");
}
@ -232,7 +238,8 @@ class LanguageServer extends Dispatcher
Config $config,
ClientConfiguration $clientConfiguration,
string $base_dir,
bool $inMemory = false,
PathMapper $path_mapper,
bool $inMemory = false
): void {
$progress = new Progress();
@ -304,6 +311,7 @@ class LanguageServer extends Dispatcher
$codebase,
$clientConfiguration,
$progress,
$path_mapper,
);
EventLoop::run();
} elseif ($clientConfiguration->TCPServerMode && $clientConfiguration->TCPServerAddress) {
@ -327,6 +335,7 @@ class LanguageServer extends Dispatcher
$codebase,
$clientConfiguration,
$progress,
$path_mapper,
);
EventLoop::run();
}
@ -340,6 +349,7 @@ class LanguageServer extends Dispatcher
$codebase,
$clientConfiguration,
$progress,
$path_mapper,
);
EventLoop::run();
}
@ -375,6 +385,10 @@ class LanguageServer extends Dispatcher
$this->clientCapabilities = $capabilities;
$this->trace = $trace;
if ($rootUri !== null) {
$this->path_mapper->configureClientRoot($this->getPathPart($rootUri));
}
$this->logInfo("Initializing...");
$this->clientStatus('initializing');
@ -917,12 +931,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);
@ -939,7 +956,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
@ -949,21 +988,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

@ -72,7 +72,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);
@ -95,7 +95,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);
@ -117,7 +117,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;
@ -152,7 +152,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, []);
@ -175,7 +175,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)) {
@ -201,7 +201,7 @@ class TextDocument
}
return 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),
@ -226,7 +226,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)) {
@ -281,7 +281,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)) {
@ -349,7 +349,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)) {
@ -402,7 +402,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)) {
@ -418,7 +418,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

@ -61,7 +61,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;
}
@ -77,7 +77,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;
@ -136,7 +136,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;
@ -21,6 +22,7 @@ use Psalm\Tests\LanguageServer\Message as MessageBody;
use Psalm\Tests\LanguageServer\MockProtocolStream;
use Psalm\Tests\TestConfig;
use function getcwd;
use function rand;
class DiagnosticTest extends AsyncTestCase
@ -84,6 +86,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"];
}
}