Completion for keywords and bug fixes
parent
6adb3f48e1
commit
f6a7ce1a8b
|
@ -0,0 +1 @@
|
|||
<
|
|
@ -0,0 +1,3 @@
|
|||
<?php
|
||||
|
||||
cl
|
|
@ -6,6 +6,8 @@ namespace LanguageServer;
|
|||
use PhpParser\Node;
|
||||
use phpDocumentor\Reflection\Types;
|
||||
use LanguageServer\Protocol\{
|
||||
TextEdit,
|
||||
Range,
|
||||
Position,
|
||||
SymbolKind,
|
||||
CompletionItem,
|
||||
|
@ -14,6 +16,77 @@ use LanguageServer\Protocol\{
|
|||
|
||||
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
|
||||
*/
|
||||
|
@ -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('<?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;
|
||||
|
|
|
@ -37,6 +37,13 @@ class Definition
|
|||
*/
|
||||
public $isGlobal;
|
||||
|
||||
/**
|
||||
* False for instance methods and properties
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $isStatic;
|
||||
|
||||
/**
|
||||
* True if the Definition is a class
|
||||
*
|
||||
|
|
|
@ -107,6 +107,10 @@ class DefinitionResolver
|
|||
|| $node instanceof Node\Stmt\Function_
|
||||
|| $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->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);
|
||||
}
|
||||
} else {
|
||||
$type = new Types\Object_(new Fqsen('\\' . (string)$node->type));
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Unknown parameter type
|
||||
return new Types\Mixed;
|
||||
return $type ?? new Types\Mixed;
|
||||
}
|
||||
if ($node instanceof Node\FunctionLike) {
|
||||
// Functions/methods
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -64,7 +64,7 @@ class LanguageServerTest extends TestCase
|
|||
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 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();
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
'<?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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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('', ''));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue