1
0
Fork 0

Merge remote-tracking branch 'origin/master' into staticMethodsInParents

pull/679/head
Martin Letáček 2018-12-22 23:44:19 +01:00
commit c484e65a1d
32 changed files with 1316 additions and 268 deletions

1
.gitattributes vendored
View File

@ -10,6 +10,7 @@
/.gitignore export-ignore /.gitignore export-ignore
/.gitmodules export-ignore /.gitmodules export-ignore
/.npmrc export-ignore /.npmrc export-ignore
/.phan export-ignore
/.travis.yml export-ignore /.travis.yml export-ignore
/appveyor.yml export-ignore /appveyor.yml export-ignore
/codecov.yml export-ignore /codecov.yml export-ignore

308
.phan/config.php Normal file
View File

@ -0,0 +1,308 @@
<?php
use Phan\Issue;
/**
* This configuration file was automatically generated by 'phan --init --init-level=1'
*
* TODOs (added by 'phan --init'):
*
* - Go through this file and verify that there are no missing/unnecessary files/directories.
* (E.g. this only includes direct composer dependencies - You may have to manually add indirect composer dependencies to 'directory_list')
* - Look at 'plugins' and add or remove plugins if appropriate (see https://github.com/phan/phan/tree/master/.phan/plugins#plugins)
* - Add global suppressions for pre-existing issues to suppress_issue_types (https://github.com/phan/phan/wiki/Tutorial-for-Analyzing-a-Large-Sloppy-Code-Base)
*
* This configuration will be read and overlayed on top of the
* default configuration. Command line arguments will be applied
* after this file is read.
*
* @see src/Phan/Config.php
* See Config for all configurable options.
*
* A Note About Paths
* ==================
*
* Files referenced from this file should be defined as
*
* ```
* Config::projectPath('relative_path/to/file')
* ```
*
* where the relative path is relative to the root of the
* project which is defined as either the working directory
* of the phan executable or a path passed in via the CLI
* '-d' flag.
*/
return [
// Supported values: '7.0', '7.1', '7.2', null.
// If this is set to null,
// then Phan assumes the PHP version which is closest to the minor version
// of the php executable used to execute phan.
// Automatically inferred from composer.json requirement for "php" of "^7.0"
'target_php_version' => '7.0',
// If enabled, missing properties will be created when
// they are first seen. If false, we'll report an
// error message if there is an attempt to write
// to a class property that wasn't explicitly
// defined.
'allow_missing_properties' => false,
// If enabled, null can be cast as any type and any
// type can be cast to null. Setting this to true
// will cut down on false positives.
'null_casts_as_any_type' => false,
// If enabled, allow null to be cast as any array-like type.
// This is an incremental step in migrating away from null_casts_as_any_type.
// If null_casts_as_any_type is true, this has no effect.
'null_casts_as_array' => false,
// If enabled, allow any array-like type to be cast to null.
// This is an incremental step in migrating away from null_casts_as_any_type.
// If null_casts_as_any_type is true, this has no effect.
'array_casts_as_null' => false,
// If enabled, scalars (int, float, bool, string, null)
// are treated as if they can cast to each other.
// This does not affect checks of array keys. See scalar_array_key_cast.
'scalar_implicit_cast' => false,
// If enabled, any scalar array keys (int, string)
// are treated as if they can cast to each other.
// E.g. array<int,stdClass> can cast to array<string,stdClass> and vice versa.
// Normally, a scalar type such as int could only cast to/from int and mixed.
'scalar_array_key_cast' => false,
// If this has entries, scalars (int, float, bool, string, null)
// are allowed to perform the casts listed.
// E.g. ['int' => ['float', 'string'], 'float' => ['int'], 'string' => ['int'], 'null' => ['string']]
// allows casting null to a string, but not vice versa.
// (subset of scalar_implicit_cast)
'scalar_implicit_partial' => [],
// If true, seemingly undeclared variables in the global
// scope will be ignored. This is useful for projects
// with complicated cross-file globals that you have no
// hope of fixing.
'ignore_undeclared_variables_in_global_scope' => false,
// Backwards Compatibility Checking. This is slow
// and expensive, but you should consider running
// it before upgrading your version of PHP to a
// new version that has backward compatibility
// breaks.
'backward_compatibility_checks' => false,
// If true, check to make sure the return type declared
// in the doc-block (if any) matches the return type
// declared in the method signature.
'check_docblock_signature_return_type_match' => true,
// (*Requires check_docblock_signature_param_type_match to be true*)
// If true, make narrowed types from phpdoc params override
// the real types from the signature, when real types exist.
// (E.g. allows specifying desired lists of subclasses,
// or to indicate a preference for non-nullable types over nullable types)
// Affects analysis of the body of the method and the param types passed in by callers.
'prefer_narrowed_phpdoc_param_type' => true,
// (*Requires check_docblock_signature_return_type_match to be true*)
// If true, make narrowed types from phpdoc returns override
// the real types from the signature, when real types exist.
// (E.g. allows specifying desired lists of subclasses,
// or to indicate a preference for non-nullable types over nullable types)
// Affects analysis of return statements in the body of the method and the return types passed in by callers.
'prefer_narrowed_phpdoc_return_type' => true,
'ensure_signature_compatibility' => true,
// Set to true in order to attempt to detect dead
// (unreferenced) code. Keep in mind that the
// results will only be a guess given that classes,
// properties, constants and methods can be referenced
// as variables (like `$class->$property` or
// `$class->$method()`) in ways that we're unable
// to make sense of.
'dead_code_detection' => false,
// If true, this run a quick version of checks that takes less
// time at the cost of not running as thorough
// an analysis. You should consider setting this
// to true only when you wish you had more **undiagnosed** issues
// to fix in your code base.
//
// In quick-mode the scanner doesn't rescan a function
// or a method's code block every time a call is seen.
// This means that the problem here won't be detected:
//
// ```php
// <?php
// function test($arg):int {
// return $arg;
// }
// test("abc");
// ```
//
// This would normally generate:
//
// ```sh
// test.php:3 TypeError return string but `test()` is declared to return int
// ```
//
// The initial scan of the function's code block has no
// type information for `$arg`. It isn't until we see
// the call and rescan test()'s code block that we can
// detect that it is actually returning the passed in
// `string` instead of an `int` as declared.
'quick_mode' => false,
// If true, then before analysis, try to simplify AST into a form
// which improves Phan's type inference in edge cases.
//
// This may conflict with 'dead_code_detection'.
// When this is true, this slows down analysis slightly.
//
// E.g. rewrites `if ($a = value() && $a > 0) {...}`
// into $a = value(); if ($a) { if ($a > 0) {...}}`
'simplify_ast' => true,
// Enable or disable support for generic templated
// class types.
'generic_types_enabled' => true,
// Override to hardcode existence and types of (non-builtin) globals in the global scope.
// Class names should be prefixed with '\\'.
// (E.g. ['_FOO' => '\\FooClass', 'page' => '\\PageClass', 'userId' => 'int'])
'globals_type_map' => [],
// The minimum severity level to report on. This can be
// set to Issue::SEVERITY_LOW, Issue::SEVERITY_NORMAL or
// Issue::SEVERITY_CRITICAL. Setting it to only
// critical issues is a good place to start on a big
// sloppy mature code base.
'minimum_severity' => Issue::SEVERITY_LOW,
// Add any issue types (such as 'PhanUndeclaredMethod')
// to this black-list to inhibit them from being reported.
'suppress_issue_types' => [
'PhanTypeMismatchDeclaredParamNullable',
'PhanUndeclaredProperty', // 66 occurence(s) (e.g. not being specific enough about the subclass)
'PhanUndeclaredMethod', // 32 occurence(s) (e.g. not being specific enough about the subclass of Node)
'PhanTypeMismatchArgument', // 21 occurence(s)
'PhanTypeMismatchProperty', // 13 occurence(s)
'PhanUnreferencedUseNormal', // 10 occurence(s) TODO: Fix
'PhanTypeMismatchDeclaredReturn', // 8 occurence(s)
'PhanUndeclaredTypeProperty', // 7 occurence(s)
'PhanTypeMismatchReturn', // 6 occurence(s)
'PhanUndeclaredVariable', // 4 occurence(s)
'PhanUndeclaredTypeReturnType', // 4 occurence(s)
'PhanParamTooMany', // 3 occurence(s)
'PhanUndeclaredTypeParameter', // 2 occurence(s)
'PhanUndeclaredClassProperty', // 2 occurence(s)
'PhanTypeSuspiciousStringExpression', // 2 occurence(s)
'PhanTypeMismatchArgumentInternal', // 2 occurence(s)
'PhanUnextractableAnnotationElementName', // 1 occurence(s)
'PhanUndeclaredClassMethod', // 1 occurence(s)
'PhanUndeclaredClassInstanceof', // 1 occurence(s)
'PhanTypeSuspiciousNonTraversableForeach', // 1 occurence(s)
'PhanTypeMismatchDimAssignment', // 1 occurence(s)
'PhanTypeMismatchDeclaredParam', // 1 occurence(s)
'PhanTypeInvalidDimOffset', // 1 occurence(s)
],
// A regular expression to match files to be excluded
// from parsing and analysis and will not be read at all.
//
// This is useful for excluding groups of test or example
// directories/files, unanalyzable files, or files that
// can't be removed for whatever reason.
// (e.g. '@Test\.php$@', or '@vendor/.*/(tests|Tests)/@')
'exclude_file_regex' => '@^vendor/.*/(tests?|Tests?)/@',
// A file list that defines files that will be excluded
// from parsing and analysis and will not be read at all.
//
// This is useful for excluding hopelessly unanalyzable
// files that can't be removed for whatever reason.
'exclude_file_list' => [],
// A directory list that defines files that will be excluded
// from static analysis, but whose class and method
// information should be included.
//
// Generally, you'll want to include the directories for
// third-party code (such as "vendor/") in this list.
//
// n.b.: If you'd like to parse but not analyze 3rd
// party code, directories containing that code
// should be added to the `directory_list` as
// to `excluce_analysis_directory_list`.
'exclude_analysis_directory_list' => [
'vendor/',
],
// The number of processes to fork off during the analysis
// phase.
'processes' => 1,
// List of case-insensitive file extensions supported by Phan.
// (e.g. php, html, htm)
'analyzed_file_extensions' => [
'php',
],
// You can put paths to stubs of internal extensions in this config option.
// If the corresponding extension is **not** loaded, then phan will use the stubs instead.
// Phan will continue using its detailed type annotations,
// but load the constants, classes, functions, and classes (and their Reflection types)
// from these stub files (doubling as valid php files).
// Use a different extension from php to avoid accidentally loading these.
// The 'tools/make_stubs' script can be used to generate your own stubs (compatible with php 7.0+ right now)
'autoload_internal_extension_signatures' => [],
// A list of plugin files to execute
// Plugins which are bundled with Phan can be added here by providing their name (e.g. 'AlwaysReturnPlugin')
// Alternately, you can pass in the full path to a PHP file with the plugin's implementation (e.g. 'vendor/phan/phan/.phan/plugins/AlwaysReturnPlugin.php')
'plugins' => [
'AlwaysReturnPlugin',
'DollarDollarPlugin',
'DuplicateArrayKeyPlugin',
'PregRegexCheckerPlugin',
'PrintfCheckerPlugin',
'UnreachableCodePlugin',
],
// A list of directories that should be parsed for class and
// method information. After excluding the directories
// defined in exclude_analysis_directory_list, the remaining
// files will be statically analyzed for errors.
//
// Thus, both first-party and third-party code being used by
// your application should be included in this list.
'directory_list' => [
'src',
'vendor/composer/xdebug-handler/src',
'vendor/felixfbecker/advanced-json-rpc/lib',
'vendor/felixfbecker/language-server-protocol/src/',
'vendor/microsoft/tolerant-php-parser/src',
'vendor/netresearch/jsonmapper/src',
'vendor/phpdocumentor/reflection-common/src',
'vendor/phpdocumentor/reflection-docblock/src',
'vendor/phpdocumentor/type-resolver/src',
'vendor/phpunit/phpunit/src',
'vendor/psr/log/Psr',
'vendor/sabre/event/lib',
'vendor/sabre/uri/lib',
'vendor/webmozart/glob/src',
'vendor/webmozart/path-util/src',
],
// A list of individual files to include in analysis
// with a path relative to the root directory of the
// project
'file_list' => [
'bin/php-language-server.php',
],
];

