diff --git a/fixtures/completion.php b/fixtures/completion.php new file mode 100644 index 00000000..677bcbbc --- /dev/null +++ b/fixtures/completion.php @@ -0,0 +1,4 @@ +t diff --git a/src/DefinitionResolver.php b/src/DefinitionResolver.php index a35750ee..64ae2475 100644 --- a/src/DefinitionResolver.php +++ b/src/DefinitionResolver.php @@ -327,7 +327,7 @@ public static function resolveVariableToNode(Node\Expr\Variable $var) * @param \PhpParser\Node\Expr $expr * @return \phpDocumentor\Type */ - private function resolveExpressionNodeToType(Node\Expr $expr): Type + public function resolveExpressionNodeToType(Node\Expr $expr): Type { if ($expr instanceof Node\Expr\Variable) { if ($expr->name === 'this') { diff --git a/src/LanguageServer.php b/src/LanguageServer.php index a2c1f524..7f884941 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -11,7 +11,8 @@ MessageType, InitializeResult, SymbolInformation, - TextDocumentIdentifier + TextDocumentIdentifier, + CompletionOptions }; use AdvancedJsonRpc; use Sabre\Event\{Loop, Promise}; @@ -140,6 +141,10 @@ public function initialize(int $processId, ClientCapabilities $capabilities, str $serverCapabilities->referencesProvider = true; // Support "Hover" $serverCapabilities->hoverProvider = true; + // Support "Completion" + $serverCapabilities->completionProvider = new CompletionOptions; + $serverCapabilities->completionProvider->resolveProvider = false; + $serverCapabilities->completionProvider->triggerCharacters = ['$', '>']; return new InitializeResult($serverCapabilities); } diff --git a/src/Protocol/CompletionItem.php b/src/Protocol/CompletionItem.php index 780f31f4..75fbeded 100644 --- a/src/Protocol/CompletionItem.php +++ b/src/Protocol/CompletionItem.php @@ -69,6 +69,24 @@ class CompletionItem */ public $textEdit; + /** + * An optional array of additional text edits that are applied when + * selecting this completion. Edits must not overlap with the main edit + * nor with themselves. + * + * @var TextEdit[]|null + */ + public $additionalTextEdits; + + /** + * An optional command that is executed *after* inserting this completion. *Note* that + * additional modifications to the current document should be described with the + * additionalTextEdits-property. + * + * @var Command|null + */ + public $command; + /** * An data entry field that is preserved on a completion item between * a completion and a completion resolve request. @@ -76,4 +94,43 @@ class CompletionItem * @var mixed */ public $data; + + /** + * @param string $label + * @param int|null $kind + * @param string|null $detail + * @param string|null $documentation + * @param string|null $sortText + * @param string|null $filterText + * @param string|null $insertQuery + * @param TextEdit|null $textEdit + * @param TextEdit[]|null $additionalTextEdits + * @param Command|null $command + * @param mixed|null $data + */ + public function __construct( + string $label = null, + int $kind = null, + string $detail = null, + string $documentation = null, + string $sortText = null, + string $filterText = null, + string $insertQuery = null, + TextEdit $textEdit = null, + array $additionalTextEdits = null, + Command $command = null, + $data = null + ) { + $this->label = $label; + $this->kind = $kind; + $this->detail = $detail; + $this->documentation = $documentation; + $this->sortText = $sortText; + $this->filterText = $filterText; + $this->insertQuery = $insertQuery; + $this->textEdit = $textEdit; + $this->additionalTextEdits = $additionalTextEdits; + $this->command = $command; + $this->data = $data; + } } diff --git a/src/Protocol/CompletionItemKind.php b/src/Protocol/CompletionItemKind.php index 6ef57963..3c4e72f0 100644 --- a/src/Protocol/CompletionItemKind.php +++ b/src/Protocol/CompletionItemKind.php @@ -13,7 +13,7 @@ abstract class CompletionItemKind const CONSTRUCTOR = 4; const FIELD = 5; const VARIABLE = 6; - const _CLASS = 7; + const CLASS_ = 7; const INTERFACE = 8; const MODULE = 9; const PROPERTY = 10; diff --git a/src/Protocol/CompletionOptions.php b/src/Protocol/CompletionOptions.php index 0be727ed..f668ca0c 100644 --- a/src/Protocol/CompletionOptions.php +++ b/src/Protocol/CompletionOptions.php @@ -11,14 +11,14 @@ class CompletionOptions * The server provides support to resolve additional information for a completion * item. * - * @var bool + * @var bool|null */ public $resolveProvider; /** * The characters that trigger completion automatically. * - * @var string|null + * @var string[]|null */ public $triggerCharacters; } diff --git a/src/Server/TextDocument.php b/src/Server/TextDocument.php index 6c673888..a04407dc 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -18,8 +18,12 @@ SymbolInformation, ReferenceContext, Hover, - MarkedString + MarkedString, + SymbolKind, + CompletionItem, + CompletionItemKind }; +use phpDocumentor\Reflection\Types; use Sabre\Event\Promise; use function Sabre\Event\coroutine; @@ -210,4 +214,64 @@ public function hover(TextDocumentIdentifier $textDocument, Position $position): return new Hover($contents, $range); }); } + + /** + * The Completion request is sent from the client to the server to compute completion items at a given cursor + * position. Completion items are presented in the IntelliSense user interface. If computing full completion items + * is expensive, servers can additionally provide a handler for the completion item resolve request + * ('completionItem/resolve'). This request is sent when a completion item is selected in the user interface. A + * typically use case is for example: the 'textDocument/completion' request doesn't fill in the documentation + * property for returned completion items since it is expensive to compute. When the item is selected in the user + * interface then a 'completionItem/resolve' request is sent with the selected completion item as a param. The + * returned completion item should have the documentation property filled in. + * + * @param TextDocumentIdentifier The text document + * @param Position $position The position + * @return Promise + */ + public function completion(TextDocumentIdentifier $textDocument, Position $position): Promise + { + return coroutine(function () use ($textDocument, $position) { + $document = yield $this->project->getOrLoadDocument($textDocument->uri); + $node = $document->getNodeAtPosition($position); + if ($node === null) { + return []; + } + if ($node instanceof Node\Expr\Error) { + $node = $node->getAttribute('parentNode'); + } + if ($node instanceof Node\Expr\PropertyFetch) { + // Resolve object + $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; + } + $prefixLen = strlen($prefix); + $items = []; + 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; + } + 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; + } + } + return $items; + } + } + return []; + }); + } } diff --git a/tests/LanguageServerTest.php b/tests/LanguageServerTest.php index 272c458a..98d5d362 100644 --- a/tests/LanguageServerTest.php +++ b/tests/LanguageServerTest.php @@ -36,7 +36,10 @@ public function testInitialize() 'textDocumentSync' => TextDocumentSyncKind::FULL, 'documentSymbolProvider' => true, 'hoverProvider' => true, - 'completionProvider' => null, + 'completionProvider' => (object)[ + 'resolveProvider' => false, + 'triggerCharacters' => ['$', '>'] + ], 'signatureHelpProvider' => null, 'definitionProvider' => true, 'referencesProvider' => true, @@ -61,7 +64,7 @@ public function testIndexingWithDirectFileAccess() if ($msg->body->method === 'window/logMessage' && $promise->state === Promise::PENDING) { if ($msg->body->params->type === MessageType::ERROR) { $promise->reject(new Exception($msg->body->params->message)); - } else if (strpos($msg->body->params->message, 'All 10 PHP files parsed') !== false) { + } else if (strpos($msg->body->params->message, 'All 11 PHP files parsed') !== false) { $promise->fulfill(); } } @@ -106,7 +109,7 @@ public function testIndexingWithFilesAndContentRequests() if ($promise->state === Promise::PENDING) { $promise->reject(new Exception($msg->body->params->message)); } - } else if (strpos($msg->body->params->message, 'All 10 PHP files parsed') !== false) { + } else if (strpos($msg->body->params->message, 'All 11 PHP files parsed') !== false) { // Indexing finished $promise->fulfill(); } diff --git a/tests/Server/TextDocument/CompletionTest.php b/tests/Server/TextDocument/CompletionTest.php new file mode 100644 index 00000000..5aee8f27 --- /dev/null +++ b/tests/Server/TextDocument/CompletionTest.php @@ -0,0 +1,55 @@ +completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion.php'); + $project->loadDocument(pathToUri(__DIR__ . '/../../../fixtures/global_symbols.php')); + $project->openDocument($this->completionUri, file_get_contents($this->completionUri)); + $this->textDocument = new Server\TextDocument($project, $client); + } + + public function testCompletion() + { + $items = $this->textDocument->completion( + new TextDocumentIdentifier($this->completionUri), + new Position(3, 7) + )->wait(); + $this->assertEquals([ + 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.' + ) + ], $items); + } +}