1
0
Fork 0

Completion for keywords and bug fixes

pull/165/head
Felix Becker 2016-11-24 12:27:55 +01:00
parent 6adb3f48e1
commit f6a7ce1a8b
13 changed files with 308 additions and 25 deletions

View File

View File

@ -0,0 +1 @@
<

View File

@ -0,0 +1,3 @@
<?php
cl

View File

@ -6,6 +6,8 @@ namespace LanguageServer;
use PhpParser\Node; use PhpParser\Node;
use phpDocumentor\Reflection\Types; use phpDocumentor\Reflection\Types;
use LanguageServer\Protocol\{ use LanguageServer\Protocol\{
TextEdit,
Range,
Position, Position,
SymbolKind, SymbolKind,
CompletionItem, CompletionItem,
@ -14,6 +16,77 @@ use LanguageServer\Protocol\{
class CompletionProvider class CompletionProvider
{ {
const KEYWORDS = [
'?>',
'__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 * @var DefinitionResolver
*/ */
@ -38,12 +111,12 @@ class CompletionProvider
* Returns suggestions for a specific cursor position in a document * Returns suggestions for a specific cursor position in a document
* *
* @param PhpDocument $document The opened document * @param PhpDocument $document The opened document
* @param Position $position The cursor position * @param Position $pos The cursor position
* @return CompletionItem[] * @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) { if ($node instanceof Node\Expr\Error) {
$node = $node->getAttribute('parentNode'); $node = $node->getAttribute('parentNode');
@ -93,11 +166,12 @@ class CompletionProvider
} }
} }
} else if ( } 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\Name && $node->getAttribute('parentNode') instanceof Node\Expr\ConstFetch)
|| $node instanceof Node\Expr\New_ || $node instanceof Node\Expr\New_
) { ) {
$prefix = null; $prefix = '';
$prefixLen = 0;
if ($node instanceof Node\Name) { if ($node instanceof Node\Name) {
$isFullyQualified = $node->isFullyQualified(); $isFullyQualified = $node->isFullyQualified();
$prefix = (string)$node; $prefix = (string)$node;
@ -115,7 +189,7 @@ class CompletionProvider
foreach ($stmt->uses as $use) { foreach ($stmt->uses as $use) {
// Get the definition for the used namespace, class-like, function or constant // Get the definition for the used namespace, class-like, function or constant
// And save it under the alias // 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); $aliasedDefs[$use->alias] = $this->project->getDefinition($fqn);
} }
} else { } else {
@ -165,6 +239,16 @@ class CompletionProvider
$items[] = $item; $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 ( } else if (
$node instanceof Node\Expr\Variable $node instanceof Node\Expr\Variable
|| ($node && $node->getAttribute('parentNode') 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); $item->detail = (string)$this->definitionResolver->getTypeFromNode($var);
$items[] = $item; $items[] = $item;
} }
} else if ($node instanceof Node\Stmt\InlineHTML || $pos == new Position(0, 0)) {
$item = new CompletionItem('<?php', CompletionItemKind::KEYWORD);
$item->textEdit = new TextEdit(
new Range($pos, $pos),
stripStringOverlap($document->getRange(new Range(new Position(0, 0), $pos)), '<?php')
);
$items[] = $item;
} }
return $items; return $items;

View File

@ -37,6 +37,13 @@ class Definition
*/ */
public $isGlobal; public $isGlobal;
/**
* False for instance methods and properties
*
* @var bool
*/
public $isStatic;
/** /**
* True if the Definition is a class * True if the Definition is a class
* *

View File

@ -107,6 +107,10 @@ class DefinitionResolver
|| $node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\Function_
|| $node->getAttribute('parentNode') instanceof Node\Stmt\Const_ || $node->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->fqn = $fqn;
$def->symbolInformation = SymbolInformation::fromNode($node, $fqn); $def->symbolInformation = SymbolInformation::fromNode($node, $fqn);
$def->type = $this->getTypeFromNode($node); $def->type = $this->getTypeFromNode($node);
@ -664,28 +668,35 @@ class DefinitionResolver
if ($node instanceof Node\Param) { if ($node instanceof Node\Param) {
// Parameters // Parameters
$docBlock = $node->getAttribute('parentNode')->getAttribute('docBlock'); $docBlock = $node->getAttribute('parentNode')->getAttribute('docBlock');
if ( if ($docBlock !== null) {
$docBlock !== null
&& !empty($paramTags = $docBlock->getTagsByName('param'))
&& $paramTags[0]->getType() !== null
) {
// Use @param tag // 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) { if ($node->type !== null) {
// Use PHP7 return type hint // Use PHP7 return type hint
if (is_string($node->type)) { if (is_string($node->type)) {
// Resolve a string like "bool" to a type object // Resolve a string like "bool" to a type object
$type = $this->typeResolver->resolve($node->type); $type = $this->typeResolver->resolve($node->type);
} } else {
$type = new Types\Object_(new Fqsen('\\' . (string)$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]);
} }
} }
// Unknown parameter type if ($node->default !== null) {
return new Types\Mixed; $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) { if ($node instanceof Node\FunctionLike) {
// Functions/methods // Functions/methods

View File

@ -300,6 +300,22 @@ class PhpDocument
return $finder->node; 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 * Returns the definition node for a fully qualified name
* *

View File

@ -156,6 +156,9 @@ class CompletionItem
if ($def->documentation) { if ($def->documentation) {
$item->documentation = $def->documentation; $item->documentation = $def->documentation;
} }
if ($def->isStatic && $def->symbolInformation->kind === SymbolKind::PROPERTY) {
$item->insertText = '$' . $def->symbolInformation->name;
}
return $item; return $item;
} }
} }

View File

@ -49,4 +49,16 @@ class Position
return $this->character - $position->character; 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;
}
} }

View File

@ -95,3 +95,25 @@ function getClosestNode(Node $node, string $type)
} }
} }
} }
/**
* Returns the part of $b that is not overlapped by $a
* Example:
*
* stripStringOverlap('whatever<?', '<?php') === 'php'
*
* @param string $a
* @param string $b
* @return string
*/
function stripStringOverlap(string $a, string $b): string
{
$aLen = strlen($a);
$bLen = strlen($b);
for ($i = 1; $i <= $bLen; $i++) {
if (substr($b, 0, $i) === substr($a, $aLen - $i)) {
return substr($b, $i);
}
}
return $b;
}