View File

@ -16,8 +16,11 @@ cache:
install: install:
- composer install --prefer-dist --no-interaction - composer install --prefer-dist --no-interaction
- pecl install ast-1.0.0
script: script:
- vendor/bin/phpcs -n - vendor/bin/phpcs -n
- vendor/bin/phan
- vendor/bin/phpunit --coverage-clover=coverage.xml --colors=always - vendor/bin/phpunit --coverage-clover=coverage.xml --colors=always
- bash <(curl -s https://codecov.io/bash) - bash <(curl -s https://codecov.io/bash)

View File

@ -1,17 +1,19 @@
# Running this container will start a language server that listens for TCP connections on port 2088 # Running this container will start a language server that listens for TCP connections on port 2088
# Every connection will be run in a forked child process # Every connection will be run in a forked child process
# Please note that before building the image, you have to install dependencies with `composer install` FROM composer AS builder
COPY ./ /app
RUN composer install
FROM php:7-cli FROM php:7-cli
MAINTAINER Felix Becker <felix.b@outlook.com> LABEL maintainer="Felix Becker <felix.b@outlook.com>"
RUN docker-php-ext-configure pcntl --enable-pcntl RUN docker-php-ext-configure pcntl --enable-pcntl
RUN docker-php-ext-install pcntl RUN docker-php-ext-install pcntl
COPY ./php.ini /usr/local/etc/php/conf.d/ COPY ./php.ini /usr/local/etc/php/conf.d/
COPY ./ /srv/phpls COPY --from=builder /app /srv/phpls
WORKDIR /srv/phpls WORKDIR /srv/phpls

89
benchmarks/completion.php Normal file
View File

@ -0,0 +1,89 @@
<?php
namespace LanguageServer\Tests;
require __DIR__ . '/../vendor/autoload.php';
use Composer\XdebugHandler\XdebugHandler;
use Exception;
use LanguageServer\CompletionProvider;
use LanguageServer\DefinitionResolver;
use LanguageServer\Index\Index;
use LanguageServer\PhpDocument;
use LanguageServer\StderrLogger;
use LanguageServerProtocol\Position;
use Microsoft\PhpParser;
use phpDocumentor\Reflection\DocBlockFactory;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
$logger = new StderrLogger();
$xdebugHandler = new XdebugHandler('PHPLS');
$xdebugHandler->setLogger($logger);
$xdebugHandler->check();
unset($xdebugHandler);
$totalSize = 0;
$framework = "symfony";
$iterator = new RecursiveDirectoryIterator(__DIR__ . "/../validation/frameworks/$framework");
$testProviderArray = array();
foreach (new RecursiveIteratorIterator($iterator) as $file) {
if (strpos((string)$file, ".php") !== false) {
$totalSize += $file->getSize();
$testProviderArray[] = $file->getRealPath();
}
}
if (count($testProviderArray) === 0) {
throw new Exception("ERROR: Validation testsuite frameworks not found - run `git submodule update --init --recursive` to download.");
}
$index = new Index;
$definitionResolver = new DefinitionResolver($index);
$completionProvider = new CompletionProvider($definitionResolver, $index);
$docBlockFactory = DocBlockFactory::createInstance();
$completionFile = realpath(__DIR__ . '/../validation/frameworks/symfony/src/Symfony/Component/HttpFoundation/Request.php');
$parser = new PhpParser\Parser();
$completionDocument = null;
echo "Indexing $framework" . PHP_EOL;
foreach ($testProviderArray as $idx => $testCaseFile) {
if (filesize($testCaseFile) > 100000) {
continue;
}
if ($idx % 100 === 0) {
echo $idx . '/' . count($testProviderArray) . PHP_EOL;
}
$fileContents = file_get_contents($testCaseFile);
try {
$d = new PhpDocument($testCaseFile, $fileContents, $index, $parser, $docBlockFactory, $definitionResolver);
if ($testCaseFile === $completionFile) {
$completionDocument = $d;
}
} catch (\Throwable $e) {
echo $e->getMessage() . PHP_EOL;
continue;
}
}
echo "Getting completion". PHP_EOL;
// Completion in $this->|request = new ParameterBag($request);
$start = microtime(true);
$list = $completionProvider->provideCompletion($completionDocument, new Position(274, 15));
$end = microtime(true);
echo 'Time ($this->|): ' . ($end - $start) . 's' . PHP_EOL;
echo count($list->items) . ' completion items' . PHP_EOL;
// Completion in $this->request = new| ParameterBag($request);
// (this only finds ParameterBag though.)
$start = microtime(true);
$list = $completionProvider->provideCompletion($completionDocument, new Position(274, 28));
$end = microtime(true);
echo 'Time (new|): ' . ($end - $start) . 's' . PHP_EOL;
echo count($list->items) . ' completion items' . PHP_EOL;

View File

@ -1,23 +1,31 @@
<?php <?php
namespace LanguageServer\Tests; namespace LanguageServer\Tests;
require __DIR__ . '/vendor/autoload.php'; require __DIR__ . '/../vendor/autoload.php';
use Composer\XdebugHandler\XdebugHandler;
use Exception; use Exception;
use LanguageServer\DefinitionResolver;
use LanguageServer\Index\Index; use LanguageServer\Index\Index;
use LanguageServer\PhpDocument; use LanguageServer\PhpDocument;
use LanguageServer\DefinitionResolver; use LanguageServer\StderrLogger;
use Microsoft\PhpParser; use Microsoft\PhpParser;
use phpDocumentor\Reflection\DocBlockFactory; use phpDocumentor\Reflection\DocBlockFactory;
use RecursiveDirectoryIterator; use RecursiveDirectoryIterator;
use RecursiveIteratorIterator; use RecursiveIteratorIterator;
$logger = new StderrLogger();
$xdebugHandler = new XdebugHandler('PHPLS');
$xdebugHandler->setLogger($logger);
$xdebugHandler->check();
unset($xdebugHandler);
$totalSize = 0; $totalSize = 0;
$frameworks = ["drupal", "wordpress", "php-language-server", "tolerant-php-parser", "math-php", "symfony", "codeigniter", "cakephp"]; $frameworks = ["drupal", "wordpress", "php-language-server", "tolerant-php-parser", "math-php", "symfony", "codeigniter", "cakephp"];
foreach($frameworks as $framework) { foreach($frameworks as $framework) {
$iterator = new RecursiveDirectoryIterator(__DIR__ . "/validation/frameworks/$framework"); $iterator = new RecursiveDirectoryIterator(__DIR__ . "/../validation/frameworks/$framework");
$testProviderArray = array(); $testProviderArray = array();
foreach (new RecursiveIteratorIterator($iterator) as $file) { foreach (new RecursiveIteratorIterator($iterator) as $file) {
@ -37,8 +45,8 @@ foreach($frameworks as $framework) {
if (filesize($testCaseFile) > 10000) { if (filesize($testCaseFile) > 10000) {
continue; continue;
} }
if ($idx % 1000 === 0) { if ($idx % 500 === 0) {
echo "$idx\n"; echo $idx . '/' . count($testProviderArray) . PHP_EOL;
} }
$fileContents = file_get_contents($testCaseFile); $fileContents = file_get_contents($testCaseFile);

View File

@ -37,6 +37,7 @@
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^6.3", "phpunit/phpunit": "^6.3",
"phan/phan": "1.1.4",
"squizlabs/php_codesniffer": "^3.1" "squizlabs/php_codesniffer": "^3.1"
}, },
"autoload": { "autoload": {

View File

@ -0,0 +1,10 @@
<?php
namespace Whatever;
use TestNamespace\InnerNamespace as AliasNamespace;
class IDontShowUpInCompletion {}
AliasNamespace\I;
AliasNamespace\;

View File

@ -0,0 +1,20 @@
<?php
namespace RecursiveTest;
class A extends A {}
class B extends C {}
class C extends B {}
class D extends E {}
class E extends F {}
class F extends D {}
$a = new A;
$a->undef_prop = 1;
$b = new B;
$b->undef_prop = 1;
$d = new D;
$d->undef_prop = 1;

View File

@ -103,3 +103,8 @@ class Example {
public function __construct() {} public function __construct() {}
public function __destruct() {} public function __destruct() {}
} }
namespace TestNamespace\InnerNamespace;
class InnerClass {
}

4
package-lock.json generated
View File

@ -1,8 +1,6 @@
{ {
"name": "php-language-server",
"version": "0.0.0-development",
"lockfileVersion": 1,
"requires": true, "requires": true,
"lockfileVersion": 1,
"dependencies": { "dependencies": {
"@gimenete/type-writer": { "@gimenete/type-writer": {
"version": "0.1.3", "version": "0.1.3",

View File

@ -17,7 +17,7 @@
"prepare": [ "prepare": [
{ {
"path": "@semantic-release/exec", "path": "@semantic-release/exec",
"cmd": "composer install --prefer-dist --no-interaction && docker build -t felixfbecker/php-language-server ." "cmd": "docker build -t felixfbecker/php-language-server ."
} }
], ],
"publish": [ "publish": [

View File

@ -11,6 +11,11 @@ use Sabre\Event\Promise;
*/ */
class ClientCache implements Cache class ClientCache implements Cache
{ {
/**
* @var LanguageClient
*/
public $client;
/** /**
* @param LanguageClient $client * @param LanguageClient $client
*/ */

View File

@ -4,7 +4,7 @@ declare(strict_types = 1);
namespace LanguageServer\Client; namespace LanguageServer\Client;
use LanguageServer\ClientHandler; use LanguageServer\ClientHandler;
use LanguageServerProtocol\{TextDocumentItem, TextDocumentIdentifier}; use LanguageServerProtocol\{Diagnostic, TextDocumentItem, TextDocumentIdentifier};
use Sabre\Event\Promise; use Sabre\Event\Promise;
use JsonMapper; use JsonMapper;

View File

@ -17,7 +17,15 @@ use LanguageServerProtocol\{
}; };
use Microsoft\PhpParser; use Microsoft\PhpParser;
use Microsoft\PhpParser\Node; use Microsoft\PhpParser\Node;
use Microsoft\PhpParser\ResolvedName;
use Generator; use Generator;
use function LanguageServer\FqnUtilities\{
nameConcat,
nameGetFirstPart,
nameGetParent,
nameStartsWith,
nameWithoutFirstPart
};
class CompletionProvider class CompletionProvider
{ {
@ -144,8 +152,11 @@ class CompletionProvider
* @param CompletionContext $context The completion context * @param CompletionContext $context The completion context
* @return CompletionList * @return CompletionList
*/ */
public function provideCompletion(PhpDocument $doc, Position $pos, CompletionContext $context = null): CompletionList public function provideCompletion(
{ PhpDocument $doc,
Position $pos,
CompletionContext $context = null
): CompletionList {
// This can be made much more performant if the tree follows specific invariants. // This can be made much more performant if the tree follows specific invariants.
$node = $doc->getNodeAtPosition($pos); $node = $doc->getNodeAtPosition($pos);
@ -237,16 +248,14 @@ class CompletionProvider
$this->definitionResolver->resolveExpressionNodeToType($node->dereferencableExpression) $this->definitionResolver->resolveExpressionNodeToType($node->dereferencableExpression)
); );
// Add the object access operator to only get members of all parents // The FQNs of the symbol and its parents (eg the implemented interfaces)
$prefixes = []; foreach ($this->expandParentFqns($fqns) as $parentFqn) {
foreach ($this->expandParentFqns($fqns) as $prefix) { // Add the object access operator to only get members of all parents
$prefixes[] = $prefix . '->'; $prefix = $parentFqn . '->';
} $prefixLen = strlen($prefix);
// Collect fqn definitions
// Collect all definitions that match any of the prefixes foreach ($this->index->getChildDefinitionsForFqn($parentFqn) as $fqn => $def) {
foreach ($this->index->getDefinitions() as $fqn => $def) { if (substr($fqn, 0, $prefixLen) === $prefix && $def->isMember) {
foreach ($prefixes as $prefix) {
if (substr($fqn, 0, strlen($prefix)) === $prefix && $def->isMember) {
$list->items[] = CompletionItemFactory::fromDefinition($def); $list->items[] = CompletionItemFactory::fromDefinition($def);
} }
} }
@ -270,16 +279,14 @@ class CompletionProvider
$classType = $this->definitionResolver->resolveExpressionNodeToType($scoped->scopeResolutionQualifier) $classType = $this->definitionResolver->resolveExpressionNodeToType($scoped->scopeResolutionQualifier)
); );
// Append :: operator to only get static members of all parents // The FQNs of the symbol and its parents (eg the implemented interfaces)
$prefixes = []; foreach ($this->expandParentFqns($fqns) as $parentFqn) {
foreach ($this->expandParentFqns($fqns) as $prefix) { // Append :: operator to only get static members of all parents
$prefixes[] = $prefix . '::'; $prefix = strtolower($parentFqn . '::');
} $prefixLen = strlen($prefix);
// Collect fqn definitions
// Collect all definitions that match any of the prefixes foreach ($this->index->getChildDefinitionsForFqn($parentFqn) as $fqn => $def) {
foreach ($this->index->getDefinitions() as $fqn => $def) { if (substr(strtolower($fqn), 0, $prefixLen) === $prefix && $def->isMember) {
foreach ($prefixes as $prefix) {
if (substr(strtolower($fqn), 0, strlen($prefix)) === strtolower($prefix) && $def->isMember) {
$list->items[] = CompletionItemFactory::fromDefinition($def); $list->items[] = CompletionItemFactory::fromDefinition($def);
} }
} }
@ -297,114 +304,278 @@ class CompletionProvider
// my_func| // my_func|
// MY_CONS| // MY_CONS|
// MyCla| // MyCla|
// \MyCla|
// The name Node under the cursor // The name Node under the cursor
$nameNode = isset($creation) ? $creation->classTypeDesignator : $node; $nameNode = isset($creation) ? $creation->classTypeDesignator : $node;
/** The typed name */ if ($nameNode instanceof Node\QualifiedName) {
$prefix = $nameNode instanceof Node\QualifiedName /** @var string The typed name. */
? (string)PhpParser\ResolvedName::buildName($nameNode->nameParts, $nameNode->getFileContents()) $prefix = (string)PhpParser\ResolvedName::buildName($nameNode->nameParts, $nameNode->getFileContents());
: $nameNode->getText($node->getFileContents()); } else {
$prefixLen = strlen($prefix); $prefix = $nameNode->getText($node->getFileContents());
}
/** Whether the prefix is qualified (contains at least one backslash) */
$isQualified = $nameNode instanceof Node\QualifiedName && $nameNode->isQualifiedName();
/** Whether the prefix is fully qualified (begins with a backslash) */
$isFullyQualified = $nameNode instanceof Node\QualifiedName && $nameNode->isFullyQualifiedName();
/** The closest NamespaceDefinition Node */
$namespaceNode = $node->getNamespaceDefinition(); $namespaceNode = $node->getNamespaceDefinition();
/** @var string The current namespace without a leading backslash. */
$currentNamespace = $namespaceNode === null ? '' : $namespaceNode->name->getText();
/** @var string The name of the namespace */ /** @var bool Whether the prefix is qualified (contains at least one backslash) */
$namespacedPrefix = null; $isFullyQualified = false;
if ($namespaceNode) {
$namespacedPrefix = (string)PhpParser\ResolvedName::buildName($namespaceNode->name->nameParts, $node->getFileContents()) . '\\' . $prefix; /** @var bool Whether the prefix is qualified (contains at least one backslash) */
$namespacedPrefixLen = strlen($namespacedPrefix); $isQualified = false;
if ($nameNode instanceof Node\QualifiedName) {
$isFullyQualified = $nameNode->isFullyQualifiedName();
$isQualified = $nameNode->isQualifiedName();
} }
// Get the namespace use statements /** @var bool Whether we are in a new expression */
// TODO: use function statements, use const statements $isCreation = isset($creation);
/** @var string[] $aliases A map from local alias to fully qualified name */ /** @var array Import (use) tables */
list($aliases,,) = $node->getImportTablesForCurrentScope(); $importTables = $node->getImportTablesForCurrentScope();
foreach ($aliases as $alias => $name) { if ($isFullyQualified) {
$aliases[$alias] = (string)$name; // \Prefix\Goes\Here| - Only return completions from the root namespace.
/** @var $items \Generator|CompletionItem[] Generator yielding CompletionItems indexed by their FQN */
$items = $this->getCompletionsForFqnPrefix($prefix, $isCreation, false);
} else if ($isQualified) {
// Prefix\Goes\Here|
$items = $this->getPartiallyQualifiedCompletions(
$prefix,
$currentNamespace,
$importTables,
$isCreation
);
} else {
// PrefixGoesHere|
$items = $this->getUnqualifiedCompletions($prefix, $currentNamespace, $importTables, $isCreation);
} }
// If there is a prefix that does not start with a slash, suggest `use`d symbols $list->items = array_values(iterator_to_array($items));
if ($prefix && !$isFullyQualified) { foreach ($list->items as $item) {
foreach ($aliases as $alias => $fqn) { // Remove ()
// Suggest symbols that have been `use`d and match the prefix if (is_string($item->insertText) && substr($item->insertText, strlen($item->insertText) - 2) === '()') {
if (substr($alias, 0, $prefixLen) === $prefix && ($def = $this->index->getDefinition($fqn))) { $item->insertText = substr($item->insertText, 0, -2);
$list->items[] = CompletionItemFactory::fromDefinition($def);
}
} }
} }
// Suggest global symbols that either }
// - start with the current namespace + prefix, if the Name node is not fully qualified return $list;
// - start with just the prefix, if the Name node is fully qualified }
foreach ($this->index->getDefinitions() as $fqn => $def) {
$fqnStartsWithPrefix = substr($fqn, 0, $prefixLen) === $prefix; private function getPartiallyQualifiedCompletions(
string $prefix,
if ( string $currentNamespace,
// Exclude methods, properties etc. array $importTables,
!$def->isMember bool $requireCanBeInstantiated
&& ( ): \Generator {
!$prefix // If the first part of the partially qualified name matches a namespace alias,
|| ( // only definitions below that alias can be completed.
// Either not qualified, but a matching prefix with global fallback list($namespaceAliases,,) = $importTables;
($def->roamed && !$isQualified && $fqnStartsWithPrefix) $prefixFirstPart = nameGetFirstPart($prefix);
// Or not in a namespace or a fully qualified name or AND matching the prefix $foundAlias = $foundAliasFqn = null;
|| ((!$namespaceNode || $isFullyQualified) && $fqnStartsWithPrefix) foreach ($namespaceAliases as $alias => $aliasFqn) {
// Or in a namespace, not fully qualified and matching the prefix + current namespace if (strcasecmp($prefixFirstPart, $alias) === 0) {
|| ( $foundAlias = $alias;
$namespaceNode $foundAliasFqn = (string)$aliasFqn;
&& !$isFullyQualified break;
&& substr($fqn, 0, $namespacedPrefixLen) === $namespacedPrefix
)
)
)
// Only suggest classes for `new`
&& (!isset($creation) || $def->canBeInstantiated)
) {
$item = CompletionItemFactory::fromDefinition($def);
// Find the shortest name to reference the symbol
if ($namespaceNode && ($alias = array_search($fqn, $aliases, true)) !== false) {
// $alias is the name under which this definition is aliased in the current namespace
$item->insertText = $alias;
} else if ($namespaceNode && !($prefix && $isFullyQualified)) {
// Insert the global FQN with leading backslash
$item->insertText = '\\' . $fqn;
} else {
// Insert the FQN without leading backlash
$item->insertText = $fqn;
}
// Don't insert the parenthesis for functions
// TODO return a snippet and put the cursor inside
if (substr($item->insertText, -2) === '()') {
$item->insertText = substr($item->insertText, 0, -2);
}
$list->items[] = $item;
}
}
// If not a class instantiation, also suggest keywords
if (!isset($creation)) {
foreach (self::KEYWORDS as $keyword) {
if (substr($keyword, 0, $prefixLen) === $prefix) {
$item = new CompletionItem($keyword, CompletionItemKind::KEYWORD);
$item->insertText = $keyword;
$list->items[] = $item;
}
}
} }
} }
return $list; if ($foundAlias !== null) {
yield from $this->getCompletionsFromAliasedNamespace(
$prefix,
$foundAlias,
$foundAliasFqn,
$requireCanBeInstantiated
);
} else {
yield from $this->getCompletionsForFqnPrefix(
nameConcat($currentNamespace, $prefix),
$requireCanBeInstantiated,
false
);
}
}
/**
* Yields completions for non-qualified global names.
*
* Yields
* - Aliased classes
* - Completions from current namespace
* - Roamed completions from the global namespace (when not creating and not already in root NS)
* - PHP keywords (when not creating)
*
* @return \Generator|CompletionItem[]
* Yields CompletionItems
*/
private function getUnqualifiedCompletions(
string $prefix,
string $currentNamespace,
array $importTables,
bool $requireCanBeInstantiated
): \Generator {
// Aliases
list($namespaceAliases,,) = $importTables;
// use Foo\Bar
yield from $this->getCompletionsForAliases(
$prefix,
$namespaceAliases,
$requireCanBeInstantiated
);
// Completions from the current namespace
yield from $this->getCompletionsForFqnPrefix(
nameConcat($currentNamespace, $prefix),
$requireCanBeInstantiated,
false
);
if ($currentNamespace !== '' && $prefix === '') {
// Get additional suggestions from the global namespace.
// When completing e.g. for new |, suggest \DateTime
yield from $this->getCompletionsForFqnPrefix('', $requireCanBeInstantiated, true);
}
if (!$requireCanBeInstantiated) {
if ($currentNamespace !== '' && $prefix !== '') {
// Roamed definitions (i.e. global constants and functions). The prefix is checked against '', since
// in that case global completions have already been provided (including non-roamed definitions.)
yield from $this->getRoamedCompletions($prefix);
}
// Lastly and least importantly, suggest keywords.
yield from $this->getCompletionsForKeywords($prefix);
}
}
/**
* Gets completions for prefixes of fully qualified names in their parent namespace.
*
* @param string $prefix Prefix to complete for. Fully qualified.
* @param bool $requireCanBeInstantiated If set, only return classes.
* @param bool $insertFullyQualified If set, return completion with the leading \ inserted.
* @return \Generator|CompletionItem[]
* Yields CompletionItems.
*/
private function getCompletionsForFqnPrefix(
string $prefix,
bool $requireCanBeInstantiated,
bool $insertFullyQualified
): \Generator {
$namespace = nameGetParent($prefix);
foreach ($this->index->getChildDefinitionsForFqn($namespace) as $fqn => $def) {
if ($requireCanBeInstantiated && !$def->canBeInstantiated) {
continue;
}
if (!nameStartsWith($fqn, $prefix)) {
continue;
}
$completion = CompletionItemFactory::fromDefinition($def);
if ($insertFullyQualified) {
$completion->insertText = '\\' . $fqn;
}
yield $fqn => $completion;
}
}
/**
* Gets completions for non-qualified names matching the start of an used class, function, or constant.
*
* @param string $prefix Non-qualified name being completed for
* @param QualifiedName[] $aliases Array of alias FQNs indexed by the alias.
* @return \Generator|CompletionItem[]
* Yields CompletionItems.
*/
private function getCompletionsForAliases(
string $prefix,
array $aliases,
bool $requireCanBeInstantiated
): \Generator {
foreach ($aliases as $alias => $aliasFqn) {
if (!nameStartsWith($alias, $prefix)) {
continue;
}
$definition = $this->index->getDefinition((string)$aliasFqn);
if ($definition) {
if ($requireCanBeInstantiated && !$definition->canBeInstantiated) {
continue;
}
$completionItem = CompletionItemFactory::fromDefinition($definition);
$completionItem->insertText = $alias;
yield (string)$aliasFqn => $completionItem;
}
}
}
/**
* Gets completions for partially qualified names, where the first part is matched by an alias.
*
* @return \Generator|CompletionItem[]
* Yields CompletionItems.
*/
private function getCompletionsFromAliasedNamespace(
string $prefix,
string $alias,
string $aliasFqn,
bool $requireCanBeInstantiated
): \Generator {
$prefixFirstPart = nameGetFirstPart($prefix);
// Matched alias.
$resolvedPrefix = nameConcat($aliasFqn, nameWithoutFirstPart($prefix));
$completionItems = $this->getCompletionsForFqnPrefix(
$resolvedPrefix,
$requireCanBeInstantiated,
false
);
// Convert FQNs in the CompletionItems so they are expressed in terms of the alias.
foreach ($completionItems as $fqn => $completionItem) {
/** @var string $fqn with the leading parts determined by the alias removed. Has the leading backslash. */
$nameWithoutAliasedPart = substr($fqn, strlen($aliasFqn));
$completionItem->insertText = $alias . $nameWithoutAliasedPart;
yield $fqn => $completionItem;
}
}
/**
* Gets completions for globally defined functions and constants (i.e. symbols which may be used anywhere)
*
* @return \Generator|CompletionItem[]
* Yields CompletionItems.
*/
private function getRoamedCompletions(string $prefix): \Generator
{
foreach ($this->index->getChildDefinitionsForFqn('') as $fqn => $def) {
if (!$def->roamed || !nameStartsWith($fqn, $prefix)) {
continue;
}
$completionItem = CompletionItemFactory::fromDefinition($def);
// Second-guessing the user here - do not trust roaming to work. If the same symbol is
// inserted in the current namespace, the code will stop working.
$completionItem->insertText = '\\' . $fqn;
yield $fqn => $completionItem;
}
}
/**
* Completes PHP keywords.
*
* @return \Generator|CompletionItem[]
* Yields CompletionItems.
*/
private function getCompletionsForKeywords(string $prefix): \Generator
{
foreach (self::KEYWORDS as $keyword) {
if (nameStartsWith($keyword, $prefix)) {
$item = new CompletionItem($keyword, CompletionItemKind::KEYWORD);
$item->insertText = $keyword;
yield $keyword => $item;
}
}
} }
/** /**
@ -473,8 +644,9 @@ class CompletionProvider
} }
} }
if ($level instanceof Node\Expression\AnonymousFunctionCreationExpression && $level->anonymousFunctionUseClause !== null && if ($level instanceof Node\Expression\AnonymousFunctionCreationExpression
$level->anonymousFunctionUseClause->useVariableNameList !== null) { && $level->anonymousFunctionUseClause !== null
&& $level->anonymousFunctionUseClause->useVariableNameList !== null) {
foreach ($level->anonymousFunctionUseClause->useVariableNameList->getValues() as $use) { foreach ($level->anonymousFunctionUseClause->useVariableNameList->getValues() as $use) {
$useName = $use->getName(); $useName = $use->getName();
if (empty($namePrefix) || strpos($useName, $namePrefix) !== false) { if (empty($namePrefix) || strpos($useName, $namePrefix) !== false) {

View File

@ -4,7 +4,7 @@ declare(strict_types = 1);
namespace LanguageServer; namespace LanguageServer;
use LanguageServer\Index\ReadableIndex; use LanguageServer\Index\ReadableIndex;
use phpDocumentor\Reflection\{Types, Type, Fqsen, TypeResolver}; use phpDocumentor\Reflection\{Types, Type, TypeResolver};
use LanguageServerProtocol\SymbolInformation; use LanguageServerProtocol\SymbolInformation;
use Generator; use Generator;
@ -80,7 +80,7 @@ class Definition
* Can also be a compound type. * Can also be a compound type.
* If it is unknown, will be Types\Mixed_. * If it is unknown, will be Types\Mixed_.
* *
* @var \phpDocumentor\Type|null * @var Type|null
*/ */
public $type; public $type;

View File

@ -438,6 +438,7 @@ class DefinitionResolver
// Find the right class that implements the member // Find the right class that implements the member
$implementorFqns = [$classFqn]; $implementorFqns = [$classFqn];
$visitedFqns = [];
while ($implementorFqn = array_shift($implementorFqns)) { while ($implementorFqn = array_shift($implementorFqns)) {
// If the member FQN exists, return it // If the member FQN exists, return it
@ -450,10 +451,15 @@ class DefinitionResolver
if ($implementorDef === null) { if ($implementorDef === null) {
break; break;
} }
// Note the FQN as visited
$visitedFqns[] = $implementorFqn;
// Repeat for parent class // Repeat for parent class
if ($implementorDef->extends) { if ($implementorDef->extends) {
foreach ($implementorDef->extends as $extends) { foreach ($implementorDef->extends as $extends) {
$implementorFqns[] = $extends; // Don't add the parent FQN if it's already been visited
if (!\in_array($extends, $visitedFqns)) {
$implementorFqns[] = $extends;
}
} }
} }
} }
@ -507,11 +513,11 @@ class DefinitionResolver
} else { } else {
$nameSuffix = '::' . $scoped->memberName->getText($scoped->getFileContents()); $nameSuffix = '::' . $scoped->memberName->getText($scoped->getFileContents());
} }
if ($scoped->parent instanceof Node\Expression\CallExpression) {
$nameSuffix .= '()';
}
do { do {
$name = (string)$className . $nameSuffix; $name = (string)$className . $nameSuffix;
if ($scoped->parent instanceof Node\Expression\CallExpression) {
$name .= '()';
}
if ($origName === null) { if ($origName === null) {
$origName = $name; $origName = $name;
} }
@ -1251,7 +1257,13 @@ class DefinitionResolver
if ( if (
$node instanceof PhpParser\ClassLike $node instanceof PhpParser\ClassLike
) { ) {
return (string) $node->getNamespacedName(); $className = (string)$node->getNamespacedName();
// An (invalid) class declaration without a name will have an empty string as name,
// but should not define an FQN
if ($className === '') {
return null;
}
return $className;
} }
// INPUT OUTPUT: // INPUT OUTPUT:

View File

@ -28,3 +28,91 @@ function getFqnsFromType($type): array
} }
return $fqns; return $fqns;
} }
/**
* Returns parent of an FQN.
*
* getFqnParent('') === ''
* getFqnParent('\\') === ''
* getFqnParent('\A') === ''
* getFqnParent('A') === ''
* getFqnParent('\A\') === '\A' // Empty trailing name is considered a name.
*
* @return string
*/
function nameGetParent(string $name): string
{
if ($name === '') { // Special-case handling for the root namespace.
return '';
}
$parts = explode('\\', $name);
array_pop($parts);
return implode('\\', $parts);
}
/**
* Concatenates two names.
*
* nameConcat('\Foo\Bar', 'Baz') === '\Foo\Bar\Baz'
* nameConcat('\Foo\Bar\\', '\Baz') === '\Foo\Bar\Baz'
* nameConcat('\\', 'Baz') === '\Baz'
* nameConcat('', 'Baz') === 'Baz'
*
* @return string
*/
function nameConcat(string $a, string $b): string
{
if ($a === '') {
return $b;
}
$a = rtrim($a, '\\');
$b = ltrim($b, '\\');
return "$a\\$b";
}
/**
* Returns the first component of $name.
*
* nameGetFirstPart('Foo\Bar') === 'Foo'
* nameGetFirstPart('\Foo\Bar') === 'Foo'
* nameGetFirstPart('') === ''
* nameGetFirstPart('\') === ''
*/
function nameGetFirstPart(string $name): string
{
$parts = explode('\\', $name, 3);
if ($parts[0] === '' && count($parts) > 1) {
return $parts[1];
} else {
return $parts[0];
}
}
/**
* Removes the first component of $name.
*
* nameWithoutFirstPart('Foo\Bar') === 'Bar'
* nameWithoutFirstPart('\Foo\Bar') === 'Bar'
* nameWithoutFirstPart('') === ''
* nameWithoutFirstPart('\') === ''
*/
function nameWithoutFirstPart(string $name): string
{
$parts = explode('\\', $name, 3);
if ($parts[0] === '') {
array_shift($parts);
}
array_shift($parts);
return implode('\\', $parts);
}
/**
* @param string $name Name to match against
* @param string $prefix Prefix $name has to starts with
* @return bool
*/
function nameStartsWith(string $name, string $prefix): bool
{
return strlen($name) >= strlen($prefix)
&& strncmp($name, $prefix, strlen($prefix)) === 0;
}

View File

@ -99,20 +99,29 @@ abstract class AbstractAggregateIndex implements ReadableIndex
} }
/** /**
* Returns an associative array [string => Definition] that maps fully qualified symbol names * Returns a Generator providing an associative array [string => Definition]
* to Definitions * that maps fully qualified symbol names to Definitions (global or not)
* *
* @return Definition[] * @return \Generator yields Definition
*/ */
public function getDefinitions(): array public function getDefinitions(): \Generator
{ {
$defs = [];
foreach ($this->getIndexes() as $index) { foreach ($this->getIndexes() as $index) {
foreach ($index->getDefinitions() as $fqn => $def) { yield from $index->getDefinitions();
$defs[$fqn] = $def; }
} }
/**
* Returns a Generator that yields all the direct child Definitions of a given FQN
*
* @param string $fqn
* @return \Generator yields Definition
*/
public function getChildDefinitionsForFqn(string $fqn): \Generator
{
foreach ($this->getIndexes() as $index) {
yield from $index->getChildDefinitionsForFqn($fqn);
} }
return $defs;
} }
/** /**
@ -132,19 +141,15 @@ abstract class AbstractAggregateIndex implements ReadableIndex
} }
/** /**
* Returns all URIs in this index that reference a symbol * Returns a Generator providing all URIs in this index that reference a symbol
* *
* @param string $fqn The fully qualified name of the symbol * @param string $fqn The fully qualified name of the symbol
* @return string[] * @return \Generator yields string
*/ */
public function getReferenceUris(string $fqn): array public function getReferenceUris(string $fqn): \Generator
{ {
$refs = [];
foreach ($this->getIndexes() as $index) { foreach ($this->getIndexes() as $index) {
foreach ($index->getReferenceUris($fqn) as $ref) { yield from $index->getReferenceUris($fqn);
$refs[] = $ref;
}
} }
return $refs;
} }
} }

View File

@ -15,14 +15,26 @@ class Index implements ReadableIndex, \Serializable
use EmitterTrait; use EmitterTrait;
/** /**
* An associative array that maps fully qualified symbol names to Definitions * An associative array that maps splitted fully qualified symbol names
* to definitions, eg :
* [
* 'Psr' => [
* '\Log' => [
* '\LoggerInterface' => [
* '' => $def1, // definition for 'Psr\Log\LoggerInterface' which is non-member
* '->log()' => $def2, // definition for 'Psr\Log\LoggerInterface->log()' which is a member
* ],
* ],
* ],
* ]
* *
* @var Definition[] * @var array
*/ */
private $definitions = []; private $definitions = [];
/** /**
* An associative array that maps fully qualified symbol names to arrays of document URIs that reference the symbol * An associative array that maps fully qualified symbol names
* to arrays of document URIs that reference the symbol
* *
* @var string[][] * @var string[][]
*/ */
@ -84,14 +96,46 @@ class Index implements ReadableIndex, \Serializable
} }
/** /**
* Returns an associative array [string => Definition] that maps fully qualified symbol names * Returns a Generator providing an associative array [string => Definition]
* to Definitions * that maps fully qualified symbol names to Definitions (global or not)
* *
* @return Definition[] * @return \Generator yields Definition
*/ */
public function getDefinitions(): array public function getDefinitions(): \Generator
{ {
return $this->definitions; yield from $this->yieldDefinitionsRecursively($this->definitions);
}
/**
* Returns a Generator that yields all the direct child Definitions of a given FQN
*
* @param string $fqn
* @return \Generator yields Definition
*/
public function getChildDefinitionsForFqn(string $fqn): \Generator
{
$parts = $this->splitFqn($fqn);
if ('' === end($parts)) {
// we want to return all the definitions in the given FQN, not only
// the one (non member) matching exactly the FQN.
array_pop($parts);
}
$result = $this->getIndexValue($parts, $this->definitions);
if (!$result) {
return;
}
foreach ($result as $name => $item) {
// Don't yield the parent
if ($name === '') {
continue;
}
if ($item instanceof Definition) {
yield $fqn.$name => $item;
} elseif (is_array($item) && isset($item[''])) {
yield $fqn.$name => $item[''];
}
}
} }
/** /**
@ -103,12 +147,17 @@ class Index implements ReadableIndex, \Serializable
*/ */
public function getDefinition(string $fqn, bool $globalFallback = false) public function getDefinition(string $fqn, bool $globalFallback = false)
{ {
if (isset($this->definitions[$fqn])) { $parts = $this->splitFqn($fqn);
return $this->definitions[$fqn]; $result = $this->getIndexValue($parts, $this->definitions);
if ($result instanceof Definition) {
return $result;
} }
if ($globalFallback) { if ($globalFallback) {
$parts = explode('\\', $fqn); $parts = explode('\\', $fqn);
$fqn = end($parts); $fqn = end($parts);
return $this->getDefinition($fqn); return $this->getDefinition($fqn);
} }
} }
@ -122,7 +171,9 @@ class Index implements ReadableIndex, \Serializable
*/ */
public function setDefinition(string $fqn, Definition $definition) public function setDefinition(string $fqn, Definition $definition)
{ {
$this->definitions[$fqn] = $definition; $parts = $this->splitFqn($fqn);
$this->indexDefinition(0, $parts, $this->definitions, $definition);
$this->emit('definition-added'); $this->emit('definition-added');
} }
@ -135,19 +186,23 @@ class Index implements ReadableIndex, \Serializable
*/ */
public function removeDefinition(string $fqn) public function removeDefinition(string $fqn)
{ {
unset($this->definitions[$fqn]); $parts = $this->splitFqn($fqn);
$this->removeIndexedDefinition(0, $parts, $this->definitions, $this->definitions);
unset($this->references[$fqn]); unset($this->references[$fqn]);
} }
/** /**
* Returns all URIs in this index that reference a symbol * Returns a Generator providing all URIs in this index that reference a symbol
* *
* @param string $fqn The fully qualified name of the symbol * @param string $fqn The fully qualified name of the symbol
* @return string[] * @return \Generator yields string
*/ */
public function getReferenceUris(string $fqn): array public function getReferenceUris(string $fqn): \Generator
{ {
return $this->references[$fqn] ?? []; foreach ($this->references[$fqn] ?? [] as $uri) {
yield $uri;
}
} }
/** /**
@ -204,22 +259,184 @@ class Index implements ReadableIndex, \Serializable
public function unserialize($serialized) public function unserialize($serialized)
{ {
$data = unserialize($serialized); $data = unserialize($serialized);
if (isset($data['definitions'])) {
foreach ($data['definitions'] as $fqn => $definition) {
$this->setDefinition($fqn, $definition);
}
unset($data['definitions']);
}
foreach ($data as $prop => $val) { foreach ($data as $prop => $val) {
$this->$prop = $val; $this->$prop = $val;
} }
} }
/** /**
* @param string $serialized
* @return string * @return string
*/ */
public function serialize() public function serialize()
{ {
return serialize([ return serialize([
'definitions' => $this->definitions, 'definitions' => iterator_to_array($this->getDefinitions()),
'references' => $this->references, 'references' => $this->references,
'complete' => $this->complete, 'complete' => $this->complete,
'staticComplete' => $this->staticComplete 'staticComplete' => $this->staticComplete
]); ]);
} }
/**
* Returns a Generator that yields all the Definitions in the given $storage recursively.
* The generator yields key => value pairs, e.g.
* `'Psr\Log\LoggerInterface->log()' => $definition`
*
* @param array &$storage
* @param string $prefix (optional)
* @return \Generator
*/
private function yieldDefinitionsRecursively(array &$storage, string $prefix = ''): \Generator
{
foreach ($storage as $key => $value) {
if (!is_array($value)) {
yield $prefix.$key => $value;
} else {
yield from $this->yieldDefinitionsRecursively($value, $prefix.$key);
}
}
}
/**
* Splits the given FQN into an array, eg :
* - `'Psr\Log\LoggerInterface->log'` will be `['Psr', '\Log', '\LoggerInterface', '->log()']`
* - `'\Exception->getMessage()'` will be `['\Exception', '->getMessage()']`
* - `'PHP_VERSION'` will be `['PHP_VERSION']`
*
* @param string $fqn
* @return string[]
*/
private function splitFqn(string $fqn): array
{
// split fqn at backslashes
$parts = explode('\\', $fqn);
// write back the backslash prefix to the first part if it was present
if ('' === $parts[0] && count($parts) > 1) {
$parts = array_slice($parts, 1);
$parts[0] = '\\' . $parts[0];
}
// write back the backslashes prefixes for the other parts
for ($i = 1; $i < count($parts); $i++) {
$parts[$i] = '\\' . $parts[$i];
}
// split the last part in 2 parts at the operator
$hasOperator = false;
$lastPart = end($parts);
foreach (['::', '->'] as $operator) {
$endParts = explode($operator, $lastPart);
if (count($endParts) > 1) {
$hasOperator = true;
// replace the last part by its pieces
array_pop($parts);
$parts[] = $endParts[0];
$parts[] = $operator . $endParts[1];
break;
}
}
// The end($parts) === '' holds for the root namespace.
if (!$hasOperator && end($parts) !== '') {
// add an empty part to store the non-member definition to avoid
// definition collisions in the index array, eg
// 'Psr\Log\LoggerInterface' will be stored at
// ['Psr']['\Log']['\LoggerInterface'][''] to be able to also store
// member definitions, ie 'Psr\Log\LoggerInterface->log()' will be
// stored at ['Psr']['\Log']['\LoggerInterface']['->log()']
$parts[] = '';
}
return $parts;
}
/**
* Return the values stored in this index under the given $parts array.
* It can be an index node or a Definition if the $parts are precise
* enough. Returns null when nothing is found.
*
* @param string[] $path The splitted FQN
* @param array|Definition &$storage The current level to look for $path.
* @return array|Definition|null
*/
private function getIndexValue(array $path, &$storage)
{
// Empty path returns the object itself.
if (empty($path)) {
return $storage;
}
$part = array_shift($path);
if (!isset($storage[$part])) {
return null;
}
return $this->getIndexValue($path, $storage[$part]);
}
/**
* Recursive function that stores the given Definition in the given $storage array represented
* as a tree matching the given $parts.
*
* @param int $level The current level of FQN part
* @param string[] $parts The splitted FQN
* @param array &$storage The array in which to store the $definition
* @param Definition $definition The Definition to store
*/
private function indexDefinition(int $level, array $parts, array &$storage, Definition $definition)
{
$part = $parts[$level];
if ($level + 1 === count($parts)) {
$storage[$part] = $definition;
return;
}
if (!isset($storage[$part])) {
$storage[$part] = [];
}
$this->indexDefinition($level + 1, $parts, $storage[$part], $definition);
}
/**
* Recursive function that removes the definition matching the given $parts from the given
* $storage array. The function also looks up recursively to remove the parents of the
* definition which no longer has children to avoid to let empty arrays in the index.
*
* @param int $level The current level of FQN part
* @param string[] $parts The splitted FQN
* @param array &$storage The current array in which to remove data
* @param array &$rootStorage The root storage array
*/
private function removeIndexedDefinition(int $level, array $parts, array &$storage, array &$rootStorage)
{
$part = $parts[$level];
if ($level + 1 === count($parts)) {
if (isset($storage[$part])) {
unset($storage[$part]);
if (0 === count($storage)) {
// parse again the definition tree to remove the parent
// when it has no more children
$this->removeIndexedDefinition(0, array_slice($parts, 0, $level), $rootStorage, $rootStorage);
}
}
} else {
$this->removeIndexedDefinition($level + 1, $parts, $storage[$part], $rootStorage);
}
}
} }

