Skip to content

Signature help #547

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Dec 10, 2017
56 changes: 56 additions & 0 deletions fixtures/signature_help/calls.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace Foo;

class Test
{
/**
* Constructor comment goes here
*
* @param string $first First param
* @param int $second Second param
* @param Test $third Third param with a longer description
*/
public function __construct(string $first, int $second, Test $third)
{
}

/**
* Function doc
*
* @param SomethingElse $a A param with a different doc type
* @param int|null $b Param with default value
*/
public function foo(\DateTime $a, int $b = null)
{
}

public static function bar($a)
{
}

/**
* Method with no params
*/
public function baz()
{
}
}

/**
* @param int $i Global function param one
* @param bool $b Default false param
*/
function foo(int $i, bool $b = false)
{
}

$t = new Test();
$t->foo();
$t->foo(1,
$t->foo(1,);
$t->baz();

foo();

Test::bar();
7 changes: 6 additions & 1 deletion src/LanguageServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
TextDocumentSyncKind,
Message,
InitializeResult,
CompletionOptions
CompletionOptions,
SignatureHelpOptions
};
use LanguageServer\FilesFinder\{FilesFinder, ClientFilesFinder, FileSystemFilesFinder};
use LanguageServer\ContentRetriever\{ContentRetriever, ClientContentRetriever, FileSystemContentRetriever};
Expand Down Expand Up @@ -275,6 +276,10 @@ public function initialize(ClientCapabilities $capabilities, string $rootPath =
$serverCapabilities->completionProvider = new CompletionOptions;
$serverCapabilities->completionProvider->resolveProvider = false;
$serverCapabilities->completionProvider->triggerCharacters = ['$', '>'];

$serverCapabilities->signatureHelpProvider = new SignatureHelpOptions();
$serverCapabilities->signatureHelpProvider->triggerCharacters = ['(', ','];

// Support global references
$serverCapabilities->xworkspaceReferencesProvider = true;
$serverCapabilities->xdefinitionProvider = true;
Expand Down
25 changes: 24 additions & 1 deletion src/Server/TextDocument.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
namespace LanguageServer\Server;

use LanguageServer\{
CompletionProvider, LanguageClient, PhpDocument, PhpDocumentLoader, DefinitionResolver
CompletionProvider, SignatureHelpProvider, LanguageClient, PhpDocument, PhpDocumentLoader, DefinitionResolver
};
use LanguageServer\Index\ReadableIndex;
use LanguageServer\Protocol\{
Expand Down Expand Up @@ -59,6 +59,11 @@ class TextDocument
*/
protected $completionProvider;

/**
* @var SignatureHelpProvider
*/
protected $signatureHelpProvider;

/**
* @var ReadableIndex
*/
Expand Down Expand Up @@ -94,6 +99,7 @@ public function __construct(
$this->client = $client;
$this->definitionResolver = $definitionResolver;
$this->completionProvider = new CompletionProvider($this->definitionResolver, $index);
$this->signatureHelpProvider = new SignatureHelpProvider($this->definitionResolver, $index, $documentLoader);
$this->index = $index;
$this->composerJson = $composerJson;
$this->composerLock = $composerLock;
Expand Down Expand Up @@ -237,6 +243,23 @@ public function references(
});
}

/**
* The signature help request is sent from the client to the server to request signature information at a given
* cursor position.
*
* @param TextDocumentIdentifier $textDocument The text document
* @param Position $position The position inside the text document
*
* @return Promise <SignatureHelp>
*/
public function signatureHelp(TextDocumentIdentifier $textDocument, Position $position): Promise
{
return coroutine(function () use ($textDocument, $position) {
$document = yield $this->documentLoader->getOrLoad($textDocument->uri);
return $this->signatureHelpProvider->getSignatureHelp($document, $position);
});
}

/**
* The goto definition request is sent from the client to the server to resolve the definition location of a symbol
* at a given text document position.
Expand Down
242 changes: 242 additions & 0 deletions src/SignatureHelpProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
<?php
declare(strict_types = 1);

namespace LanguageServer;

use LanguageServer\Index\ReadableIndex;
use LanguageServer\Protocol\{
Position,
SignatureHelp,
SignatureInformation,
ParameterInformation
};
use Microsoft\PhpParser;
use Microsoft\PhpParser\Node;

class SignatureHelpProvider
{
/** @var DefinitionResolver */
private $definitionResolver;

/** @var ReadableIndex */
private $index;

/** @var PhpDocumentLoader */
private $documentLoader;

/**
* Constructor
*
* @param DefinitionResolver $definitionResolver
* @param ReadableIndex $index
* @param PhpDocumentLoader $documentLoader
*/
public function __construct(DefinitionResolver $definitionResolver, ReadableIndex $index, PhpDocumentLoader $documentLoader)
{
$this->definitionResolver = $definitionResolver;
$this->index = $index;
$this->documentLoader = $documentLoader;
}

/**
* Finds signature help for a callable position
*
* @param PhpDocument $doc The document the position belongs to
* @param Position $position The position to detect a call from
*
* @return SignatureHelp
*/
public function getSignatureHelp(PhpDocument $doc, Position $position): SignatureHelp
{
// Find the node under the cursor
$node = $doc->getNodeAtPosition($position);

// Find the definition of the item being called
list($def, $argumentExpressionList) = $this->getCallingInfo($node);

if (!$def) {
return new SignatureHelp();
}

// Find the active parameter
$activeParam = $argumentExpressionList
? $this->findActiveParameter($argumentExpressionList, $position, $doc)
: 0;

// Get information from the item being called to build the signature information
$calledDoc = $this->documentLoader->getOrLoad($def->symbolInformation->location->uri)->wait();
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never use wait except in tests, it blocks the event loop. Make it a coroutine and yield the Promise

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I'm still getting my head around this event loop business. I've updated this to wrap this function in a coroutine.

if (!$calledDoc) {
return new SignatureHelp();
}
$calledNode = $calledDoc->getNodeAtPosition($def->symbolInformation->location->range->start);
$params = $this->getParameters($calledNode, $calledDoc);
$label = $this->getLabel($calledNode, $params, $calledDoc);

$signatureInformation = new SignatureInformation();
$signatureInformation->label = $label;
$signatureInformation->parameters = $params;
$signatureInformation->documentation = $this->definitionResolver->getDocumentationFromNode($calledNode);
$signatureHelp = new SignatureHelp();
$signatureHelp->signatures = [$signatureInformation];
$signatureHelp->activeSignature = 0;
$signatureHelp->activeParameter = $activeParam;
return $signatureHelp;
}

/**
* Given a node that could be a callable, finds the definition of the call and the argument expression list of
* the node
*
* @param Node $node The node to find calling information from
*
* @return array|null
*/
private function getCallingInfo(Node $node)
{
$fqn = null;
$callingNode = null;
if ($node instanceof Node\DelimitedList\ArgumentExpressionList) {
// Cursor is already inside a (
$argumentExpressionList = $node;
if ($node->parent instanceof Node\Expression\ObjectCreationExpression) {
// Constructing something
$callingNode = $node->parent->classTypeDesignator;
if (!$callingNode instanceof Node\QualifiedName) {
// We only support constructing from a QualifiedName
return null;
}
$fqn = $this->definitionResolver->resolveReferenceNodeToFqn($callingNode);
$fqn = "{$fqn}->__construct()";
} else {
$callingNode = $node->parent->getFirstChildNode(
Node\Expression\MemberAccessExpression::class,
Node\Expression\ScopedPropertyAccessExpression::class,
Node\QualifiedName::class
);
}
} elseif ($node instanceof Node\Expression\CallExpression) {
$argumentExpressionList = $node->getFirstChildNode(Node\DelimitedList\ArgumentExpressionList::class);
$callingNode = $node->getFirstChildNode(
Node\Expression\MemberAccessExpression::class,
Node\Expression\ScopedPropertyAccessExpression::class,
Node\QualifiedName::class
);
} elseif ($node instanceof Node\Expression\ObjectCreationExpression) {
$argumentExpressionList = $node->getFirstChildNode(Node\DelimitedList\ArgumentExpressionList::class);
$callingNode = $node->classTypeDesignator;
if (!$callingNode instanceof Node\QualifiedName) {
// We only support constructing from a QualifiedName
return null;
}
// Manually build the __construct fqn
$fqn = $this->definitionResolver->resolveReferenceNodeToFqn($callingNode);
$fqn = "{$fqn}->__construct()";
}

if (!$callingNode) {
return null;
}

// Now find the definition of the call
$fqn = $fqn ?: DefinitionResolver::getDefinedFqn($callingNode);
if ($fqn) {
$def = $this->index->getDefinition($fqn);
} else {
$def = $this->definitionResolver->resolveReferenceNodeToDefinition($callingNode);
}

if (!$def) {
return null;
}
return [$def, $argumentExpressionList];
}

/**
* Creates a label for SignatureInformation
*
* @param Node\MethodDeclaration|Node\Statement\FunctionDeclaration $node The method/function declaration node
* we are building the label for
* @param ParameterInformation[] $params Parameters that belong to the node
*
* @return string
*/
private function getLabel($node, array $params): string
{
$label = '(';
if ($params) {
foreach ($params as $param) {
$label .= $param->label . ', ';
}
$label = substr($label, 0, -2);
}
$label .= ')';
return $label;
}

/**
* Builds ParameterInformation from a node
*
* @param Node\MethodDeclaration|Node\Statement\FunctionDeclaration $node The node to build parameters from
* @param PhpDocument $doc The document the node belongs to
*
* @return ParameterInformation[]
*/
private function getParameters($node, PhpDocument $doc): array
{
$params = [];
if ($node->parameters) {
foreach ($node->parameters->getElements() as $element) {
$param = (string) $this->definitionResolver->getTypeFromNode($element);
$param .= ' ' . $element->variableName->getText($doc->getContent());
if ($element->default) {
$param .= ' = ' . $element->default->getText($doc->getContent());
}
$info = new ParameterInformation();
$info->label = $param;
$info->documentation = $this->definitionResolver->getDocumentationFromNode($element);
$params[] = $info;
}
}
return $params;
}

/**
* Given a position and arguments, finds the "active" argument at the position
*
* @param Node\DelimitedList\ArgumentExpressionList $argumentExpressionList The argument expression list
* @param Position $position The position to detect the active argument from
* @param PhpDocument $doc The document that contains the expression
*
* @return int
*/
private function findActiveParameter(
Node\DelimitedList\ArgumentExpressionList $argumentExpressionList,
Position $position,
PhpDocument $doc
): int {
$args = $argumentExpressionList->children;
$i = 0;
$found = null;
foreach ($args as $arg) {
if ($arg instanceof Node) {
$start = $arg->getFullStart();
$end = $arg->getEndPosition();
} else {
$start = $arg->fullStart;
$end = $start + $arg->length;
}
$offset = $position->toOffset($doc->getContent());
if ($offset >= $start && $offset <= $end) {
$found = $i;
break;
}
if ($arg instanceof Node) {
++$i;
}
}
if (is_null($found)) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use === null

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

$found = $i;
}
return $found;
}
}
5 changes: 4 additions & 1 deletion tests/LanguageServerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
TextDocumentIdentifier,
InitializeResult,
ServerCapabilities,
CompletionOptions
CompletionOptions,
SignatureHelpOptions
};
use AdvancedJsonRpc;
use Webmozart\Glob\Glob;
Expand All @@ -40,6 +41,8 @@ public function testInitialize()
$serverCapabilities->completionProvider = new CompletionOptions;
$serverCapabilities->completionProvider->resolveProvider = false;
$serverCapabilities->completionProvider->triggerCharacters = ['$', '>'];
$serverCapabilities->signatureHelpProvider = new SignatureHelpOptions;
$serverCapabilities->signatureHelpProvider->triggerCharacters = ['(', ','];
$serverCapabilities->xworkspaceReferencesProvider = true;
$serverCapabilities->xdefinitionProvider = true;
$serverCapabilities->xdependenciesProvider = true;
Expand Down
Loading