1
0
mirror of https://github.com/danog/psalm.git synced 2025-01-21 21:31:13 +01:00

Add completions for functions (#5128)

* Add completions for functions

Provide autocompletions in the LSP for all global functions and functions from namespaces used in the current context.

* Uncomment code

* PHPCS

* Simplify functions map

Co-authored-by: Matthew Brown <github@muglug.com>

* Switch to storing lowercase function string in array key

* Fix spacing

Co-authored-by: Matthew Brown <github@muglug.com>
This commit is contained in:
Joe Hoyle 2021-02-12 22:59:47 +01:00 committed by GitHub
parent fa337375ae
commit 4077de2c93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 321 additions and 1 deletions

View File

@ -44,6 +44,7 @@ use function substr;
use function substr_count;
use function array_pop;
use function implode;
use function array_reverse;
class Codebase
{
@ -1611,6 +1612,51 @@ class Codebase
);
}
$functions = $this->functions->getMatchingFunctionNames($type_string, $offset, $file_path, $this);
$namespace_map = [];
if ($aliases) {
$namespace_map += $aliases->uses_flipped;
if ($aliases->namespace) {
$namespace_map[$aliases->namespace] = '';
}
}
// Sort the map by longest first, so we replace most specific
// used namespaces first.
ksort($namespace_map);
$namespace_map = array_reverse($namespace_map);
foreach ($functions as $function_lowercase => $function) {
// Transform FQFN relative to all uses namespaces
$function_name = $function->cased_name;
if (!$function_name) {
continue;
}
$in_namespace_map = false;
foreach ($namespace_map as $namespace_name => $namespace_alias) {
if (strpos($function_lowercase, $namespace_name . '\\') === 0) {
$function_name = $namespace_alias . '\\' . substr($function_name, strlen($namespace_name) + 1);
$in_namespace_map = true;
}
}
// If the function is not use'd, and it's not a global function
// prepend it with a backslash.
if (!$in_namespace_map && strpos($function_name, '\\') !== false) {
$function_name = '\\' . $function_name;
}
$completion_items[] = new \LanguageServerProtocol\CompletionItem(
$function_name,
\LanguageServerProtocol\CompletionItemKind::FUNCTION,
$function->getSignature(false),
null,
null,
$function_name,
$function_name . '()',
null
);
}
return $completion_items;
}

View File

