From 10fb3c92e0dc77923b0a429dcd8a288a9b1fa186 Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Wed, 30 Nov 2016 22:23:51 +0100 Subject: [PATCH] Completion (#165) * Add support for method/property completion * Move completion fixtures into directory * Add support for variable suggestions Refactor logic into CompletionProvider class * Allow getTypeFromNode() to take Variable nodes * Use property and constant values * Fix using @var tag for variables * Improve completion * classes * variables with prefix filtering * Make FQNs more distinct * use -> for instance methods/properties * use ::$ for static properties * Add tests for static access * Properly filter completion on empty property * Fix existing tests * Add support for static access without prefix * Fix testFullyQualifiedClass * Add missing fixtures * Correct file number in init test * Only insert backslash if not typed yet * Completion for keywords and bug fixes * Correct variable insertion * Support completion for namespaces * Use CompletionList * Always set isIncomplete to true * Update PHPCodeSniffer * Remove unused method * And the call * Handle case where FQN could not be resolved --- .editorconfig | 2 +- composer.json | 4 +- .../completion/class_const_with_prefix.php | 3 + fixtures/completion/fully_qualified_class.php | 9 + fixtures/completion/html.php | 0 fixtures/completion/html_with_prefix.php | 1 + fixtures/completion/keywords.php | 3 + fixtures/completion/namespace.php | 5 + fixtures/completion/property.php | 4 + fixtures/completion/property_with_prefix.php | 4 + fixtures/completion/static.php | 3 + .../completion/static_method_with_prefix.php | 3 + .../static_property_with_prefix.php | 3 + fixtures/completion/used_class.php | 9 + fixtures/completion/used_new.php | 7 + fixtures/completion/variable.php | 10 + fixtures/completion/variable_with_prefix.php | 10 + src/CompletionProvider.php | 385 ++++++++++++++++++ src/Definition.php | 29 +- src/DefinitionResolver.php | 202 ++++++--- src/LanguageServer.php | 7 +- src/NodeVisitor/DefinitionCollector.php | 9 +- src/PhpDocument.php | 19 + src/Protocol/CompletionItem.php | 85 ++++ src/Protocol/CompletionItemKind.php | 44 +- src/Protocol/CompletionList.php | 10 + src/Protocol/CompletionOptions.php | 4 +- src/Protocol/Position.php | 13 + src/Protocol/SymbolInformation.php | 2 +- src/Server/TextDocument.php | 35 +- src/utils.php | 22 + tests/LanguageServerTest.php | 9 +- tests/NodeVisitor/DefinitionCollectorTest.php | 16 +- tests/Server/ServerTestCase.php | 14 +- tests/Server/TextDocument/CompletionTest.php | 383 +++++++++++++++++ .../TextDocument/DocumentSymbolTest.php | 1 + tests/Server/Workspace/SymbolTest.php | 18 +- tests/Utils/StripStringOverlapTest.php | 45 ++ 38 files changed, 1341 insertions(+), 91 deletions(-) create mode 100644 fixtures/completion/class_const_with_prefix.php create mode 100644 fixtures/completion/fully_qualified_class.php create mode 100644 fixtures/completion/html.php create mode 100644 fixtures/completion/html_with_prefix.php create mode 100644 fixtures/completion/keywords.php create mode 100644 fixtures/completion/namespace.php create mode 100644 fixtures/completion/property.php create mode 100644 fixtures/completion/property_with_prefix.php create mode 100644 fixtures/completion/static.php create mode 100644 fixtures/completion/static_method_with_prefix.php create mode 100644 fixtures/completion/static_property_with_prefix.php create mode 100644 fixtures/completion/used_class.php create mode 100644 fixtures/completion/used_new.php create mode 100644 fixtures/completion/variable.php create mode 100644 fixtures/completion/variable_with_prefix.php create mode 100644 src/CompletionProvider.php create mode 100644 tests/Server/TextDocument/CompletionTest.php create mode 100644 tests/Utils/StripStringOverlapTest.php diff --git a/.editorconfig b/.editorconfig index 55581242..d1620666 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,5 +13,5 @@ indent_size = 2 [composer.json] indent_size = 4 -[*.md] +[{*.md,fixtures/**}] trim_trailing_whitespace = false diff --git a/composer.json b/composer.json index df9110aa..224d3d01 100644 --- a/composer.json +++ b/composer.json @@ -24,11 +24,11 @@ "bin": ["bin/php-language-server.php"], "require": { "php": ">=7.0", - "nikic/php-parser": "^3.0.0beta2", + "nikic/php-parser": "dev-master#e52ffc4447e034514339a03b450aab9cd625e37c", "phpdocumentor/reflection-docblock": "^3.0", "sabre/event": "^5.0", "felixfbecker/advanced-json-rpc": "^2.0", - "squizlabs/php_codesniffer" : "3.0.0RC1", + "squizlabs/php_codesniffer" : "3.0.x-dev#e8acf8e029301b0e3ea7e7c9eef0aee914db78bf", "netresearch/jsonmapper": "^1.0", "webmozart/path-util": "^2.3", "webmozart/glob": "^4.1", diff --git a/fixtures/completion/class_const_with_prefix.php b/fixtures/completion/class_const_with_prefix.php new file mode 100644 index 00000000..36c71d4a --- /dev/null +++ b/fixtures/completion/class_const_with_prefix.php @@ -0,0 +1,3 @@ + diff --git a/fixtures/completion/property_with_prefix.php b/fixtures/completion/property_with_prefix.php new file mode 100644 index 00000000..677bcbbc --- /dev/null +++ b/fixtures/completion/property_with_prefix.php @@ -0,0 +1,4 @@ +t diff --git a/fixtures/completion/static.php b/fixtures/completion/static.php new file mode 100644 index 00000000..f975d7ca --- /dev/null +++ b/fixtures/completion/static.php @@ -0,0 +1,3 @@ +', + '__halt_compiler', + 'abstract', + 'and', + 'array', + 'as', + 'break', + 'callable', + 'case', + 'catch', + 'class', + 'clone', + 'const', + 'continue', + 'declare', + 'default', + 'die', + 'do', + 'echo', + 'else', + 'elseif', + 'empty', + 'enddeclare', + 'endfor', + 'endforeach', + 'endif', + 'endswitch', + 'endwhile', + 'eval', + 'exit', + 'extends', + 'final', + 'finally', + 'for', + 'foreach', + 'function', + 'global', + 'goto', + 'if', + 'implements', + 'include', + 'include_once', + 'instanceof', + 'insteadof', + 'interface', + 'isset', + 'list', + 'namespace', + 'new', + 'or', + 'print', + 'private', + 'protected', + 'public', + 'require', + 'require_once', + 'return', + 'static', + 'switch', + 'throw', + 'trait', + 'try', + 'unset', + 'use', + 'var', + 'while', + 'xor', + 'yield' + ]; + + /** + * @var DefinitionResolver + */ + private $definitionResolver; + + /** + * @var Project + */ + private $project; + + /** + * @param DefinitionResolver $definitionResolver + * @param Project $project + */ + public function __construct(DefinitionResolver $definitionResolver, Project $project) + { + $this->definitionResolver = $definitionResolver; + $this->project = $project; + } + + /** + * Returns suggestions for a specific cursor position in a document + * + * @param PhpDocument $doc The opened document + * @param Position $pos The cursor position + * @return CompletionList + */ + public function provideCompletion(PhpDocument $doc, Position $pos): CompletionList + { + $node = $doc->getNodeAtPosition($pos); + + if ($node instanceof Node\Expr\Error) { + $node = $node->getAttribute('parentNode'); + } + + $list = new CompletionList; + $list->isIncomplete = true; + + // A non-free node means we do NOT suggest global symbols + if ( + $node instanceof Node\Expr\MethodCall + || $node instanceof Node\Expr\PropertyFetch + || $node instanceof Node\Expr\StaticCall + || $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 .= '::$'; + } + } + } else { + $fqn = $this->definitionResolver->resolveReferenceNodeToFqn($node); + $prefixes = $fqn !== null ? [$fqn] : []; + } + + foreach ($this->project->getDefinitions() as $fqn => $def) { + foreach ($prefixes as $prefix) { + if (substr($fqn, 0, strlen($prefix)) === $prefix && !$def->isGlobal) { + $list->items[] = CompletionItem::fromDefinition($def); + } + } + } + } else if ( + // A ConstFetch means any static reference, like a class, interface, etc. or keyword + ($node instanceof Node\Name && $node->getAttribute('parentNode') instanceof Node\Expr\ConstFetch) + || $node instanceof Node\Expr\New_ + ) { + $prefix = ''; + $prefixLen = 0; + if ($node instanceof Node\Name) { + $isFullyQualified = $node->isFullyQualified(); + $prefix = (string)$node; + $prefixLen = strlen($prefix); + $namespacedPrefix = (string)$node->getAttribute('namespacedName'); + $namespacedPrefixLen = strlen($prefix); + } + // Find closest namespace + $namespace = getClosestNode($node, Node\Stmt\Namespace_::class); + /** Map from alias to Definition */ + $aliasedDefs = []; + if ($namespace) { + foreach ($namespace->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Use_ || $stmt instanceof Node\Stmt\GroupUse) { + foreach ($stmt->uses as $use) { + // 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); + } + } else { + // Use statements are always the first statements in a namespace + break; + } + } + } + // If there is a prefix that does not start with a slash, suggest `use`d symbols + if ($prefix && !$isFullyQualified) { + // Suggest symbols that have been `use`d + // Search the aliases for the typed-in name + foreach ($aliasedDefs as $alias => $def) { + if (substr($alias, 0, $prefixLen) === $prefix) { + $list->items[] = CompletionItem::fromDefinition($def); + } + } + } + // 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) { + if ( + $def->isGlobal // exclude methods, properties etc. + && ( + !$prefix + || ( + ((!$namespace || $isFullyQualified) && substr($fqn, 0, $prefixLen) === $prefix) + || ( + $namespace + && !$isFullyQualified + && substr($fqn, 0, $namespacedPrefixLen) === $namespacedPrefix + ) + ) + ) + // Only suggest classes for `new` + && (!($node instanceof Node\Expr\New_) || $def->canBeInstantiated) + ) { + $item = CompletionItem::fromDefinition($def); + // Find the shortest name to reference the symbol + if ($namespace && ($alias = array_search($def, $aliasedDefs, true)) !== false) { + // $alias is the name under which this definition is aliased in the current namespace + $item->insertText = $alias; + } else if ($namespace && !($prefix && $isFullyQualified)) { + // Insert the global FQN with trailing backslash + $item->insertText = '\\' . $fqn; + } else { + // Insert the FQN without trailing backlash + $item->insertText = $fqn; + } + $list->items[] = $item; + } + } + // Suggest keywords + if ($node instanceof Node\Name && $node->getAttribute('parentNode') instanceof Node\Expr\ConstFetch) { + foreach (self::KEYWORDS as $keyword) { + if (substr($keyword, 0, $prefixLen) === $prefix) { + $item = new CompletionItem($keyword, CompletionItemKind::KEYWORD); + $item->insertText = $keyword . ' '; + $list->items[] = $item; + } + } + } + } else if ( + $node instanceof Node\Expr\Variable + || ($node && $node->getAttribute('parentNode') instanceof Node\Expr\Variable) + ) { + // Find variables, parameters and use statements in the scope + // If there was only a $ typed, $node will be instanceof Node\Error + $namePrefix = $node instanceof Node\Expr\Variable && is_string($node->name) ? $node->name : ''; + foreach ($this->suggestVariablesAtNode($node, $namePrefix) as $var) { + $item = new CompletionItem; + $item->kind = CompletionItemKind::VARIABLE; + $item->label = '$' . ($var instanceof Node\Expr\ClosureUse ? $var->var : $var->name); + $item->documentation = $this->definitionResolver->getDocumentationFromNode($var); + $item->detail = (string)$this->definitionResolver->getTypeFromNode($var); + $item->textEdit = new TextEdit( + new Range($pos, $pos), + stripStringOverlap($doc->getRange(new Range(new Position(0, 0), $pos)), $item->label) + ); + $list->items[] = $item; + } + } else if ($node instanceof Node\Stmt\InlineHTML || $pos == new Position(0, 0)) { + $item = new CompletionItem('textEdit = new TextEdit( + new Range($pos, $pos), + stripStringOverlap($doc->getRange(new Range(new Position(0, 0), $pos)), 'items[] = $item; + } + + return $list; + } + + /** + * Will walk the AST upwards until a function-like node is met + * and at each level walk all previous siblings and their children to search for definitions + * of that variable + * + * @param Node $node + * @param string $namePrefix Prefix to filter + * @return array + */ + private function suggestVariablesAtNode(Node $node, string $namePrefix = ''): array + { + $vars = []; + + // Find variables in the node itself + // When getting completion in the middle of a function, $node will be the function node + // so we need to search it + foreach ($this->findVariableDefinitionsInNode($node, $namePrefix) as $var) { + // Only use the first definition + if (!isset($vars[$var->name])) { + $vars[$var->name] = $var; + } + } + + // Walk the AST upwards until a scope boundary is met + $level = $node; + while ($level && !($level instanceof Node\FunctionLike)) { + // Walk siblings before the node + $sibling = $level; + while ($sibling = $sibling->getAttribute('previousSibling')) { + // Collect all variables inside the sibling node + foreach ($this->findVariableDefinitionsInNode($sibling, $namePrefix) as $var) { + $vars[$var->name] = $var; + } + } + $level = $level->getAttribute('parentNode'); + } + + // If the traversal ended because a function was met, + // also add its parameters and closure uses to the result list + if ($level instanceof Node\FunctionLike) { + foreach ($level->params as $param) { + if (!isset($vars[$param->name]) && substr($param->name, 0, strlen($namePrefix)) === $namePrefix) { + $vars[$param->name] = $param; + } + } + if ($level instanceof Node\Expr\Closure) { + foreach ($level->uses as $use) { + if (!isset($vars[$param->name]) && substr($param->name, 0, strlen($namePrefix)) === $namePrefix) { + $vars[$use->var] = $use; + } + } + } + } + + return array_values($vars); + } + + /** + * Searches the subnodes of a node for variable assignments + * + * @param Node $node + * @param string $namePrefix Prefix to filter + * @return Node\Expr\Variable[] + */ + private function findVariableDefinitionsInNode(Node $node, string $namePrefix = ''): array + { + $vars = []; + // If the child node is a variable assignment, save it + $parent = $node->getAttribute('parentNode'); + if ( + $node instanceof Node\Expr\Variable + && ($parent instanceof Node\Expr\Assign || $parent instanceof Node\Expr\AssignOp) + && is_string($node->name) // Variable variables are of no use + && substr($node->name, 0, strlen($namePrefix)) === $namePrefix + ) { + $vars[] = $node; + } + // Iterate over subnodes + foreach ($node->getSubNodeNames() as $attr) { + if (!isset($node->$attr)) { + continue; + } + $children = is_array($node->$attr) ? $node->$attr : [$node->$attr]; + foreach ($children as $child) { + // Dont try to traverse scalars + // Dont traverse functions, the contained variables are in a different scope + if (!($child instanceof Node) || $child instanceof Node\FunctionLike) { + continue; + } + foreach ($this->findVariableDefinitionsInNode($child, $namePrefix) as $var) { + $vars[] = $var; + } + } + } + return $vars; + } +} diff --git a/src/Definition.php b/src/Definition.php index cba69ab9..b7730f3a 100644 --- a/src/Definition.php +++ b/src/Definition.php @@ -18,17 +18,40 @@ class Definition * * Examples of FQNs: * - testFunction() + * - TestNamespace * - TestNamespace\TestClass * - TestNamespace\TestClass::TEST_CONSTANT - * - TestNamespace\TestClass::staticTestProperty - * - TestNamespace\TestClass::testProperty + * - TestNamespace\TestClass::$staticTestProperty + * - TestNamespace\TestClass->testProperty * - TestNamespace\TestClass::staticTestMethod() - * - TestNamespace\TestClass::testMethod() + * - TestNamespace\TestClass->testMethod() * * @var string|null */ public $fqn; + /** + * Only true for classes, interfaces, traits, functions and non-class constants + * This is so methods and properties are not suggested in the global scope + * + * @var bool + */ + public $isGlobal; + + /** + * False for instance methods and properties + * + * @var bool + */ + public $isStatic; + + /** + * True if the Definition is a class + * + * @var bool + */ + public $canBeInstantiated; + /** * @var Protocol\SymbolInformation */ diff --git a/src/DefinitionResolver.php b/src/DefinitionResolver.php index a35750ee..87c5aa0b 100644 --- a/src/DefinitionResolver.php +++ b/src/DefinitionResolver.php @@ -91,6 +91,35 @@ public function getDocumentationFromNode(Node $node) } } + /** + * Create a Definition for a definition node + * + * @param Node $node + * @param string $fqn + * @return Definition + */ + public function createDefinitionFromNode(Node $node, string $fqn = null): Definition + { + $def = new Definition; + $def->canBeInstantiated = $node instanceof Node\Stmt\Class_; + $def->isGlobal = ( + $node instanceof Node\Stmt\ClassLike + || $node instanceof Node\Stmt\Namespace_ + || $node instanceof Node\Stmt\Function_ + || $node->getAttribute('parentNode') instanceof Node\Stmt\Const_ + ); + $def->isStatic = ( + ($node instanceof Node\Stmt\ClassMethod && $node->isStatic()) + || ($node instanceof Node\Stmt\PropertyProperty && $node->getAttribute('parentNode')->isStatic()) + ); + $def->fqn = $fqn; + $def->symbolInformation = SymbolInformation::fromNode($node, $fqn); + $def->type = $this->getTypeFromNode($node); + $def->declarationLine = $this->getDeclarationLineFromNode($node); + $def->documentation = $this->getDocumentationFromNode($node); + return $def; + } + /** * Given any node, returns the Definition object of the symbol that is referenced * @@ -106,21 +135,7 @@ public function resolveReferenceNodeToDefinition(Node $node) if ($defNode === null) { return null; } - $def = new Definition; - // Get symbol information from node (range, symbol kind) - $def->symbolInformation = SymbolInformation::fromNode($defNode); - // Declaration line - $def->declarationLine = $this->getDeclarationLineFromNode($defNode); - // Documentation - $def->documentation = $this->getDocumentationFromNode($defNode); - if ($defNode instanceof Node\Param) { - // Get parameter type - $def->type = $this->getTypeFromNode($defNode); - } else { - // Resolve the type of the assignment/closure use node - $def->type = $this->resolveExpressionNodeToType($defNode); - } - return $def; + return $this->createDefinitionFromNode($defNode); } // Other references are references to a global symbol that have an FQN // Find out the FQN @@ -136,6 +151,31 @@ public function resolveReferenceNodeToDefinition(Node $node) return $this->project->getDefinition($fqn, $globalFallback); } + /** + * Returns all possible FQNs in a type + * + * @param Type $type + * @return string[] + */ + public static function getFqnsFromType(Type $type): array + { + $fqns = []; + if ($type instanceof Types\Object_) { + $fqsen = $type->getFqsen(); + if ($fqsen !== null) { + $fqns[] = substr((string)$fqsen, 1); + } + } + if ($type instanceof Types\Compound) { + for ($i = 0; $t = $type->get($i); $i++) { + foreach (self::getFqnsFromType($type) as $fqn) { + $fqns[] = $fqn; + } + } + } + return $fqns; + } + /** * Given any node, returns the FQN of the symbol that is referenced * Returns null if the FQN could not be resolved or the reference node references a variable @@ -150,6 +190,7 @@ public function resolveReferenceNodeToFqn(Node $node) if ( $node instanceof Node\Name && ( $parent instanceof Node\Stmt\ClassLike + || $parent instanceof Node\Namespace_ || $parent instanceof Node\Param || $parent instanceof Node\FunctionLike || $parent instanceof Node\Expr\StaticCall @@ -211,7 +252,7 @@ public function resolveReferenceNodeToFqn(Node $node) } else { $classFqn = substr((string)$varType->getFqsen(), 1); } - $name = $classFqn . '::' . (string)$node->name; + $name = $classFqn . '->' . (string)$node->name; } else if ($parent instanceof Node\Expr\FuncCall) { if ($parent->name instanceof Node\Expr) { return null; @@ -245,7 +286,11 @@ public function resolveReferenceNodeToFqn(Node $node) $className = (string)$classNode->namespacedName; } } - $name = (string)$className . '::' . $node->name; + if ($node instanceof Node\Expr\StaticPropertyFetch) { + $name = (string)$className . '::$' . $node->name; + } else { + $name = (string)$className . '::' . $node->name; + } } else { return null; } @@ -281,25 +326,34 @@ private static function getContainingClassFqn(Node $node) /** * Returns the assignment or parameter node where a variable was defined * - * @param Node\Expr\Variable $n The variable access + * @param Node\Expr\Variable|Node\Expr\ClosureUse $var The variable access * @return Node\Expr\Assign|Node\Param|Node\Expr\ClosureUse|null */ - public static function resolveVariableToNode(Node\Expr\Variable $var) + public static function resolveVariableToNode(Node\Expr $var) { $n = $var; + // When a use is passed, start outside the closure to not return immediatly + if ($var instanceof Node\Expr\ClosureUse) { + $n = $var->getAttribute('parentNode')->getAttribute('parentNode'); + $name = $var->var; + } else if ($var instanceof Node\Expr\Variable || $var instanceof Node\Param) { + $name = $var->name; + } else { + throw new \InvalidArgumentException('$var must be Variable, Param or ClosureUse, not ' . get_class($var)); + } // Traverse the AST up do { // If a function is met, check the parameters and use statements if ($n instanceof Node\FunctionLike) { foreach ($n->getParams() as $param) { - if ($param->name === $var->name) { + if ($param->name === $name) { return $param; } } // If it is a closure, also check use statements if ($n instanceof Node\Expr\Closure) { foreach ($n->uses as $use) { - if ($use->var === $var->name) { + if ($use->var === $name) { return $use; } } @@ -310,7 +364,7 @@ public static function resolveVariableToNode(Node\Expr\Variable $var) while ($n->getAttribute('previousSibling') && $n = $n->getAttribute('previousSibling')) { if ( ($n instanceof Node\Expr\Assign || $n instanceof Node\Expr\AssignOp) - && $n->var instanceof Node\Expr\Variable && $n->var->name === $var->name + && $n->var instanceof Node\Expr\Variable && $n->var->name === $name ) { return $n; } @@ -327,10 +381,10 @@ public static function resolveVariableToNode(Node\Expr\Variable $var) * @param \PhpParser\Node\Expr $expr * @return \phpDocumentor\Type */ - private function resolveExpressionNodeToType(Node\Expr $expr): Type + public function resolveExpressionNodeToType(Node\Expr $expr): Type { - if ($expr instanceof Node\Expr\Variable) { - if ($expr->name === 'this') { + if ($expr instanceof Node\Expr\Variable || $expr instanceof Node\Expr\ClosureUse) { + if ($expr instanceof Node\Expr\Variable && $expr->name === 'this') { return new Types\This; } // Find variable definition @@ -385,7 +439,7 @@ private function resolveExpressionNodeToType(Node\Expr $expr): Type } else { $classFqn = substr((string)$t->getFqsen(), 1); } - $fqn = $classFqn . '::' . $expr->name; + $fqn = $classFqn . '->' . $expr->name; if ($expr instanceof Node\Expr\MethodCall) { $fqn .= '()'; } @@ -404,7 +458,11 @@ private function resolveExpressionNodeToType(Node\Expr $expr): Type if (!($classType instanceof Types\Object_) || $classType->getFqsen() === null || $expr->name instanceof Node\Expr) { return new Types\Mixed; } - $fqn = substr((string)$classType->getFqsen(), 1) . '::' . $expr->name; + $fqn = substr((string)$classType->getFqsen(), 1) . '::'; + if ($expr instanceof Node\Expr\StaticPropertyFetch) { + $fqn .= '$'; + } + $fqn .= $expr->name; if ($expr instanceof Node\Expr\StaticCall) { $fqn .= '()'; } @@ -599,7 +657,7 @@ private static function resolveClassNameToType(Node $class): Type * For functions and methods, this is the return type. * For parameters, this is the type of the parameter. * For classes and interfaces, this is the class type (object). - * Variables are not indexed for performance reasons. + * For variables / assignments, this is the documented type or type the assignment resolves to. * Can also be a compound type. * If it is unknown, will be Types\Mixed. * Returns null if the node does not have a type. @@ -612,28 +670,35 @@ public function getTypeFromNode(Node $node) if ($node instanceof Node\Param) { // Parameters $docBlock = $node->getAttribute('parentNode')->getAttribute('docBlock'); - if ( - $docBlock !== null - && !empty($paramTags = $docBlock->getTagsByName('param')) - && $paramTags[0]->getType() !== null - ) { + if ($docBlock !== null) { // Use @param tag - return $paramTags[0]->getType(); + foreach ($docBlock->getTagsByName('param') as $paramTag) { + if ($paramTag->getVariableName() === $node->name) { + if ($paramTag->getType() === null) { + break; + } + return $paramTag->getType(); + } + } } if ($node->type !== null) { // Use PHP7 return type hint if (is_string($node->type)) { // Resolve a string like "bool" to a type object $type = $this->typeResolver->resolve($node->type); + } else { + $type = new Types\Object_(new Fqsen('\\' . (string)$node->type)); } - $type = new Types\Object_(new Fqsen('\\' . (string)$node->type)); - if ($node->default !== null) { - $defaultType = $this->resolveExpressionNodeToType($node->default); + } + if ($node->default !== null) { + $defaultType = $this->resolveExpressionNodeToType($node->default); + if (isset($type) && !is_a($type, get_class($defaultType))) { $type = new Types\Compound([$type, $defaultType]); + } else { + $type = $defaultType; } } - // Unknown parameter type - return new Types\Mixed; + return $type ?? new Types\Mixed; } if ($node instanceof Node\FunctionLike) { // Functions/methods @@ -657,16 +722,39 @@ public function getTypeFromNode(Node $node) // Unknown return type return new Types\Mixed; } - if ($node instanceof Node\Stmt\PropertyProperty || $node instanceof Node\Const_) { - // Property or constant - $docBlock = $node->getAttribute('parentNode')->getAttribute('docBlock'); + if ($node instanceof Node\Expr\Variable) { + $node = $node->getAttribute('parentNode'); + } + if ( + $node instanceof Node\Stmt\PropertyProperty + || $node instanceof Node\Const_ + || $node instanceof Node\Expr\Assign + || $node instanceof Node\Expr\AssignOp + ) { + if ($node instanceof Node\Stmt\PropertyProperty || $node instanceof Node\Const_) { + $docBlockHolder = $node->getAttribute('parentNode'); + } else { + $docBlockHolder = $node; + } + // Property, constant or variable + // Use @var tag if ( - $docBlock !== null + isset($docBlockHolder) + && ($docBlock = $docBlockHolder->getAttribute('docBlock')) && !empty($varTags = $docBlock->getTagsByName('var')) - && $varTags[0]->getType() + && ($type = $varTags[0]->getType()) ) { - // Use @var tag - return $varTags[0]->getType(); + return $type; + } + // Resolve the expression + if ($node instanceof Node\Stmt\PropertyProperty) { + if ($node->default) { + return $this->resolveExpressionNodeToType($node->default); + } + } else if ($node instanceof Node\Const_) { + return $this->resolveExpressionNodeToType($node->value); + } else if ($node instanceof Node\Expr\Assign || $node instanceof Node\Expr\AssignOp) { + return $this->resolveExpressionNodeToType($node); } // TODO: read @property tags of class // TODO: Try to infer the type from default value / constant value @@ -689,25 +777,37 @@ public static function getDefinedFqn(Node $node) if ($node instanceof Node\Stmt\ClassLike && isset($node->name)) { // Class, interface or trait declaration return (string)$node->namespacedName; + } else if ($node instanceof Node\Stmt\Namespace_) { + return (string)$node->name; } else if ($node instanceof Node\Stmt\Function_) { // Function: use functionName() as the name return (string)$node->namespacedName . '()'; } else if ($node instanceof Node\Stmt\ClassMethod) { - // Class method: use ClassName::methodName() as name + // Class method: use ClassName->methodName() as name $class = $node->getAttribute('parentNode'); if (!isset($class->name)) { // Ignore anonymous classes return null; } - return (string)$class->namespacedName . '::' . (string)$node->name . '()'; + if ($node->isStatic()) { + return (string)$class->namespacedName . '::' . (string)$node->name . '()'; + } else { + return (string)$class->namespacedName . '->' . (string)$node->name . '()'; + } } else if ($node instanceof Node\Stmt\PropertyProperty) { - // Property: use ClassName::propertyName as name - $class = $node->getAttribute('parentNode')->getAttribute('parentNode'); + $property = $node->getAttribute('parentNode'); + $class = $property->getAttribute('parentNode'); if (!isset($class->name)) { // Ignore anonymous classes return null; } - return (string)$class->namespacedName . '::' . (string)$node->name; + if ($property->isStatic()) { + // Static Property: use ClassName::$propertyName as name + return (string)$class->namespacedName . '::$' . (string)$node->name; + } else { + // Instance Property: use ClassName->propertyName as name + return (string)$class->namespacedName . '->' . (string)$node->name; + } } else if ($node instanceof Node\Const_) { $parent = $node->getAttribute('parentNode'); if ($parent instanceof Node\Stmt\Const_) { diff --git a/src/LanguageServer.php b/src/LanguageServer.php index d2ae7429..42a1b64b 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -11,7 +11,8 @@ MessageType, InitializeResult, SymbolInformation, - TextDocumentIdentifier + TextDocumentIdentifier, + CompletionOptions }; use AdvancedJsonRpc; use Sabre\Event\{Loop, Promise}; @@ -144,6 +145,10 @@ public function initialize(ClientCapabilities $capabilities, string $rootPath = $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); } diff --git a/src/NodeVisitor/DefinitionCollector.php b/src/NodeVisitor/DefinitionCollector.php index 162f6708..51981395 100644 --- a/src/NodeVisitor/DefinitionCollector.php +++ b/src/NodeVisitor/DefinitionCollector.php @@ -42,13 +42,6 @@ public function enterNode(Node $node) return; } $this->nodes[$fqn] = $node; - $def = new Definition; - $def->fqn = $fqn; - $def->symbolInformation = SymbolInformation::fromNode($node, $fqn); - $def->type = $this->definitionResolver->getTypeFromNode($node); - $def->declarationLine = $this->definitionResolver->getDeclarationLineFromNode($node); - $def->documentation = $this->definitionResolver->getDocumentationFromNode($node); - - $this->definitions[$fqn] = $def; + $this->definitions[$fqn] = $this->definitionResolver->createDefinitionFromNode($node, $fqn); } } diff --git a/src/PhpDocument.php b/src/PhpDocument.php index 575a5e62..1e1a726c 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -290,6 +290,9 @@ public function getStmts(): array */ public function getNodeAtPosition(Position $position) { + if ($this->stmts === null) { + return null; + } $traverser = new NodeTraverser; $finder = new NodeAtPositionFinder($position); $traverser->addVisitor($finder); @@ -297,6 +300,22 @@ public function getNodeAtPosition(Position $position) return $finder->node; } + /** + * Returns a range of the content + * + * @param Range $range + * @return string|null + */ + public function getRange(Range $range) + { + if ($this->content === null) { + return null; + } + $start = $range->start->toOffset($this->content); + $length = $range->end->toOffset($this->content) - $start; + return substr($this->content, $start, $length); + } + /** * Returns the definition node for a fully qualified name * diff --git a/src/Protocol/CompletionItem.php b/src/Protocol/CompletionItem.php index 780f31f4..64bc69da 100644 --- a/src/Protocol/CompletionItem.php +++ b/src/Protocol/CompletionItem.php @@ -1,7 +1,10 @@ label = $label; + $this->kind = $kind; + $this->detail = $detail; + $this->documentation = $documentation; + $this->sortText = $sortText; + $this->filterText = $filterText; + $this->insertText = $insertText; + $this->textEdit = $textEdit; + $this->additionalTextEdits = $additionalTextEdits; + $this->command = $command; + $this->data = $data; + } + + /** + * Creates a CompletionItem for a Definition + * + * @param Definition $def + * @return self + */ + public static function fromDefinition(Definition $def): self + { + $item = new CompletionItem; + $item->label = $def->symbolInformation->name; + $item->kind = CompletionItemKind::fromSymbolKind($def->symbolInformation->kind); + if ($def->type) { + $item->detail = (string)$def->type; + } else if ($def->symbolInformation->containerName) { + $item->detail = $def->symbolInformation->containerName; + } + if ($def->documentation) { + $item->documentation = $def->documentation; + } + if ($def->isStatic && $def->symbolInformation->kind === SymbolKind::PROPERTY) { + $item->insertText = '$' . $def->symbolInformation->name; + } + return $item; + } } diff --git a/src/Protocol/CompletionItemKind.php b/src/Protocol/CompletionItemKind.php index 6ef57963..046f8ef0 100644 --- a/src/Protocol/CompletionItemKind.php +++ b/src/Protocol/CompletionItemKind.php @@ -13,7 +13,7 @@ abstract class CompletionItemKind const CONSTRUCTOR = 4; const FIELD = 5; const VARIABLE = 6; - const _CLASS = 7; + const CLASS_ = 7; const INTERFACE = 8; const MODULE = 9; const PROPERTY = 10; @@ -25,4 +25,46 @@ abstract class CompletionItemKind const COLOR = 16; const FILE = 17; const REFERENCE = 18; + + /** + * Returns the CompletionItemKind for a SymbolKind + * + * @param int $kind A SymbolKind + * @return int The CompletionItemKind + */ + public static function fromSymbolKind(int $kind): int + { + switch ($kind) { + case SymbolKind::PROPERTY: + case SymbolKind::FIELD: + return self::PROPERTY; + case SymbolKind::METHOD: + return self::METHOD; + case SymbolKind::CLASS_: + return self::CLASS_; + case SymbolKind::INTERFACE: + return self::INTERFACE; + case SymbolKind::FUNCTION: + return self::FUNCTION; + case SymbolKind::NAMESPACE: + case SymbolKind::MODULE: + case SymbolKind::PACKAGE: + return self::MODULE; + case SymbolKind::FILE: + return self::FILE; + case SymbolKind::STRING: + return self::TEXT; + case SymbolKind::NUMBER: + case SymbolKind::BOOLEAN: + case SymbolKind::ARRAY: + return self::VALUE; + case SymbolKind::ENUM: + return self::ENUM; + case SymbolKind::CONSTRUCTOR: + return self::CONSTRUCTOR; + case SymbolKind::VARIABLE: + case SymbolKind::CONSTANT: + return self::VARIABLE; + } + } } diff --git a/src/Protocol/CompletionList.php b/src/Protocol/CompletionList.php index d3488309..4d0bd646 100644 --- a/src/Protocol/CompletionList.php +++ b/src/Protocol/CompletionList.php @@ -22,4 +22,14 @@ class CompletionList * @var CompletionItem[] */ public $items; + + /** + * @param CompletionItem[] $items The completion items. + * @param bool $isIncomplete This list it not complete. Further typing should result in recomputing this list. + */ + public function __construct(array $items = [], bool $isIncomplete = false) + { + $this->items = $items; + $this->isIncomplete = $isIncomplete; + } } diff --git a/src/Protocol/CompletionOptions.php b/src/Protocol/CompletionOptions.php index 0be727ed..f668ca0c 100644 --- a/src/Protocol/CompletionOptions.php +++ b/src/Protocol/CompletionOptions.php @@ -11,14 +11,14 @@ class CompletionOptions * The server provides support to resolve additional information for a completion * item. * - * @var bool + * @var bool|null */ public $resolveProvider; /** * The characters that trigger completion automatically. * - * @var string|null + * @var string[]|null */ public $triggerCharacters; } diff --git a/src/Protocol/Position.php b/src/Protocol/Position.php index 01cff0b6..f47afe27 100644 --- a/src/Protocol/Position.php +++ b/src/Protocol/Position.php @@ -49,4 +49,17 @@ public function compare(Position $position): int return $this->character - $position->character; } + + /** + * Returns the offset of the position in a string + * + * @param string $content + * @return int + */ + public function toOffset(string $content): int + { + $lines = explode("\n", $content); + $slice = array_slice($lines, 0, $this->line); + return array_sum(array_map('strlen', $slice)) + count($slice) + $this->character; + } } diff --git a/src/Protocol/SymbolInformation.php b/src/Protocol/SymbolInformation.php index 1111dc08..06e8f7e9 100644 --- a/src/Protocol/SymbolInformation.php +++ b/src/Protocol/SymbolInformation.php @@ -88,7 +88,7 @@ public static function fromNode(Node $node, string $fqn = null) } $symbol->location = Location::fromNode($node); if ($fqn !== null) { - $parts = preg_split('/(::|\\\\)/', $fqn); + $parts = preg_split('/(::|->|\\\\)/', $fqn); array_pop($parts); $symbol->containerName = implode('\\', $parts); } diff --git a/src/Server/TextDocument.php b/src/Server/TextDocument.php index 6c673888..5998accc 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -3,7 +3,7 @@ namespace LanguageServer\Server; -use LanguageServer\{LanguageClient, Project, PhpDocument, DefinitionResolver}; +use LanguageServer\{LanguageClient, Project, PhpDocument, DefinitionResolver, CompletionProvider}; use PhpParser\PrettyPrinter\Standard as PrettyPrinter; use PhpParser\Node; use LanguageServer\Protocol\{ @@ -18,7 +18,10 @@ SymbolInformation, ReferenceContext, Hover, - MarkedString + MarkedString, + SymbolKind, + CompletionItem, + CompletionItemKind }; use Sabre\Event\Promise; use function Sabre\Event\coroutine; @@ -50,12 +53,18 @@ class TextDocument */ private $definitionResolver; + /** + * @var CompletionProvider + */ + private $completionProvider; + public function __construct(Project $project, LanguageClient $client) { $this->project = $project; $this->client = $client; $this->prettyPrinter = new PrettyPrinter(); $this->definitionResolver = new DefinitionResolver($project); + $this->completionProvider = new CompletionProvider($this->definitionResolver, $project); } /** @@ -210,4 +219,26 @@ public function hover(TextDocumentIdentifier $textDocument, Position $position): return new Hover($contents, $range); }); } + + /** + * The Completion request is sent from the client to the server to compute completion items at a given cursor + * position. Completion items are presented in the IntelliSense user interface. If computing full completion items + * is expensive, servers can additionally provide a handler for the completion item resolve request + * ('completionItem/resolve'). This request is sent when a completion item is selected in the user interface. A + * typically use case is for example: the 'textDocument/completion' request doesn't fill in the documentation + * property for returned completion items since it is expensive to compute. When the item is selected in the user + * interface then a 'completionItem/resolve' request is sent with the selected completion item as a param. The + * returned completion item should have the documentation property filled in. + * + * @param TextDocumentIdentifier The text document + * @param Position $position The position + * @return Promise + */ + public function completion(TextDocumentIdentifier $textDocument, Position $position): Promise + { + return coroutine(function () use ($textDocument, $position) { + $document = yield $this->project->getOrLoadDocument($textDocument->uri); + return $this->completionProvider->provideCompletion($document, $position); + }); + } } diff --git a/src/utils.php b/src/utils.php index 172ccac3..ed7a4198 100644 --- a/src/utils.php +++ b/src/utils.php @@ -95,3 +95,25 @@ function getClosestNode(Node $node, string $type) } } } + +/** + * Returns the part of $b that is not overlapped by $a + * Example: + * + * stripStringOverlap('whatever TextDocumentSyncKind::FULL, 'documentSymbolProvider' => true, 'hoverProvider' => true, - 'completionProvider' => null, + 'completionProvider' => (object)[ + 'resolveProvider' => false, + 'triggerCharacters' => ['$', '>'] + ], 'signatureHelpProvider' => null, 'definitionProvider' => true, 'referencesProvider' => true, @@ -61,7 +64,7 @@ public function testIndexingWithDirectFileAccess() if ($msg->body->method === 'window/logMessage' && $promise->state === Promise::PENDING) { if ($msg->body->params->type === MessageType::ERROR) { $promise->reject(new Exception($msg->body->params->message)); - } else if (strpos($msg->body->params->message, 'All 10 PHP files parsed') !== false) { + } else if (strpos($msg->body->params->message, 'All 25 PHP files parsed') !== false) { $promise->fulfill(); } } @@ -106,7 +109,7 @@ public function testIndexingWithFilesAndContentRequests() if ($promise->state === Promise::PENDING) { $promise->reject(new Exception($msg->body->params->message)); } - } else if (strpos($msg->body->params->message, 'All 10 PHP files parsed') !== false) { + } else if (strpos($msg->body->params->message, 'All 25 PHP files parsed') !== false) { // Indexing finished $promise->fulfill(); } diff --git a/tests/NodeVisitor/DefinitionCollectorTest.php b/tests/NodeVisitor/DefinitionCollectorTest.php index ded65d1c..74e0d5c9 100644 --- a/tests/NodeVisitor/DefinitionCollectorTest.php +++ b/tests/NodeVisitor/DefinitionCollectorTest.php @@ -30,13 +30,14 @@ public function testCollectsSymbols() $traverser->traverse($stmts); $defNodes = $definitionCollector->nodes; $this->assertEquals([ + 'TestNamespace', 'TestNamespace\\TEST_CONST', 'TestNamespace\\TestClass', 'TestNamespace\\TestClass::TEST_CLASS_CONST', - 'TestNamespace\\TestClass::staticTestProperty', - 'TestNamespace\\TestClass::testProperty', + 'TestNamespace\\TestClass::$staticTestProperty', + 'TestNamespace\\TestClass->testProperty', 'TestNamespace\\TestClass::staticTestMethod()', - 'TestNamespace\\TestClass::testMethod()', + 'TestNamespace\\TestClass->testMethod()', 'TestNamespace\\TestTrait', 'TestNamespace\\TestInterface', 'TestNamespace\\test_function()' @@ -44,10 +45,10 @@ public function testCollectsSymbols() $this->assertInstanceOf(Node\Const_::class, $defNodes['TestNamespace\\TEST_CONST']); $this->assertInstanceOf(Node\Stmt\Class_::class, $defNodes['TestNamespace\\TestClass']); $this->assertInstanceOf(Node\Const_::class, $defNodes['TestNamespace\\TestClass::TEST_CLASS_CONST']); - $this->assertInstanceOf(Node\Stmt\PropertyProperty::class, $defNodes['TestNamespace\\TestClass::staticTestProperty']); - $this->assertInstanceOf(Node\Stmt\PropertyProperty::class, $defNodes['TestNamespace\\TestClass::testProperty']); + $this->assertInstanceOf(Node\Stmt\PropertyProperty::class, $defNodes['TestNamespace\\TestClass::$staticTestProperty']); + $this->assertInstanceOf(Node\Stmt\PropertyProperty::class, $defNodes['TestNamespace\\TestClass->testProperty']); $this->assertInstanceOf(Node\Stmt\ClassMethod::class, $defNodes['TestNamespace\\TestClass::staticTestMethod()']); - $this->assertInstanceOf(Node\Stmt\ClassMethod::class, $defNodes['TestNamespace\\TestClass::testMethod()']); + $this->assertInstanceOf(Node\Stmt\ClassMethod::class, $defNodes['TestNamespace\\TestClass->testMethod()']); $this->assertInstanceOf(Node\Stmt\Trait_::class, $defNodes['TestNamespace\\TestTrait']); $this->assertInstanceOf(Node\Stmt\Interface_::class, $defNodes['TestNamespace\\TestInterface']); $this->assertInstanceOf(Node\Stmt\Function_::class, $defNodes['TestNamespace\\test_function()']); @@ -68,7 +69,8 @@ public function testDoesNotCollectReferences() $stmts = $parser->parse(file_get_contents($uri)); $traverser->traverse($stmts); $defNodes = $definitionCollector->nodes; - $this->assertEquals(['TestNamespace\\whatever()'], array_keys($defNodes)); + $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/Server/ServerTestCase.php b/tests/Server/ServerTestCase.php index 1a608ca4..23d1763c 100644 --- a/tests/Server/ServerTestCase.php +++ b/tests/Server/ServerTestCase.php @@ -54,13 +54,11 @@ public function setUp() $referencesUri = pathToUri(realpath(__DIR__ . '/../../fixtures/references.php')); $useUri = pathToUri(realpath(__DIR__ . '/../../fixtures/use.php')); - Promise\all([ - $this->project->loadDocument($symbolsUri), - $this->project->loadDocument($referencesUri), - $this->project->loadDocument($globalSymbolsUri), - $this->project->loadDocument($globalReferencesUri), - $this->project->loadDocument($useUri) - ])->wait(); + $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(); // @codingStandardsIgnoreStart $this->definitionLocations = [ @@ -79,6 +77,8 @@ public function setUp() 'whatever()' => new Location($globalReferencesUri, new Range(new Position(21, 0), new Position(23, 1))), // Namespaced + 'TestNamespace' => new Location($symbolsUri, new Range(new Position( 2, 0), new Position( 2, 24))), + 'SecondTestNamespace' => new Location($useUri, new Range(new Position( 2, 0), new Position( 2, 30))), 'TestNamespace\\TEST_CONST' => new Location($symbolsUri, new Range(new Position( 9, 6), new Position( 9, 22))), 'TestNamespace\\TestClass' => new Location($symbolsUri, new Range(new Position(20, 0), new Position(61, 1))), 'TestNamespace\\TestTrait' => new Location($symbolsUri, new Range(new Position(63, 0), new Position(66, 1))), diff --git a/tests/Server/TextDocument/CompletionTest.php b/tests/Server/TextDocument/CompletionTest.php new file mode 100644 index 00000000..7b5dd0ac --- /dev/null +++ b/tests/Server/TextDocument/CompletionTest.php @@ -0,0 +1,383 @@ +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); + } + + public function testPropertyAndMethodWithPrefix() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/property_with_prefix.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(3, 7) + )->wait(); + $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.' + ) + ], true), $items); + } + + public function testPropertyAndMethodWithoutPrefix() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/property.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(3, 6) + )->wait(); + $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.' + ) + ], true), $items); + } + + public function testVariable() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/variable.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(8, 5) + )->wait(); + $this->assertEquals(new CompletionList([ + new CompletionItem( + '$var', + CompletionItemKind::VARIABLE, + 'int', + null, + null, + null, + null, + new TextEdit(new Range(new Position(8, 5), new Position(8, 5)), 'var') + ), + new CompletionItem( + '$param', + CompletionItemKind::VARIABLE, + 'string|null', + 'A parameter', + null, + null, + null, + new TextEdit(new Range(new Position(8, 5), new Position(8, 5)), 'param') + ) + ], true), $items); + } + + public function testVariableWithPrefix() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/variable_with_prefix.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(8, 6) + )->wait(); + $this->assertEquals(new CompletionList([ + new CompletionItem( + '$param', + CompletionItemKind::VARIABLE, + 'string|null', + 'A parameter', + null, + null, + null, + new TextEdit(new Range(new Position(8, 6), new Position(8, 6)), 'aram') + ) + ], true), $items); + } + + public function testNewInNamespace() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_new.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(6, 10) + )->wait(); + $this->assertEquals(new CompletionList([ + // Global TestClass definition (inserted as \TestClass) + new CompletionItem( + 'TestClass', + CompletionItemKind::CLASS_, + null, + 'Pariatur ut laborum tempor voluptate consequat ea deserunt.', + null, + null, + '\TestClass' + ), + // Namespaced, `use`d TestClass definition (inserted as TestClass) + new CompletionItem( + 'TestClass', + CompletionItemKind::CLASS_, + 'TestNamespace', + 'Pariatur ut laborum tempor voluptate consequat ea deserunt.', + null, + null, + 'TestClass' + ), + ], true), $items); + } + + public function testUsedClass() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/used_class.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(6, 5) + )->wait(); + $this->assertEquals(new CompletionList([ + new CompletionItem( + 'TestClass', + CompletionItemKind::CLASS_, + 'TestNamespace', + 'Pariatur ut laborum tempor voluptate consequat ea deserunt.' + ) + ], true), $items); + } + + public function testStaticPropertyWithPrefix() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static_property_with_prefix.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(2, 14) + )->wait(); + $this->assertEquals(new CompletionList([ + new CompletionItem( + 'staticTestProperty', + CompletionItemKind::PROPERTY, + '\TestClass[]', + 'Lorem excepteur officia sit anim velit veniam enim.', + null, + null, + '$staticTestProperty' + ) + ], true), $items); + } + + public function testStaticWithoutPrefix() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/static.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(2, 11) + )->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', // Method return type + 'Do magna consequat veniam minim proident eiusmod incididunt aute proident.' + ) + ], true), $items); + } + + 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)); + $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.' + ) + ], true), $items); + } + + public function testFullyQualifiedClass() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/fully_qualified_class.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(6, 6) + )->wait(); + $this->assertEquals(new CompletionList([ + new CompletionItem( + 'TestClass', + CompletionItemKind::CLASS_, + null, + 'Pariatur ut laborum tempor voluptate consequat ea deserunt.', + null, + null, + 'TestClass' + ) + ], true), $items); + } + + public function testKeywords() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/keywords.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(2, 1) + )->wait(); + $this->assertEquals(new CompletionList([ + new CompletionItem('class', CompletionItemKind::KEYWORD, null, null, null, null, 'class '), + new CompletionItem('clone', CompletionItemKind::KEYWORD, null, null, null, null, 'clone ') + ], true), $items); + } + + public function testHtmlWithoutPrefix() + { + $completionUri = pathToUri(__DIR__ . '/../../../fixtures/completion/html.php'); + $this->project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(0, 0) + )->wait(); + $this->assertEquals(new CompletionList([ + new CompletionItem( + 'project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(0, 1) + )->wait(); + $this->assertEquals(new CompletionList([ + new CompletionItem( + 'project->openDocument($completionUri, file_get_contents($completionUri)); + $items = $this->textDocument->completion( + new TextDocumentIdentifier($completionUri), + new Position(4, 6) + )->wait(); + $this->assertEquals(new CompletionList([ + new CompletionItem( + 'SomeNamespace', + CompletionItemKind::MODULE, + null, + null, + null, + null, + 'SomeNamespace' + ) + ], true), $items); + } +} diff --git a/tests/Server/TextDocument/DocumentSymbolTest.php b/tests/Server/TextDocument/DocumentSymbolTest.php index 4f09e20d..b9c937ef 100644 --- a/tests/Server/TextDocument/DocumentSymbolTest.php +++ b/tests/Server/TextDocument/DocumentSymbolTest.php @@ -18,6 +18,7 @@ public function test() $result = $this->textDocument->documentSymbol(new TextDocumentIdentifier($uri))->wait(); // @codingStandardsIgnoreStart $this->assertEquals([ + new SymbolInformation('TestNamespace', SymbolKind::NAMESPACE, $this->getDefinitionLocation('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('TEST_CLASS_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TestNamespace\\TestClass::TEST_CLASS_CONST'), 'TestNamespace\\TestClass'), diff --git a/tests/Server/Workspace/SymbolTest.php b/tests/Server/Workspace/SymbolTest.php index 70869426..33b4cf1d 100644 --- a/tests/Server/Workspace/SymbolTest.php +++ b/tests/Server/Workspace/SymbolTest.php @@ -6,7 +6,17 @@ use LanguageServer\Tests\MockProtocolStream; use LanguageServer\Tests\Server\ServerTestCase; use LanguageServer\{Server, Client, LanguageClient, Project, PhpDocument}; -use LanguageServer\Protocol\{TextDocumentItem, TextDocumentIdentifier, SymbolInformation, SymbolKind, DiagnosticSeverity, FormattingOptions}; +use LanguageServer\Protocol\{ + TextDocumentItem, + TextDocumentIdentifier, + SymbolInformation, + SymbolKind, + DiagnosticSeverity, + FormattingOptions, + Location, + Range, + Position +}; use AdvancedJsonRpc\{Request as RequestBody, Response as ResponseBody}; use function LanguageServer\pathToUri; @@ -16,8 +26,10 @@ public function testEmptyQueryReturnsAllSymbols() { // Request symbols $result = $this->workspace->symbol(''); + $referencesUri = pathToUri(realpath(__DIR__ . '/../../../fixtures/references.php')); // @codingStandardsIgnoreStart $this->assertEquals([ + new SymbolInformation('TestNamespace', SymbolKind::NAMESPACE, new Location($referencesUri, new Range(new Position(2, 0), new Position(2, 24))), ''), // Namespaced new SymbolInformation('TEST_CONST', SymbolKind::CONSTANT, $this->getDefinitionLocation('TestNamespace\\TEST_CONST'), 'TestNamespace'), new SymbolInformation('TestClass', SymbolKind::CLASS_, $this->getDefinitionLocation('TestNamespace\\TestClass'), 'TestNamespace'), @@ -41,7 +53,9 @@ public function testEmptyQueryReturnsAllSymbols() new SymbolInformation('TestTrait', SymbolKind::CLASS_, $this->getDefinitionLocation('TestTrait'), ''), new SymbolInformation('TestInterface', SymbolKind::INTERFACE, $this->getDefinitionLocation('TestInterface'), ''), new SymbolInformation('test_function', SymbolKind::FUNCTION, $this->getDefinitionLocation('test_function()'), ''), - new SymbolInformation('whatever', SymbolKind::FUNCTION, $this->getDefinitionLocation('whatever()'), '') + new SymbolInformation('whatever', SymbolKind::FUNCTION, $this->getDefinitionLocation('whatever()'), ''), + + new SymbolInformation('SecondTestNamespace', SymbolKind::NAMESPACE, $this->getDefinitionLocation('SecondTestNamespace'), '') ], $result); // @codingStandardsIgnoreEnd } diff --git a/tests/Utils/StripStringOverlapTest.php b/tests/Utils/StripStringOverlapTest.php new file mode 100644 index 00000000..06db68eb --- /dev/null +++ b/tests/Utils/StripStringOverlapTest.php @@ -0,0 +1,45 @@ +assertEquals('assertEquals('?php', stripStringOverlap('bla<', 'assertEquals('php', stripStringOverlap('blaassertEquals('', stripStringOverlap('blaassertEquals('assertEquals('', stripStringOverlap('bla', '')); + } + + public function testBothEmpty() + { + $this->assertEquals('', stripStringOverlap('', '')); + } +}