From cdc431e9407affbe0a3ba36de0c9fac542339a4d Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Tue, 26 Jan 2021 21:34:46 -0500 Subject: [PATCH] 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 --- src/Psalm/Codebase.php | 159 +++++++++++++++++- .../LanguageServer/LanguageServer.php | 2 +- .../LanguageServer/Server/TextDocument.php | 22 ++- src/Psalm/Internal/Type/ParseTreeCreator.php | 2 +- tests/LanguageServer/CompletionTest.php | 111 ++++++++++++ 5 files changed, 288 insertions(+), 8 deletions(-) diff --git a/src/Psalm/Codebase.php b/src/Psalm/Codebase.php index 607d6f8b3..6ad201368 100644 --- a/src/Psalm/Codebase.php +++ b/src/Psalm/Codebase.php @@ -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); diff --git a/src/Psalm/Internal/LanguageServer/LanguageServer.php b/src/Psalm/Internal/LanguageServer/LanguageServer.php index 34b0d4b3a..929440bdf 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageServer.php +++ b/src/Psalm/Internal/LanguageServer/LanguageServer.php @@ -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(['(', ',']); diff --git a/src/Psalm/Internal/LanguageServer/Server/TextDocument.php b/src/Psalm/Internal/LanguageServer/Server/TextDocument.php index 4f9c347c9..b075cd4fd 100644 --- a/src/Psalm/Internal/LanguageServer/Server/TextDocument.php +++ b/src/Psalm/Internal/LanguageServer/Server/TextDocument.php @@ -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)); diff --git a/src/Psalm/Internal/Type/ParseTreeCreator.php b/src/Psalm/Internal/Type/ParseTreeCreator.php index af76d50e0..da20be0fd 100644 --- a/src/Psalm/Internal/Type/ParseTreeCreator.php +++ b/src/Psalm/Internal/Type/ParseTreeCreator.php @@ -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' ); } diff --git a/tests/LanguageServer/CompletionTest.php b/tests/LanguageServer/CompletionTest.php index a99046f09..fb0c54ef6 100644 --- a/tests/LanguageServer/CompletionTest.php +++ b/tests/LanguageServer/CompletionTest.php @@ -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', + ' 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', + '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', + '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); + } }