1
0
Fork 0

Initial implemention for code completion

pull/38/head
Michal Niewrzal 2016-10-20 14:01:00 +02:00
parent b16674d394
commit d6200fc07a
14 changed files with 655 additions and 6 deletions

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types = 1);
namespace LanguageServer\Completion;
use LanguageServer\PhpDocument;
use LanguageServer\Protocol\ {
Range,
Position
};
class CompletionContext
{
/**
*
* @var \LanguageServer\Protocol\Position
*/
private $position;
/**
*
* @var \LanguageServer\PhpDocument
*/
private $phpDocument;
/**
*
* @var string[]
*/
private $lines;
public function __construct(PhpDocument $phpDocument)
{
$this->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;
}
}

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types = 1);
namespace LanguageServer\Completion;
use LanguageServer\Protocol\ {
CompletionItem,
Range,
Position,
TextEdit,
CompletionItemKind,
CompletionList
};
use LanguageServer\Completion\Strategies\ {
KeywordsStrategy,
VariablesStrategy,
ClassMembersStrategy,
GlobalElementsStrategy
};
use LanguageServer\PhpDocument;
use PhpParser\Node;
class CompletionReporter
{
/**
* @var \LanguageServer\Protocol\CompletionItem
*/
private $completionItems;
/**
* @var \LanguageServer\Completion\ICompletionStrategy
*/
private $strategies;
private $context;
public function __construct(PhpDocument $phpDocument)
{
$this->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;
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types = 1);
namespace LanguageServer\Completion;
interface ICompletionStrategy
{
/**
*
* @param \LanguageServer\Completion\CompletionContext $context
* @param \LanguageServer\Completion\CompletionReporter $reporter
*/
public function apply(CompletionContext $context, CompletionReporter $reporter);
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types = 1);
namespace LanguageServer\Completion\Strategies;
use LanguageServer\Completion\ {
CompletionContext,
CompletionReporter,
ICompletionStrategy
};
use LanguageServer\Protocol\Range;
class ClassMembersStrategy implements ICompletionStrategy
{
/**
* {@inheritdoc}
*/
public function apply(CompletionContext $context, CompletionReporter $reporter)
{
if (!$this->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;
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types = 1);
namespace LanguageServer\Completion\Strategies;
use LanguageServer\Completion\ {
CompletionContext,
CompletionReporter,
ICompletionStrategy
};
use LanguageServer\Protocol\CompletionItemKind;
use LanguageServer\Protocol\SymbolInformation;
use LanguageServer\Protocol\SymbolKind;
class GlobalElementsStrategy implements ICompletionStrategy
{
/**
* {@inheritdoc}
*/
public function apply(CompletionContext $context, CompletionReporter $reporter)
{
$range = $context->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;
}
}

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types = 1);
namespace LanguageServer\Completion\Strategies;
use LanguageServer\Protocol\ {
Range,
CompletionItemKind
};
use LanguageServer\Completion\ {
ICompletionStrategy,
CompletionContext,
CompletionReporter
};
class KeywordsStrategy implements ICompletionStrategy
{
/**
* @var string[]
*/
const KEYWORDS = [
"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",
"false",
"final",
"finally",
"for",
"foreach",
"function",
"global",
"goto",
"if",
"implements",
"include",
"include_once",
"instanceof",
"insteadof",
"interface",
"isset",
"list",
"namespace",
"new",
"null",
"or",
"parent",
"print",
"private",
"protected",
"public",
"require",
"require_once",
"return",
"self",
"static",
"switch",
"throw",
"trait",
"true",
"try",
"unset",
"use",
"var",
"while",
"xor",
"yield"
];
/**
* {@inheritdoc}
*/
public function apply(CompletionContext $context, CompletionReporter $reporter)
{
$range = $context->getReplacementRange();
foreach (self::KEYWORDS as $keyword) {
$reporter->report($keyword, CompletionItemKind::KEYWORD, $keyword, $range);
}
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types = 1);
namespace LanguageServer\Completion\Strategies;
use LanguageServer\Protocol\ {
CompletionItemKind,
Range,
SymbolKind,
SymbolInformation
};
use LanguageServer\Completion\ {
ICompletionStrategy,
CompletionContext,
CompletionReporter
};
class VariablesStrategy implements ICompletionStrategy
{
/**
* {@inheritdoc}
*/
public function apply(CompletionContext $context, CompletionReporter $reporter)
{
$range = $context->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;
}
}

View File

@ -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);
}

View File

@ -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.
*

View File

@ -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];
}
}

View File

@ -18,7 +18,7 @@ class CompletionOptions
/**
* The characters that trigger completion automatically.
*
* @var string|null
* @var string[]|null
*/
public $triggerCharacters;
}

View File

@ -0,0 +1,105 @@
<?php
declare(strict_types = 1);
namespace LanguageServer\Server;
use LanguageServer\Protocol\ {
CompletionItem,
TextEdit
};
use PhpParser\Node;
use LanguageServer\Project;
use phpDocumentor\Reflection\DocBlockFactory;
class CompletionItemResolver
{
/**
* @var \LanguageServer\Project
*/
private $project;
/**
* @var \phpDocumentor\Reflection\DocBlockFactory
*/
private $docBlockFactory;
public function __construct(Project $project)
{
$this->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;
}
}

View File

@ -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);
}
}

View File

@ -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,