diff --git a/src/LanguageServer.php b/src/LanguageServer.php index cee9faa..f23d271 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -153,7 +153,7 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher $shortName = substr($file, strlen($rootPath) + 1); $this->client->window->logMessage(MessageType::INFO, "Parsing file $fileNum/$numTotalFiles: $shortName."); - $this->project->getDocument($uri)->updateContent(file_get_contents($file)); + $this->project->getDocument($uri)->updateContent(file_get_contents($file), false); Loop\setTimeout($processFile, 0); } else { diff --git a/src/PhpDocument.php b/src/PhpDocument.php index 963e0c9..4dd7929 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -8,6 +8,8 @@ use LanguageServer\NodeVisitor\{NodeAtPositionFinder, ReferencesAdder, Definitio use PhpParser\{Error, Comment, Node, ParserFactory, NodeTraverser, Lexer, Parser}; use PhpParser\PrettyPrinter\Standard as PrettyPrinter; use PhpParser\NodeVisitor\NameResolver; +use Exception; +use function LanguageServer\uriToPath; class PhpDocument { @@ -53,14 +55,14 @@ class PhpDocument * * @var Node[] */ - private $stmts = []; + private $statements; /** * Map from fully qualified name (FQN) to Node * * @var Node[] */ - private $definitions = []; + private $definitions; /** * Map from fully qualified name (FQN) to array of nodes that reference the symbol @@ -84,87 +86,56 @@ class PhpDocument } /** - * Returns all symbols in this document. + * Returns true if the content of this document is being held in memory * - * @return SymbolInformation[]|null + * @return bool */ - public function getSymbols() + public function isLoaded() { - if (!isset($this->definitions)) { - return null; - } - $nodeSymbolKindMap = [ - Node\Stmt\Class_::class => SymbolKind::CLASS_, - Node\Stmt\Trait_::class => SymbolKind::CLASS_, - Node\Stmt\Interface_::class => SymbolKind::INTERFACE, - Node\Stmt\Namespace_::class => SymbolKind::NAMESPACE, - Node\Stmt\Function_::class => SymbolKind::FUNCTION, - Node\Stmt\ClassMethod::class => SymbolKind::METHOD, - Node\Stmt\PropertyProperty::class => SymbolKind::PROPERTY, - Node\Const_::class => SymbolKind::CONSTANT - ]; - $symbols = []; - foreach ($this->definitions as $fqn => $node) { - $class = get_class($node); - if (!isset($nodeSymbolKindMap[$class])) { - continue; - } - $symbol = new SymbolInformation(); - $symbol->kind = $nodeSymbolKindMap[$class]; - $symbol->name = (string)$node->name; - $symbol->location = Location::fromNode($node); - $parts = preg_split('/(::|\\\\)/', $fqn); - array_pop($parts); - $symbol->containerName = implode('\\', $parts); - $symbols[] = $symbol; - } - return $symbols; + return isset($this->content); } /** - * Returns symbols in this document filtered by query string. + * Loads the content from disk and saves statements and definitions in memory * - * @param string $query The search query - * @return SymbolInformation[]|null + * @return void */ - public function findSymbols(string $query) + public function load() { - $symbols = $this->getSymbols(); - if ($symbols === null) { - return null; - } - if ($query === '') { - return $symbols; - } - return array_filter($symbols, function($symbol) use ($query) { - return stripos($symbol->name, $query) !== false; - }); + $this->updateContent(file_get_contents(uriToPath($this->getUri())), true); } /** - * Updates the content on this document. + * Unloads the content, statements and definitions from memory + * + * @return void + */ + public function unload() + { + unset($this->content); + unset($this->statements); + unset($this->definitions); + unset($this->references); + } + + /** + * Re-parses a source file, updates symbols and reports parsing errors + * that may have occured as diagnostics. * * @param string $content + * @param bool $keepInMemory Wether to keep content, statements and definitions in memory or only update project definitions * @return void */ - public function updateContent(string $content) - { - $this->content = $content; - $this->parse(); - } - - /** - * Re-parses a source file, updates symbols, reports parsing errors - * that may have occured as diagnostics and returns parsed nodes. - * - * @return void - */ - public function parse() + public function updateContent(string $content, bool $keepInMemory = true) { + $keepInMemory = $keepInMemory || $this->isLoaded(); + if ($keepInMemory) { + $this->content = $content; + } $stmts = null; $errors = []; try { - $stmts = $this->parser->parse($this->content); + $stmts = $this->parser->parse($content); } catch (\PhpParser\Error $e) { // Lexer can throw errors. e.g for unterminated comments // unfortunately we don't get a location back @@ -177,8 +148,8 @@ class PhpDocument foreach ($errors as $error) { $diagnostic = new Diagnostic(); $diagnostic->range = new Range( - new Position($error->getStartLine() - 1, $error->hasColumnInfo() ? $error->getStartColumn($this->content) - 1 : 0), - new Position($error->getEndLine() - 1, $error->hasColumnInfo() ? $error->getEndColumn($this->content) : 0) + new Position($error->getStartLine() - 1, $error->hasColumnInfo() ? $error->getStartColumn($content) - 1 : 0), + new Position($error->getEndLine() - 1, $error->hasColumnInfo() ? $error->getEndColumn($content) : 0) ); $diagnostic->severity = DiagnosticSeverity::ERROR; $diagnostic->source = 'php'; @@ -199,7 +170,7 @@ class PhpDocument $traverser->addVisitor(new ReferencesAdder($this)); // Add column attributes to nodes - $traverser->addVisitor(new ColumnCalculator($this->content)); + $traverser->addVisitor(new ColumnCalculator($content)); // Collect all definitions $definitionCollector = new DefinitionCollector; @@ -207,13 +178,15 @@ class PhpDocument $traverser->traverse($stmts); - $this->definitions = $definitionCollector->definitions; // Register this document on the project for all the symbols defined in it foreach ($definitionCollector->definitions as $fqn => $node) { $this->project->addDefinitionDocument($fqn, $this); } - $this->stmts = $stmts; + if ($keepInMemory) { + $this->statements = $stmts; + $this->definitions = $definitionCollector->definitions; + } } } @@ -234,12 +207,29 @@ class PhpDocument * Returns this document's text content. * * @return string + * @throws Exception If the content was not loaded */ public function getContent() { + if (!isset($this->content)) { + throw new Exception('Content is not loaded'); + } return $this->content; } + /** + * Returns this document's AST. + * + * @return Node[] + */ + public function &getStatements() + { + if (!isset($this->statements)) { + $this->parse($this->getContent()); + } + return $this->statements; + } + /** * Returns the URI of the document * @@ -258,13 +248,10 @@ class PhpDocument */ public function getNodeAtPosition(Position $position) { - if ($this->stmts === null) { - return null; - } $traverser = new NodeTraverser; $finder = new NodeAtPositionFinder($position); $traverser->addVisitor($finder); - $traverser->traverse($this->stmts); + $traverser->traverse($this->getStatements()); return $finder->node; } @@ -276,7 +263,21 @@ class PhpDocument */ public function getDefinitionByFqn(string $fqn) { - return $this->definitions[$fqn] ?? null; + return $this->getDefinitions()[$fqn] ?? null; + } + + /** + * Returns a map from fully qualified name (FQN) to Nodes defined in this document + * + * @return Node[] + * @throws Exception If the definitions are not loaded + */ + public function &getDefinitions() + { + if (!isset($this->definitions)) { + throw new Exception('Definitions of this document are not loaded'); + } + return $this->definitions; } /** @@ -287,7 +288,7 @@ class PhpDocument */ public function isDefined(string $fqn): bool { - return isset($this->definitions[$fqn]); + return isset($this->getDefinitions()[$fqn]); } /** @@ -306,16 +307,11 @@ class PhpDocument */ public function getDefinedFqn(Node $node) { - if ($node instanceof Node\Name) { - $nameNode = $node; - $node = $node->getAttribute('parentNode'); - } - // Only the class node should count as the definition, not the name node // Anonymous classes don't count as a definition - if ($node instanceof Node\Stmt\ClassLike && !isset($nameNode) && isset($node->name)) { + if ($node instanceof Node\Stmt\ClassLike && isset($node->name)) { // Class, interface or trait declaration return (string)$node->namespacedName; - } else if ($node instanceof Node\Stmt\Function_ && !isset($nameNode)) { + } else if ($node instanceof Node\Stmt\Function_) { // Function: use functionName() as the name return (string)$node->namespacedName . '()'; } else if ($node instanceof Node\Stmt\ClassMethod) { @@ -485,6 +481,9 @@ class PhpDocument return null; } $document = $this->project->getDefinitionDocument($fqn); + if (!$document->isLoaded()) { + $document->load(); + } if (!isset($document)) { return null; } diff --git a/src/Project.php b/src/Project.php index d9ff73d..41319ed 100644 --- a/src/Project.php +++ b/src/Project.php @@ -83,6 +83,17 @@ class Project return $this->definitions[$fqn] ?? null; } + /** + * Returns an associative array [string => PhpDocument] + * that maps fully qualified symbol names to loaded PhpDocuments + * + * @return PhpDocument[] + */ + public function &getDefinitionDocuments() + { + return $this->definitions; + } + /** * Returns true if the given FQN is defined in the project * @@ -93,22 +104,4 @@ class Project { return isset($this->definitions[$fqn]); } - - /** - * Finds symbols in all documents, filtered by query parameter. - * - * @param string $query - * @return SymbolInformation[] - */ - public function findSymbols(string $query) - { - $queryResult = []; - foreach ($this->documents as $uri => $document) { - $documentQueryResult = $document->findSymbols($query); - if ($documentQueryResult !== null) { - $queryResult = array_merge($queryResult, $documentQueryResult); - } - } - return $queryResult; - } } diff --git a/src/Protocol/SymbolInformation.php b/src/Protocol/SymbolInformation.php index e02fd9b..461b430 100644 --- a/src/Protocol/SymbolInformation.php +++ b/src/Protocol/SymbolInformation.php @@ -3,6 +3,7 @@ namespace LanguageServer\Protocol; use PhpParser\Node; +use Exception; /** * Represents information about programming constructs like variables, classes, @@ -37,4 +38,39 @@ class SymbolInformation * @var string|null */ public $containerName; + + /** + * Converts a Node to a SymbolInformation + * + * @param Node $node + * @param string $fqn If given, $containerName will be extracted from it + * @return self + */ + public static function fromNode(Node $node, string $fqn = null) + { + $nodeSymbolKindMap = [ + Node\Stmt\Class_::class => SymbolKind::CLASS_, + Node\Stmt\Trait_::class => SymbolKind::CLASS_, + Node\Stmt\Interface_::class => SymbolKind::INTERFACE, + Node\Stmt\Namespace_::class => SymbolKind::NAMESPACE, + Node\Stmt\Function_::class => SymbolKind::FUNCTION, + Node\Stmt\ClassMethod::class => SymbolKind::METHOD, + Node\Stmt\PropertyProperty::class => SymbolKind::PROPERTY, + Node\Const_::class => SymbolKind::CONSTANT + ]; + $class = get_class($node); + if (!isset($nodeSymbolKindMap[$class])) { + throw new Exception("Not a declaration node: $class"); + } + $symbol = new self; + $symbol->kind = $nodeSymbolKindMap[$class]; + $symbol->name = (string)$node->name; + $symbol->location = Location::fromNode($node); + if ($fqn !== null) { + $parts = preg_split('/(::|\\\\)/', $fqn); + array_pop($parts); + $symbol->containerName = implode('\\', $parts); + } + return $symbol; + } } diff --git a/src/Server/TextDocument.php b/src/Server/TextDocument.php index 39a9699..9c8799a 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -14,7 +14,8 @@ use LanguageServer\Protocol\{ Position, FormattingOptions, TextEdit, - Location + Location, + SymbolInformation }; /** @@ -49,7 +50,11 @@ class TextDocument */ public function documentSymbol(TextDocumentIdentifier $textDocument): array { - return $this->project->getDocument($textDocument->uri)->getSymbols(); + $symbols = []; + foreach ($this->project->getDocument($textDocument->uri)->getDefinitions() as $fqn => $node) { + $symbols[] = SymbolInformation::fromNode($node, $fqn); + } + return $symbols; } /** @@ -77,6 +82,18 @@ class TextDocument $this->project->getDocument($textDocument->uri)->updateContent($contentChanges[0]->text); } + /** + * The document close notification is sent from the client to the server when the document got closed in the client. + * The document's truth now exists where the document's uri points to (e.g. if the document's uri is a file uri the + * truth now exists on disk). + * + * @param \LanguageServer\Protocol\TextDocumentItem $textDocument The document that was closed + * @return void + */ + public function didClose(TextDocumentIdentifier $textDocument) + { + $this->project->getDocument($textDocument->uri)->unload(); + } /** * The document formatting request is sent from the server to the client to format a whole document. diff --git a/src/Server/Workspace.php b/src/Server/Workspace.php index c6d963a..5186008 100644 --- a/src/Server/Workspace.php +++ b/src/Server/Workspace.php @@ -53,6 +53,15 @@ class Workspace */ public function symbol(string $query): array { - return $this->project->findSymbols($query); + $symbols = []; + foreach ($this->project->getDefinitionDocuments() as $fqn => $document) { + if ($query === '' || stripos($fqn, $query) !== false) { + if (!$document->isLoaded()) { + $document->load(); + } + $symbols[] = SymbolInformation::fromNode($document->getDefinitionByFqn($fqn), $fqn); + } + } + return $symbols; } } diff --git a/tests/PhpDocumentTest.php b/tests/PhpDocumentTest.php index 93075fc..f6ab98a 100644 --- a/tests/PhpDocumentTest.php +++ b/tests/PhpDocumentTest.php @@ -28,9 +28,7 @@ class PhpDocumentTest extends TestCase $document->updateContent("getSymbols(); - - $this->assertEquals([], json_decode(json_encode($symbols), true)); + $this->assertEquals([], $document->getDefinitions()); } public function testGetNodeAtPosition() diff --git a/tests/ProjectTest.php b/tests/ProjectTest.php index 5fb9afb..97c49f9 100644 --- a/tests/ProjectTest.php +++ b/tests/ProjectTest.php @@ -36,52 +36,4 @@ class ProjectTest extends TestCase $this->assertSame($document1, $document2); } - - public function testFindSymbols() - { - $this->project->getDocument('file:///document1.php')->updateContent("project->getDocument('file:///document2.php')->updateContent("project->getDocument('invalid_file')->updateContent(file_get_contents(__DIR__ . '/../fixtures/invalid_file.php')); - - $symbols = $this->project->findSymbols('ba'); - - $this->assertEquals([ - [ - 'name' => 'bar', - 'kind' => SymbolKind::FUNCTION, - 'location' => [ - 'uri' => 'file:///document1.php', - 'range' => [ - 'start' => [ - 'line' => 2, - 'character' => 0 - ], - 'end' => [ - 'line' => 2, - 'character' => 17 - ] - ] - ], - 'containerName' => null - ], - [ - 'name' => 'baz', - 'kind' => SymbolKind::FUNCTION, - 'location' => [ - 'uri' => 'file:///document2.php', - 'range' => [ - 'start' => [ - 'line' => 1, - 'character' => 0 - ], - 'end' => [ - 'line' => 1, - 'character' => 17 - ] - ] - ], - 'containerName' => null - ] - ], json_decode(json_encode($symbols), true)); - } } diff --git a/tests/Server/TextDocument/DidCloseTest.php b/tests/Server/TextDocument/DidCloseTest.php new file mode 100644 index 0000000..f473ace --- /dev/null +++ b/tests/Server/TextDocument/DidCloseTest.php @@ -0,0 +1,34 @@ +getDocument('whatever'); + $phpDocument->updateContent('hello world'); + + $textDocumentItem = new TextDocumentItem(); + $textDocumentItem->uri = 'whatever'; + $textDocumentItem->languageId = 'php'; + $textDocumentItem->version = 1; + $textDocumentItem->text = 'hello world'; + $textDocument->didOpen($textDocumentItem); + + $textDocument->didClose(new TextDocumentIdentifier($textDocumentItem->uri)); + + $this->expectException(Exception::class); + $phpDocument->getContent(); + } +} diff --git a/tests/Server/TextDocument/FormattingTest.php b/tests/Server/TextDocument/FormattingTest.php index 957f4a6..4d8ef24 100644 --- a/tests/Server/TextDocument/FormattingTest.php +++ b/tests/Server/TextDocument/FormattingTest.php @@ -57,14 +57,4 @@ class FormattingTest extends TestCase 'newText' => $expected ]], json_decode(json_encode($result), true)); } - - public function testFormattingInvalidUri() - { - $client = new LanguageClient(new MockProtocolStream()); - $project = new Project($client); - $textDocument = new Server\TextDocument($project, $client); - - $result = $textDocument->formatting(new TextDocumentIdentifier('whatever'), new FormattingOptions()); - $this->assertSame([], $result); - } } diff --git a/tests/Server/Workspace/SymbolTest.php b/tests/Server/Workspace/SymbolTest.php index 345b142..f238207 100644 --- a/tests/Server/Workspace/SymbolTest.php +++ b/tests/Server/Workspace/SymbolTest.php @@ -1,13 +1,14 @@ workspace = new Server\Workspace($project, $client); - $project->getDocument('symbols')->updateContent(file_get_contents(__DIR__ . '/../../../fixtures/symbols.php')); - $project->getDocument('references')->updateContent(file_get_contents(__DIR__ . '/../../../fixtures/references.php')); + $this->symbolsUri = pathToUri(realpath(__DIR__ . '/../../../fixtures/symbols.php')); + $this->referencesUri = pathToUri(realpath(__DIR__ . '/../../../fixtures/references.php')); + $project->getDocument($this->symbolsUri)->updateContent(file_get_contents($this->symbolsUri), false); + $project->getDocument($this->referencesUri)->updateContent(file_get_contents($this->referencesUri), false); } public function testEmptyQueryReturnsAllSymbols() @@ -34,7 +47,7 @@ class SymbolTest extends TestCase 'name' => 'TEST_CONST', 'kind' => SymbolKind::CONSTANT, 'location' => [ - 'uri' => 'symbols', + 'uri' => $this->symbolsUri, 'range' => [ 'start' => [ 'line' => 4, @@ -52,7 +65,7 @@ class SymbolTest extends TestCase 'name' => 'TestClass', 'kind' => SymbolKind::CLASS_, 'location' => [ - 'uri' => 'symbols', + 'uri' => $this->symbolsUri, 'range' => [ 'start' => [ 'line' => 6, @@ -70,7 +83,7 @@ class SymbolTest extends TestCase 'name' => 'TEST_CLASS_CONST', 'kind' => SymbolKind::CONSTANT, 'location' => [ - 'uri' => 'symbols', + 'uri' => $this->symbolsUri, 'range' => [ 'start' => [ 'line' => 8, @@ -88,7 +101,7 @@ class SymbolTest extends TestCase 'name' => 'staticTestProperty', 'kind' => SymbolKind::PROPERTY, 'location' => [ - 'uri' => 'symbols', + 'uri' => $this->symbolsUri, 'range' => [ 'start' => [ 'line' => 9, @@ -106,7 +119,7 @@ class SymbolTest extends TestCase 'name' => 'testProperty', 'kind' => SymbolKind::PROPERTY, 'location' => [ - 'uri' => 'symbols', + 'uri' => $this->symbolsUri, 'range' => [ 'start' => [ 'line' => 10, @@ -124,7 +137,7 @@ class SymbolTest extends TestCase 'name' => 'staticTestMethod', 'kind' => SymbolKind::METHOD, 'location' => [ - 'uri' => 'symbols', + 'uri' => $this->symbolsUri, 'range' => [ 'start' => [ 'line' => 12, @@ -142,7 +155,7 @@ class SymbolTest extends TestCase 'name' => 'testMethod', 'kind' => SymbolKind::METHOD, 'location' => [ - 'uri' => 'symbols', + 'uri' => $this->symbolsUri, 'range' => [ 'start' => [ 'line' => 17, @@ -160,7 +173,7 @@ class SymbolTest extends TestCase 'name' => 'TestTrait', 'kind' => SymbolKind::CLASS_, 'location' => [ - 'uri' => 'symbols', + 'uri' => $this->symbolsUri, 'range' => [ 'start' => [ 'line' => 23, @@ -178,7 +191,7 @@ class SymbolTest extends TestCase 'name' => 'TestInterface', 'kind' => SymbolKind::INTERFACE, 'location' => [ - 'uri' => 'symbols', + 'uri' => $this->symbolsUri, 'range' => [ 'start' => [ 'line' => 28, @@ -196,7 +209,7 @@ class SymbolTest extends TestCase 'name' => 'test_function', 'kind' => SymbolKind::FUNCTION, 'location' => [ - 'uri' => 'symbols', + 'uri' => $this->symbolsUri, 'range' => [ 'start' => [ 'line' => 33, @@ -214,7 +227,7 @@ class SymbolTest extends TestCase 'name' => 'whatever', 'kind' => SymbolKind::FUNCTION, 'location' => [ - 'uri' => 'references', + 'uri' => $this->referencesUri, 'range' => [ 'start' => [ 'line' => 15, @@ -240,7 +253,7 @@ class SymbolTest extends TestCase 'name' => 'staticTestMethod', 'kind' => SymbolKind::METHOD, 'location' => [ - 'uri' => 'symbols', + 'uri' => $this->symbolsUri, 'range' => [ 'start' => [ 'line' => 12, @@ -258,7 +271,7 @@ class SymbolTest extends TestCase 'name' => 'testMethod', 'kind' => SymbolKind::METHOD, 'location' => [ - 'uri' => 'symbols', + 'uri' => $this->symbolsUri, 'range' => [ 'start' => [ 'line' => 17,