1
0
Fork 0

Implemented workspace symbol search

pull/31/head
Stephan Unverwerth 2016-09-18 00:58:32 +02:00
parent 41e9fb7e8a
commit 6d97d526b1
11 changed files with 448 additions and 115 deletions

View File

@ -1,5 +1,7 @@
<?php <?php
ini_set('memory_limit', '-1');
use LanguageServer\{LanguageServer, ProtocolStreamReader, ProtocolStreamWriter}; use LanguageServer\{LanguageServer, ProtocolStreamReader, ProtocolStreamWriter};
use Sabre\Event\Loop; use Sabre\Event\Loop;

60
src/Client/Window.php Normal file
View File

@ -0,0 +1,60 @@
<?php
declare(strict_types = 1);
namespace LanguageServer\Client;
use AdvancedJsonRpc\Notification as NotificationBody;
use PhpParser\{Error, Comment, Node, ParserFactory, NodeTraverser, Lexer};
use PhpParser\NodeVisitor\NameResolver;
use LanguageServer\ProtocolWriter;
use LanguageServer\Protocol\{TextDocumentItem, TextDocumentIdentifier, VersionedTextDocumentIdentifier, Message};
/**
* Provides method handlers for all window/* methods
*/
class Window
{
/**
* @var ProtocolWriter
*/
private $protocolWriter;
public function __construct(ProtocolWriter $protocolWriter)
{
$this->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
]
)));
}
}

View File

@ -4,6 +4,7 @@ declare(strict_types = 1);
namespace LanguageServer; namespace LanguageServer;
use LanguageServer\Client\TextDocument; use LanguageServer\Client\TextDocument;
use LanguageServer\Client\Window;
class LanguageClient class LanguageClient
{ {
@ -14,11 +15,19 @@ class LanguageClient
*/ */
public $textDocument; public $textDocument;
/**
* Handles window/* methods
*
* @var Client\Window
*/
public $window;
private $protocolWriter; private $protocolWriter;
public function __construct(ProtocolWriter $writer) public function __construct(ProtocolWriter $writer)
{ {
$this->protocolWriter = $writer; $this->protocolWriter = $writer;
$this->textDocument = new TextDocument($writer); $this->textDocument = new TextDocument($writer);
$this->window = new Window($writer);
} }
} }

View File

@ -6,6 +6,7 @@ use LanguageServer\Server\TextDocument;
use LanguageServer\Protocol\{ServerCapabilities, ClientCapabilities, TextDocumentSyncKind, Message}; use LanguageServer\Protocol\{ServerCapabilities, ClientCapabilities, TextDocumentSyncKind, Message};
use LanguageServer\Protocol\InitializeResult; use LanguageServer\Protocol\InitializeResult;
use AdvancedJsonRpc\{Dispatcher, ResponseError, Response as ResponseBody, Request as RequestBody}; use AdvancedJsonRpc\{Dispatcher, ResponseError, Response as ResponseBody, Request as RequestBody};
use Sabre\Event\Loop;
class LanguageServer extends \AdvancedJsonRpc\Dispatcher class LanguageServer extends \AdvancedJsonRpc\Dispatcher
{ {
@ -16,9 +17,15 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher
*/ */
public $textDocument; public $textDocument;
/**
* Handles workspace/* method calls
*
* @var Server\Workspace
*/
public $workspace;
public $telemetry; public $telemetry;
public $window; public $window;
public $workspace;
public $completionItem; public $completionItem;
public $codeLens; public $codeLens;
@ -26,6 +33,8 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher
private $protocolWriter; private $protocolWriter;
private $client; private $client;
private $project;
public function __construct(ProtocolReader $reader, ProtocolWriter $writer) public function __construct(ProtocolReader $reader, ProtocolWriter $writer)
{ {
parent::__construct($this, '/'); parent::__construct($this, '/');
@ -56,7 +65,11 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher
}); });
$this->protocolWriter = $writer; $this->protocolWriter = $writer;
$this->client = new LanguageClient($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 public function initialize(string $rootPath, int $processId, ClientCapabilities $capabilities): InitializeResult
{ {
// start building project index
if ($rootPath) {
$this->indexProject($rootPath);
}
$serverCapabilities = new ServerCapabilities(); $serverCapabilities = new ServerCapabilities();
// Ask the client to return always full documents (because we need to rebuild the AST from scratch) // Ask the client to return always full documents (because we need to rebuild the AST from scratch)
$serverCapabilities->textDocumentSync = TextDocumentSyncKind::FULL; $serverCapabilities->textDocumentSync = TextDocumentSyncKind::FULL;
// Support "Find all symbols" // Support "Find all symbols"
$serverCapabilities->documentSymbolProvider = true; $serverCapabilities->documentSymbolProvider = true;
// Support "Find all symbols in workspace"
$serverCapabilities->workspaceSymbolProvider = true;
// Support "Format Code" // Support "Format Code"
$serverCapabilities->documentFormattingProvider = true; $serverCapabilities->documentFormattingProvider = true;
return new InitializeResult($serverCapabilities); return new InitializeResult($serverCapabilities);
@ -100,4 +120,42 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher
{ {
exit(0); 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);
}
} }

112
src/PhpDocument.php Normal file
View File

@ -0,0 +1,112 @@
<?php
namespace LanguageServer;
use \LanguageServer\Protocol\{Diagnostic, DiagnosticSeverity, Range, Position, SymbolKind};
use PhpParser\{Error, Comment, Node, ParserFactory, NodeTraverser, Lexer, Parser};
use PhpParser\PrettyPrinter\Standard as PrettyPrinter;
use PhpParser\NodeVisitor\NameResolver;
class PhpDocument
{
private $stmts;
private $client;
private $project;
private $symbols = [];
private $parser;
private $uri;
public function __construct(string $uri, Project $project, LanguageClient $client, Parser $parser)
{
$this->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];
}
}