@ -39,6 +39,7 @@ use function strlen;
use function strrpos;
use function strtolower;
use function substr;
use function preg_quote;
/**
* @internal
@ -238,7 +239,7 @@ class ClassLikes
$stub = substr($stub, 1);
}
$stub = strtolower($stub);
$stub = preg_quote(strtolower($stub));
foreach ($this->existing_classes as $fq_classlike_name => $found) {
if (!$found) {

View File

@ -18,6 +18,10 @@ use function strtolower;
use function substr;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Internal\MethodIdentifier;
use function strlen;
use function rtrim;
use function is_bool;
use function array_values;
/**
* @internal
@ -252,6 +256,94 @@ class Functions
return ($namespace ? $namespace . '\\' : '') . $function_name;
}
/**
* @return array<lowercase-string,FunctionStorage>
*/
public function getMatchingFunctionNames(
string $stub,
int $offset,
string $file_path,
Codebase $codebase
) : array {
if ($stub[0] === '*') {
$stub = substr($stub, 1);
}
/** @var array<lowercase-string, FunctionStorage> */
$matching_functions = [];
$stub = strtolower($stub);
$file_storage = $this->file_storage_provider->get($file_path);
$current_namespace_aliases = null;
foreach ($file_storage->namespace_aliases as $namespace_start => $namespace_aliases) {
if ($namespace_start < $offset) {
$current_namespace_aliases = $namespace_aliases;
break;
}
}
// We will search all functions for several patterns. This will
// be for all used namespaces, the global namespace and matched
// used functions.
$match_function_patterns = [
$stub . '*',
];
if ($current_namespace_aliases) {
// As the stub still include the current namespace in the symbol,
// remove the current namespace to provide the function-only token
// and match against global functions and all imported namespaces.
// "Bar/foo" will become "foo".
if ($current_namespace_aliases->namespace
&& strpos($stub, strtolower($current_namespace_aliases->namespace) . '\\') === 0
) {
$stub = substr($stub, strlen($current_namespace_aliases->namespace) + 1);
$match_function_patterns[] = $stub . '*';
}
foreach ($current_namespace_aliases->functions as $alias_name => $function_name) {
if (strpos($alias_name, $stub) === 0) {
try {
$match_function_patterns[] = $function_name;
} catch (\Exception $e) {
}
}
}
foreach ($current_namespace_aliases->uses as $namespace_name) {
$match_function_patterns[] = $namespace_name . '\\' . $stub . '*';
}
}
$function_map = $file_storage->functions
+ $this->getAllStubbedFunctions()
+ $this->reflection->getFunctions()
+ $codebase->config->getPredefinedFunctions();
foreach ($function_map as $function_name => $function) {
foreach ($match_function_patterns as $pattern) {
if (substr($pattern, -1, 1) === '*') {
if (strpos($function_name, rtrim($pattern, '*')) !== 0) {
continue;
}
} elseif ($function_name !== $pattern) {
continue;
}
if (is_bool($function)) {
/** @var callable-string $function_name */
if ($this->reflection->registerFunction($function_name) === false) {
continue;
}
$function = $this->reflection->getFunctionStorage($function_name);
}
/** @var lowercase-string $function_name */
$matching_functions[$function_name] = $function;
}
}
return $matching_functions;
}
public static function isVariadic(Codebase $codebase, string $function_id, string $file_path): bool
{
$file_storage = $codebase->file_storage_provider->get($file_path);

View File

@ -517,6 +517,14 @@ class Reflection
throw new \UnexpectedValueException('Expecting to have a function for ' . $function_id);
}
/**
* @return array<string, FunctionStorage>
*/
public function getFunctions(): array
{
return self::$builtin_functions;
}
public static function clearCache() : void
{
self::$builtin_functions = [];

View File

@ -8,6 +8,7 @@ use Psalm\Internal\Provider\Providers;
use Psalm\Tests\Internal\Provider;
use Psalm\Tests\TestConfig;
use Psalm\Type;
use function count;
class CompletionTest extends \Psalm\Tests\TestCase
{
@ -726,6 +727,178 @@ class CompletionTest extends \Psalm\Tests\TestCase
$this->assertSame(44, $completion_items[0]->additionalTextEdits[0]->range->end->character);
}
public function testCompletionForFunctionNames(): void
{
$codebase = $this->project_analyzer->getCodebase();
$config = $codebase->config;
$config->throw_exception = false;
$this->addFile(
'somefile.php',
'<?php
namespace Bar;
function my_function_in_bar() : void {
}
my_function_in'
);
$codebase->file_provider->openFile('somefile.php');
$codebase->scanFiles();
$this->analyzeFile('somefile.php', new Context());
$completion_data = $codebase->getCompletionDataAtPosition('somefile.php', new Position(7, 30));
$this->assertNotNull($completion_data);
$this->assertSame('*Bar\my_function_in', $completion_data[0]);
$completion_items = $codebase->getCompletionItemsForPartialSymbol($completion_data[0], $completion_data[2], 'somefile.php');
$this->assertSame(1, count($completion_items));
}
public function testCompletionForFunctionNamesRespectUsedNamespaces(): void
{
$codebase = $this->project_analyzer->getCodebase();
$config = $codebase->config;
$config->throw_exception = false;
$this->addFile(
'somefile.php',
'<?php
namespace Bar;
use phpunit\framework as phpf;
atleaston'
);
$codebase->file_provider->openFile('somefile.php');
$codebase->scanFiles();
$this->analyzeFile('somefile.php', new Context());
$completion_data = $codebase->getCompletionDataAtPosition('somefile.php', new Position(3, 25));
$this->assertNotNull($completion_data);
$this->assertSame('*Bar\atleaston', $completion_data[0]);
$completion_items = $codebase->getCompletionItemsForPartialSymbol($completion_data[0], $completion_data[2], 'somefile.php');
$this->assertSame(1, count($completion_items));
$this->assertSame('phpf\\atLeastOnce', $completion_items[0]->label);
}
public function testGetMatchingFunctionNames(): void
{
$codebase = $this->project_analyzer->getCodebase();
$config = $codebase->config;
$config->throw_exception = false;
$this->addFile(
'somefile.php',
'<?php
function my_function() {
}'
);
$codebase->file_provider->openFile('somefile.php');
$codebase->scanFiles();
$this->analyzeFile('somefile.php', new Context());
$functions = $codebase->functions->getMatchingFunctionNames('array_su', 0, 'somefile.php', $codebase);
$this->assertSame(1, count($functions));
$functions = $codebase->functions->getMatchingFunctionNames('my_funct', 0, 'somefile.php', $codebase);
$this->assertSame(1, count($functions));
}
public function testGetMatchingFunctionNamesFromPredefinedFunctions(): void
{
$codebase = $this->project_analyzer->getCodebase();
$config = $codebase->config;
$config->throw_exception = false;
$this->addFile(
'somefile.php',
'<?php'
);
$codebase->file_provider->openFile('somefile.php');
$codebase->scanFiles();
$this->analyzeFile('somefile.php', new Context());
$functions = $codebase->functions->getMatchingFunctionNames('urlencod', 0, 'somefile.php', $codebase);
$this->assertSame(1, count($functions));
}
public function testGetMatchingFunctionNamesFromUsedFunction(): void
{
$codebase = $this->project_analyzer->getCodebase();
$config = $codebase->config;
$config->throw_exception = false;
$this->addFile(
'somefile.php',
'<?php
namespace Foo;
use function phpunit\framework\atleastonce;
'
);
$codebase->file_provider->openFile('somefile.php');
$codebase->scanFiles();
$this->analyzeFile('somefile.php', new Context());
$functions = $codebase->functions->getMatchingFunctionNames('Foo\atleaston', 81, 'somefile.php', $codebase);
$this->assertSame(1, count($functions));
}
public function testGetMatchingFunctionNamesFromUsedNamespace(): void
{
$codebase = $this->project_analyzer->getCodebase();
$config = $codebase->config;
$config->throw_exception = false;
$this->addFile(
'somefile.php',
'<?php
namespace Foo;
use phpunit\framework;
'
);
$codebase->file_provider->openFile('somefile.php');
$codebase->scanFiles();
$this->analyzeFile('somefile.php', new Context());
$functions = $codebase->functions->getMatchingFunctionNames('Foo\atleaston', 81, 'somefile.php', $codebase);
$this->assertSame(1, count($functions));
}
public function testGetMatchingFunctionNamesWithNamespace(): void
{
$codebase = $this->project_analyzer->getCodebase();
$config = $codebase->config;
$config->throw_exception = false;
$this->addFile(
'somefile.php',
'<?php
namespace Foo;
function my_function() {
}'
);
$codebase->file_provider->openFile('somefile.php');
$codebase->scanFiles();
$this->analyzeFile('somefile.php', new Context());
$functions = $codebase->functions->getMatchingFunctionNames('*Foo\array_su', 45, 'somefile.php', $codebase);
$this->assertSame(1, count($functions));
$functions = $codebase->functions->getMatchingFunctionNames('Foo\my_funct', 45, 'somefile.php', $codebase);
$this->assertSame(1, count($functions));
}
public function testCompletionOnInstanceofWithNamespaceAndUse(): void
{
$codebase = $this->project_analyzer->getCodebase();