View File

@ -30,12 +30,20 @@ interface ReadableIndex extends EmitterInterface
public function isStaticComplete(): bool; public function isStaticComplete(): bool;
/** /**
* Returns an associative array [string => Definition] that maps fully qualified symbol names * Returns a Generator providing an associative array [string => Definition]
* to Definitions * that maps fully qualified symbol names to Definitions (global or not)
* *
* @return Definitions[] * @return \Generator yields Definition
*/ */
public function getDefinitions(): array; public function getDefinitions(): \Generator;
/**
* Returns a Generator that yields all the direct child Definitions of a given FQN
*
* @param string $fqn
* @return \Generator yields Definition
*/
public function getChildDefinitionsForFqn(string $fqn): \Generator;
/** /**
* Returns the Definition object by a specific FQN * Returns the Definition object by a specific FQN
@ -47,10 +55,10 @@ interface ReadableIndex extends EmitterInterface
public function getDefinition(string $fqn, bool $globalFallback = false); public function getDefinition(string $fqn, bool $globalFallback = false);
/** /**
* Returns all URIs in this index that reference a symbol * Returns a Generator that yields all URIs in this index that reference a symbol
* *
* @param string $fqn The fully qualified name of the symbol * @param string $fqn The fully qualified name of the symbol
* @return string[] * @return \Generator yields string
*/ */
public function getReferenceUris(string $fqn): array; public function getReferenceUris(string $fqn): \Generator;
} }

