diff --git a/fixtures/completion/used_namespace.php b/fixtures/completion/used_namespace.php index 7c82290..40a7e50 100644 --- a/fixtures/completion/used_namespace.php +++ b/fixtures/completion/used_namespace.php @@ -4,6 +4,7 @@ namespace Whatever; use TestNamespace\InnerNamespace as AliasNamespace; -AliasNamespace\I - class IDontShowUpInCompletion {} + +AliasNamespace\I; +AliasNamespace\; diff --git a/src/CompletionProvider.php b/src/CompletionProvider.php index 933b8e7..4d982d9 100644 --- a/src/CompletionProvider.php +++ b/src/CompletionProvider.php @@ -18,6 +18,13 @@ use Microsoft\PhpParser; use Microsoft\PhpParser\Node; use Microsoft\PhpParser\ResolvedName; use Generator; +use function LanguageServer\FqnUtilities\{ + nameConcat, + nameGetFirstPart, + nameGetParent, + nameStartsWith, + nameWithoutFirstPart +}; class CompletionProvider { @@ -280,224 +287,278 @@ class CompletionProvider // my_func| // MY_CONS| // MyCla| + // \MyCla| // The name Node under the cursor $nameNode = isset($creation) ? $creation->classTypeDesignator : $node; - $filterNameTokens = static function ($tokens) { - return array_values( - array_filter( - $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 = []; + if ($nameNode instanceof Node\QualifiedName) { + /** @var string The typed name. */ + $prefix = (string)PhpParser\ResolvedName::buildName($nameNode->nameParts, $nameNode->getFileContents()); + } else { + $prefix = $nameNode->getText($node->getFileContents()); } - /** Whether the prefix is qualified (contains at least one backslash) */ - $isQualified = $nameNode instanceof Node\QualifiedName && $nameNode->isQualifiedName(); - - /** Whether the prefix is fully qualified (begins with a backslash) */ - $isFullyQualified = $nameNode instanceof Node\QualifiedName && $nameNode->isFullyQualifiedName(); - - /** The closest NamespaceDefinition Node */ $namespaceNode = $node->getNamespaceDefinition(); + /** @var string The current namespace without a leading backslash. */ + $currentNamespace = $namespaceNode === null ? '' : $namespaceNode->name->getText(); - // Get the namespace use statements - // TODO: use function statements, use const statements + /** @var bool Whether the prefix is qualified (contains at least one backslash) */ + $isFullyQualified = false; - /** @var string[] $aliases A map from local alias to fully qualified name */ - list($aliases,,) = $node->getImportTablesForCurrentScope(); + /** @var bool Whether the prefix is qualified (contains at least one backslash) */ + $isQualified = false; - /** @var array Array of [fqn=string, requiresRoaming=bool] the prefix may represent. */ - $possibleFqns = []; + if ($nameNode instanceof Node\QualifiedName) { + $isFullyQualified = $nameNode->isFullyQualifiedName(); + $isQualified = $nameNode->isQualifiedName(); + } + + /** @var bool Whether we are in a new expression */ + $isCreation = isset($creation); + + /** @var array Import (use) tables */ + $importTables = $node->getImportTablesForCurrentScope(); 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 + // \Prefix\Goes\Here| - Only return completions from the root namespace. + /** @var $items \Generator|CompletionItem[] Generator yielding CompletionItems indexed by their FQN */ + $items = $this->getCompletionsForFqnPrefix($prefix, $isCreation, false); + } else if ($isQualified) { + // Prefix\Goes\Here| + $items = $this->getPartiallyQualifiedCompletions( + $prefix, + $currentNamespace, + $importTables, + $isCreation ); - $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]; + // PrefixGoesHere| + $items = $this->getUnqualifiedCompletions($prefix, $currentNamespace, $importTables, $isCreation); } - $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) { - foreach ($aliases as $alias => $fqn) { - // Suggest symbols that have been `use`d and match the prefix - if (substr($alias, 0, $prefixLen) === $prefixStr - && ($def = $this->index->getDefinition((string)$fqn))) { - $list->items[] = CompletionItem::fromDefinition($def); - } + $list->items = array_values(iterator_to_array($items)); + foreach ($list->items as $item) { + // Remove () + if (is_string($item->insertText) && substr($item->insertText, strlen($item->insertText) - 2) === '()') { + $item->insertText = substr($item->insertText, 0, strlen($item->insertText) - 2); } } - 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) { - if (isset($creation) && !$def->canBeInstantiated) { - // Only suggest classes for `new` - continue; - } - if ($requiresRoaming && !$def->roamed) { - continue; - } - - if (substr($fqn, 0, $fqnToSearchLen) === $fqnToSearch) { - $item = CompletionItem::fromDefinition($def); - if (($aliasMatch = $this->tryMatchAlias($aliases, $fqn)) !== null) { - $item->insertText = $aliasMatch; - } else if ($namespaceNode && (empty($prefix) || $requiresRoaming)) { - // Insert the global FQN with a leading backslash. - // For empty prefix: Assume that the user wants an FQN. They have not - // 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; - } else { - // Insert the FQN without a leading backslash - $item->insertText = $fqn; - } - // Don't insert the parenthesis for functions - // TODO return a snippet and put the cursor inside - if (substr($item->insertText, -2) === '()') { - $item->insertText = substr($item->insertText, 0, -2); - } - $list->items[] = $item; - } - } - } - - // Suggest keywords - if (!$isQualified && !isset($creation)) { - foreach (self::KEYWORDS as $keyword) { - if (substr($keyword, 0, $prefixLen) === $prefixStr) { - $item = new CompletionItem($keyword, CompletionItemKind::KEYWORD); - $item->insertText = $keyword; - $list->items[] = $item; - } - } - } } - 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; + private function getPartiallyQualifiedCompletions( + string $prefix, + string $currentNamespace, + array $importTables, + bool $requireCanBeInstantiated + ): \Generator { + // If the first part of the partially qualified name matches a namespace alias, + // only definitions below that alias can be completed. + list($namespaceAliases,,) = $importTables; + $prefixFirstPart = nameGetFirstPart($prefix); + $foundAlias = $foundAliasFqn = null; + foreach ($namespaceAliases as $alias => $aliasFqn) { + if (strcasecmp($prefixFirstPart, $alias) === 0) { + $foundAlias = $alias; + $foundAliasFqn = (string)$aliasFqn; + break; } } - if ($aliasMatch === null) { - return null; + if ($foundAlias !== null) { + yield from $this->getCompletionsFromAliasedNamespace( + $prefix, + $foundAlias, + $foundAliasFqn, + $requireCanBeInstantiated + ); + } else { + yield from $this->getCompletionsForFqnPrefix( + nameConcat($currentNamespace, $prefix), + $requireCanBeInstantiated, + false + ); } - - $fqnNoAlias = array_slice($fullyQualifiedName, $aliasMatchLength); - return join('\\', array_merge([$aliasMatch], $fqnNoAlias)); } /** - * Tries to convert a partially qualified name to an FQN using aliases. + * Yields completions for non-qualified global names. * - * Example: + * Yields + * - Aliased classes + * - Completions from current namespace + * - Roamed completions from the global namespace (when not creating and not already in root NS) + * - PHP keywords (when not creating) * - * use Microsoft\PhpParser as TheParser; - * "TheParser\Node" will convert to "Microsoft\PhpParser\Node" + * @return \Generator|CompletionItem[] + * Yields CompletionItems + */ + private function getUnqualifiedCompletions( + string $prefix, + string $currentNamespace, + array $importTables, + bool $requireCanBeInstantiated + ): \Generator { + // Aliases + list($namespaceAliases,,) = $importTables; + // use Foo\Bar + yield from $this->getCompletionsForAliases( + $prefix, + $namespaceAliases, + $requireCanBeInstantiated + ); + + // Completions from the current namespace + yield from $this->getCompletionsForFqnPrefix( + nameConcat($currentNamespace, $prefix), + $requireCanBeInstantiated, + false + ); + + if ($currentNamespace !== '' && $prefix === '') { + // Get additional suggestions from the global namespace. + // When completing e.g. for new |, suggest \DateTime + yield from $this->getCompletionsForFqnPrefix('', $requireCanBeInstantiated, true); + } + + if (!$requireCanBeInstantiated) { + if ($currentNamespace !== '' && $prefix !== '') { + // Roamed definitions (i.e. global constants and functions). The prefix is checked against '', since + // in that case global completions have already been provided (including non-roamed definitions.) + yield from $this->getRoamedCompletions($prefix); + } + + // Lastly and least importantly, suggest keywords. + yield from $this->getCompletionsForKeywords($prefix); + } + } + + /** + * Gets completions for prefixes of fully qualified names in their parent namespace. * - * @param \Microsoft\PhpParser\ResolvedName[] $aliases - * Aliases available in the scope of resolution. Keyed by alias. - * @param string[] $partiallyQualifiedName - **/ - private function tryApplyAlias( + * @param string $prefix Prefix to complete for. Fully qualified. + * @param bool $requireCanBeInstantiated If set, only return classes. + * @param bool $insertFullyQualified If set, return completion with the leading \ inserted. + * @return \Generator|CompletionItem[] + * Yields CompletionItems. + */ + private function getCompletionsForFqnPrefix( + string $prefix, + bool $requireCanBeInstantiated, + bool $insertFullyQualified + ): \Generator { + $namespace = nameGetParent($prefix); + foreach ($this->index->getChildDefinitionsForFqn($namespace) as $fqn => $def) { + if ($requireCanBeInstantiated && !$def->canBeInstantiated) { + continue; + } + if (!nameStartsWith($fqn, $prefix)) { + continue; + } + $completion = CompletionItem::fromDefinition($def); + if ($insertFullyQualified) { + $completion->insertText = '\\' . $fqn; + } + yield $fqn => $completion; + } + } + + /** + * Gets completions for non-qualified names matching the start of an used class, function, or constant. + * + * @param string $prefix Non-qualified name being completed for + * @param QualifiedName[] $aliases Array of alias FQNs indexed by the alias. + * @return \Generator|CompletionItem[] + * Yields CompletionItems. + */ + private function getCompletionsForAliases( + string $prefix, array $aliases, - array $partiallyQualifiedName - ): ?array { - if (empty($partiallyQualifiedName)) { - return null; + bool $requireCanBeInstantiated + ): \Generator { + foreach ($aliases as $alias => $aliasFqn) { + if (!nameStartsWith($alias, $prefix)) { + continue; + } + $definition = $this->index->getDefinition((string)$aliasFqn); + if ($definition) { + if ($requireCanBeInstantiated && !$definition->canBeInstantiated) { + continue; + } + $completionItem = CompletionItem::fromDefinition($definition); + $completionItem->insertText = $alias; + yield (string)$aliasFqn => $completionItem; + } } - $head = $partiallyQualifiedName[0]; - $tail = array_slice($partiallyQualifiedName, 1); - if (!isset($aliases[$head])) { - return null; + } + + /** + * Gets completions for partially qualified names, where the first part is matched by an alias. + * + * @return \Generator|CompletionItem[] + * Yields CompletionItems. + */ + private function getCompletionsFromAliasedNamespace( + string $prefix, + string $alias, + string $aliasFqn, + bool $requireCanBeInstantiated + ): \Generator { + $prefixFirstPart = nameGetFirstPart($prefix); + // Matched alias. + $resolvedPrefix = nameConcat($aliasFqn, nameWithoutFirstPart($prefix)); + $completionItems = $this->getCompletionsForFqnPrefix( + $resolvedPrefix, + $requireCanBeInstantiated, + false + ); + // Convert FQNs in the CompletionItems so they are expressed in terms of the alias. + foreach ($completionItems as $fqn => $completionItem) { + /** @var string $fqn with the leading parts determined by the alias removed. Has the leading backslash. */ + $nameWithoutAliasedPart = substr($fqn, strlen($aliasFqn)); + $completionItem->insertText = $alias . $nameWithoutAliasedPart; + yield $fqn => $completionItem; + } + } + + /** + * Gets completions for globally defined functions and constants (i.e. symbols which may be used anywhere) + * + * @return \Generator|CompletionItem[] + * Yields CompletionItems. + */ + private function getRoamedCompletions(string $prefix): \Generator + { + foreach ($this->index->getChildDefinitionsForFqn('') as $fqn => $def) { + if (!$def->roamed || !nameStartsWith($fqn, $prefix)) { + continue; + } + $completionItem = CompletionItem::fromDefinition($def); + // Second-guessing the user here - do not trust roaming to work. If the same symbol is + // inserted in the current namespace, the code will stop working. + $completionItem->insertText = '\\' . $fqn; + yield $fqn => $completionItem; + } + } + + /** + * Completes PHP keywords. + * + * @return \Generator|CompletionItem[] + * Yields CompletionItems. + */ + private function getCompletionsForKeywords(string $prefix): \Generator + { + foreach (self::KEYWORDS as $keyword) { + if (nameStartsWith($keyword, $prefix)) { + $item = new CompletionItem($keyword, CompletionItemKind::KEYWORD); + $item->insertText = $keyword; + yield $keyword => $item; + } } - return array_merge($aliases[$head]->getNameParts(), $tail); } /** diff --git a/src/FqnUtilities.php b/src/FqnUtilities.php index b5d01a9..21f75cb 100644 --- a/src/FqnUtilities.php +++ b/src/FqnUtilities.php @@ -29,3 +29,91 @@ function getFqnsFromType($type): array } return $fqns; } + +/** + * Returns parent of an FQN. + * + * getFqnParent('') === '' + * getFqnParent('\\') === '' + * getFqnParent('\A') === '' + * getFqnParent('A') === '' + * getFqnParent('\A\') === '\A' // Empty trailing name is considered a name. + * + * @return string + */ +function nameGetParent(string $name): ?string +{ + if ($name === '') { // Special-case handling for the root namespace. + return ''; + } + $parts = explode('\\', $name); + array_pop($parts); + return implode('\\', $parts); +} + +/** + * Concatenates two names. + * + * nameConcat('\Foo\Bar', 'Baz') === '\Foo\Bar\Baz' + * nameConcat('\Foo\Bar\\', '\Baz') === '\Foo\Bar\Baz' + * nameConcat('\\', 'Baz') === '\Baz' + * nameConcat('', 'Baz') === 'Baz' + * + * @return string + */ +function nameConcat(string $a, string $b): string +{ + if ($a === '') { + return $b; + } + $a = rtrim($a, '\\'); + $b = ltrim($b, '\\'); + return "$a\\$b"; +} + +/** + * Returns the first component of $name. + * + * nameGetFirstPart('Foo\Bar') === 'Foo' + * nameGetFirstPart('\Foo\Bar') === 'Foo' + * nameGetFirstPart('') === '' + * nameGetFirstPart('\') === '' + */ +function nameGetFirstPart(string $name): string +{ + $parts = explode('\\', $name, 3); + if ($parts[0] === '' && count($parts) > 1) { + return $parts[1]; + } else { + return $parts[0]; + } +} + +/** + * Removes the first component of $name. + * + * nameWithoutFirstPart('Foo\Bar') === 'Bar' + * nameWithoutFirstPart('\Foo\Bar') === 'Bar' + * nameWithoutFirstPart('') === '' + * nameWithoutFirstPart('\') === '' + */ +function nameWithoutFirstPart(string $name): string +{ + $parts = explode('\\', $name, 3); + if ($parts[0] === '') { + array_shift($parts); + } + array_shift($parts); + return implode('\\', $parts); +} + +/** + * @param string $name Name to match against + * @param string $prefix Prefix $name has to starts with + * @return bool + */ +function nameStartsWith(string $name, string $prefix): bool +{ + return strlen($name) >= strlen($prefix) + && strncmp($name, $prefix, strlen($prefix)) === 0; +} diff --git a/tests/Server/TextDocument/CompletionTest.php b/tests/Server/TextDocument/CompletionTest.php index 26a04aa..de33f9f 100644 --- a/tests/Server/TextDocument/CompletionTest.php +++ b/tests/Server/TextDocument/CompletionTest.php @@ -47,6 +47,9 @@ class CompletionTest extends TestCase $this->textDocument = new Server\TextDocument($this->loader, $definitionResolver, $client, $projectIndex); } + /** + * Tests completion at `$obj->t|` + */ public function testPropertyAndMethodWithPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/property_with_prefix.php'); @@ -71,6 +74,9 @@ class CompletionTest extends TestCase ], true), $items); } + /** + * Tests completion at `public function a() { tes| }` + */ public function testGlobalFunctionInsideNamespaceAndClass() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/inside_namespace_and_method.php'); @@ -92,6 +98,9 @@ class CompletionTest extends TestCase ], true), $items); } + /** + * Tests completion at `$obj->|` + */ public function testPropertyAndMethodWithoutPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/property.php'); @@ -116,6 +125,9 @@ class CompletionTest extends TestCase ], true), $items); } + /** + * Tests completion at `$|` when variables are defined + */ public function testVariable() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/variable.php'); @@ -148,6 +160,9 @@ class CompletionTest extends TestCase ], true), $items); } + /** + * Tests completion at `$p|` when variables are defined + */ public function testVariableWithPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/variable_with_prefix.php'); @@ -170,6 +185,9 @@ class CompletionTest extends TestCase ], true), $items); } + /** + * Tests completion at `new|` when in a namespace and have used variables. + */ public function testNewInNamespace() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_new.php'); @@ -213,11 +231,17 @@ class CompletionTest extends TestCase '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" . '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); } + /** + * Tests completion at `TestC|` with `use TestNamespace\TestClass` + */ public function testUsedClass() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_class.php'); @@ -236,32 +260,74 @@ class CompletionTest extends TestCase '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" . '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); + + $this->assertCompletionsListDoesNotContainLabel('OtherClass', $items); + $this->assertCompletionsListDoesNotContainLabel('TestInterface', $items); } - public function testUsedNamespace() + /** + * Tests completion at `AliasNamespace\I|` with `use TestNamespace\InnerNamespace as AliasNamespace` + */ + public function testUsedNamespaceWithPrefix() { $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) + new Position(8, 16) )->wait(); - $this->assertCompletionsListSubset(new CompletionList([ - new CompletionItem( - 'InnerClass', - CompletionItemKind::CLASS_, - 'TestNamespace\\InnerNamespace', - null, - null, - null, - 'AliasNamespace\\InnerClass' - ) - ], true), $items); + $this->assertEquals( + new CompletionList([ + new CompletionItem( + 'InnerClass', + CompletionItemKind::CLASS_, + 'TestNamespace\\InnerNamespace', + null, + null, + null, + 'AliasNamespace\\InnerClass' + ) + ], true), + $items + ); } + /** + * Tests completion at `AliasNamespace\|` with `use TestNamespace\InnerNamespace as AliasNamespace` + */ + public function testUsedNamespaceWithoutPrefix() + { + $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(9, 15) + )->wait(); + $this->assertEquals( + new CompletionList([ + new CompletionItem( + 'InnerClass', + CompletionItemKind::CLASS_, + 'TestNamespace\InnerNamespace', + null, + null, + null, + 'AliasNamespace\InnerClass' + ), + ], true), + $items + ); + } + + /** + * Tests completion at `TestClass::$st|` + */ public function testStaticPropertyWithPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static_property_with_prefix.php'); @@ -283,6 +349,9 @@ class CompletionTest extends TestCase ], true), $items); } + /** + * Tests completion at `TestClass::|` + */ public function testStaticWithoutPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static.php'); @@ -316,6 +385,9 @@ class CompletionTest extends TestCase ], true), $items); } + /** + * Tests completion at `TestClass::st|` + */ public function testStaticMethodWithPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static_method_with_prefix.php'); @@ -325,21 +397,6 @@ class CompletionTest extends TestCase new Position(2, 13) )->wait(); $this->assertCompletionsListSubset(new CompletionList([ - new CompletionItem( - 'TEST_CLASS_CONST', - CompletionItemKind::VARIABLE, - 'int', - 'Anim labore veniam consectetur laboris minim quis aute aute esse nulla ad.' - ), - new CompletionItem( - 'staticTestProperty', - CompletionItemKind::PROPERTY, - '\TestClass[]', - 'Lorem excepteur officia sit anim velit veniam enim.', - null, - null, - '$staticTestProperty' - ), new CompletionItem( 'staticTestMethod', CompletionItemKind::METHOD, @@ -349,6 +406,9 @@ class CompletionTest extends TestCase ], true), $items); } + /** + * Tests completion at `TestClass::TE` at the root level. + */ public function testClassConstWithPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/class_const_with_prefix.php'); @@ -363,25 +423,13 @@ class CompletionTest extends TestCase CompletionItemKind::VARIABLE, 'int', 'Anim labore veniam consectetur laboris minim quis aute aute esse nulla ad.' - ), - new CompletionItem( - 'staticTestProperty', - CompletionItemKind::PROPERTY, - '\TestClass[]', - 'Lorem excepteur officia sit anim velit veniam enim.', - null, - null, - '$staticTestProperty' - ), - new CompletionItem( - 'staticTestMethod', - CompletionItemKind::METHOD, - 'mixed', - 'Do magna consequat veniam minim proident eiusmod incididunt aute proident.' ) ], true), $items); } + /** + * Test completion at `\TestC|` in a namespace + */ public function testFullyQualifiedClass() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/fully_qualified_class.php'); @@ -400,14 +448,18 @@ class CompletionTest extends TestCase '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" . 'consequat sunt culpa exercitation pariatur id reprehenderit nisi incididunt Lorem' . "\n" . - 'sint. Officia culpa pariatur laborum nostrud cupidatat consequat mollit.', - null, - null, - 'TestClass' + 'sint. Officia culpa pariatur laborum nostrud cupidatat consequat mollit.' ) ], true), $items); + // Assert that all results are non-namespaced. + foreach ($items->items as $item) { + $this->assertSame($item->detail, null); + } } + /** + * Tests completion at `cl|` at root level + */ public function testKeywords() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/keywords.php'); @@ -422,6 +474,9 @@ class CompletionTest extends TestCase ], true), $items); } + /** + * Tests completion in an empty file + */ public function testHtmlWithoutPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html.php'); @@ -444,6 +499,9 @@ class CompletionTest extends TestCase ], true), $items); } + /** + * Tests completion in `<|` when not within `assertEquals(new CompletionList([], true), $items); } + /** + * Tests completion in `<|` when not within `assertEquals(new CompletionList([], true), $items); } + /** + * Tests completion at `<|` when not within `assertCompletionsListSubset(new CompletionList([ new CompletionItem( 'SomeNamespace', - CompletionItemKind::MODULE, - null, - null, - null, - null, - 'SomeNamespace' + CompletionItemKind::MODULE ) ], true), $items); } - public function testBarePhp() + /** + * Tests completion at `echo $ab|` at the root level. + */ + public function testBarePhpVariable() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/bare_php.php'); $this->loader->open($completionUri, file_get_contents($completionUri)); @@ -776,6 +844,16 @@ class CompletionTest extends TestCase $this->assertEquals($subsetList->isIncomplete, $list->isIncomplete); } + private function assertCompletionsListDoesNotContainLabel(string $label, CompletionList $list) + { + foreach ($list->items as $item) { + $this->assertNotSame($label, $item->label, "Completion list should not contain $label."); + } + } + + /** + * Tests completion for `$this->|` + */ public function testThisWithoutPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/this.php'); @@ -812,6 +890,9 @@ class CompletionTest extends TestCase ], true), $items); } + /** + * Tests completion at `$this->m|` + */ public function testThisWithPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/this_with_prefix.php'); @@ -860,6 +941,9 @@ class CompletionTest extends TestCase ], true), $items); } + /** + * Tests completion at `$this->foo()->q|` + */ public function testThisReturnValue() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/this_return_value.php');