1
0
mirror of https://github.com/danog/psalm.git synced 2024-11-26 12:24:49 +01:00

Completions for array keys and type literals (#5105)

* Add completions for known array keys

* Use dynamic gap value

* Provide completions for known type contexts

* Fix formatting

* Remove trailing comma

* PHPCS fixes

* Remove support for literal floats

* Fix test for floats
This commit is contained in:
Joe Hoyle 2021-01-26 21:34:46 -05:00 committed by Daniil Gentili
parent 037b24550f
commit cdc431e940
Signed by: danog
GPG Key ID: 8C1BE3B34B230CA7
5 changed files with 288 additions and 8 deletions

View File

@ -900,6 +900,41 @@ class Codebase
$this->file_storage_provider->remove($file_path);
}
public function getFunctionStorageForSymbol(string $file_path, string $symbol): ?FunctionLikeStorage
{
if (strpos($symbol, '::')) {
$symbol = substr($symbol, 0, -2);
/** @psalm-suppress ArgumentTypeCoercion */
$method_id = new \Psalm\Internal\MethodIdentifier(...explode('::', $symbol));
$declaring_method_id = $this->methods->getDeclaringMethodId($method_id);
if (!$declaring_method_id) {
return null;
}
$storage = $this->methods->getStorage($declaring_method_id);
return $storage;
}
$function_id = strtolower(substr($symbol, 0, -2));
$file_storage = $this->file_storage_provider->get($file_path);
if (isset($file_storage->functions[$function_id])) {
$function_storage = $file_storage->functions[$function_id];
return $function_storage;
}
if (!$function_id) {
return null;
}
$function = $this->functions->getStorage(null, $function_id);
return $function;
}
public function getSymbolInformation(string $file_path, string $symbol): ?string
{
if (\is_numeric($symbol[0])) {
@ -1230,7 +1265,7 @@ class Codebase
}
/**
* @return array{0: string, 1: '->'|'::'|'symbol', 2: int}|null
* @return array{0: string, 1: '->'|'::'|'['|'symbol', 2: int}|null
*/
public function getCompletionDataAtPosition(string $file_path, Position $position): ?array
{
@ -1262,6 +1297,21 @@ class Codebase
: 0;
$end_pos = $end_pos_excluding_whitespace + $num_whitespace_bytes;
if ($offset - $end_pos === 1) {
$candidate_gap = substr($file_contents, $end_pos, 1);
if ($candidate_gap == '[') {
$gap = $candidate_gap;
$recent_type = $possible_type;
if ($recent_type === 'mixed') {
return null;
}
return [$recent_type, $gap, $offset];
}
}
if ($offset - $end_pos === 2 || $offset - $end_pos === 3) {
$candidate_gap = substr($file_contents, $end_pos, 2);
@ -1302,6 +1352,31 @@ class Codebase
return null;
}
public function getTypeContextAtPosition(string $file_path, Position $position): ?Type\Union
{
$file_contents = $this->getFileContents($file_path);
$offset = $position->toOffset($file_contents);
[$reference_map, $type_map, $argument_map] = $this->analyzer->getMapsForFile($file_path);
if (!$reference_map && !$type_map && !$argument_map) {
return null;
}
foreach ($argument_map as $start_pos => [$end_pos, $function, $argument_num]) {
if ($offset < $start_pos || $offset > $end_pos) {
continue;
}
// First parameter to a function-like
$function_storage = $this->getFunctionStorageForSymbol($file_path, $function . '()');
if (!$function_storage || !$function_storage->params) {
return null;
}
$parameter = $function_storage->params[$argument_num];
return $parameter->type;
}
return null;
}
/**
* @return list<\LanguageServerProtocol\CompletionItem>
*/
@ -1499,6 +1574,88 @@ class Codebase
return $completion_items;
}
/**
* @return list<\LanguageServerProtocol\CompletionItem>
*/
public function getCompletionItemsForType(Type\Union $type): array
{
$completion_items = [];
foreach ($type->getAtomicTypes() as $atomic_type) {
if ($atomic_type instanceof Type\Atomic\TBool) {
$bools = (string) $atomic_type === 'bool' ? ['true', 'false'] : [(string) $atomic_type];
foreach ($bools as $property_name) {
$completion_items[] = new \LanguageServerProtocol\CompletionItem(
$property_name,
\LanguageServerProtocol\CompletionItemKind::VALUE,
'bool',
null,
null,
null,
$property_name,
);
}
} elseif ($atomic_type instanceof Type\Atomic\TLiteralString) {
$completion_items[] = new \LanguageServerProtocol\CompletionItem(
$atomic_type->value,
\LanguageServerProtocol\CompletionItemKind::VALUE,
$atomic_type->getId(),
null,
null,
null,
"'$atomic_type->value'",
);
} elseif ($atomic_type instanceof Type\Atomic\TLiteralInt) {
$completion_items[] = new \LanguageServerProtocol\CompletionItem(
(string) $atomic_type->value,
\LanguageServerProtocol\CompletionItemKind::VALUE,
$atomic_type->getId(),
null,
null,
null,
(string) $atomic_type->value,
);
} elseif ($atomic_type instanceof Type\Atomic\TScalarClassConstant) {
$const = $atomic_type->fq_classlike_name . '::' . $atomic_type->const_name;
$completion_items[] = new \LanguageServerProtocol\CompletionItem(
$const,
\LanguageServerProtocol\CompletionItemKind::VALUE,
$atomic_type->getId(),
null,
null,
null,
$const,
);
}
}
return $completion_items;
}
/**
* @return list<\LanguageServerProtocol\CompletionItem>
*/
public function getCompletionItemsForArrayKeys(
string $type_string
) : array {
$completion_items = [];
$type = Type::parseString($type_string);
foreach ($type->getAtomicTypes() as $atomic_type) {
if ($atomic_type instanceof Type\Atomic\TKeyedArray) {
foreach ($atomic_type->properties as $property_name => $property) {
$completion_items[] = new \LanguageServerProtocol\CompletionItem(
(string) $property_name,
\LanguageServerProtocol\CompletionItemKind::PROPERTY,
(string) $property,
null,
null,
null,
"'$property_name'",
);
}
}
}
return $completion_items;
}
private static function getPositionFromOffset(int $offset, string $file_contents) : Position
{
$file_contents = substr($file_contents, 0, $offset);

View File

@ -247,7 +247,7 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher
if ($this->project_analyzer->provide_completion) {
$serverCapabilities->completionProvider = new CompletionOptions();
$serverCapabilities->completionProvider->resolveProvider = false;
$serverCapabilities->completionProvider->triggerCharacters = ['$', '>', ':'];
$serverCapabilities->completionProvider->triggerCharacters = ['$', '>', ':',"[", "(", ",", " "];
}
$serverCapabilities->signatureHelpProvider = new SignatureHelpOptions(['(', ',']);

View File

@ -249,18 +249,30 @@ class TextDocument
return new Success([]);
}
if (!$completion_data) {
$type_context = $this->codebase->getTypeContextAtPosition($file_path, $position);
if (!$completion_data && !$type_context) {
error_log('completion not found at ' . $position->line . ':' . $position->character);
return new Success([]);
}
[$recent_type, $gap, $offset] = $completion_data;
if ($completion_data) {
[$recent_type, $gap, $offset] = $completion_data;
if ($gap === '->' || $gap === '::') {
$completion_items = $this->codebase->getCompletionItemsForClassishThing($recent_type, $gap);
if ($gap === '->' || $gap === '::') {
$completion_items = $this->codebase->getCompletionItemsForClassishThing($recent_type, $gap);
} elseif ($gap === '[') {
$completion_items = $this->codebase->getCompletionItemsForArrayKeys($recent_type);
} else {
$completion_items = $this->codebase->getCompletionItemsForPartialSymbol(
$recent_type,
$offset,
$file_path
);
}
} else {
$completion_items = $this->codebase->getCompletionItemsForPartialSymbol($recent_type, $offset, $file_path);
$completion_items = $this->codebase->getCompletionItemsForType($type_context);
}
return new Success(new CompletionList($completion_items, false));

View File

@ -764,7 +764,7 @@ class ParseTreeCreator
);
} else {
throw new TypeParseTreeException(
'Bracket must be preceded by “Closure”, “callable”, "pure-callable" or a valid @method name'
'Paranthesis must be preceded by “Closure”, “callable”, "pure-callable" or a valid @method name'
);
}

View File

@ -7,6 +7,7 @@ use Psalm\Internal\Analyzer\ProjectAnalyzer;
use Psalm\Internal\Provider\Providers;
use Psalm\Tests\Internal\Provider;
use Psalm\Tests\TestConfig;
use Psalm\Type;
class CompletionTest extends \Psalm\Tests\TestCase
{
@ -815,4 +816,114 @@ class CompletionTest extends \Psalm\Tests\TestCase
$codebase->scanFiles();
$this->analyzeFile('somefile.php', new Context());
}
public function testCompletionOnArrayKey(): void
{
$codebase = $this->project_analyzer->getCodebase();
$config = $codebase->config;
$config->throw_exception = false;
$this->addFile(
'somefile.php',
'<?php
$my_array = ["foo" => 1, "bar" => 2];
$my_array[]
'
);
$codebase->file_provider->openFile('somefile.php');
$codebase->scanFiles();
$this->analyzeFile('somefile.php', new Context());
$completion_data = $codebase->getCompletionDataAtPosition('somefile.php', new Position(2, 26));
$this->assertSame(
[
'array{bar: 2, foo: 1}',
'[',
86,
],
$completion_data
);
$completion_items = $codebase->getCompletionItemsForArrayKeys($completion_data[0]);
$this->assertCount(2, $completion_items);
}
public function testTypeContextForFunctionArgument(): void
{
$codebase = $this->project_analyzer->getCodebase();
$config = $codebase->config;
$config->throw_exception = false;
$this->addFile(
'somefile.php',
'<?php
namespace Bar;
function my_func(string $arg_a, bool $arg_b) : string {
}
my_func()'
);
$codebase->file_provider->openFile('somefile.php');
$codebase->scanFiles();
$this->analyzeFile('somefile.php', new Context());
$type = $codebase->getTypeContextAtPosition('somefile.php', new Position(5, 24));
$this->assertSame('string', (string) $type);
}
public function testTypeContextForFunctionArgumentWithWhiteSpace(): void
{
$codebase = $this->project_analyzer->getCodebase();
$config = $codebase->config;
$config->throw_exception = false;
$this->addFile(
'somefile.php',
'<?php
namespace Bar;
function my_func(string $arg_a, bool $arg_b) : string {
}
my_func( "yes", )'
);
$codebase->file_provider->openFile('somefile.php');
$codebase->scanFiles();
$this->analyzeFile('somefile.php', new Context());
$type = $codebase->getTypeContextAtPosition('somefile.php', new Position(5, 32));
$this->assertSame('bool', (string) $type);
}
public function testCompletionsForType(): void
{
$codebase = $this->project_analyzer->getCodebase();
$config = $codebase->config;
$config->throw_exception = false;
$completion_items = $codebase->getCompletionItemsForType(Type::parseString('bool'));
$this->assertCount(2, $completion_items);
$completion_items = $codebase->getCompletionItemsForType(Type::parseString('true'));
$this->assertCount(1, $completion_items);
$completion_items = $codebase->getCompletionItemsForType(Type::parseString("'yes'|'no'"));
$this->assertCount(2, $completion_items);
$completion_items = $codebase->getCompletionItemsForType(Type::parseString("1|2|3"));
$this->assertCount(3, $completion_items);
// Floats not supported.
$completion_items = $codebase->getCompletionItemsForType(Type::parseString("1.0"));
$this->assertCount(0, $completion_items);
$completion_items = $codebase->getCompletionItemsForType(Type::parseString("DateTime::RFC3339"));
$this->assertCount(1, $completion_items);
}
}