From 106aa24b5d71c58010a4b383ca4d49420c69fa38 Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Tue, 10 Jan 2017 17:08:52 -0800 Subject: [PATCH] Implement global references protocol extension (#236) --- src/Index/ProjectIndex.php | 4 +- src/LanguageServer.php | 78 +++++++++--- src/Protocol/DependencyReference.php | 27 +++++ src/Protocol/ReferenceInformation.php | 36 ++++++ src/Protocol/ServerCapabilities.php | 21 ++++ src/Protocol/SymbolDescriptor.php | 33 +++++ src/Protocol/SymbolLocationInformation.php | 32 +++++ src/Server/TextDocument.php | 98 +++++++++++++-- src/Server/Workspace.php | 134 +++++++++++++++++++-- tests/LanguageServerTest.php | 3 + tests/Server/ServerTestCase.php | 6 +- 11 files changed, 432 insertions(+), 40 deletions(-) create mode 100644 src/Protocol/DependencyReference.php create mode 100644 src/Protocol/ReferenceInformation.php create mode 100644 src/Protocol/SymbolDescriptor.php create mode 100644 src/Protocol/SymbolLocationInformation.php diff --git a/src/Index/ProjectIndex.php b/src/Index/ProjectIndex.php index 8b42f8f..aa31b21 100644 --- a/src/Index/ProjectIndex.php +++ b/src/Index/ProjectIndex.php @@ -42,8 +42,8 @@ class ProjectIndex extends AbstractAggregateIndex */ public function getIndexForUri(string $uri): Index { - if (preg_match('/\/vendor\/(\w+\/\w+)\//', $uri, $matches)) { - $packageName = $matches[0]; + if (preg_match('/\/vendor\/([^\/]+\/[^\/]+)\//', $uri, $matches)) { + $packageName = $matches[1]; return $this->dependenciesIndex->getDependencyIndex($packageName); } return $this->sourceIndex; diff --git a/src/LanguageServer.php b/src/LanguageServer.php index 595c20b..d230a3c 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -81,6 +81,30 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher */ protected $documentLoader; + /** + * The parsed composer.json file in the project, if any + * + * @var \stdClass + */ + protected $composerJson; + + /** + * The parsed composer.lock file in the project, if any + * + * @var \stdClass + */ + protected $composerLock; + + /** + * @var GlobalIndex + */ + protected $globalIndex; + + /** + * @var DefinitionResolver + */ + protected $definitionResolver; + /** * @param PotocolReader $reader * @param ProtocolWriter $writer @@ -144,8 +168,6 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher { return coroutine(function () use ($capabilities, $rootPath, $processId) { - yield null; - if ($capabilities->xfilesProvider) { $this->filesFinder = new ClientFilesFinder($this->client); } else { @@ -158,31 +180,53 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher $this->contentRetriever = new FileSystemContentRetriever; } - $projectIndex = new ProjectIndex(new Index, new DependenciesIndex); + $dependenciesIndex = new DependenciesIndex; + $sourceIndex = new Index; + $projectIndex = new ProjectIndex($sourceIndex, $dependenciesIndex); $stubsIndex = StubsIndex::read(); - $globalIndex = new GlobalIndex($stubsIndex, $projectIndex); + $this->globalIndex = new GlobalIndex($stubsIndex, $projectIndex); // The DefinitionResolver should look in stubs, the project source and dependencies - $definitionResolver = new DefinitionResolver($globalIndex); + $this->definitionResolver = new DefinitionResolver($this->globalIndex); $this->documentLoader = new PhpDocumentLoader( $this->contentRetriever, $projectIndex, - $definitionResolver + $this->definitionResolver ); if ($rootPath !== null) { - $this->index($rootPath)->otherwise('\\LanguageServer\\crash'); + yield $this->index($rootPath)->otherwise('LanguageServer\\crash'); } - $this->textDocument = new Server\TextDocument( - $this->documentLoader, - $definitionResolver, - $this->client, - $globalIndex - ); - // workspace/symbol should only look inside the project source and dependencies - $this->workspace = new Server\Workspace($projectIndex, $this->client); + // Find composer.json + if ($this->composerJson === null) { + $composerJsonFiles = yield $this->filesFinder->find(Path::makeAbsolute('**/composer.json', $rootPath)); + if (!empty($composerJsonFiles)) { + $this->composerJson = json_decode(yield $this->contentRetriever->retrieve($composerJsonFiles[0])); + } + } + // Find composer.lock + if ($this->composerLock === null) { + $composerLockFiles = yield $this->filesFinder->find(Path::makeAbsolute('**/composer.lock', $rootPath)); + if (!empty($composerLockFiles)) { + $this->composerLock = json_decode(yield $this->contentRetriever->retrieve($composerLockFiles[0])); + } + } + + if ($this->textDocument === null) { + $this->textDocument = new Server\TextDocument( + $this->documentLoader, + $this->definitionResolver, + $this->client, + $this->globalIndex, + $this->composerJson, + $this->composerLock + ); + } + if ($this->workspace === null) { + $this->workspace = new Server\Workspace($projectIndex, $dependenciesIndex, $sourceIndex, $this->composerLock, $this->documentLoader); + } $serverCapabilities = new ServerCapabilities(); // Ask the client to return always full documents (because we need to rebuild the AST from scratch) @@ -203,6 +247,10 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher $serverCapabilities->completionProvider = new CompletionOptions; $serverCapabilities->completionProvider->resolveProvider = false; $serverCapabilities->completionProvider->triggerCharacters = ['$', '>']; + // Support global references + $serverCapabilities->xworkspaceReferencesProvider = true; + $serverCapabilities->xdefinitionProvider = true; + $serverCapabilities->xdependenciesProvider = true; return new InitializeResult($serverCapabilities); }); diff --git a/src/Protocol/DependencyReference.php b/src/Protocol/DependencyReference.php new file mode 100644 index 0000000..7dc7b8f --- /dev/null +++ b/src/Protocol/DependencyReference.php @@ -0,0 +1,27 @@ +attributes = $attributes ?? new \stdClass; + $this->hints = $hints; + } +} diff --git a/src/Protocol/ReferenceInformation.php b/src/Protocol/ReferenceInformation.php new file mode 100644 index 0000000..e66f1fa --- /dev/null +++ b/src/Protocol/ReferenceInformation.php @@ -0,0 +1,36 @@ +reference = $reference; + $this->symbol = $symbol; + } +} diff --git a/src/Protocol/ServerCapabilities.php b/src/Protocol/ServerCapabilities.php index 6b3e4de..1893a51 100644 --- a/src/Protocol/ServerCapabilities.php +++ b/src/Protocol/ServerCapabilities.php @@ -108,4 +108,25 @@ class ServerCapabilities * @var bool|null */ public $renameProvider; + + /** + * The server provides workspace references exporting support. + * + * @var bool|null + */ + public $xworkspaceReferencesProvider; + + /** + * The server provides extended text document definition support. + * + * @var bool|null + */ + public $xdefinitionProvider; + + /** + * The server provides workspace dependencies support. + * + * @var bool|null + */ + public $dependenciesProvider; } diff --git a/src/Protocol/SymbolDescriptor.php b/src/Protocol/SymbolDescriptor.php new file mode 100644 index 0000000..fa74bcb --- /dev/null +++ b/src/Protocol/SymbolDescriptor.php @@ -0,0 +1,33 @@ +fqsen = $fqsen; + $this->package = $package; + } +} diff --git a/src/Protocol/SymbolLocationInformation.php b/src/Protocol/SymbolLocationInformation.php new file mode 100644 index 0000000..4009879 --- /dev/null +++ b/src/Protocol/SymbolLocationInformation.php @@ -0,0 +1,32 @@ +symbol = $symbol; + $this->location = $location; + } +} diff --git a/src/Server/TextDocument.php b/src/Server/TextDocument.php index 1d58efc..a3183dc 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -8,6 +8,8 @@ use PhpParser\{Node, NodeTraverser}; use LanguageServer\{LanguageClient, PhpDocumentLoader, PhpDocument, DefinitionResolver, CompletionProvider}; use LanguageServer\NodeVisitor\VariableReferencesCollector; use LanguageServer\Protocol\{ + SymbolLocationInformation, + SymbolDescriptor, TextDocumentItem, TextDocumentIdentifier, VersionedTextDocumentIdentifier, @@ -39,44 +41,58 @@ class TextDocument * * @var \LanguageServer\LanguageClient */ - private $client; + protected $client; /** * @var Project */ - private $project; + protected $project; /** * @var PrettyPrinter */ - private $prettyPrinter; + protected $prettyPrinter; /** * @var DefinitionResolver */ - private $definitionResolver; + protected $definitionResolver; /** * @var CompletionProvider */ - private $completionProvider; + protected $completionProvider; /** * @var ReadableIndex */ - private $index; + protected $index; + + /** + * @var \stdClass|null + */ + protected $composerJson; + + /** + * @var \stdClass|null + */ + protected $composerLock; /** * @param PhpDocumentLoader $documentLoader * @param DefinitionResolver $definitionResolver * @param LanguageClient $client * @param ReadableIndex $index + * @param \stdClass $composerJson + * @param \stdClass $composerLock */ public function __construct( PhpDocumentLoader $documentLoader, DefinitionResolver $definitionResolver, LanguageClient $client, - ReadableIndex $index + ReadableIndex $index, + \stdClass $composerJson = null, + \stdClass $composerLock = null ) { $this->documentLoader = $documentLoader; $this->client = $client; @@ -84,6 +100,8 @@ class TextDocument $this->definitionResolver = $definitionResolver; $this->completionProvider = new CompletionProvider($this->definitionResolver, $index); $this->index = $index; + $this->composerJson = $composerJson; + $this->composerLock = $composerLock; } /** @@ -247,7 +265,14 @@ class TextDocument if ($node === null) { return []; } - $def = $this->definitionResolver->resolveReferenceNodeToDefinition($node); + // Handle definition nodes + $fqn = DefinitionResolver::getDefinedFqn($node); + if ($fqn !== null) { + $def = $this->index->getDefinition($fqn); + } else { + // Handle reference nodes + $def = $this->definitionResolver->resolveReferenceNodeToDefinition($node); + } if ( $def === null || $def->symbolInformation === null @@ -317,4 +342,61 @@ class TextDocument return $this->completionProvider->provideCompletion($document, $position); }); } + + /** + * This method is the same as textDocument/definition, except that + * + * The method returns metadata about the definition (the same metadata that workspace/xreferences searches for). + * The concrete location to the definition (location field) is optional. This is useful because the language server + * might not be able to resolve a goto definition request to a concrete location (e.g. due to lack of dependencies) + * but still may know some information about it. + * + * @param TextDocumentIdentifier $textDocument The text document + * @param Position $position The position inside the text document + * @return Promise + */ + public function xdefinition(TextDocumentIdentifier $textDocument, Position $position): Promise + { + return coroutine(function () use ($textDocument, $position) { + $document = yield $this->documentLoader->getOrLoad($textDocument->uri); + $node = $document->getNodeAtPosition($position); + if ($node === null) { + return []; + } + // Handle definition nodes + $fqn = DefinitionResolver::getDefinedFqn($node); + if ($fqn !== null) { + $def = $this->index->getDefinition($fqn); + } else { + // Handle reference nodes + $def = $this->definitionResolver->resolveReferenceNodeToDefinition($node); + } + if ( + $def === null + || $def->symbolInformation === null + || Uri\parse($def->symbolInformation->location->uri)['scheme'] === 'phpstubs' + ) { + return []; + } + $symbol = new SymbolDescriptor; + foreach (get_object_vars($def->symbolInformation) as $prop => $val) { + $symbol->$prop = $val; + } + $symbol->fqsen = $def->fqn; + if (preg_match('/\/vendor\/([^\/]+\/[^\/]+)\//', $def->symbolInformation->location->uri, $matches) && $this->composerLock !== null) { + // Definition is inside a dependency + $packageName = $matches[1]; + foreach ($this->composerLock->packages as $package) { + if ($package->name === $packageName) { + $symbol->package = $package; + break; + } + } + } else if ($this->composerJson !== null) { + // Definition belongs to a root package + $symbol->package = $this->composerJson; + } + return [new SymbolLocationInformation($symbol, $symbol->location)]; + }); + } } diff --git a/src/Server/Workspace.php b/src/Server/Workspace.php index 66f8606..112e601 100644 --- a/src/Server/Workspace.php +++ b/src/Server/Workspace.php @@ -3,22 +3,17 @@ declare(strict_types = 1); namespace LanguageServer\Server; -use LanguageServer\{LanguageClient, Project}; -use LanguageServer\Index\ProjectIndex; -use LanguageServer\Protocol\SymbolInformation; +use LanguageServer\{LanguageClient, Project, PhpDocumentLoader}; +use LanguageServer\Index\{ProjectIndex, DependenciesIndex, Index}; +use LanguageServer\Protocol\{SymbolInformation, SymbolDescriptor, ReferenceInformation, DependencyReference, Location}; +use Sabre\Event\Promise; +use function Sabre\Event\coroutine; /** * Provides method handlers for all workspace/* methods */ class Workspace { - /** - * The lanugage client object to call methods on the client - * - * @var \LanguageServer\LanguageClient - */ - private $client; - /** * The symbol index for the workspace * @@ -27,12 +22,39 @@ class Workspace private $index; /** - * @param ProjectIndex $index Index that is searched on a workspace/symbol request + * @var DependenciesIndex */ - public function __construct(ProjectIndex $index, LanguageClient $client) + private $dependenciesIndex; + + /** + * @var Index + */ + private $sourceIndex; + + /** + * @var \stdClass + */ + public $composerLock; + + /** + * @var PhpDocumentLoader + */ + public $documentLoader; + + /** + * @param ProjectIndex $index Index that is searched on a workspace/symbol request + * @param DependenciesIndex $dependenciesIndex Index that is used on a workspace/xreferences request + * @param DependenciesIndex $sourceIndex Index that is used on a workspace/xreferences request + * @param \stdClass $composerLock The parsed composer.lock of the project, if any + * @param PhpDocumentLoader $documentLoader PhpDocumentLoader instance to load documents + */ + public function __construct(ProjectIndex $index, DependenciesIndex $dependenciesIndex, Index $sourceIndex, \stdClass $composerLock = null, PhpDocumentLoader $documentLoader) { + $this->sourceIndex = $sourceIndex; $this->index = $index; - $this->client = $client; + $this->dependenciesIndex = $dependenciesIndex; + $this->composerLock = $composerLock; + $this->documentLoader = $documentLoader; } /** @@ -51,4 +73,90 @@ class Workspace } return $symbols; } + + /** + * The workspace references request is sent from the client to the server to locate project-wide references to a symbol given its description / metadata. + * + * @param SymbolDescriptor $query Partial metadata about the symbol that is being searched for. + * @param string[] $files An optional list of files to restrict the search to. + * @return ReferenceInformation[] + */ + public function xreferences($query, array $files = null): Promise + { + return coroutine(function () use ($query, $files) { + if ($this->composerLock === null) { + return []; + } + /** Map from URI to array of referenced FQNs in dependencies */ + $refs = []; + // Get all references TO dependencies + $fqns = isset($query->fqsen) ? [$query->fqsen] : array_values($this->dependenciesIndex->getDefinitions()); + foreach ($fqns as $fqn) { + foreach ($this->sourceIndex->getReferenceUris($fqn) as $uri) { + if (!isset($refs[$uri])) { + $refs[$uri] = []; + } + if (array_search($uri, $refs[$uri]) === false) { + $refs[$uri][] = $fqn; + } + } + } + $refInfos = []; + foreach ($refs as $uri => $fqns) { + foreach ($fqns as $fqn) { + $def = $this->dependenciesIndex->getDefinition($fqn); + $symbol = new SymbolDescriptor; + $symbol->fqsen = $fqn; + foreach (get_object_vars($def->symbolInformation) as $prop => $val) { + $symbol->$prop = $val; + } + // Find out package name + preg_match('/\/vendor\/([^\/]+\/[^\/]+)\//', $def->symbolInformation->location->uri, $matches); + $packageName = $matches[1]; + foreach ($this->composerLock->packages as $package) { + if ($package->name === $packageName) { + $symbol->package = $package; + break; + } + } + // If there was no FQSEN provided, check if query attributes match + if (!isset($query->fqsen)) { + $matches = true; + foreach (get_object_vars($query) as $prop => $val) { + if ($query->$prop != $symbol->$prop) { + $matches = false; + break; + } + } + if (!$matches) { + continue; + } + } + $doc = yield $this->documentLoader->getOrLoad($uri); + foreach ($doc->getReferenceNodesByFqn($fqn) as $node) { + $refInfo = new ReferenceInformation; + $refInfo->reference = Location::fromNode($node); + $refInfo->symbol = $symbol; + $refInfos[] = $refInfo; + } + } + } + return $refInfos; + }); + } + + /** + * @return DependencyReference[] + */ + public function xdependencies(): array + { + if ($this->composerLock === null) { + return []; + } + $dependencyReferences = []; + foreach ($this->composerLock->packages as $package) { + $dependencyReferences[] = new DependencyReference($package); + } + return $dependencyReferences; + } } diff --git a/tests/LanguageServerTest.php b/tests/LanguageServerTest.php index 6f8e705..6bfa3c9 100644 --- a/tests/LanguageServerTest.php +++ b/tests/LanguageServerTest.php @@ -41,6 +41,9 @@ class LanguageServerTest extends TestCase $serverCapabilities->completionProvider = new CompletionOptions; $serverCapabilities->completionProvider->resolveProvider = false; $serverCapabilities->completionProvider->triggerCharacters = ['$', '>']; + $serverCapabilities->xworkspaceReferencesProvider = true; + $serverCapabilities->xdefinitionProvider = true; + $serverCapabilities->xdependenciesProvider = true; $this->assertEquals(new InitializeResult($serverCapabilities), $result); } diff --git a/tests/Server/ServerTestCase.php b/tests/Server/ServerTestCase.php index a4661a2..e37844a 100644 --- a/tests/Server/ServerTestCase.php +++ b/tests/Server/ServerTestCase.php @@ -45,13 +45,15 @@ abstract class ServerTestCase extends TestCase public function setUp() { - $projectIndex = new ProjectIndex(new Index, new DependenciesIndex); + $sourceIndex = new Index; + $dependenciesIndex = new DependenciesIndex; + $projectIndex = new ProjectIndex($sourceIndex, $dependenciesIndex); $definitionResolver = new DefinitionResolver($projectIndex); $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); $this->documentLoader = new PhpDocumentLoader(new FileSystemContentRetriever, $projectIndex, $definitionResolver); $this->textDocument = new Server\TextDocument($this->documentLoader, $definitionResolver, $client, $projectIndex); - $this->workspace = new Server\Workspace($projectIndex, $client); + $this->workspace = new Server\Workspace($projectIndex, $dependenciesIndex, $sourceIndex, null, $this->documentLoader); $globalSymbolsUri = pathToUri(realpath(__DIR__ . '/../../fixtures/global_symbols.php')); $globalReferencesUri = pathToUri(realpath(__DIR__ . '/../../fixtures/global_references.php'));