diff --git a/.editorconfig b/.editorconfig index 5558124..d162066 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/bin/php-language-server.php b/bin/php-language-server.php index 7cb3178..62bdeb4 100644 --- a/bin/php-language-server.php +++ b/bin/php-language-server.php @@ -68,10 +68,12 @@ if (!empty($options['tcp'])) { exit(1); } else if ($pid === 0) { // Child process - $ls = new LanguageServer( - new ProtocolStreamReader($socket), - new ProtocolStreamWriter($socket) - ); + $reader = new ProtocolStreamReader($socket); + $writer = new ProtocolStreamWriter($socket); + $reader->on('close', function () { + fwrite(STDOUT, "Connection closed\n"); + }); + $ls = new LanguageServer($reader, $writer); Loop\run(); // Just for safety exit(0); diff --git a/composer.json b/composer.json index df9110a..224d3d0 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 0000000..36c71d4 --- /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 0000000..677bcbb --- /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 0000000..f975d7c --- /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 cba69ab..b7730f3 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 a35750e..87c5aa0 100644 --- a/src/DefinitionResolver.php +++ b/src/DefinitionResolver.php @@ -91,6 +91,35 @@ class DefinitionResolver } } + /** + * 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 @@ class DefinitionResolver 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 @@ class DefinitionResolver 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 @@ class DefinitionResolver 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 @@ class DefinitionResolver } 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 @@ class DefinitionResolver $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 @@ class DefinitionResolver /** * 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 @@ class DefinitionResolver 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 @@ class DefinitionResolver * @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 @@ class DefinitionResolver } 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 @@ class DefinitionResolver 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 @@ class DefinitionResolver * 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 @@ class DefinitionResolver 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); - } - $type = new Types\Object_(new Fqsen('\\' . (string)$node->type)); - if ($node->default !== null) { - $defaultType = $this->resolveExpressionNodeToType($node->default); - $type = new Types\Compound([$type, $defaultType]); + } else { + $type = new Types\Object_(new Fqsen('\\' . (string)$node->type)); } } - // Unknown parameter type - return new Types\Mixed; + 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; + } + } + return $type ?? new Types\Mixed; } if ($node instanceof Node\FunctionLike) { // Functions/methods @@ -657,16 +722,39 @@ class DefinitionResolver // 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 @@ class DefinitionResolver 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 b982870..9ec906c 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -4,13 +4,14 @@ declare(strict_types = 1); namespace LanguageServer; use LanguageServer\Protocol\{ - ServerCapabilities, - ClientCapabilities, - TextDocumentSyncKind, - Message, - MessageType, - InitializeResult, - TextDocumentIdentifier + ServerCapabilities, + ClientCapabilities, + TextDocumentSyncKind, + Message, + MessageType, + InitializeResult, + TextDocumentIdentifier, + CompletionOptions }; use AdvancedJsonRpc; use Sabre\Event\Promise; @@ -25,224 +26,232 @@ use function Sabre\Event\Loop\setTimeout; class LanguageServer extends AdvancedJsonRpc\Dispatcher { - /** - * Handles textDocument/* method calls - * - * @var Server\TextDocument - */ - public $textDocument; + /** + * Handles textDocument/* method calls + * + * @var Server\TextDocument + */ + public $textDocument; - /** - * Handles workspace/* method calls - * - * @var Server\Workspace - */ - public $workspace; + /** + * Handles workspace/* method calls + * + * @var Server\Workspace + */ + public $workspace; - public $telemetry; - public $window; - public $completionItem; - public $codeLens; + public $telemetry; + public $window; + public $completionItem; + public $codeLens; - /** - * ClientCapabilities - */ - private $clientCapabilities; + /** + * ClientCapabilities + */ + private $clientCapabilities; - private $protocolReader; - private $protocolWriter; - private $client; + private $protocolReader; + private $protocolWriter; + private $client; - /** - * The root project path that was passed to initialize() - * - * @var string - */ - private $rootPath; - private $project; + /** + * The root project path that was passed to initialize() + * + * @var string + */ + private $rootPath; + private $project; - public function __construct(ProtocolReader $reader, ProtocolWriter $writer) - { - parent::__construct($this, '/'); - $this->protocolReader = $reader; - $this->protocolReader->on('message', function (Message $msg) { - coroutine(function () use ($msg) { - // Ignore responses, this is the handler for requests and notifications - if (AdvancedJsonRpc\Response::isResponse($msg->body)) { - return; - } - $result = null; - $error = null; - try { - // Invoke the method handler to get a result - $result = yield $this->dispatch($msg->body); - } catch (AdvancedJsonRpc\Error $e) { - // If a ResponseError is thrown, send it back in the Response - $error = $e; - } catch (Throwable $e) { - // If an unexpected error occured, send back an INTERNAL_ERROR error response - $error = new AdvancedJsonRpc\Error( - $e->getMessage(), - AdvancedJsonRpc\ErrorCode::INTERNAL_ERROR, - null, - $e - ); - } - // Only send a Response for a Request - // Notifications do not send Responses - if (AdvancedJsonRpc\Request::isRequest($msg->body)) { - if ($error !== null) { - $responseBody = new AdvancedJsonRpc\ErrorResponse($msg->body->id, $error); - } else { - $responseBody = new AdvancedJsonRpc\SuccessResponse($msg->body->id, $result); - } - $this->protocolWriter->write(new Message($responseBody)); - } - })->otherwise('\\LanguageServer\\crash'); - }); - $this->protocolWriter = $writer; - $this->client = new LanguageClient($reader, $writer); - } + public function __construct(ProtocolReader $reader, ProtocolWriter $writer) + { + parent::__construct($this, '/'); + $this->protocolReader = $reader; + $this->protocolReader->on('close', function () { + $this->shutdown(); + $this->exit(); + }); + $this->protocolReader->on('message', function (Message $msg) { + coroutine(function () use ($msg) { + // Ignore responses, this is the handler for requests and notifications + if (AdvancedJsonRpc\Response::isResponse($msg->body)) { + return; + } + $result = null; + $error = null; + try { + // Invoke the method handler to get a result + $result = yield $this->dispatch($msg->body); + } catch (AdvancedJsonRpc\Error $e) { + // If a ResponseError is thrown, send it back in the Response + $error = $e; + } catch (Throwable $e) { + // If an unexpected error occured, send back an INTERNAL_ERROR error response + $error = new AdvancedJsonRpc\Error( + $e->getMessage(), + AdvancedJsonRpc\ErrorCode::INTERNAL_ERROR, + null, + $e + ); + } + // Only send a Response for a Request + // Notifications do not send Responses + if (AdvancedJsonRpc\Request::isRequest($msg->body)) { + if ($error !== null) { + $responseBody = new AdvancedJsonRpc\ErrorResponse($msg->body->id, $error); + } else { + $responseBody = new AdvancedJsonRpc\SuccessResponse($msg->body->id, $result); + } + $this->protocolWriter->write(new Message($responseBody)); + } + })->otherwise('\\LanguageServer\\crash'); + }); + $this->protocolWriter = $writer; + $this->client = new LanguageClient($reader, $writer); + } - /** - * The initialize request is sent as the first request from the client to the server. - * - * @param ClientCapabilities $capabilities The capabilities provided by the client (editor) - * @param string|null $rootPath The rootPath of the workspace. Is null if no folder is open. - * @param int|null $processId The process Id of the parent process that started the server. Is null if the process has not been started by another process. If the parent process is not alive then the server should exit (see exit notification) its process. - * @return InitializeResult - */ - public function initialize(ClientCapabilities $capabilities, string $rootPath = null, int $processId = null): InitializeResult - { - $this->rootPath = $rootPath; - $this->clientCapabilities = $capabilities; - $this->project = new Project($this->client, $capabilities); - $this->textDocument = new Server\TextDocument($this->project, $this->client); - $this->workspace = new Server\Workspace($this->project, $this->client); + /** + * The initialize request is sent as the first request from the client to the server. + * + * @param ClientCapabilities $capabilities The capabilities provided by the client (editor) + * @param string|null $rootPath The rootPath of the workspace. Is null if no folder is open. + * @param int|null $processId The process Id of the parent process that started the server. Is null if the process has not been started by another process. If the parent process is not alive then the server should exit (see exit notification) its process. + * @return InitializeResult + */ + public function initialize(ClientCapabilities $capabilities, string $rootPath = null, int $processId = null): InitializeResult + { + $this->rootPath = $rootPath; + $this->clientCapabilities = $capabilities; + $this->project = new Project($this->client, $capabilities); + $this->textDocument = new Server\TextDocument($this->project, $this->client); + $this->workspace = new Server\Workspace($this->project, $this->client); - // start building project index - if ($rootPath !== null) { - $this->indexProject()->otherwise('\\LanguageServer\\crash'); - } + // start building project index + if ($rootPath !== null) { + $this->indexProject()->otherwise('\\LanguageServer\\crash'); + } + + if (extension_loaded('xdebug')) { + setTimeout(function () { + $this->client->window->showMessage(MessageType::WARNING, 'You are running PHP Language Server with xdebug enabled. This has a major impact on server performance.'); + }, 1); + } - if (extension_loaded('xdebug')) { - setTimeout(function () { - $this->client->window->showMessage(MessageType::WARNING, 'You are running PHP Language Server with xdebug enabled. This has a major impact on server performance.'); - }, 1); - } + $serverCapabilities = new ServerCapabilities(); + // Ask the client to return always full documents (because we need to rebuild the AST from scratch) + $serverCapabilities->textDocumentSync = TextDocumentSyncKind::FULL; + // Support "Find all symbols" + $serverCapabilities->documentSymbolProvider = true; + // Support "Find all symbols in workspace" + $serverCapabilities->workspaceSymbolProvider = true; + // Support "Format Code" + $serverCapabilities->documentFormattingProvider = true; + // Support "Go to definition" + $serverCapabilities->definitionProvider = true; + // Support "Find all references" + $serverCapabilities->referencesProvider = true; + // Support "Hover" + $serverCapabilities->hoverProvider = true; + // Support "Completion" + $serverCapabilities->completionProvider = new CompletionOptions; + $serverCapabilities->completionProvider->resolveProvider = false; + $serverCapabilities->completionProvider->triggerCharacters = ['$', '>']; - $serverCapabilities = new ServerCapabilities(); - // Ask the client to return always full documents (because we need to rebuild the AST from scratch) - $serverCapabilities->textDocumentSync = TextDocumentSyncKind::FULL; - // Support "Find all symbols" - $serverCapabilities->documentSymbolProvider = true; - // Support "Find all symbols in workspace" - $serverCapabilities->workspaceSymbolProvider = true; - // Support "Format Code" - $serverCapabilities->documentFormattingProvider = true; - // Support "Go to definition" - $serverCapabilities->definitionProvider = true; - // Support "Find all references" - $serverCapabilities->referencesProvider = true; - // Support "Hover" - $serverCapabilities->hoverProvider = true; + return new InitializeResult($serverCapabilities); + } - return new InitializeResult($serverCapabilities); - } + /** + * The shutdown request is sent from the client to the server. It asks the server to shut down, but to not exit + * (otherwise the response might not be delivered correctly to the client). There is a separate exit notification that + * asks the server to exit. + * + * @return void + */ + public function shutdown() + { + unset($this->project); + } - /** - * The shutdown request is sent from the client to the server. It asks the server to shut down, but to not exit - * (otherwise the response might not be delivered correctly to the client). There is a separate exit notification that - * asks the server to exit. - * - * @return void - */ - public function shutdown() - { - unset($this->project); - } + /** + * A notification to ask the server to exit its process. + * + * @return void + */ + public function exit() + { + exit(0); + } - /** - * A notification to ask the server to exit its process. - * - * @return void - */ - public function exit() - { - exit(0); - } + /** + * Parses workspace files, one at a time. + * + * @return Promise + */ + private function indexProject(): Promise + { + return coroutine(function () { + $textDocuments = yield $this->findPhpFiles(); + $count = count($textDocuments); - /** - * Parses workspace files, one at a time. - * - * @return Promise - */ - private function indexProject(): Promise - { - return coroutine(function () { - $textDocuments = yield $this->findPhpFiles(); - $count = count($textDocuments); + $startTime = microtime(true); - $startTime = microtime(true); + foreach ($textDocuments as $i => $textDocument) { + // Give LS to the chance to handle requests while indexing + yield timeout(); + $this->client->window->logMessage( + MessageType::LOG, + "Parsing file $i/$count: {$textDocument->uri}" + ); + try { + yield $this->project->loadDocument($textDocument->uri); + } catch (ContentTooLargeException $e) { + $this->client->window->logMessage( + MessageType::INFO, + "Ignoring file {$textDocument->uri} because it exceeds size limit of {$e->limit} bytes ({$e->size})" + ); + } catch (Exception $e) { + $this->client->window->logMessage( + MessageType::ERROR, + "Error parsing file {$textDocument->uri}: " . (string)$e + ); + } + } - foreach ($textDocuments as $i => $textDocument) { - // Give LS to the chance to handle requests while indexing - yield timeout(); - $this->client->window->logMessage( - MessageType::LOG, - "Parsing file $i/$count: {$textDocument->uri}" - ); - try { - yield $this->project->loadDocument($textDocument->uri); - } catch (ContentTooLargeException $e) { - $this->client->window->logMessage( - MessageType::INFO, - "Ignoring file {$textDocument->uri} because it exceeds size limit of {$e->limit} bytes ({$e->size})" - ); - } catch (Exception $e) { - $this->client->window->logMessage( - MessageType::ERROR, - "Error parsing file {$textDocument->uri}: " . (string)$e - ); - } - } + $duration = (int)(microtime(true) - $startTime); + $mem = (int)(memory_get_usage(true) / (1024 * 1024)); + $this->client->window->logMessage( + MessageType::INFO, + "All $count PHP files parsed in $duration seconds. $mem MiB allocated." + ); + }); + } - $duration = (int)(microtime(true) - $startTime); - $mem = (int)(memory_get_usage(true) / (1024 * 1024)); - $this->client->window->logMessage( - MessageType::INFO, - "All $count PHP files parsed in $duration seconds. $mem MiB allocated." - ); - }); - } - - /** - * Returns all PHP files in the workspace. - * If the client does not support workspace/files, it falls back to searching the file system directly. - * - * @return Promise - */ - private function findPhpFiles(): Promise - { - return coroutine(function () { - $textDocuments = []; - $pattern = Path::makeAbsolute('**/*.php', $this->rootPath); - if ($this->clientCapabilities->xfilesProvider) { - // Use xfiles request - foreach (yield $this->client->workspace->xfiles() as $textDocument) { - $path = Uri\parse($textDocument->uri)['path']; - if (Glob::match($path, $pattern)) { - $textDocuments[] = $textDocument; - } - } - } else { - // Use the file system - foreach (new GlobIterator($pattern) as $path) { - $textDocuments[] = new TextDocumentIdentifier(pathToUri($path)); - yield timeout(); - } - } - return $textDocuments; - }); - } + /** + * Returns all PHP files in the workspace. + * If the client does not support workspace/files, it falls back to searching the file system directly. + * + * @return Promise + */ + private function findPhpFiles(): Promise + { + return coroutine(function () { + $textDocuments = []; + $pattern = Path::makeAbsolute('**/*.php', $this->rootPath); + if ($this->clientCapabilities->xfilesProvider) { + // Use xfiles request + foreach (yield $this->client->workspace->xfiles() as $textDocument) { + $path = Uri\parse($textDocument->uri)['path']; + if (Glob::match($path, $pattern)) { + $textDocuments[] = $textDocument; + } + } + } else { + // Use the file system + foreach (new GlobIterator($pattern) as $path) { + $textDocuments[] = new TextDocumentIdentifier(pathToUri($path)); + yield timeout(); + } + } + return $textDocuments; + }); + } } diff --git a/src/NodeVisitor/DefinitionCollector.php b/src/NodeVisitor/DefinitionCollector.php index 162f670..5198139 100644 --- a/src/NodeVisitor/DefinitionCollector.php +++ b/src/NodeVisitor/DefinitionCollector.php @@ -42,13 +42,6 @@ class DefinitionCollector extends NodeVisitorAbstract 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 575a5e6..1e1a726 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -290,6 +290,9 @@ class PhpDocument */ 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 @@ class PhpDocument 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 780f31f..64bc69d 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 6ef5796..046f8ef 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 d348830..4d0bd64 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 0be727e..f668ca0 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 01cff0b..f47afe2 100644 --- a/src/Protocol/Position.php +++ b/src/Protocol/Position.php @@ -49,4 +49,17 @@ class Position 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 1111dc0..06e8f7e 100644 --- a/src/Protocol/SymbolInformation.php +++ b/src/Protocol/SymbolInformation.php @@ -88,7 +88,7 @@ class SymbolInformation } $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/ProtocolReader.php b/src/ProtocolReader.php index 7515bab..c199fba 100644 --- a/src/ProtocolReader.php +++ b/src/ProtocolReader.php @@ -8,6 +8,8 @@ use Sabre\Event\EmitterInterface; /** * Must emit a "message" event with a Protocol\Message object as parameter * when a message comes in + * + * Must emit a "close" event when the stream closes */ interface ProtocolReader extends EmitterInterface { diff --git a/src/ProtocolStreamReader.php b/src/ProtocolStreamReader.php index af4f3ed..4071dcd 100644 --- a/src/ProtocolStreamReader.php +++ b/src/ProtocolStreamReader.php @@ -25,7 +25,17 @@ class ProtocolStreamReader extends Emitter implements ProtocolReader { $this->input = $input; + $this->on('close', function () { + Loop\removeReadStream($this->input); + }); + Loop\addReadStream($this->input, function () { + if (feof($this->input)) { + // If stream_select reported a status change for this stream, + // but the stream is EOF, it means it was closed. + $this->emit('close'); + return; + } while (($c = fgetc($this->input)) !== false && $c !== '') { $this->buffer .= $c; switch ($this->parsingMode) { diff --git a/src/Server/TextDocument.php b/src/Server/TextDocument.php index 6c67388..5998acc 100644 --- a/src/Server/TextDocument.php +++ b/src/Server/TextDocument.php @@ -3,7 +3,7 @@ declare(strict_types = 1); 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 @@ use LanguageServer\Protocol\{ 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 @@ class TextDocument 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 172ccac..ed7a419 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 @@ class LanguageServerTest extends TestCase 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 @@ class LanguageServerTest extends TestCase 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 ded65d1..74e0d5c 100644 --- a/tests/NodeVisitor/DefinitionCollectorTest.php +++ b/tests/NodeVisitor/DefinitionCollectorTest.php @@ -30,13 +30,14 @@ class DefinitionCollectorTest extends TestCase $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 @@ class DefinitionCollectorTest extends TestCase $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 @@ class DefinitionCollectorTest extends TestCase $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 1a608ca..23d1763 100644 --- a/tests/Server/ServerTestCase.php +++ b/tests/Server/ServerTestCase.php @@ -54,13 +54,11 @@ abstract class ServerTestCase extends TestCase $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 @@ abstract class ServerTestCase extends TestCase '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 0000000..7b5dd0a --- /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 4f09e20..b9c937e 100644 --- a/tests/Server/TextDocument/DocumentSymbolTest.php +++ b/tests/Server/TextDocument/DocumentSymbolTest.php @@ -18,6 +18,7 @@ class DocumentSymbolTest extends ServerTestCase $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 7086942..33b4cf1 100644 --- a/tests/Server/Workspace/SymbolTest.php +++ b/tests/Server/Workspace/SymbolTest.php @@ -6,7 +6,17 @@ namespace LanguageServer\Tests\Server\Workspace; 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 @@ class SymbolTest extends ServerTestCase { // 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 @@ class SymbolTest extends ServerTestCase 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 0000000..06db68e --- /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('', '')); + } +}