From 6d97d526b164821f9ea5ac885278a72d5c5c80d0 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Sun, 18 Sep 2016 00:58:32 +0200 Subject: [PATCH 01/32] Implemented workspace symbol search --- bin/php-language-server.php | 2 + src/Client/Window.php | 60 +++++++++++++++++++ src/LanguageClient.php | 9 +++ src/LanguageServer.php | 62 ++++++++++++++++++- src/PhpDocument.php | 112 +++++++++++++++++++++++++++++++++++ src/Project.php | 52 ++++++++++++++++ src/ProtocolStreamReader.php | 51 ++++++++-------- src/Server/TextDocument.php | 83 ++++---------------------- src/Server/Workspace.php | 73 +++++++++++++++++++++++ src/SymbolFinder.php | 57 ++++++++++++++---- tests/LanguageServerTest.php | 2 +- 11 files changed, 448 insertions(+), 115 deletions(-) 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 diff --git a/bin/php-language-server.php b/bin/php-language-server.php index c8dfaef0..14f53263 100644 --- a/bin/php-language-server.php +++ b/bin/php-language-server.php @@ -1,5 +1,7 @@ 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 + ] + ))); + } +} \ No newline at end of file diff --git a/src/LanguageClient.php b/src/LanguageClient.php index 4ee5073c..de09c413 100644 --- a/src/LanguageClient.php +++ b/src/LanguageClient.php @@ -4,6 +4,7 @@ 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 9208e8df..94712d5e 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -6,6 +6,7 @@ use LanguageServer\Protocol\{ServerCapabilities, ClientCapabilities, TextDocumentSyncKind, Message}; use LanguageServer\Protocol\InitializeResult; use AdvancedJsonRpc\{Dispatcher, ResponseError, Response as ResponseBody, Request as RequestBody}; +use Sabre\Event\Loop; class LanguageServer extends \AdvancedJsonRpc\Dispatcher { @@ -16,9 +17,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 +33,8 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher private $protocolWriter; private $client; + private $project; + public function __construct(ProtocolReader $reader, ProtocolWriter $writer) { parent::__construct($this, '/'); @@ -56,7 +65,11 @@ public function __construct(ProtocolReader $reader, ProtocolWriter $writer) }); $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); } /** @@ -69,11 +82,18 @@ public function __construct(ProtocolReader $reader, ProtocolWriter $writer) */ public function initialize(string $rootPath, int $processId, ClientCapabilities $capabilities): 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 +120,42 @@ public function exit() { exit(0); } + + /** + * Parses workspace files, one at a time. + * + * @param string $rootPath The rootPath of the workspace. Is null if no folder is open. + * @return void + */ + private function indexProject(string $rootPath) + { + $dir = new \RecursiveDirectoryIterator($rootPath); + $ite = new \RecursiveIteratorIterator($dir); + $files = new \RegexIterator($ite, '/^.+\.php$/i', \RegexIterator::GET_MATCH); + $fileList = array(); + foreach($files as $file) { + $fileList = array_merge($fileList, $file); + } + + $processFile = function() use (&$fileList, &$processFile, &$rootPath){ + if ($file = array_pop($fileList)) { + + $uri = 'file://'.(substr($file, -1) == '/' || substr($file, -1) == '\\' ? '' : '/').str_replace('\\', '/', $file); + + $numFiles = count($fileList); + if (($numFiles % 100) == 0) { + $this->client->window->logMessage(3, $numFiles.' PHP files remaining.'); + } + + $this->project->getDocument($uri)->updateAst(file_get_contents($file)); + + Loop\nextTick($processFile); + } + else { + $this->client->window->logMessage(3, 'All PHP files parsed.'); + } + }; + + Loop\nextTick($processFile); + } } diff --git a/src/PhpDocument.php b/src/PhpDocument.php new file mode 100644 index 00000000..051dc964 --- /dev/null +++ b/src/PhpDocument.php @@ -0,0 +1,112 @@ +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; + }); + } + + /** + * Re-parses a source file, updates the AST and reports parsing errors that may occured as diagnostics + * + * @param string $content The new content of the source file + * @return void + */ + public function updateAst(string $content) + { + $stmts = null; + try { + $stmts = $this->parser->parse($content); + } + catch(Error $e) { + // Parser still throws errors. e.g for unterminated comments + } + + $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($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($content)); + $traverser->addVisitor($finder); + $traverser->traverse($stmts); + + $this->stmts = $stmts; + $this->symbols = $finder->symbols; + } + } + + /** + * Returns this document as formatted text. + * + * @return string + */ + public function getFormattedText() + { + if (empty($this->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($this->stmts); + return [$edit]; + } +} diff --git a/src/Project.php b/src/Project.php new file mode 100644 index 00000000..c7792c21 --- /dev/null +++ b/src/Project.php @@ -0,0 +1,52 @@ +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; + } +} \ No newline at end of file diff --git a/src/ProtocolStreamReader.php b/src/ProtocolStreamReader.php index 434735ce..36b11115 100644 --- a/src/ProtocolStreamReader.php +++ b/src/ProtocolStreamReader.php @@ -30,33 +30,32 @@ public function __construct($input) { $this->input = $input; Loop\addReadStream($this->input, function() { - while (($c = fgetc($this->input)) !== false) { - $this->buffer .= $c; - switch ($this->parsingMode) { - case ParsingMode::HEADERS: - if ($this->buffer === "\r\n") { - $this->parsingMode = ParsingMode::BODY; - $this->contentLength = (int)$this->headers['Content-Length']; - $this->buffer = ''; - } else if (substr($this->buffer, -2) === "\r\n") { - $parts = explode(':', $this->buffer); - $this->headers[$parts[0]] = trim($parts[1]); - $this->buffer = ''; + $c = fgetc($this->input); + $this->buffer .= $c; + switch ($this->parsingMode) { + case ParsingMode::HEADERS: + if ($this->buffer === "\r\n") { + $this->parsingMode = ParsingMode::BODY; + $this->contentLength = (int)$this->headers['Content-Length']; + $this->buffer = ''; + } else if (substr($this->buffer, -2) === "\r\n") { + $parts = explode(':', $this->buffer); + $this->headers[$parts[0]] = trim($parts[1]); + $this->buffer = ''; + } + break; + case ParsingMode::BODY: + if (strlen($this->buffer) === $this->contentLength) { + if (isset($this->listener)) { + $msg = new Message(MessageBody::parse($this->buffer), $this->headers); + $listener = $this->listener; + $listener($msg); } - break; - case ParsingMode::BODY: - if (strlen($this->buffer) === $this->contentLength) { - if (isset($this->listener)) { - $msg = new Message(MessageBody::parse($this->buffer), $this->headers); - $listener = $this->listener; - $listener($msg); - } - $this->parsingMode = ParsingMode::HEADERS; - $this->headers = []; - $this->buffer = ''; - } - break; - } + $this->parsingMode = ParsingMode::HEADERS; + $this->headers = []; + $this->buffer = ''; + } + break; } }); } diff --git a/src/Server/TextDocument.php b/src/Server/TextDocument.php index 23c01c02..c340b048 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 @@ */ 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 @@ public function __construct(LanguageClient $client) */ 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 @@ public function documentSymbol(TextDocumentIdentifier $textDocument): array */ public function didOpen(TextDocumentItem $textDocument) { - $this->updateAst($textDocument->uri, $textDocument->text); + $this->project->getDocument($textDocument->uri)->updateAst($textDocument->text); } /** @@ -91,42 +69,9 @@ public function didOpen(TextDocumentItem $textDocument) */ public function didChange(VersionedTextDocumentIdentifier $textDocument, array $contentChanges) { - $this->updateAst($textDocument->uri, $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; - } + $this->project->getDocument($textDocument->uri)->updateAst($contentChanges[0]->text); } + /** * The document formatting request is sent from the server to the client to format a whole document. @@ -137,15 +82,7 @@ private function updateAst(string $uri, string $content) */ 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 00000000..98de35f4 --- /dev/null +++ b/src/Server/Workspace.php @@ -0,0 +1,73 @@ +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. + * document. + * + * @param string $query + * @return SymbolInformation[] + */ + public function symbol(string $query): array + { + return $this->project->findSymbols($query); + } + + /** + * A notification sent from the client to the server to signal the change of configuration settings. + * + * @param The actual changed settings + * @return void + */ + public function didChangeConfiguration($settings) + { + } + + /** + * The document change notification is sent from the client to the server to signal changes to a text document. + * + * @param \LanguageServer\Protocol\FileEvent[] $textDocument + * @return void + */ + public function didChangeWatchedFiles(array $changes) + { + } +} diff --git a/src/SymbolFinder.php b/src/SymbolFinder.php index 087cadd6..e54fa775 100644 --- a/src/SymbolFinder.php +++ b/src/SymbolFinder.php @@ -35,6 +35,21 @@ class SymbolFinder extends NodeVisitorAbstract */ private $containerName; + /** + * @var array + */ + private $nameStack = array(); + + /** + * @var array + */ + private $nodeStack = array(); + + /** + * @var int + */ + private $functionCount = 0; + public function __construct(string $uri) { $this->uri = $uri; @@ -42,24 +57,34 @@ public function __construct(string $uri) public function enterNode(Node $node) { + array_push($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 (isset($node->name) && is_string($node->name) && !empty($node->name)){ + array_push($this->nameStack, $node->name); + } + else { + array_push($this->nameStack, $containerName); + } + $class = get_class($node); if (!isset(self::NODE_SYMBOL_KIND_MAP[$class])) { return; } + // if we enter a method or function, increase the function counter + if ($class === Node\Stmt\Function_::class || $class === Node\Stmt\ClassMethod::class) { + $this->functionCount++; + } + $symbol = end($this->symbols); $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 +97,19 @@ public function enterNode(Node $node) 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 + $class = get_class($node); + if ($class === Node\Stmt\Function_::class || $class === Node\Stmt\ClassMethod::class) { + $this->functionCount--; + } } } diff --git a/tests/LanguageServerTest.php b/tests/LanguageServerTest.php index fbba7b54..7d82c238 100644 --- a/tests/LanguageServerTest.php +++ b/tests/LanguageServerTest.php @@ -37,7 +37,7 @@ public function testInitialize() 'definitionProvider' => null, 'referencesProvider' => null, 'documentHighlightProvider' => null, - 'workspaceSymbolProvider' => null, + 'workspaceSymbolProvider' => true, 'codeActionProvider' => null, 'codeLensProvider' => null, 'documentFormattingProvider' => true, From 1efb09ba86a1dbae9b6765dda302d7353c0c047b Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Sun, 18 Sep 2016 14:27:12 +0200 Subject: [PATCH 02/32] Fixed missing TextEdit using declaration --- src/PhpDocument.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhpDocument.php b/src/PhpDocument.php index 051dc964..a23b4ded 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -2,7 +2,7 @@ namespace LanguageServer; -use \LanguageServer\Protocol\{Diagnostic, DiagnosticSeverity, Range, Position, SymbolKind}; +use \LanguageServer\Protocol\{Diagnostic, DiagnosticSeverity, Range, Position, SymbolKind, TextEdit}; use PhpParser\{Error, Comment, Node, ParserFactory, NodeTraverser, Lexer, Parser}; use PhpParser\PrettyPrinter\Standard as PrettyPrinter; From d597eeebf65ed771c403ce2cc2d3a3689d9b3c3b Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Sun, 18 Sep 2016 17:09:08 +0200 Subject: [PATCH 03/32] Fixed generating uri when parsing next file. --- src/LanguageServer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LanguageServer.php b/src/LanguageServer.php index 94712d5e..1a1c53d7 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -140,7 +140,7 @@ private function indexProject(string $rootPath) $processFile = function() use (&$fileList, &$processFile, &$rootPath){ if ($file = array_pop($fileList)) { - $uri = 'file://'.(substr($file, -1) == '/' || substr($file, -1) == '\\' ? '' : '/').str_replace('\\', '/', $file); + $uri = 'file://'.($file[0] == '/' || $file[0] == '\\' ? '' : '/').str_replace('\\', '/', $file); $numFiles = count($fileList); if (($numFiles % 100) == 0) { From a34426fb4e92d02d0776f27bc66bfe81090ae5db Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Sun, 18 Sep 2016 21:51:23 +0200 Subject: [PATCH 04/32] Cleaned up code. Fixed tests --- src/LanguageServer.php | 25 +++++++------- src/Project.php | 18 +++++++++++ src/ProtocolStreamReader.php | 54 +++++++++++++++++-------------- src/Server/Workspace.php | 25 +++----------- src/SymbolFinder.php | 8 +++-- tests/Server/TextDocumentTest.php | 19 ++++++++--- 6 files changed, 85 insertions(+), 64 deletions(-) diff --git a/src/LanguageServer.php b/src/LanguageServer.php index 1a1c53d7..c88cda6d 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -75,12 +75,12 @@ public function __construct(ProtocolReader $reader, ProtocolWriter $writer) /** * 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 $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) { @@ -124,7 +124,7 @@ public function exit() /** * Parses workspace files, one at a time. * - * @param string $rootPath The rootPath of the workspace. Is null if no folder is open. + * @param string $rootPath The rootPath of the workspace. * @return void */ private function indexProject(string $rootPath) @@ -136,26 +136,29 @@ private function indexProject(string $rootPath) foreach($files as $file) { $fileList = array_merge($fileList, $file); } + $numTotalFiles = count($fileList); + + $startTime = microtime(true); - $processFile = function() use (&$fileList, &$processFile, &$rootPath){ + $processFile = function() use (&$fileList, &$processFile, $rootPath, $numTotalFiles, $startTime) { if ($file = array_pop($fileList)) { $uri = 'file://'.($file[0] == '/' || $file[0] == '\\' ? '' : '/').str_replace('\\', '/', $file); - $numFiles = count($fileList); - if (($numFiles % 100) == 0) { - $this->client->window->logMessage(3, $numFiles.' PHP files remaining.'); - } + $fileNum = $numTotalFiles - count($fileList); + $shortName = substr($file, strlen($rootPath)+1); + $this->client->window->logMessage(3, "Parsing file $fileNum/$numTotalFiles: $shortName."); $this->project->getDocument($uri)->updateAst(file_get_contents($file)); - Loop\nextTick($processFile); + Loop\setTimeout($processFile, 0); } else { - $this->client->window->logMessage(3, 'All PHP files parsed.'); + $duration = (int)(microtime(true) - $startTime); + $this->client->window->logMessage(3, "All PHP files parsed in $duration seconds."); } }; - Loop\nextTick($processFile); + Loop\setTimeout($processFile, 0); } } diff --git a/src/Project.php b/src/Project.php index c7792c21..27e84ea1 100644 --- a/src/Project.php +++ b/src/Project.php @@ -8,8 +8,26 @@ class Project { + /** + * An associative array [string => 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) diff --git a/src/ProtocolStreamReader.php b/src/ProtocolStreamReader.php index 36b11115..32d95ea9 100644 --- a/src/ProtocolStreamReader.php +++ b/src/ProtocolStreamReader.php @@ -30,32 +30,36 @@ public function __construct($input) { $this->input = $input; Loop\addReadStream($this->input, function() { - $c = fgetc($this->input); - $this->buffer .= $c; - switch ($this->parsingMode) { - case ParsingMode::HEADERS: - if ($this->buffer === "\r\n") { - $this->parsingMode = ParsingMode::BODY; - $this->contentLength = (int)$this->headers['Content-Length']; - $this->buffer = ''; - } else if (substr($this->buffer, -2) === "\r\n") { - $parts = explode(':', $this->buffer); - $this->headers[$parts[0]] = trim($parts[1]); - $this->buffer = ''; - } - break; - case ParsingMode::BODY: - if (strlen($this->buffer) === $this->contentLength) { - if (isset($this->listener)) { - $msg = new Message(MessageBody::parse($this->buffer), $this->headers); - $listener = $this->listener; - $listener($msg); + while(($c = fgetc($this->input)) !== false) { + $this->buffer .= $c; + switch ($this->parsingMode) { + case ParsingMode::HEADERS: + if ($this->buffer === "\r\n") { + $this->parsingMode = ParsingMode::BODY; + $this->contentLength = (int)$this->headers['Content-Length']; + $this->buffer = ''; + } else if (substr($this->buffer, -2) === "\r\n") { + $parts = explode(':', $this->buffer); + $this->headers[$parts[0]] = trim($parts[1]); + $this->buffer = ''; } - $this->parsingMode = ParsingMode::HEADERS; - $this->headers = []; - $this->buffer = ''; - } - break; + break; + case ParsingMode::BODY: + if (strlen($this->buffer) === $this->contentLength) { + if (isset($this->listener)) { + $msg = new Message(MessageBody::parse($this->buffer), $this->headers); + $listener = $this->listener; + $listener($msg); + } + $this->parsingMode = ParsingMode::HEADERS; + $this->headers = []; + $this->buffer = ''; + + // after reading a full message, leave to allow different tasks to run + return; + } + break; + } } }); } diff --git a/src/Server/Workspace.php b/src/Server/Workspace.php index 98de35f4..ef3e50b9 100644 --- a/src/Server/Workspace.php +++ b/src/Server/Workspace.php @@ -31,6 +31,11 @@ class Workspace */ private $client; + /** + * The current project database + * + * @var Project + */ private $project; public function __construct(Project $project, LanguageClient $client) @@ -50,24 +55,4 @@ public function symbol(string $query): array { return $this->project->findSymbols($query); } - - /** - * A notification sent from the client to the server to signal the change of configuration settings. - * - * @param The actual changed settings - * @return void - */ - public function didChangeConfiguration($settings) - { - } - - /** - * The document change notification is sent from the client to the server to signal changes to a text document. - * - * @param \LanguageServer\Protocol\FileEvent[] $textDocument - * @return void - */ - public function didChangeWatchedFiles(array $changes) - { - } } diff --git a/src/SymbolFinder.php b/src/SymbolFinder.php index e54fa775..e79a1d16 100644 --- a/src/SymbolFinder.php +++ b/src/SymbolFinder.php @@ -4,6 +4,9 @@ namespace LanguageServer; use PhpParser\{NodeVisitorAbstract, Node}; +use PhpParser\Builder\Function_; +use PhpParser\Node\Stmt\ClassMethod; + use LanguageServer\Protocol\{SymbolInformation, SymbolKind, Range, Position, Location}; class SymbolFinder extends NodeVisitorAbstract @@ -75,7 +78,7 @@ public function enterNode(Node $node) } // if we enter a method or function, increase the function counter - if ($class === Node\Stmt\Function_::class || $class === Node\Stmt\ClassMethod::class) { + if ($node instanceof Function_ || $node instanceof ClassMethod) { $this->functionCount++; } @@ -107,8 +110,7 @@ public function leaveNode(Node $node) array_pop($this->nameStack); // if we leave a method or function, decrease the function counter - $class = get_class($node); - if ($class === Node\Stmt\Function_::class || $class === Node\Stmt\ClassMethod::class) { + if ($node instanceof Function_ || $node instanceof ClassMethod) { $this->functionCount--; } } diff --git a/tests/Server/TextDocumentTest.php b/tests/Server/TextDocumentTest.php index ec9c3a35..f227fe84 100644 --- a/tests/Server/TextDocumentTest.php +++ b/tests/Server/TextDocumentTest.php @@ -5,7 +5,7 @@ use PHPUnit\Framework\TestCase; use LanguageServer\Tests\MockProtocolStream; -use LanguageServer\{Server, Client, LanguageClient}; +use LanguageServer\{Server, Client, LanguageClient, Project, PhpDocument}; use LanguageServer\Protocol\{TextDocumentItem, TextDocumentIdentifier, SymbolKind, DiagnosticSeverity, FormattingOptions}; use AdvancedJsonRpc\{Request as RequestBody, Response as ResponseBody}; @@ -13,7 +13,9 @@ 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'; @@ -94,7 +96,7 @@ public function testDocumentSymbol() ] ] ], - 'containerName' => null + 'containerName' => 'TestClass' ], [ 'name' => 'TestTrait', @@ -151,7 +153,11 @@ public function publishDiagnostics(string $uri, array $diagnostics) $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 @@ public function publishDiagnostics(string $uri, array $diagnostics) 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'; From 1865ce853894cf70b3bf2084501a64e58ef84425 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Mon, 19 Sep 2016 20:31:50 +0200 Subject: [PATCH 05/32] Fixed PHPDoc for LanguageServer::initialize() --- src/LanguageServer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LanguageServer.php b/src/LanguageServer.php index c88cda6d..79abc832 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -77,7 +77,7 @@ public function __construct(ProtocolReader $reader, ProtocolWriter $writer) * * @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 $rootPath The rootPath of the workspace. Is null if no folder is open. + * @param string|null $rootPath The rootPath of the workspace. Is null if no folder is open. * @return InitializeResult */ public function initialize(int $processId, ClientCapabilities $capabilities, string $rootPath = null): InitializeResult From 6b28cb6a93e4470bab9db4c6f0edea6b45d153e9 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Mon, 19 Sep 2016 21:06:31 +0200 Subject: [PATCH 06/32] Moved utility functions to utils.php --- composer.json | 3 ++- src/LanguageServer.php | 14 ++++---------- src/utils.php | 31 +++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 src/utils.php diff --git a/composer.json b/composer.json index 853fb8e6..2efccb44 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ "autoload": { "psr-4": { "LanguageServer\\": "src/" - } + }, + "files" : ["src/utils.php"] }, "autoload-dev": { "psr-4": { diff --git a/src/LanguageServer.php b/src/LanguageServer.php index 79abc832..ac36313d 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -129,13 +129,7 @@ public function exit() */ private function indexProject(string $rootPath) { - $dir = new \RecursiveDirectoryIterator($rootPath); - $ite = new \RecursiveIteratorIterator($dir); - $files = new \RegexIterator($ite, '/^.+\.php$/i', \RegexIterator::GET_MATCH); - $fileList = array(); - foreach($files as $file) { - $fileList = array_merge($fileList, $file); - } + $fileList = findFilesRecursive($rootPath, '/^.+\.php$/i'); $numTotalFiles = count($fileList); $startTime = microtime(true); @@ -143,8 +137,7 @@ private function indexProject(string $rootPath) $processFile = function() use (&$fileList, &$processFile, $rootPath, $numTotalFiles, $startTime) { if ($file = array_pop($fileList)) { - $uri = 'file://'.($file[0] == '/' || $file[0] == '\\' ? '' : '/').str_replace('\\', '/', $file); - + $uri = pathToUri($file); $fileNum = $numTotalFiles - count($fileList); $shortName = substr($file, strlen($rootPath)+1); $this->client->window->logMessage(3, "Parsing file $fileNum/$numTotalFiles: $shortName."); @@ -155,7 +148,8 @@ private function indexProject(string $rootPath) } else { $duration = (int)(microtime(true) - $startTime); - $this->client->window->logMessage(3, "All PHP files parsed in $duration seconds."); + $mem = memory_get_usage(true); + $this->client->window->logMessage(3, "All PHP files parsed in $duration seconds. $mem bytes allocated."); } }; diff --git a/src/utils.php b/src/utils.php new file mode 100644 index 00000000..27434393 --- /dev/null +++ b/src/utils.php @@ -0,0 +1,31 @@ + Date: Mon, 19 Sep 2016 23:57:29 +0200 Subject: [PATCH 07/32] Added tests for pathToUri and findFilesRecursive --- fixtures/recursive/a.txt | 1 + fixtures/recursive/search/b.txt | 1 + fixtures/recursive/search/here/c.txt | 1 + src/utils.php | 62 +++++++++++++------------ tests/Utils/FileUriTest.php | 36 ++++++++++++++ tests/Utils/RecursiveFileSearchTest.php | 21 +++++++++ 6 files changed, 92 insertions(+), 30 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 tests/Utils/FileUriTest.php create mode 100644 tests/Utils/RecursiveFileSearchTest.php diff --git a/fixtures/recursive/a.txt b/fixtures/recursive/a.txt new file mode 100644 index 00000000..8c7e5a66 --- /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 00000000..7371f47a --- /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 00000000..d5274b3d --- /dev/null +++ b/fixtures/recursive/search/here/c.txt @@ -0,0 +1 @@ +Peeakboo! \ No newline at end of file diff --git a/src/utils.php b/src/utils.php index 27434393..2d62dc04 100644 --- a/src/utils.php +++ b/src/utils.php @@ -1,31 +1,33 @@ -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/'); + $this->assertEquals('file:///a/b/c', $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 00000000..bcefbdc1 --- /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); + } +} From 1a006deee5c92ad1d103e4fbdbcc66d99429e5a2 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Thu, 22 Sep 2016 23:15:53 +0200 Subject: [PATCH 08/32] Added command line argument for socket communication --- bin/php-language-server.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/bin/php-language-server.php b/bin/php-language-server.php index 14f53263..ab7e217b 100644 --- a/bin/php-language-server.php +++ b/bin/php-language-server.php @@ -12,6 +12,21 @@ } } -$server = new LanguageServer(new ProtocolStreamReader(STDIN), new ProtocolStreamWriter(STDOUT)); +if (count($argv) >= 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; +} + +$server = new LanguageServer(new ProtocolStreamReader($inputStream), new ProtocolStreamWriter($outputStream)); Loop\run(); From e6b48c751ebf5a34ea3754254a45bf3c2aa9095c Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Thu, 22 Sep 2016 23:17:20 +0200 Subject: [PATCH 09/32] Fixed local variable detection and containerName generation in SymbolFinder --- src/SymbolFinder.php | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/SymbolFinder.php b/src/SymbolFinder.php index e79a1d16..58fc6959 100644 --- a/src/SymbolFinder.php +++ b/src/SymbolFinder.php @@ -4,8 +4,6 @@ namespace LanguageServer; use PhpParser\{NodeVisitorAbstract, Node}; -use PhpParser\Builder\Function_; -use PhpParser\Node\Stmt\ClassMethod; use LanguageServer\Protocol\{SymbolInformation, SymbolKind, Range, Position, Location}; @@ -65,8 +63,18 @@ public function enterNode(Node $node) // If we enter a named node, push its name onto name stack. // Else push the current name onto stack. - if (isset($node->name) && is_string($node->name) && !empty($node->name)){ - array_push($this->nameStack, $node->name); + if (!empty($node->name) && (is_string($node->name) || method_exists($node->name, '__toString')) && !empty((string)$node->name)) { + if (empty($containerName)) { + array_push($this->nameStack, (string)$node->name); + } + else { + if ($node instanceof Stmt\ClassMethod) { + array_push($this->nameStack, $containerName . '::' . (string)$node->name); + } + else { + array_push($this->nameStack, $containerName . '\\' . (string)$node->name); + } + } } else { array_push($this->nameStack, $containerName); @@ -78,11 +86,10 @@ public function enterNode(Node $node) } // if we enter a method or function, increase the function counter - if ($node instanceof Function_ || $node instanceof ClassMethod) { + if ($node instanceof Stmt\Function_ || $node instanceof Stmt\ClassMethod) { $this->functionCount++; } - $symbol = end($this->symbols); $kind = self::NODE_SYMBOL_KIND_MAP[$class]; // exclude non-global variable symbols. From 4d6774f09db0ef56de881bf4b665156448f13369 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Thu, 22 Sep 2016 23:18:03 +0200 Subject: [PATCH 10/32] Fixed formatting in ProtocolStreamReader --- src/ProtocolStreamReader.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ProtocolStreamReader.php b/src/ProtocolStreamReader.php index 32d95ea9..2cac6623 100644 --- a/src/ProtocolStreamReader.php +++ b/src/ProtocolStreamReader.php @@ -30,7 +30,7 @@ public function __construct($input) { $this->input = $input; Loop\addReadStream($this->input, function() { - while(($c = fgetc($this->input)) !== false) { + while(($c = fgetc($this->input)) !== false) { $this->buffer .= $c; switch ($this->parsingMode) { case ParsingMode::HEADERS: From 28d6cc6e8567b081e4a9f3de23b4b7c747bc502f Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Thu, 22 Sep 2016 23:34:14 +0200 Subject: [PATCH 11/32] Store text content in PHPDocument, removed stmts, regenerate on demand --- src/LanguageServer.php | 6 +++--- src/PhpDocument.php | 40 +++++++++++++++++++++++++------------ src/Server/TextDocument.php | 4 ++-- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/LanguageServer.php b/src/LanguageServer.php index ac36313d..c9aea3fc 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -142,14 +142,14 @@ private function indexProject(string $rootPath) $shortName = substr($file, strlen($rootPath)+1); $this->client->window->logMessage(3, "Parsing file $fileNum/$numTotalFiles: $shortName."); - $this->project->getDocument($uri)->updateAst(file_get_contents($file)); + $this->project->getDocument($uri)->updateContent(file_get_contents($file)); Loop\setTimeout($processFile, 0); } else { $duration = (int)(microtime(true) - $startTime); - $mem = memory_get_usage(true); - $this->client->window->logMessage(3, "All PHP files parsed in $duration seconds. $mem bytes allocated."); + $mem = (int)(memory_get_usage(true)/(1024*1024)); + $this->client->window->logMessage(3, "All PHP files parsed in $duration seconds. $mem MiB allocated."); } }; diff --git a/src/PhpDocument.php b/src/PhpDocument.php index a23b4ded..a052a251 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -10,12 +10,13 @@ class PhpDocument { - private $stmts; private $client; private $project; - private $symbols = []; private $parser; + private $uri; + private $content; + private $symbols = []; public function __construct(string $uri, Project $project, LanguageClient $client, Parser $parser) { @@ -49,16 +50,27 @@ public function findSymbols(string $query) } /** - * Re-parses a source file, updates the AST and reports parsing errors that may occured as diagnostics + * 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. * - * @param string $content The new content of the source file - * @return void + * @return \PhpParser\Node[] */ - public function updateAst(string $content) + public function parse() { $stmts = null; try { - $stmts = $this->parser->parse($content); + $stmts = $this->parser->parse($this->content); } catch(Error $e) { // Parser still throws errors. e.g for unterminated comments @@ -68,8 +80,8 @@ public function updateAst(string $content) 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) + 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'; @@ -84,13 +96,14 @@ public function updateAst(string $content) $traverser = new NodeTraverser; $finder = new SymbolFinder($this->uri); $traverser->addVisitor(new NameResolver); - $traverser->addVisitor(new ColumnCalculator($content)); + $traverser->addVisitor(new ColumnCalculator($this->content)); $traverser->addVisitor($finder); $traverser->traverse($stmts); - $this->stmts = $stmts; $this->symbols = $finder->symbols; } + + return $stmts; } /** @@ -100,13 +113,14 @@ public function updateAst(string $content) */ public function getFormattedText() { - if (empty($this->stmts)) { + $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($this->stmts); + $edit->newText = $prettyPrinter->prettyPrintFile($stmts); return [$edit]; } } diff --git a/src/Server/TextDocument.php b/src/Server/TextDocument.php index c340b048..92a9cef9 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -57,7 +57,7 @@ public function documentSymbol(TextDocumentIdentifier $textDocument): array */ public function didOpen(TextDocumentItem $textDocument) { - $this->project->getDocument($textDocument->uri)->updateAst($textDocument->text); + $this->project->getDocument($textDocument->uri)->updateContent($textDocument->text); } /** @@ -69,7 +69,7 @@ public function didOpen(TextDocumentItem $textDocument) */ public function didChange(VersionedTextDocumentIdentifier $textDocument, array $contentChanges) { - $this->project->getDocument($textDocument->uri)->updateAst($contentChanges[0]->text); + $this->project->getDocument($textDocument->uri)->updateContent($contentChanges[0]->text); } From 05fb3cfb862be4207287a95ba33f805a46a78488 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Thu, 22 Sep 2016 23:35:06 +0200 Subject: [PATCH 12/32] Fixed local variable detection and containerName generation in SymbolFinder. --- src/SymbolFinder.php | 6 +++--- tests/Server/TextDocumentTest.php | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/SymbolFinder.php b/src/SymbolFinder.php index 58fc6959..e185d3e6 100644 --- a/src/SymbolFinder.php +++ b/src/SymbolFinder.php @@ -68,7 +68,7 @@ public function enterNode(Node $node) array_push($this->nameStack, (string)$node->name); } else { - if ($node instanceof Stmt\ClassMethod) { + if ($node instanceof Node\Stmt\ClassMethod) { array_push($this->nameStack, $containerName . '::' . (string)$node->name); } else { @@ -86,7 +86,7 @@ public function enterNode(Node $node) } // if we enter a method or function, increase the function counter - if ($node instanceof Stmt\Function_ || $node instanceof Stmt\ClassMethod) { + if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { $this->functionCount++; } @@ -117,7 +117,7 @@ public function leaveNode(Node $node) array_pop($this->nameStack); // if we leave a method or function, decrease the function counter - if ($node instanceof Function_ || $node instanceof ClassMethod) { + if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { $this->functionCount--; } } diff --git a/tests/Server/TextDocumentTest.php b/tests/Server/TextDocumentTest.php index f227fe84..0be1e27d 100644 --- a/tests/Server/TextDocumentTest.php +++ b/tests/Server/TextDocumentTest.php @@ -60,7 +60,7 @@ public function testDocumentSymbol() ] ] ], - 'containerName' => null + 'containerName' => 'TestNamespace' ], [ 'name' => 'testProperty', @@ -78,7 +78,7 @@ public function testDocumentSymbol() ] ] ], - 'containerName' => 'TestClass' + 'containerName' => 'TestNamespace\\TestClass' ], [ 'name' => 'testMethod', @@ -96,7 +96,7 @@ public function testDocumentSymbol() ] ] ], - 'containerName' => 'TestClass' + 'containerName' => 'TestNamespace\\TestClass' ], [ 'name' => 'TestTrait', @@ -114,7 +114,7 @@ public function testDocumentSymbol() ] ] ], - 'containerName' => null + 'containerName' => 'TestNamespace' ], [ 'name' => 'TestInterface', @@ -132,7 +132,7 @@ public function testDocumentSymbol() ] ] ], - 'containerName' => null + 'containerName' => 'TestNamespace' ] ], json_decode(json_encode($result), true)); } From 0b15abcac5c17541da298692a070415e3e352059 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Wed, 28 Sep 2016 21:14:50 +0200 Subject: [PATCH 13/32] Added Tests for Project and Workspace --- tests/ProjectTest.php | 86 ++++++++++++++++++++++++++++++++++ tests/Server/WorkspaceTest.php | 73 +++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 tests/ProjectTest.php create mode 100644 tests/Server/WorkspaceTest.php diff --git a/tests/ProjectTest.php b/tests/ProjectTest.php new file mode 100644 index 00000000..eb37fa7c --- /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/Server/WorkspaceTest.php b/tests/Server/WorkspaceTest.php new file mode 100644 index 00000000..5a62b859 --- /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)); + } +} From 6688e9e29ff4412c05b141431d851a0455d13047 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Wed, 28 Sep 2016 21:29:42 +0200 Subject: [PATCH 14/32] Added test for didChange event --- src/PhpDocument.php | 10 ++++++++++ tests/Server/TextDocumentTest.php | 22 +++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/PhpDocument.php b/src/PhpDocument.php index a052a251..b2bae320 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -123,4 +123,14 @@ public function getFormattedText() $edit->newText = $prettyPrinter->prettyPrintFile($stmts); return [$edit]; } + + /** + * Returns this document's text content. + * + * @return string + */ + public function getContent() + { + return $this->content; + } } diff --git a/tests/Server/TextDocumentTest.php b/tests/Server/TextDocumentTest.php index 0be1e27d..d3d0c2d6 100644 --- a/tests/Server/TextDocumentTest.php +++ b/tests/Server/TextDocumentTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use LanguageServer\Tests\MockProtocolStream; use LanguageServer\{Server, Client, LanguageClient, Project, PhpDocument}; -use LanguageServer\Protocol\{TextDocumentItem, TextDocumentIdentifier, SymbolKind, DiagnosticSeverity, FormattingOptions}; +use LanguageServer\Protocol\{TextDocumentItem, TextDocumentIdentifier, SymbolKind, DiagnosticSeverity, FormattingOptions, VersionedTextDocumentIdentifier, TextDocumentContentChangeEvent, Range, Position}; use AdvancedJsonRpc\{Request as RequestBody, Response as ResponseBody}; class TextDocumentTest extends TestCase @@ -218,4 +218,24 @@ public function testFormatting() '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()); + } } From 1e668598ace6564a332e46dc6013491cfec69fe6 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Wed, 28 Sep 2016 21:58:29 +0200 Subject: [PATCH 15/32] Modified lexer error handling --- src/PhpDocument.php | 11 ++++-- src/StopWatch.php | 96 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 src/StopWatch.php diff --git a/src/PhpDocument.php b/src/PhpDocument.php index b2bae320..6049f0da 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -69,15 +69,20 @@ public function updateContent(string $content) public function parse() { $stmts = null; + $errors = []; try { $stmts = $this->parser->parse($this->content); } - catch(Error $e) { - // Parser still throws errors. e.g for unterminated comments + 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 ($this->parser->getErrors() as $error) { + foreach ($errors as $error) { $diagnostic = new Diagnostic(); $diagnostic->range = new Range( new Position($error->getStartLine() - 1, $error->hasColumnInfo() ? $error->getStartColumn($this->content) - 1 : 0), diff --git a/src/StopWatch.php b/src/StopWatch.php new file mode 100644 index 00000000..09e10997 --- /dev/null +++ b/src/StopWatch.php @@ -0,0 +1,96 @@ + self::$max[$name]) { + self::$max[$name] = $time; + self::$maxlabels[$name] = $label; + } + unset(self::$starts[$name]); + } + + public function getTimings(): string + { + $total = 0; + foreach(self::$timings as $timing) { + $total += $timing; + } + + $texts = ['', '--- StopWatch timings ----------------']; + foreach(self::$timings as $name => $time) { + $texts[] = $name . '*' . self::$counters[$name] + . ': tot. ' . self::formatTime($time) + . ', ' . self::formatPercent($time / $total) + . ', avg. '. self::formatTime($time / self::$counters[$name]) + . ', min. '. self::formatTime(self::$min[$name]) + . ', max. '. self::formatTime(self::$max[$name]) + . (empty(self::$maxlabels[$name]) ? '' : '(' . self::$maxlabels[$name] . ')'); + } + $texts[] = '--------------------------------------'; + return implode("\n", $texts); + } + + private function formatTime(float $seconds): string + { + if ($seconds >= 60) { + return (int)($seconds / 60).'min'; + } + else if ($seconds >= 1) { + return (int)($seconds).'s'; + } + else if ($seconds >= 0.001) { + return (int)($seconds * 1000).'ms'; + } + else if ($seconds >= 0.000001) { + return (int)($seconds * 1000000).'us'; + } + else { + return '0'; + } + } + + private function formatPercent(float $fraction): string + { + return number_format($fraction * 100, 1) . '%'; + } +} \ No newline at end of file From 21b55eff3c1eda08a470f97b5bc26b74eaf47846 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Wed, 28 Sep 2016 22:02:53 +0200 Subject: [PATCH 16/32] Removed file that shouldn't have been committed. --- src/StopWatch.php | 96 ----------------------------------------------- 1 file changed, 96 deletions(-) delete mode 100644 src/StopWatch.php diff --git a/src/StopWatch.php b/src/StopWatch.php deleted file mode 100644 index 09e10997..00000000 --- a/src/StopWatch.php +++ /dev/null @@ -1,96 +0,0 @@ - self::$max[$name]) { - self::$max[$name] = $time; - self::$maxlabels[$name] = $label; - } - unset(self::$starts[$name]); - } - - public function getTimings(): string - { - $total = 0; - foreach(self::$timings as $timing) { - $total += $timing; - } - - $texts = ['', '--- StopWatch timings ----------------']; - foreach(self::$timings as $name => $time) { - $texts[] = $name . '*' . self::$counters[$name] - . ': tot. ' . self::formatTime($time) - . ', ' . self::formatPercent($time / $total) - . ', avg. '. self::formatTime($time / self::$counters[$name]) - . ', min. '. self::formatTime(self::$min[$name]) - . ', max. '. self::formatTime(self::$max[$name]) - . (empty(self::$maxlabels[$name]) ? '' : '(' . self::$maxlabels[$name] . ')'); - } - $texts[] = '--------------------------------------'; - return implode("\n", $texts); - } - - private function formatTime(float $seconds): string - { - if ($seconds >= 60) { - return (int)($seconds / 60).'min'; - } - else if ($seconds >= 1) { - return (int)($seconds).'s'; - } - else if ($seconds >= 0.001) { - return (int)($seconds * 1000).'ms'; - } - else if ($seconds >= 0.000001) { - return (int)($seconds * 1000000).'us'; - } - else { - return '0'; - } - } - - private function formatPercent(float $fraction): string - { - return number_format($fraction * 100, 1) . '%'; - } -} \ No newline at end of file From 6c098bf3d753ce28b45355fd481d755f037facba Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Wed, 28 Sep 2016 23:22:56 +0200 Subject: [PATCH 17/32] Updated sabre/event dependency to 4.0.0 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2efccb44..ee05937a 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", From 9036ae84fd753b6b58e26cf67b4ed1863285d725 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Wed, 28 Sep 2016 23:30:11 +0200 Subject: [PATCH 18/32] Updated readme.md to show tcp option --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 60970ee8..f9415a51 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 + From 5f8e37be9e923e40d1219072116bb83fc30bafa5 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Thu, 29 Sep 2016 00:08:07 +0200 Subject: [PATCH 19/32] make input stream non-blocking --- bin/php-language-server.php | 2 ++ src/ProtocolStreamReader.php | 5 +---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bin/php-language-server.php b/bin/php-language-server.php index ab7e217b..51b3dfb8 100644 --- a/bin/php-language-server.php +++ b/bin/php-language-server.php @@ -27,6 +27,8 @@ $outputStream = STDOUT; } +stream_set_blocking($inputStream, false); + $server = new LanguageServer(new ProtocolStreamReader($inputStream), new ProtocolStreamWriter($outputStream)); Loop\run(); diff --git a/src/ProtocolStreamReader.php b/src/ProtocolStreamReader.php index 2cac6623..32fd992a 100644 --- a/src/ProtocolStreamReader.php +++ b/src/ProtocolStreamReader.php @@ -30,7 +30,7 @@ public function __construct($input) { $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: @@ -54,9 +54,6 @@ public function __construct($input) $this->parsingMode = ParsingMode::HEADERS; $this->headers = []; $this->buffer = ''; - - // after reading a full message, leave to allow different tasks to run - return; } break; } From b99fb94840e8d160529b2c769dfe8319d70a6b2b Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Thu, 29 Sep 2016 18:14:50 +0200 Subject: [PATCH 20/32] Correct code style --- src/Client/Window.php | 120 +++++++-------- src/LanguageServer.php | 10 +- src/PhpDocument.php | 281 ++++++++++++++++++------------------ src/Project.php | 140 +++++++++--------- src/Server/TextDocument.php | 3 +- src/Server/Workspace.php | 116 +++++++-------- src/SymbolFinder.php | 27 ++-- src/utils.php | 12 +- tests/Utils/FileUriTest.php | 4 +- 9 files changed, 353 insertions(+), 360 deletions(-) diff --git a/src/Client/Window.php b/src/Client/Window.php index 431ef7cc..192ab470 100644 --- a/src/Client/Window.php +++ b/src/Client/Window.php @@ -1,60 +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 - ] - ))); - } -} \ No newline at end of file +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/LanguageServer.php b/src/LanguageServer.php index c9aea3fc..1876040c 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -133,22 +133,22 @@ private function indexProject(string $rootPath) $numTotalFiles = count($fileList); $startTime = microtime(true); - + $processFile = function() use (&$fileList, &$processFile, $rootPath, $numTotalFiles, $startTime) { if ($file = array_pop($fileList)) { - + $uri = pathToUri($file); $fileNum = $numTotalFiles - count($fileList); $shortName = substr($file, strlen($rootPath)+1); $this->client->window->logMessage(3, "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)); + $mem = (int)(memory_get_usage(true) / (1024 * 1024)); $this->client->window->logMessage(3, "All PHP files parsed in $duration seconds. $mem MiB allocated."); } }; diff --git a/src/PhpDocument.php b/src/PhpDocument.php index 6049f0da..424afd37 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -1,141 +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; - } -} +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 index 27e84ea1..d62a8cf5 100644 --- a/src/Project.php +++ b/src/Project.php @@ -1,70 +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; - } -} \ No newline at end of file + 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/Server/TextDocument.php b/src/Server/TextDocument.php index 92a9cef9..38c8ea31 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -71,7 +71,7 @@ public function didChange(VersionedTextDocumentIdentifier $textDocument, array $ { $this->project->getDocument($textDocument->uri)->updateContent($contentChanges[0]->text); } - + /** * The document formatting request is sent from the server to the client to format a whole document. @@ -84,5 +84,4 @@ public function formatting(TextDocumentIdentifier $textDocument, FormattingOptio { return $this->project->getDocument($textDocument->uri)->getFormattedText(); } - } diff --git a/src/Server/Workspace.php b/src/Server/Workspace.php index ef3e50b9..9f193f6d 100644 --- a/src/Server/Workspace.php +++ b/src/Server/Workspace.php @@ -1,58 +1,58 @@ -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. - * document. - * - * @param string $query - * @return SymbolInformation[] - */ - public function symbol(string $query): array - { - return $this->project->findSymbols($query); - } -} +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. + * document. + * + * @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 e185d3e6..73cb7419 100644 --- a/src/SymbolFinder.php +++ b/src/SymbolFinder.php @@ -39,12 +39,12 @@ class SymbolFinder extends NodeVisitorAbstract /** * @var array */ - private $nameStack = array(); + private $nameStack = []; /** * @var array */ - private $nodeStack = array(); + private $nodeStack = []; /** * @var int @@ -58,26 +58,21 @@ public function __construct(string $uri) public function enterNode(Node $node) { - array_push($this->nodeStack, $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($node->name) && !empty((string)$node->name)) { if (empty($containerName)) { - array_push($this->nameStack, (string)$node->name); + $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 { - if ($node instanceof Node\Stmt\ClassMethod) { - array_push($this->nameStack, $containerName . '::' . (string)$node->name); - } - else { - array_push($this->nameStack, $containerName . '\\' . (string)$node->name); - } - } - } - else { - array_push($this->nameStack, $containerName); + } else { + $this->nameStack[] = $containerName; } $class = get_class($node); diff --git a/src/utils.php b/src/utils.php index 2d62dc04..8d45cab0 100644 --- a/src/utils.php +++ b/src/utils.php @@ -3,7 +3,7 @@ namespace LanguageServer; /** - * Recursively Searches files with matching filename, starting at $path. + * Recursively Searches files with matching filename, starting at $path. * * @param string $path * @param string $pattern @@ -13,15 +13,15 @@ function findFilesRecursive(string $path, string $pattern): array { $dir = new \RecursiveDirectoryIterator($path); $ite = new \RecursiveIteratorIterator($dir); $files = new \RegexIterator($ite, $pattern, \RegexIterator::GET_MATCH); - $fileList = array(); - foreach($files as $file) { + $fileList = []; + foreach ($files as $file) { $fileList = array_merge($fileList, $file); } return $fileList; } /** - * Transforms an absolute file path into a URI as used by the language server protocol. + * Transforms an absolute file path into a URI as used by the language server protocol. * * @param string $filepath * @return string @@ -29,5 +29,5 @@ function findFilesRecursive(string $path, string $pattern): array { function pathToUri(string $filepath): string { $filepath = trim(str_replace('\\', '/', $filepath), '/'); $filepath = implode('/', array_map('urlencode', explode('/', $filepath))); - return 'file:///'.$filepath; -} \ No newline at end of file + return 'file:///' . $filepath; +} diff --git a/tests/Utils/FileUriTest.php b/tests/Utils/FileUriTest.php index dcd32425..802f78b7 100644 --- a/tests/Utils/FileUriTest.php +++ b/tests/Utils/FileUriTest.php @@ -21,8 +21,8 @@ public function testUriIsWellFormed() $uri = \LanguageServer\pathToUri('/usr/local/bin'); $this->assertEquals('file:///usr/local/bin', $uri); - $uri = \LanguageServer\pathToUri('a/b/c/'); - $this->assertEquals('file:///a/b/c', $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); From d1fd4bcd26108f715344aeb3af4119d045203a97 Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Thu, 29 Sep 2016 19:25:38 +0200 Subject: [PATCH 21/32] Use triple equals --- bin/php-language-server.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/php-language-server.php b/bin/php-language-server.php index 51b3dfb8..61c6ddf8 100644 --- a/bin/php-language-server.php +++ b/bin/php-language-server.php @@ -12,7 +12,7 @@ } } -if (count($argv) >= 3 && $argv[1] == '--tcp') { +if (count($argv) >= 3 && $argv[1] === '--tcp') { $address = $argv[2]; $socket = stream_socket_client('tcp://' . $address, $errno, $errstr); if ($socket === false) { From fa9e540c475678524132b6abfafb5e59d995940d Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Thu, 29 Sep 2016 19:33:41 +0200 Subject: [PATCH 22/32] Revert change in SymbolFinder --- src/SymbolFinder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SymbolFinder.php b/src/SymbolFinder.php index 73cb7419..0e681723 100644 --- a/src/SymbolFinder.php +++ b/src/SymbolFinder.php @@ -63,7 +63,7 @@ public function enterNode(Node $node) // If we enter a named node, push its name onto name stack. // Else push the current name onto stack. - if (!empty($node->name) && !empty((string)$node->name)) { + 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) { From 38c8cac7c7568a80a2df61ac243394c85d7724d6 Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Thu, 29 Sep 2016 19:59:23 +0200 Subject: [PATCH 23/32] Optimize processFile() a bit --- src/LanguageServer.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/LanguageServer.php b/src/LanguageServer.php index 1876040c..e3afd127 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -133,20 +133,20 @@ private function indexProject(string $rootPath) $numTotalFiles = count($fileList); $startTime = microtime(true); + $fileNum = 0; - $processFile = function() use (&$fileList, &$processFile, $rootPath, $numTotalFiles, $startTime) { - if ($file = array_pop($fileList)) { - + $processFile = function() use (&$fileList, &$fileNum, &$processFile, $rootPath, $numTotalFiles, $startTime) { + if ($fileNum < $numTotalFiles) { + $file = $fileList[$fileNum]; $uri = pathToUri($file); - $fileNum = $numTotalFiles - count($fileList); - $shortName = substr($file, strlen($rootPath)+1); + $fileNum++; + $shortName = substr($file, strlen($rootPath) + 1); $this->client->window->logMessage(3, "Parsing file $fileNum/$numTotalFiles: $shortName."); $this->project->getDocument($uri)->updateContent(file_get_contents($file)); Loop\setTimeout($processFile, 0); - } - else { + } else { $duration = (int)(microtime(true) - $startTime); $mem = (int)(memory_get_usage(true) / (1024 * 1024)); $this->client->window->logMessage(3, "All PHP files parsed in $duration seconds. $mem MiB allocated."); From 12931ae6bad09e0750865e7b31d0ee098bde8802 Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Thu, 29 Sep 2016 20:02:55 +0200 Subject: [PATCH 24/32] Use MessageType enum instead of number literal --- src/LanguageServer.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/LanguageServer.php b/src/LanguageServer.php index e3afd127..04206fd8 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -3,8 +3,14 @@ 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; @@ -141,7 +147,7 @@ private function indexProject(string $rootPath) $uri = pathToUri($file); $fileNum++; $shortName = substr($file, strlen($rootPath) + 1); - $this->client->window->logMessage(3, "Parsing file $fileNum/$numTotalFiles: $shortName."); + $this->client->window->logMessage(MessageType::INFO, "Parsing file $fileNum/$numTotalFiles: $shortName."); $this->project->getDocument($uri)->updateContent(file_get_contents($file)); @@ -149,7 +155,7 @@ private function indexProject(string $rootPath) } else { $duration = (int)(microtime(true) - $startTime); $mem = (int)(memory_get_usage(true) / (1024 * 1024)); - $this->client->window->logMessage(3, "All PHP files parsed in $duration seconds. $mem MiB allocated."); + $this->client->window->logMessage(MessageType::INFO, "All PHP files parsed in $duration seconds. $mem MiB allocated."); } }; From 7633bb5b3a498252a15f9aaf784b05cd939f63de Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Thu, 29 Sep 2016 20:19:10 +0200 Subject: [PATCH 25/32] Add missing space --- src/ProtocolStreamReader.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ProtocolStreamReader.php b/src/ProtocolStreamReader.php index 32fd992a..15ccfa41 100644 --- a/src/ProtocolStreamReader.php +++ b/src/ProtocolStreamReader.php @@ -30,7 +30,7 @@ public function __construct($input) { $this->input = $input; Loop\addReadStream($this->input, function() { - while(($c = fgetc($this->input)) !== false && $c !== '') { + while (($c = fgetc($this->input)) !== false && $c !== '') { $this->buffer .= $c; switch ($this->parsingMode) { case ParsingMode::HEADERS: From dbfb7b391286aebcf96dded29815d86df452329a Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Thu, 29 Sep 2016 20:34:45 +0200 Subject: [PATCH 26/32] Fixed ProtocolStreamWriter for nonblocking connection. --- src/ProtocolStreamWriter.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ProtocolStreamWriter.php b/src/ProtocolStreamWriter.php index f44daeb5..643e9401 100644 --- a/src/ProtocolStreamWriter.php +++ b/src/ProtocolStreamWriter.php @@ -25,6 +25,16 @@ public function __construct($output) */ public function write(Message $msg) { - fwrite($this->output, (string)$msg); + $data = (string)$msg; + $msgSize = strlen($data); + $totalBytesWritten = 0; + + while ($totalBytesWritten < $msgSize) { + $bytesWritten = fwrite($this->output, substr($data, $totalBytesWritten)); + if ($bytesWritten === false) { + throw new Error('Could not write message.'); + } + $totalBytesWritten += $bytesWritten; + } } } From 1fb1722f49128190cc17dd259587a1fd909aa1cd Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Thu, 29 Sep 2016 20:39:42 +0200 Subject: [PATCH 27/32] Suppress fwrite() notice when not all bytes could be written. --- src/ProtocolStreamWriter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ProtocolStreamWriter.php b/src/ProtocolStreamWriter.php index 643e9401..4d0a98c4 100644 --- a/src/ProtocolStreamWriter.php +++ b/src/ProtocolStreamWriter.php @@ -30,7 +30,7 @@ public function write(Message $msg) $totalBytesWritten = 0; while ($totalBytesWritten < $msgSize) { - $bytesWritten = fwrite($this->output, substr($data, $totalBytesWritten)); + $bytesWritten = @fwrite($this->output, substr($data, $totalBytesWritten)); if ($bytesWritten === false) { throw new Error('Could not write message.'); } From b55404154e72be27c6b2cee180742642040839c2 Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Thu, 29 Sep 2016 20:40:42 +0200 Subject: [PATCH 28/32] Fix another code style issue --- bin/php-language-server.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bin/php-language-server.php b/bin/php-language-server.php index 61c6ddf8..6790338a 100644 --- a/bin/php-language-server.php +++ b/bin/php-language-server.php @@ -21,8 +21,7 @@ exit(1); } $inputStream = $outputStream = $socket; -} -else { +} else { $inputStream = STDIN; $outputStream = STDOUT; } From ffea1d0c3acfaad1e6f374fa654ba13ac6903641 Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Thu, 29 Sep 2016 23:19:37 +0200 Subject: [PATCH 29/32] Throw Exceotion instead of Error --- src/ProtocolStreamWriter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ProtocolStreamWriter.php b/src/ProtocolStreamWriter.php index 4d0a98c4..d32a9d3d 100644 --- a/src/ProtocolStreamWriter.php +++ b/src/ProtocolStreamWriter.php @@ -32,7 +32,7 @@ public function write(Message $msg) while ($totalBytesWritten < $msgSize) { $bytesWritten = @fwrite($this->output, substr($data, $totalBytesWritten)); if ($bytesWritten === false) { - throw new Error('Could not write message.'); + throw new Exception('Could not write message.'); } $totalBytesWritten += $bytesWritten; } From c7c7e457770500e088b23c824c74c4f73c3e9c3d Mon Sep 17 00:00:00 2001 From: Stephan Unverwerth Date: Thu, 29 Sep 2016 23:20:10 +0200 Subject: [PATCH 30/32] Added ProtocolStreamWriter test --- tests/ProtocolStreamWriterTest.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/ProtocolStreamWriterTest.php diff --git a/tests/ProtocolStreamWriterTest.php b/tests/ProtocolStreamWriterTest.php new file mode 100644 index 00000000..90ef32d1 --- /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)); + } +} From 9f6cf32e5d1dbbd21e582466ad718a6beacfca76 Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Fri, 30 Sep 2016 10:17:28 +0200 Subject: [PATCH 31/32] Correct workspace/symbol documentation --- src/Server/Workspace.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Server/Workspace.php b/src/Server/Workspace.php index 9f193f6d..36248445 100644 --- a/src/Server/Workspace.php +++ b/src/Server/Workspace.php @@ -46,7 +46,6 @@ public function __construct(Project $project, LanguageClient $client) /** * The workspace symbol request is sent from the client to the server to list project-wide symbols matching the query string. - * document. * * @param string $query * @return SymbolInformation[] From a9c8f9c49e64d0d2ddeb2be57008cfcb7af7aa29 Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Fri, 30 Sep 2016 10:39:09 +0200 Subject: [PATCH 32/32] Improve exception in ProtocolStreamWriter::write() --- src/ProtocolStreamWriter.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ProtocolStreamWriter.php b/src/ProtocolStreamWriter.php index d32a9d3d..2ac35798 100644 --- a/src/ProtocolStreamWriter.php +++ b/src/ProtocolStreamWriter.php @@ -4,6 +4,7 @@ namespace LanguageServer; use LanguageServer\Protocol\Message; +use RuntimeException; class ProtocolStreamWriter implements ProtocolWriter { @@ -30,9 +31,15 @@ public function write(Message $msg) $totalBytesWritten = 0; while ($totalBytesWritten < $msgSize) { + error_clear_last(); $bytesWritten = @fwrite($this->output, substr($data, $totalBytesWritten)); if ($bytesWritten === false) { - throw new Exception('Could not write message.'); + $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; }