From f6a7ce1a8b24a6b3768259c2075397e4764bd8b4 Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Thu, 24 Nov 2016 12:27:55 +0100 Subject: [PATCH] Completion for keywords and bug fixes --- fixtures/completion/html.php | 0 fixtures/completion/html_with_prefix.php | 1 + fixtures/completion/keywords.php | 3 + src/CompletionProvider.php | 103 +++++++++++++++++-- src/Definition.php | 7 ++ src/DefinitionResolver.php | 37 ++++--- src/PhpDocument.php | 16 +++ src/Protocol/CompletionItem.php | 3 + src/Protocol/Position.php | 12 +++ src/utils.php | 22 ++++ tests/LanguageServerTest.php | 4 +- tests/Server/TextDocument/CompletionTest.php | 80 +++++++++++++- tests/Utils/StripStringOverlapTest.php | 45 ++++++++ 13 files changed, 308 insertions(+), 25 deletions(-) create mode 100644 fixtures/completion/html.php create mode 100644 fixtures/completion/html_with_prefix.php create mode 100644 fixtures/completion/keywords.php create mode 100644 tests/Utils/StripStringOverlapTest.php diff --git a/fixtures/completion/html.php b/fixtures/completion/html.php new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/completion/html_with_prefix.php b/fixtures/completion/html_with_prefix.php new file mode 100644 index 0000000..9318418 --- /dev/null +++ b/fixtures/completion/html_with_prefix.php @@ -0,0 +1 @@ +< diff --git a/fixtures/completion/keywords.php b/fixtures/completion/keywords.php new file mode 100644 index 0000000..76fc7cb --- /dev/null +++ b/fixtures/completion/keywords.php @@ -0,0 +1,3 @@ +', + '__halt_compiler', + 'abstract', + 'and', + 'array', + 'as', + 'break', + 'callable', + 'case', + 'catch', + 'class', + 'clone', + 'const', + 'continue', + 'declare', + 'default', + 'die', + 'do', + 'echo', + 'else', + 'elseif', + 'empty', + 'enddeclare', + 'endfor', + 'endforeach', + 'endif', + 'endswitch', + 'endwhile', + 'eval', + 'exit', + 'extends', + 'final', + 'finally', + 'for', + 'foreach', + 'function', + 'global', + 'goto', + 'if', + 'implements', + 'include', + 'include_once', + 'instanceof', + 'insteadof', + 'interface', + 'isset', + 'list', + 'namespace', + 'new', + 'or', + 'print', + 'private', + 'protected', + 'public', + 'require', + 'require_once', + 'return', + 'static', + 'switch', + 'throw', + 'trait', + 'try', + 'unset', + 'use', + 'var', + 'while', + 'xor', + 'yield' + ]; + /** * @var DefinitionResolver */ @@ -38,12 +111,12 @@ class CompletionProvider * Returns suggestions for a specific cursor position in a document * * @param PhpDocument $document The opened document - * @param Position $position The cursor position + * @param Position $pos The cursor position * @return CompletionItem[] */ - public function provideCompletion(PhpDocument $document, Position $position): array + public function provideCompletion(PhpDocument $document, Position $pos): array { - $node = $document->getNodeAtPosition($position); + $node = $document->getNodeAtPosition($pos); if ($node instanceof Node\Expr\Error) { $node = $node->getAttribute('parentNode'); @@ -93,11 +166,12 @@ class CompletionProvider } } } else if ( - // A ConstFetch means any static reference, like a class, interface, etc. + // A ConstFetch means any static reference, like a class, interface, etc. or keyword ($node instanceof Node\Name && $node->getAttribute('parentNode') instanceof Node\Expr\ConstFetch) || $node instanceof Node\Expr\New_ ) { - $prefix = null; + $prefix = ''; + $prefixLen = 0; if ($node instanceof Node\Name) { $isFullyQualified = $node->isFullyQualified(); $prefix = (string)$node; @@ -115,7 +189,7 @@ class CompletionProvider 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); + $fqn = (string)Node\Name::concat($stmt->prefix ?? null, $use->name); $aliasedDefs[$use->alias] = $this->project->getDefinition($fqn); } } else { @@ -165,6 +239,16 @@ class CompletionProvider $items[] = $item; } } + // Suggest keywords + if ($node instanceof Node\Name && $node->getAttribute('parentNode') instanceof Node\Expr\ConstFetch) { + foreach (self::KEYWORDS as $keyword) { + if (substr($keyword, 0, $prefixLen) === $prefix) { + $item = new CompletionItem($keyword, CompletionItemKind::KEYWORD); + $item->insertText = $keyword . ' '; + $items[] = $item; + } + } + } } else if ( $node instanceof Node\Expr\Variable || ($node && $node->getAttribute('parentNode') instanceof Node\Expr\Variable) @@ -180,6 +264,13 @@ class CompletionProvider $item->detail = (string)$this->definitionResolver->getTypeFromNode($var); $items[] = $item; } + } else if ($node instanceof Node\Stmt\InlineHTML || $pos == new Position(0, 0)) { + $item = new CompletionItem('textEdit = new TextEdit( + new Range($pos, $pos), + stripStringOverlap($document->getRange(new Range(new Position(0, 0), $pos)), 'getAttribute('parentNode') instanceof Node\Stmt\Const_ ); + $def->isStatic = ( + ($node instanceof Node\Stmt\ClassMethod && $node->isStatic()) + || ($node instanceof Node\Stmt\PropertyProperty && $node->getAttribute('parentNode')->isStatic()) + ); $def->fqn = $fqn; $def->symbolInformation = SymbolInformation::fromNode($node, $fqn); $def->type = $this->getTypeFromNode($node); @@ -664,28 +668,35 @@ class DefinitionResolver if ($node instanceof Node\Param) { // Parameters $docBlock = $node->getAttribute('parentNode')->getAttribute('docBlock'); - if ( - $docBlock !== null - && !empty($paramTags = $docBlock->getTagsByName('param')) - && $paramTags[0]->getType() !== null - ) { + if ($docBlock !== null) { // Use @param tag - return $paramTags[0]->getType(); + foreach ($docBlock->getTagsByName('param') as $paramTag) { + if ($paramTag->getVariableName() === $node->name) { + if ($paramTag->getType() === null) { + break; + } + return $paramTag->getType(); + } + } } if ($node->type !== null) { // Use PHP7 return type hint if (is_string($node->type)) { // Resolve a string like "bool" to a type object $type = $this->typeResolver->resolve($node->type); - } - $type = new Types\Object_(new Fqsen('\\' . (string)$node->type)); - if ($node->default !== null) { - $defaultType = $this->resolveExpressionNodeToType($node->default); - $type = new Types\Compound([$type, $defaultType]); + } else { + $type = new Types\Object_(new Fqsen('\\' . (string)$node->type)); } } - // Unknown parameter type - return new Types\Mixed; + if ($node->default !== null) { + $defaultType = $this->resolveExpressionNodeToType($node->default); + if (isset($type) && !is_a($type, get_class($defaultType))) { + $type = new Types\Compound([$type, $defaultType]); + } else { + $type = $defaultType; + } + } + return $type ?? new Types\Mixed; } if ($node instanceof Node\FunctionLike) { // Functions/methods diff --git a/src/PhpDocument.php b/src/PhpDocument.php index d37ec0a..cf16495 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -300,6 +300,22 @@ class PhpDocument return $finder->node; } + /** + * Returns a range of the content + * + * @param Range $range + * @return string|null + */ + public function getRange(Range $range) + { + if ($this->content === null) { + return null; + } + $start = $range->start->toOffset($this->content); + $length = $range->end->toOffset($this->content) - $start; + return substr($this->content, $start, $length); + } + /** * Returns the definition node for a fully qualified name * diff --git a/src/Protocol/CompletionItem.php b/src/Protocol/CompletionItem.php index a764175..64bc69d 100644 --- a/src/Protocol/CompletionItem.php +++ b/src/Protocol/CompletionItem.php @@ -156,6 +156,9 @@ class CompletionItem if ($def->documentation) { $item->documentation = $def->documentation; } + if ($def->isStatic && $def->symbolInformation->kind === SymbolKind::PROPERTY) { + $item->insertText = '$' . $def->symbolInformation->name; + } return $item; } } diff --git a/src/Protocol/Position.php b/src/Protocol/Position.php index 01cff0b..1012fde 100644 --- a/src/Protocol/Position.php +++ b/src/Protocol/Position.php @@ -49,4 +49,16 @@ class Position return $this->character - $position->character; } + + /** + * Returns the offset of the position in a string + * + * @param string $content + * @return int + */ + public function toOffset(string $content): int + { + $lines = explode("\n", $content); + return array_sum(array_map('strlen', array_slice($lines, 0, $this->line))) + $this->character; + } } diff --git a/src/utils.php b/src/utils.php index 172ccac..ed7a419 100644 --- a/src/utils.php +++ b/src/utils.php @@ -95,3 +95,25 @@ function getClosestNode(Node $node, string $type) } } } + +/** + * Returns the part of $b that is not overlapped by $a + * Example: + * + * stripStringOverlap('whateverbody->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 21 PHP files parsed') !== false) { + } else if (strpos($msg->body->params->message, 'All 24 PHP files parsed') !== false) { $promise->fulfill(); } } @@ -109,7 +109,7 @@ class LanguageServerTest extends TestCase if ($promise->state === Promise::PENDING) { $promise->reject(new Exception($msg->body->params->message)); } - } else if (strpos($msg->body->params->message, 'All 21 PHP files parsed') !== false) { + } else if (strpos($msg->body->params->message, 'All 24 PHP files parsed') !== false) { // Indexing finished $promise->fulfill(); } diff --git a/tests/Server/TextDocument/CompletionTest.php b/tests/Server/TextDocument/CompletionTest.php index e5ab259..a7afb0d 100644 --- a/tests/Server/TextDocument/CompletionTest.php +++ b/tests/Server/TextDocument/CompletionTest.php @@ -5,8 +5,16 @@ namespace LanguageServer\Tests\Server\TextDocument; use PHPUnit\Framework\TestCase; use LanguageServer\Tests\MockProtocolStream; -use LanguageServer\{Server, LanguageClient, Project}; -use LanguageServer\Protocol\{TextDocumentIdentifier, Position, ClientCapabilities, CompletionItem, CompletionItemKind}; +use LanguageServer\{Server, LanguageClient, Project, CompletionProvider}; +use LanguageServer\Protocol\{ + TextDocumentIdentifier, + TextEdit, + Range, + Position, + ClientCapabilities, + CompletionItem, + CompletionItemKind +}; use function LanguageServer\pathToUri; class CompletionTest extends TestCase @@ -168,7 +176,10 @@ class CompletionTest extends TestCase 'staticTestProperty', CompletionItemKind::PROPERTY, '\TestClass[]', - 'Lorem excepteur officia sit anim velit veniam enim.' + 'Lorem excepteur officia sit anim velit veniam enim.', + null, + null, + '$staticTestProperty' ) ], $items); } @@ -192,7 +203,10 @@ class CompletionTest extends TestCase 'staticTestProperty', CompletionItemKind::PROPERTY, '\TestClass[]', - 'Lorem excepteur officia sit anim velit veniam enim.' + 'Lorem excepteur officia sit anim velit veniam enim.', + null, + null, + '$staticTestProperty' ), new CompletionItem( 'staticTestMethod', @@ -259,4 +273,62 @@ class CompletionTest extends TestCase ) ], $items); } + + public function testKeywords() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/keywords.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(2, 1) + )->wait(); + $this->assertEquals([ + new CompletionItem('class', CompletionItemKind::KEYWORD, null, null, null, null, 'class '), + new CompletionItem('clone', CompletionItemKind::KEYWORD, null, null, null, null, 'clone ') + ], $items); + } + + public function testHtmlWithoutPrefix() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(0, 0) + )->wait(); + $this->assertEquals([ + new CompletionItem( + 'project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(0, 1) + )->wait(); + $this->assertEquals([ + new CompletionItem( + 'assertEquals('assertEquals('?php', stripStringOverlap('bla<', 'assertEquals('php', stripStringOverlap('blaassertEquals('', stripStringOverlap('blaassertEquals('assertEquals('', stripStringOverlap('bla', '')); + } + + public function testBothEmpty() + { + $this->assertEquals('', stripStringOverlap('', '')); + } +}