View File

@ -16,7 +16,7 @@ class Indexer
/** /**
* @var int The prefix for every cache item * @var int The prefix for every cache item
*/ */
const CACHE_VERSION = 2; const CACHE_VERSION = 3;
/** /**
* @var FilesFinder * @var FilesFinder

View File

@ -165,8 +165,11 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher
* @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. * @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 Promise <InitializeResult> * @return Promise <InitializeResult>
*/ */
public function initialize(ClientCapabilities $capabilities, string $rootPath = null, int $processId = null): Promise public function initialize(ClientCapabilities $capabilities, string $rootPath = null, int $processId = null, string $rootUri = null): Promise
{ {
if ($rootPath === null && $rootUri !== null) {
$rootPath = uriToPath($rootUri);
}
return coroutine(function () use ($capabilities, $rootPath, $processId) { return coroutine(function () use ($capabilities, $rootPath, $processId) {
if ($capabilities->xfilesProvider) { if ($capabilities->xfilesProvider) {

View File

@ -63,7 +63,7 @@ class PhpDocument
/** /**
* Map from fully qualified name (FQN) to Node * Map from fully qualified name (FQN) to Node
* *
* @var Node * @var Node[]
*/ */
private $definitionNodes; private $definitionNodes;

View File

@ -227,10 +227,11 @@ class TextDocument
return []; return [];
} }
} }
$refDocuments = yield Promise\all(array_map( $refDocumentPromises = [];
[$this->documentLoader, 'getOrLoad'], foreach ($this->index->getReferenceUris($fqn) as $uri) {
$this->index->getReferenceUris($fqn) $refDocumentPromises[] = $this->documentLoader->getOrLoad($uri);
)); }
$refDocuments = yield Promise\all($refDocumentPromises);
foreach ($refDocuments as $document) { foreach ($refDocuments as $document) {
$refs = $document->getReferenceNodesByFqn($fqn); $refs = $document->getReferenceNodesByFqn($fqn);
if ($refs !== null) { if ($refs !== null) {

View File

@ -6,8 +6,7 @@ namespace LanguageServer;
use LanguageServer\Index\ReadableIndex; use LanguageServer\Index\ReadableIndex;
use LanguageServerProtocol\{ use LanguageServerProtocol\{
Position, Position,
SignatureHelp, SignatureHelp
ParameterInformation
}; };
use Microsoft\PhpParser\Node; use Microsoft\PhpParser\Node;
use Sabre\Event\Promise; use Sabre\Event\Promise;

View File

@ -38,7 +38,7 @@ function pathToUri(string $filepath): string
function uriToPath(string $uri) function uriToPath(string $uri)
{ {
$fragments = parse_url($uri); $fragments = parse_url($uri);
if ($fragments === null || !isset($fragments['scheme']) || $fragments['scheme'] !== 'file') { if ($fragments === false || !isset($fragments['scheme']) || $fragments['scheme'] !== 'file') {
throw new InvalidArgumentException("Not a valid file URI: $uri"); throw new InvalidArgumentException("Not a valid file URI: $uri");
} }
$filepath = urldecode($fragments['path']); $filepath = urldecode($fragments['path']);

View File

@ -35,7 +35,9 @@ class DefinitionCollectorTest extends TestCase
'TestNamespace\\ChildClass', 'TestNamespace\\ChildClass',
'TestNamespace\\Example', 'TestNamespace\\Example',
'TestNamespace\\Example->__construct()', 'TestNamespace\\Example->__construct()',
'TestNamespace\\Example->__destruct()' 'TestNamespace\\Example->__destruct()',
'TestNamespace\\InnerNamespace',
'TestNamespace\\InnerNamespace\\InnerClass',
], array_keys($defNodes)); ], array_keys($defNodes));
$this->assertInstanceOf(Node\ConstElement::class, $defNodes['TestNamespace\\TEST_CONST']); $this->assertInstanceOf(Node\ConstElement::class, $defNodes['TestNamespace\\TEST_CONST']);
@ -53,6 +55,7 @@ class DefinitionCollectorTest extends TestCase
$this->assertInstanceOf(Node\Statement\ClassDeclaration::class, $defNodes['TestNamespace\\Example']); $this->assertInstanceOf(Node\Statement\ClassDeclaration::class, $defNodes['TestNamespace\\Example']);
$this->assertInstanceOf(Node\MethodDeclaration::class, $defNodes['TestNamespace\\Example->__construct()']); $this->assertInstanceOf(Node\MethodDeclaration::class, $defNodes['TestNamespace\\Example->__construct()']);
$this->assertInstanceOf(Node\MethodDeclaration::class, $defNodes['TestNamespace\\Example->__destruct()']); $this->assertInstanceOf(Node\MethodDeclaration::class, $defNodes['TestNamespace\\Example->__destruct()']);
$this->assertInstanceOf(Node\Statement\ClassDeclaration::class, $defNodes['TestNamespace\\InnerNamespace\\InnerClass']);
} }
public function testDoesNotCollectReferences() public function testDoesNotCollectReferences()

View File

@ -109,7 +109,9 @@ abstract class ServerTestCase extends TestCase
'TestNamespace\\whatever()' => new Location($referencesUri, new Range(new Position(21, 0), new Position(23, 1))), 'TestNamespace\\whatever()' => new Location($referencesUri, new Range(new Position(21, 0), new Position(23, 1))),
'TestNamespace\\Example' => new Location($symbolsUri, new Range(new Position(101, 0), new Position(104, 1))), 'TestNamespace\\Example' => new Location($symbolsUri, new Range(new Position(101, 0), new Position(104, 1))),
'TestNamespace\\Example::__construct' => new Location($symbolsUri, new Range(new Position(102, 4), new Position(102, 36))), 'TestNamespace\\Example::__construct' => new Location($symbolsUri, new Range(new Position(102, 4), new Position(102, 36))),
'TestNamespace\\Example::__destruct' => new Location($symbolsUri, new Range(new Position(103, 4), new Position(103, 35))) 'TestNamespace\\Example::__destruct' => new Location($symbolsUri, new Range(new Position(103, 4), new Position(103, 35))),
'TestNamespace\\InnerNamespace' => new Location($symbolsUri, new Range(new Position(106, 0), new Position(106, 39))),
'TestNamespace\\InnerNamespace\\InnerClass' => new Location($symbolsUri, new Range(new Position(108, 0), new Position(109, 1))),
]; ];
$this->referenceLocations = [ $this->referenceLocations = [

View File

@ -47,6 +47,9 @@ class CompletionTest extends TestCase
$this->textDocument = new Server\TextDocument($this->loader, $definitionResolver, $client, $projectIndex); $this->textDocument = new Server\TextDocument($this->loader, $definitionResolver, $client, $projectIndex);
} }
/**
* Tests completion at `$obj->t|`
*/
public function testPropertyAndMethodWithPrefix() public function testPropertyAndMethodWithPrefix()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/property_with_prefix.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/property_with_prefix.php');
@ -71,6 +74,9 @@ class CompletionTest extends TestCase
], true), $items); ], true), $items);
} }
/**
* Tests completion at `public function a() { tes| }`
*/
public function testGlobalFunctionInsideNamespaceAndClass() public function testGlobalFunctionInsideNamespaceAndClass()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/inside_namespace_and_method.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/inside_namespace_and_method.php');
@ -92,6 +98,9 @@ class CompletionTest extends TestCase
], true), $items); ], true), $items);
} }
/**
* Tests completion at `$obj->|`
*/
public function testPropertyAndMethodWithoutPrefix() public function testPropertyAndMethodWithoutPrefix()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/property.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/property.php');
@ -116,6 +125,9 @@ class CompletionTest extends TestCase
], true), $items); ], true), $items);
} }
/**
* Tests completion at `$|` when variables are defined
*/
public function testVariable() public function testVariable()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/variable.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/variable.php');
@ -148,6 +160,9 @@ class CompletionTest extends TestCase
], true), $items); ], true), $items);
} }
/**
* Tests completion at `$p|` when variables are defined
*/
public function testVariableWithPrefix() public function testVariableWithPrefix()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/variable_with_prefix.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/variable_with_prefix.php');
@ -170,6 +185,9 @@ class CompletionTest extends TestCase
], true), $items); ], true), $items);
} }
/**
* Tests completion at `new|` when in a namespace and have used variables.
*/
public function testNewInNamespace() public function testNewInNamespace()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_new.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_new.php');
@ -218,27 +236,12 @@ class CompletionTest extends TestCase
null, null,
'TestClass' 'TestClass'
), ),
new CompletionItem(
'ChildClass',
CompletionItemKind::CLASS_,
'TestNamespace',
null,
null,
null,
'\TestNamespace\ChildClass'
),
new CompletionItem(
'Example',
CompletionItemKind::CLASS_,
'TestNamespace',
null,
null,
null,
'\TestNamespace\Example'
)
], true), $items); ], true), $items);
} }
/**
* Tests completion at `TestC|` with `use TestNamespace\TestClass`
*/
public function testUsedClass() public function testUsedClass()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_class.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_class.php');
@ -257,11 +260,74 @@ class CompletionTest extends TestCase
'laboris commodo ad commodo velit mollit qui non officia id. Nulla duis veniam' . "\n" . 'laboris commodo ad commodo velit mollit qui non officia id. Nulla duis veniam' . "\n" .
'veniam officia deserunt et non dolore mollit ea quis eiusmod sit non. Occaecat' . "\n" . 'veniam officia deserunt et non dolore mollit ea quis eiusmod sit non. Occaecat' . "\n" .
'consequat sunt culpa exercitation pariatur id reprehenderit nisi incididunt Lorem' . "\n" . 'consequat sunt culpa exercitation pariatur id reprehenderit nisi incididunt Lorem' . "\n" .
'sint. Officia culpa pariatur laborum nostrud cupidatat consequat mollit.' 'sint. Officia culpa pariatur laborum nostrud cupidatat consequat mollit.',
null,
null,
'TestClass'
) )
], true), $items); ], true), $items);
$this->assertCompletionsListDoesNotContainLabel('OtherClass', $items);
$this->assertCompletionsListDoesNotContainLabel('TestInterface', $items);
} }
/**
* Tests completion at `AliasNamespace\I|` with `use TestNamespace\InnerNamespace as AliasNamespace`
*/
public function testUsedNamespaceWithPrefix()
{
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_namespace.php');
$this->loader->open($completionUri, file_get_contents($completionUri));
$items = $this->textDocument->completion(
new TextDocumentIdentifier($completionUri),
new Position(8, 16)
)->wait();
$this->assertEquals(
new CompletionList([
new CompletionItem(
'InnerClass',
CompletionItemKind::CLASS_,
'TestNamespace\\InnerNamespace',
null,
null,
null,
'AliasNamespace\\InnerClass'
)
], true),
$items
);
}
/**
* Tests completion at `AliasNamespace\|` with `use TestNamespace\InnerNamespace as AliasNamespace`
*/
public function testUsedNamespaceWithoutPrefix()
{
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_namespace.php');
$this->loader->open($completionUri, file_get_contents($completionUri));
$items = $this->textDocument->completion(
new TextDocumentIdentifier($completionUri),
new Position(9, 15)
)->wait();
$this->assertEquals(
new CompletionList([
new CompletionItem(
'InnerClass',
CompletionItemKind::CLASS_,
'TestNamespace\InnerNamespace',
null,
null,
null,
'AliasNamespace\InnerClass'
),
], true),
$items
);
}
/**
* Tests completion at `TestClass::$st|`
*/
public function testStaticPropertyWithPrefix() public function testStaticPropertyWithPrefix()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static_property_with_prefix.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static_property_with_prefix.php');
@ -283,6 +349,9 @@ class CompletionTest extends TestCase
], true), $items); ], true), $items);
} }
/**
* Tests completion at `TestClass::|`
*/
public function testStaticWithoutPrefix() public function testStaticWithoutPrefix()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static.php');
@ -316,6 +385,9 @@ class CompletionTest extends TestCase
], true), $items); ], true), $items);
} }
/**
* Tests completion at `TestClass::st|`
*/
public function testStaticMethodWithPrefix() public function testStaticMethodWithPrefix()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static_method_with_prefix.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static_method_with_prefix.php');
@ -325,21 +397,6 @@ class CompletionTest extends TestCase
new Position(2, 13) new Position(2, 13)
)->wait(); )->wait();
$this->assertCompletionsListSubset(new CompletionList([ $this->assertCompletionsListSubset(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( new CompletionItem(
'staticTestMethod', 'staticTestMethod',
CompletionItemKind::METHOD, CompletionItemKind::METHOD,
@ -349,6 +406,9 @@ class CompletionTest extends TestCase
], true), $items); ], true), $items);
} }
/**
* Tests completion at `TestClass::TE` at the root level.
*/
public function testClassConstWithPrefix() public function testClassConstWithPrefix()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/class_const_with_prefix.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/class_const_with_prefix.php');
@ -363,25 +423,13 @@ class CompletionTest extends TestCase
CompletionItemKind::VARIABLE, CompletionItemKind::VARIABLE,
'int', 'int',
'Anim labore veniam consectetur laboris minim quis aute aute esse nulla ad.' '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); ], true), $items);
} }
/**
* Test completion at `\TestC|` in a namespace
*/
public function testFullyQualifiedClass() public function testFullyQualifiedClass()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/fully_qualified_class.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/fully_qualified_class.php');
@ -400,14 +448,18 @@ class CompletionTest extends TestCase
'laboris commodo ad commodo velit mollit qui non officia id. Nulla duis veniam' . "\n" . 'laboris commodo ad commodo velit mollit qui non officia id. Nulla duis veniam' . "\n" .
'veniam officia deserunt et non dolore mollit ea quis eiusmod sit non. Occaecat' . "\n" . 'veniam officia deserunt et non dolore mollit ea quis eiusmod sit non. Occaecat' . "\n" .
'consequat sunt culpa exercitation pariatur id reprehenderit nisi incididunt Lorem' . "\n" . 'consequat sunt culpa exercitation pariatur id reprehenderit nisi incididunt Lorem' . "\n" .
'sint. Officia culpa pariatur laborum nostrud cupidatat consequat mollit.', 'sint. Officia culpa pariatur laborum nostrud cupidatat consequat mollit.'
null,
null,
'TestClass'
) )
], true), $items); ], true), $items);
// Assert that all results are non-namespaced.
foreach ($items->items as $item) {
$this->assertSame($item->detail, null);
}
} }
/**
* Tests completion at `cl|` at root level
*/
public function testKeywords() public function testKeywords()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/keywords.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/keywords.php');
@ -422,6 +474,9 @@ class CompletionTest extends TestCase
], true), $items); ], true), $items);
} }
/**
* Tests completion in an empty file
*/
public function testHtmlWithoutPrefix() public function testHtmlWithoutPrefix()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html.php');
@ -444,6 +499,9 @@ class CompletionTest extends TestCase
], true), $items); ], true), $items);
} }
/**
* Tests completion in `<|` when not within `<?php` tags
*/
public function testHtmlWontBeProposedWithoutCompletionContext() public function testHtmlWontBeProposedWithoutCompletionContext()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html_with_prefix.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html_with_prefix.php');
@ -456,6 +514,9 @@ class CompletionTest extends TestCase
$this->assertEquals(new CompletionList([], true), $items); $this->assertEquals(new CompletionList([], true), $items);
} }
/**
* Tests completion in `<|` when not within `<?php` tags
*/
public function testHtmlWontBeProposedWithPrefixWithCompletionContext() public function testHtmlWontBeProposedWithPrefixWithCompletionContext()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html_with_prefix.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html_with_prefix.php');
@ -480,6 +541,9 @@ class CompletionTest extends TestCase
], true), $items); ], true), $items);
} }
/**
* Tests completion at `<|` when not within `<?php` tags when triggered by trigger character.
*/
public function testHtmlPrefixShouldNotTriggerCompletion() public function testHtmlPrefixShouldNotTriggerCompletion()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html_no_completion.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html_no_completion.php');
@ -492,6 +556,9 @@ class CompletionTest extends TestCase
$this->assertEquals(new CompletionList([], true), $items); $this->assertEquals(new CompletionList([], true), $items);
} }
/**
* Tests completion at `<|` when not within `<?php` tags when triggered by user input.
*/
public function testHtmlPrefixShouldTriggerCompletionIfManuallyInvoked() public function testHtmlPrefixShouldTriggerCompletionIfManuallyInvoked()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html_no_completion.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html_no_completion.php');
@ -515,6 +582,9 @@ class CompletionTest extends TestCase
], true), $items); ], true), $items);
} }
/**
* Tests completion at `SomeNa|` when namespace `SomeNamespace` is defined
*/
public function testNamespace() public function testNamespace()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/namespace.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/namespace.php');
@ -526,17 +596,15 @@ class CompletionTest extends TestCase
$this->assertCompletionsListSubset(new CompletionList([ $this->assertCompletionsListSubset(new CompletionList([
new CompletionItem( new CompletionItem(
'SomeNamespace', 'SomeNamespace',
CompletionItemKind::MODULE, CompletionItemKind::MODULE
null,
null,
null,
null,
'SomeNamespace'
) )
], true), $items); ], true), $items);
} }
public function testBarePhp() /**
* Tests completion at `echo $ab|` at the root level.
*/
public function testBarePhpVariable()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/bare_php.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/bare_php.php');
$this->loader->open($completionUri, file_get_contents($completionUri)); $this->loader->open($completionUri, file_get_contents($completionUri));
@ -776,6 +844,16 @@ class CompletionTest extends TestCase
$this->assertEquals($subsetList->isIncomplete, $list->isIncomplete); $this->assertEquals($subsetList->isIncomplete, $list->isIncomplete);
} }
private function assertCompletionsListDoesNotContainLabel(string $label, CompletionList $list)
{
foreach ($list->items as $item) {
$this->assertNotSame($label, $item->label, "Completion list should not contain $label.");
}
}
/**
* Tests completion for `$this->|`
*/
public function testThisWithoutPrefix() public function testThisWithoutPrefix()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/this.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/this.php');
@ -812,6 +890,9 @@ class CompletionTest extends TestCase
], true), $items); ], true), $items);
} }
/**
* Tests completion at `$this->m|`
*/
public function testThisWithPrefix() public function testThisWithPrefix()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/this_with_prefix.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/this_with_prefix.php');
@ -821,18 +902,6 @@ class CompletionTest extends TestCase
new Position(12, 16) new Position(12, 16)
)->wait(); )->wait();
$this->assertEquals(new CompletionList([ $this->assertEquals(new CompletionList([
new CompletionItem(
'testProperty',
CompletionItemKind::PROPERTY,
'\TestClass', // Type of the property
'Reprehenderit magna velit mollit ipsum do.'
),
new CompletionItem(
'testMethod',
CompletionItemKind::METHOD,
'\TestClass', // Return type of the method
'Non culpa nostrud mollit esse sunt laboris in irure ullamco cupidatat amet.'
),
new CompletionItem( new CompletionItem(
'foo', 'foo',
CompletionItemKind::PROPERTY, CompletionItemKind::PROPERTY,
@ -856,10 +925,25 @@ class CompletionTest extends TestCase
CompletionItemKind::METHOD, CompletionItemKind::METHOD,
'mixed', // Return type of the method 'mixed', // Return type of the method
null null
) ),
new CompletionItem(
'testProperty',
CompletionItemKind::PROPERTY,
'\TestClass', // Type of the property
'Reprehenderit magna velit mollit ipsum do.'
),
new CompletionItem(
'testMethod',
CompletionItemKind::METHOD,
'\TestClass', // Return type of the method
'Non culpa nostrud mollit esse sunt laboris in irure ullamco cupidatat amet.'
),
], true), $items); ], true), $items);
} }
/**
* Tests completion at `$this->foo()->q|`
*/
public function testThisReturnValue() public function testThisReturnValue()
{ {
$completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/this_return_value.php'); $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/this_return_value.php');
@ -869,11 +953,6 @@ class CompletionTest extends TestCase
new Position(17, 23) new Position(17, 23)
)->wait(); )->wait();
$this->assertEquals(new CompletionList([ $this->assertEquals(new CompletionList([
new CompletionItem(
'foo',
CompletionItemKind::METHOD,
'$this' // Return type of the method
),
new CompletionItem( new CompletionItem(
'bar', 'bar',
CompletionItemKind::METHOD, CompletionItemKind::METHOD,
@ -883,7 +962,12 @@ class CompletionTest extends TestCase
'qux', 'qux',
CompletionItemKind::METHOD, CompletionItemKind::METHOD,
'mixed' // Return type of the method 'mixed' // Return type of the method
) ),
new CompletionItem(
'foo',
CompletionItemKind::METHOD,
'$this' // Return type of the method
),
], true), $items); ], true), $items);
} }
} }

