From e40336bd5d576b740b71d6630db0b91d9f676165 Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Thu, 8 Dec 2016 01:22:41 +0100 Subject: [PATCH] WIP --- src/LanguageServer.php | 91 +++++++++++--------- src/PhpDocument.php | 12 +-- src/Project.php | 188 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 233 insertions(+), 58 deletions(-) diff --git a/src/LanguageServer.php b/src/LanguageServer.php index 43558ba..65e109a 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -119,38 +119,55 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher */ public function initialize(ClientCapabilities $capabilities, string $rootPath = null, int $processId = null): InitializeResult { - $this->rootPath = $rootPath; - $this->clientCapabilities = $capabilities; - $this->project = new Project($this->client, $capabilities); - $this->textDocument = new Server\TextDocument($this->project, $this->client); - $this->workspace = new Server\Workspace($this->project, $this->client); + return coroutine(function () use ($capabilities, $rootPath, $processId) { + $this->rootPath = $rootPath; + $this->clientCapabilities = $capabilities; - // start building project index - if ($rootPath !== null) { - $this->indexProject()->otherwise('\\LanguageServer\\crash'); - } + // start building project index + if ($rootPath !== null) { + $pattern = Path::makeAbsolute('**/{*.php,composer.lock}', $this->rootPath); + $uris = yield $this->findFiles($pattern); - $serverCapabilities = new ServerCapabilities(); - // Ask the client to return always full documents (because we need to rebuild the AST from scratch) - $serverCapabilities->textDocumentSync = TextDocumentSyncKind::FULL; - // Support "Find all symbols" - $serverCapabilities->documentSymbolProvider = true; - // Support "Find all symbols in workspace" - $serverCapabilities->workspaceSymbolProvider = true; - // Support "Format Code" - $serverCapabilities->documentFormattingProvider = true; - // Support "Go to definition" - $serverCapabilities->definitionProvider = true; - // Support "Find all references" - $serverCapabilities->referencesProvider = true; - // Support "Hover" - $serverCapabilities->hoverProvider = true; - // Support "Completion" - $serverCapabilities->completionProvider = new CompletionOptions; - $serverCapabilities->completionProvider->resolveProvider = false; - $serverCapabilities->completionProvider->triggerCharacters = ['$', '>']; + // Find composer.lock files + $composerLockFiles = []; + $phpFiles = []; + foreach ($uris as $uri) { + if (Glob::match(Uri\parse($uri)['path'], $composerLockPattern)) { + $composerLockFiles[$uri] = json_decode(yield $this->getFileContent($uri)); + } else { + $phpFiles[] = $uri; + } + } - return new InitializeResult($serverCapabilities); + $this->index($phpFiles)->otherwise('\\LanguageServer\\crash'); + } + + $this->project = new Project($this->client, $capabilities, $rootPath); + $this->textDocument = new Server\TextDocument($this->project, $this->client); + $this->workspace = new Server\Workspace($this->project, $this->client); + + $serverCapabilities = new ServerCapabilities(); + // Ask the client to return always full documents (because we need to rebuild the AST from scratch) + $serverCapabilities->textDocumentSync = TextDocumentSyncKind::FULL; + // Support "Find all symbols" + $serverCapabilities->documentSymbolProvider = true; + // Support "Find all symbols in workspace" + $serverCapabilities->workspaceSymbolProvider = true; + // Support "Format Code" + $serverCapabilities->documentFormattingProvider = true; + // Support "Go to definition" + $serverCapabilities->definitionProvider = true; + // Support "Find all references" + $serverCapabilities->referencesProvider = true; + // Support "Hover" + $serverCapabilities->hoverProvider = true; + // Support "Completion" + $serverCapabilities->completionProvider = new CompletionOptions; + $serverCapabilities->completionProvider->resolveProvider = false; + $serverCapabilities->completionProvider->triggerCharacters = ['$', '>']; + + return new InitializeResult($serverCapabilities); + }); } /** @@ -176,25 +193,23 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher } /** - * Parses workspace files, one at a time. + * Will read and parse the passed source files in the project and add them to the appropiate indexes * * @return Promise */ - private function indexProject(): Promise + private function index(array $phpFiles): Promise { return coroutine(function () { - $pattern = Path::makeAbsolute('**/{*.php,composer.lock}', $this->rootPath); - $phpPattern = Path::makeAbsolute('**/*.php', $this->rootPath); - $composerLockPattern = Path::makeAbsolute('**/composer.lock', $this->rootPath); - $uris = yield $this->findFiles($pattern); - $count = count($uris); + + $count = count($phpFiles); $startTime = microtime(true); - foreach ($uris as $i => $uri) { + // Parse PHP files + foreach ($phpFiles as $i => $uri) { // Give LS to the chance to handle requests while indexing yield timeout(); - if (Glob::match()) + $path = Uri\parse($uri); $this->client->window->logMessage( MessageType::LOG, "Parsing file $i/$count: {$uri}" diff --git a/src/PhpDocument.php b/src/PhpDocument.php index 1e1a726..e9263dd 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -104,7 +104,8 @@ class PhpDocument /** * @param string $uri The URI of the document * @param string $content The content of the document - * @param Project $project The Project this document belongs to (to register definitions etc) + * @param Project $project The Project this document belongs to (to load other documents) + * @param Index $index The Index to register definitions etc * @param LanguageClient $client The LanguageClient instance (to report errors etc) * @param Parser $parser The PHPParser instance * @param DocBlockFactory $docBlockFactory The DocBlockFactory instance to parse docblocks @@ -113,6 +114,7 @@ class PhpDocument string $uri, string $content, Project $project, + Index $index, LanguageClient $client, Parser $parser, DocBlockFactory $docBlockFactory, @@ -198,26 +200,26 @@ class PhpDocument // Unregister old definitions if (isset($this->definitions)) { foreach ($this->definitions as $fqn => $definition) { - $this->project->removeDefinition($fqn); + $this->index->removeDefinition($fqn); } } // Register this document on the project for all the symbols defined in it $this->definitions = $definitionCollector->definitions; $this->definitionNodes = $definitionCollector->nodes; foreach ($definitionCollector->definitions as $fqn => $definition) { - $this->project->setDefinition($fqn, $definition); + $this->index->setDefinition($fqn, $definition); } // Unregister old references if (isset($this->referenceNodes)) { foreach ($this->referenceNodes as $fqn => $node) { - $this->project->removeReferenceUri($fqn, $this->uri); + $this->index->removeReferenceUri($fqn, $this->uri); } } // Register this document on the project for references $this->referenceNodes = $referencesCollector->nodes; foreach ($referencesCollector->nodes as $fqn => $nodes) { - $this->project->addReferenceUri($fqn, $this->uri); + $this->index->addReferenceUri($fqn, $this->uri); } $this->stmts = $stmts; diff --git a/src/Project.php b/src/Project.php index b9802d3..983da78 100644 --- a/src/Project.php +++ b/src/Project.php @@ -75,13 +75,31 @@ class Project */ private $clientCapabilities; - public function __construct(LanguageClient $client, ClientCapabilities $clientCapabilities) - { + private $rootPath; + + private $composerLockFiles; + + /** + * @param LanguageClient $client Used for logging and reporting diagnostics + * @param ClientCapabilities $clientCapabilities Used for determining the right content/find strategies + * @param string|null $rootPath Used for finding files in the project + * @param string[] $composerLockFiles An array of URIs of composer.lock files in the project + */ + public function __construct( + LanguageClient $client, + ClientCapabilities $clientCapabilities, + array $composerLockFiles, + string $rootPath = null + ) { $this->client = $client; $this->clientCapabilities = $clientCapabilities; + $this->rootPath = $rootPath; $this->parser = new Parser; $this->docBlockFactory = DocBlockFactory::createInstance(); $this->definitionResolver = new DefinitionResolver($this); + $this->composerLockFiles = $composerLockFiles; + // The index for the project itself + $this->indexes[''] = new Index; } /** @@ -120,20 +138,48 @@ class Project { return coroutine(function () use ($uri) { $limit = 150000; - if ($this->clientCapabilities->xcontentProvider) { - $content = (yield $this->client->textDocument->xcontent(new TextDocumentIdentifier($uri)))->text; - $size = strlen($content); - if ($size > $limit) { - throw new ContentTooLargeException($uri, $size, $limit); - } - } else { - $path = uriToPath($uri); - $size = filesize($path); - if ($size > $limit) { - throw new ContentTooLargeException($uri, $size, $limit); - } - $content = file_get_contents($path); + $content = yield $this->getFileContent($uri); + $size = strlen($content); + if ($size > $limit) { + throw new ContentTooLargeException($uri, $size, $limit); } + + /** The key for the index */ + $key = ''; + + // If the document is part of a dependency + if (preg_match($u['path'], '/vendor\/(\w+\/\w+)/', $matches)) { + if ($this->composerLockFiles === null) { + throw new \Exception('composer.lock files were not read yet'); + } + // Try to find closest composer.lock + $u = Uri\parse($uri); + $packageName = $matches[1]; + do { + $u['path'] = dirname($u['path']); + foreach ($this->composerLockFiles as $lockFileUri => $lockFileContent) { + $lockFileUri = Uri\parse($composerLockFile); + $lockFileUri['path'] = dirname($lockFileUri['path']); + if ($u == $lockFileUri) { + // Found it, find out package version + foreach ($lockFileContent->packages as $package) { + if ($package->name === $packageName) { + $key = $packageName . ':' . $package->version; + break; + } + } + break; + } + } + } while (!empty(trim($u, '/'))); + } + + // If there is no index for the key yet, create one + if (!isset($this->indexes[$key])) { + $this->indexes[$key] = new Index; + } + $index = $this->indexes[$key]; + if (isset($this->documents[$uri])) { $document = $this->documents[$uri]; $document->updateContent($content); @@ -142,6 +188,7 @@ class Project $uri, $content, $this, + $index, $this->client, $this->parser, $this->docBlockFactory, @@ -152,6 +199,25 @@ class Project }); } + /** + * Gets the content of a document depending on the client's capabilities + * + * @param string $uri + * @return Promise + */ + public function getFileContent(string $uri): Promise + { + if ($this->clientCapabilities->xcontentProvider) { + return $this->client->textDocument->xcontent(new TextDocumentIdentifier($uri)) + ->then(function (TextDocumentItem $textDocumentItem) { + return $textDocumentItem->text; + }); + } else { + $path = uriToPath($uri); + return Promise\resolve(file_get_contents($path)); + } + } + /** * Ensures a document is loaded and added to the list of open documents. * @@ -362,4 +428,96 @@ class Project { return isset($this->definitions[$fqn]); } + + /** + * Will read and parse all source files in the project and add them to the appropiate indexes + * + * @return Promise + */ + private function index(): Promise + { + return coroutine(function () { + + $pattern = Path::makeAbsolute('**/{*.php,composer.lock}', $this->rootPath); + $phpPattern = Path::makeAbsolute('**/*.php', $this->rootPath); + $composerLockPattern = Path::makeAbsolute('**/composer.lock', $this->rootPath); + + $uris = yield $this->findFiles($pattern); + $count = count($uris); + + $startTime = microtime(true); + + // Find composer.lock files + $this->composerLockFiles = []; + foreach ($uris as $uri) { + if (Glob::match($path, $composerLockPattern)) { + $this->composerLockFiles[$uri] = json_decode(yield $this->getFileContent($uri)); + } + } + + // Parse PHP files + foreach ($uris as $i => $uri) { + // Give LS to the chance to handle requests while indexing + yield timeout(); + $path = Uri\parse($uri); + if (!Glob::match($path, $phpPattern)) { + continue; + } + $this->client->window->logMessage( + MessageType::LOG, + "Parsing file $i/$count: {$uri}" + ); + try { + yield $this->project->loadDocument($uri); + } catch (ContentTooLargeException $e) { + $this->client->window->logMessage( + MessageType::INFO, + "Ignoring file {$uri} because it exceeds size limit of {$e->limit} bytes ({$e->size})" + ); + } catch (Exception $e) { + $this->client->window->logMessage( + MessageType::ERROR, + "Error parsing file {$uri}: " . (string)$e + ); + } + } + + $duration = (int)(microtime(true) - $startTime); + $mem = (int)(memory_get_usage(true) / (1024 * 1024)); + $this->client->window->logMessage( + MessageType::INFO, + "All $count PHP files parsed in $duration seconds. $mem MiB allocated." + ); + }); + } + + /** + * Returns all PHP files in the workspace. + * If the client does not support workspace/files, it falls back to searching the file system directly. + * + * @param string $pattern + * @return Promise + */ + private function findFiles(string $pattern): Promise + { + return coroutine(function () { + $uris = []; + if ($this->clientCapabilities->xfilesProvider) { + // Use xfiles request + foreach (yield $this->client->workspace->xfiles() as $textDocument) { + $path = Uri\parse($textDocument->uri)['path']; + if (Glob::match($path, $pattern)) { + $uris[] = $textDocument->uri; + } + } + } else { + // Use the file system + foreach (new GlobIterator($pattern) as $path) { + $uris[] = pathToUri($path); + yield timeout(); + } + } + return $uris; + }); + } }