From 24388bcf26d6e088b132904ab81f02a4df813212 Mon Sep 17 00:00:00 2001 From: Michael V Date: Sun, 11 Nov 2018 03:47:57 +0100 Subject: [PATCH 01/11] perf: change index to a tree to speed up completion (#680) Refactors Index into a tree structure, rather than an array of Fqns to definitions. Closes #274 --- benchmarks/completion.php | 89 ++++ Performance.php => benchmarks/parsing.php | 18 +- fixtures/completion/used_namespace.php | 10 + fixtures/symbols.php | 5 + src/CompletionProvider.php | 396 +++++++++++++----- src/DefinitionResolver.php | 8 +- src/FqnUtilities.php | 88 ++++ src/Index/AbstractAggregateIndex.php | 39 +- src/Index/Index.php | 252 ++++++++++- src/Index/ReadableIndex.php | 22 +- src/Server/TextDocument.php | 9 +- tests/NodeVisitor/DefinitionCollectorTest.php | 5 +- tests/Server/ServerTestCase.php | 4 +- tests/Server/TextDocument/CompletionTest.php | 242 +++++++---- .../TextDocument/DocumentSymbolTest.php | 4 +- tests/Server/Workspace/SymbolTest.php | 4 +- 16 files changed, 949 insertions(+), 246 deletions(-) create mode 100644 benchmarks/completion.php rename Performance.php => benchmarks/parsing.php (77%) create mode 100644 fixtures/completion/used_namespace.php diff --git a/benchmarks/completion.php b/benchmarks/completion.php new file mode 100644 index 0000000..2e736d7 --- /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 4d76d38..516de40 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 @@ foreach($frameworks as $framework) { 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 0000000..40a7e50 --- /dev/null +++ b/fixtures/completion/used_namespace.php @@ -0,0 +1,10 @@ +getNodeAtPosition($pos); @@ -237,16 +248,14 @@ class CompletionProvider $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 @@ class CompletionProvider $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 @@ class CompletionProvider // 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(); - - /** 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(); - /** @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); + /** @var bool Whether the prefix is qualified (contains at least one backslash) */ + $isFullyQualified = false; + + /** @var bool Whether the prefix is qualified (contains at least one backslash) */ + $isQualified = false; + + 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 string[] $aliases A map from local alias to fully qualified name */ - list($aliases,,) = $node->getImportTablesForCurrentScope(); + /** @var array Import (use) tables */ + $importTables = $node->getImportTablesForCurrentScope(); - foreach ($aliases as $alias => $name) { - $aliases[$alias] = (string)$name; + 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); } - // 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); - } + $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); } } - // 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) { + } + return $list; + } - $fqnStartsWithPrefix = substr($fqn, 0, $prefixLen) === $prefix; - - 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; - } - } - - // 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; - } - } + 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; } } - return $list; + 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); + } + + // Lastly and least importantly, suggest keywords. + yield from $this->getCompletionsForKeywords($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; + } + } + + /** + * 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; + } + } + } + + /** + * 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; + } + } + + /** + * 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 @@ class CompletionProvider } } - 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 3a4f378..f3d6fa1 100644 --- a/src/DefinitionResolver.php +++ b/src/DefinitionResolver.php @@ -1233,7 +1233,13 @@ class DefinitionResolver 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 fcd6027..57e3776 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 5377c3a..8c8c95a 100644 --- a/src/Index/AbstractAggregateIndex.php +++ b/src/Index/AbstractAggregateIndex.php @@ -99,20 +99,29 @@ abstract class AbstractAggregateIndex implements ReadableIndex } /** - * 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 @@ abstract class AbstractAggregateIndex implements ReadableIndex } /** - * 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 9cb975e..0c8e3e9 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 @@ class Index implements ReadableIndex, \Serializable } /** - * 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 @@ class Index implements ReadableIndex, \Serializable */ 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 @@ class Index implements ReadableIndex, \Serializable */ 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 @@ class Index implements ReadableIndex, \Serializable */ 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 @@ class Index implements ReadableIndex, \Serializable 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 @@ class Index implements ReadableIndex, \Serializable 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 67b20b6..505bb9a 100644 --- a/src/Index/ReadableIndex.php +++ b/src/Index/ReadableIndex.php @@ -30,12 +30,20 @@ interface ReadableIndex extends EmitterInterface 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 @@ interface ReadableIndex extends EmitterInterface 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 0922b5e..039ff57 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -227,10 +227,11 @@ class TextDocument 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 a092b62..c27822c 100644 --- a/tests/NodeVisitor/DefinitionCollectorTest.php +++ b/tests/NodeVisitor/DefinitionCollectorTest.php @@ -35,7 +35,9 @@ class DefinitionCollectorTest extends TestCase '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 @@ class DefinitionCollectorTest extends TestCase $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 880e1a0..3331aef 100644 --- a/tests/Server/ServerTestCase.php +++ b/tests/Server/ServerTestCase.php @@ -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\\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 c64c8e6..516d3b4 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'); @@ -218,27 +236,12 @@ class CompletionTest extends TestCase 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 @@ 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); } + /** + * 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 @@ 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'); @@ -821,18 +902,6 @@ class CompletionTest extends TestCase 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 @@ class CompletionTest extends TestCase 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 @@ class CompletionTest extends TestCase 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 @@ class CompletionTest extends TestCase '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 205e8fe..1267fd0 100644 --- a/tests/Server/TextDocument/DocumentSymbolTest.php +++ b/tests/Server/TextDocument/DocumentSymbolTest.php @@ -32,7 +32,9 @@ class DocumentSymbolTest extends ServerTestCase 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 9dc5df1..74fc92e 100644 --- a/tests/Server/Workspace/SymbolTest.php +++ b/tests/Server/Workspace/SymbolTest.php @@ -30,7 +30,7 @@ class SymbolTest extends ServerTestCase // @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 @@ class SymbolTest extends ServerTestCase 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'), ''), From 71390c9903d820804f9070cbff74da1e915f372b Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Sun, 11 Nov 2018 04:07:16 +0100 Subject: [PATCH 02/11] chore: update package-lock.json --- package-lock.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index fd26f34..fae7b60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,8 +1,6 @@ { - "name": "php-language-server", - "version": "0.0.0-development", - "lockfileVersion": 1, "requires": true, + "lockfileVersion": 1, "dependencies": { "@gimenete/type-writer": { "version": "0.1.3", From c7d25c7b4463d838f6dbc53b8b5d30b01fbf0adf Mon Sep 17 00:00:00 2001 From: Dylan McGannon Date: Sun, 11 Nov 2018 14:26:39 +1100 Subject: [PATCH 03/11] fix(definitionresolver): infinite loop when indexing self referencing classes (#670) --- fixtures/self_referencing_class.php | 20 ++++++++++++++++++++ src/DefinitionResolver.php | 8 +++++++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 fixtures/self_referencing_class.php diff --git a/fixtures/self_referencing_class.php b/fixtures/self_referencing_class.php new file mode 100644 index 0000000..06dc8f0 --- /dev/null +++ b/fixtures/self_referencing_class.php @@ -0,0 +1,20 @@ +undef_prop = 1; + +$b = new B; +$b->undef_prop = 1; + +$d = new D; +$d->undef_prop = 1; diff --git a/src/DefinitionResolver.php b/src/DefinitionResolver.php index f3d6fa1..adddf77 100644 --- a/src/DefinitionResolver.php +++ b/src/DefinitionResolver.php @@ -438,6 +438,7 @@ class DefinitionResolver // Find the right class that implements the member $implementorFqns = [$classFqn]; + $visitedFqns = []; while ($implementorFqn = array_shift($implementorFqns)) { // If the member FQN exists, return it @@ -450,10 +451,15 @@ class DefinitionResolver if ($implementorDef === null) { break; } + // Note the FQN as visited + $visitedFqns[] = $implementorFqn; // Repeat for parent class if ($implementorDef->extends) { foreach ($implementorDef->extends as $extends) { - $implementorFqns[] = $extends; + // Don't add the parent FQN if it's already been visited + if (!\in_array($extends, $visitedFqns)) { + $implementorFqns[] = $extends; + } } } } From 680f4304533bc3b100c378189c5eaf44293005c5 Mon Sep 17 00:00:00 2001 From: JJK96 Date: Sun, 11 Nov 2018 04:33:12 +0100 Subject: [PATCH 04/11] fix: support rootUri (#672) --- src/LanguageServer.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/LanguageServer.php b/src/LanguageServer.php index 05dd48a..a30992b 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -165,8 +165,11 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher * @param int|null $processId The process Id of the parent process that started the server. Is null if the process has not been started by another process. If the parent process is not alive then the server should exit (see exit notification) its process. * @return Promise */ - public function initialize(ClientCapabilities $capabilities, string $rootPath = null, int $processId = null): Promise + public function initialize(ClientCapabilities $capabilities, string $rootPath = null, int $processId = null, string $rootUri = null): Promise { + if ($rootPath === null) { + $rootPath = uriToPath($rootUri); + } return coroutine(function () use ($capabilities, $rootPath, $processId) { if ($capabilities->xfilesProvider) { From ed2d8ddb1eff2d0beef11b31a46bdb3022a2d1a8 Mon Sep 17 00:00:00 2001 From: Matthew Brown Date: Sat, 10 Nov 2018 22:47:10 -0500 Subject: [PATCH 05/11] refactor: fix impossible parse_url equality (#676) `parse_url` returns `false` for malformed urls, not `null` --- src/utils.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.php b/src/utils.php index 97e091d..af47a96 100644 --- a/src/utils.php +++ b/src/utils.php @@ -38,7 +38,7 @@ function pathToUri(string $filepath): string function uriToPath(string $uri) { $fragments = parse_url($uri); - if ($fragments === null || !isset($fragments['scheme']) || $fragments['scheme'] !== 'file') { + if ($fragments === false || !isset($fragments['scheme']) || $fragments['scheme'] !== 'file') { throw new InvalidArgumentException("Not a valid file URI: $uri"); } $filepath = urldecode($fragments['path']); From b1cc565d7e99ce5df9a4fc6d0f05fc69c50de2fb Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Sun, 11 Nov 2018 12:57:20 +0100 Subject: [PATCH 06/11] fix(cache): bump cache version --- src/Indexer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Indexer.php b/src/Indexer.php index 8b1fd9b..7ebff3f 100644 --- a/src/Indexer.php +++ b/src/Indexer.php @@ -16,7 +16,7 @@ class Indexer /** * @var int The prefix for every cache item */ - const CACHE_VERSION = 2; + const CACHE_VERSION = 3; /** * @var FilesFinder From 450116e2f35e2b95ab389d441dfea37351d64b56 Mon Sep 17 00:00:00 2001 From: Tyson Andre Date: Sun, 11 Nov 2018 14:45:47 -0500 Subject: [PATCH 07/11] docs: remove unused use statements, nit on phpdoc (#625) * Remove unused use statements, nit on phpdoc Add a note on something that looks like an invalid array index * Remove phpdoc param with no real param --- src/Cache/ClientCache.php | 5 +++++ src/Client/TextDocument.php | 2 +- src/Definition.php | 4 ++-- src/Index/Index.php | 1 - src/PhpDocument.php | 2 +- src/SignatureHelpProvider.php | 3 +-- 6 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Cache/ClientCache.php b/src/Cache/ClientCache.php index bc91b97..e92a3d5 100644 --- a/src/Cache/ClientCache.php +++ b/src/Cache/ClientCache.php @@ -11,6 +11,11 @@ use Sabre\Event\Promise; */ class ClientCache implements Cache { + /** + * @var LanguageClient + */ + public $client; + /** * @param LanguageClient $client */ diff --git a/src/Client/TextDocument.php b/src/Client/TextDocument.php index 7630438..03211fa 100644 --- a/src/Client/TextDocument.php +++ b/src/Client/TextDocument.php @@ -4,7 +4,7 @@ declare(strict_types = 1); namespace LanguageServer\Client; use LanguageServer\ClientHandler; -use LanguageServerProtocol\{TextDocumentItem, TextDocumentIdentifier}; +use LanguageServerProtocol\{Diagnostic, TextDocumentItem, TextDocumentIdentifier}; use Sabre\Event\Promise; use JsonMapper; diff --git a/src/Definition.php b/src/Definition.php index 9ea27f9..d81594d 100644 --- a/src/Definition.php +++ b/src/Definition.php @@ -4,7 +4,7 @@ declare(strict_types = 1); namespace LanguageServer; use LanguageServer\Index\ReadableIndex; -use phpDocumentor\Reflection\{Types, Type, Fqsen, TypeResolver}; +use phpDocumentor\Reflection\{Types, Type, TypeResolver}; use LanguageServerProtocol\SymbolInformation; use Generator; @@ -80,7 +80,7 @@ class Definition * Can also be a compound type. * If it is unknown, will be Types\Mixed_. * - * @var \phpDocumentor\Type|null + * @var Type|null */ public $type; diff --git a/src/Index/Index.php b/src/Index/Index.php index 0c8e3e9..0d61f9c 100644 --- a/src/Index/Index.php +++ b/src/Index/Index.php @@ -274,7 +274,6 @@ class Index implements ReadableIndex, \Serializable } /** - * @param string $serialized * @return string */ public function serialize() diff --git a/src/PhpDocument.php b/src/PhpDocument.php index 0547615..d148f9c 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -63,7 +63,7 @@ class PhpDocument /** * Map from fully qualified name (FQN) to Node * - * @var Node + * @var Node[] */ private $definitionNodes; diff --git a/src/SignatureHelpProvider.php b/src/SignatureHelpProvider.php index 43851b0..66afd5f 100644 --- a/src/SignatureHelpProvider.php +++ b/src/SignatureHelpProvider.php @@ -6,8 +6,7 @@ namespace LanguageServer; use LanguageServer\Index\ReadableIndex; use LanguageServerProtocol\{ Position, - SignatureHelp, - ParameterInformation + SignatureHelp }; use Microsoft\PhpParser\Node; use Sabre\Event\Promise; From 1da3328bc23ebd6418529035d357481c8c028640 Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Tue, 13 Nov 2018 18:33:11 +0100 Subject: [PATCH 08/11] fix: allow rootUri to be null Fixes #684 --- src/LanguageServer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LanguageServer.php b/src/LanguageServer.php index a30992b..38dfeb1 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -167,7 +167,7 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher */ public function initialize(ClientCapabilities $capabilities, string $rootPath = null, int $processId = null, string $rootUri = null): Promise { - if ($rootPath === null) { + if ($rootPath === null && $rootUri !== null) { $rootPath = uriToPath($rootUri); } return coroutine(function () use ($capabilities, $rootPath, $processId) { From 1705583e321795a895ac87343a2346c720ce8fc9 Mon Sep 17 00:00:00 2001 From: Tyson Andre Date: Thu, 29 Nov 2018 03:50:01 -0500 Subject: [PATCH 09/11] chore: add Phan (#690) --- .gitattributes | 1 + .phan/config.php | 308 +++++++++++++++++++++++++++++++++++++++++++++++ .travis.yml | 3 + composer.json | 1 + 4 files changed, 313 insertions(+) create mode 100644 .phan/config.php diff --git a/.gitattributes b/.gitattributes index 63babab..3f3d483 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,6 +10,7 @@ /.gitignore export-ignore /.gitmodules export-ignore /.npmrc export-ignore +/.phan export-ignore /.travis.yml export-ignore /appveyor.yml export-ignore /codecov.yml export-ignore diff --git a/.phan/config.php b/.phan/config.php new file mode 100644 index 0000000..a3beb49 --- /dev/null +++ b/.phan/config.php @@ -0,0 +1,308 @@ + '7.0', + + // If enabled, missing properties will be created when + // they are first seen. If false, we'll report an + // error message if there is an attempt to write + // to a class property that wasn't explicitly + // defined. + 'allow_missing_properties' => false, + + // If enabled, null can be cast as any type and any + // type can be cast to null. Setting this to true + // will cut down on false positives. + 'null_casts_as_any_type' => false, + + // If enabled, allow null to be cast as any array-like type. + // This is an incremental step in migrating away from null_casts_as_any_type. + // If null_casts_as_any_type is true, this has no effect. + 'null_casts_as_array' => false, + + // If enabled, allow any array-like type to be cast to null. + // This is an incremental step in migrating away from null_casts_as_any_type. + // If null_casts_as_any_type is true, this has no effect. + 'array_casts_as_null' => false, + + // If enabled, scalars (int, float, bool, string, null) + // are treated as if they can cast to each other. + // This does not affect checks of array keys. See scalar_array_key_cast. + 'scalar_implicit_cast' => false, + + // If enabled, any scalar array keys (int, string) + // are treated as if they can cast to each other. + // E.g. array can cast to array and vice versa. + // Normally, a scalar type such as int could only cast to/from int and mixed. + 'scalar_array_key_cast' => false, + + // If this has entries, scalars (int, float, bool, string, null) + // are allowed to perform the casts listed. + // E.g. ['int' => ['float', 'string'], 'float' => ['int'], 'string' => ['int'], 'null' => ['string']] + // allows casting null to a string, but not vice versa. + // (subset of scalar_implicit_cast) + 'scalar_implicit_partial' => [], + + // If true, seemingly undeclared variables in the global + // scope will be ignored. This is useful for projects + // with complicated cross-file globals that you have no + // hope of fixing. + 'ignore_undeclared_variables_in_global_scope' => false, + + // Backwards Compatibility Checking. This is slow + // and expensive, but you should consider running + // it before upgrading your version of PHP to a + // new version that has backward compatibility + // breaks. + 'backward_compatibility_checks' => false, + + // If true, check to make sure the return type declared + // in the doc-block (if any) matches the return type + // declared in the method signature. + 'check_docblock_signature_return_type_match' => true, + + // (*Requires check_docblock_signature_param_type_match to be true*) + // If true, make narrowed types from phpdoc params override + // the real types from the signature, when real types exist. + // (E.g. allows specifying desired lists of subclasses, + // or to indicate a preference for non-nullable types over nullable types) + // Affects analysis of the body of the method and the param types passed in by callers. + 'prefer_narrowed_phpdoc_param_type' => true, + + // (*Requires check_docblock_signature_return_type_match to be true*) + // If true, make narrowed types from phpdoc returns override + // the real types from the signature, when real types exist. + // (E.g. allows specifying desired lists of subclasses, + // or to indicate a preference for non-nullable types over nullable types) + // Affects analysis of return statements in the body of the method and the return types passed in by callers. + 'prefer_narrowed_phpdoc_return_type' => true, + + 'ensure_signature_compatibility' => true, + + // Set to true in order to attempt to detect dead + // (unreferenced) code. Keep in mind that the + // results will only be a guess given that classes, + // properties, constants and methods can be referenced + // as variables (like `$class->$property` or + // `$class->$method()`) in ways that we're unable + // to make sense of. + 'dead_code_detection' => false, + + // If true, this run a quick version of checks that takes less + // time at the cost of not running as thorough + // an analysis. You should consider setting this + // to true only when you wish you had more **undiagnosed** issues + // to fix in your code base. + // + // In quick-mode the scanner doesn't rescan a function + // or a method's code block every time a call is seen. + // This means that the problem here won't be detected: + // + // ```php + // false, + + // If true, then before analysis, try to simplify AST into a form + // which improves Phan's type inference in edge cases. + // + // This may conflict with 'dead_code_detection'. + // When this is true, this slows down analysis slightly. + // + // E.g. rewrites `if ($a = value() && $a > 0) {...}` + // into $a = value(); if ($a) { if ($a > 0) {...}}` + 'simplify_ast' => true, + + // Enable or disable support for generic templated + // class types. + 'generic_types_enabled' => true, + + // Override to hardcode existence and types of (non-builtin) globals in the global scope. + // Class names should be prefixed with '\\'. + // (E.g. ['_FOO' => '\\FooClass', 'page' => '\\PageClass', 'userId' => 'int']) + 'globals_type_map' => [], + + // The minimum severity level to report on. This can be + // set to Issue::SEVERITY_LOW, Issue::SEVERITY_NORMAL or + // Issue::SEVERITY_CRITICAL. Setting it to only + // critical issues is a good place to start on a big + // sloppy mature code base. + 'minimum_severity' => Issue::SEVERITY_LOW, + + // Add any issue types (such as 'PhanUndeclaredMethod') + // to this black-list to inhibit them from being reported. + 'suppress_issue_types' => [ + 'PhanTypeMismatchDeclaredParamNullable', + 'PhanUndeclaredProperty', // 66 occurence(s) (e.g. not being specific enough about the subclass) + 'PhanUndeclaredMethod', // 32 occurence(s) (e.g. not being specific enough about the subclass of Node) + 'PhanTypeMismatchArgument', // 21 occurence(s) + 'PhanTypeMismatchProperty', // 13 occurence(s) + 'PhanUnreferencedUseNormal', // 10 occurence(s) TODO: Fix + 'PhanTypeMismatchDeclaredReturn', // 8 occurence(s) + 'PhanUndeclaredTypeProperty', // 7 occurence(s) + 'PhanTypeMismatchReturn', // 6 occurence(s) + 'PhanUndeclaredVariable', // 4 occurence(s) + 'PhanUndeclaredTypeReturnType', // 4 occurence(s) + 'PhanParamTooMany', // 3 occurence(s) + 'PhanUndeclaredTypeParameter', // 2 occurence(s) + 'PhanUndeclaredClassProperty', // 2 occurence(s) + 'PhanTypeSuspiciousStringExpression', // 2 occurence(s) + 'PhanTypeMismatchArgumentInternal', // 2 occurence(s) + 'PhanUnextractableAnnotationElementName', // 1 occurence(s) + 'PhanUndeclaredClassMethod', // 1 occurence(s) + 'PhanUndeclaredClassInstanceof', // 1 occurence(s) + 'PhanTypeSuspiciousNonTraversableForeach', // 1 occurence(s) + 'PhanTypeMismatchDimAssignment', // 1 occurence(s) + 'PhanTypeMismatchDeclaredParam', // 1 occurence(s) + 'PhanTypeInvalidDimOffset', // 1 occurence(s) + ], + + // A regular expression to match files to be excluded + // from parsing and analysis and will not be read at all. + // + // This is useful for excluding groups of test or example + // directories/files, unanalyzable files, or files that + // can't be removed for whatever reason. + // (e.g. '@Test\.php$@', or '@vendor/.*/(tests|Tests)/@') + 'exclude_file_regex' => '@^vendor/.*/(tests?|Tests?)/@', + + // A file list that defines files that will be excluded + // from parsing and analysis and will not be read at all. + // + // This is useful for excluding hopelessly unanalyzable + // files that can't be removed for whatever reason. + 'exclude_file_list' => [], + + // A directory list that defines files that will be excluded + // from static analysis, but whose class and method + // information should be included. + // + // Generally, you'll want to include the directories for + // third-party code (such as "vendor/") in this list. + // + // n.b.: If you'd like to parse but not analyze 3rd + // party code, directories containing that code + // should be added to the `directory_list` as + // to `excluce_analysis_directory_list`. + 'exclude_analysis_directory_list' => [ + 'vendor/', + ], + + // The number of processes to fork off during the analysis + // phase. + 'processes' => 1, + + // List of case-insensitive file extensions supported by Phan. + // (e.g. php, html, htm) + 'analyzed_file_extensions' => [ + 'php', + ], + + // You can put paths to stubs of internal extensions in this config option. + // If the corresponding extension is **not** loaded, then phan will use the stubs instead. + // Phan will continue using its detailed type annotations, + // but load the constants, classes, functions, and classes (and their Reflection types) + // from these stub files (doubling as valid php files). + // Use a different extension from php to avoid accidentally loading these. + // The 'tools/make_stubs' script can be used to generate your own stubs (compatible with php 7.0+ right now) + 'autoload_internal_extension_signatures' => [], + + // A list of plugin files to execute + // Plugins which are bundled with Phan can be added here by providing their name (e.g. 'AlwaysReturnPlugin') + // Alternately, you can pass in the full path to a PHP file with the plugin's implementation (e.g. 'vendor/phan/phan/.phan/plugins/AlwaysReturnPlugin.php') + 'plugins' => [ + 'AlwaysReturnPlugin', + 'DollarDollarPlugin', + 'DuplicateArrayKeyPlugin', + 'PregRegexCheckerPlugin', + 'PrintfCheckerPlugin', + 'UnreachableCodePlugin', + ], + + // A list of directories that should be parsed for class and + // method information. After excluding the directories + // defined in exclude_analysis_directory_list, the remaining + // files will be statically analyzed for errors. + // + // Thus, both first-party and third-party code being used by + // your application should be included in this list. + 'directory_list' => [ + 'src', + 'vendor/composer/xdebug-handler/src', + 'vendor/felixfbecker/advanced-json-rpc/lib', + 'vendor/felixfbecker/language-server-protocol/src/', + 'vendor/microsoft/tolerant-php-parser/src', + 'vendor/netresearch/jsonmapper/src', + 'vendor/phpdocumentor/reflection-common/src', + 'vendor/phpdocumentor/reflection-docblock/src', + 'vendor/phpdocumentor/type-resolver/src', + 'vendor/phpunit/phpunit/src', + 'vendor/psr/log/Psr', + 'vendor/sabre/event/lib', + 'vendor/sabre/uri/lib', + 'vendor/webmozart/glob/src', + 'vendor/webmozart/path-util/src', + ], + + // A list of individual files to include in analysis + // with a path relative to the root directory of the + // project + 'file_list' => [ + 'bin/php-language-server.php', + ], +]; diff --git a/.travis.yml b/.travis.yml index 3d03dcf..da1f118 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,8 +16,11 @@ cache: install: - composer install --prefer-dist --no-interaction + - pecl install ast-1.0.0 + script: - vendor/bin/phpcs -n + - vendor/bin/phan - vendor/bin/phpunit --coverage-clover=coverage.xml --colors=always - bash <(curl -s https://codecov.io/bash) diff --git a/composer.json b/composer.json index ffa5971..f63bf3a 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ }, "require-dev": { "phpunit/phpunit": "^6.3", + "phan/phan": "1.1.4", "squizlabs/php_codesniffer": "^3.1" }, "autoload": { From 7303143a6048e199f0cbf65e0cea5c8b9406488b Mon Sep 17 00:00:00 2001 From: Jakob Blume Date: Wed, 12 Dec 2018 16:28:19 +0100 Subject: [PATCH 10/11] build: run 'composer install' in a docker builder stage (#694) --- Dockerfile | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 549a28f..c11b756 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,19 @@ - # Running this container will start a language server that listens for TCP connections on port 2088 # Every connection will be run in a forked child process -# Please note that before building the image, you have to install dependencies with `composer install` +FROM composer AS builder + +COPY ./ /app +RUN composer install FROM php:7-cli -MAINTAINER Felix Becker +LABEL maintainer="Felix Becker " RUN docker-php-ext-configure pcntl --enable-pcntl RUN docker-php-ext-install pcntl COPY ./php.ini /usr/local/etc/php/conf.d/ -COPY ./ /srv/phpls +COPY --from=builder /app /srv/phpls WORKDIR /srv/phpls From 9dc16565922ae1fcf18a69b15b3cd15152ea21e6 Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Wed, 12 Dec 2018 16:29:42 +0100 Subject: [PATCH 11/11] build: remove composer install from semantic-release config --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f4e461e..841422f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "prepare": [ { "path": "@semantic-release/exec", - "cmd": "composer install --prefer-dist --no-interaction && docker build -t felixfbecker/php-language-server ." + "cmd": "docker build -t felixfbecker/php-language-server ." } ], "publish": [