diff --git a/bin/php-language-server.php b/bin/php-language-server.php index c8dfaef..14f5326 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 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..94712d5 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -6,6 +6,7 @@ use LanguageServer\Server\TextDocument; 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 @@ 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); } /** @@ -69,11 +82,18 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher */ 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 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher { 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 0000000..051dc96 --- /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 0000000..c7792c2 --- /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 434735c..36b1111 100644 --- a/src/ProtocolStreamReader.php +++ b/src/ProtocolStreamReader.php @@ -30,33 +30,32 @@ class ProtocolStreamReader implements ProtocolReader { $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 23c01c0..c340b04 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)->updateAst($textDocument->text); } /** @@ -91,42 +69,9 @@ class 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 @@ 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..98de35f --- /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 087cadd..e54fa77 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 @@ class SymbolFinder extends NodeVisitorAbstract 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 @@ 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 + $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 fbba7b5..7d82c23 100644 --- a/tests/LanguageServerTest.php +++ b/tests/LanguageServerTest.php @@ -37,7 +37,7 @@ class LanguageServerTest extends TestCase 'definitionProvider' => null, 'referencesProvider' => null, 'documentHighlightProvider' => null, - 'workspaceSymbolProvider' => null, + 'workspaceSymbolProvider' => true, 'codeActionProvider' => null, 'codeLensProvider' => null, 'documentFormattingProvider' => true,