From 501d26e1d4525d9646ffb2d9d46a8baa05ba9849 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Fri, 30 Sep 2016 11:30:08 +0200 Subject: [PATCH] Global symbol search (#31) * Implemented workspace symbol search * Fixed missing TextEdit using declaration * Fixed generating uri when parsing next file. * Cleaned up code. Fixed tests * Fixed PHPDoc for LanguageServer::initialize() * Moved utility functions to utils.php * Added tests for pathToUri and findFilesRecursive * Added command line argument for socket communication * Fixed local variable detection and containerName generation in SymbolFinder * Fixed formatting in ProtocolStreamReader * Store text content in PHPDocument, removed stmts, regenerate on demand * Fixed local variable detection and containerName generation in SymbolFinder. * Added Tests for Project and Workspace * Added test for didChange event * Modified lexer error handling * Removed file that shouldn't have been committed. * Updated sabre/event dependency to 4.0.0 * Updated readme.md to show tcp option * make input stream non-blocking * Correct code style * Use triple equals * Revert change in SymbolFinder * Optimize processFile() a bit * Use MessageType enum instead of number literal * Add missing space * Fixed ProtocolStreamWriter for nonblocking connection. * Suppress fwrite() notice when not all bytes could be written. * Fix another code style issue * Throw Exceotion instead of Error * Added ProtocolStreamWriter test * Correct workspace/symbol documentation * Improve exception in ProtocolStreamWriter::write() --- README.md | 12 ++ bin/php-language-server.php | 20 +++- composer.json | 5 +- fixtures/recursive/a.txt | 1 + fixtures/recursive/search/b.txt | 1 + fixtures/recursive/search/here/c.txt | 1 + src/Client/Window.php | 60 ++++++++++ src/LanguageClient.php | 9 ++ src/LanguageServer.php | 73 +++++++++++- src/PhpDocument.php | 140 ++++++++++++++++++++++++ src/Project.php | 70 ++++++++++++ src/ProtocolStreamReader.php | 2 +- src/ProtocolStreamWriter.php | 19 +++- src/Server/TextDocument.php | 82 ++------------ src/Server/Workspace.php | 57 ++++++++++ src/SymbolFinder.php | 63 ++++++++--- src/utils.php | 33 ++++++ tests/LanguageServerTest.php | 2 +- tests/ProjectTest.php | 86 +++++++++++++++ tests/ProtocolStreamWriterTest.php | 30 +++++ tests/Server/TextDocumentTest.php | 49 +++++++-- tests/Server/WorkspaceTest.php | 73 ++++++++++++ tests/Utils/FileUriTest.php | 36 ++++++ tests/Utils/RecursiveFileSearchTest.php | 21 ++++ 24 files changed, 836 insertions(+), 109 deletions(-) create mode 100644 fixtures/recursive/a.txt create mode 100644 fixtures/recursive/search/b.txt create mode 100644 fixtures/recursive/search/here/c.txt create mode 100644 src/Client/Window.php create mode 100644 src/PhpDocument.php create mode 100644 src/Project.php create mode 100644 src/Server/Workspace.php create mode 100644 src/utils.php create mode 100644 tests/ProjectTest.php create mode 100644 tests/ProtocolStreamWriterTest.php create mode 100644 tests/Server/WorkspaceTest.php create mode 100644 tests/Utils/FileUriTest.php create mode 100644 tests/Utils/RecursiveFileSearchTest.php diff --git a/README.md b/README.md index 60970ee..f9415a5 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,15 @@ to install dependencies. Run the tests with vendor/bin/phpunit --bootstrap vendor/autoload.php tests + +## Command line arguments + + --tcp host:port + +Causes the server to use a tcp connection for communicating with the language client instead of using STDIN/STDOUT. +The server will try to connect to the specified address. + +Example: + + php bin/php-language-server.php --tcp 127.0.0.1:12345 + diff --git a/bin/php-language-server.php b/bin/php-language-server.php index c8dfaef..6790338 100644 --- a/bin/php-language-server.php +++ b/bin/php-language-server.php @@ -1,5 +1,7 @@ = 3 && $argv[1] === '--tcp') { + $address = $argv[2]; + $socket = stream_socket_client('tcp://' . $address, $errno, $errstr); + if ($socket === false) { + fwrite(STDERR, "Could not connect to language client. Error $errno\n"); + fwrite(STDERR, "$errstr\n"); + exit(1); + } + $inputStream = $outputStream = $socket; +} else { + $inputStream = STDIN; + $outputStream = STDOUT; +} + +stream_set_blocking($inputStream, false); + +$server = new LanguageServer(new ProtocolStreamReader($inputStream), new ProtocolStreamWriter($outputStream)); Loop\run(); diff --git a/composer.json b/composer.json index 853fb8e..ee05937 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "php": ">=7.0", "nikic/php-parser": "^3.0.0beta1", "phpdocumentor/reflection-docblock": "^3.0", - "sabre/event": "^3.0", + "sabre/event": "^4.0", "felixfbecker/advanced-json-rpc": "^1.2" }, "minimum-stability": "dev", @@ -34,7 +34,8 @@ "autoload": { "psr-4": { "LanguageServer\\": "src/" - } + }, + "files" : ["src/utils.php"] }, "autoload-dev": { "psr-4": { diff --git a/fixtures/recursive/a.txt b/fixtures/recursive/a.txt new file mode 100644 index 0000000..8c7e5a6 --- /dev/null +++ b/fixtures/recursive/a.txt @@ -0,0 +1 @@ +A \ No newline at end of file diff --git a/fixtures/recursive/search/b.txt b/fixtures/recursive/search/b.txt new file mode 100644 index 0000000..7371f47 --- /dev/null +++ b/fixtures/recursive/search/b.txt @@ -0,0 +1 @@ +B \ No newline at end of file diff --git a/fixtures/recursive/search/here/c.txt b/fixtures/recursive/search/here/c.txt new file mode 100644 index 0000000..d5274b3 --- /dev/null +++ b/fixtures/recursive/search/here/c.txt @@ -0,0 +1 @@ +Peeakboo! \ No newline at end of file diff --git a/src/Client/Window.php b/src/Client/Window.php new file mode 100644 index 0000000..192ab47 --- /dev/null +++ b/src/Client/Window.php @@ -0,0 +1,60 @@ +protocolWriter = $protocolWriter; + } + + /** + * The show message notification is sent from a server to a client to ask the client to display a particular message in the user interface. + * + * @param int $type + * @param string $message + */ + public function showMessage(int $type, string $message) + { + $this->protocolWriter->write(new Message(new NotificationBody( + 'window/showMessage', + (object)[ + 'type' => $type, + 'message' => $message + ] + ))); + } + + /** + * The log message notification is sent from the server to the client to ask the client to log a particular message. + * + * @param int $type + * @param string $message + */ + public function logMessage(int $type, string $message) + { + $this->protocolWriter->write(new Message(new NotificationBody( + 'window/logMessage', + (object)[ + 'type' => $type, + 'message' => $message + ] + ))); + } +} diff --git a/src/LanguageClient.php b/src/LanguageClient.php index 4ee5073..de09c41 100644 --- a/src/LanguageClient.php +++ b/src/LanguageClient.php @@ -4,6 +4,7 @@ declare(strict_types = 1); namespace LanguageServer; use LanguageServer\Client\TextDocument; +use LanguageServer\Client\Window; class LanguageClient { @@ -14,11 +15,19 @@ class LanguageClient */ public $textDocument; + /** + * Handles window/* methods + * + * @var Client\Window + */ + public $window; + private $protocolWriter; public function __construct(ProtocolWriter $writer) { $this->protocolWriter = $writer; $this->textDocument = new TextDocument($writer); + $this->window = new Window($writer); } } diff --git a/src/LanguageServer.php b/src/LanguageServer.php index 9208e8d..04206fd 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -3,9 +3,16 @@ namespace LanguageServer; use LanguageServer\Server\TextDocument; -use LanguageServer\Protocol\{ServerCapabilities, ClientCapabilities, TextDocumentSyncKind, Message}; -use LanguageServer\Protocol\InitializeResult; +use LanguageServer\Protocol\{ + ServerCapabilities, + ClientCapabilities, + TextDocumentSyncKind, + Message, + MessageType, + InitializeResult +}; use AdvancedJsonRpc\{Dispatcher, ResponseError, Response as ResponseBody, Request as RequestBody}; +use Sabre\Event\Loop; class LanguageServer extends \AdvancedJsonRpc\Dispatcher { @@ -16,9 +23,15 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher */ public $textDocument; + /** + * Handles workspace/* method calls + * + * @var Server\Workspace + */ + public $workspace; + public $telemetry; public $window; - public $workspace; public $completionItem; public $codeLens; @@ -26,6 +39,8 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher private $protocolWriter; private $client; + private $project; + public function __construct(ProtocolReader $reader, ProtocolWriter $writer) { parent::__construct($this, '/'); @@ -56,24 +71,35 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher }); $this->protocolWriter = $writer; $this->client = new LanguageClient($writer); - $this->textDocument = new Server\TextDocument($this->client); + + $this->project = new Project($this->client); + + $this->textDocument = new Server\TextDocument($this->project, $this->client); + $this->workspace = new Server\Workspace($this->project, $this->client); } /** * The initialize request is sent as the first request from the client to the server. * - * @param string $rootPath The rootPath of the workspace. Is null if no folder is open. * @param int $processId The process Id of the parent process that started the server. * @param ClientCapabilities $capabilities The capabilities provided by the client (editor) + * @param string|null $rootPath The rootPath of the workspace. Is null if no folder is open. * @return InitializeResult */ - public function initialize(string $rootPath, int $processId, ClientCapabilities $capabilities): InitializeResult + public function initialize(int $processId, ClientCapabilities $capabilities, string $rootPath = null): InitializeResult { + // start building project index + if ($rootPath) { + $this->indexProject($rootPath); + } + $serverCapabilities = new ServerCapabilities(); // Ask the client to return always full documents (because we need to rebuild the AST from scratch) $serverCapabilities->textDocumentSync = TextDocumentSyncKind::FULL; // Support "Find all symbols" $serverCapabilities->documentSymbolProvider = true; + // Support "Find all symbols in workspace" + $serverCapabilities->workspaceSymbolProvider = true; // Support "Format Code" $serverCapabilities->documentFormattingProvider = true; return new InitializeResult($serverCapabilities); @@ -100,4 +126,39 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher { exit(0); } + + /** + * Parses workspace files, one at a time. + * + * @param string $rootPath The rootPath of the workspace. + * @return void + */ + private function indexProject(string $rootPath) + { + $fileList = findFilesRecursive($rootPath, '/^.+\.php$/i'); + $numTotalFiles = count($fileList); + + $startTime = microtime(true); + $fileNum = 0; + + $processFile = function() use (&$fileList, &$fileNum, &$processFile, $rootPath, $numTotalFiles, $startTime) { + if ($fileNum < $numTotalFiles) { + $file = $fileList[$fileNum]; + $uri = pathToUri($file); + $fileNum++; + $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)); + + Loop\setTimeout($processFile, 0); + } else { + $duration = (int)(microtime(true) - $startTime); + $mem = (int)(memory_get_usage(true) / (1024 * 1024)); + $this->client->window->logMessage(MessageType::INFO, "All PHP files parsed in $duration seconds. $mem MiB allocated."); + } + }; + + Loop\setTimeout($processFile, 0); + } } diff --git a/src/PhpDocument.php b/src/PhpDocument.php new file mode 100644 index 0000000..424afd3 --- /dev/null +++ b/src/PhpDocument.php @@ -0,0 +1,140 @@ +uri = $uri; + $this->project = $project; + $this->client = $client; + $this->parser = $parser; + } + + /** + * Returns all symbols in this document. + * + * @return SymbolInformation[] + */ + public function getSymbols() + { + return $this->symbols; + } + + /** + * Returns symbols in this document filtered by query string. + * + * @param string $query The search query + * @return SymbolInformation[] + */ + public function findSymbols(string $query) + { + return array_filter($this->symbols, function($symbol) use(&$query) { + return stripos($symbol->name, $query) !== false; + }); + } + + /** + * Updates the content on this document. + * + * @param string $content + */ + 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 \PhpParser\Node[] + */ + public function parse() + { + $stmts = null; + $errors = []; + try { + $stmts = $this->parser->parse($this->content); + } catch(\PhpParser\Error $e) { + // Lexer can throw errors. e.g for unterminated comments + // unfortunately we don't get a location back + $errors[] = $e; + } + + $errors = array_merge($this->parser->getErrors(), $errors); + + $diagnostics = []; + 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) + ); + $diagnostic->severity = DiagnosticSeverity::ERROR; + $diagnostic->source = 'php'; + // Do not include "on line ..." in the error message + $diagnostic->message = $error->getRawMessage(); + $diagnostics[] = $diagnostic; + } + $this->client->textDocument->publishDiagnostics($this->uri, $diagnostics); + + // $stmts can be null in case of a fatal parsing error + if ($stmts) { + $traverser = new NodeTraverser; + $finder = new SymbolFinder($this->uri); + $traverser->addVisitor(new NameResolver); + $traverser->addVisitor(new ColumnCalculator($this->content)); + $traverser->addVisitor($finder); + $traverser->traverse($stmts); + + $this->symbols = $finder->symbols; + } + + return $stmts; + } + + /** + * Returns this document as formatted text. + * + * @return string + */ + public function getFormattedText() + { + $stmts = $this->parse(); + if (empty($stmts)) { + return []; + } + $prettyPrinter = new PrettyPrinter(); + $edit = new TextEdit(); + $edit->range = new Range(new Position(0, 0), new Position(PHP_INT_MAX, PHP_INT_MAX)); + $edit->newText = $prettyPrinter->prettyPrintFile($stmts); + return [$edit]; + } + + /** + * Returns this document's text content. + * + * @return string + */ + public function getContent() + { + return $this->content; + } +} diff --git a/src/Project.php b/src/Project.php new file mode 100644 index 0000000..d62a8cf --- /dev/null +++ b/src/Project.php @@ -0,0 +1,70 @@ + PhpDocument] + * that maps URIs to loaded PhpDocuments + * + * @var array + */ + private $documents; + + /** + * Instance of the PHP parser + * + * @var ParserAbstract + */ + private $parser; + + /** + * Reference to the language server client interface + * + * @var LanguageClient + */ + private $client; + + public function __construct(LanguageClient $client) + { + $this->client = $client; + + $lexer = new Lexer(['usedAttributes' => ['comments', 'startLine', 'endLine', 'startFilePos', 'endFilePos']]); + $this->parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7, $lexer, ['throwOnError' => false]); + } + + /** + * Returns the document indicated by uri. Instantiates a new document if none exists. + * + * @param string $uri + * @return LanguageServer\PhpDocument + */ + public function getDocument(string $uri) + { + $uri = urldecode($uri); + if (!isset($this->documents[$uri])) { + $this->documents[$uri] = new PhpDocument($uri, $this, $this->client, $this->parser); + } + return $this->documents[$uri]; + } + + /** + * 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) { + $queryResult = array_merge($queryResult, $document->findSymbols($query)); + } + return $queryResult; + } +} diff --git a/src/ProtocolStreamReader.php b/src/ProtocolStreamReader.php index 434735c..15ccfa4 100644 --- a/src/ProtocolStreamReader.php +++ b/src/ProtocolStreamReader.php @@ -30,7 +30,7 @@ class ProtocolStreamReader implements ProtocolReader { $this->input = $input; Loop\addReadStream($this->input, function() { - while (($c = fgetc($this->input)) !== false) { + while (($c = fgetc($this->input)) !== false && $c !== '') { $this->buffer .= $c; switch ($this->parsingMode) { case ParsingMode::HEADERS: diff --git a/src/ProtocolStreamWriter.php b/src/ProtocolStreamWriter.php index f44daeb..2ac3579 100644 --- a/src/ProtocolStreamWriter.php +++ b/src/ProtocolStreamWriter.php @@ -4,6 +4,7 @@ declare(strict_types = 1); namespace LanguageServer; use LanguageServer\Protocol\Message; +use RuntimeException; class ProtocolStreamWriter implements ProtocolWriter { @@ -25,6 +26,22 @@ class ProtocolStreamWriter implements ProtocolWriter */ public function write(Message $msg) { - fwrite($this->output, (string)$msg); + $data = (string)$msg; + $msgSize = strlen($data); + $totalBytesWritten = 0; + + while ($totalBytesWritten < $msgSize) { + error_clear_last(); + $bytesWritten = @fwrite($this->output, substr($data, $totalBytesWritten)); + if ($bytesWritten === false) { + $error = error_get_last(); + if ($error !== null) { + throw new RuntimeException('Could not write message: ' . error_get_last()['message']); + } else { + throw new RuntimeException('Could not write message'); + } + } + $totalBytesWritten += $bytesWritten; + } } } diff --git a/src/Server/TextDocument.php b/src/Server/TextDocument.php index 23c01c0..38c8ea3 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -2,10 +2,7 @@ namespace LanguageServer\Server; -use PhpParser\{Error, Comment, Node, ParserFactory, NodeTraverser, Lexer}; -use PhpParser\PrettyPrinter\Standard as PrettyPrinter; -use PhpParser\NodeVisitor\NameResolver; -use LanguageServer\{LanguageClient, ColumnCalculator, SymbolFinder}; +use LanguageServer\{LanguageClient, ColumnCalculator, SymbolFinder, Project}; use LanguageServer\Protocol\{ TextDocumentItem, TextDocumentIdentifier, @@ -23,18 +20,6 @@ use LanguageServer\Protocol\{ */ class TextDocument { - /** - * @var \PhpParser\Parser - */ - private $parser; - - /** - * A map from file URIs to ASTs - * - * @var \PhpParser\Stmt[][] - */ - private $asts; - /** * The lanugage client object to call methods on the client * @@ -42,11 +27,12 @@ class TextDocument */ private $client; - public function __construct(LanguageClient $client) + private $project; + + public function __construct(Project $project, LanguageClient $client) { + $this->project = $project; $this->client = $client; - $lexer = new Lexer(['usedAttributes' => ['comments', 'startLine', 'endLine', 'startFilePos', 'endFilePos']]); - $this->parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7, $lexer, ['throwOnError' => false]); } /** @@ -58,15 +44,7 @@ class TextDocument */ public function documentSymbol(TextDocumentIdentifier $textDocument): array { - $stmts = $this->asts[$textDocument->uri]; - if (!$stmts) { - return []; - } - $finder = new SymbolFinder($textDocument->uri); - $traverser = new NodeTraverser; - $traverser->addVisitor($finder); - $traverser->traverse($stmts); - return $finder->symbols; + return $this->project->getDocument($textDocument->uri)->getSymbols(); } /** @@ -79,7 +57,7 @@ class TextDocument */ public function didOpen(TextDocumentItem $textDocument) { - $this->updateAst($textDocument->uri, $textDocument->text); + $this->project->getDocument($textDocument->uri)->updateContent($textDocument->text); } /** @@ -91,42 +69,9 @@ class TextDocument */ public function didChange(VersionedTextDocumentIdentifier $textDocument, array $contentChanges) { - $this->updateAst($textDocument->uri, $contentChanges[0]->text); + $this->project->getDocument($textDocument->uri)->updateContent($contentChanges[0]->text); } - /** - * Re-parses a source file, updates the AST and reports parsing errors that may occured as diagnostics - * - * @param string $uri The URI of the source file - * @param string $content The new content of the source file - * @return void - */ - private function updateAst(string $uri, string $content) - { - $stmts = $this->parser->parse($content); - $diagnostics = []; - foreach ($this->parser->getErrors() as $error) { - $diagnostic = new Diagnostic(); - $diagnostic->range = new Range( - 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'; - // Do not include "on line ..." in the error message - $diagnostic->message = $error->getRawMessage(); - $diagnostics[] = $diagnostic; - } - $this->client->textDocument->publishDiagnostics($uri, $diagnostics); - // $stmts can be null in case of a fatal parsing error - if ($stmts) { - $traverser = new NodeTraverser; - $traverser->addVisitor(new NameResolver); - $traverser->addVisitor(new ColumnCalculator($content)); - $traverser->traverse($stmts); - $this->asts[$uri] = $stmts; - } - } /** * The document formatting request is sent from the server to the client to format a whole document. @@ -137,15 +82,6 @@ class TextDocument */ public function formatting(TextDocumentIdentifier $textDocument, FormattingOptions $options) { - $nodes = $this->asts[$textDocument->uri]; - if (empty($nodes)) { - return []; - } - $prettyPrinter = new PrettyPrinter(); - $edit = new TextEdit(); - $edit->range = new Range(new Position(0, 0), new Position(PHP_INT_MAX, PHP_INT_MAX)); - $edit->newText = $prettyPrinter->prettyPrintFile($nodes); - return [$edit]; + return $this->project->getDocument($textDocument->uri)->getFormattedText(); } - } diff --git a/src/Server/Workspace.php b/src/Server/Workspace.php new file mode 100644 index 0000000..3624844 --- /dev/null +++ b/src/Server/Workspace.php @@ -0,0 +1,57 @@ +project = $project; + $this->client = $client; + } + + /** + * The workspace symbol request is sent from the client to the server to list project-wide symbols matching the query string. + * + * @param string $query + * @return SymbolInformation[] + */ + public function symbol(string $query): array + { + return $this->project->findSymbols($query); + } +} diff --git a/src/SymbolFinder.php b/src/SymbolFinder.php index 087cadd..0e68172 100644 --- a/src/SymbolFinder.php +++ b/src/SymbolFinder.php @@ -4,6 +4,7 @@ declare(strict_types = 1); namespace LanguageServer; use PhpParser\{NodeVisitorAbstract, Node}; + use LanguageServer\Protocol\{SymbolInformation, SymbolKind, Range, Position, Location}; class SymbolFinder extends NodeVisitorAbstract @@ -35,6 +36,21 @@ class SymbolFinder extends NodeVisitorAbstract */ private $containerName; + /** + * @var array + */ + private $nameStack = []; + + /** + * @var array + */ + private $nodeStack = []; + + /** + * @var int + */ + private $functionCount = 0; + public function __construct(string $uri) { $this->uri = $uri; @@ -42,24 +58,38 @@ class SymbolFinder extends NodeVisitorAbstract public function enterNode(Node $node) { + $this->nodeStack[] = $node; + $containerName = end($this->nameStack); + + // If we enter a named node, push its name onto name stack. + // Else push the current name onto stack. + if (!empty($node->name) && (is_string($node->name) || method_exists($node->name, '__toString')) && !empty((string)$node->name)) { + if (empty($containerName)) { + $this->nameStack[] = (string)$node->name; + } else if ($node instanceof Node\Stmt\ClassMethod) { + $this->nameStack[] = $containerName . '::' . (string)$node->name; + } else { + $this->nameStack[] = $containerName . '\\' . (string)$node->name; + } + } else { + $this->nameStack[] = $containerName; + } + $class = get_class($node); if (!isset(self::NODE_SYMBOL_KIND_MAP[$class])) { return; } - $symbol = end($this->symbols); + // if we enter a method or function, increase the function counter + if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { + $this->functionCount++; + } + $kind = self::NODE_SYMBOL_KIND_MAP[$class]; - // exclude variable symbols that are defined in methods and functions. - if ($symbol && $kind === SymbolKind::VARIABLE && - ($symbol->kind === SymbolKind::METHOD || $symbol->kind === SymbolKind::FUNCTION) - ) { - if ( - $node->getAttribute('startLine') - 1 > $symbol->location->range->start->line && - $node->getAttribute('endLine') - 1 < $symbol->location->range->end->line - ) { - return; - } + // exclude non-global variable symbols. + if ($kind === SymbolKind::VARIABLE && $this->functionCount > 0) { + return; } $symbol = new SymbolInformation(); @@ -72,13 +102,18 @@ class SymbolFinder extends NodeVisitorAbstract new Position($node->getAttribute('endLine') - 1, $node->getAttribute('endColumn')) ) ); - $symbol->containerName = $this->containerName; - $this->containerName = $symbol->name; + $symbol->containerName = $containerName; $this->symbols[] = $symbol; } public function leaveNode(Node $node) { - $this->containerName = null; + array_pop($this->nodeStack); + array_pop($this->nameStack); + + // if we leave a method or function, decrease the function counter + if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { + $this->functionCount--; + } } } diff --git a/src/utils.php b/src/utils.php new file mode 100644 index 0000000..8d45cab --- /dev/null +++ b/src/utils.php @@ -0,0 +1,33 @@ + null, 'referencesProvider' => null, 'documentHighlightProvider' => null, - 'workspaceSymbolProvider' => null, + 'workspaceSymbolProvider' => true, 'codeActionProvider' => null, 'codeLensProvider' => null, 'documentFormattingProvider' => true, diff --git a/tests/ProjectTest.php b/tests/ProjectTest.php new file mode 100644 index 0000000..eb37fa7 --- /dev/null +++ b/tests/ProjectTest.php @@ -0,0 +1,86 @@ +project = new Project(new LanguageClient(new MockProtocolStream())); + } + + public function testGetDocumentCreatesNewDocument() + { + $document = $this->project->getDocument('file:///document1.php'); + + $this->assertNotNull($document); + $this->assertInstanceOf(PhpDocument::class, $document); + } + + public function testGetDocumentCreatesDocumentOnce() + { + $document1 = $this->project->getDocument('file:///document1.php'); + $document2 = $this->project->getDocument('file:///document1.php'); + + $this->assertSame($document1, $document2); + } + + public function testFindSymbols() + { + $this->project->getDocument('file:///document1.php')->updateContent("project->getDocument('file:///document2.php')->updateContent("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/ProtocolStreamWriterTest.php b/tests/ProtocolStreamWriterTest.php new file mode 100644 index 0000000..90ef32d --- /dev/null +++ b/tests/ProtocolStreamWriterTest.php @@ -0,0 +1,30 @@ + str_repeat('X', 100000)])); + $msgString = (string)$msg; + + $writer->write($msg); + + fclose($writeHandle); + + $this->assertEquals(strlen($msgString), filesize($tmpfile)); + } +} diff --git a/tests/Server/TextDocumentTest.php b/tests/Server/TextDocumentTest.php index ec9c3a3..d3d0c2d 100644 --- a/tests/Server/TextDocumentTest.php +++ b/tests/Server/TextDocumentTest.php @@ -5,15 +5,17 @@ namespace LanguageServer\Tests\Server; use PHPUnit\Framework\TestCase; use LanguageServer\Tests\MockProtocolStream; -use LanguageServer\{Server, Client, LanguageClient}; -use LanguageServer\Protocol\{TextDocumentItem, TextDocumentIdentifier, SymbolKind, DiagnosticSeverity, FormattingOptions}; +use LanguageServer\{Server, Client, LanguageClient, Project, PhpDocument}; +use LanguageServer\Protocol\{TextDocumentItem, TextDocumentIdentifier, SymbolKind, DiagnosticSeverity, FormattingOptions, VersionedTextDocumentIdentifier, TextDocumentContentChangeEvent, Range, Position}; use AdvancedJsonRpc\{Request as RequestBody, Response as ResponseBody}; class TextDocumentTest extends TestCase { public function testDocumentSymbol() { - $textDocument = new Server\TextDocument(new LanguageClient(new MockProtocolStream())); + $client = new LanguageClient(new MockProtocolStream()); + $project = new Project($client); + $textDocument = new Server\TextDocument($project, $client); // Trigger parsing of source $textDocumentItem = new TextDocumentItem(); $textDocumentItem->uri = 'whatever'; @@ -58,7 +60,7 @@ class TextDocumentTest extends TestCase ] ] ], - 'containerName' => null + 'containerName' => 'TestNamespace' ], [ 'name' => 'testProperty', @@ -76,7 +78,7 @@ class TextDocumentTest extends TestCase ] ] ], - 'containerName' => 'TestClass' + 'containerName' => 'TestNamespace\\TestClass' ], [ 'name' => 'testMethod', @@ -94,7 +96,7 @@ class TextDocumentTest extends TestCase ] ] ], - 'containerName' => null + 'containerName' => 'TestNamespace\\TestClass' ], [ 'name' => 'TestTrait', @@ -112,7 +114,7 @@ class TextDocumentTest extends TestCase ] ] ], - 'containerName' => null + 'containerName' => 'TestNamespace' ], [ 'name' => 'TestInterface', @@ -130,7 +132,7 @@ class TextDocumentTest extends TestCase ] ] ], - 'containerName' => null + 'containerName' => 'TestNamespace' ] ], json_decode(json_encode($result), true)); } @@ -151,7 +153,11 @@ class TextDocumentTest extends TestCase $this->args = func_get_args(); } }; - $textDocument = new Server\TextDocument($client); + + $project = new Project($client); + + $textDocument = new Server\TextDocument($project, $client); + // Trigger parsing of source $textDocumentItem = new TextDocumentItem(); $textDocumentItem->uri = 'whatever'; @@ -182,7 +188,10 @@ class TextDocumentTest extends TestCase public function testFormatting() { - $textDocument = new Server\TextDocument(new LanguageClient(new MockProtocolStream())); + $client = new LanguageClient(new MockProtocolStream()); + $project = new Project($client); + $textDocument = new Server\TextDocument($project, $client); + // Trigger parsing of source $textDocumentItem = new TextDocumentItem(); $textDocumentItem->uri = 'whatever'; @@ -209,4 +218,24 @@ class TextDocumentTest extends TestCase 'newText' => $expected ]], json_decode(json_encode($result), true)); } + + public function testDidChange() + { + $client = new LanguageClient(new MockProtocolStream()); + $project = new Project($client); + $textDocument = new Server\TextDocument($project, $client); + + $phpDocument = $project->getDocument('whatever'); + $phpDocument->updateContent("range = new Range(new Position(0,0), new Position(9999,9999)); + $changeEvent->rangeLength = 9999; + $changeEvent->text = "didChange($identifier, [$changeEvent]); + + $this->assertEquals("getContent()); + } } diff --git a/tests/Server/WorkspaceTest.php b/tests/Server/WorkspaceTest.php new file mode 100644 index 0000000..5a62b85 --- /dev/null +++ b/tests/Server/WorkspaceTest.php @@ -0,0 +1,73 @@ +workspace = new Server\Workspace($project, $client); + + // create two documents + $project->getDocument('file:///document1.php')->updateContent("getDocument('file:///document2.php')->updateContent("workspace->symbol('f'); + $this->assertEquals([ + [ + 'name' => 'foo', + 'kind' => SymbolKind::FUNCTION, + 'location' => [ + 'uri' => 'file:///document1.php', + 'range' => [ + 'start' => [ + 'line' => 1, + 'character' => 0 + ], + 'end' => [ + 'line' => 1, + 'character' => 17 + ] + ] + ], + 'containerName' => null + ], + [ + 'name' => 'frob', + 'kind' => SymbolKind::FUNCTION, + 'location' => [ + 'uri' => 'file:///document2.php', + 'range' => [ + 'start' => [ + 'line' => 2, + 'character' => 0 + ], + 'end' => [ + 'line' => 2, + 'character' => 18 + ] + ] + ], + 'containerName' => null + ] + ], json_decode(json_encode($result), true)); + } +} diff --git a/tests/Utils/FileUriTest.php b/tests/Utils/FileUriTest.php new file mode 100644 index 0000000..802f78b --- /dev/null +++ b/tests/Utils/FileUriTest.php @@ -0,0 +1,36 @@ +assertEquals('file:///c%3A/path/to/file/d%C3%BCr%C3%BCm+d%C3%B6ner.php', $uri); + } + + public function testUriIsWellFormed() + { + $uri = \LanguageServer\pathToUri('var/log'); + $this->assertEquals('file:///var/log', $uri); + + $uri = \LanguageServer\pathToUri('/usr/local/bin'); + $this->assertEquals('file:///usr/local/bin', $uri); + + $uri = \LanguageServer\pathToUri('a/b/c/test.txt'); + $this->assertEquals('file:///a/b/c/test.txt', $uri); + + $uri = \LanguageServer\pathToUri('/d/e/f'); + $this->assertEquals('file:///d/e/f', $uri); + } + + public function testBackslashesAreTransformed() + { + $uri = \LanguageServer\pathToUri('c:\\foo\\bar.baz'); + $this->assertEquals('file:///c%3A/foo/bar.baz', $uri); + } +} diff --git a/tests/Utils/RecursiveFileSearchTest.php b/tests/Utils/RecursiveFileSearchTest.php new file mode 100644 index 0000000..bcefbdc --- /dev/null +++ b/tests/Utils/RecursiveFileSearchTest.php @@ -0,0 +1,21 @@ +assertEquals([ + $path . DIRECTORY_SEPARATOR . 'a.txt', + $path . DIRECTORY_SEPARATOR . 'search' . DIRECTORY_SEPARATOR . 'b.txt', + $path . DIRECTORY_SEPARATOR . 'search' . DIRECTORY_SEPARATOR . 'here' . DIRECTORY_SEPARATOR . 'c.txt', + ], $files); + } +}