From d6200fc07ad6e1eacf9b54c2e54f740b69f87062 Mon Sep 17 00:00:00 2001 From: Michal Niewrzal Date: Thu, 20 Oct 2016 14:01:00 +0200 Subject: [PATCH] Initial implemention for code completion --- src/Completion/CompletionContext.php | 81 +++++++++++++ src/Completion/CompletionReporter.php | 98 ++++++++++++++++ src/Completion/ICompletionStrategy.php | 15 +++ .../Strategies/ClassMembersStrategy.php | 57 ++++++++++ .../Strategies/GlobalElementsStrategy.php | 40 +++++++ .../Strategies/KeywordsStrategy.php | 106 ++++++++++++++++++ .../Strategies/VariablesStrategy.php | 65 +++++++++++ src/LanguageServer.php | 10 +- src/PhpDocument.php | 22 +++- src/Protocol/CompletionItemKind.php | 38 +++++++ src/Protocol/CompletionOptions.php | 2 +- src/Server/CompletionItemResolver.php | 105 +++++++++++++++++ src/Server/TextDocument.php | 13 +++ tests/LanguageServerTest.php | 9 +- 14 files changed, 655 insertions(+), 6 deletions(-) create mode 100644 src/Completion/CompletionContext.php create mode 100644 src/Completion/CompletionReporter.php create mode 100644 src/Completion/ICompletionStrategy.php create mode 100644 src/Completion/Strategies/ClassMembersStrategy.php create mode 100644 src/Completion/Strategies/GlobalElementsStrategy.php create mode 100644 src/Completion/Strategies/KeywordsStrategy.php create mode 100644 src/Completion/Strategies/VariablesStrategy.php create mode 100644 src/Server/CompletionItemResolver.php diff --git a/src/Completion/CompletionContext.php b/src/Completion/CompletionContext.php new file mode 100644 index 0000000..22220c1 --- /dev/null +++ b/src/Completion/CompletionContext.php @@ -0,0 +1,81 @@ +phpDocument = $phpDocument; + $this->lines = explode("\n", $this->phpDocument->getContent()); + } + + public function getReplacementRange(): Range + { + $line = $this->getLine($this->position->line); + if (!empty($line)) { + // modified regexp from http://php.net/manual/en/language.variables.basics.php + if (preg_match_all('@\$?[a-zA-Z_\x7f-\xff]?[a-zA-Z0-9_\x7f-\xff]*@', $line, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[0] as $match) { + if (!empty($match[0])) { + $start = new Position($this->position->line, $match[1]); + $end = new Position($this->position->line, $match[1] + strlen($match[0])); + $range = new Range($start, $end); + if ($range->includes($this->position)) { + return $range; + } + } + } + } + } + return new Range($this->position, $this->position); + } + + public function getPosition() + { + return $this->position; + } + + public function setPosition(Position $position) + { + $this->position = $position; + } + + public function getLine(int $line) + { + if (count($this->lines) <= $line) { + return null; + } + return $this->lines[$line]; + } + + public function getPhpDocument() + { + return $this->phpDocument; + } +} diff --git a/src/Completion/CompletionReporter.php b/src/Completion/CompletionReporter.php new file mode 100644 index 0000000..25c0f2f --- /dev/null +++ b/src/Completion/CompletionReporter.php @@ -0,0 +1,98 @@ +context = new CompletionContext($phpDocument); + $this->strategies = [ + new KeywordsStrategy(), + new VariablesStrategy(), + new ClassMembersStrategy(), + new GlobalElementsStrategy() + ]; + } + + public function complete(Position $position) + { + $this->completionItems = []; + $this->context->setPosition($position); + foreach ($this->strategies as $strategy) { + $strategy->apply($this->context, $this); + } + } + + public function reportByNode(Node $node, Range $editRange, string $fqn = null) + { + if (!$node) { + return; + } + + if ($node instanceof \PhpParser\Node\Stmt\Property) { + foreach ($node->props as $prop) { + $this->reportByNode($prop, $editRange, $fqn); + } + } else if ($node instanceof \PhpParser\Node\Stmt\ClassConst) { + foreach ($node->consts as $const) { + $this->reportByNode($const, $editRange, $fqn); + } + } else { + $this->report($node->name, CompletionItemKind::fromNode($node), $node->name, $editRange, $fqn); + } + } + + public function report(string $label, int $kind, string $insertText, Range $editRange, string $fqn = null) + { + $item = new CompletionItem(); + $item->label = $label; + $item->kind = $kind; + $item->textEdit = new TextEdit($editRange, $insertText); + $item->data = $fqn; + + $this->completionItems[] = $item; + } + + /** + * + * @return CompletionList + */ + public function getCompletionList(): CompletionList + { + $completionList = new CompletionList(); + $completionList->isIncomplete = false; + $completionList->items = $this->completionItems; + return $completionList; + } +} diff --git a/src/Completion/ICompletionStrategy.php b/src/Completion/ICompletionStrategy.php new file mode 100644 index 0000000..6b8a79a --- /dev/null +++ b/src/Completion/ICompletionStrategy.php @@ -0,0 +1,15 @@ +isValidContext($context)) { + return; + } + $range = $context->getReplacementRange(); + $nodes = $context->getPhpDocument()->getDefinitions(); + foreach ($nodes as $fqn => $node) { + if ($node instanceof \PhpParser\Node\Stmt\ClassLike) { + $nodeRange = Range::fromNode($node); + if ($nodeRange->includes($context->getPosition())) { + foreach ($nodes as $childFqn => $child) { + if (stripos($childFqn, $fqn) == 0 && $childFqn !== $fqn) { + $reporter->reportByNode($child, $range, $childFqn); + } + } + return; + } + } + } + } + + private function isValidContext(CompletionContext $context) + { + $line = $context->getLine($context->getPosition()->line); + if (empty($line)) { + return false; + } + $range = $context->getReplacementRange($context); + if (preg_match_all('@(\$this->|self::)@', $line, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[0] as $match) { + if (($match[1] + strlen($match[0])) === $range->start->character) { + return true; + } + } + } + return false; + } +} diff --git a/src/Completion/Strategies/GlobalElementsStrategy.php b/src/Completion/Strategies/GlobalElementsStrategy.php new file mode 100644 index 0000000..0266731 --- /dev/null +++ b/src/Completion/Strategies/GlobalElementsStrategy.php @@ -0,0 +1,40 @@ +getReplacementRange($context); + $project = $context->getPhpDocument()->project; + foreach ($project->getSymbols() as $fqn => $symbol) { + if ($this->isValid($symbol)) { + $kind = CompletionItemKind::fromSymbol($symbol->kind); + $reporter->report($symbol->name, $kind, $symbol->name, $range, $fqn); + } + } + } + + private function isValid(SymbolInformation $symbol) + { + return $symbol->kind == SymbolKind::CLASS_ + || $symbol->kind == SymbolKind::INTERFACE + || $symbol->kind == SymbolKind::FUNCTION; + } + +} diff --git a/src/Completion/Strategies/KeywordsStrategy.php b/src/Completion/Strategies/KeywordsStrategy.php new file mode 100644 index 0000000..d259720 --- /dev/null +++ b/src/Completion/Strategies/KeywordsStrategy.php @@ -0,0 +1,106 @@ +getReplacementRange(); + foreach (self::KEYWORDS as $keyword) { + $reporter->report($keyword, CompletionItemKind::KEYWORD, $keyword, $range); + } + } +} diff --git a/src/Completion/Strategies/VariablesStrategy.php b/src/Completion/Strategies/VariablesStrategy.php new file mode 100644 index 0000000..d84323c --- /dev/null +++ b/src/Completion/Strategies/VariablesStrategy.php @@ -0,0 +1,65 @@ +getReplacementRange(); + $symbols = $context->getPhpDocument()->getSymbols(); + $contextSymbol = null; + foreach ($symbols as $symbol) { + if ($this->isValid($symbol) && $symbol->location->range->includes($context->getPosition())) { + $contextSymbol = $symbol; + } + } + + if ($contextSymbol !== null) { + $content = ''; + $start = $contextSymbol->location->range->start; + $end = $contextSymbol->location->range->end; + for ($i = $start->line; $i <= $end->line; $i++) { + $content .= $context->getLine($i); + } + } else { + $content = $context->getPhpDocument()->getContent(); + } + + if (preg_match_all('@\$[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*@', $content, $matches, PREG_OFFSET_CAPTURE)) { + $variables = []; + foreach ($matches[0] as $match) { + $variables[] = $match[0]; + } + + $variables = array_unique($variables); + + foreach ($variables as $variable) { + $reporter->report($variable, CompletionItemKind::VARIABLE, $variable, $range); + } + } + } + + private function isValid(SymbolInformation $symbol) + { + return $symbol->kind == SymbolKind::FUNCTION || $symbol->kind == SymbolKind::METHOD; + } + +} diff --git a/src/LanguageServer.php b/src/LanguageServer.php index 04d7c5e..352eda3 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -11,11 +11,10 @@ use LanguageServer\Protocol\{ Message, MessageType, InitializeResult, - SymbolInformation + CompletionOptions }; use AdvancedJsonRpc; use Sabre\Event\Loop; -use JsonMapper; use Exception; use Throwable; @@ -87,6 +86,7 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher $this->textDocument = new Server\TextDocument($this->project, $this->client); $this->workspace = new Server\Workspace($this->project, $this->client); + $this->completionItem = new Server\CompletionItemResolver($this->project); } /** @@ -122,7 +122,11 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher $serverCapabilities->referencesProvider = true; // Support "Hover" $serverCapabilities->hoverProvider = true; - + // Support code completion + $completionOptions = new CompletionOptions(); + $completionOptions->resolveProvider = true; + $completionOptions->triggerCharacters = [':', '$', '>']; + $serverCapabilities->completionProvider = $completionOptions; return new InitializeResult($serverCapabilities); } diff --git a/src/PhpDocument.php b/src/PhpDocument.php index 02bb854..ea5d732 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -3,7 +3,7 @@ declare(strict_types = 1); namespace LanguageServer; -use LanguageServer\Protocol\{Diagnostic, DiagnosticSeverity, Range, Position, TextEdit}; +use LanguageServer\Protocol\{Diagnostic, DiagnosticSeverity, Position, TextEdit}; use LanguageServer\NodeVisitor\{ NodeAtPositionFinder, ReferencesAdder, @@ -17,6 +17,7 @@ use PhpParser\{Error, Node, NodeTraverser, Parser}; use PhpParser\NodeVisitor\NameResolver; use phpDocumentor\Reflection\DocBlockFactory; use function LanguageServer\Fqn\{getDefinedFqn, getVariableDefinition, getReferencedFqn}; +use LanguageServer\Completion\CompletionReporter; class PhpDocument { @@ -92,6 +93,12 @@ class PhpDocument */ private $symbols; + /** + * + * @var \LanguageServer\Completion\CompletionReporter + */ + private $completionReporter; + /** * @param string $uri The URI of the document * @param string $content The content of the document @@ -132,6 +139,8 @@ class PhpDocument public function updateContent(string $content) { $this->content = $content; + $this->completionReporter = new CompletionReporter($this); + $stmts = null; $errors = []; try { @@ -229,6 +238,17 @@ class PhpDocument return Formatter::format($this->content, $this->uri); } + /** + * @param Position $position + * + * @return \LanguageServer\Protocol\CompletionList + */ + public function complete(Position $position) + { + $this->completionReporter->complete($position); + return $this->completionReporter->getCompletionList(); + } + /** * Returns this document's text content. * diff --git a/src/Protocol/CompletionItemKind.php b/src/Protocol/CompletionItemKind.php index 9a2bd15..043169d 100644 --- a/src/Protocol/CompletionItemKind.php +++ b/src/Protocol/CompletionItemKind.php @@ -2,6 +2,8 @@ namespace LanguageServer\Protocol; +use PhpParser\Node; + /** * The kind of a completion entry. */ @@ -24,4 +26,40 @@ abstract class CompletionItemKind { const COLOR = 16; const FILE = 17; const REFERENCE = 18; + + public static function fromSymbol(int $symbolKind) + { + $symbolCompletionKindMap = [ + SymbolKind::CLASS_ => CompletionItemKind::_CLASS, + SymbolKind::INTERFACE => CompletionItemKind::INTERFACE, + SymbolKind::FUNCTION => CompletionItemKind::FUNCTION, + SymbolKind::METHOD => CompletionItemKind::METHOD, + SymbolKind::FIELD => CompletionItemKind::FIELD, + SymbolKind::CONSTRUCTOR => CompletionItemKind::CONSTRUCTOR, + SymbolKind::VARIABLE => CompletionItemKind::VARIABLE, + ]; + + return $symbolCompletionKindMap[$symbolKind]; + } + + public static function fromNode(Node $node) + { + $nodeCompletionKindMap = [ + Node\Stmt\Class_::class => CompletionItemKind::_CLASS, + Node\Stmt\Trait_::class => CompletionItemKind::_CLASS, + Node\Stmt\Interface_::class => CompletionItemKind::INTERFACE, + + Node\Stmt\Function_::class => CompletionItemKind::FUNCTION, + Node\Stmt\ClassMethod::class => CompletionItemKind::METHOD, + Node\Stmt\PropertyProperty::class => CompletionItemKind::PROPERTY, + Node\Const_::class => CompletionItemKind::FIELD + ]; + $class = get_class($node); + if (!isset($nodeCompletionKindMap[$class])) { + throw new Exception("Not a declaration node: $class"); + } + + return $nodeCompletionKindMap[$class]; + } + } diff --git a/src/Protocol/CompletionOptions.php b/src/Protocol/CompletionOptions.php index 0be727e..b1b1c7c 100644 --- a/src/Protocol/CompletionOptions.php +++ b/src/Protocol/CompletionOptions.php @@ -18,7 +18,7 @@ class CompletionOptions /** * The characters that trigger completion automatically. * - * @var string|null + * @var string[]|null */ public $triggerCharacters; } diff --git a/src/Server/CompletionItemResolver.php b/src/Server/CompletionItemResolver.php new file mode 100644 index 0000000..7a76566 --- /dev/null +++ b/src/Server/CompletionItemResolver.php @@ -0,0 +1,105 @@ +project = $project; + $this->docBlockFactory = DocBlockFactory::createInstance(); + } + + /** + * The request is sent from the client to the server to resolve additional information for a given completion item. + * + * @param string $label + * @param int $kind + * @param TextEdit $textEdit + * @param string $data + * + * @return \LanguageServer\Protocol\CompletionItem + */ + public function resolve($label, $kind, $textEdit, $data) + { + $item = new CompletionItem(); + $item->label = $label; + $item->kind = $kind; + $item->textEdit = $textEdit; + + if (!isset($data)) { + return $item; + } + + $fqn = $data; + $phpDocument = $this->project->getDefinitionDocument($fqn); + if (!$phpDocument) { + return $item; + } + + $node = $phpDocument->getDefinitionByFqn($fqn); + if (!isset($node)) { + return $item; + } + $item->detail = $this->generateItemDetails($node); + $item->documentation = $this->getDocumentation($node); + return $item; + } + + private function generateItemDetails(Node $node) + { + if ($node instanceof \PhpParser\Node\FunctionLike) { + return $this->generateFunctionSignature($node); + } + if (isset($node->namespacedName)) { + return '\\' . ((string) $node->namespacedName); + } + return ''; + } + + private function generateFunctionSignature(\PhpParser\Node\FunctionLike $node) + { + $params = []; + foreach ($node->getParams() as $param) { + $label = $param->type ? ((string) $param->type) . ' ' : ''; + $label .= '$' . $param->name; + $params[] = $label; + } + $signature = '(' . implode(', ', $params) . ')'; + if ($node->getReturnType()) { + $signature .= ': ' . $node->getReturnType(); + } + return $signature; + } + + private function getDocumentation(Node $node) + { + // Get the documentation string + $contents = ''; + $docBlock = $node->getAttribute('docBlock'); + if ($docBlock !== null) { + $contents .= $docBlock->getSummary() . "\n\n"; + $contents .= $docBlock->getDescription(); + } + return $contents; + } +} diff --git a/src/Server/TextDocument.php b/src/Server/TextDocument.php index 188e629..abd2c5b 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -223,4 +223,17 @@ class TextDocument return new Hover($contents, $range); } + + /** + * @param \LanguageServer\Protocol\TextDocumentIdentifier $textDocument + * @param \LanguageServer\Protocol\Position $position + * + * @return \LanguageServer\Protocol\CompletionList + */ + public function completion(TextDocumentIdentifier $textDocument, Position $position) + { + $document = $this->project->getDocument($textDocument->uri); + return $document->complete($position); + } + } diff --git a/tests/LanguageServerTest.php b/tests/LanguageServerTest.php index 18b580e..75c35a8 100644 --- a/tests/LanguageServerTest.php +++ b/tests/LanguageServerTest.php @@ -31,7 +31,14 @@ class LanguageServerTest extends TestCase 'textDocumentSync' => TextDocumentSyncKind::FULL, 'documentSymbolProvider' => true, 'hoverProvider' => true, - 'completionProvider' => null, + 'completionProvider' => (object)[ + 'resolveProvider' => true, + 'triggerCharacters' => [ + ':', + '$', + '>', + ] + ], 'signatureHelpProvider' => null, 'definitionProvider' => true, 'referencesProvider' => true,