mirror of
https://github.com/danog/psalm.git
synced 2024-11-30 04:39:00 +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:
parent
037b24550f
commit
cdc431e940
@ -900,6 +900,41 @@ class Codebase
|
|||||||
$this->file_storage_provider->remove($file_path);
|
$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
|
public function getSymbolInformation(string $file_path, string $symbol): ?string
|
||||||
{
|
{
|
||||||
if (\is_numeric($symbol[0])) {
|
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
|
public function getCompletionDataAtPosition(string $file_path, Position $position): ?array
|
||||||
{
|
{
|
||||||
@ -1262,6 +1297,21 @@ class Codebase
|
|||||||
: 0;
|
: 0;
|
||||||
$end_pos = $end_pos_excluding_whitespace + $num_whitespace_bytes;
|
$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) {
|
if ($offset - $end_pos === 2 || $offset - $end_pos === 3) {
|
||||||
$candidate_gap = substr($file_contents, $end_pos, 2);
|
$candidate_gap = substr($file_contents, $end_pos, 2);
|
||||||
|
|
||||||
@ -1302,6 +1352,31 @@ class Codebase
|
|||||||
return null;
|
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>
|
* @return list<\LanguageServerProtocol\CompletionItem>
|
||||||
*/
|
*/
|
||||||
@ -1499,6 +1574,88 @@ class Codebase
|
|||||||
return $completion_items;
|
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
|
private static function getPositionFromOffset(int $offset, string $file_contents) : Position
|
||||||
{
|
{
|
||||||
$file_contents = substr($file_contents, 0, $offset);
|
$file_contents = substr($file_contents, 0, $offset);
|
||||||
|
@ -247,7 +247,7 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher
|
|||||||
if ($this->project_analyzer->provide_completion) {
|
if ($this->project_analyzer->provide_completion) {
|
||||||
$serverCapabilities->completionProvider = new CompletionOptions();
|
$serverCapabilities->completionProvider = new CompletionOptions();
|
||||||
$serverCapabilities->completionProvider->resolveProvider = false;
|
$serverCapabilities->completionProvider->resolveProvider = false;
|
||||||
$serverCapabilities->completionProvider->triggerCharacters = ['$', '>', ':'];
|
$serverCapabilities->completionProvider->triggerCharacters = ['$', '>', ':',"[", "(", ",", " "];
|
||||||
}
|
}
|
||||||
|
|
||||||
$serverCapabilities->signatureHelpProvider = new SignatureHelpOptions(['(', ',']);
|
$serverCapabilities->signatureHelpProvider = new SignatureHelpOptions(['(', ',']);
|
||||||
|
@ -249,18 +249,30 @@ class TextDocument
|
|||||||
return new Success([]);
|
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);
|
error_log('completion not found at ' . $position->line . ':' . $position->character);
|
||||||
|
|
||||||
return new Success([]);
|
return new Success([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($completion_data) {
|
||||||
[$recent_type, $gap, $offset] = $completion_data;
|
[$recent_type, $gap, $offset] = $completion_data;
|
||||||
|
|
||||||
if ($gap === '->' || $gap === '::') {
|
if ($gap === '->' || $gap === '::') {
|
||||||
$completion_items = $this->codebase->getCompletionItemsForClassishThing($recent_type, $gap);
|
$completion_items = $this->codebase->getCompletionItemsForClassishThing($recent_type, $gap);
|
||||||
|
} elseif ($gap === '[') {
|
||||||
|
$completion_items = $this->codebase->getCompletionItemsForArrayKeys($recent_type);
|
||||||
} else {
|
} else {
|
||||||
$completion_items = $this->codebase->getCompletionItemsForPartialSymbol($recent_type, $offset, $file_path);
|
$completion_items = $this->codebase->getCompletionItemsForPartialSymbol(
|
||||||
|
$recent_type,
|
||||||
|
$offset,
|
||||||
|
$file_path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$completion_items = $this->codebase->getCompletionItemsForType($type_context);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Success(new CompletionList($completion_items, false));
|
return new Success(new CompletionList($completion_items, false));
|
||||||
|
@ -764,7 +764,7 @@ class ParseTreeCreator
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw new TypeParseTreeException(
|
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'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ use Psalm\Internal\Analyzer\ProjectAnalyzer;
|
|||||||
use Psalm\Internal\Provider\Providers;
|
use Psalm\Internal\Provider\Providers;
|
||||||
use Psalm\Tests\Internal\Provider;
|
use Psalm\Tests\Internal\Provider;
|
||||||
use Psalm\Tests\TestConfig;
|
use Psalm\Tests\TestConfig;
|
||||||
|
use Psalm\Type;
|
||||||
|
|
||||||
class CompletionTest extends \Psalm\Tests\TestCase
|
class CompletionTest extends \Psalm\Tests\TestCase
|
||||||
{
|
{
|
||||||
@ -815,4 +816,114 @@ class CompletionTest extends \Psalm\Tests\TestCase
|
|||||||
$codebase->scanFiles();
|
$codebase->scanFiles();
|
||||||
$this->analyzeFile('somefile.php', new Context());
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user