diff --git a/.editorconfig b/.editorconfig index 5558124..d162066 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,5 +13,5 @@ indent_size = 2 [composer.json] indent_size = 4 -[*.md] +[{*.md,fixtures/**}] trim_trailing_whitespace = false diff --git a/composer.json b/composer.json index df9110a..b29008c 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "bin": ["bin/php-language-server.php"], "require": { "php": ">=7.0", - "nikic/php-parser": "^3.0.0beta2", + "nikic/php-parser": "dev-master#c5cdd5ad73ac20d855b84fa6d0f1f22ebff2e302", "phpdocumentor/reflection-docblock": "^3.0", "sabre/event": "^5.0", "felixfbecker/advanced-json-rpc": "^2.0", diff --git a/fixtures/completion/fully_qualified_class.php b/fixtures/completion/fully_qualified_class.php new file mode 100644 index 0000000..8c4da3d --- /dev/null +++ b/fixtures/completion/fully_qualified_class.php @@ -0,0 +1,9 @@ +getAttribute('parentNode'); - } - - // If we get a property fetch node, resolve items of the class - if ($node instanceof Node\Expr\PropertyFetch) { - $objType = $this->definitionResolver->resolveExpressionNodeToType($node->var); - if ($objType instanceof Types\Object_ && $objType->getFqsen() !== null) { - $prefix = substr((string)$objType->getFqsen(), 1) . '::'; - if (is_string($node->name)) { - $prefix .= $node->name; + // A non-free node means we do NOT suggest global symbols + if ( + $node instanceof Node\Expr\MethodCall + || $node instanceof Node\Expr\PropertyFetch + || $node instanceof Node\Expr\StaticCall + || $node instanceof Node\Expr\StaticPropertyFetch + || $node instanceof Node\Expr\ClassConstFetch + ) { + /** The FQN to be completed */ + $prefix = $this->definitionResolver->resolveReferenceNodeToFqn($node) ?? ''; + $prefixLen = strlen($prefix); + foreach ($this->project->getDefinitions() as $fqn => $def) { + if (substr($fqn, 0, $prefixLen) === $prefix && !$def->isGlobal) { + $items[] = CompletionItem::fromDefinition($def); } + } + } else if ( + // A ConstFetch means any static reference, like a class, interface, etc. + ($node instanceof Node\Name && $node->getAttribute('parentNode') instanceof Node\Expr\ConstFetch) + || $node instanceof Node\Expr\New_ + ) { + $prefix = null; + if ($node instanceof Node\Name) { + $isFullyQualified = $node->isFullyQualified(); + $prefix = (string)$node; $prefixLen = strlen($prefix); - foreach ($this->project->getDefinitions() as $fqn => $def) { - if (substr($fqn, 0, $prefixLen) === $prefix) { - $item = new CompletionItem; - $item->label = $def->symbolInformation->name; - if ($def->type) { - $item->detail = (string)$def->type; + $namespacedPrefix = (string)$node->getAttribute('namespacedName'); + $namespacedPrefixLen = strlen($prefix); + } + // Find closest namespace + $namespace = getClosestNode($node, Node\Stmt\Namespace_::class); + /** Map from alias to Definition */ + $aliasedDefs = []; + if ($namespace) { + foreach ($namespace->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Use_ || $stmt instanceof Node\Stmt\GroupUse) { + foreach ($stmt->uses as $use) { + // Get the definition for the used namespace, class-like, function or constant + // And save it under the alias + $fqn = (string)Node\Name::concat($stmt->prefix, $use->name); + $aliasedDefs[$use->alias] = $this->project->getDefinition($fqn); } - if ($def->documentation) { - $item->documentation = $def->documentation; - } - if ($def->symbolInformation->kind === SymbolKind::PROPERTY) { - $item->kind = CompletionItemKind::PROPERTY; - } else if ($def->symbolInformation->kind === SymbolKind::METHOD) { - $item->kind = CompletionItemKind::METHOD; - } - $items[] = $item; + } else { + // Use statements are always the first statements in a namespace + break; } } } - } else { + // If there is a prefix that does not start with a slash, suggest `use`d symbols + if ($prefix && !$isFullyQualified) { + // Suggest symbols that have been `use`d + // Search the aliases for the typed-in name + foreach ($aliasedDefs as $alias => $def) { + if (substr($alias, 0, $prefixLen) === $prefix) { + $items[] = CompletionItem::fromDefinition($def); + } + } + } + // Additionally, 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->project->getDefinitions() as $fqn => $def) { + if ( + $def->isGlobal // exclude methods, properties etc. + && ( + !$prefix + || ( + ($isFullyQualified && substr($fqn, 0, $prefixLen) === $prefix) + || (!$isFullyQualified && substr($fqn, 0, $namespacedPrefixLen) === $namespacedPrefix) + ) + ) + // Only suggest classes for `new` + && (!($node instanceof Node\Expr\New_) || $def->canBeInstantiated) + ) { + $item = CompletionItem::fromDefinition($def); + // Find the shortest name to reference the symbol + if ($namespace && ($alias = array_search($def, $aliasedDefs, true)) !== false) { + // $alias is the name under which this definition is aliased in the current namespace + $item->insertText = $alias; + } else if ($namespace) { + // Insert the global FQN with trailing backslash + $item->insertText = '\\' . $fqn; + } else { + // Insert the FQN without trailing backlash + $item->insertText = $fqn; + } + $items[] = $item; + } + } + } else if ( + $node instanceof Node\Expr\Variable + || ($node && $node->getAttribute('parentNode') instanceof Node\Expr\Variable) + ) { // Find variables, parameters and use statements in the scope - foreach ($this->suggestVariablesAtNode($node) as $var) { + // If there was only a $ typed, $node will be instanceof Node\Error + $namePrefix = $node instanceof Node\Expr\Variable && is_string($node->name) ? $node->name : ''; + foreach ($this->suggestVariablesAtNode($node, $namePrefix) as $var) { $item = new CompletionItem; $item->kind = CompletionItemKind::VARIABLE; $item->label = '$' . ($var instanceof Node\Expr\ClosureUse ? $var->var : $var->name); @@ -101,16 +163,17 @@ class CompletionProvider * of that variable * * @param Node $node + * @param string $namePrefix Prefix to filter * @return array */ - private function suggestVariablesAtNode(Node $node): array + private function suggestVariablesAtNode(Node $node, string $namePrefix = ''): array { $vars = []; // Find variables in the node itself // When getting completion in the middle of a function, $node will be the function node // so we need to search it - foreach ($this->findVariableDefinitionsInNode($node) as $var) { + foreach ($this->findVariableDefinitionsInNode($node, $namePrefix) as $var) { // Only use the first definition if (!isset($vars[$var->name])) { $vars[$var->name] = $var; @@ -124,7 +187,7 @@ class CompletionProvider $sibling = $level; while ($sibling = $sibling->getAttribute('previousSibling')) { // Collect all variables inside the sibling node - foreach ($this->findVariableDefinitionsInNode($sibling) as $var) { + foreach ($this->findVariableDefinitionsInNode($sibling, $namePrefix) as $var) { $vars[$var->name] = $var; } } @@ -135,13 +198,13 @@ class CompletionProvider // also add its parameters and closure uses to the result list if ($level instanceof Node\FunctionLike) { foreach ($level->params as $param) { - if (!isset($vars[$param->name])) { + if (!isset($vars[$param->name]) && substr($param->name, 0, strlen($namePrefix)) === $namePrefix) { $vars[$param->name] = $param; } } if ($level instanceof Node\Expr\Closure) { foreach ($level->uses as $use) { - if (!isset($vars[$param->name])) { + if (!isset($vars[$param->name]) && substr($param->name, 0, strlen($namePrefix)) === $namePrefix) { $vars[$use->var] = $use; } } @@ -155,9 +218,10 @@ class CompletionProvider * Searches the subnodes of a node for variable assignments * * @param Node $node + * @param string $namePrefix Prefix to filter * @return Node\Expr\Variable[] */ - private function findVariableDefinitionsInNode(Node $node): array + private function findVariableDefinitionsInNode(Node $node, string $namePrefix = ''): array { $vars = []; // If the child node is a variable assignment, save it @@ -166,6 +230,7 @@ class CompletionProvider $node instanceof Node\Expr\Variable && ($parent instanceof Node\Expr\Assign || $parent instanceof Node\Expr\AssignOp) && is_string($node->name) // Variable variables are of no use + && substr($node->name, 0, strlen($namePrefix)) === $namePrefix ) { $vars[] = $node; } @@ -181,7 +246,7 @@ class CompletionProvider if (!($child instanceof Node) || $child instanceof Node\FunctionLike) { continue; } - foreach ($this->findVariableDefinitionsInNode($child) as $var) { + foreach ($this->findVariableDefinitionsInNode($child, $namePrefix) as $var) { $vars[] = $var; } } diff --git a/src/Definition.php b/src/Definition.php index cba69ab..0459ff3 100644 --- a/src/Definition.php +++ b/src/Definition.php @@ -29,6 +29,21 @@ class Definition */ public $fqn; + /** + * Only true for classes, interfaces, traits, functions and non-class constants + * This is so methods and properties are not suggested in the global scope + * + * @var bool + */ + public $isGlobal; + + /** + * True if the Definition is a class + * + * @var bool + */ + public $canBeInstantiated; + /** * @var Protocol\SymbolInformation */ diff --git a/src/DefinitionResolver.php b/src/DefinitionResolver.php index 827fb55..b90d7b3 100644 --- a/src/DefinitionResolver.php +++ b/src/DefinitionResolver.php @@ -91,6 +91,30 @@ class DefinitionResolver } } + /** + * Create a Definition for a definition node + * + * @param Node $node + * @param string $fqn + * @return Definition + */ + public function createDefinitionFromNode(Node $node, string $fqn = null): Definition + { + $def = new Definition; + $def->canBeInstantiated = $node instanceof Node\Stmt\Class_; + $def->isGlobal = ( + $node instanceof Node\Stmt\ClassLike + || $node instanceof Node\Stmt\Function_ + || $node->getAttribute('parentNode') instanceof Node\Stmt\Const_ + ); + $def->fqn = $fqn; + $def->symbolInformation = SymbolInformation::fromNode($node, $fqn); + $def->type = $this->getTypeFromNode($node); + $def->declarationLine = $this->getDeclarationLineFromNode($node); + $def->documentation = $this->getDocumentationFromNode($node); + return $def; + } + /** * Given any node, returns the Definition object of the symbol that is referenced * @@ -106,16 +130,7 @@ class DefinitionResolver if ($defNode === null) { return null; } - $def = new Definition; - // Get symbol information from node (range, symbol kind) - $def->symbolInformation = SymbolInformation::fromNode($defNode); - // Declaration line - $def->declarationLine = $this->getDeclarationLineFromNode($defNode); - // Documentation - $def->documentation = $this->getDocumentationFromNode($defNode); - // Get type from docblock - $def->type = $this->getTypeFromNode($defNode); - return $def; + return $this->createDefinitionFromNode($defNode); } // Other references are references to a global symbol that have an FQN // Find out the FQN diff --git a/src/NodeVisitor/DefinitionCollector.php b/src/NodeVisitor/DefinitionCollector.php index 162f670..5198139 100644 --- a/src/NodeVisitor/DefinitionCollector.php +++ b/src/NodeVisitor/DefinitionCollector.php @@ -42,13 +42,6 @@ class DefinitionCollector extends NodeVisitorAbstract return; } $this->nodes[$fqn] = $node; - $def = new Definition; - $def->fqn = $fqn; - $def->symbolInformation = SymbolInformation::fromNode($node, $fqn); - $def->type = $this->definitionResolver->getTypeFromNode($node); - $def->declarationLine = $this->definitionResolver->getDeclarationLineFromNode($node); - $def->documentation = $this->definitionResolver->getDocumentationFromNode($node); - - $this->definitions[$fqn] = $def; + $this->definitions[$fqn] = $this->definitionResolver->createDefinitionFromNode($node, $fqn); } } diff --git a/src/Protocol/CompletionItem.php b/src/Protocol/CompletionItem.php index 75fbede..a764175 100644 --- a/src/Protocol/CompletionItem.php +++ b/src/Protocol/CompletionItem.php @@ -1,7 +1,10 @@ documentation = $documentation; $this->sortText = $sortText; $this->filterText = $filterText; - $this->insertQuery = $insertQuery; + $this->insertText = $insertText; $this->textEdit = $textEdit; $this->additionalTextEdits = $additionalTextEdits; $this->command = $command; $this->data = $data; } + + /** + * Creates a CompletionItem for a Definition + * + * @param Definition $def + * @return self + */ + public static function fromDefinition(Definition $def): self + { + $item = new CompletionItem; + $item->label = $def->symbolInformation->name; + $item->kind = CompletionItemKind::fromSymbolKind($def->symbolInformation->kind); + if ($def->type) { + $item->detail = (string)$def->type; + } else if ($def->symbolInformation->containerName) { + $item->detail = $def->symbolInformation->containerName; + } + if ($def->documentation) { + $item->documentation = $def->documentation; + } + return $item; + } } diff --git a/src/Protocol/CompletionItemKind.php b/src/Protocol/CompletionItemKind.php index 3c4e72f..046f8ef 100644 --- a/src/Protocol/CompletionItemKind.php +++ b/src/Protocol/CompletionItemKind.php @@ -25,4 +25,46 @@ abstract class CompletionItemKind const COLOR = 16; const FILE = 17; const REFERENCE = 18; + + /** + * Returns the CompletionItemKind for a SymbolKind + * + * @param int $kind A SymbolKind + * @return int The CompletionItemKind + */ + public static function fromSymbolKind(int $kind): int + { + switch ($kind) { + case SymbolKind::PROPERTY: + case SymbolKind::FIELD: + return self::PROPERTY; + case SymbolKind::METHOD: + return self::METHOD; + case SymbolKind::CLASS_: + return self::CLASS_; + case SymbolKind::INTERFACE: + return self::INTERFACE; + case SymbolKind::FUNCTION: + return self::FUNCTION; + case SymbolKind::NAMESPACE: + case SymbolKind::MODULE: + case SymbolKind::PACKAGE: + return self::MODULE; + case SymbolKind::FILE: + return self::FILE; + case SymbolKind::STRING: + return self::TEXT; + case SymbolKind::NUMBER: + case SymbolKind::BOOLEAN: + case SymbolKind::ARRAY: + return self::VALUE; + case SymbolKind::ENUM: + return self::ENUM; + case SymbolKind::CONSTRUCTOR: + return self::CONSTRUCTOR; + case SymbolKind::VARIABLE: + case SymbolKind::CONSTANT: + return self::VARIABLE; + } + } } diff --git a/tests/Server/TextDocument/CompletionTest.php b/tests/Server/TextDocument/CompletionTest.php index e493488..8f22ad0 100644 --- a/tests/Server/TextDocument/CompletionTest.php +++ b/tests/Server/TextDocument/CompletionTest.php @@ -26,6 +26,7 @@ class CompletionTest extends TestCase $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); $this->project = new Project($client, new ClientCapabilities); $this->project->loadDocument(pathToUri(__DIR__ . '/../../../fixtures/global_symbols.php'))->wait(); + $this->project->loadDocument(pathToUri(__DIR__ . '/../../../fixtures/symbols.php'))->wait(); $this->textDocument = new Server\TextDocument($this->project, $client); } @@ -53,7 +54,7 @@ class CompletionTest extends TestCase ], $items); } - public function testForVariables() + public function testVariable() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/variable.php'); $this->project->openDocument($completionUri, file_get_contents($completionUri)); @@ -66,4 +67,85 @@ class CompletionTest extends TestCase new CompletionItem('$param', CompletionItemKind::VARIABLE, 'string|null', 'A parameter') ], $items); } + + public function testVariableWithPrefix() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/variable_with_prefix.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(8, 5) + )->wait(); + $this->assertEquals([ + new CompletionItem('$param', CompletionItemKind::VARIABLE, 'string|null', 'A parameter') + ], $items); + } + + public function testNewInNamespace() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_new.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(6, 10) + )->wait(); + $this->assertEquals([ + // Global TestClass definition (inserted as \TestClass) + new CompletionItem( + 'TestClass', + CompletionItemKind::CLASS_, + null, + 'Pariatur ut laborum tempor voluptate consequat ea deserunt.', + null, + null, + '\TestClass' + ), + // Namespaced, `use`d TestClass definition (inserted as TestClass) + new CompletionItem( + 'TestClass', + CompletionItemKind::CLASS_, + 'TestNamespace', + 'Pariatur ut laborum tempor voluptate consequat ea deserunt.', + null, + null, + 'TestClass' + ), + ], $items); + } + + public function testUsedClass() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_class.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(6, 5) + )->wait(); + $this->assertEquals([ + new CompletionItem( + 'TestClass', + CompletionItemKind::CLASS_, + 'TestNamespace', + 'Pariatur ut laborum tempor voluptate consequat ea deserunt.' + ) + ], $items); + } + + public function testFullyQualifiedClass() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/fully_qualified_class.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(6, 6) + )->wait(); + $this->assertEquals([ + new CompletionItem( + 'TestClass', + CompletionItemKind::CLASS_, + null, + 'Pariatur ut laborum tempor voluptate consequat ea deserunt.' + ) + ], $items); + } }