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