diff --git a/.gitignore b/.gitignore index c018fa6..4791ea2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ vendor/ .phpls/ composer.lock +stubs diff --git a/.travis.yml b/.travis.yml index 0199d52..7fb9c9a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,10 +8,11 @@ services: cache: directories: - - vendor + - $HOME/.composer/cache install: - composer install + - composer run-script parse-stubs script: - vendor/bin/phpcs -n diff --git a/README.md b/README.md index de9167b..9f50476 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ Non-Standard: An empty query will return _all_ symbols found in the workspace. PHP parse errors are reported as errors, parse errors of docblocks are reported as warnings. Errors/Warnings from the `vendor` directory are ignored. +### Stubs for PHP built-ins + +Completion, type resolval etc. will use the standard PHP library and common extensions. + ### What is considered a definition? Globally searchable definitions are: @@ -131,6 +135,11 @@ Simply run and you will get the latest stable release and all dependencies. Running `composer update` will update the server to the latest non-breaking version. +After installing the language server and its dependencies, +you must parse the stubs for standard PHP symbols and save the index for fast initialization. + + composer run-script --working-dir=vendor/felixfbecker/language-server parse-stubs + ## Running Start the language server with @@ -178,6 +187,9 @@ Clone the repository and run composer install to install dependencies. +Then parse the stubs with + + composer run-script parse-stubs Run the tests with diff --git a/composer.json b/composer.json index 224d3d0..588d4a0 100644 --- a/composer.json +++ b/composer.json @@ -22,9 +22,12 @@ "refactor" ], "bin": ["bin/php-language-server.php"], + "scripts": { + "parse-stubs": "LanguageServer\\ComposerScripts::parseStubs" + }, "require": { "php": ">=7.0", - "nikic/php-parser": "dev-master#e52ffc4447e034514339a03b450aab9cd625e37c", + "nikic/php-parser": "^3.0", "phpdocumentor/reflection-docblock": "^3.0", "sabre/event": "^5.0", "felixfbecker/advanced-json-rpc": "^2.0", @@ -32,8 +35,27 @@ "netresearch/jsonmapper": "^1.0", "webmozart/path-util": "^2.3", "webmozart/glob": "^4.1", - "sabre/uri": "^2.0" + "sabre/uri": "^2.0", + "JetBrains/phpstorm-stubs": "dev-master" }, + "repositories": [ + { + "type": "package", + "package": { + "name": "JetBrains/phpstorm-stubs", + "version": "dev-master", + "dist": { + "url": "https://github.com/JetBrains/phpstorm-stubs/archive/master.zip", + "type": "zip" + }, + "source": { + "url": "https://github.com/JetBrains/phpstorm-stubs", + "type": "git", + "reference": "master" + } + } + } + ], "minimum-stability": "dev", "prefer-stable": true, "autoload": { diff --git a/fixtures/references.php b/fixtures/references.php index 2ac1a63..4a34698 100644 --- a/fixtures/references.php +++ b/fixtures/references.php @@ -2,7 +2,7 @@ namespace TestNamespace; -$obj = new TestClass(); +$obj = new TestClass($a, $b, $c); $obj->testMethod(); echo $obj->testProperty; TestClass::staticTestMethod(); diff --git a/src/CompletionProvider.php b/src/CompletionProvider.php index 261bc1d..fb9ebb8 100644 --- a/src/CompletionProvider.php +++ b/src/CompletionProvider.php @@ -4,12 +4,11 @@ declare(strict_types = 1); namespace LanguageServer; use PhpParser\Node; -use phpDocumentor\Reflection\Types; +use LanguageServer\Index\ReadableIndex; use LanguageServer\Protocol\{ TextEdit, Range, Position, - SymbolKind, CompletionList, CompletionItem, CompletionItemKind @@ -99,13 +98,18 @@ class CompletionProvider private $project; /** - * @param DefinitionResolver $definitionResolver - * @param Project $project + * @var ReadableIndex */ - public function __construct(DefinitionResolver $definitionResolver, Project $project) + private $index; + + /** + * @param DefinitionResolver $definitionResolver + * @param ReadableIndex $index + */ + public function __construct(DefinitionResolver $definitionResolver, ReadableIndex $index) { $this->definitionResolver = $definitionResolver; - $this->project = $project; + $this->index = $index; } /** @@ -134,33 +138,28 @@ class CompletionProvider || $node instanceof Node\Expr\StaticPropertyFetch || $node instanceof Node\Expr\ClassConstFetch ) { - if (!is_string($node->name)) { - // If the name is an Error node, just filter by the class - if ($node instanceof Node\Expr\MethodCall || $node instanceof Node\Expr\PropertyFetch) { - // For instances, resolve the variable type - $prefixes = DefinitionResolver::getFqnsFromType( - $this->definitionResolver->resolveExpressionNodeToType($node->var) - ); - } else { - $prefixes = [$node->class instanceof Node\Name ? (string)$node->class : '']; - } - // If we are just filtering by the class, add the appropiate operator to the prefix - // to filter the type of symbol - foreach ($prefixes as &$prefix) { - if ($node instanceof Node\Expr\MethodCall || $node instanceof Node\Expr\PropertyFetch) { - $prefix .= '->'; - } else if ($node instanceof Node\Expr\StaticCall || $node instanceof Node\Expr\ClassConstFetch) { - $prefix .= '::'; - } else if ($node instanceof Node\Expr\StaticPropertyFetch) { - $prefix .= '::$'; - } - } + // If the name is an Error node, just filter by the class + if ($node instanceof Node\Expr\MethodCall || $node instanceof Node\Expr\PropertyFetch) { + // For instances, resolve the variable type + $prefixes = DefinitionResolver::getFqnsFromType( + $this->definitionResolver->resolveExpressionNodeToType($node->var) + ); } else { - $fqn = $this->definitionResolver->resolveReferenceNodeToFqn($node); - $prefixes = $fqn !== null ? [$fqn] : []; + $prefixes = [$node->class instanceof Node\Name ? (string)$node->class : '']; + } + // If we are just filtering by the class, add the appropiate operator to the prefix + // to filter the type of symbol + foreach ($prefixes as &$prefix) { + if ($node instanceof Node\Expr\MethodCall || $node instanceof Node\Expr\PropertyFetch) { + $prefix .= '->'; + } else if ($node instanceof Node\Expr\StaticCall || $node instanceof Node\Expr\ClassConstFetch) { + $prefix .= '::'; + } else if ($node instanceof Node\Expr\StaticPropertyFetch) { + $prefix .= '::$'; + } } - foreach ($this->project->getDefinitions() as $fqn => $def) { + foreach ($this->index->getDefinitions() as $fqn => $def) { foreach ($prefixes as $prefix) { if (substr($fqn, 0, strlen($prefix)) === $prefix && !$def->isGlobal) { $list->items[] = CompletionItem::fromDefinition($def); @@ -192,7 +191,9 @@ class CompletionProvider // Get the definition for the used namespace, class-like, function or constant // And save it under the alias $fqn = (string)Node\Name::concat($stmt->prefix ?? null, $use->name); - $aliasedDefs[$use->alias] = $this->project->getDefinition($fqn); + if ($def = $this->index->getDefinition($fqn)) { + $aliasedDefs[$use->alias] = $def; + } } } else { // Use statements are always the first statements in a namespace @@ -213,7 +214,7 @@ class CompletionProvider // Additionally, suggest global symbols that either // - start with the current namespace + prefix, if the Name node is not fully qualified // - start with just the prefix, if the Name node is fully qualified - foreach ($this->project->getDefinitions() as $fqn => $def) { + foreach ($this->index->getDefinitions() as $fqn => $def) { if ( $def->isGlobal // exclude methods, properties etc. && ( @@ -333,7 +334,7 @@ class CompletionProvider } if ($level instanceof Node\Expr\Closure) { foreach ($level->uses as $use) { - if (!isset($vars[$param->name]) && substr($param->name, 0, strlen($namePrefix)) === $namePrefix) { + if (!isset($vars[$use->var]) && substr($use->var, 0, strlen($namePrefix)) === $namePrefix) { $vars[$use->var] = $use; } } diff --git a/src/ComposerScripts.php b/src/ComposerScripts.php new file mode 100644 index 0000000..67060bc --- /dev/null +++ b/src/ComposerScripts.php @@ -0,0 +1,57 @@ +find("$stubsLocation/**/*.php"); + + foreach ($uris as $uri) { + echo "Parsing $uri\n"; + $content = yield $contentRetriever->retrieve($uri); + + // Change URI to phpstubs:// + $parts = Uri\parse($uri); + $parts['path'] = Path::makeRelative($parts['path'], $stubsLocation); + $parts['scheme'] = 'phpstubs'; + $uri = Uri\build($parts); + + $document = new PhpDocument($uri, $content, $index, $parser, $docBlockFactory, $definitionResolver); + } + + echo "Saving Index\n"; + + $index->save(); + + echo "Finished\n"; + })->wait(); + } +} diff --git a/src/ContentRetriever/ClientContentRetriever.php b/src/ContentRetriever/ClientContentRetriever.php new file mode 100644 index 0000000..b88042c --- /dev/null +++ b/src/ContentRetriever/ClientContentRetriever.php @@ -0,0 +1,36 @@ +client = $client; + } + + /** + * Retrieves the content of a text document identified by the URI through a textDocument/xcontent request + * + * @param string $uri The URI of the document + * @return Promise Resolved with the content as a string + */ + public function retrieve(string $uri): Promise + { + return $this->client->textDocument->xcontent(new TextDocumentIdentifier($uri)) + ->then(function (TextDocumentItem $textDocument) { + return $textDocument->text; + }); + } +} diff --git a/src/ContentRetriever/ContentRetriever.php b/src/ContentRetriever/ContentRetriever.php new file mode 100644 index 0000000..4d16b98 --- /dev/null +++ b/src/ContentRetriever/ContentRetriever.php @@ -0,0 +1,20 @@ + Resolved with the content as a string + */ + public function retrieve(string $uri): Promise; +} diff --git a/src/ContentRetriever/FileSystemContentRetriever.php b/src/ContentRetriever/FileSystemContentRetriever.php new file mode 100644 index 0000000..82e7002 --- /dev/null +++ b/src/ContentRetriever/FileSystemContentRetriever.php @@ -0,0 +1,24 @@ + Resolved with the content as a string + */ + public function retrieve(string $uri): Promise + { + return Promise\resolve(file_get_contents(uriToPath($uri))); + } +} diff --git a/src/DefinitionResolver.php b/src/DefinitionResolver.php index 87c5aa0..e2b7212 100644 --- a/src/DefinitionResolver.php +++ b/src/DefinitionResolver.php @@ -7,15 +7,14 @@ use PhpParser\Node; use PhpParser\PrettyPrinter\Standard as PrettyPrinter; use phpDocumentor\Reflection\{Types, Type, Fqsen, TypeResolver}; use LanguageServer\Protocol\SymbolInformation; -use Sabre\Event\Promise; -use function Sabre\Event\coroutine; +use LanguageServer\Index\ReadableIndex; class DefinitionResolver { /** - * @var \LanguageServer\Project + * @var \LanguageServer\Index */ - private $project; + private $index; /** * @var \phpDocumentor\Reflection\TypeResolver @@ -27,9 +26,12 @@ class DefinitionResolver */ private $prettyPrinter; - public function __construct(Project $project) + /** + * @param ReadableIndex $index + */ + public function __construct(ReadableIndex $index) { - $this->project = $project; + $this->index = $index; $this->typeResolver = new TypeResolver; $this->prettyPrinter = new PrettyPrinter; } @@ -147,8 +149,8 @@ class DefinitionResolver // http://php.net/manual/en/language.namespaces.fallback.php $parent = $node->getAttribute('parentNode'); $globalFallback = $parent instanceof Node\Expr\ConstFetch || $parent instanceof Node\Expr\FuncCall; - // Return the Definition object from the project index - return $this->project->getDefinition($fqn, $globalFallback); + // Return the Definition object from the index index + return $this->index->getDefinition($fqn, $globalFallback); } /** @@ -193,6 +195,7 @@ class DefinitionResolver || $parent instanceof Node\Namespace_ || $parent instanceof Node\Param || $parent instanceof Node\FunctionLike + || $parent instanceof Node\Expr\New_ || $parent instanceof Node\Expr\StaticCall || $parent instanceof Node\Expr\ClassConstFetch || $parent instanceof Node\Expr\StaticPropertyFetch @@ -210,13 +213,6 @@ class DefinitionResolver } else if ($grandParent instanceof Node\Stmt\Use_ && $grandParent->type === Node\Stmt\Use_::TYPE_FUNCTION) { $name .= '()'; } - // Only the name node should be considered a reference, not the New_ node itself - } else if ($parent instanceof Node\Expr\New_) { - if (!($parent->class instanceof Node\Name)) { - // Cannot get definition of dynamic calls - return null; - } - $name = (string)$parent->class; } else if ($node instanceof Node\Expr\MethodCall || $node instanceof Node\Expr\PropertyFetch) { if ($node->name instanceof Node\Expr) { // Cannot get definition if right-hand side is expression @@ -403,7 +399,7 @@ class DefinitionResolver return new Types\Mixed; } $fqn = (string)($expr->getAttribute('namespacedName') ?? $expr->name); - $def = $this->project->getDefinition($fqn, true); + $def = $this->index->getDefinition($fqn, true); if ($def !== null) { return $def->type; } @@ -414,7 +410,7 @@ class DefinitionResolver } // Resolve constant $fqn = (string)($expr->getAttribute('namespacedName') ?? $expr->name); - $def = $this->project->getDefinition($fqn, true); + $def = $this->index->getDefinition($fqn, true); if ($def !== null) { return $def->type; } @@ -443,7 +439,7 @@ class DefinitionResolver if ($expr instanceof Node\Expr\MethodCall) { $fqn .= '()'; } - $def = $this->project->getDefinition($fqn); + $def = $this->index->getDefinition($fqn); if ($def !== null) { return $def->type; } @@ -466,7 +462,7 @@ class DefinitionResolver if ($expr instanceof Node\Expr\StaticCall) { $fqn .= '()'; } - $def = $this->project->getDefinition($fqn); + $def = $this->index->getDefinition($fqn); if ($def === null) { return new Types\Mixed; } diff --git a/src/FilesFinder/ClientFilesFinder.php b/src/FilesFinder/ClientFilesFinder.php new file mode 100644 index 0000000..4315ede --- /dev/null +++ b/src/FilesFinder/ClientFilesFinder.php @@ -0,0 +1,49 @@ +client = $client; + } + + /** + * Returns all files in the workspace that match a glob. + * If the client does not support workspace/files, it falls back to searching the file system directly. + * + * @param string $glob + * @return Promise The URIs + */ + public function find(string $glob): Promise + { + return $this->client->workspace->xfiles()->then(function (array $textDocuments) use ($glob) { + $uris = []; + foreach ($textDocuments as $textDocument) { + $path = Uri\parse($textDocument->uri)['path']; + if (Glob::match($path, $glob)) { + $uris[] = $textDocument->uri; + } + } + return $uris; + }); + } +} diff --git a/src/FilesFinder/FileSystemFilesFinder.php b/src/FilesFinder/FileSystemFilesFinder.php new file mode 100644 index 0000000..52df4b6 --- /dev/null +++ b/src/FilesFinder/FileSystemFilesFinder.php @@ -0,0 +1,31 @@ + + */ + public function find(string $glob): Promise + { + return coroutine(function () use ($glob) { + $uris = []; + foreach (new GlobIterator($glob) as $path) { + $uris[] = pathToUri($path); + yield timeout(); + } + return $uris; + }); + } +} diff --git a/src/FilesFinder/FilesFinder.php b/src/FilesFinder/FilesFinder.php new file mode 100644 index 0000000..81d6de5 --- /dev/null +++ b/src/FilesFinder/FilesFinder.php @@ -0,0 +1,21 @@ + + */ + public function find(string $glob): Promise; +} diff --git a/src/Index/AbstractAggregateIndex.php b/src/Index/AbstractAggregateIndex.php new file mode 100644 index 0000000..f8934c6 --- /dev/null +++ b/src/Index/AbstractAggregateIndex.php @@ -0,0 +1,66 @@ + Definition] that maps fully qualified symbol names + * to Definitions + * + * @return Definition[] + */ + public function getDefinitions(): array + { + $defs = []; + foreach ($this->getIndexes() as $index) { + foreach ($index->getDefinitions() as $fqn => $def) { + $defs[$fqn] = $def; + } + } + return $defs; + } + + /** + * Returns the Definition object by a specific FQN + * + * @param string $fqn + * @param bool $globalFallback Whether to fallback to global if the namespaced FQN was not found + * @return Definition|null + */ + public function getDefinition(string $fqn, bool $globalFallback = false) + { + foreach ($this->getIndexes() as $index) { + if ($def = $index->getDefinition($fqn, $globalFallback)) { + return $def; + } + } + } + + /** + * Returns all URIs in this index that reference a symbol + * + * @param string $fqn The fully qualified name of the symbol + * @return string[] + */ + public function getReferenceUris(string $fqn): array + { + $refs = []; + foreach ($this->getIndexes() as $index) { + foreach ($index->getReferenceUris($fqn) as $ref) { + $refs[] = $ref; + } + } + return $refs; + } +} diff --git a/src/Index/DependenciesIndex.php b/src/Index/DependenciesIndex.php new file mode 100644 index 0000000..a355821 --- /dev/null +++ b/src/Index/DependenciesIndex.php @@ -0,0 +1,52 @@ +indexes; + } + + /** + * @param string $packageName + * @return Index + */ + public function getDependencyIndex(string $packageName): Index + { + if (!isset($this->indexes[$packageName])) { + $this->indexes[$packageName] = new Index; + } + return $this->indexes[$packageName]; + } + + /** + * @param string $packageName + * @return void + */ + public function removeDependencyIndex(string $packageName) + { + unset($this->indexes[$packageName]); + } + + /** + * @param string $packageName + * @return bool + */ + public function hasDependencyIndex(string $packageName): bool + { + return isset($this->indexes[$packageName]); + } +} diff --git a/src/Index/GlobalIndex.php b/src/Index/GlobalIndex.php new file mode 100644 index 0000000..e1e6d48 --- /dev/null +++ b/src/Index/GlobalIndex.php @@ -0,0 +1,38 @@ +stubsIndex = $stubsIndex; + $this->projectIndex = $projectIndex; + } + + /** + * @return ReadableIndex[] + */ + protected function getIndexes(): array + { + return [$this->stubsIndex, $this->projectIndex]; + } +} diff --git a/src/Index/Index.php b/src/Index/Index.php new file mode 100644 index 0000000..29cbb99 --- /dev/null +++ b/src/Index/Index.php @@ -0,0 +1,129 @@ + Definition] that maps fully qualified symbol names + * to Definitions + * + * @return Definition[] + */ + public function getDefinitions(): array + { + return $this->definitions; + } + + /** + * Returns the Definition object by a specific FQN + * + * @param string $fqn + * @param bool $globalFallback Whether to fallback to global if the namespaced FQN was not found + * @return Definition|null + */ + public function getDefinition(string $fqn, bool $globalFallback = false) + { + if (isset($this->definitions[$fqn])) { + return $this->definitions[$fqn]; + } + if ($globalFallback) { + $parts = explode('\\', $fqn); + $fqn = end($parts); + return $this->getDefinition($fqn); + } + } + + /** + * Registers a definition + * + * @param string $fqn The fully qualified name of the symbol + * @param string $definition The Definition object + * @return void + */ + public function setDefinition(string $fqn, Definition $definition) + { + $this->definitions[$fqn] = $definition; + } + + /** + * Unsets the Definition for a specific symbol + * and removes all references pointing to that symbol + * + * @param string $fqn The fully qualified name of the symbol + * @return void + */ + public function removeDefinition(string $fqn) + { + unset($this->definitions[$fqn]); + unset($this->references[$fqn]); + } + + /** + * Returns all URIs in this index that reference a symbol + * + * @param string $fqn The fully qualified name of the symbol + * @return string[] + */ + public function getReferenceUris(string $fqn): array + { + return $this->references[$fqn] ?? []; + } + + /** + * Adds a document URI as a referencee of a specific symbol + * + * @param string $fqn The fully qualified name of the symbol + * @return void + */ + public function addReferenceUri(string $fqn, string $uri) + { + if (!isset($this->references[$fqn])) { + $this->references[$fqn] = []; + } + // TODO: use DS\Set instead of searching array + if (array_search($uri, $this->references[$fqn], true) === false) { + $this->references[$fqn][] = $uri; + } + } + + /** + * Removes a document URI as the container for a specific symbol + * + * @param string $fqn The fully qualified name of the symbol + * @param string $uri The URI + * @return void + */ + public function removeReferenceUri(string $fqn, string $uri) + { + if (!isset($this->references[$fqn])) { + return; + } + $index = array_search($fqn, $this->references[$fqn], true); + if ($index === false) { + return; + } + array_splice($this->references[$fqn], $index, 1); + } +} diff --git a/src/Index/ProjectIndex.php b/src/Index/ProjectIndex.php new file mode 100644 index 0000000..8b42f8f --- /dev/null +++ b/src/Index/ProjectIndex.php @@ -0,0 +1,51 @@ +sourceIndex = $sourceIndex; + $this->dependenciesIndex = $dependenciesIndex; + } + + /** + * @return ReadableIndex[] + */ + protected function getIndexes(): array + { + return [$this->sourceIndex, $this->dependenciesIndex]; + } + + /** + * @param string $uri + * @return Index + */ + public function getIndexForUri(string $uri): Index + { + if (preg_match('/\/vendor\/(\w+\/\w+)\//', $uri, $matches)) { + $packageName = $matches[0]; + return $this->dependenciesIndex->getDependencyIndex($packageName); + } + return $this->sourceIndex; + } +} diff --git a/src/Index/ReadableIndex.php b/src/Index/ReadableIndex.php new file mode 100644 index 0000000..40f4e40 --- /dev/null +++ b/src/Index/ReadableIndex.php @@ -0,0 +1,37 @@ + Definition] that maps fully qualified symbol names + * to Definitions + * + * @return Definitions[] + */ + public function getDefinitions(): array; + + /** + * Returns the Definition object by a specific FQN + * + * @param string $fqn + * @param bool $globalFallback Whether to fallback to global if the namespaced FQN was not found + * @return Definition|null + */ + public function getDefinition(string $fqn, bool $globalFallback = false); + + /** + * Returns all URIs in this index that reference a symbol + * + * @param string $fqn The fully qualified name of the symbol + * @return string[] + */ + public function getReferenceUris(string $fqn): array; +} diff --git a/src/Index/StubsIndex.php b/src/Index/StubsIndex.php new file mode 100644 index 0000000..3828f31 --- /dev/null +++ b/src/Index/StubsIndex.php @@ -0,0 +1,27 @@ +shutdown(); $this->exit(); }); - $this->protocolReader->on('message', function (Message $msg) { - coroutine(function () use ($msg) { - // Ignore responses, this is the handler for requests and notifications - if (AdvancedJsonRpc\Response::isResponse($msg->body)) { - return; - } - $result = null; - $error = null; - try { - // Invoke the method handler to get a result - $result = yield $this->dispatch($msg->body); - } catch (AdvancedJsonRpc\Error $e) { - // If a ResponseError is thrown, send it back in the Response - $error = $e; - } catch (Throwable $e) { - // If an unexpected error occured, send back an INTERNAL_ERROR error response - $error = new AdvancedJsonRpc\Error( - $e->getMessage(), - AdvancedJsonRpc\ErrorCode::INTERNAL_ERROR, - null, - $e - ); - } - // Only send a Response for a Request - // Notifications do not send Responses - if (AdvancedJsonRpc\Request::isRequest($msg->body)) { - if ($error !== null) { - $responseBody = new AdvancedJsonRpc\ErrorResponse($msg->body->id, $error); - } else { - $responseBody = new AdvancedJsonRpc\SuccessResponse($msg->body->id, $result); + $this->protocolReader->on('message', function (Message $msg) { + coroutine(function () use ($msg) { + // Ignore responses, this is the handler for requests and notifications + if (AdvancedJsonRpc\Response::isResponse($msg->body)) { + return; } - $this->protocolWriter->write(new Message($responseBody)); - } - })->otherwise('\\LanguageServer\\crash'); - }); - $this->protocolWriter = $writer; - $this->client = new LanguageClient($reader, $writer); + $result = null; + $error = null; + try { + // Invoke the method handler to get a result + $result = yield $this->dispatch($msg->body); + } catch (AdvancedJsonRpc\Error $e) { + // If a ResponseError is thrown, send it back in the Response + $error = $e; + } catch (Throwable $e) { + // If an unexpected error occured, send back an INTERNAL_ERROR error response + $error = new AdvancedJsonRpc\Error( + (string)$e, + AdvancedJsonRpc\ErrorCode::INTERNAL_ERROR, + null, + $e + ); + } + // Only send a Response for a Request + // Notifications do not send Responses + if (AdvancedJsonRpc\Request::isRequest($msg->body)) { + if ($error !== null) { + $responseBody = new AdvancedJsonRpc\ErrorResponse($msg->body->id, $error); + } else { + $responseBody = new AdvancedJsonRpc\SuccessResponse($msg->body->id, $result); + } + $this->protocolWriter->write(new Message($responseBody)); + } + })->otherwise('\\LanguageServer\\crash'); + }); + $this->protocolWriter = $writer; + $this->client = new LanguageClient($reader, $writer); } /** @@ -115,48 +131,80 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher * @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. * @param int|null $processId The process Id of the parent process that started the server. Is null if the process has not been started by another process. If the parent process is not alive then the server should exit (see exit notification) its process. - * @return InitializeResult + * @return Promise */ - public function initialize(ClientCapabilities $capabilities, string $rootPath = null, int $processId = null): InitializeResult + public function initialize(ClientCapabilities $capabilities, string $rootPath = null, int $processId = null): Promise { - $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) { - // start building project index - if ($rootPath !== null) { - $this->indexProject()->otherwise('\\LanguageServer\\crash'); - } + if ($capabilities->xfilesProvider) { + $this->filesFinder = new ClientFilesFinder($this->client); + } else { + $this->filesFinder = new FileSystemFilesFinder; + } - if (extension_loaded('xdebug')) { - setTimeout(function () { - $this->client->window->showMessage(MessageType::WARNING, 'You are running PHP Language Server with xdebug enabled. This has a major impact on server performance.'); - }, 1); - } + if ($capabilities->xcontentProvider) { + $this->contentRetriever = new ClientContentRetriever($this->client); + } else { + $this->contentRetriever = new FileSystemContentRetriever; + } - $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 = ['$', '>']; + $projectIndex = new ProjectIndex(new Index, new DependenciesIndex); + $stubsIndex = StubsIndex::read(); + $globalIndex = new GlobalIndex($stubsIndex, $projectIndex); - return new InitializeResult($serverCapabilities); + // The DefinitionResolver should look in stubs, the project source and dependencies + $definitionResolver = new DefinitionResolver($globalIndex); + + $this->documentLoader = new PhpDocumentLoader( + $this->contentRetriever, + $projectIndex, + $definitionResolver + ); + + if ($rootPath !== null) { + $pattern = Path::makeAbsolute('**/*.php', $rootPath); + $uris = yield $this->filesFinder->find($pattern); + $this->index($uris)->otherwise('\\LanguageServer\\crash'); + } + + $this->textDocument = new Server\TextDocument( + $this->documentLoader, + $definitionResolver, + $this->client, + $globalIndex + ); + // workspace/symbol should only look inside the project source and dependencies + $this->workspace = new Server\Workspace($projectIndex, $this->client); + + if (extension_loaded('xdebug')) { + setTimeout(function () { + $this->client->window->showMessage(MessageType::WARNING, 'You are running PHP Language Server with xdebug enabled. This has a major impact on server performance.'); + }, 1); + } + + $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); + }); } /** @@ -182,76 +230,55 @@ 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 $uris): Promise { - return coroutine(function () { - $textDocuments = yield $this->findPhpFiles(); - $count = count($textDocuments); + return coroutine(function () use ($uris) { + + $count = count($uris); $startTime = microtime(true); - foreach ($textDocuments as $i => $textDocument) { - // Give LS to the chance to handle requests while indexing - yield timeout(); - $this->client->window->logMessage( - MessageType::LOG, - "Parsing file $i/$count: {$textDocument->uri}" - ); - try { - yield $this->project->loadDocument($textDocument->uri); - } catch (ContentTooLargeException $e) { - $this->client->window->logMessage( - MessageType::INFO, - "Ignoring file {$textDocument->uri} because it exceeds size limit of {$e->limit} bytes ({$e->size})" - ); - } catch (Exception $e) { - $this->client->window->logMessage( - MessageType::ERROR, - "Error parsing file {$textDocument->uri}: " . (string)$e - ); - } - } + foreach (['Collecting definitions and static references', 'Collecting dynamic references'] as $run) { + $this->client->window->logMessage(MessageType::INFO, $run); + foreach ($uris as $i => $uri) { + if ($this->documentLoader->isOpen($uri)) { + continue; + } - $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. - * - * @return Promise - */ - private function findPhpFiles(): Promise - { - return coroutine(function () { - $textDocuments = []; - $pattern = Path::makeAbsolute('**/*.php', $this->rootPath); - 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)) { - $textDocuments[] = $textDocument; + // Give LS to the chance to handle requests while indexing + yield timeout(); + $this->client->window->logMessage( + MessageType::LOG, + "Parsing file $i/$count: {$uri}" + ); + try { + $document = yield $this->documentLoader->load($uri); + if (!$document->isVendored()) { + $this->client->textDocument->publishDiagnostics($uri, $document->getDiagnostics()); + } + } 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 + ); } } - } else { - // Use the file system - foreach (new GlobIterator($pattern) as $path) { - $textDocuments[] = new TextDocumentIdentifier(pathToUri($path)); - yield timeout(); - } + $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." + ); } - return $textDocuments; }); } } diff --git a/src/NodeVisitor/ReferencesCollector.php b/src/NodeVisitor/ReferencesCollector.php index 7e35beb..ae3027e 100644 --- a/src/NodeVisitor/ReferencesCollector.php +++ b/src/NodeVisitor/ReferencesCollector.php @@ -19,6 +19,11 @@ class ReferencesCollector extends NodeVisitorAbstract */ public $nodes = []; + /** + * @var DefinitionResolver + */ + private $definitionResolver; + /** * @param DefinitionResolver $definitionResolver The DefinitionResolver to resolve reference nodes to definitions */ diff --git a/src/PhpDocument.php b/src/PhpDocument.php index 1e1a726..76009a9 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -10,34 +10,16 @@ use LanguageServer\NodeVisitor\{ DocBlockParser, DefinitionCollector, ColumnCalculator, - ReferencesCollector, - VariableReferencesCollector + ReferencesCollector }; +use LanguageServer\Index\Index; use PhpParser\{Error, ErrorHandler, Node, NodeTraverser}; use PhpParser\NodeVisitor\NameResolver; use phpDocumentor\Reflection\DocBlockFactory; -use Sabre\Event\Promise; -use function Sabre\Event\coroutine; use Sabre\Uri; class PhpDocument { - /** - * The LanguageClient instance (to report errors etc) - * - * @var LanguageClient - */ - private $client; - - /** - * The Project this document belongs to (to register definitions etc) - * - * @var Project - */ - public $project; - // for whatever reason I get "cannot access private property" error if $project is not public - // https://github.com/felixfbecker/php-language-server/pull/49#issuecomment-252427359 - /** * The PHPParser instance * @@ -59,6 +41,11 @@ class PhpDocument */ private $definitionResolver; + /** + * @var Index + */ + private $index; + /** * The URI of the document * @@ -102,25 +89,30 @@ class PhpDocument private $referenceNodes; /** - * @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 LanguageClient $client The LanguageClient instance (to report errors etc) - * @param Parser $parser The PHPParser instance - * @param DocBlockFactory $docBlockFactory The DocBlockFactory instance to parse docblocks + * Diagnostics for this document that were collected while parsing + * + * @var Diagnostic[] + */ + private $diagnostics; + + /** + * @param string $uri The URI of the document + * @param string $content The content of the document + * @param Index $index The Index to register definitions and references to + * @param Parser $parser The PHPParser instance + * @param DocBlockFactory $docBlockFactory The DocBlockFactory instance to parse docblocks + * @param DefinitionResolver $definitionResolver The DefinitionResolver to resolve definitions to symbols in the workspace */ public function __construct( string $uri, string $content, - Project $project, - LanguageClient $client, + Index $index, Parser $parser, DocBlockFactory $docBlockFactory, DefinitionResolver $definitionResolver ) { $this->uri = $uri; - $this->project = $project; - $this->client = $client; + $this->index = $index; $this->parser = $parser; $this->docBlockFactory = $docBlockFactory; $this->definitionResolver = $definitionResolver; @@ -154,9 +146,9 @@ class PhpDocument $errorHandler = new ErrorHandler\Collecting; $stmts = $this->parser->parse($content, $errorHandler); - $diagnostics = []; + $this->diagnostics = []; foreach ($errorHandler->getErrors() as $error) { - $diagnostics[] = Diagnostic::fromError($error, $this->content, DiagnosticSeverity::ERROR, 'php'); + $this->diagnostics[] = Diagnostic::fromError($error, $this->content, DiagnosticSeverity::ERROR, 'php'); } // $stmts can be null in case of a fatal parsing error @@ -180,7 +172,7 @@ class PhpDocument // Report errors from parsing docblocks foreach ($docBlockParser->errors as $error) { - $diagnostics[] = Diagnostic::fromError($error, $this->content, DiagnosticSeverity::WARNING, 'php'); + $this->diagnostics[] = Diagnostic::fromError($error, $this->content, DiagnosticSeverity::WARNING, 'php'); } $traverser = new NodeTraverser; @@ -198,34 +190,30 @@ 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; } - - if (!$this->isVendored()) { - $this->client->textDocument->publishDiagnostics($this->uri, $diagnostics); - } } /** @@ -262,6 +250,16 @@ class PhpDocument return $this->content; } + /** + * Returns this document's diagnostics + * + * @return Diagnostic[] + */ + public function getDiagnostics() + { + return $this->diagnostics; + } + /** * Returns the URI of the document * @@ -357,57 +355,4 @@ class PhpDocument { return isset($this->definitions[$fqn]); } - - /** - * Returns the reference nodes for any node - * The references node MAY be in other documents, check the ownerDocument attribute - * - * @param Node $node - * @return Promise - */ - public function getReferenceNodesByNode(Node $node): Promise - { - return coroutine(function () use ($node) { - // Variables always stay in the boundary of the file and need to be searched inside their function scope - // by traversing the AST - if ( - $node instanceof Node\Expr\Variable - || $node instanceof Node\Param - || $node instanceof Node\Expr\ClosureUse - ) { - if ($node->name instanceof Node\Expr) { - return null; - } - // Find function/method/closure scope - $n = $node; - while (isset($n) && !($n instanceof Node\FunctionLike)) { - $n = $n->getAttribute('parentNode'); - } - if (!isset($n)) { - $n = $node->getAttribute('ownerDocument'); - } - $traverser = new NodeTraverser; - $refCollector = new VariableReferencesCollector($node->name); - $traverser->addVisitor($refCollector); - $traverser->traverse($n->getStmts()); - return $refCollector->nodes; - } - // Definition with a global FQN - $fqn = DefinitionResolver::getDefinedFqn($node); - if ($fqn === null) { - return []; - } - $refDocuments = yield $this->project->getReferenceDocuments($fqn); - $nodes = []; - foreach ($refDocuments as $document) { - $refs = $document->getReferenceNodesByFqn($fqn); - if ($refs !== null) { - foreach ($refs as $ref) { - $nodes[] = $ref; - } - } - } - return $nodes; - }); - } } diff --git a/src/PhpDocumentLoader.php b/src/PhpDocumentLoader.php new file mode 100644 index 0000000..b6c04cb --- /dev/null +++ b/src/PhpDocumentLoader.php @@ -0,0 +1,178 @@ + PhpDocument of open documents that should be kept in memory + * + * @var PhpDocument + */ + private $documents = []; + + /** + * @var ContentRetriever + */ + private $contentRetriever; + + /** + * @var ProjectIndex + */ + private $projectIndex; + + /** + * @var Parser + */ + private $parser; + + /** + * @var DocBlockFactory + */ + private $docBlockFactory; + + /** + * @var DefinitionResolver + */ + private $definitionResolver; + + /** + * @param ContentRetriever $contentRetriever + * @param ProjectIndex $project + * @param DefinitionResolver $definitionResolver + */ + public function __construct( + ContentRetriever $contentRetriever, + ProjectIndex $projectIndex, + DefinitionResolver $definitionResolver + ) { + $this->contentRetriever = $contentRetriever; + $this->projectIndex = $projectIndex; + $this->definitionResolver = $definitionResolver; + $this->parser = new Parser; + $this->docBlockFactory = DocBlockFactory::createInstance(); + } + + /** + * Returns the document indicated by uri. + * Returns null if the document if not loaded. + * + * @param string $uri + * @return PhpDocument|null + */ + public function get(string $uri) + { + return $this->documents[$uri] ?? null; + } + + /** + * Returns the document indicated by uri. + * If the document is not open, loads it. + * + * @param string $uri + * @return Promise + */ + public function getOrLoad(string $uri): Promise + { + return isset($this->documents[$uri]) ? Promise\resolve($this->documents[$uri]) : $this->load($uri); + } + + /** + * Loads the document by doing a textDocument/xcontent request to the client. + * If the client does not support textDocument/xcontent, tries to read the file from the file system. + * The document is NOT added to the list of open documents, but definitions are registered. + * + * @param string $uri + * @return Promise + */ + public function load(string $uri): Promise + { + return coroutine(function () use ($uri) { + + $limit = 150000; + $content = yield $this->contentRetriever->retrieve($uri); + $size = strlen($content); + if ($size > $limit) { + throw new ContentTooLargeException($uri, $size, $limit); + } + + if (isset($this->documents[$uri])) { + $document = $this->documents[$uri]; + $document->updateContent($content); + } else { + $document = $this->create($uri, $content); + } + return $document; + }); + } + + /** + * Builds a PhpDocument instance + * + * @param string $uri + * @param string $content + * @return PhpDocument + */ + public function create(string $uri, string $content): PhpDocument + { + return new PhpDocument( + $uri, + $content, + $this->projectIndex->getIndexForUri($uri), + $this->parser, + $this->docBlockFactory, + $this->definitionResolver + ); + } + + /** + * Ensures a document is loaded and added to the list of open documents. + * + * @param string $uri + * @param string $content + * @return void + */ + public function open(string $uri, string $content) + { + if (isset($this->documents[$uri])) { + $document = $this->documents[$uri]; + $document->updateContent($content); + } else { + $document = $this->create($uri, $content); + $this->documents[$uri] = $document; + } + return $document; + } + + /** + * Removes the document with the specified URI from the list of open documents + * + * @param string $uri + * @return void + */ + public function close(string $uri) + { + unset($this->documents[$uri]); + } + + /** + * Returns true if the document is open (and loaded) + * + * @param string $uri + * @return bool + */ + public function isOpen(string $uri): bool + { + return isset($this->documents[$uri]); + } +} diff --git a/src/Project.php b/src/Project.php deleted file mode 100644 index 3021a1c..0000000 --- a/src/Project.php +++ /dev/null @@ -1,357 +0,0 @@ - PhpDocument] - * that maps URIs to loaded PhpDocuments - * - * @var PhpDocument[] - */ - private $documents = []; - - /** - * An associative array that maps fully qualified symbol names to Definitions - * - * @var Definition[] - */ - private $definitions = []; - - /** - * An associative array that maps fully qualified symbol names to arrays of document URIs that reference the symbol - * - * @var PhpDocument[][] - */ - private $references = []; - - /** - * Instance of the PHP parser - * - * @var Parser - */ - private $parser; - - /** - * The DocBlockFactory instance to parse docblocks - * - * @var DocBlockFactory - */ - private $docBlockFactory; - - /** - * The DefinitionResolver instance to resolve reference nodes to Definitions - * - * @var DefinitionResolver - */ - private $definitionResolver; - - /** - * Reference to the language server client interface - * - * @var LanguageClient - */ - private $client; - - /** - * The client's capabilities - * - * @var ClientCapabilities - */ - private $clientCapabilities; - - public function __construct(LanguageClient $client, ClientCapabilities $clientCapabilities) - { - $this->client = $client; - $this->clientCapabilities = $clientCapabilities; - $this->parser = new Parser; - $this->docBlockFactory = DocBlockFactory::createInstance(); - $this->definitionResolver = new DefinitionResolver($this); - } - - /** - * Returns the document indicated by uri. - * Returns null if the document if not loaded. - * - * @param string $uri - * @return PhpDocument|null - */ - public function getDocument(string $uri) - { - return $this->documents[$uri] ?? null; - } - - /** - * Returns the document indicated by uri. - * If the document is not open, loads it. - * - * @param string $uri - * @return Promise - */ - public function getOrLoadDocument(string $uri) - { - return isset($this->documents[$uri]) ? Promise\resolve($this->documents[$uri]) : $this->loadDocument($uri); - } - - /** - * Loads the document by doing a textDocument/xcontent request to the client. - * If the client does not support textDocument/xcontent, tries to read the file from the file system. - * The document is NOT added to the list of open documents, but definitions are registered. - * - * @param string $uri - * @return Promise - */ - public function loadDocument(string $uri): Promise - { - 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); - } - if (isset($this->documents[$uri])) { - $document = $this->documents[$uri]; - $document->updateContent($content); - } else { - $document = new PhpDocument( - $uri, - $content, - $this, - $this->client, - $this->parser, - $this->docBlockFactory, - $this->definitionResolver - ); - } - return $document; - }); - } - - /** - * Ensures a document is loaded and added to the list of open documents. - * - * @param string $uri - * @param string $content - * @return void - */ - public function openDocument(string $uri, string $content) - { - if (isset($this->documents[$uri])) { - $document = $this->documents[$uri]; - $document->updateContent($content); - } else { - $document = new PhpDocument( - $uri, - $content, - $this, - $this->client, - $this->parser, - $this->docBlockFactory, - $this->definitionResolver - ); - $this->documents[$uri] = $document; - } - return $document; - } - - /** - * Removes the document with the specified URI from the list of open documents - * - * @param string $uri - * @return void - */ - public function closeDocument(string $uri) - { - unset($this->documents[$uri]); - } - - /** - * Returns true if the document is open (and loaded) - * - * @param string $uri - * @return bool - */ - public function isDocumentOpen(string $uri): bool - { - return isset($this->documents[$uri]); - } - - /** - * Returns an associative array [string => Definition] that maps fully qualified symbol names - * to Definitions - * - * @return Definitions[] - */ - public function getDefinitions() - { - return $this->definitions; - } - - /** - * Returns the Definition object by a specific FQN - * - * @param string $fqn - * @param bool $globalFallback Whether to fallback to global if the namespaced FQN was not found - * @return Definition|null - */ - public function getDefinition(string $fqn, $globalFallback = false) - { - if (isset($this->definitions[$fqn])) { - return $this->definitions[$fqn]; - } else if ($globalFallback) { - $parts = explode('\\', $fqn); - $fqn = end($parts); - return $this->getDefinition($fqn); - } - } - - /** - * Registers a definition - * - * @param string $fqn The fully qualified name of the symbol - * @param string $definition The Definition object - * @return void - */ - public function setDefinition(string $fqn, Definition $definition) - { - $this->definitions[$fqn] = $definition; - } - - /** - * Sets the Definition index - * - * @param Definition[] $definitions Map from FQN to Definition - * @return void - */ - public function setDefinitions(array $definitions) - { - $this->definitions = $definitions; - } - - /** - * Unsets the Definition for a specific symbol - * and removes all references pointing to that symbol - * - * @param string $fqn The fully qualified name of the symbol - * @return void - */ - public function removeDefinition(string $fqn) - { - unset($this->definitions[$fqn]); - unset($this->references[$fqn]); - } - - /** - * Adds a document URI as a referencee of a specific symbol - * - * @param string $fqn The fully qualified name of the symbol - * @return void - */ - public function addReferenceUri(string $fqn, string $uri) - { - if (!isset($this->references[$fqn])) { - $this->references[$fqn] = []; - } - // TODO: use DS\Set instead of searching array - if (array_search($uri, $this->references[$fqn], true) === false) { - $this->references[$fqn][] = $uri; - } - } - - /** - * Removes a document URI as the container for a specific symbol - * - * @param string $fqn The fully qualified name of the symbol - * @param string $uri The URI - * @return void - */ - public function removeReferenceUri(string $fqn, string $uri) - { - if (!isset($this->references[$fqn])) { - return; - } - $index = array_search($fqn, $this->references[$fqn], true); - if ($index === false) { - return; - } - array_splice($this->references[$fqn], $index, 1); - } - - /** - * Returns all documents that reference a symbol - * - * @param string $fqn The fully qualified name of the symbol - * @return Promise - */ - public function getReferenceDocuments(string $fqn): Promise - { - if (!isset($this->references[$fqn])) { - return Promise\resolve([]); - } - return Promise\all(array_map([$this, 'getOrLoadDocument'], $this->references[$fqn])); - } - - /** - * Returns an associative array [string => string[]] that maps fully qualified symbol names - * to URIs of the document where the symbol is referenced - * - * @return string[][] - */ - public function getReferenceUris() - { - return $this->references; - } - - /** - * Sets the reference index - * - * @param string[][] $references an associative array [string => string[]] from FQN to URIs - * @return void - */ - public function setReferenceUris(array $references) - { - $this->references = $references; - } - - /** - * Returns the document where a symbol is defined - * - * @param string $fqn The fully qualified name of the symbol - * @return Promise - */ - public function getDefinitionDocument(string $fqn): Promise - { - if (!isset($this->definitions[$fqn])) { - return Promise\resolve(null); - } - return $this->getOrLoadDocument($this->definitions[$fqn]->symbolInformation->location->uri); - } - - /** - * Returns true if the given FQN is defined in the project - * - * @param string $fqn The fully qualified name of the symbol - * @return bool - */ - public function isDefined(string $fqn): bool - { - return isset($this->definitions[$fqn]); - } -} diff --git a/src/Server/TextDocument.php b/src/Server/TextDocument.php index 5998acc..1d58efc 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -3,9 +3,10 @@ declare(strict_types = 1); namespace LanguageServer\Server; -use LanguageServer\{LanguageClient, Project, PhpDocument, DefinitionResolver, CompletionProvider}; use PhpParser\PrettyPrinter\Standard as PrettyPrinter; -use PhpParser\Node; +use PhpParser\{Node, NodeTraverser}; +use LanguageServer\{LanguageClient, PhpDocumentLoader, PhpDocument, DefinitionResolver, CompletionProvider}; +use LanguageServer\NodeVisitor\VariableReferencesCollector; use LanguageServer\Protocol\{ TextDocumentItem, TextDocumentIdentifier, @@ -23,7 +24,9 @@ use LanguageServer\Protocol\{ CompletionItem, CompletionItemKind }; +use LanguageServer\Index\ReadableIndex; use Sabre\Event\Promise; +use Sabre\Uri; use function Sabre\Event\coroutine; /** @@ -58,13 +61,29 @@ class TextDocument */ private $completionProvider; - public function __construct(Project $project, LanguageClient $client) - { - $this->project = $project; + /** + * @var ReadableIndex + */ + private $index; + + /** + * @param PhpDocumentLoader $documentLoader + * @param DefinitionResolver $definitionResolver + * @param LanguageClient $client + * @param ReadableIndex $index + */ + public function __construct( + PhpDocumentLoader $documentLoader, + DefinitionResolver $definitionResolver, + LanguageClient $client, + ReadableIndex $index + ) { + $this->documentLoader = $documentLoader; $this->client = $client; $this->prettyPrinter = new PrettyPrinter(); - $this->definitionResolver = new DefinitionResolver($project); - $this->completionProvider = new CompletionProvider($this->definitionResolver, $project); + $this->definitionResolver = $definitionResolver; + $this->completionProvider = new CompletionProvider($this->definitionResolver, $index); + $this->index = $index; } /** @@ -76,7 +95,7 @@ class TextDocument */ public function documentSymbol(TextDocumentIdentifier $textDocument): Promise { - return $this->project->getOrLoadDocument($textDocument->uri)->then(function (PhpDocument $document) { + return $this->documentLoader->getOrLoad($textDocument->uri)->then(function (PhpDocument $document) { $symbols = []; foreach ($document->getDefinitions() as $fqn => $definition) { $symbols[] = $definition->symbolInformation; @@ -95,7 +114,10 @@ class TextDocument */ public function didOpen(TextDocumentItem $textDocument) { - $this->project->openDocument($textDocument->uri, $textDocument->text); + $document = $this->documentLoader->open($textDocument->uri, $textDocument->text); + if (!$document->isVendored()) { + $this->client->textDocument->publishDiagnostics($textDocument->uri, $document->getDiagnostics()); + } } /** @@ -107,7 +129,9 @@ class TextDocument */ public function didChange(VersionedTextDocumentIdentifier $textDocument, array $contentChanges) { - $this->project->getDocument($textDocument->uri)->updateContent($contentChanges[0]->text); + $document = $this->documentLoader->get($textDocument->uri); + $document->updateContent($contentChanges[0]->text); + $this->client->textDocument->publishDiagnostics($textDocument->uri, $document->getDiagnostics()); } /** @@ -120,7 +144,7 @@ class TextDocument */ public function didClose(TextDocumentIdentifier $textDocument) { - $this->project->closeDocument($textDocument->uri); + $this->documentLoader->close($textDocument->uri); } /** @@ -132,7 +156,7 @@ class TextDocument */ public function formatting(TextDocumentIdentifier $textDocument, FormattingOptions $options) { - return $this->project->getOrLoadDocument($textDocument->uri)->then(function (PhpDocument $document) { + return $this->documentLoader->getOrLoad($textDocument->uri)->then(function (PhpDocument $document) { return $document->getFormattedText(); }); } @@ -150,15 +174,58 @@ class TextDocument Position $position ): Promise { return coroutine(function () use ($textDocument, $position) { - $document = yield $this->project->getOrLoadDocument($textDocument->uri); + $document = yield $this->documentLoader->getOrLoad($textDocument->uri); $node = $document->getNodeAtPosition($position); if ($node === null) { return []; } - $refs = yield $document->getReferenceNodesByNode($node); $locations = []; - foreach ($refs as $ref) { - $locations[] = Location::fromNode($ref); + // Variables always stay in the boundary of the file and need to be searched inside their function scope + // by traversing the AST + if ( + $node instanceof Node\Expr\Variable + || $node instanceof Node\Param + || $node instanceof Node\Expr\ClosureUse + ) { + if ($node->name instanceof Node\Expr) { + return null; + } + // Find function/method/closure scope + $n = $node; + while (isset($n) && !($n instanceof Node\FunctionLike)) { + $n = $n->getAttribute('parentNode'); + } + if (!isset($n)) { + $n = $node->getAttribute('ownerDocument'); + } + $traverser = new NodeTraverser; + $refCollector = new VariableReferencesCollector($node->name); + $traverser->addVisitor($refCollector); + $traverser->traverse($n->getStmts()); + foreach ($refCollector->nodes as $ref) { + $locations[] = Location::fromNode($ref); + } + } else { + // Definition with a global FQN + $fqn = DefinitionResolver::getDefinedFqn($node); + if ($fqn === null) { + $fqn = $this->definitionResolver->resolveReferenceNodeToFqn($node); + if ($fqn === null) { + return []; + } + } + $refDocuments = yield Promise\all(array_map( + [$this->documentLoader, 'getOrLoad'], + $this->index->getReferenceUris($fqn) + )); + foreach ($refDocuments as $document) { + $refs = $document->getReferenceNodesByFqn($fqn); + if ($refs !== null) { + foreach ($refs as $ref) { + $locations[] = Location::fromNode($ref); + } + } + } } return $locations; }); @@ -175,13 +242,17 @@ class TextDocument public function definition(TextDocumentIdentifier $textDocument, Position $position): Promise { return coroutine(function () use ($textDocument, $position) { - $document = yield $this->project->getOrLoadDocument($textDocument->uri); + $document = yield $this->documentLoader->getOrLoad($textDocument->uri); $node = $document->getNodeAtPosition($position); if ($node === null) { return []; } $def = $this->definitionResolver->resolveReferenceNodeToDefinition($node); - if ($def === null || $def->symbolInformation === null) { + if ( + $def === null + || $def->symbolInformation === null + || Uri\parse($def->symbolInformation->location->uri)['scheme'] === 'phpstubs' + ) { return []; } return $def->symbolInformation->location; @@ -198,15 +269,20 @@ class TextDocument public function hover(TextDocumentIdentifier $textDocument, Position $position): Promise { return coroutine(function () use ($textDocument, $position) { - $document = yield $this->project->getOrLoadDocument($textDocument->uri); + $document = yield $this->documentLoader->getOrLoad($textDocument->uri); // Find the node under the cursor $node = $document->getNodeAtPosition($position); if ($node === null) { return new Hover([]); } $range = Range::fromNode($node); - // Get the definition for whatever node is under the cursor - $def = $this->definitionResolver->resolveReferenceNodeToDefinition($node); + if ($definedFqn = DefinitionResolver::getDefinedFqn($node)) { + // Support hover for definitions + $def = $this->index->getDefinition($definedFqn); + } else { + // Get the definition for whatever node is under the cursor + $def = $this->definitionResolver->resolveReferenceNodeToDefinition($node); + } if ($def === null) { return new Hover([], $range); } @@ -237,7 +313,7 @@ class TextDocument public function completion(TextDocumentIdentifier $textDocument, Position $position): Promise { return coroutine(function () use ($textDocument, $position) { - $document = yield $this->project->getOrLoadDocument($textDocument->uri); + $document = yield $this->documentLoader->getOrLoad($textDocument->uri); return $this->completionProvider->provideCompletion($document, $position); }); } diff --git a/src/Server/Workspace.php b/src/Server/Workspace.php index 26feb72..66f8606 100644 --- a/src/Server/Workspace.php +++ b/src/Server/Workspace.php @@ -4,6 +4,7 @@ declare(strict_types = 1); namespace LanguageServer\Server; use LanguageServer\{LanguageClient, Project}; +use LanguageServer\Index\ProjectIndex; use LanguageServer\Protocol\SymbolInformation; /** @@ -19,15 +20,18 @@ class Workspace private $client; /** - * The current project database + * The symbol index for the workspace * - * @var Project + * @var ProjectIndex */ - private $project; + private $index; - public function __construct(Project $project, LanguageClient $client) + /** + * @param ProjectIndex $index Index that is searched on a workspace/symbol request + */ + public function __construct(ProjectIndex $index, LanguageClient $client) { - $this->project = $project; + $this->index = $index; $this->client = $client; } @@ -40,7 +44,7 @@ class Workspace public function symbol(string $query): array { $symbols = []; - foreach ($this->project->getDefinitions() as $fqn => $definition) { + foreach ($this->index->getDefinitions() as $fqn => $definition) { if ($query === '' || stripos($fqn, $query) !== false) { $symbols[] = $definition->symbolInformation; } diff --git a/tests/LanguageServerTest.php b/tests/LanguageServerTest.php index 8bd7b4f..6f8e705 100644 --- a/tests/LanguageServerTest.php +++ b/tests/LanguageServerTest.php @@ -6,7 +6,16 @@ namespace LanguageServer\Tests; use PHPUnit\Framework\TestCase; use LanguageServer\LanguageServer; use LanguageServer\Protocol\{ - Message, ClientCapabilities, TextDocumentSyncKind, MessageType, TextDocumentItem, TextDocumentIdentifier}; + Message, + ClientCapabilities, + TextDocumentSyncKind, + MessageType, + TextDocumentItem, + TextDocumentIdentifier, + InitializeResult, + ServerCapabilities, + CompletionOptions +}; use AdvancedJsonRpc; use Webmozart\Glob\Glob; use Webmozart\PathUtil\Path; @@ -18,41 +27,22 @@ class LanguageServerTest extends TestCase { public function testInitialize() { - $reader = new MockProtocolStream(); - $writer = new MockProtocolStream(); - $server = new LanguageServer($reader, $writer); - $promise = new Promise; - $writer->once('message', [$promise, 'fulfill']); - $reader->write(new Message(new AdvancedJsonRpc\Request(1, 'initialize', [ - 'rootPath' => __DIR__, - 'processId' => getmypid(), - 'capabilities' => new ClientCapabilities() - ]))); - $msg = $promise->wait(); - $this->assertNotNull($msg, 'message event should be emitted'); - $this->assertInstanceOf(AdvancedJsonRpc\SuccessResponse::class, $msg->body); - $this->assertEquals((object)[ - 'capabilities' => (object)[ - 'textDocumentSync' => TextDocumentSyncKind::FULL, - 'documentSymbolProvider' => true, - 'hoverProvider' => true, - 'completionProvider' => (object)[ - 'resolveProvider' => false, - 'triggerCharacters' => ['$', '>'] - ], - 'signatureHelpProvider' => null, - 'definitionProvider' => true, - 'referencesProvider' => true, - 'documentHighlightProvider' => null, - 'workspaceSymbolProvider' => true, - 'codeActionProvider' => null, - 'codeLensProvider' => null, - 'documentFormattingProvider' => true, - 'documentRangeFormattingProvider' => null, - 'documentOnTypeFormattingProvider' => null, - 'renameProvider' => null - ] - ], $msg->body->result); + $server = new LanguageServer(new MockProtocolStream, new MockProtocolStream); + $result = $server->initialize(new ClientCapabilities, __DIR__, getmypid())->wait(); + + $serverCapabilities = new ServerCapabilities(); + $serverCapabilities->textDocumentSync = TextDocumentSyncKind::FULL; + $serverCapabilities->documentSymbolProvider = true; + $serverCapabilities->workspaceSymbolProvider = true; + $serverCapabilities->documentFormattingProvider = true; + $serverCapabilities->definitionProvider = true; + $serverCapabilities->referencesProvider = true; + $serverCapabilities->hoverProvider = true; + $serverCapabilities->completionProvider = new CompletionOptions; + $serverCapabilities->completionProvider->resolveProvider = false; + $serverCapabilities->completionProvider->triggerCharacters = ['$', '>']; + + $this->assertEquals(new InitializeResult($serverCapabilities), $result); } public function testIndexingWithDirectFileAccess() @@ -83,7 +73,8 @@ class LanguageServerTest extends TestCase $rootPath = realpath(__DIR__ . '/../fixtures'); $input = new MockProtocolStream; $output = new MockProtocolStream; - $output->on('message', function (Message $msg) use ($promise, $input, $rootPath, &$filesCalled, &$contentCalled) { + $run = 1; + $output->on('message', function (Message $msg) use ($promise, $input, $rootPath, &$filesCalled, &$contentCalled, &$run) { if ($msg->body->method === 'textDocument/xcontent') { // Document content requested $contentCalled = true; @@ -110,8 +101,11 @@ class LanguageServerTest extends TestCase $promise->reject(new Exception($msg->body->params->message)); } } else if (strpos($msg->body->params->message, 'All 25 PHP files parsed') !== false) { - // Indexing finished - $promise->fulfill(); + if ($run === 1) { + $run++; + } else { + $promise->fulfill(); + } } } }); diff --git a/tests/NodeVisitor/DefinitionCollectorTest.php b/tests/NodeVisitor/DefinitionCollectorTest.php index 74e0d5c..9b60814 100644 --- a/tests/NodeVisitor/DefinitionCollectorTest.php +++ b/tests/NodeVisitor/DefinitionCollectorTest.php @@ -6,8 +6,11 @@ namespace LanguageServer\Tests\Server\TextDocument; use PHPUnit\Framework\TestCase; use PhpParser\{NodeTraverser, Node}; use PhpParser\NodeVisitor\NameResolver; -use LanguageServer\{LanguageClient, Project, PhpDocument, Parser, DefinitionResolver}; +use phpDocumentor\Reflection\DocBlockFactory; +use LanguageServer\{LanguageClient, PhpDocument, PhpDocumentLoader, Parser, DefinitionResolver}; +use LanguageServer\ContentRetriever\FileSystemContentRetriever; use LanguageServer\Protocol\ClientCapabilities; +use LanguageServer\Index\{ProjectIndex, Index, DependenciesIndex}; use LanguageServer\Tests\MockProtocolStream; use LanguageServer\NodeVisitor\{ReferencesAdder, DefinitionCollector}; use function LanguageServer\pathToUri; @@ -16,19 +19,25 @@ class DefinitionCollectorTest extends TestCase { public function testCollectsSymbols() { - $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); - $project = new Project($client, new ClientCapabilities); + $path = realpath(__DIR__ . '/../../fixtures/symbols.php'); + $uri = pathToUri($path); $parser = new Parser; - $uri = pathToUri(realpath(__DIR__ . '/../../fixtures/symbols.php')); - $document = $project->loadDocument($uri)->wait(); + $docBlockFactory = DocBlockFactory::createInstance(); + $index = new Index; + $definitionResolver = new DefinitionResolver($index); + $content = file_get_contents($path); + $document = new PhpDocument($uri, $content, $index, $parser, $docBlockFactory, $definitionResolver); + $stmts = $parser->parse($content); + $traverser = new NodeTraverser; $traverser->addVisitor(new NameResolver); $traverser->addVisitor(new ReferencesAdder($document)); - $definitionCollector = new DefinitionCollector(new DefinitionResolver($project)); + $definitionCollector = new DefinitionCollector($definitionResolver); $traverser->addVisitor($definitionCollector); - $stmts = $parser->parse(file_get_contents($uri)); $traverser->traverse($stmts); + $defNodes = $definitionCollector->nodes; + $this->assertEquals([ 'TestNamespace', 'TestNamespace\\TEST_CONST', @@ -56,19 +65,25 @@ class DefinitionCollectorTest extends TestCase public function testDoesNotCollectReferences() { - $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); - $project = new Project($client, new ClientCapabilities); + $path = realpath(__DIR__ . '/../../fixtures/references.php'); + $uri = pathToUri($path); $parser = new Parser; - $uri = pathToUri(realpath(__DIR__ . '/../../fixtures/references.php')); - $document = $project->loadDocument($uri)->wait(); + $docBlockFactory = DocBlockFactory::createInstance(); + $index = new Index; + $definitionResolver = new DefinitionResolver($index); + $content = file_get_contents($path); + $document = new PhpDocument($uri, $content, $index, $parser, $docBlockFactory, $definitionResolver); + $stmts = $parser->parse($content); + $traverser = new NodeTraverser; $traverser->addVisitor(new NameResolver); $traverser->addVisitor(new ReferencesAdder($document)); - $definitionCollector = new DefinitionCollector(new DefinitionResolver($project)); + $definitionCollector = new DefinitionCollector($definitionResolver); $traverser->addVisitor($definitionCollector); - $stmts = $parser->parse(file_get_contents($uri)); $traverser->traverse($stmts); + $defNodes = $definitionCollector->nodes; + $this->assertEquals(['TestNamespace', 'TestNamespace\\whatever()'], array_keys($defNodes)); $this->assertInstanceOf(Node\Stmt\Namespace_::class, $defNodes['TestNamespace']); $this->assertInstanceOf(Node\Stmt\Function_::class, $defNodes['TestNamespace\\whatever()']); diff --git a/tests/PhpDocumentLoaderTest.php b/tests/PhpDocumentLoaderTest.php new file mode 100644 index 0000000..7be062d --- /dev/null +++ b/tests/PhpDocumentLoaderTest.php @@ -0,0 +1,54 @@ +loader = new PhpDocumentLoader( + new FileSystemContentRetriever, + $projectIndex, + new DefinitionResolver($projectIndex) + ); + } + + public function testGetOrLoadLoadsDocument() + { + $document = $this->loader->getOrLoad(pathToUri(__FILE__))->wait(); + + $this->assertNotNull($document); + $this->assertInstanceOf(PhpDocument::class, $document); + } + + public function testGetReturnsOpenedInstance() + { + $document1 = $this->loader->open(pathToUri(__FILE__), file_get_contents(__FILE__)); + $document2 = $this->loader->get(pathToUri(__FILE__)); + + $this->assertSame($document1, $document2); + } +} diff --git a/tests/PhpDocumentTest.php b/tests/PhpDocumentTest.php index 057551e..b9b3704 100644 --- a/tests/PhpDocumentTest.php +++ b/tests/PhpDocumentTest.php @@ -4,35 +4,36 @@ declare(strict_types = 1); namespace LanguageServer\Tests\Server; use PHPUnit\Framework\TestCase; +use phpDocumentor\Reflection\DocBlockFactory; use LanguageServer\Tests\MockProtocolStream; -use LanguageServer\{LanguageClient, Project}; +use LanguageServer\{LanguageClient, PhpDocument, DefinitionResolver, Parser}; use LanguageServer\NodeVisitor\NodeAtPositionFinder; +use LanguageServer\ContentRetriever\FileSystemContentRetriever; use LanguageServer\Protocol\{SymbolKind, Position, ClientCapabilities}; +use LanguageServer\Index\{Index, ProjectIndex, DependenciesIndex}; use PhpParser\Node; class PhpDocumentTest extends TestCase { - /** - * @var Project $project - */ - private $project; - - public function setUp() + public function createDocument(string $uri, string $content) { - $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); - $this->project = new Project($client, new ClientCapabilities); + $parser = new Parser; + $docBlockFactory = DocBlockFactory::createInstance(); + $index = new Index; + $definitionResolver = new DefinitionResolver($index); + return new PhpDocument($uri, $content, $index, $parser, $docBlockFactory, $definitionResolver); } public function testParsesVariableVariables() { - $document = $this->project->openDocument('whatever', "createDocument('whatever', "assertEquals([], $document->getDefinitions()); } public function testGetNodeAtPosition() { - $document = $this->project->openDocument('whatever', "createDocument('whatever', "getNodeAtPosition(new Position(1, 13)); $this->assertInstanceOf(Node\Name\FullyQualified::class, $node); $this->assertEquals('SomeClass', (string)$node); @@ -40,19 +41,19 @@ class PhpDocumentTest extends TestCase public function testIsVendored() { - $document = $this->project->openDocument('file:///dir/vendor/x.php', "createDocument('file:///dir/vendor/x.php', "assertEquals(true, $document->isVendored()); - $document = $this->project->openDocument('file:///c:/dir/vendor/x.php', "createDocument('file:///c:/dir/vendor/x.php', "assertEquals(true, $document->isVendored()); - $document = $this->project->openDocument('file:///vendor/x.php', "createDocument('file:///vendor/x.php', "assertEquals(true, $document->isVendored()); - $document = $this->project->openDocument('file:///dir/vendor.php', "createDocument('file:///dir/vendor.php', "assertEquals(false, $document->isVendored()); - $document = $this->project->openDocument('file:///dir/x.php', "createDocument('file:///dir/x.php', "assertEquals(false, $document->isVendored()); } } diff --git a/tests/ProjectTest.php b/tests/ProjectTest.php deleted file mode 100644 index 161370c..0000000 --- a/tests/ProjectTest.php +++ /dev/null @@ -1,48 +0,0 @@ -project = new Project($client, new ClientCapabilities); - } - - public function testGetOrLoadDocumentLoadsDocument() - { - $document = $this->project->getOrLoadDocument(pathToUri(__FILE__))->wait(); - - $this->assertNotNull($document); - $this->assertInstanceOf(PhpDocument::class, $document); - } - - public function testGetDocumentReturnsOpenedInstance() - { - $document1 = $this->project->openDocument(pathToUri(__FILE__), file_get_contents(__FILE__)); - $document2 = $this->project->getDocument(pathToUri(__FILE__)); - - $this->assertSame($document1, $document2); - } -} diff --git a/tests/Server/ServerTestCase.php b/tests/Server/ServerTestCase.php index 23d1763..602bda0 100644 --- a/tests/Server/ServerTestCase.php +++ b/tests/Server/ServerTestCase.php @@ -5,7 +5,9 @@ namespace LanguageServer\Tests\Server; use PHPUnit\Framework\TestCase; use LanguageServer\Tests\MockProtocolStream; -use LanguageServer\{Server, LanguageClient, Project}; +use LanguageServer\{Server, LanguageClient, PhpDocumentLoader, DefinitionResolver}; +use LanguageServer\Index\{ProjectIndex, StubsIndex, GlobalIndex, DependenciesIndex, Index}; +use LanguageServer\ContentRetriever\FileSystemContentRetriever; use LanguageServer\Protocol\{Position, Location, Range, ClientCapabilities}; use function LanguageServer\pathToUri; use Sabre\Event\Promise; @@ -23,9 +25,9 @@ abstract class ServerTestCase extends TestCase protected $workspace; /** - * @var Project + * @var PhpDocumentLoader */ - protected $project; + protected $documentLoader; /** * Map from FQN to Location of definition @@ -43,10 +45,13 @@ abstract class ServerTestCase extends TestCase public function setUp() { - $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); - $this->project = new Project($client, new ClientCapabilities); - $this->textDocument = new Server\TextDocument($this->project, $client); - $this->workspace = new Server\Workspace($this->project, $client); + $projectIndex = new ProjectIndex(new Index, new DependenciesIndex); + + $definitionResolver = new DefinitionResolver($projectIndex); + $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); + $this->documentLoader = new PhpDocumentLoader(new FileSystemContentRetriever, $projectIndex, $definitionResolver); + $this->textDocument = new Server\TextDocument($this->documentLoader, $definitionResolver, $client, $projectIndex); + $this->workspace = new Server\Workspace($projectIndex, $client); $globalSymbolsUri = pathToUri(realpath(__DIR__ . '/../../fixtures/global_symbols.php')); $globalReferencesUri = pathToUri(realpath(__DIR__ . '/../../fixtures/global_references.php')); @@ -54,11 +59,11 @@ abstract class ServerTestCase extends TestCase $referencesUri = pathToUri(realpath(__DIR__ . '/../../fixtures/references.php')); $useUri = pathToUri(realpath(__DIR__ . '/../../fixtures/use.php')); - $this->project->loadDocument($symbolsUri)->wait(); - $this->project->loadDocument($referencesUri)->wait(); - $this->project->loadDocument($globalSymbolsUri)->wait(); - $this->project->loadDocument($globalReferencesUri)->wait(); - $this->project->loadDocument($useUri)->wait(); + $this->documentLoader->load($symbolsUri)->wait(); + $this->documentLoader->load($referencesUri)->wait(); + $this->documentLoader->load($globalSymbolsUri)->wait(); + $this->documentLoader->load($globalReferencesUri)->wait(); + $this->documentLoader->load($useUri)->wait(); // @codingStandardsIgnoreStart $this->definitionLocations = [ diff --git a/tests/Server/TextDocument/CompletionTest.php b/tests/Server/TextDocument/CompletionTest.php index 7b5dd0a..1e48d7b 100644 --- a/tests/Server/TextDocument/CompletionTest.php +++ b/tests/Server/TextDocument/CompletionTest.php @@ -5,7 +5,9 @@ namespace LanguageServer\Tests\Server\TextDocument; use PHPUnit\Framework\TestCase; use LanguageServer\Tests\MockProtocolStream; -use LanguageServer\{Server, LanguageClient, Project, CompletionProvider}; +use LanguageServer\{Server, LanguageClient, PhpDocumentLoader, CompletionProvider, DefinitionResolver}; +use LanguageServer\Index\{Index, ProjectIndex, DependenciesIndex, GlobalIndex, StubsIndex}; +use LanguageServer\ContentRetriever\FileSystemContentRetriever; use LanguageServer\Protocol\{ TextDocumentIdentifier, TextEdit, @@ -26,23 +28,26 @@ class CompletionTest extends TestCase private $textDocument; /** - * @var Project + * @var PhpDocumentLoader */ - private $project; + private $loader; public function setUp() { $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); - $this->project = new Project($client, new ClientCapabilities); - $this->project->loadDocument(pathToUri(__DIR__ . '/../../../fixtures/global_symbols.php'))->wait(); - $this->project->loadDocument(pathToUri(__DIR__ . '/../../../fixtures/symbols.php'))->wait(); - $this->textDocument = new Server\TextDocument($this->project, $client); + $projectIndex = new ProjectIndex(new Index, new DependenciesIndex); + $definitionResolver = new DefinitionResolver($projectIndex); + $contentRetriever = new FileSystemContentRetriever; + $this->loader = new PhpDocumentLoader($contentRetriever, $projectIndex, $definitionResolver); + $this->loader->load(pathToUri(__DIR__ . '/../../../fixtures/global_symbols.php'))->wait(); + $this->loader->load(pathToUri(__DIR__ . '/../../../fixtures/symbols.php'))->wait(); + $this->textDocument = new Server\TextDocument($this->loader, $definitionResolver, $client, $projectIndex); } public function testPropertyAndMethodWithPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/property_with_prefix.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(3, 7) @@ -66,7 +71,7 @@ class CompletionTest extends TestCase public function testPropertyAndMethodWithoutPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/property.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(3, 6) @@ -90,7 +95,7 @@ class CompletionTest extends TestCase public function testVariable() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/variable.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(8, 5) @@ -122,7 +127,7 @@ class CompletionTest extends TestCase public function testVariableWithPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/variable_with_prefix.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(8, 6) @@ -144,7 +149,7 @@ class CompletionTest extends TestCase public function testNewInNamespace() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_new.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(6, 10) @@ -176,7 +181,7 @@ class CompletionTest extends TestCase public function testUsedClass() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_class.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(6, 5) @@ -194,7 +199,7 @@ class CompletionTest extends TestCase public function testStaticPropertyWithPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static_property_with_prefix.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(2, 14) @@ -215,7 +220,7 @@ class CompletionTest extends TestCase public function testStaticWithoutPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(2, 11) @@ -248,25 +253,7 @@ class CompletionTest extends TestCase public function testStaticMethodWithPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static_method_with_prefix.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); - $items = $this->textDocument->completion( - new TextDocumentIdentifier($completionUri), - new Position(2, 13) - )->wait(); - $this->assertEquals(new CompletionList([ - new CompletionItem( - 'staticTestMethod', - CompletionItemKind::METHOD, - 'mixed', // Method return type - 'Do magna consequat veniam minim proident eiusmod incididunt aute proident.' - ) - ], true), $items); - } - - public function testClassConstWithPrefix() - { - $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/class_const_with_prefix.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(2, 13) @@ -277,6 +264,54 @@ class CompletionTest extends TestCase CompletionItemKind::VARIABLE, 'int', 'Anim labore veniam consectetur laboris minim quis aute aute esse nulla ad.' + ), + new CompletionItem( + 'staticTestProperty', + CompletionItemKind::PROPERTY, + '\TestClass[]', + 'Lorem excepteur officia sit anim velit veniam enim.', + null, + null, + '$staticTestProperty' + ), + new CompletionItem( + 'staticTestMethod', + CompletionItemKind::METHOD, + 'mixed', + 'Do magna consequat veniam minim proident eiusmod incididunt aute proident.' + ) + ], true), $items); + } + + public function testClassConstWithPrefix() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/class_const_with_prefix.php'); + $this->loader->open($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(2, 13) + )->wait(); + $this->assertEquals(new CompletionList([ + new CompletionItem( + 'TEST_CLASS_CONST', + CompletionItemKind::VARIABLE, + 'int', + 'Anim labore veniam consectetur laboris minim quis aute aute esse nulla ad.' + ), + new CompletionItem( + 'staticTestProperty', + CompletionItemKind::PROPERTY, + '\TestClass[]', + 'Lorem excepteur officia sit anim velit veniam enim.', + null, + null, + '$staticTestProperty' + ), + new CompletionItem( + 'staticTestMethod', + CompletionItemKind::METHOD, + 'mixed', + 'Do magna consequat veniam minim proident eiusmod incididunt aute proident.' ) ], true), $items); } @@ -284,7 +319,7 @@ class CompletionTest extends TestCase public function testFullyQualifiedClass() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/fully_qualified_class.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(6, 6) @@ -305,7 +340,7 @@ class CompletionTest extends TestCase public function testKeywords() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/keywords.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(2, 1) @@ -319,7 +354,7 @@ class CompletionTest extends TestCase public function testHtmlWithoutPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(0, 0) @@ -341,7 +376,7 @@ class CompletionTest extends TestCase public function testHtmlWithPrefix() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html_with_prefix.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(0, 1) @@ -363,7 +398,7 @@ class CompletionTest extends TestCase public function testNamespace() { $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/namespace.php'); - $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $this->loader->open($completionUri, file_get_contents($completionUri)); $items = $this->textDocument->completion( new TextDocumentIdentifier($completionUri), new Position(4, 6) diff --git a/tests/Server/TextDocument/Definition/GlobalFallbackTest.php b/tests/Server/TextDocument/Definition/GlobalFallbackTest.php index c4b021d..4c09e18 100644 --- a/tests/Server/TextDocument/Definition/GlobalFallbackTest.php +++ b/tests/Server/TextDocument/Definition/GlobalFallbackTest.php @@ -5,7 +5,9 @@ namespace LanguageServer\Tests\Server\TextDocument\Definition; use LanguageServer\Tests\MockProtocolStream; use LanguageServer\Tests\Server\ServerTestCase; -use LanguageServer\{Server, LanguageClient, Project}; +use LanguageServer\{Server, LanguageClient, PhpDocumentLoader, DefinitionResolver}; +use LanguageServer\Index\{Index, ProjectIndex, DependenciesIndex}; +use LanguageServer\ContentRetriever\FileSystemContentRetriever; use LanguageServer\Protocol\{TextDocumentIdentifier, Position, Range, Location, ClientCapabilities}; use Sabre\Event\Promise; @@ -13,11 +15,14 @@ class GlobalFallbackTest extends ServerTestCase { public function setUp() { + $projectIndex = new ProjectIndex(new Index, new DependenciesIndex); $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); - $project = new Project($client, new ClientCapabilities); - $this->textDocument = new Server\TextDocument($project, $client); - $project->openDocument('global_fallback', file_get_contents(__DIR__ . '/../../../../fixtures/global_fallback.php')); - $project->openDocument('global_symbols', file_get_contents(__DIR__ . '/../../../../fixtures/global_symbols.php')); + $definitionResolver = new DefinitionResolver($projectIndex); + $contentRetriever = new FileSystemContentRetriever; + $loader = new PhpDocumentLoader($contentRetriever, $projectIndex, $definitionResolver); + $this->textDocument = new Server\TextDocument($loader, $definitionResolver, $client, $projectIndex); + $loader->open('global_fallback', file_get_contents(__DIR__ . '/../../../../fixtures/global_fallback.php')); + $loader->open('global_symbols', file_get_contents(__DIR__ . '/../../../../fixtures/global_symbols.php')); } public function testClassDoesNotFallback() diff --git a/tests/Server/TextDocument/DidChangeTest.php b/tests/Server/TextDocument/DidChangeTest.php index 1df0505..bdd3b22 100644 --- a/tests/Server/TextDocument/DidChangeTest.php +++ b/tests/Server/TextDocument/DidChangeTest.php @@ -5,7 +5,9 @@ namespace LanguageServer\Tests\Server\TextDocument; use PHPUnit\Framework\TestCase; use LanguageServer\Tests\MockProtocolStream; -use LanguageServer\{Server, Client, LanguageClient, Project}; +use LanguageServer\{Server, Client, LanguageClient, PhpDocumentLoader, DefinitionResolver}; +use LanguageServer\ContentRetriever\FileSystemContentRetriever; +use LanguageServer\Index\{Index, ProjectIndex, DependenciesIndex}; use LanguageServer\Protocol\{ TextDocumentIdentifier, TextDocumentItem, @@ -20,10 +22,12 @@ class DidChangeTest extends TestCase { public function test() { + $projectIndex = new ProjectIndex(new Index, new DependenciesIndex); $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); - $project = new Project($client, new ClientCapabilities); - $textDocument = new Server\TextDocument($project, $client); - $phpDocument = $project->openDocument('whatever', "open('whatever', "openDocument('whatever', 'hello world'); + $definitionResolver = new DefinitionResolver($projectIndex); + $loader = new PhpDocumentLoader(new FileSystemContentRetriever, $projectIndex, $definitionResolver); + $textDocument = new Server\TextDocument($loader, $definitionResolver, $client, $projectIndex); + $phpDocument = $loader->open('whatever', "uri = 'whatever'; @@ -27,6 +31,6 @@ class DidCloseTest extends TestCase $textDocument->didClose(new TextDocumentIdentifier($textDocumentItem->uri)); - $this->assertFalse($project->isDocumentOpen($textDocumentItem->uri)); + $this->assertFalse($loader->isOpen($textDocumentItem->uri)); } } diff --git a/tests/Server/TextDocument/FormattingTest.php b/tests/Server/TextDocument/FormattingTest.php index b7d0609..abb3d6d 100644 --- a/tests/Server/TextDocument/FormattingTest.php +++ b/tests/Server/TextDocument/FormattingTest.php @@ -5,7 +5,9 @@ namespace LanguageServer\Tests\Server\TextDocument; use PHPUnit\Framework\TestCase; use LanguageServer\Tests\MockProtocolStream; -use LanguageServer\{Server, Client, LanguageClient, Project}; +use LanguageServer\{Server, Client, LanguageClient, PhpDocumentLoader, DefinitionResolver}; +use LanguageServer\Index\{Index, ProjectIndex, DependenciesIndex}; +use LanguageServer\ContentRetriever\FileSystemContentRetriever; use LanguageServer\Protocol\{ TextDocumentIdentifier, TextDocumentItem, @@ -19,23 +21,14 @@ use function LanguageServer\{pathToUri, uriToPath}; class FormattingTest extends TestCase { - /** - * @var Server\TextDocument - */ - private $textDocument; - - public function setUp() - { - $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); - $project = new Project($client, new ClientCapabilities); - $this->textDocument = new Server\TextDocument($project, $client); - } - public function testFormatting() { + $projectIndex = new ProjectIndex(new Index, new DependenciesIndex); $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); - $project = new Project($client, new ClientCapabilities); - $textDocument = new Server\TextDocument($project, $client); + $definitionResolver = new DefinitionResolver($projectIndex); + $loader = new PhpDocumentLoader(new FileSystemContentRetriever, $projectIndex, $definitionResolver); + $textDocument = new Server\TextDocument($loader, $definitionResolver, $client, $projectIndex); + $path = realpath(__DIR__ . '/../../../fixtures/format.php'); $uri = pathToUri($path); diff --git a/tests/Server/TextDocument/HoverTest.php b/tests/Server/TextDocument/HoverTest.php index 80cef64..9f33268 100644 --- a/tests/Server/TextDocument/HoverTest.php +++ b/tests/Server/TextDocument/HoverTest.php @@ -26,6 +26,21 @@ class HoverTest extends ServerTestCase ], $reference->range), $result); } + public function testHoverForClassLikeDefinition() + { + // class TestClass implements TestInterface + // Get hover for TestClass + $definition = $this->getDefinitionLocation('TestClass'); + $result = $this->textDocument->hover( + new TextDocumentIdentifier($definition->uri), + $definition->range->start + )->wait(); + $this->assertEquals(new Hover([ + new MarkedString('php', "range), $result); + } + public function testHoverForMethod() { // $obj->testMethod(); diff --git a/tests/Server/TextDocument/ParseErrorsTest.php b/tests/Server/TextDocument/ParseErrorsTest.php index 2a02efe..6c927f8 100644 --- a/tests/Server/TextDocument/ParseErrorsTest.php +++ b/tests/Server/TextDocument/ParseErrorsTest.php @@ -5,7 +5,9 @@ namespace LanguageServer\Tests\Server\TextDocument; use PHPUnit\Framework\TestCase; use LanguageServer\Tests\MockProtocolStream; -use LanguageServer\{Server, Client, LanguageClient, Project, ClientHandler}; +use LanguageServer\{Server, Client, LanguageClient, ClientHandler, PhpDocumentLoader, DefinitionResolver}; +use LanguageServer\Index\{Index, ProjectIndex, DependenciesIndex}; +use LanguageServer\ContentRetriever\FileSystemContentRetriever; use LanguageServer\Protocol\{TextDocumentIdentifier, TextDocumentItem, DiagnosticSeverity, ClientCapabilities}; use Sabre\Event\Promise; use JsonMapper; @@ -35,8 +37,10 @@ class ParseErrorsTest extends TestCase return Promise\resolve(null); } }; - $project = new Project($client, new ClientCapabilities); - $this->textDocument = new Server\TextDocument($project, $client); + $projectIndex = new ProjectIndex(new Index, new DependenciesIndex); + $definitionResolver = new DefinitionResolver($projectIndex); + $loader = new PhpDocumentLoader(new FileSystemContentRetriever, $projectIndex, $definitionResolver); + $this->textDocument = new Server\TextDocument($loader, $definitionResolver, $client, $projectIndex); } private function openFile($file) diff --git a/tests/Server/TextDocument/References/GlobalFallbackTest.php b/tests/Server/TextDocument/References/GlobalFallbackTest.php index 9f68cb9..2679d15 100644 --- a/tests/Server/TextDocument/References/GlobalFallbackTest.php +++ b/tests/Server/TextDocument/References/GlobalFallbackTest.php @@ -5,7 +5,9 @@ namespace LanguageServer\Tests\Server\TextDocument\References; use PHPUnit\Framework\TestCase; use LanguageServer\Tests\MockProtocolStream; -use LanguageServer\{Server, LanguageClient, Project}; +use LanguageServer\{Server, LanguageClient, PhpDocumentLoader, DefinitionResolver}; +use LanguageServer\Index\{Index, ProjectIndex, DependenciesIndex}; +use LanguageServer\ContentRetriever\FileSystemContentRetriever; use LanguageServer\Protocol\{TextDocumentIdentifier, Position, ReferenceContext, Location, Range, ClientCapabilities}; use LanguageServer\Tests\Server\ServerTestCase; @@ -13,11 +15,13 @@ class GlobalFallbackTest extends ServerTestCase { public function setUp() { - $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); - $project = new Project($client, new ClientCapabilities); - $this->textDocument = new Server\TextDocument($project, $client); - $project->openDocument('global_fallback', file_get_contents(__DIR__ . '/../../../../fixtures/global_fallback.php')); - $project->openDocument('global_symbols', file_get_contents(__DIR__ . '/../../../../fixtures/global_symbols.php')); + $projectIndex = new ProjectIndex(new Index, new DependenciesIndex); + $definitionResolver = new DefinitionResolver($projectIndex); + $client = new LanguageClient(new MockProtocolStream, new MockProtocolStream); + $this->documentLoader = new PhpDocumentLoader(new FileSystemContentRetriever, $projectIndex, $definitionResolver); + $this->textDocument = new Server\TextDocument($this->documentLoader, $definitionResolver, $client, $projectIndex); + $this->documentLoader->open('global_fallback', file_get_contents(__DIR__ . '/../../../../fixtures/global_fallback.php')); + $this->documentLoader->open('global_symbols', file_get_contents(__DIR__ . '/../../../../fixtures/global_symbols.php')); } public function testClassDoesNotFallback() diff --git a/tests/Server/TextDocument/References/GlobalTest.php b/tests/Server/TextDocument/References/GlobalTest.php index bf6eb97..4febaf3 100644 --- a/tests/Server/TextDocument/References/GlobalTest.php +++ b/tests/Server/TextDocument/References/GlobalTest.php @@ -146,4 +146,17 @@ class GlobalTest extends ServerTestCase new Location($referencesUri, new Range(new Position(31, 13), new Position(31, 40))) ], $result); } + + public function testReferencesForReference() + { + // $obj = new TestClass(); + // Get references for TestClass + $reference = $this->getReferenceLocations('TestClass')[0]; + $result = $this->textDocument->references( + new ReferenceContext, + new TextDocumentIdentifier($reference->uri), + $reference->range->start + )->wait(); + $this->assertEquals($this->getReferenceLocations('TestClass'), $result); + } }