1
0
Fork 0

feat(completion): complete for used namespaces

pull/602/head
Declspeck 2018-02-09 22:42:22 +02:00
parent 6858bd3513
commit d6b4e79491
No known key found for this signature in database
GPG Key ID: F0417663122A2189
8 changed files with 205 additions and 91 deletions

View File

@ -0,0 +1,9 @@
<?php
namespace Whatever;
use TestNamespace\InnerNamespace as AliasNamespace;
AliasNamespace\I
class IDontShowUpInCompletion {}

View File

@ -103,3 +103,8 @@ class Example {
public function __construct() {} public function __construct() {}
public function __destruct() {} public function __destruct() {}
} }
namespace TestNamespace\InnerNamespace;
class InnerClass {
}

View File

@ -16,6 +16,7 @@ use LanguageServer\Protocol\{
}; };
use Microsoft\PhpParser; use Microsoft\PhpParser;
use Microsoft\PhpParser\Node; use Microsoft\PhpParser\Node;
use Microsoft\PhpParser\ResolvedName;
use Generator; use Generator;
class CompletionProvider class CompletionProvider
@ -281,10 +282,32 @@ class CompletionProvider
// The name Node under the cursor // The name Node under the cursor
$nameNode = isset($creation) ? $creation->classTypeDesignator : $node; $nameNode = isset($creation) ? $creation->classTypeDesignator : $node;
/** The typed name */ $filterNameTokens = static function ($tokens) {
$prefix = $nameNode instanceof Node\QualifiedName return array_values(
? (string)PhpParser\ResolvedName::buildName($nameNode->nameParts, $nameNode->getFileContents()) array_filter(
: $nameNode->getText($node->getFileContents()); $tokens,
static function ($token): bool {
return $token->kind === PhpParser\TokenKind::Name;
}
)
);
};
/** @var string[] The written name, exploded by \ */
$prefix = array_map(
static function ($part) use ($node) : string {
return $part->getText($node->getFileContents());
},
$filterNameTokens(
$nameNode instanceof Node\QualifiedName
? $nameNode->nameParts
: [$nameNode]
)
);
if ($prefix === ['']) {
$prefix = [];
}
/** Whether the prefix is qualified (contains at least one backslash) */ /** Whether the prefix is qualified (contains at least one backslash) */
$isQualified = $nameNode instanceof Node\QualifiedName && $nameNode->isQualifiedName(); $isQualified = $nameNode instanceof Node\QualifiedName && $nameNode->isQualifiedName();
@ -295,103 +318,100 @@ class CompletionProvider
/** The closest NamespaceDefinition Node */ /** The closest NamespaceDefinition Node */
$namespaceNode = $node->getNamespaceDefinition(); $namespaceNode = $node->getNamespaceDefinition();
if ($nameNode instanceof Node\QualifiedName) {
/** @var array For Psr\Http\Mess this will be ['Psr', 'Http'] */
$namePartsWithoutLast = $nameNode->nameParts;
array_pop($namePartsWithoutLast);
/** @var string When typing \Foo\Bar\Fooba, this will be Foo\Bar */
$prefixParentNamespace = (string)PhpParser\ResolvedName::buildName(
$namePartsWithoutLast,
$node->getFileContents()
);
} else {
// Not qualified, parent namespace is root.
$prefixParentNamespace = '';
}
/** @var string[] Namespaces to search completions in. */
$namespacesToSearch = [];
if ($namespaceNode && !$isFullyQualified) {
/** @var string Declared namespace of the file (or section) */
$currentNamespace = (string)PhpParser\ResolvedName::buildName(
$namespaceNode->name->nameParts,
$namespaceNode->getFileContents()
);
if ($prefixParentNamespace === '') {
$namespacesToSearch[] = $currentNamespace;
} else {
// Partially qualified, concatenate with current namespace.
$namespacesToSearch[] = $currentNamespace . '\\' . $prefixParentNamespace;
}
/** @var string Prefix with namespace inferred. */
$namespacedPrefix = $currentNamespace . '\\' . $prefix;
} else {
// In the global namespace, prefix parent refers to global namespace,
// OR completing a fully qualified name, prefix parent starts from the global namespace.
$namespacesToSearch[] = $prefixParentNamespace;
$namespacedPrefix = $prefix;
}
if (!$isQualified && $namespacesToSearch[0] !== '' && ($prefix === '' || !isset($creation))) {
// Also search the global namespace for non-qualified completions, as roamed
// definitions may be found. Also, without a prefix, suggest completions from the global namespace.
// Since only functions and constants can be roamed, don't search the global namespace for creation
// with a prefix.
$namespacesToSearch[] = '';
}
/** @var int Length of $namespacedPrefix */
$namespacedPrefixLen = strlen($namespacedPrefix);
/** @var int Length of $prefix */
$prefixLen = strlen($prefix);
// Get the namespace use statements // Get the namespace use statements
// TODO: use function statements, use const statements // TODO: use function statements, use const statements
/** @var string[] $aliases A map from local alias to fully qualified name */ /** @var string[] $aliases A map from local alias to fully qualified name */
list($aliases,,) = $node->getImportTablesForCurrentScope(); list($aliases,,) = $node->getImportTablesForCurrentScope();
// If there is a prefix that does not start with a slash, suggest `use`d symbols /** @var array Array of [fqn=string, requiresRoaming=bool] the prefix may represent. */
$possibleFqns = [];
if ($isFullyQualified) {
// Case \Microsoft\PhpParser\Res|
$possibleFqns[] = [$prefix, false];
} else if ($fqnAfterAlias = $this->tryApplyAlias($aliases, $prefix)) {
// Cases handled here: (i.e. all namespaces involving use clauses)
//
// use Microsoft\PhpParser\Node; //Note that Node is both a class and a namespace.
// Nod|
// Node\Qual|
//
// use Microsoft\PhpParser as TheParser;
// TheParser\Nod|
$possibleFqns[] = [$fqnAfterAlias, false];
} else if ($namespaceNode) {
// Cases handled here:
//
// namespace Foo;
// Microsoft\PhpParser\Nod| // Can refer only to \Foo\Microsoft, not to \Microsoft.
//
// namespace Foo;
// Test| // Can refer either to functions or constants at the global scope, or to
// // everything below \Foo. (Global fallback / roaming)
/** @var \Microsoft\PhpParser\ResolvedName Declared namespace of the file (or section) */
$namespacedFqn = array_merge(
array_map(
static function ($token) use ($namespaceNode): string {
return $token->getText($namespaceNode->getFileContents());
},
$filterNameTokens($namespaceNode->name->nameParts)
),
$prefix
);
$possibleFqns[] = [$namespacedFqn, false];
if (!$isQualified) {
// Case of global fallback. If nothing is entered, also complete for root-level classnames.
// If something has been entered, complete root-level roamed symbols only.
$possibleFqns[] = [$prefix, !empty($prefix)];
}
} else {
// Case handled here: (no namespace declaration in file)
//
// Microsoft\PhpParser\N|
$possibleFqns[] = [$prefix, false];
}
$prefixStr = implode('\\', $prefix);
/** @var int Length of $prefix */
$prefixLen = strlen($prefixStr);
// If there is a prefix that does not contain a slash, suggest used names.
if (!$isQualified) { if (!$isQualified) {
foreach ($aliases as $alias => $fqn) { foreach ($aliases as $alias => $fqn) {
// Suggest symbols that have been `use`d and match the prefix // Suggest symbols that have been `use`d and match the prefix
if (substr($alias, 0, $prefixLen) === $prefix if (substr($alias, 0, $prefixLen) === $prefixStr
&& ($def = $this->index->getDefinition((string)$fqn))) { && ($def = $this->index->getDefinition((string)$fqn))) {
// TODO: complete even when getDefinition($fqn) fails, e.g. complete definitions that are were $list->items[] = CompletionItem::fromDefinition($def);
// not found in the files parsed.
$item = CompletionItem::fromDefinition($def);
$item->insertText = $alias;
$list->items[] = $item;
} }
} }
} }
foreach ($namespacesToSearch as $namespaceToSearch) { foreach ($possibleFqns as list ($fqnToSearch, $requiresRoaming)) {
$namespaceToSearch = $fqnToSearch;
array_pop($namespaceToSearch);
$namespaceToSearch = implode('\\', $namespaceToSearch);
$fqnToSearch = implode('\\', $fqnToSearch);
$fqnToSearchLen = strlen($fqnToSearch);
foreach ($this->index->getChildDefinitionsForFqn($namespaceToSearch) as $fqn => $def) { foreach ($this->index->getChildDefinitionsForFqn($namespaceToSearch) as $fqn => $def) {
if (isset($creation) && !$def->canBeInstantiated) { if (isset($creation) && !$def->canBeInstantiated) {
// Only suggest classes for `new` // Only suggest classes for `new`
continue; continue;
} }
if ($requiresRoaming && !$def->roamed) {
continue;
}
$fqnStartsWithPrefix = substr($fqn, 0, $prefixLen) === $prefix; if (substr($fqn, 0, $fqnToSearchLen) === $fqnToSearch) {
$fqnStartsWithNamespacedPrefix = substr($fqn, 0, $namespacedPrefixLen) === $namespacedPrefix;
if (
// No prefix - return all,
$prefix === ''
// or FQN starts with namespaced prefix,
|| $fqnStartsWithNamespacedPrefix
// or a roamed definition (i.e. global fallback to a constant or a function) matches prefix.
|| ($def->roamed && $fqnStartsWithPrefix)
) {
$item = CompletionItem::fromDefinition($def); $item = CompletionItem::fromDefinition($def);
// Find the shortest name to reference the symbol if (($aliasMatch = $this->tryMatchAlias($aliases, $fqn)) !== null) {
if ($namespaceNode && ($alias = array_search($fqn, $aliases, true)) !== false) { $item->insertText = $aliasMatch;
// $alias is the name under which this definition is aliased in the current namespace } else if ($namespaceNode && (empty($prefix) || $requiresRoaming)) {
$item->insertText = $alias; // Insert the global FQN with a leading backslash.
} else if ($namespaceNode && !($prefix && $isFullyQualified)) { // For empty prefix: Assume that the user wants an FQN. They have not
// Insert the global FQN with a leading backslash // started writing anything yet, so we are not second-guessing.
// For roaming: Second-guess that the user doesn't want to depend on
// roaming.
$item->insertText = '\\' . $fqn; $item->insertText = '\\' . $fqn;
} else { } else {
// Insert the FQN without a leading backslash // Insert the FQN without a leading backslash
@ -410,7 +430,7 @@ class CompletionProvider
// Suggest keywords // Suggest keywords
if (!$isQualified && !isset($creation)) { if (!$isQualified && !isset($creation)) {
foreach (self::KEYWORDS as $keyword) { foreach (self::KEYWORDS as $keyword) {
if (substr($keyword, 0, $prefixLen) === $prefix) { if (substr($keyword, 0, $prefixLen) === $prefixStr) {
$item = new CompletionItem($keyword, CompletionItemKind::KEYWORD); $item = new CompletionItem($keyword, CompletionItemKind::KEYWORD);
$item->insertText = $keyword; $item->insertText = $keyword;
$list->items[] = $item; $list->items[] = $item;
@ -422,6 +442,62 @@ class CompletionProvider
return $list; return $list;
} }
private function tryMatchAlias(
array $aliases,
string $fullyQualifiedName
): ?string {
$fullyQualifiedName = explode('\\', $fullyQualifiedName);
$aliasMatch = null;
$aliasMatchLength = null;
foreach ($aliases as $alias => $aliasFqn) {
$aliasFqn = $aliasFqn->getNameParts();
$aliasFqnLength = count($aliasFqn);
if ($aliasMatchLength && $aliasFqnLength < $aliasFqnLength) {
// Find the longest possible match. This one won't do.
continue;
}
$fqnStart = array_slice($fullyQualifiedName, 0, $aliasFqnLength);
if ($fqnStart === $aliasFqn) {
$aliasMatch = $alias;
$aliasMatchLength = $aliasFqnLength;
}
}
if ($aliasMatch === null) {
return null;
}
$fqnNoAlias = array_slice($fullyQualifiedName, $aliasMatchLength);
return join('\\', array_merge([$aliasMatch], $fqnNoAlias));
}
/**
* Tries to convert a partially qualified name to an FQN using aliases.
*
* Example:
*
* use Microsoft\PhpParser as TheParser;
* "TheParser\Node" will convert to "Microsoft\PhpParser\Node"
*
* @param \Microsoft\PhpParser\ResolvedName[] $aliases
* Aliases available in the scope of resolution. Keyed by alias.
* @param string[] $partiallyQualifiedName
**/
private function tryApplyAlias(
array $aliases,
array $partiallyQualifiedName
): ?array {
if (empty($partiallyQualifiedName)) {
return null;
}
$head = $partiallyQualifiedName[0];
$tail = array_slice($partiallyQualifiedName, 1);
if (!isset($aliases[$head])) {
return null;
}
return array_merge($aliases[$head]->getNameParts(), $tail);
}
/** /**
* Yields FQNs from an array along with the FQNs of all parent classes * Yields FQNs from an array along with the FQNs of all parent classes
* *

View File

@ -35,7 +35,9 @@ class DefinitionCollectorTest extends TestCase
'TestNamespace\\ChildClass', 'TestNamespace\\ChildClass',
'TestNamespace\\Example', 'TestNamespace\\Example',
'TestNamespace\\Example->__construct()', 'TestNamespace\\Example->__construct()',
'TestNamespace\\Example->__destruct()' 'TestNamespace\\Example->__destruct()',
'TestNamespace\\InnerNamespace',
'TestNamespace\\InnerNamespace\\InnerClass',
], array_keys($defNodes)); ], array_keys($defNodes));
$this->assertInstanceOf(Node\ConstElement::class, $defNodes['TestNamespace\\TEST_CONST']); $this->assertInstanceOf(Node\ConstElement::class, $defNodes['TestNamespace\\TEST_CONST']);
@ -53,6 +55,7 @@ class DefinitionCollectorTest extends TestCase
$this->assertInstanceOf(Node\Statement\ClassDeclaration::class, $defNodes['TestNamespace\\Example']); $this->assertInstanceOf(Node\Statement\ClassDeclaration::class, $defNodes['TestNamespace\\Example']);
$this->assertInstanceOf(Node\MethodDeclaration::class, $defNodes['TestNamespace\\Example->__construct()']); $this->assertInstanceOf(Node\MethodDeclaration::class, $defNodes['TestNamespace\\Example->__construct()']);
$this->assertInstanceOf(Node\MethodDeclaration::class, $defNodes['TestNamespace\\Example->__destruct()']); $this->assertInstanceOf(Node\MethodDeclaration::class, $defNodes['TestNamespace\\Example->__destruct()']);
$this->assertInstanceOf(Node\Statement\ClassDeclaration::class, $defNodes['TestNamespace\\InnerNamespace\\InnerClass']);
} }
public function testDoesNotCollectReferences() public function testDoesNotCollectReferences()

View File

@ -107,7 +107,9 @@ abstract class ServerTestCase extends TestCase
'TestNamespace\\whatever()' => new Location($referencesUri, new Range(new Position(21, 0), new Position(23, 1))), 'TestNamespace\\whatever()' => new Location($referencesUri, new Range(new Position(21, 0), new Position(23, 1))),
'TestNamespace\\Example' => new Location($symbolsUri, new Range(new Position(101, 0), new Position(104, 1))), 'TestNamespace\\Example' => new Location($symbolsUri, new Range(new Position(101, 0), new Position(104, 1))),
'TestNamespace\\Example::__construct' => new Location($symbolsUri, new Range(new Position(102, 4), new Position(102, 36))), 'TestNamespace\\Example::__construct' => new Location($symbolsUri, new Range(new Position(102, 4), new Position(102, 36))),
'TestNamespace\\Example::__destruct' => new Location($symbolsUri, new Range(new Position(103, 4), new Position(103, 35))) 'TestNamespace\\Example::__destruct' => new Location($symbolsUri, new Range(new Position(103, 4), new Position(103, 35))),
'TestNamespace\\InnerNamespace' => new Location($symbolsUri, new Range(new Position(106, 0), new Position(106, 39))),
'TestNamespace\\InnerNamespace\\InnerClass' => new Location($symbolsUri, new Range(new Position(108, 0), new Position(109, 1))),
]; ];
$this->referenceLocations = [ $this->referenceLocations = [

View File

@ -213,10 +213,7 @@ class CompletionTest extends TestCase
'laboris commodo ad commodo velit mollit qui non officia id. Nulla duis veniam' . "\n" . 'laboris commodo ad commodo velit mollit qui non officia id. Nulla duis veniam' . "\n" .
'veniam officia deserunt et non dolore mollit ea quis eiusmod sit non. Occaecat' . "\n" . 'veniam officia deserunt et non dolore mollit ea quis eiusmod sit non. Occaecat' . "\n" .
'consequat sunt culpa exercitation pariatur id reprehenderit nisi incididunt Lorem' . "\n" . 'consequat sunt culpa exercitation pariatur id reprehenderit nisi incididunt Lorem' . "\n" .
'sint. Officia culpa pariatur laborum nostrud cupidatat consequat mollit.', 'sint. Officia culpa pariatur laborum nostrud cupidatat consequat mollit.'
null,
null,
'TestClass'
), ),
], true), $items); ], true), $items);
} }
@ -239,10 +236,28 @@ class CompletionTest extends TestCase
'laboris commodo ad commodo velit mollit qui non officia id. Nulla duis veniam' . "\n" . 'laboris commodo ad commodo velit mollit qui non officia id. Nulla duis veniam' . "\n" .
'veniam officia deserunt et non dolore mollit ea quis eiusmod sit non. Occaecat' . "\n" . 'veniam officia deserunt et non dolore mollit ea quis eiusmod sit non. Occaecat' . "\n" .
'consequat sunt culpa exercitation pariatur id reprehenderit nisi incididunt Lorem' . "\n" . 'consequat sunt culpa exercitation pariatur id reprehenderit nisi incididunt Lorem' . "\n" .
'sint. Officia culpa pariatur laborum nostrud cupidatat consequat mollit.', 'sint. Officia culpa pariatur laborum nostrud cupidatat consequat mollit.'
)
], true), $items);
}
public function testUsedNamespace()
{
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_namespace.php');
$this->loader->open($completionUri, file_get_contents($completionUri));
$items = $this->textDocument->completion(
new TextDocumentIdentifier($completionUri),
new Position(6, 16)
)->wait();
$this->assertCompletionsListSubset(new CompletionList([
new CompletionItem(
'InnerClass',
CompletionItemKind::CLASS_,
'TestNamespace\\InnerNamespace',
null, null,
null, null,
'TestClass' null,
'AliasNamespace\\InnerClass'
) )
], true), $items); ], true), $items);
} }

View File

@ -32,7 +32,9 @@ class DocumentSymbolTest extends ServerTestCase
new SymbolInformation('ChildClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\ChildClass'), 'TestNamespace'), new SymbolInformation('ChildClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\ChildClass'), 'TestNamespace'),
new SymbolInformation('Example', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\Example'), 'TestNamespace'), new SymbolInformation('Example', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\Example'), 'TestNamespace'),
new SymbolInformation('__construct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__construct'), 'TestNamespace\\Example'), new SymbolInformation('__construct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__construct'), 'TestNamespace\\Example'),
new SymbolInformation('__destruct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__destruct'), 'TestNamespace\\Example') new SymbolInformation('__destruct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__destruct'), 'TestNamespace\\Example'),
new SymbolInformation('TestNamespace\\InnerNamespace', SymbolKind::NAMESPACE, $this->getDefinitionLocation('TestNamespace\\InnerNamespace'), 'TestNamespace'),
new SymbolInformation('InnerClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\InnerNamespace\\InnerClass'), 'TestNamespace\\InnerNamespace'),
], $result); ], $result);
// @codingStandardsIgnoreEnd // @codingStandardsIgnoreEnd
} }

View File

@ -30,7 +30,7 @@ class SymbolTest extends ServerTestCase
// @codingStandardsIgnoreStart // @codingStandardsIgnoreStart
$this->assertEquals([ $this->assertEquals([
new SymbolInformation('TestNamespace', SymbolKind::NAMESPACE, new Location($referencesUri, new Range(new Position(2, 0), new Position(2, 24))), ''), new SymbolInformation('TestNamespace', SymbolKind::NAMESPACE, new Location($referencesUri, new Range(new Position(2, 0), new Position(2, 24))), ''),
// Namespaced // Namespaced
new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TestNamespace\\TEST_CONST'), 'TestNamespace'), new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TestNamespace\\TEST_CONST'), 'TestNamespace'),
new SymbolInformation('TestClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\TestClass'), 'TestNamespace'), new SymbolInformation('TestClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\TestClass'), 'TestNamespace'),
@ -46,6 +46,8 @@ class SymbolTest extends ServerTestCase
new SymbolInformation('Example', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\Example'), 'TestNamespace'), new SymbolInformation('Example', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\Example'), 'TestNamespace'),
new SymbolInformation('__construct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__construct'), 'TestNamespace\\Example'), new SymbolInformation('__construct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__construct'), 'TestNamespace\\Example'),
new SymbolInformation('__destruct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__destruct'), 'TestNamespace\\Example'), new SymbolInformation('__destruct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__destruct'), 'TestNamespace\\Example'),
new SymbolInformation('TestNamespace\\InnerNamespace', SymbolKind::NAMESPACE, $this->getDefinitionLocation('TestNamespace\\InnerNamespace'), 'TestNamespace'),
new SymbolInformation('InnerClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\InnerNamespace\\InnerClass'), 'TestNamespace\\InnerNamespace'),
new SymbolInformation('whatever', SymbolKind::FUNCTION, $this->getDefinitionLocation('TestNamespace\\whatever()'), 'TestNamespace'), new SymbolInformation('whatever', SymbolKind::FUNCTION, $this->getDefinitionLocation('TestNamespace\\whatever()'), 'TestNamespace'),
// Global // Global
new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TEST_CONST'), ''), new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TEST_CONST'), ''),