Global symbol search (#31)
* Implemented workspace symbol search * Fixed missing TextEdit using declaration * Fixed generating uri when parsing next file. * Cleaned up code. Fixed tests * Fixed PHPDoc for LanguageServer::initialize() * Moved utility functions to utils.php * Added tests for pathToUri and findFilesRecursive * Added command line argument for socket communication * Fixed local variable detection and containerName generation in SymbolFinder * Fixed formatting in ProtocolStreamReader * Store text content in PHPDocument, removed stmts, regenerate on demand * Fixed local variable detection and containerName generation in SymbolFinder. * Added Tests for Project and Workspace * Added test for didChange event * Modified lexer error handling * Removed file that shouldn't have been committed. * Updated sabre/event dependency to 4.0.0 * Updated readme.md to show tcp option * make input stream non-blocking * Correct code style * Use triple equals * Revert change in SymbolFinder * Optimize processFile() a bit * Use MessageType enum instead of number literal * Add missing space * Fixed ProtocolStreamWriter for nonblocking connection. * Suppress fwrite() notice when not all bytes could be written. * Fix another code style issue * Throw Exceotion instead of Error * Added ProtocolStreamWriter test * Correct workspace/symbol documentation * Improve exception in ProtocolStreamWriter::write()pull/39/head
parent
bc2d6b9b59
commit
501d26e1d4
12
README.md
12
README.md
|
@ -26,3 +26,15 @@ to install dependencies.
|
||||||
Run the tests with
|
Run the tests with
|
||||||
|
|
||||||
vendor/bin/phpunit --bootstrap vendor/autoload.php tests
|
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
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
@ -10,6 +12,22 @@ foreach ([__DIR__ . '/../../../autoload.php', __DIR__ . '/../autoload.php', __DI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream_set_blocking($inputStream, false);
|
||||||
|
|
||||||
|
$server = new LanguageServer(new ProtocolStreamReader($inputStream), new ProtocolStreamWriter($outputStream));
|
||||||
|
|
||||||
Loop\run();
|
Loop\run();
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
"php": ">=7.0",
|
"php": ">=7.0",
|
||||||
"nikic/php-parser": "^3.0.0beta1",
|
"nikic/php-parser": "^3.0.0beta1",
|
||||||
"phpdocumentor/reflection-docblock": "^3.0",
|
"phpdocumentor/reflection-docblock": "^3.0",
|
||||||
"sabre/event": "^3.0",
|
"sabre/event": "^4.0",
|
||||||
"felixfbecker/advanced-json-rpc": "^1.2"
|
"felixfbecker/advanced-json-rpc": "^1.2"
|
||||||
},
|
},
|
||||||
"minimum-stability": "dev",
|
"minimum-stability": "dev",
|
||||||
|
@ -34,7 +34,8 @@
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"LanguageServer\\": "src/"
|
"LanguageServer\\": "src/"
|
||||||
}
|
},
|
||||||
|
"files" : ["src/utils.php"]
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
A
|
|
@ -0,0 +1 @@
|
||||||
|
B
|
|
@ -0,0 +1 @@
|
||||||
|
Peeakboo!
|
|
@ -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
|
||||||
|
]
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,9 +3,16 @@
|
||||||
namespace LanguageServer;
|
namespace LanguageServer;
|
||||||
|
|
||||||
use LanguageServer\Server\TextDocument;
|
use LanguageServer\Server\TextDocument;
|
||||||
use LanguageServer\Protocol\{ServerCapabilities, ClientCapabilities, TextDocumentSyncKind, Message};
|
use LanguageServer\Protocol\{
|
||||||
use LanguageServer\Protocol\InitializeResult;
|
ServerCapabilities,
|
||||||
|
ClientCapabilities,
|
||||||
|
TextDocumentSyncKind,
|
||||||
|
Message,
|
||||||
|
MessageType,
|
||||||
|
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 +23,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 +39,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,24 +71,35 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The initialize request is sent as the first request from the client to the server.
|
* 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 int $processId The process Id of the parent process that started the server.
|
||||||
* @param ClientCapabilities $capabilities The capabilities provided by the client (editor)
|
* @param ClientCapabilities $capabilities The capabilities provided by the client (editor)
|
||||||
|
* @param string|null $rootPath The rootPath of the workspace. Is null if no folder is open.
|
||||||
* @return InitializeResult
|
* @return InitializeResult
|
||||||
*/
|
*/
|
||||||
public function initialize(string $rootPath, int $processId, ClientCapabilities $capabilities): InitializeResult
|
public function initialize(int $processId, ClientCapabilities $capabilities, string $rootPath = null): InitializeResult
|
||||||
{
|
{
|
||||||
|
// start building project index
|
||||||
|
if ($rootPath) {
|
||||||
|
$this->indexProject($rootPath);
|
||||||
|
}
|
||||||
|
|
||||||
$serverCapabilities = new ServerCapabilities();
|
$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 +126,39 @@ class LanguageServer extends \AdvancedJsonRpc\Dispatcher
|
||||||
{
|
{
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses workspace files, one at a time.
|
||||||
|
*
|
||||||
|
* @param string $rootPath The rootPath of the workspace.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function indexProject(string $rootPath)
|
||||||
|
{
|
||||||
|
$fileList = findFilesRecursive($rootPath, '/^.+\.php$/i');
|
||||||
|
$numTotalFiles = count($fileList);
|
||||||
|
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$fileNum = 0;
|
||||||
|
|
||||||
|
$processFile = function() use (&$fileList, &$fileNum, &$processFile, $rootPath, $numTotalFiles, $startTime) {
|
||||||
|
if ($fileNum < $numTotalFiles) {
|
||||||
|
$file = $fileList[$fileNum];
|
||||||
|
$uri = pathToUri($file);
|
||||||
|
$fileNum++;
|
||||||
|
$shortName = substr($file, strlen($rootPath) + 1);
|
||||||
|
$this->client->window->logMessage(MessageType::INFO, "Parsing file $fileNum/$numTotalFiles: $shortName.");
|
||||||
|
|
||||||
|
$this->project->getDocument($uri)->updateContent(file_get_contents($file));
|
||||||
|
|
||||||
|
Loop\setTimeout($processFile, 0);
|
||||||
|
} else {
|
||||||
|
$duration = (int)(microtime(true) - $startTime);
|
||||||
|
$mem = (int)(memory_get_usage(true) / (1024 * 1024));
|
||||||
|
$this->client->window->logMessage(MessageType::INFO, "All PHP files parsed in $duration seconds. $mem MiB allocated.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Loop\setTimeout($processFile, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,140 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace LanguageServer;
|
||||||
|
|
||||||
|
use \LanguageServer\Protocol\{Diagnostic, DiagnosticSeverity, Range, Position, SymbolKind, TextEdit};
|
||||||
|
|
||||||
|
use PhpParser\{Error, Comment, Node, ParserFactory, NodeTraverser, Lexer, Parser};
|
||||||
|
use PhpParser\PrettyPrinter\Standard as PrettyPrinter;
|
||||||
|
use PhpParser\NodeVisitor\NameResolver;
|
||||||
|
|
||||||
|
class PhpDocument
|
||||||
|
{
|
||||||
|
private $client;
|
||||||
|
private $project;
|
||||||
|
private $parser;
|
||||||
|
|
||||||
|
private $uri;
|
||||||
|
private $content;
|
||||||
|
private $symbols = [];
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace LanguageServer;
|
||||||
|
|
||||||
|
use PhpParser\{Error, Comment, Node, ParserFactory, NodeTraverser, Lexer};
|
||||||
|
use PhpParser\PrettyPrinter\Standard as PrettyPrinter;
|
||||||
|
use PhpParser\NodeVisitor\NameResolver;
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -30,7 +30,7 @@ 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) {
|
while (($c = fgetc($this->input)) !== false && $c !== '') {
|
||||||
$this->buffer .= $c;
|
$this->buffer .= $c;
|
||||||
switch ($this->parsingMode) {
|
switch ($this->parsingMode) {
|
||||||
case ParsingMode::HEADERS:
|
case ParsingMode::HEADERS:
|
||||||
|
|
|
@ -4,6 +4,7 @@ declare(strict_types = 1);
|
||||||
namespace LanguageServer;
|
namespace LanguageServer;
|
||||||
|
|
||||||
use LanguageServer\Protocol\Message;
|
use LanguageServer\Protocol\Message;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
class ProtocolStreamWriter implements ProtocolWriter
|
class ProtocolStreamWriter implements ProtocolWriter
|
||||||
{
|
{
|
||||||
|
@ -25,6 +26,22 @@ class ProtocolStreamWriter implements ProtocolWriter
|
||||||
*/
|
*/
|
||||||
public function write(Message $msg)
|
public function write(Message $msg)
|
||||||
{
|
{
|
||||||
fwrite($this->output, (string)$msg);
|
$data = (string)$msg;
|
||||||
|
$msgSize = strlen($data);
|
||||||
|
$totalBytesWritten = 0;
|
||||||
|
|
||||||
|
while ($totalBytesWritten < $msgSize) {
|
||||||
|
error_clear_last();
|
||||||
|
$bytesWritten = @fwrite($this->output, substr($data, $totalBytesWritten));
|
||||||
|
if ($bytesWritten === false) {
|
||||||
|
$error = error_get_last();
|
||||||
|
if ($error !== null) {
|
||||||
|
throw new RuntimeException('Could not write message: ' . error_get_last()['message']);
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException('Could not write message');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$totalBytesWritten += $bytesWritten;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)->updateContent($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)->updateContent($contentChanges[0]->text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-parses a source file, updates the AST and reports parsing errors that may occured as diagnostics
|
|
||||||
*
|
|
||||||
* @param string $uri The URI of the source file
|
|
||||||
* @param string $content The new content of the source file
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function updateAst(string $uri, string $content)
|
|
||||||
{
|
|
||||||
$stmts = $this->parser->parse($content);
|
|
||||||
$diagnostics = [];
|
|
||||||
foreach ($this->parser->getErrors() as $error) {
|
|
||||||
$diagnostic = new Diagnostic();
|
|
||||||
$diagnostic->range = new Range(
|
|
||||||
new Position($error->getStartLine() - 1, $error->hasColumnInfo() ? $error->getStartColumn($content) - 1 : 0),
|
|
||||||
new Position($error->getEndLine() - 1, $error->hasColumnInfo() ? $error->getEndColumn($content) : 0)
|
|
||||||
);
|
|
||||||
$diagnostic->severity = DiagnosticSeverity::ERROR;
|
|
||||||
$diagnostic->source = 'php';
|
|
||||||
// Do not include "on line ..." in the error message
|
|
||||||
$diagnostic->message = $error->getRawMessage();
|
|
||||||
$diagnostics[] = $diagnostic;
|
|
||||||
}
|
|
||||||
$this->client->textDocument->publishDiagnostics($uri, $diagnostics);
|
|
||||||
// $stmts can be null in case of a fatal parsing error
|
|
||||||
if ($stmts) {
|
|
||||||
$traverser = new NodeTraverser;
|
|
||||||
$traverser->addVisitor(new NameResolver);
|
|
||||||
$traverser->addVisitor(new ColumnCalculator($content));
|
|
||||||
$traverser->traverse($stmts);
|
|
||||||
$this->asts[$uri] = $stmts;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The document formatting request is sent from the server to the client to format a whole document.
|
* The document formatting request is sent from the server to the client to format a whole document.
|
||||||
|
@ -137,15 +82,6 @@ class TextDocument
|
||||||
*/
|
*/
|
||||||
public function formatting(TextDocumentIdentifier $textDocument, FormattingOptions $options)
|
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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current project database
|
||||||
|
*
|
||||||
|
* @var Project
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* @param string $query
|
||||||
|
* @return SymbolInformation[]
|
||||||
|
*/
|
||||||
|
public function symbol(string $query): array
|
||||||
|
{
|
||||||
|
return $this->project->findSymbols($query);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ declare(strict_types = 1);
|
||||||
namespace LanguageServer;
|
namespace LanguageServer;
|
||||||
|
|
||||||
use PhpParser\{NodeVisitorAbstract, Node};
|
use PhpParser\{NodeVisitorAbstract, Node};
|
||||||
|
|
||||||
use LanguageServer\Protocol\{SymbolInformation, SymbolKind, Range, Position, Location};
|
use LanguageServer\Protocol\{SymbolInformation, SymbolKind, Range, Position, Location};
|
||||||
|
|
||||||
class SymbolFinder extends NodeVisitorAbstract
|
class SymbolFinder extends NodeVisitorAbstract
|
||||||
|
@ -35,6 +36,21 @@ class SymbolFinder extends NodeVisitorAbstract
|
||||||
*/
|
*/
|
||||||
private $containerName;
|
private $containerName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
private $nameStack = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
private $nodeStack = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private $functionCount = 0;
|
||||||
|
|
||||||
public function __construct(string $uri)
|
public function __construct(string $uri)
|
||||||
{
|
{
|
||||||
$this->uri = $uri;
|
$this->uri = $uri;
|
||||||
|
@ -42,24 +58,38 @@ class SymbolFinder extends NodeVisitorAbstract
|
||||||
|
|
||||||
public function enterNode(Node $node)
|
public function enterNode(Node $node)
|
||||||
{
|
{
|
||||||
|
$this->nodeStack[] = $node;
|
||||||
|
$containerName = end($this->nameStack);
|
||||||
|
|
||||||
|
// If we enter a named node, push its name onto name stack.
|
||||||
|
// Else push the current name onto stack.
|
||||||
|
if (!empty($node->name) && (is_string($node->name) || method_exists($node->name, '__toString')) && !empty((string)$node->name)) {
|
||||||
|
if (empty($containerName)) {
|
||||||
|
$this->nameStack[] = (string)$node->name;
|
||||||
|
} else if ($node instanceof Node\Stmt\ClassMethod) {
|
||||||
|
$this->nameStack[] = $containerName . '::' . (string)$node->name;
|
||||||
|
} else {
|
||||||
|
$this->nameStack[] = $containerName . '\\' . (string)$node->name;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->nameStack[] = $containerName;
|
||||||
|
}
|
||||||
|
|
||||||
$class = get_class($node);
|
$class = get_class($node);
|
||||||
if (!isset(self::NODE_SYMBOL_KIND_MAP[$class])) {
|
if (!isset(self::NODE_SYMBOL_KIND_MAP[$class])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$symbol = end($this->symbols);
|
// if we enter a method or function, increase the function counter
|
||||||
|
if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) {
|
||||||
|
$this->functionCount++;
|
||||||
|
}
|
||||||
|
|
||||||
$kind = self::NODE_SYMBOL_KIND_MAP[$class];
|
$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 +102,18 @@ 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
|
||||||
|
if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) {
|
||||||
|
$this->functionCount--;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace LanguageServer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively Searches files with matching filename, starting at $path.
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @param string $pattern
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
function findFilesRecursive(string $path, string $pattern): array {
|
||||||
|
$dir = new \RecursiveDirectoryIterator($path);
|
||||||
|
$ite = new \RecursiveIteratorIterator($dir);
|
||||||
|
$files = new \RegexIterator($ite, $pattern, \RegexIterator::GET_MATCH);
|
||||||
|
$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.
|
||||||
|
*
|
||||||
|
* @param string $filepath
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function pathToUri(string $filepath): string {
|
||||||
|
$filepath = trim(str_replace('\\', '/', $filepath), '/');
|
||||||
|
$filepath = implode('/', array_map('urlencode', explode('/', $filepath)));
|
||||||
|
return 'file:///' . $filepath;
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace LanguageServer\Tests\Server;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use LanguageServer\Tests\MockProtocolStream;
|
||||||
|
use LanguageServer\{Server, Client, LanguageClient, Project, PhpDocument};
|
||||||
|
use LanguageServer\Protocol\{TextDocumentItem, TextDocumentIdentifier, SymbolKind, DiagnosticSeverity, FormattingOptions};
|
||||||
|
use AdvancedJsonRpc\{Request as RequestBody, Response as ResponseBody};
|
||||||
|
|
||||||
|
class ProjectTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var Project $project
|
||||||
|
*/
|
||||||
|
private $project;
|
||||||
|
|
||||||
|
public function setUp()
|
||||||
|
{
|
||||||
|
$this->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("<?php\nfunction foo() {}\nfunction bar() {}\n");
|
||||||
|
$this->project->getDocument('file:///document2.php')->updateContent("<?php\nfunction baz() {}\nfunction frob() {}\n");
|
||||||
|
|
||||||
|
$symbols = $this->project->findSymbols('ba');
|
||||||
|
|
||||||
|
$this->assertEquals([
|
||||||
|
[
|
||||||
|
'name' => 'bar',
|
||||||
|
'kind' => SymbolKind::FUNCTION,
|
||||||
|
'location' => [
|
||||||
|
'uri' => 'file:///document1.php',
|
||||||
|
'range' => [
|
||||||
|
'start' => [
|
||||||
|
'line' => 2,
|
||||||
|
'character' => 0
|
||||||
|
],
|
||||||
|
'end' => [
|
||||||
|
'line' => 2,
|
||||||
|
'character' => 17
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'containerName' => null
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'baz',
|
||||||
|
'kind' => SymbolKind::FUNCTION,
|
||||||
|
'location' => [
|
||||||
|
'uri' => 'file:///document2.php',
|
||||||
|
'range' => [
|
||||||
|
'start' => [
|
||||||
|
'line' => 1,
|
||||||
|
'character' => 0
|
||||||
|
],
|
||||||
|
'end' => [
|
||||||
|
'line' => 1,
|
||||||
|
'character' => 17
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'containerName' => null
|
||||||
|
]
|
||||||
|
], json_decode(json_encode($symbols), true));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace LanguageServer\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use LanguageServer\ProtocolStreamWriter;
|
||||||
|
use LanguageServer\Protocol\Message;
|
||||||
|
use AdvancedJsonRpc\{Request as RequestBody};
|
||||||
|
|
||||||
|
class ProtocolStreamWriterTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testLargeMessageIsSent()
|
||||||
|
{
|
||||||
|
$tmpfile = tempnam('', '');
|
||||||
|
$writeHandle = fopen($tmpfile, 'w');
|
||||||
|
|
||||||
|
stream_set_blocking($writeHandle, false);
|
||||||
|
|
||||||
|
$writer = new ProtocolStreamWriter($writeHandle);
|
||||||
|
$msg = new Message(new RequestBody(1, 'aMethod', ['arg' => str_repeat('X', 100000)]));
|
||||||
|
$msgString = (string)$msg;
|
||||||
|
|
||||||
|
$writer->write($msg);
|
||||||
|
|
||||||
|
fclose($writeHandle);
|
||||||
|
|
||||||
|
$this->assertEquals(strlen($msgString), filesize($tmpfile));
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,15 +5,17 @@ namespace LanguageServer\Tests\Server;
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use LanguageServer\Tests\MockProtocolStream;
|
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 LanguageServer\Protocol\{TextDocumentItem, TextDocumentIdentifier, SymbolKind, DiagnosticSeverity, FormattingOptions, VersionedTextDocumentIdentifier, TextDocumentContentChangeEvent, Range, Position};
|
||||||
use AdvancedJsonRpc\{Request as RequestBody, Response as ResponseBody};
|
use AdvancedJsonRpc\{Request as RequestBody, Response as ResponseBody};
|
||||||
|
|
||||||
class TextDocumentTest extends TestCase
|
class TextDocumentTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testDocumentSymbol()
|
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
|
// Trigger parsing of source
|
||||||
$textDocumentItem = new TextDocumentItem();
|
$textDocumentItem = new TextDocumentItem();
|
||||||
$textDocumentItem->uri = 'whatever';
|
$textDocumentItem->uri = 'whatever';
|
||||||
|
@ -58,7 +60,7 @@ class TextDocumentTest extends TestCase
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
'containerName' => null
|
'containerName' => 'TestNamespace'
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'testProperty',
|
'name' => 'testProperty',
|
||||||
|
@ -76,7 +78,7 @@ class TextDocumentTest extends TestCase
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
'containerName' => 'TestClass'
|
'containerName' => 'TestNamespace\\TestClass'
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'testMethod',
|
'name' => 'testMethod',
|
||||||
|
@ -94,7 +96,7 @@ class TextDocumentTest extends TestCase
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
'containerName' => null
|
'containerName' => 'TestNamespace\\TestClass'
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'TestTrait',
|
'name' => 'TestTrait',
|
||||||
|
@ -112,7 +114,7 @@ class TextDocumentTest extends TestCase
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
'containerName' => null
|
'containerName' => 'TestNamespace'
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'TestInterface',
|
'name' => 'TestInterface',
|
||||||
|
@ -130,7 +132,7 @@ class TextDocumentTest extends TestCase
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
'containerName' => null
|
'containerName' => 'TestNamespace'
|
||||||
]
|
]
|
||||||
], json_decode(json_encode($result), true));
|
], json_decode(json_encode($result), true));
|
||||||
}
|
}
|
||||||
|
@ -151,7 +153,11 @@ class TextDocumentTest extends TestCase
|
||||||
$this->args = func_get_args();
|
$this->args = func_get_args();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
$textDocument = new Server\TextDocument($client);
|
|
||||||
|
$project = new Project($client);
|
||||||
|
|
||||||
|
$textDocument = new Server\TextDocument($project, $client);
|
||||||
|
|
||||||
// Trigger parsing of source
|
// Trigger parsing of source
|
||||||
$textDocumentItem = new TextDocumentItem();
|
$textDocumentItem = new TextDocumentItem();
|
||||||
$textDocumentItem->uri = 'whatever';
|
$textDocumentItem->uri = 'whatever';
|
||||||
|
@ -182,7 +188,10 @@ class TextDocumentTest extends TestCase
|
||||||
|
|
||||||
public function testFormatting()
|
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
|
// Trigger parsing of source
|
||||||
$textDocumentItem = new TextDocumentItem();
|
$textDocumentItem = new TextDocumentItem();
|
||||||
$textDocumentItem->uri = 'whatever';
|
$textDocumentItem->uri = 'whatever';
|
||||||
|
@ -209,4 +218,24 @@ class TextDocumentTest extends TestCase
|
||||||
'newText' => $expected
|
'newText' => $expected
|
||||||
]], json_decode(json_encode($result), true));
|
]], 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("<?php\necho 'Hello, World'\n");
|
||||||
|
|
||||||
|
$identifier = new VersionedTextDocumentIdentifier('whatever');
|
||||||
|
$changeEvent = new TextDocumentContentChangeEvent();
|
||||||
|
$changeEvent->range = new Range(new Position(0,0), new Position(9999,9999));
|
||||||
|
$changeEvent->rangeLength = 9999;
|
||||||
|
$changeEvent->text = "<?php\necho 'Goodbye, World'\n";
|
||||||
|
|
||||||
|
$textDocument->didChange($identifier, [$changeEvent]);
|
||||||
|
|
||||||
|
$this->assertEquals("<?php\necho 'Goodbye, World'\n", $phpDocument->getContent());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace LanguageServer\Tests\Server;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use LanguageServer\Tests\MockProtocolStream;
|
||||||
|
use LanguageServer\{Server, Client, LanguageClient, Project, PhpDocument};
|
||||||
|
use LanguageServer\Protocol\{TextDocumentItem, TextDocumentIdentifier, SymbolKind, DiagnosticSeverity, FormattingOptions};
|
||||||
|
use AdvancedJsonRpc\{Request as RequestBody, Response as ResponseBody};
|
||||||
|
|
||||||
|
class WorkspaceTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var LanguageServer\Workspace $workspace
|
||||||
|
*/
|
||||||
|
private $workspace;
|
||||||
|
|
||||||
|
public function setUp()
|
||||||
|
{
|
||||||
|
$client = new LanguageClient(new MockProtocolStream());
|
||||||
|
$project = new Project($client);
|
||||||
|
$this->workspace = new Server\Workspace($project, $client);
|
||||||
|
|
||||||
|
// create two documents
|
||||||
|
$project->getDocument('file:///document1.php')->updateContent("<?php\nfunction foo() {}\nfunction bar() {}\n");
|
||||||
|
$project->getDocument('file:///document2.php')->updateContent("<?php\nfunction baz() {}\nfunction frob() {}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSymbol()
|
||||||
|
{
|
||||||
|
// Request symbols
|
||||||
|
$result = $this->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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace LanguageServer\Tests\Utils;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class FileUriTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testSpecialCharsAreEscaped()
|
||||||
|
{
|
||||||
|
$uri = \LanguageServer\pathToUri('c:/path/to/file/dürüm döner.php');
|
||||||
|
$this->assertEquals('file:///c%3A/path/to/file/d%C3%BCr%C3%BCm+d%C3%B6ner.php', $uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUriIsWellFormed()
|
||||||
|
{
|
||||||
|
$uri = \LanguageServer\pathToUri('var/log');
|
||||||
|
$this->assertEquals('file:///var/log', $uri);
|
||||||
|
|
||||||
|
$uri = \LanguageServer\pathToUri('/usr/local/bin');
|
||||||
|
$this->assertEquals('file:///usr/local/bin', $uri);
|
||||||
|
|
||||||
|
$uri = \LanguageServer\pathToUri('a/b/c/test.txt');
|
||||||
|
$this->assertEquals('file:///a/b/c/test.txt', $uri);
|
||||||
|
|
||||||
|
$uri = \LanguageServer\pathToUri('/d/e/f');
|
||||||
|
$this->assertEquals('file:///d/e/f', $uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBackslashesAreTransformed()
|
||||||
|
{
|
||||||
|
$uri = \LanguageServer\pathToUri('c:\\foo\\bar.baz');
|
||||||
|
$this->assertEquals('file:///c%3A/foo/bar.baz', $uri);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace LanguageServer\Tests\Utils;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class RecursiveFileSearchTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testFilesAreFound()
|
||||||
|
{
|
||||||
|
$path = realpath(__DIR__ . '/../../fixtures/recursive');
|
||||||
|
$files = \LanguageServer\findFilesRecursive($path, '/.+\.txt/');
|
||||||
|
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue