diff --git a/fixtures/signatureHelp/funcClosed.php b/fixtures/signatureHelp/funcClosed.php new file mode 100644 index 0000000..6012844 --- /dev/null +++ b/fixtures/signatureHelp/funcClosed.php @@ -0,0 +1,7 @@ +method(); + } +} + +$a = new HelpClass1; +$a->method(); diff --git a/fixtures/signatureHelp/methodNotClosed.php b/fixtures/signatureHelp/methodNotClosed.php new file mode 100644 index 0000000..d5ce4cc --- /dev/null +++ b/fixtures/signatureHelp/methodNotClosed.php @@ -0,0 +1,17 @@ +method(1,1); + } +} +$a = new HelpClass2; +$a + ->method( + 1, + array(), diff --git a/fixtures/signatureHelp/staticClosed.php b/fixtures/signatureHelp/staticClosed.php new file mode 100644 index 0000000..301009f --- /dev/null +++ b/fixtures/signatureHelp/staticClosed.php @@ -0,0 +1,10 @@ +type = $this->getTypeFromNode($node); $def->declarationLine = $this->getDeclarationLineFromNode($node); $def->documentation = $this->getDocumentationFromNode($node); + $def->parameters = []; + if ($node instanceof Node\FunctionLike) { + foreach ($node->getParams() as $param) { + if (!$param->getAttribute('parentNode')) { + $param->setAttribute('parentNode', $node); + } + $def->parameters[] = new ParameterInformation( + $this->prettyPrinter->prettyPrint([$param]), + $this->getDocumentationFromNode($param) + ); + } + } return $def; } diff --git a/src/LanguageServer.php b/src/LanguageServer.php index 7e856b1..6969b1d 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -12,7 +12,8 @@ use LanguageServer\Protocol\{ InitializeResult, SymbolInformation, TextDocumentIdentifier, - CompletionOptions + CompletionOptions, + SignatureHelpOptions }; use LanguageServer\FilesFinder\{FilesFinder, ClientFilesFinder, FileSystemFilesFinder}; use LanguageServer\ContentRetriever\{ContentRetriever, ClientContentRetriever, FileSystemContentRetriever}; @@ -259,6 +260,9 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher $serverCapabilities->completionProvider = new CompletionOptions; $serverCapabilities->completionProvider->resolveProvider = false; $serverCapabilities->completionProvider->triggerCharacters = ['$', '>']; + // Support "Signature Help" + $serverCapabilities->signatureHelpProvider = new SignatureHelpOptions; + $serverCapabilities->signatureHelpProvider->triggerCharacters = ['(',',']; // Support global references $serverCapabilities->xworkspaceReferencesProvider = true; $serverCapabilities->xdefinitionProvider = true; diff --git a/src/Protocol/ParameterInformation.php b/src/Protocol/ParameterInformation.php index 89b5e53..628804a 100644 --- a/src/Protocol/ParameterInformation.php +++ b/src/Protocol/ParameterInformation.php @@ -23,4 +23,14 @@ class ParameterInformation * @var string|null */ public $documentation; + + /** + * @param string $label The label of this signature. Will be shown in the UI. + * @param string|null $documentation The human-readable doc-comment of this signature. + */ + public function __construct(string $label = null, string $documentation = null) + { + $this->label = $label; + $this->documentation = $documentation; + } } diff --git a/src/Protocol/SignatureHelp.php b/src/Protocol/SignatureHelp.php index 407b25a..9c6f3ea 100644 --- a/src/Protocol/SignatureHelp.php +++ b/src/Protocol/SignatureHelp.php @@ -29,4 +29,16 @@ class SignatureHelp * @var int|null */ public $activeParameter; + + /** + * @param SignatureInformation[] $signatures The signatures. + * @param int|null $activeSignature The active signature. + * @param int|null $activeParameter The active parameter of the active signature. + */ + public function __construct(array $signatures = [], int $activeSignature = null, int $activeParameter = null) + { + $this->signatures = $signatures; + $this->activeSignature = $activeSignature; + $this->activeParameter = $activeParameter; + } } diff --git a/src/Protocol/SignatureInformation.php b/src/Protocol/SignatureInformation.php index 77e152c..e1d4273 100644 --- a/src/Protocol/SignatureInformation.php +++ b/src/Protocol/SignatureInformation.php @@ -31,4 +31,16 @@ class SignatureInformation * @var ParameterInformation[]|null */ public $parameters; + + /** + * @param string $label The label of this signature. Will be shown in the UI. + * @param string|null $documentation The human-readable doc-comment of this signature. + * @param ParameterInformation[]|null $parameters The parameters of this signature. + */ + public function __construct(string $label = null, string $documentation = null, array $parameters = null) + { + $this->label = $label; + $this->documentation = $documentation; + $this->parameters = $parameters; + } } diff --git a/src/Server/TextDocument.php b/src/Server/TextDocument.php index 532d642..0952696 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -5,7 +5,7 @@ namespace LanguageServer\Server; use PhpParser\PrettyPrinter\Standard as PrettyPrinter; use PhpParser\{Node, NodeTraverser}; -use LanguageServer\{LanguageClient, PhpDocumentLoader, PhpDocument, DefinitionResolver, CompletionProvider}; +use LanguageServer\{LanguageClient, PhpDocumentLoader, PhpDocument, DefinitionResolver, CompletionProvider, SignatureHelpProvider}; use LanguageServer\NodeVisitor\VariableReferencesCollector; use LanguageServer\Protocol\{ SymbolLocationInformation, @@ -64,6 +64,11 @@ class TextDocument */ protected $completionProvider; + /** + * @var SignatureHelpProvider + */ + protected $signatureHelpProvider; + /** * @var ReadableIndex */ @@ -100,6 +105,7 @@ class TextDocument $this->prettyPrinter = new PrettyPrinter(); $this->definitionResolver = $definitionResolver; $this->completionProvider = new CompletionProvider($this->definitionResolver, $index); + $this->signatureHelpProvider = new SignatureHelpProvider($this->definitionResolver, $index); $this->index = $index; $this->composerJson = $composerJson; $this->composerLock = $composerLock; @@ -363,6 +369,14 @@ class TextDocument }); } + public function signatureHelp(TextDocumentIdentifier $textDocument, Position $position): Promise + { + return coroutine(function () use ($textDocument, $position) { + $document = yield $this->documentLoader->getOrLoad($textDocument->uri); + return $this->signatureHelpProvider->provideSignature($document, $position); + }); + } + /** * This method is the same as textDocument/definition, except that * diff --git a/src/SignatureHelpProvider.php b/src/SignatureHelpProvider.php new file mode 100644 index 0000000..c29c2e8 --- /dev/null +++ b/src/SignatureHelpProvider.php @@ -0,0 +1,199 @@ +definitionResolver = $definitionResolver; + $this->index = $index; + } + + /** + * Returns signature help for a specific cursor position in a document + * + * @param PhpDocument $doc The opened document + * @param Position $pos The cursor position + * @return SignatureHelp + */ + public function provideSignature(PhpDocument $doc, Position $pos) : SignatureHelp + { + $handle = fopen('php://temp', 'r+'); + fwrite($handle, $doc->getContent()); + fseek($handle, 0); + + $lines = []; + for ($i = 0; $i < $pos->line; $i++) { + $lines[] = strlen(fgets($handle)); + } + $filePos = ftell($handle) + $pos->character; + $line = substr(fgets($handle), 0, $pos->character); + fseek($handle, 0); + + $i = 0; + $orig = null; + do { + $node = $doc->getNodeAtPosition($pos); + if ($node !== null && $orig === null) { + $orig = $node; + } + $pos->character--; + if ($pos->character < 0) { + $pos->line --; + if ($pos->line < 0) { + break; + } + $pos->character = $lines[$pos->line]; + } + } while (!( + $node instanceof Node\Expr\PropertyFetch || + $node instanceof Node\Expr\MethodCall || + $node instanceof Node\Expr\FuncCall || + $node instanceof Node\Expr\ClassConstFetch || + $node instanceof Node\Expr\StaticCall + ) && ++$i < 120); + + if ($node === null) { + $node = $orig; + } + + if ($node === null) { + fclose($handle); + return new SignatureHelp; + } + + $params = ''; + if ($node instanceof Node\Expr\PropertyFetch) { + fseek($handle, $node->name->getAttribute('startFilePos')); + $method = fread($handle, ($node->name->getAttribute('endFilePos') + 1) - $node->name->getAttribute('startFilePos')); + fseek($handle, $node->name->getAttribute('endFilePos') + 1); + $params = fread($handle, ($filePos - 1) - $node->name->getAttribute('endFilePos')); + if ($def = $this->definitionResolver->resolveReferenceNodeToDefinition($node->var)) { + $fqn = $def->fqn; + if (!$fqn) { + $fqns = DefinitionResolver::getFqnsFromType( + $this->definitionResolver->resolveExpressionNodeToType($node->var) + ); + if (count($fqns)) { + $fqn = $fqns[0]; + } + } + if ($fqn) { + $fqn = $fqn . '->' . $method . '()'; + $def = $this->index->getDefinition($fqn); + } + } + } else if ($node instanceof Node\Expr\MethodCall) { + fseek($handle, $node->getAttribute('startFilePos')); + $params = explode('(', fread($handle, $filePos - $node->getAttribute('startFilePos')), 2)[1]; + $def = $this->definitionResolver->resolveReferenceNodeToDefinition($node); + } else if ($node instanceof Node\Expr\FuncCall) { + fseek($handle, $node->getAttribute('startFilePos')); + $params = explode('(', fread($handle, $filePos - $node->getAttribute('startFilePos')), 2)[1]; + $fqn = $this->definitionResolver->resolveReferenceNodeToFqn($node->name); + $def = $this->index->getDefinition($fqn); + } else if ($node instanceof Node\Expr\StaticCall) { + fseek($handle, $node->getAttribute('startFilePos')); + $params = explode('(', fread($handle, $filePos - $node->getAttribute('startFilePos')), 2)[1]; + $def = $this->definitionResolver->resolveReferenceNodeToDefinition($node); + } else if ($node instanceof Node\Expr\ClassConstFetch) { + fseek($handle, $node->name->getAttribute('endFilePos') + 2); + $params = fread($handle, ($filePos - 1) - $node->name->getAttribute('endFilePos')); + fseek($handle, $node->name->getAttribute('startFilePos')); + $method = fread($handle, ($node->name->getAttribute('endFilePos') + 1) - $node->name->getAttribute('startFilePos')); + $method = explode('::', str_replace('()', '', $method), 2); + $method = $method[1] ?? $method[0]; + $fqn = $this->definitionResolver->resolveReferenceNodeToFqn($node->class); + $def = $this->index->getDefinition($fqn.'::'.$method.'()'); + } else { + if (!preg_match('(([a-zA-Z_\x7f-\xff][:a-zA-Z0-9_\x7f-\xff]*)\s*\((.*)$)', $line, $method)) { + fclose($handle); + return new SignatureHelp; + } + $def = $this->index->getDefinition($method[1] . '()'); + $params = $method[2]; + } + fclose($handle); + + if ($def) { + $method = preg_split('(::|->)', str_replace('()', '', $def->fqn), 2); + $method = $method[1] ?? $method[0]; + $params = ltrim($params, "( "); + $activeParameter = 0; + if (strlen(trim($params))) { + try { + $lex = new \PhpParser\Lexer(); + $lex->startLexing('getNextToken($value); + $lex->getNextToken($value); + $lex->getNextToken($value); + $params = 0; + $stack = []; + while ($value !== "\0") { + $lex->getNextToken($value); + if (($value === ")" || $value === ";") && !count($stack)) { + return new SignatureHelp; + } + if ($value === ',' && !count($stack)) { + $activeParameter++; + } + if ($value === '(') { + $stack[] = ')'; + } else if ($value === '[') { + $stack[] = ']'; + } else if (count($stack) && $value === $stack[count($stack)-1]) { + array_pop($stack); + } + } + } catch (\Exception $ignore) { + } + } + if ($activeParameter < count($def->parameters)) { + $params = array_map(function ($v) { + return $v->label; + }, $def->parameters); + return new SignatureHelp( + [ + new SignatureInformation( + $method . '('.implode(', ', $params).')', + $def->documentation, + $def->parameters + ) + ], + 0, + $activeParameter + ); + } + } + + return new SignatureHelp; + } +} diff --git a/tests/LanguageServerTest.php b/tests/LanguageServerTest.php index 6bfa3c9..2f1a6e2 100644 --- a/tests/LanguageServerTest.php +++ b/tests/LanguageServerTest.php @@ -14,7 +14,8 @@ use LanguageServer\Protocol\{ TextDocumentIdentifier, InitializeResult, ServerCapabilities, - CompletionOptions + CompletionOptions, + SignatureHelpOptions }; use AdvancedJsonRpc; use Webmozart\Glob\Glob; @@ -41,6 +42,8 @@ class LanguageServerTest extends TestCase $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; @@ -57,7 +60,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 25 PHP files parsed') !== false) { + } else if (strpos($msg->body->params->message, 'All 31 PHP files parsed') !== false) { $promise->fulfill(); } } @@ -103,7 +106,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 25 PHP files parsed') !== false) { + } else if (strpos($msg->body->params->message, 'All 31 PHP files parsed') !== false) { if ($run === 1) { $run++; } else { diff --git a/tests/Server/TextDocument/SignatureHelpTest.php b/tests/Server/TextDocument/SignatureHelpTest.php new file mode 100644 index 0000000..3814c09 --- /dev/null +++ b/tests/Server/TextDocument/SignatureHelpTest.php @@ -0,0 +1,222 @@ +loader = new PhpDocumentLoader($contentRetriever, $projectIndex, $definitionResolver); + $this->loader->load(pathToUri(__DIR__ . '/../../../fixtures/global_symbols.php'))->wait(); + $this->loader->load(pathToUri(__DIR__ . '/../../../fixtures/symbols.php'))->wait(); + $this->textDocument = new Server\TextDocument($this->loader, $definitionResolver, $client, $projectIndex); + } + + public function testMethodClosed() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/signatureHelp/methodClosed.php'); + $this->loader->open($completionUri, file_get_contents($completionUri)); + $result = $this->textDocument->signatureHelp( + new TextDocumentIdentifier($completionUri), + new Position(9, 22) + )->wait(); + + $this->assertEquals(new SignatureHelp( + [ + new SignatureInformation( + 'method(string $param = "")', + null, + [ + new ParameterInformation('string $param = ""') + ] + ) + ] + ), $result); + } + + public function testMethodClosedReference() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/signatureHelp/methodClosed.php'); + $this->loader->open($completionUri, file_get_contents($completionUri)); + $result = $this->textDocument->signatureHelp( + new TextDocumentIdentifier($completionUri), + new Position(14, 11) + )->wait(); + + $this->assertEquals(new SignatureHelp( + [ + new SignatureInformation( + 'method(string $param = "")', + null, + [ + new ParameterInformation('string $param = ""') + ] + ) + ] + ), $result); + } + + public function testMethodNotClosed() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/signatureHelp/methodNotClosed.php'); + $this->loader->open($completionUri, file_get_contents($completionUri)); + $result = $this->textDocument->signatureHelp( + new TextDocumentIdentifier($completionUri), + new Position(9, 22) + )->wait(); + + $this->assertEquals(new SignatureHelp( + [ + new SignatureInformation( + 'method(string $param = "")', + null, + [ + new ParameterInformation('string $param = ""') + ] + ) + ] + ), $result); + } + + public function testMethodNotClosedReference() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/signatureHelp/methodNotClosed.php'); + $this->loader->open($completionUri, file_get_contents($completionUri)); + $result = $this->textDocument->signatureHelp( + new TextDocumentIdentifier($completionUri), + new Position(14, 14) + )->wait(); + + $this->assertEquals(new SignatureHelp( + [ + new SignatureInformation( + 'method(string $param = "")', + null, + [ + new ParameterInformation('string $param = ""') + ] + ) + ] + ), $result); + } + + public function testFuncClosed() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/signatureHelp/funcClosed.php'); + $this->loader->open($completionUri, file_get_contents($completionUri)); + $result = $this->textDocument->signatureHelp( + new TextDocumentIdentifier($completionUri), + new Position(6, 10) + )->wait(); + + $this->assertEquals(new SignatureHelp( + [ + new SignatureInformation( + 'helpFunc1(int $count = 0)', + null, + [ + new ParameterInformation('int $count = 0') + ] + ) + ] + ), $result); + } + + public function testFuncNotClosed() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/signatureHelp/funcNotClosed.php'); + $this->loader->open($completionUri, file_get_contents($completionUri)); + $result = $this->textDocument->signatureHelp( + new TextDocumentIdentifier($completionUri), + new Position(6, 10) + )->wait(); + + $this->assertEquals(new SignatureHelp( + [ + new SignatureInformation( + 'helpFunc2(int $count = 0)', + null, + [ + new ParameterInformation('int $count = 0') + ] + ) + ] + ), $result); + } + + public function testStaticClosed() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/signatureHelp/staticClosed.php'); + $this->loader->open($completionUri, file_get_contents($completionUri)); + $result = $this->textDocument->signatureHelp( + new TextDocumentIdentifier($completionUri), + new Position(9, 19) + )->wait(); + + $this->assertEquals(new SignatureHelp( + [ + new SignatureInformation( + 'method(string $param = "")', + null, + [ + new ParameterInformation('string $param = ""') + ] + ) + ] + ), $result); + } + + public function testStaticNotClosed() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/signatureHelp/staticNotClosed.php'); + $this->loader->open($completionUri, file_get_contents($completionUri)); + $result = $this->textDocument->signatureHelp( + new TextDocumentIdentifier($completionUri), + new Position(9, 19) + )->wait(); + + $this->assertEquals(new SignatureHelp( + [ + new SignatureInformation( + 'method(string $param = "")', + null, + [ + new ParameterInformation('string $param = ""') + ] + ) + ] + ), $result); + } +}