View File

@ -64,7 +64,7 @@ class LanguageServerTest extends TestCase
if ($msg->body->method === 'window/logMessage' && $promise->state === Promise::PENDING) { if ($msg->body->method === 'window/logMessage' && $promise->state === Promise::PENDING) {
if ($msg->body->params->type === MessageType::ERROR) { if ($msg->body->params->type === MessageType::ERROR) {
$promise->reject(new Exception($msg->body->params->message)); $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(); $promise->fulfill();
} }
} }
@ -109,7 +109,7 @@ class LanguageServerTest extends TestCase
if ($promise->state === Promise::PENDING) { if ($promise->state === Promise::PENDING) {
$promise->reject(new Exception($msg->body->params->message)); $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 // Indexing finished
$promise->fulfill(); $promise->fulfill();
} }

View File

@ -5,8 +5,16 @@ namespace LanguageServer\Tests\Server\TextDocument;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use LanguageServer\Tests\MockProtocolStream; use LanguageServer\Tests\MockProtocolStream;
use LanguageServer\{Server, LanguageClient, Project}; use LanguageServer\{Server, LanguageClient, Project, CompletionProvider};
use LanguageServer\Protocol\{TextDocumentIdentifier, Position, ClientCapabilities, CompletionItem, CompletionItemKind}; use LanguageServer\Protocol\{
TextDocumentIdentifier,
TextEdit,
Range,
Position,
ClientCapabilities,
CompletionItem,
CompletionItemKind
};
use function LanguageServer\pathToUri; use function LanguageServer\pathToUri;
class CompletionTest extends TestCase class CompletionTest extends TestCase
@ -168,7 +176,10 @@ class CompletionTest extends TestCase
'staticTestProperty', 'staticTestProperty',
CompletionItemKind::PROPERTY, CompletionItemKind::PROPERTY,
'\TestClass[]', '\TestClass[]',
'Lorem excepteur officia sit anim velit veniam enim.' 'Lorem excepteur officia sit anim velit veniam enim.',
null,
null,
'$staticTestProperty'
) )
], $items); ], $items);
} }
@ -192,7 +203,10 @@ class CompletionTest extends TestCase
'staticTestProperty', 'staticTestProperty',
CompletionItemKind::PROPERTY, CompletionItemKind::PROPERTY,
'\TestClass[]', '\TestClass[]',
'Lorem excepteur officia sit anim velit veniam enim.' 'Lorem excepteur officia sit anim velit veniam enim.',
null,
null,
'$staticTestProperty'
), ),
new CompletionItem( new CompletionItem(
'staticTestMethod', 'staticTestMethod',
@ -259,4 +273,62 @@ class CompletionTest extends TestCase
) )
], $items); ], $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(
'<?php',
CompletionItemKind::KEYWORD,
null,
null,
null,
null,
null,
new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), '<?php')
)
], $items);
}
public function testHtmlWithPrefix()
{
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html_with_prefix.php');
$this->project->openDocument($completionUri, file_get_contents($completionUri));
$items = $this->textDocument->completion(
new TextDocumentIdentifier($completionUri),
new Position(0, 1)
)->wait();
$this->assertEquals([
new CompletionItem(
'<?php',
CompletionItemKind::KEYWORD,
null,
null,
null,
null,
null,
new TextEdit(new Range(new Position(0, 1), new Position(0, 1)), '?php')
)
], $items);
}
} }

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types = 1);
namespace LanguageServer\Tests\Utils;
use PHPUnit\Framework\TestCase;
use function LanguageServer\stripStringOverlap;
class StripStringOverlapTest extends TestCase
{
public function testNoCharOverlaps()
{
$this->assertEquals('<?php', stripStringOverlap('bla', '<?php'));
}
public function test1CharOverlaps()
{
$this->assertEquals('?php', stripStringOverlap('bla<', '<?php'));
}
public function test2CharsOverlap()
{
$this->assertEquals('php', stripStringOverlap('bla<?', '<?php'));
}
public function testEverythingOverlaps()
{
$this->assertEquals('', stripStringOverlap('bla<?php', '<?php'));
}
public function testEmptyA()
{
$this->assertEquals('<?php', stripStringOverlap('', '<?php'));
}
public function testEmptyB()
{
$this->assertEquals('', stripStringOverlap('bla', ''));
}
public function testBothEmpty()
{
$this->assertEquals('', stripStringOverlap('', ''));
}
}