diff --git a/src/LanguageServer.php b/src/LanguageServer.php index c9fd73b..9896885 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -105,6 +105,8 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher $serverCapabilities->documentFormattingProvider = true; // Support "Go to definition" $serverCapabilities->definitionProvider = true; + // Support "Find all references" + $serverCapabilities->referencesProvider = true; return new InitializeResult($serverCapabilities); } diff --git a/src/NodeVisitor/ReferencesCollector.php b/src/NodeVisitor/ReferencesCollector.php new file mode 100644 index 0000000..a6c51e7 --- /dev/null +++ b/src/NodeVisitor/ReferencesCollector.php @@ -0,0 +1,55 @@ +definitions = $definitions; + $this->references = array_fill_keys(array_keys($definitions), []); + } + + public function enterNode(Node $node) + { + // Check if the node references any global symbol + $fqn = $node->getAttribute('ownerDocument')->getReferencedFqn($node); + if ($fqn) { + $this->references[$fqn][] = $node; + // Static method calls, constant and property fetches also need to register a reference to the class + // A reference like TestNamespace\TestClass::myStaticMethod() registers a reference for + // - TestNamespace\TestClass + // - TestNamespace\TestClass::myStaticMethod() + if ( + ($node instanceof Node\Expr\StaticCall + || $node instanceof Node\Expr\StaticPropertyFetch + || $node instanceof Node\Expr\ClassConstFetch) + && $node->class instanceof Node\Name + ) { + $this->references[(string)$node->class][] = $node->class; + } + } + } +} diff --git a/src/PhpDocument.php b/src/PhpDocument.php index c231f03..1d65c0c 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -4,7 +4,13 @@ declare(strict_types = 1); namespace LanguageServer; use LanguageServer\Protocol\{Diagnostic, DiagnosticSeverity, Range, Position, TextEdit}; -use LanguageServer\NodeVisitor\{NodeAtPositionFinder, ReferencesAdder, DefinitionCollector, ColumnCalculator}; +use LanguageServer\NodeVisitor\{ + NodeAtPositionFinder, + ReferencesAdder, + DefinitionCollector, + ColumnCalculator, + ReferencesCollector +}; use PhpParser\{Error, Node, NodeTraverser, Parser}; use PhpParser\NodeVisitor\NameResolver; @@ -85,6 +91,18 @@ class PhpDocument } /** + * Get all references of a fully qualified name + * + * @param string $fqn The fully qualified name of the symbol + * @return Node[] + */ + public function getReferencesByFqn(string $fqn) + { + return isset($this->references) && isset($this->references[$fqn]) ? $this->references[$fqn] : null; + } + + /** + * Updates the content on this document. * Re-parses a source file, updates symbols and reports parsing errors * that may have occured as diagnostics. * @@ -146,8 +164,20 @@ class PhpDocument $this->project->setDefinitionUri($fqn, $this->uri); } - $this->statements = $stmts; $this->definitions = $definitionCollector->definitions; + + // Collect all references + $traverser = new NodeTraverser; + $referencesCollector = new ReferencesCollector($this->definitions); + $traverser->addVisitor($referencesCollector); + $traverser->traverse($stmts); + $this->references = $referencesCollector->references; + // Register this document on the project for references + foreach ($referencesCollector->references as $fqn => $nodes) { + $this->project->addReferenceDocument($fqn, $this); + } + + $this->statements = $stmts; } } diff --git a/src/Project.php b/src/Project.php index b6204d5..b416524 100644 --- a/src/Project.php +++ b/src/Project.php @@ -16,12 +16,19 @@ class Project private $documents = []; /** - * An associative array that maps fully qualified symbol names to document URIs + * An associative array that maps fully qualified symbol names to document URIs that define the symbol * * @var string[] */ private $definitions = []; + /** + * An associative array that maps fully qualified symbol names to arrays of document URIs that reference the symbol + * + * @var PhpDocument[][] + */ + private $references = []; + /** * Instance of the PHP parser * @@ -143,6 +150,34 @@ class Project $this->definitions[$fqn] = $uri; } + /** + * Adds a document as a referencee of a specific symbol + * + * @param string $fqn The fully qualified name of the symbol + * @return void + */ + public function addReferenceDocument(string $fqn, PhpDocument $document) + { + if (!isset($this->references[$fqn])) { + $this->references[$fqn] = []; + } + // TODO: use DS\Set instead of searching array + if (array_search($document, $this->references[$fqn], true) === false) { + $this->references[$fqn][] = $document; + } + } + + /** + * Returns all documents that reference a symbol + * + * @param string $fqn The fully qualified name of the symbol + * @return PhpDocument[] + */ + public function getReferenceDocuments(string $fqn) + { + return $this->references[$fqn] ?? []; + } + /** * Returns the document where a symbol is defined * diff --git a/src/Server/TextDocument.php b/src/Server/TextDocument.php index aead8d6..fcee7d7 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -12,7 +12,8 @@ use LanguageServer\Protocol\{ FormattingOptions, TextEdit, Location, - SymbolInformation + SymbolInformation, + ReferenceContext }; /** @@ -30,7 +31,7 @@ class TextDocument /** * @var Project */ - private $project; + public $project; public function __construct(Project $project, LanguageClient $client) { @@ -104,6 +105,37 @@ class TextDocument return $this->project->getDocument($textDocument->uri)->getFormattedText(); } + /** + * The references request is sent from the client to the server to resolve project-wide references for the symbol + * denoted by the given text document position. + * + * @param ReferenceContext $context + * @return Location[]|null + */ + public function references(ReferenceContext $context, TextDocumentIdentifier $textDocument, Position $position) + { + $document = $this->project->getDocument($textDocument->uri); + $node = $document->getNodeAtPosition($position); + if ($node === null) { + return null; + } + $fqn = $document->getDefinedFqn($node); + if ($fqn === null) { + return null; + } + $refDocuments = $this->project->getReferenceDocuments($fqn); + $locations = []; + foreach ($refDocuments as $document) { + $refs = $document->getReferencesByFqn($fqn); + if ($refs !== null) { + foreach ($refs as $ref) { + $locations[] = Location::fromNode($ref); + } + } + } + return $locations; + } + /** * 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. diff --git a/tests/LanguageServerTest.php b/tests/LanguageServerTest.php index 521d602..06742d8 100644 --- a/tests/LanguageServerTest.php +++ b/tests/LanguageServerTest.php @@ -35,7 +35,7 @@ class LanguageServerTest extends TestCase 'completionProvider' => null, 'signatureHelpProvider' => null, 'definitionProvider' => true, - 'referencesProvider' => null, + 'referencesProvider' => true, 'documentHighlightProvider' => null, 'workspaceSymbolProvider' => true, 'codeActionProvider' => null, diff --git a/tests/Server/TextDocument/ReferencesTest.php b/tests/Server/TextDocument/ReferencesTest.php new file mode 100644 index 0000000..cecc729 --- /dev/null +++ b/tests/Server/TextDocument/ReferencesTest.php @@ -0,0 +1,354 @@ +textDocument = new Server\TextDocument($project, $client); + $this->symbolsUri = pathToUri(realpath(__DIR__ . '/../../../fixtures/symbols.php')); + $this->referencesUri = pathToUri(realpath(__DIR__ . '/../../../fixtures/references.php')); + $this->useUri = pathToUri(realpath(__DIR__ . '/../../../fixtures/use.php')); + $project->loadDocument($this->referencesUri, file_get_contents($this->referencesUri)); + $project->loadDocument($this->symbolsUri, file_get_contents($this->symbolsUri)); + $project->loadDocument($this->useUri, file_get_contents($this->useUri)); + } + + public function testReferencesForClassLike() + { + // class TestClass implements TestInterface + // Get references for TestClass + $result = $this->textDocument->references(new ReferenceContext, new TextDocumentIdentifier($this->symbolsUri), new Position(6, 9)); + $this->assertEquals([ + // $obj = new TestClass(); + [ + 'uri' => $this->referencesUri, + 'range' => [ + 'start' => [ + 'line' => 4, + 'character' => 11 + ], + 'end' => [ + 'line' => 4, + 'character' => 20 + ] + ] + ], + // TestClass::staticTestMethod(); + [ + 'uri' => $this->referencesUri, + 'range' => [ + 'start' => [ + 'line' => 7, + 'character' => 0 + ], + 'end' => [ + 'line' => 7, + 'character' => 9 + ] + ] + ], + // echo TestClass::$staticTestProperty; + [ + 'uri' => $this->referencesUri, + 'range' => [ + 'start' => [ + 'line' => 8, + 'character' => 5 + ], + 'end' => [ + 'line' => 8, + 'character' => 14 + ] + ] + ], + // TestClass::TEST_CLASS_CONST; + [ + 'uri' => $this->referencesUri, + 'range' => [ + 'start' => [ + 'line' => 9, + 'character' => 5 + ], + 'end' => [ + 'line' => 9, + 'character' => 14 + ] + ] + ], + // function whatever(TestClass $param) + [ + 'uri' => $this->referencesUri, + 'range' => [ + 'start' => [ + 'line' => 15, + 'character' => 18 + ], + 'end' => [ + 'line' => 15, + 'character' => 27 + ] + ] + ], + // function whatever(TestClass $param): TestClass + [ + 'uri' => $this->referencesUri, + 'range' => [ + 'start' => [ + 'line' => 15, + 'character' => 37 + ], + 'end' => [ + 'line' => 15, + 'character' => 46 + ] + ] + ], + // use TestNamespace\TestClass; + [ + 'uri' => $this->useUri, + 'range' => [ + 'start' => [ + 'line' => 4, + 'character' => 4 + ], + 'end' => [ + 'line' => 4, + 'character' => 27 + ] + ] + ] + ], json_decode(json_encode($result), true)); + } + + public function testReferencesForClassConstants() + { + // const TEST_CLASS_CONST = 123; + // Get references for TEST_CLASS_CONST + $result = $this->textDocument->references(new ReferenceContext, new TextDocumentIdentifier($this->symbolsUri), new Position(8, 19)); + $this->assertEquals([ + [ + 'uri' => $this->referencesUri, + 'range' => [ + 'start' => [ + 'line' => 9, + 'character' => 5 + ], + 'end' => [ + 'line' => 9, + 'character' => 32 + ] + ] + ] + ], json_decode(json_encode($result), true)); + } + + public function testReferencesForConstants() + { + // const TEST_CONST = 123; + // Get references for TEST_CONST + $result = $this->textDocument->references(new ReferenceContext, new TextDocumentIdentifier($this->symbolsUri), new Position(4, 13)); + $this->assertEquals([ + [ + 'uri' => $this->referencesUri, + 'range' => [ + 'start' => [ + 'line' => 23, + 'character' => 5 + ], + 'end' => [ + 'line' => 23, + 'character' => 15 + ] + ] + ] + ], json_decode(json_encode($result), true)); + } + + public function testReferencesForStaticMethods() + { + $this->markTestIncomplete(); + // TestClass::staticTestMethod(); + // Get definition for staticTestMethod + $result = $this->textDocument->definition(new TextDocumentIdentifier($this->referencesUri), new Position(7, 20)); + $this->assertEquals([ + 'uri' => $this->symbolsUri, + 'range' => [ + 'start' => [ + 'line' => 12, + 'character' => 4 + ], + 'end' => [ + 'line' => 15, + 'character' => 4 + ] + ] + ], json_decode(json_encode($result), true)); + } + + public function testReferencesForStaticProperties() + { + $this->markTestIncomplete(); + // echo TestClass::$staticTestProperty; + // Get definition for staticTestProperty + $result = $this->textDocument->definition(new TextDocumentIdentifier($this->referencesUri), new Position(8, 25)); + $this->assertEquals([ + 'uri' => $this->symbolsUri, + 'range' => [ + 'start' => [ + 'line' => 9, + 'character' => 18 + ], + 'end' => [ + 'line' => 9, + 'character' => 36 + ] + ] + ], json_decode(json_encode($result), true)); + } + + public function testReferencesForMethods() + { + $this->markTestIncomplete(); + // $obj->testMethod(); + // Get definition for testMethod + $result = $this->textDocument->definition(new TextDocumentIdentifier($this->referencesUri), new Position(5, 11)); + $this->assertEquals([ + 'uri' => $this->symbolsUri, + 'range' => [ + 'start' => [ + 'line' => 17, + 'character' => 4 + ], + 'end' => [ + 'line' => 20, + 'character' => 4 + ] + ] + ], json_decode(json_encode($result), true)); + } + + public function testReferencesForProperties() + { + $this->markTestIncomplete(); + // echo $obj->testProperty; + // Get definition for testProperty + $result = $this->textDocument->definition(new TextDocumentIdentifier($this->referencesUri), new Position(6, 18)); + $this->assertEquals([ + 'uri' => $this->symbolsUri, + 'range' => [ + 'start' => [ + 'line' => 10, + 'character' => 11 + ], + 'end' => [ + 'line' => 10, + 'character' => 23 + ] + ] + ], json_decode(json_encode($result), true)); + } + + public function testReferencesForVariables() + { + $this->markTestIncomplete(); + // echo $var; + // Get definition for $var + $result = $this->textDocument->definition(new TextDocumentIdentifier($this->referencesUri), new Position(13, 7)); + $this->assertEquals([ + 'uri' => $this->referencesUri, + 'range' => [ + 'start' => [ + 'line' => 12, + 'character' => 0 + ], + 'end' => [ + 'line' => 12, + 'character' => 9 + ] + ] + ], json_decode(json_encode($result), true)); + } + + public function testReferencesForParams() + { + $this->markTestIncomplete(); + // echo $param; + // Get definition for $param + $result = $this->textDocument->definition(new TextDocumentIdentifier($this->referencesUri), new Position(16, 13)); + $this->assertEquals([ + 'uri' => $this->referencesUri, + 'range' => [ + 'start' => [ + 'line' => 15, + 'character' => 18 + ], + 'end' => [ + 'line' => 15, + 'character' => 33 + ] + ] + ], json_decode(json_encode($result), true)); + } + + public function testReferencesForUsedVariables() + { + $this->markTestIncomplete(); + // echo $var; + // Get definition for $var + $result = $this->textDocument->definition(new TextDocumentIdentifier($this->referencesUri), new Position(20, 11)); + $this->assertEquals([ + 'uri' => $this->referencesUri, + 'range' => [ + 'start' => [ + 'line' => 19, + 'character' => 22 + ], + 'end' => [ + 'line' => 19, + 'character' => 25 + ] + ] + ], json_decode(json_encode($result), true)); + } + + public function testReferencesForFunctions() + { + $this->markTestIncomplete(); + // test_function(); + // Get definition for test_function + $result = $this->textDocument->definition(new TextDocumentIdentifier($this->referencesUri), new Position(10, 4)); + $this->assertEquals([ + 'uri' => $this->symbolsUri, + 'range' => [ + 'start' => [ + 'line' => 33, + 'character' => 0 + ], + 'end' => [ + 'line' => 36, + 'character' => 0 + ] + ] + ], json_decode(json_encode($result), true)); + } +}