52
src/Project.php Normal file
View File

@ -0,0 +1,52 @@
<?php
namespace LanguageServer;
use PhpParser\{Error, Comment, Node, ParserFactory, NodeTraverser, Lexer};
use PhpParser\PrettyPrinter\Standard as PrettyPrinter;
use PhpParser\NodeVisitor\NameResolver;
class Project
{
private $documents;
private $parser;
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;
}
}

View File

@ -30,33 +30,32 @@ class ProtocolStreamReader implements ProtocolReader
{ {
$this->input = $input; $this->input = $input;
Loop\addReadStream($this->input, function() { Loop\addReadStream($this->input, function() {
while (($c = fgetc($this->input)) !== false) { $c = fgetc($this->input);
$this->buffer .= $c; $this->buffer .= $c;
switch ($this->parsingMode) { switch ($this->parsingMode) {
case ParsingMode::HEADERS: case ParsingMode::HEADERS:
if ($this->buffer === "\r\n") { if ($this->buffer === "\r\n") {
$this->parsingMode = ParsingMode::BODY; $this->parsingMode = ParsingMode::BODY;
$this->contentLength = (int)$this->headers['Content-Length']; $this->contentLength = (int)$this->headers['Content-Length'];
$this->buffer = ''; $this->buffer = '';
} else if (substr($this->buffer, -2) === "\r\n") { } else if (substr($this->buffer, -2) === "\r\n") {
$parts = explode(':', $this->buffer); $parts = explode(':', $this->buffer);
$this->headers[$parts[0]] = trim($parts[1]); $this->headers[$parts[0]] = trim($parts[1]);
$this->buffer = ''; $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; $this->parsingMode = ParsingMode::HEADERS;
case ParsingMode::BODY: $this->headers = [];
if (strlen($this->buffer) === $this->contentLength) { $this->buffer = '';
if (isset($this->listener)) { }
$msg = new Message(MessageBody::parse($this->buffer), $this->headers); break;
$listener = $this->listener;
$listener($msg);
}
$this->parsingMode = ParsingMode::HEADERS;
$this->headers = [];
$this->buffer = '';
}
break;
}
} }
}); });
} }

View File

@ -2,10 +2,7 @@
namespace LanguageServer\Server; namespace LanguageServer\Server;
use PhpParser\{Error, Comment, Node, ParserFactory, NodeTraverser, Lexer}; use LanguageServer\{LanguageClient, ColumnCalculator, SymbolFinder, Project};
use PhpParser\PrettyPrinter\Standard as PrettyPrinter;
use PhpParser\NodeVisitor\NameResolver;
use LanguageServer\{LanguageClient, ColumnCalculator, SymbolFinder};
use LanguageServer\Protocol\{ use LanguageServer\Protocol\{
TextDocumentItem, TextDocumentItem,
TextDocumentIdentifier, TextDocumentIdentifier,
@ -23,18 +20,6 @@ use LanguageServer\Protocol\{
*/ */
class TextDocument 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 * The lanugage client object to call methods on the client
* *
@ -42,11 +27,12 @@ class TextDocument
*/ */
private $client; private $client;
public function __construct(LanguageClient $client) private $project;
public function __construct(Project $project, LanguageClient $client)
{ {
$this->project = $project;
$this->client = $client; $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 public function documentSymbol(TextDocumentIdentifier $textDocument): array
{ {
$stmts = $this->asts[$textDocument->uri]; return $this->project->getDocument($textDocument->uri)->getSymbols();
if (!$stmts) {
return [];
}
$finder = new SymbolFinder($textDocument->uri);
$traverser = new NodeTraverser;
$traverser->addVisitor($finder);
$traverser->traverse($stmts);
return $finder->symbols;
} }
/** /**
@ -79,7 +57,7 @@ class TextDocument
*/ */
public function didOpen(TextDocumentItem $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) public function didChange(VersionedTextDocumentIdentifier $textDocument, array $contentChanges)
{ {
$this->updateAst($textDocument->uri, $contentChanges[0]->text); $this->project->getDocument($textDocument->uri)->updateAst($contentChanges[0]->text);
} }
/**
* Re-parses a source file, updates the AST and reports parsing errors that may occured as diagnostics
*
* @param string $uri The URI of the source file
* @param string $content The new content of the source file
* @return void
*/
private function updateAst(string $uri, string $content)
{
$stmts = $this->parser->parse($content);
$diagnostics = [];
foreach ($this->parser->getErrors() as $error) {
$diagnostic = new Diagnostic();
$diagnostic->range = new Range(
new Position($error->getStartLine() - 1, $error->hasColumnInfo() ? $error->getStartColumn($content) - 1 : 0),
new Position($error->getEndLine() - 1, $error->hasColumnInfo() ? $error->getEndColumn($content) : 0)
);
$diagnostic->severity = DiagnosticSeverity::ERROR;
$diagnostic->source = 'php';
// Do not include "on line ..." in the error message
$diagnostic->message = $error->getRawMessage();
$diagnostics[] = $diagnostic;
}
$this->client->textDocument->publishDiagnostics($uri, $diagnostics);
// $stmts can be null in case of a fatal parsing error
if ($stmts) {
$traverser = new NodeTraverser;
$traverser->addVisitor(new NameResolver);
$traverser->addVisitor(new ColumnCalculator($content));
$traverser->traverse($stmts);
$this->asts[$uri] = $stmts;
}
}
/** /**
* The document formatting request is sent from the server to the client to format a whole document. * 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) public function formatting(TextDocumentIdentifier $textDocument, FormattingOptions $options)
{ {
$nodes = $this->asts[$textDocument->uri]; return $this->project->getDocument($textDocument->uri)->getFormattedText();
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];
} }
} }

73
src/Server/Workspace.php Normal file
View File

@ -0,0 +1,73 @@
<?php
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, Project};
use LanguageServer\Protocol\{
TextDocumentItem,
TextDocumentIdentifier,
VersionedTextDocumentIdentifier,
Diagnostic,
DiagnosticSeverity,
Range,
Position,
FormattingOptions,
TextEdit,
SymbolInformation
};
/**
* Provides method handlers for all workspace/* methods
*/
class Workspace
{
/**
* The lanugage client object to call methods on the client
*
* @var \LanguageServer\LanguageClient
*/
private $client;
private $project;
public function __construct(Project $project, LanguageClient $client)
{
$this->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)
{
}
}

View File

@ -35,6 +35,21 @@ class SymbolFinder extends NodeVisitorAbstract
*/ */
private $containerName; private $containerName;
/**
* @var array
*/
private $nameStack = array();
/**
* @var array
*/
private $nodeStack = array();
/**
* @var int
*/
private $functionCount = 0;
public function __construct(string $uri) public function __construct(string $uri)
{ {
$this->uri = $uri; $this->uri = $uri;
@ -42,24 +57,34 @@ class SymbolFinder extends NodeVisitorAbstract
public function enterNode(Node $node) 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); $class = get_class($node);
if (!isset(self::NODE_SYMBOL_KIND_MAP[$class])) { if (!isset(self::NODE_SYMBOL_KIND_MAP[$class])) {
return; 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); $symbol = end($this->symbols);
$kind = self::NODE_SYMBOL_KIND_MAP[$class]; $kind = self::NODE_SYMBOL_KIND_MAP[$class];
// exclude variable symbols that are defined in methods and functions. // exclude non-global variable symbols.
if ($symbol && $kind === SymbolKind::VARIABLE && if ($kind === SymbolKind::VARIABLE && $this->functionCount > 0) {
($symbol->kind === SymbolKind::METHOD || $symbol->kind === SymbolKind::FUNCTION) return;
) {
if (
$node->getAttribute('startLine') - 1 > $symbol->location->range->start->line &&
$node->getAttribute('endLine') - 1 < $symbol->location->range->end->line
) {
return;
}
} }
$symbol = new SymbolInformation(); $symbol = new SymbolInformation();
@ -72,13 +97,19 @@ class SymbolFinder extends NodeVisitorAbstract
new Position($node->getAttribute('endLine') - 1, $node->getAttribute('endColumn')) new Position($node->getAttribute('endLine') - 1, $node->getAttribute('endColumn'))
) )
); );
$symbol->containerName = $this->containerName; $symbol->containerName = $containerName;
$this->containerName = $symbol->name;
$this->symbols[] = $symbol; $this->symbols[] = $symbol;
} }
public function leaveNode(Node $node) 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--;
}
} }
} }

View File

@ -37,7 +37,7 @@ class LanguageServerTest extends TestCase
'definitionProvider' => null, 'definitionProvider' => null,
'referencesProvider' => null, 'referencesProvider' => null,
'documentHighlightProvider' => null, 'documentHighlightProvider' => null,
'workspaceSymbolProvider' => null, 'workspaceSymbolProvider' => true,
'codeActionProvider' => null, 'codeActionProvider' => null,
'codeLensProvider' => null, 'codeLensProvider' => null,
'documentFormattingProvider' => true, 'documentFormattingProvider' => true,