View File

@ -32,7 +32,9 @@ class DocumentSymbolTest extends ServerTestCase
new SymbolInformation('ChildClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\ChildClass'), 'TestNamespace'), new SymbolInformation('ChildClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\ChildClass'), 'TestNamespace'),
new SymbolInformation('Example', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\Example'), 'TestNamespace'), new SymbolInformation('Example', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\Example'), 'TestNamespace'),
new SymbolInformation('__construct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__construct'), 'TestNamespace\\Example'), new SymbolInformation('__construct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__construct'), 'TestNamespace\\Example'),
new SymbolInformation('__destruct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__destruct'), 'TestNamespace\\Example') new SymbolInformation('__destruct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__destruct'), 'TestNamespace\\Example'),
new SymbolInformation('TestNamespace\\InnerNamespace', SymbolKind::NAMESPACE, $this->getDefinitionLocation('TestNamespace\\InnerNamespace'), 'TestNamespace'),
new SymbolInformation('InnerClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\InnerNamespace\\InnerClass'), 'TestNamespace\\InnerNamespace'),
], $result); ], $result);
// @codingStandardsIgnoreEnd // @codingStandardsIgnoreEnd
} }

View File

@ -30,7 +30,7 @@ class SymbolTest extends ServerTestCase
// @codingStandardsIgnoreStart // @codingStandardsIgnoreStart
$this->assertEquals([ $this->assertEquals([
new SymbolInformation('TestNamespace', SymbolKind::NAMESPACE, new Location($referencesUri, new Range(new Position(2, 0), new Position(2, 24))), ''), new SymbolInformation('TestNamespace', SymbolKind::NAMESPACE, new Location($referencesUri, new Range(new Position(2, 0), new Position(2, 24))), ''),
// Namespaced // Namespaced
new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TestNamespace\\TEST_CONST'), 'TestNamespace'), new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TestNamespace\\TEST_CONST'), 'TestNamespace'),
new SymbolInformation('TestClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\TestClass'), 'TestNamespace'), new SymbolInformation('TestClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\TestClass'), 'TestNamespace'),
@ -46,6 +46,8 @@ class SymbolTest extends ServerTestCase
new SymbolInformation('Example', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\Example'), 'TestNamespace'), new SymbolInformation('Example', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\Example'), 'TestNamespace'),
new SymbolInformation('__construct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__construct'), 'TestNamespace\\Example'), new SymbolInformation('__construct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__construct'), 'TestNamespace\\Example'),
new SymbolInformation('__destruct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__destruct'), 'TestNamespace\\Example'), new SymbolInformation('__destruct', SymbolKind::CONSTRUCTOR, $this->getDefinitionLocation('TestNamespace\\Example::__destruct'), 'TestNamespace\\Example'),
new SymbolInformation('TestNamespace\\InnerNamespace', SymbolKind::NAMESPACE, $this->getDefinitionLocation('TestNamespace\\InnerNamespace'), 'TestNamespace'),
new SymbolInformation('InnerClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\InnerNamespace\\InnerClass'), 'TestNamespace\\InnerNamespace'),
new SymbolInformation('whatever', SymbolKind::FUNCTION, $this->getDefinitionLocation('TestNamespace\\whatever()'), 'TestNamespace'), new SymbolInformation('whatever', SymbolKind::FUNCTION, $this->getDefinitionLocation('TestNamespace\\whatever()'), 'TestNamespace'),
// Global // Global
new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TEST_CONST'), ''), new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TEST_CONST'), ''),