diff --git a/benchmarks/completion.php b/benchmarks/completion.php new file mode 100644 index 00000000..2e736d76 --- /dev/null +++ b/benchmarks/completion.php @@ -0,0 +1,89 @@ +setLogger($logger); +$xdebugHandler->check(); +unset($xdebugHandler); + +$totalSize = 0; + +$framework = "symfony"; + +$iterator = new RecursiveDirectoryIterator(__DIR__ . "/../validation/frameworks/$framework"); +$testProviderArray = array(); + +foreach (new RecursiveIteratorIterator($iterator) as $file) { + if (strpos((string)$file, ".php") !== false) { + $totalSize += $file->getSize(); + $testProviderArray[] = $file->getRealPath(); + } +} + +if (count($testProviderArray) === 0) { + throw new Exception("ERROR: Validation testsuite frameworks not found - run `git submodule update --init --recursive` to download."); +} + +$index = new Index; +$definitionResolver = new DefinitionResolver($index); +$completionProvider = new CompletionProvider($definitionResolver, $index); +$docBlockFactory = DocBlockFactory::createInstance(); +$completionFile = realpath(__DIR__ . '/../validation/frameworks/symfony/src/Symfony/Component/HttpFoundation/Request.php'); +$parser = new PhpParser\Parser(); +$completionDocument = null; + +echo "Indexing $framework" . PHP_EOL; + +foreach ($testProviderArray as $idx => $testCaseFile) { + if (filesize($testCaseFile) > 100000) { + continue; + } + if ($idx % 100 === 0) { + echo $idx . '/' . count($testProviderArray) . PHP_EOL; + } + + $fileContents = file_get_contents($testCaseFile); + + try { + $d = new PhpDocument($testCaseFile, $fileContents, $index, $parser, $docBlockFactory, $definitionResolver); + if ($testCaseFile === $completionFile) { + $completionDocument = $d; + } + } catch (\Throwable $e) { + echo $e->getMessage() . PHP_EOL; + continue; + } +} + +echo "Getting completion". PHP_EOL; + +// Completion in $this->|request = new ParameterBag($request); +$start = microtime(true); +$list = $completionProvider->provideCompletion($completionDocument, new Position(274, 15)); +$end = microtime(true); +echo 'Time ($this->|): ' . ($end - $start) . 's' . PHP_EOL; +echo count($list->items) . ' completion items' . PHP_EOL; + +// Completion in $this->request = new| ParameterBag($request); +// (this only finds ParameterBag though.) +$start = microtime(true); +$list = $completionProvider->provideCompletion($completionDocument, new Position(274, 28)); +$end = microtime(true); +echo 'Time (new|): ' . ($end - $start) . 's' . PHP_EOL; +echo count($list->items) . ' completion items' . PHP_EOL; diff --git a/Performance.php b/benchmarks/parsing.php similarity index 77% rename from Performance.php rename to benchmarks/parsing.php index 4d76d38f..516de40d 100644 --- a/Performance.php +++ b/benchmarks/parsing.php @@ -1,23 +1,31 @@ setLogger($logger); +$xdebugHandler->check(); +unset($xdebugHandler); + $totalSize = 0; $frameworks = ["drupal", "wordpress", "php-language-server", "tolerant-php-parser", "math-php", "symfony", "codeigniter", "cakephp"]; foreach($frameworks as $framework) { - $iterator = new RecursiveDirectoryIterator(__DIR__ . "/validation/frameworks/$framework"); + $iterator = new RecursiveDirectoryIterator(__DIR__ . "/../validation/frameworks/$framework"); $testProviderArray = array(); foreach (new RecursiveIteratorIterator($iterator) as $file) { @@ -37,8 +45,8 @@ if (filesize($testCaseFile) > 10000) { continue; } - if ($idx % 1000 === 0) { - echo "$idx\n"; + if ($idx % 500 === 0) { + echo $idx . '/' . count($testProviderArray) . PHP_EOL; } $fileContents = file_get_contents($testCaseFile); diff --git a/fixtures/completion/used_namespace.php b/fixtures/completion/used_namespace.php new file mode 100644 index 00000000..40a7e50d --- /dev/null +++ b/fixtures/completion/used_namespace.php @@ -0,0 +1,10 @@ +getNodeAtPosition($pos); @@ -237,16 +248,14 @@ public function provideCompletion(PhpDocument $doc, Position $pos, CompletionCon $this->definitionResolver->resolveExpressionNodeToType($node->dereferencableExpression) ); - // Add the object access operator to only get members of all parents - $prefixes = []; - foreach ($this->expandParentFqns($fqns) as $prefix) { - $prefixes[] = $prefix . '->'; - } - - // Collect all definitions that match any of the prefixes - foreach ($this->index->getDefinitions() as $fqn => $def) { - foreach ($prefixes as $prefix) { - if (substr($fqn, 0, strlen($prefix)) === $prefix && $def->isMember) { + // The FQNs of the symbol and its parents (eg the implemented interfaces) + foreach ($this->expandParentFqns($fqns) as $parentFqn) { + // Add the object access operator to only get members of all parents + $prefix = $parentFqn . '->'; + $prefixLen = strlen($prefix); + // Collect fqn definitions + foreach ($this->index->getChildDefinitionsForFqn($parentFqn) as $fqn => $def) { + if (substr($fqn, 0, $prefixLen) === $prefix && $def->isMember) { $list->items[] = CompletionItemFactory::fromDefinition($def); } } @@ -270,16 +279,14 @@ public function provideCompletion(PhpDocument $doc, Position $pos, CompletionCon $classType = $this->definitionResolver->resolveExpressionNodeToType($scoped->scopeResolutionQualifier) ); - // Append :: operator to only get static members of all parents - $prefixes = []; - foreach ($this->expandParentFqns($fqns) as $prefix) { - $prefixes[] = $prefix . '::'; - } - - // Collect all definitions that match any of the prefixes - foreach ($this->index->getDefinitions() as $fqn => $def) { - foreach ($prefixes as $prefix) { - if (substr(strtolower($fqn), 0, strlen($prefix)) === strtolower($prefix) && $def->isMember) { + // The FQNs of the symbol and its parents (eg the implemented interfaces) + foreach ($this->expandParentFqns($fqns) as $parentFqn) { + // Append :: operator to only get static members of all parents + $prefix = strtolower($parentFqn . '::'); + $prefixLen = strlen($prefix); + // Collect fqn definitions + foreach ($this->index->getChildDefinitionsForFqn($parentFqn) as $fqn => $def) { + if (substr(strtolower($fqn), 0, $prefixLen) === $prefix && $def->isMember) { $list->items[] = CompletionItemFactory::fromDefinition($def); } } @@ -297,114 +304,278 @@ public function provideCompletion(PhpDocument $doc, Position $pos, CompletionCon // my_func| // MY_CONS| // MyCla| + // \MyCla| // The name Node under the cursor $nameNode = isset($creation) ? $creation->classTypeDesignator : $node; - /** The typed name */ - $prefix = $nameNode instanceof Node\QualifiedName - ? (string)PhpParser\ResolvedName::buildName($nameNode->nameParts, $nameNode->getFileContents()) - : $nameNode->getText($node->getFileContents()); - $prefixLen = strlen($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(); + $namespaceNode = $node->getNamespaceDefinition(); + /** @var string The current namespace without a leading backslash. */ + $currentNamespace = $namespaceNode === null ? '' : $namespaceNode->name->getText(); - /** Whether the prefix is fully qualified (begins with a backslash) */ - $isFullyQualified = $nameNode instanceof Node\QualifiedName && $nameNode->isFullyQualifiedName(); + /** @var bool Whether the prefix is qualified (contains at least one backslash) */ + $isFullyQualified = false; - /** The closest NamespaceDefinition Node */ - $namespaceNode = $node->getNamespaceDefinition(); + /** @var bool Whether the prefix is qualified (contains at least one backslash) */ + $isQualified = false; - /** @var string The name of the namespace */ - $namespacedPrefix = null; - if ($namespaceNode) { - $namespacedPrefix = (string)PhpParser\ResolvedName::buildName($namespaceNode->name->nameParts, $node->getFileContents()) . '\\' . $prefix; - $namespacedPrefixLen = strlen($namespacedPrefix); + if ($nameNode instanceof Node\QualifiedName) { + $isFullyQualified = $nameNode->isFullyQualifiedName(); + $isQualified = $nameNode->isQualifiedName(); } - // Get the namespace use statements - // TODO: use function statements, use const statements + /** @var bool Whether we are in a new expression */ + $isCreation = isset($creation); + + /** @var array Import (use) tables */ + $importTables = $node->getImportTablesForCurrentScope(); + + if ($isFullyQualified) { + // \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 + ); + } else { + // PrefixGoesHere| + $items = $this->getUnqualifiedCompletions($prefix, $currentNamespace, $importTables, $isCreation); + } - /** @var string[] $aliases A map from local alias to fully qualified name */ - list($aliases,,) = $node->getImportTablesForCurrentScope(); + $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, -2); + } + } - foreach ($aliases as $alias => $name) { - $aliases[$alias] = (string)$name; + } + return $list; + } + + 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 there is a prefix that does not start with a slash, suggest `use`d symbols - if ($prefix && !$isFullyQualified) { - foreach ($aliases as $alias => $fqn) { - // Suggest symbols that have been `use`d and match the prefix - if (substr($alias, 0, $prefixLen) === $prefix && ($def = $this->index->getDefinition($fqn))) { - $list->items[] = CompletionItemFactory::fromDefinition($def); - } - } + if ($foundAlias !== null) { + yield from $this->getCompletionsFromAliasedNamespace( + $prefix, + $foundAlias, + $foundAliasFqn, + $requireCanBeInstantiated + ); + } else { + yield from $this->getCompletionsForFqnPrefix( + nameConcat($currentNamespace, $prefix), + $requireCanBeInstantiated, + false + ); + } + } + + /** + * Yields completions for non-qualified global names. + * + * 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) + * + * @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); } - // Suggest global symbols that either - // - start with the current namespace + prefix, if the Name node is not fully qualified - // - start with just the prefix, if the Name node is fully qualified - foreach ($this->index->getDefinitions() as $fqn => $def) { + // Lastly and least importantly, suggest keywords. + yield from $this->getCompletionsForKeywords($prefix); + } + } - $fqnStartsWithPrefix = substr($fqn, 0, $prefixLen) === $prefix; + /** + * Gets completions for prefixes of fully qualified names in their parent namespace. + * + * @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 = CompletionItemFactory::fromDefinition($def); + if ($insertFullyQualified) { + $completion->insertText = '\\' . $fqn; + } + yield $fqn => $completion; + } + } - if ( - // Exclude methods, properties etc. - !$def->isMember - && ( - !$prefix - || ( - // Either not qualified, but a matching prefix with global fallback - ($def->roamed && !$isQualified && $fqnStartsWithPrefix) - // Or not in a namespace or a fully qualified name or AND matching the prefix - || ((!$namespaceNode || $isFullyQualified) && $fqnStartsWithPrefix) - // Or in a namespace, not fully qualified and matching the prefix + current namespace - || ( - $namespaceNode - && !$isFullyQualified - && substr($fqn, 0, $namespacedPrefixLen) === $namespacedPrefix - ) - ) - ) - // Only suggest classes for `new` - && (!isset($creation) || $def->canBeInstantiated) - ) { - $item = CompletionItemFactory::fromDefinition($def); - // Find the shortest name to reference the symbol - if ($namespaceNode && ($alias = array_search($fqn, $aliases, true)) !== false) { - // $alias is the name under which this definition is aliased in the current namespace - $item->insertText = $alias; - } else if ($namespaceNode && !($prefix && $isFullyQualified)) { - // Insert the global FQN with leading backslash - $item->insertText = '\\' . $fqn; - } else { - // Insert the FQN without leading backlash - $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; + /** + * 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, + 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 = CompletionItemFactory::fromDefinition($definition); + $completionItem->insertText = $alias; + yield (string)$aliasFqn => $completionItem; } + } + } - // If not a class instantiation, also suggest keywords - if (!isset($creation)) { - foreach (self::KEYWORDS as $keyword) { - if (substr($keyword, 0, $prefixLen) === $prefix) { - $item = new CompletionItem($keyword, CompletionItemKind::KEYWORD); - $item->insertText = $keyword; - $list->items[] = $item; - } - } + /** + * 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 = CompletionItemFactory::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; } + } - return $list; + /** + * 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; + } + } } /** @@ -473,8 +644,9 @@ private function suggestVariablesAtNode(Node $node, string $namePrefix = ''): ar } } - if ($level instanceof Node\Expression\AnonymousFunctionCreationExpression && $level->anonymousFunctionUseClause !== null && - $level->anonymousFunctionUseClause->useVariableNameList !== null) { + if ($level instanceof Node\Expression\AnonymousFunctionCreationExpression + && $level->anonymousFunctionUseClause !== null + && $level->anonymousFunctionUseClause->useVariableNameList !== null) { foreach ($level->anonymousFunctionUseClause->useVariableNameList->getValues() as $use) { $useName = $use->getName(); if (empty($namePrefix) || strpos($useName, $namePrefix) !== false) { diff --git a/src/DefinitionResolver.php b/src/DefinitionResolver.php index 3a4f3785..f3d6fa10 100644 --- a/src/DefinitionResolver.php +++ b/src/DefinitionResolver.php @@ -1233,7 +1233,13 @@ public static function getDefinedFqn($node) if ( $node instanceof PhpParser\ClassLike ) { - return (string) $node->getNamespacedName(); + $className = (string)$node->getNamespacedName(); + // An (invalid) class declaration without a name will have an empty string as name, + // but should not define an FQN + if ($className === '') { + return null; + } + return $className; } // INPUT OUTPUT: diff --git a/src/FqnUtilities.php b/src/FqnUtilities.php index fcd60273..57e37761 100644 --- a/src/FqnUtilities.php +++ b/src/FqnUtilities.php @@ -28,3 +28,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/src/Index/AbstractAggregateIndex.php b/src/Index/AbstractAggregateIndex.php index 5377c3a4..8c8c95a1 100644 --- a/src/Index/AbstractAggregateIndex.php +++ b/src/Index/AbstractAggregateIndex.php @@ -99,20 +99,29 @@ public function isStaticComplete(): bool } /** - * Returns an associative array [string => Definition] that maps fully qualified symbol names - * to Definitions + * Returns a Generator providing an associative array [string => Definition] + * that maps fully qualified symbol names to Definitions (global or not) * - * @return Definition[] + * @return \Generator yields Definition */ - public function getDefinitions(): array + public function getDefinitions(): \Generator { - $defs = []; foreach ($this->getIndexes() as $index) { - foreach ($index->getDefinitions() as $fqn => $def) { - $defs[$fqn] = $def; - } + yield from $index->getDefinitions(); + } + } + + /** + * Returns a Generator that yields all the direct child Definitions of a given FQN + * + * @param string $fqn + * @return \Generator yields Definition + */ + public function getChildDefinitionsForFqn(string $fqn): \Generator + { + foreach ($this->getIndexes() as $index) { + yield from $index->getChildDefinitionsForFqn($fqn); } - return $defs; } /** @@ -132,19 +141,15 @@ public function getDefinition(string $fqn, bool $globalFallback = false) } /** - * Returns all URIs in this index that reference a symbol + * Returns a Generator providing all URIs in this index that reference a symbol * * @param string $fqn The fully qualified name of the symbol - * @return string[] + * @return \Generator yields string */ - public function getReferenceUris(string $fqn): array + public function getReferenceUris(string $fqn): \Generator { - $refs = []; foreach ($this->getIndexes() as $index) { - foreach ($index->getReferenceUris($fqn) as $ref) { - $refs[] = $ref; - } + yield from $index->getReferenceUris($fqn); } - return $refs; } } diff --git a/src/Index/Index.php b/src/Index/Index.php index 9cb975e5..0c8e3e98 100644 --- a/src/Index/Index.php +++ b/src/Index/Index.php @@ -15,14 +15,26 @@ class Index implements ReadableIndex, \Serializable use EmitterTrait; /** - * An associative array that maps fully qualified symbol names to Definitions + * An associative array that maps splitted fully qualified symbol names + * to definitions, eg : + * [ + * 'Psr' => [ + * '\Log' => [ + * '\LoggerInterface' => [ + * '' => $def1, // definition for 'Psr\Log\LoggerInterface' which is non-member + * '->log()' => $def2, // definition for 'Psr\Log\LoggerInterface->log()' which is a member + * ], + * ], + * ], + * ] * - * @var Definition[] + * @var array */ private $definitions = []; /** - * An associative array that maps fully qualified symbol names to arrays of document URIs that reference the symbol + * An associative array that maps fully qualified symbol names + * to arrays of document URIs that reference the symbol * * @var string[][] */ @@ -84,14 +96,46 @@ public function isStaticComplete(): bool } /** - * Returns an associative array [string => Definition] that maps fully qualified symbol names - * to Definitions + * Returns a Generator providing an associative array [string => Definition] + * that maps fully qualified symbol names to Definitions (global or not) * - * @return Definition[] + * @return \Generator yields Definition */ - public function getDefinitions(): array + public function getDefinitions(): \Generator { - return $this->definitions; + yield from $this->yieldDefinitionsRecursively($this->definitions); + } + + /** + * Returns a Generator that yields all the direct child Definitions of a given FQN + * + * @param string $fqn + * @return \Generator yields Definition + */ + public function getChildDefinitionsForFqn(string $fqn): \Generator + { + $parts = $this->splitFqn($fqn); + if ('' === end($parts)) { + // we want to return all the definitions in the given FQN, not only + // the one (non member) matching exactly the FQN. + array_pop($parts); + } + + $result = $this->getIndexValue($parts, $this->definitions); + if (!$result) { + return; + } + foreach ($result as $name => $item) { + // Don't yield the parent + if ($name === '') { + continue; + } + if ($item instanceof Definition) { + yield $fqn.$name => $item; + } elseif (is_array($item) && isset($item[''])) { + yield $fqn.$name => $item['']; + } + } } /** @@ -103,12 +147,17 @@ public function getDefinitions(): array */ public function getDefinition(string $fqn, bool $globalFallback = false) { - if (isset($this->definitions[$fqn])) { - return $this->definitions[$fqn]; + $parts = $this->splitFqn($fqn); + $result = $this->getIndexValue($parts, $this->definitions); + + if ($result instanceof Definition) { + return $result; } + if ($globalFallback) { $parts = explode('\\', $fqn); $fqn = end($parts); + return $this->getDefinition($fqn); } } @@ -122,7 +171,9 @@ public function getDefinition(string $fqn, bool $globalFallback = false) */ public function setDefinition(string $fqn, Definition $definition) { - $this->definitions[$fqn] = $definition; + $parts = $this->splitFqn($fqn); + $this->indexDefinition(0, $parts, $this->definitions, $definition); + $this->emit('definition-added'); } @@ -135,19 +186,23 @@ public function setDefinition(string $fqn, Definition $definition) */ public function removeDefinition(string $fqn) { - unset($this->definitions[$fqn]); + $parts = $this->splitFqn($fqn); + $this->removeIndexedDefinition(0, $parts, $this->definitions, $this->definitions); + unset($this->references[$fqn]); } /** - * Returns all URIs in this index that reference a symbol + * Returns a Generator providing all URIs in this index that reference a symbol * * @param string $fqn The fully qualified name of the symbol - * @return string[] + * @return \Generator yields string */ - public function getReferenceUris(string $fqn): array + public function getReferenceUris(string $fqn): \Generator { - return $this->references[$fqn] ?? []; + foreach ($this->references[$fqn] ?? [] as $uri) { + yield $uri; + } } /** @@ -204,6 +259,15 @@ public function removeReferenceUri(string $fqn, string $uri) public function unserialize($serialized) { $data = unserialize($serialized); + + if (isset($data['definitions'])) { + foreach ($data['definitions'] as $fqn => $definition) { + $this->setDefinition($fqn, $definition); + } + + unset($data['definitions']); + } + foreach ($data as $prop => $val) { $this->$prop = $val; } @@ -216,10 +280,164 @@ public function unserialize($serialized) public function serialize() { return serialize([ - 'definitions' => $this->definitions, + 'definitions' => iterator_to_array($this->getDefinitions()), 'references' => $this->references, 'complete' => $this->complete, 'staticComplete' => $this->staticComplete ]); } + + /** + * Returns a Generator that yields all the Definitions in the given $storage recursively. + * The generator yields key => value pairs, e.g. + * `'Psr\Log\LoggerInterface->log()' => $definition` + * + * @param array &$storage + * @param string $prefix (optional) + * @return \Generator + */ + private function yieldDefinitionsRecursively(array &$storage, string $prefix = ''): \Generator + { + foreach ($storage as $key => $value) { + if (!is_array($value)) { + yield $prefix.$key => $value; + } else { + yield from $this->yieldDefinitionsRecursively($value, $prefix.$key); + } + } + } + + /** + * Splits the given FQN into an array, eg : + * - `'Psr\Log\LoggerInterface->log'` will be `['Psr', '\Log', '\LoggerInterface', '->log()']` + * - `'\Exception->getMessage()'` will be `['\Exception', '->getMessage()']` + * - `'PHP_VERSION'` will be `['PHP_VERSION']` + * + * @param string $fqn + * @return string[] + */ + private function splitFqn(string $fqn): array + { + // split fqn at backslashes + $parts = explode('\\', $fqn); + + // write back the backslash prefix to the first part if it was present + if ('' === $parts[0] && count($parts) > 1) { + $parts = array_slice($parts, 1); + $parts[0] = '\\' . $parts[0]; + } + + // write back the backslashes prefixes for the other parts + for ($i = 1; $i < count($parts); $i++) { + $parts[$i] = '\\' . $parts[$i]; + } + + // split the last part in 2 parts at the operator + $hasOperator = false; + $lastPart = end($parts); + foreach (['::', '->'] as $operator) { + $endParts = explode($operator, $lastPart); + if (count($endParts) > 1) { + $hasOperator = true; + // replace the last part by its pieces + array_pop($parts); + $parts[] = $endParts[0]; + $parts[] = $operator . $endParts[1]; + break; + } + } + + // The end($parts) === '' holds for the root namespace. + if (!$hasOperator && end($parts) !== '') { + // add an empty part to store the non-member definition to avoid + // definition collisions in the index array, eg + // 'Psr\Log\LoggerInterface' will be stored at + // ['Psr']['\Log']['\LoggerInterface'][''] to be able to also store + // member definitions, ie 'Psr\Log\LoggerInterface->log()' will be + // stored at ['Psr']['\Log']['\LoggerInterface']['->log()'] + $parts[] = ''; + } + + return $parts; + } + + /** + * Return the values stored in this index under the given $parts array. + * It can be an index node or a Definition if the $parts are precise + * enough. Returns null when nothing is found. + * + * @param string[] $path The splitted FQN + * @param array|Definition &$storage The current level to look for $path. + * @return array|Definition|null + */ + private function getIndexValue(array $path, &$storage) + { + // Empty path returns the object itself. + if (empty($path)) { + return $storage; + } + + $part = array_shift($path); + + if (!isset($storage[$part])) { + return null; + } + + return $this->getIndexValue($path, $storage[$part]); + } + + /** + * Recursive function that stores the given Definition in the given $storage array represented + * as a tree matching the given $parts. + * + * @param int $level The current level of FQN part + * @param string[] $parts The splitted FQN + * @param array &$storage The array in which to store the $definition + * @param Definition $definition The Definition to store + */ + private function indexDefinition(int $level, array $parts, array &$storage, Definition $definition) + { + $part = $parts[$level]; + + if ($level + 1 === count($parts)) { + $storage[$part] = $definition; + + return; + } + + if (!isset($storage[$part])) { + $storage[$part] = []; + } + + $this->indexDefinition($level + 1, $parts, $storage[$part], $definition); + } + + /** + * Recursive function that removes the definition matching the given $parts from the given + * $storage array. The function also looks up recursively to remove the parents of the + * definition which no longer has children to avoid to let empty arrays in the index. + * + * @param int $level The current level of FQN part + * @param string[] $parts The splitted FQN + * @param array &$storage The current array in which to remove data + * @param array &$rootStorage The root storage array + */ + private function removeIndexedDefinition(int $level, array $parts, array &$storage, array &$rootStorage) + { + $part = $parts[$level]; + + if ($level + 1 === count($parts)) { + if (isset($storage[$part])) { + unset($storage[$part]); + + if (0 === count($storage)) { + // parse again the definition tree to remove the parent + // when it has no more children + $this->removeIndexedDefinition(0, array_slice($parts, 0, $level), $rootStorage, $rootStorage); + } + } + } else { + $this->removeIndexedDefinition($level + 1, $parts, $storage[$part], $rootStorage); + } + } } diff --git a/src/Index/ReadableIndex.php b/src/Index/ReadableIndex.php index 67b20b63..505bb9a9 100644 --- a/src/Index/ReadableIndex.php +++ b/src/Index/ReadableIndex.php @@ -30,12 +30,20 @@ public function isComplete(): bool; public function isStaticComplete(): bool; /** - * Returns an associative array [string => Definition] that maps fully qualified symbol names - * to Definitions + * Returns a Generator providing an associative array [string => Definition] + * that maps fully qualified symbol names to Definitions (global or not) * - * @return Definitions[] + * @return \Generator yields Definition */ - public function getDefinitions(): array; + public function getDefinitions(): \Generator; + + /** + * Returns a Generator that yields all the direct child Definitions of a given FQN + * + * @param string $fqn + * @return \Generator yields Definition + */ + public function getChildDefinitionsForFqn(string $fqn): \Generator; /** * Returns the Definition object by a specific FQN @@ -47,10 +55,10 @@ public function getDefinitions(): array; public function getDefinition(string $fqn, bool $globalFallback = false); /** - * Returns all URIs in this index that reference a symbol + * Returns a Generator that yields all URIs in this index that reference a symbol * * @param string $fqn The fully qualified name of the symbol - * @return string[] + * @return \Generator yields string */ - public function getReferenceUris(string $fqn): array; + public function getReferenceUris(string $fqn): \Generator; } diff --git a/src/Server/TextDocument.php b/src/Server/TextDocument.php index 0922b5e6..039ff578 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -227,10 +227,11 @@ public function references( return []; } } - $refDocuments = yield Promise\all(array_map( - [$this->documentLoader, 'getOrLoad'], - $this->index->getReferenceUris($fqn) - )); + $refDocumentPromises = []; + foreach ($this->index->getReferenceUris($fqn) as $uri) { + $refDocumentPromises[] = $this->documentLoader->getOrLoad($uri); + } + $refDocuments = yield Promise\all($refDocumentPromises); foreach ($refDocuments as $document) { $refs = $document->getReferenceNodesByFqn($fqn); if ($refs !== null) { diff --git a/tests/NodeVisitor/DefinitionCollectorTest.php b/tests/NodeVisitor/DefinitionCollectorTest.php index a092b625..c27822ce 100644 --- a/tests/NodeVisitor/DefinitionCollectorTest.php +++ b/tests/NodeVisitor/DefinitionCollectorTest.php @@ -35,7 +35,9 @@ public function testCollectsSymbols() 'TestNamespace\\ChildClass', 'TestNamespace\\Example', 'TestNamespace\\Example->__construct()', - 'TestNamespace\\Example->__destruct()' + 'TestNamespace\\Example->__destruct()', + 'TestNamespace\\InnerNamespace', + 'TestNamespace\\InnerNamespace\\InnerClass', ], array_keys($defNodes)); $this->assertInstanceOf(Node\ConstElement::class, $defNodes['TestNamespace\\TEST_CONST']); @@ -53,6 +55,7 @@ public function testCollectsSymbols() $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->__destruct()']); + $this->assertInstanceOf(Node\Statement\ClassDeclaration::class, $defNodes['TestNamespace\\InnerNamespace\\InnerClass']); } public function testDoesNotCollectReferences() diff --git a/tests/Server/ServerTestCase.php b/tests/Server/ServerTestCase.php index 880e1a00..3331aefa 100644 --- a/tests/Server/ServerTestCase.php +++ b/tests/Server/ServerTestCase.php @@ -107,7 +107,9 @@ public function setUp() '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::__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 = [ diff --git a/tests/Server/TextDocument/CompletionTest.php b/tests/Server/TextDocument/CompletionTest.php index c64c8e67..516d3b40 100644 --- a/tests/Server/TextDocument/CompletionTest.php +++ b/tests/Server/TextDocument/CompletionTest.php @@ -47,6 +47,9 @@ public function setUp() $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 @@ public function testPropertyAndMethodWithPrefix() ], 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 @@ public function testGlobalFunctionInsideNamespaceAndClass() ], true), $items); } + /** + * Tests completion at `$obj->|` + */ public function testPropertyAndMethodWithoutPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/property.php'); @@ -116,6 +125,9 @@ public function testPropertyAndMethodWithoutPrefix() ], true), $items); } + /** + * Tests completion at `$|` when variables are defined + */ public function testVariable() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/variable.php'); @@ -148,6 +160,9 @@ public function testVariable() ], 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 @@ public function testVariableWithPrefix() ], 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'); @@ -218,27 +236,12 @@ public function testNewInNamespace() null, 'TestClass' ), - new CompletionItem( - 'ChildClass', - CompletionItemKind::CLASS_, - 'TestNamespace', - null, - null, - null, - '\TestNamespace\ChildClass' - ), - new CompletionItem( - 'Example', - CompletionItemKind::CLASS_, - 'TestNamespace', - null, - null, - null, - '\TestNamespace\Example' - ) ], true), $items); } + /** + * Tests completion at `TestC|` with `use TestNamespace\TestClass` + */ public function testUsedClass() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_class.php'); @@ -257,11 +260,74 @@ public function testUsedClass() '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); + } + + /** + * 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(8, 16) + )->wait(); + $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 @@ public function testStaticPropertyWithPrefix() ], true), $items); } + /** + * Tests completion at `TestClass::|` + */ public function testStaticWithoutPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static.php'); @@ -316,6 +385,9 @@ public function testStaticWithoutPrefix() ], true), $items); } + /** + * Tests completion at `TestClass::st|` + */ public function testStaticMethodWithPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static_method_with_prefix.php'); @@ -325,21 +397,6 @@ public function testStaticMethodWithPrefix() 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 @@ public function testStaticMethodWithPrefix() ], 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 @@ public function testClassConstWithPrefix() 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 @@ public function testFullyQualifiedClass() '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 @@ public function testKeywords() ], true), $items); } + /** + * Tests completion in an empty file + */ public function testHtmlWithoutPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html.php'); @@ -444,6 +499,9 @@ public function testHtmlWithoutPrefix() ], 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 @@ private function assertCompletionsListSubset(CompletionList $subsetList, Complet $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 @@ public function testThisWithoutPrefix() ], true), $items); } + /** + * Tests completion at `$this->m|` + */ public function testThisWithPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/this_with_prefix.php'); @@ -821,18 +902,6 @@ public function testThisWithPrefix() new Position(12, 16) )->wait(); $this->assertEquals(new CompletionList([ - new CompletionItem( - 'testProperty', - CompletionItemKind::PROPERTY, - '\TestClass', // Type of the property - 'Reprehenderit magna velit mollit ipsum do.' - ), - new CompletionItem( - 'testMethod', - CompletionItemKind::METHOD, - '\TestClass', // Return type of the method - 'Non culpa nostrud mollit esse sunt laboris in irure ullamco cupidatat amet.' - ), new CompletionItem( 'foo', CompletionItemKind::PROPERTY, @@ -856,10 +925,25 @@ public function testThisWithPrefix() CompletionItemKind::METHOD, 'mixed', // Return type of the method null - ) + ), + new CompletionItem( + 'testProperty', + CompletionItemKind::PROPERTY, + '\TestClass', // Type of the property + 'Reprehenderit magna velit mollit ipsum do.' + ), + new CompletionItem( + 'testMethod', + CompletionItemKind::METHOD, + '\TestClass', // Return type of the method + 'Non culpa nostrud mollit esse sunt laboris in irure ullamco cupidatat amet.' + ), ], true), $items); } + /** + * Tests completion at `$this->foo()->q|` + */ public function testThisReturnValue() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/this_return_value.php'); @@ -869,11 +953,6 @@ public function testThisReturnValue() new Position(17, 23) )->wait(); $this->assertEquals(new CompletionList([ - new CompletionItem( - 'foo', - CompletionItemKind::METHOD, - '$this' // Return type of the method - ), new CompletionItem( 'bar', CompletionItemKind::METHOD, @@ -883,7 +962,12 @@ public function testThisReturnValue() 'qux', CompletionItemKind::METHOD, 'mixed' // Return type of the method - ) + ), + new CompletionItem( + 'foo', + CompletionItemKind::METHOD, + '$this' // Return type of the method + ), ], true), $items); } } diff --git a/tests/Server/TextDocument/DocumentSymbolTest.php b/tests/Server/TextDocument/DocumentSymbolTest.php index 205e8fe8..1267fd00 100644 --- a/tests/Server/TextDocument/DocumentSymbolTest.php +++ b/tests/Server/TextDocument/DocumentSymbolTest.php @@ -32,7 +32,9 @@ public function test() new SymbolInformation('ChildClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\ChildClass'), '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('__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); // @codingStandardsIgnoreEnd } diff --git a/tests/Server/Workspace/SymbolTest.php b/tests/Server/Workspace/SymbolTest.php index 9dc5df1c..74fc92e0 100644 --- a/tests/Server/Workspace/SymbolTest.php +++ b/tests/Server/Workspace/SymbolTest.php @@ -30,7 +30,7 @@ public function testEmptyQueryReturnsAllSymbols() // @codingStandardsIgnoreStart $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 new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TestNamespace\\TEST_CONST'), 'TestNamespace'), new SymbolInformation('TestClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\TestClass'), 'TestNamespace'), @@ -46,6 +46,8 @@ public function testEmptyQueryReturnsAllSymbols() new SymbolInformation('Example', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\Example'), 'TestNamespace'), 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('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'), // Global new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TEST_CONST'), ''),