From 76c8536e913a210854e5be7d322bf7739ce3209b Mon Sep 17 00:00:00 2001 From: Sara Itani Date: Tue, 25 Apr 2017 17:09:52 -0700 Subject: [PATCH] first pass at completion provider (work in progress) --- src/CompletionProvider.php | 347 ++++++++++++++++------------- src/PhpDocument.php | 2 +- src/TolerantDefinitionResolver.php | 5 + 3 files changed, 195 insertions(+), 159 deletions(-) diff --git a/src/CompletionProvider.php b/src/CompletionProvider.php index 5ce8bec..58531e2 100644 --- a/src/CompletionProvider.php +++ b/src/CompletionProvider.php @@ -13,6 +13,7 @@ use LanguageServer\Protocol\{ CompletionItem, CompletionItemKind }; +use Microsoft\PhpParser as Tolerant; class CompletionProvider { @@ -123,44 +124,71 @@ class CompletionProvider { // This can be made much more performant if the tree follows specific invariants. $node = $doc->getNodeAtPosition($pos); + - if ($node instanceof Node\Expr\Error) { - $node = $node->getAttribute('parentNode'); + if($node !== null && ($offset = $pos->toOffset($node->getFileContents())) > $node->getEndPosition() && + $node->parent->getLastChild() instanceof Tolerant\MissingToken) { + $node = $node->parent; } $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 ($node instanceof Tolerant\Node\Expression\Variable && + $node->parent instanceof Tolerant\Node\Expression\ObjectCreationExpression && + $node->name instanceof Tolerant\MissingToken ) { - // 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 = FqnUtilities::getFqnsFromType( - $this->definitionResolver->resolveExpressionNodeToType($node->var) + $node = $node->parent; + } + + if ($node === null || $node instanceof Tolerant\Node\Statement\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; + } + // VARIABLES + elseif ( + $node instanceof Tolerant\Node\Expression\Variable && + !( + $node->parent instanceof Tolerant\Node\Expression\ScopedPropertyAccessExpression && + $node->parent->memberName === $node) + ) { + // Find variables, parameters and use statements in the scope + // If there was only a $ typed, $node will be instanceof Node\Error + $namePrefix = $node->getName() ?? ''; + foreach ($this->suggestVariablesAtNode($node, $namePrefix) as $var) { + $item = new CompletionItem; + $item->kind = CompletionItemKind::VARIABLE; + $item->label = '$' . $var->getName(); + $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) ); - } else { - // Static member reference - $prefixes = [$node->class instanceof Node\Name ? (string)$node->class : '']; + $list->items[] = $item; } + } + + // MEMBER ACCESS EXPRESSIONS + // $a->c# + // $a-># + elseif ($node instanceof Tolerant\Node\Expression\MemberAccessExpression) { + $prefixes = FqnUtilities::getFqnsFromType( + $this->definitionResolver->resolveExpressionNodeToType($node->dereferencableExpression) + ); $prefixes = $this->expandParentFqns($prefixes); - // 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 .= '::$'; + $prefix .= '->'; + if ($node->memberName !== null && $node->memberName instanceof Tolerant\Token) { + $prefix .= $node->memberName->getText($node->getFileContents()); } } + unset($prefix); foreach ($this->index->getDefinitions() as $fqn => $def) { @@ -170,125 +198,126 @@ class CompletionProvider } } } - } 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_ + } + + // SCOPED PROPERTY ACCESS EXPRESSIONS + // A\B\C::$a# + // A\B\C::# + // A\B\C::$# + // A\B\C::foo# + // TODO: $a::# + elseif ( + ($scoped = $node->parent) instanceof Tolerant\Node\Expression\ScopedPropertyAccessExpression || + ($scoped = $node) instanceof Tolerant\Node\Expression\ScopedPropertyAccessExpression ) { - $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); + $prefixes = FqnUtilities::getFqnsFromType( + $classType = $this->definitionResolver->resolveExpressionNodeToType($scoped->scopeResolutionQualifier) + ); + + $prefixes = $this->expandParentFqns($prefixes); + + foreach ($prefixes as &$prefix) { + $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); - if ($def = $this->index->getDefinition($fqn)) { - $aliasedDefs[$use->alias] = $def; - } + + unset($prefix); + + $memberName = $scoped->memberName->getText($scoped->getFileContents()); + + foreach ($this->index->getDefinitions() as $fqn => $def) { + foreach ($prefixes as $prefix) { + if (substr(strtolower($fqn), 0, strlen($prefix)) === strtolower($prefix) && !$def->isGlobal) { + if (empty($memberName) || strpos(substr(strtolower($fqn), 0), strtolower($memberName)) !== false) { + $list->items[] = CompletionItem::fromDefinition($def); } - } 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); - } - } + } elseif (TolerantParserHelpers::isConstantFetch($node) || + ($creation = $node->parent) instanceof Tolerant\Node\Expression\ObjectCreationExpression || + (($creation = $node) instanceof Tolerant\Node\Expression\ObjectCreationExpression)) { + + $class = isset($creation) ? $creation->classTypeDesignator : $node; + + $prefix = $class instanceof Tolerant\Node\QualifiedName + ? (string)Tolerant\ResolvedName::buildName($class->nameParts, $class->getFileContents()) + : $class->getText($node->getFileContents()); + + $namespaceDefinition = $node->getNamespaceDefinition(); + + list($namespaceImportTable,,) = $node->getImportTablesForCurrentScope(); + foreach ($namespaceImportTable as $alias=>$name) { + $namespaceImportTable[$alias] = (string)$name; } - // 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->index->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) + ($def->canBeInstantiated || ($def->isGlobal && !isset($creation))) && (empty($prefix) || strpos($fqn, $prefix) !== false) + ) { - $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; + if ($namespaceDefinition !== null && $namespaceDefinition->name !== null) { + $namespacePrefix = (string)Tolerant\ResolvedName::buildName($namespaceDefinition->name->nameParts, $node->getFileContents()); + + $isAliased = false; + + $isNotFullyQualified = !($class instanceof Tolerant\Node\QualifiedName) || !$class->isFullyQualifiedName(); + if ($isNotFullyQualified) { + foreach ($namespaceImportTable as $alias => $name) { + if (strpos($fqn, $name) === 0) { + $fqn = $alias; + $isAliased = true; + break; + } + } + } + + + if (!$isNotFullyQualified && ((strpos($fqn, $prefix) === 0) || strpos($fqn, $namespacePrefix . "\\" . $prefix) === 0)) { + $fqn = $fqn; + } + elseif (!$isAliased && !array_search($fqn, array_values($namespaceImportTable))) { + if (empty($prefix)) { + $fqn = '\\' . $fqn; + } elseif (strpos($fqn, $namespacePrefix . "\\" . $prefix) === 0) { + $fqn = substr($fqn, strlen($namespacePrefix) + 1); + } else { + continue; + } + } elseif (!$isAliased) { + continue; + } + } elseif (strpos($fqn, $prefix) === 0 && $class->isFullyQualifiedName()) { + $fqn = '\\' . $fqn; } + + $item = CompletionItem::fromDefinition($def); + + $item->insertText = $fqn; $list->items[] = $item; } } - // Suggest keywords - if ($node instanceof Node\Name && $node->getAttribute('parentNode') instanceof Node\Expr\ConstFetch) { + + if (!isset($creation)) { foreach (self::KEYWORDS as $keyword) { - if (substr($keyword, 0, $prefixLen) === $prefix) { + if (strpos($keyword, $prefix) === 0) { $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; + } elseif (TolerantParserHelpers::isConstantFetch($node)) { + $prefix = (string) ($node->getResolvedName() ?? Tolerant\ResolvedName::buildName($node->nameParts, $node->getFileContents())); + foreach (self::KEYWORDS as $keyword) { + if (strpos($keyword, $prefix) === 0) { + $item = new CompletionItem($keyword, CompletionItemKind::KEYWORD); + $item->insertText = $keyword . ' '; + $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; + return $list; } /** @@ -303,7 +332,7 @@ class CompletionProvider foreach ($fqns as $fqn) { $def = $this->index->getDefinition($fqn); if ($def) { - foreach ($this->expandParentFqns($def->extends) as $parent) { + foreach ($this->expandParentFqns($def->extends ?? []) as $parent) { $expanded[] = $parent; } } @@ -320,7 +349,7 @@ class CompletionProvider * @param string $namePrefix Prefix to filter * @return array */ - private function suggestVariablesAtNode(Node $node, string $namePrefix = ''): array + private function suggestVariablesAtNode(Tolerant\Node $node, string $namePrefix = ''): array { $vars = []; @@ -336,30 +365,33 @@ class CompletionProvider // Walk the AST upwards until a scope boundary is met $level = $node; - while ($level && !($level instanceof Node\FunctionLike)) { + while ($level && !TolerantParserHelpers::isFunctionLike($level)) { // Walk siblings before the node $sibling = $level; - while ($sibling = $sibling->getAttribute('previousSibling')) { + while ($sibling = $sibling->getPreviousSibling()) { // Collect all variables inside the sibling node foreach ($this->findVariableDefinitionsInNode($sibling, $namePrefix) as $var) { - $vars[$var->name] = $var; + $vars[$var->getName()] = $var; } } - $level = $level->getAttribute('parentNode'); + $level = $level->parent; } // 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 && TolerantParserHelpers::isFunctionLike($level) && $level->parameters !== null) { + foreach ($level->parameters->getValues() as $param) { + $paramName = $param->getName(); + if (empty($namePrefix) || strpos($paramName, $namePrefix) !== false) { + $vars[$paramName] = $param; } } - if ($level instanceof Node\Expr\Closure) { - foreach ($level->uses as $use) { - if (!isset($vars[$use->var]) && substr($use->var, 0, strlen($namePrefix)) === $namePrefix) { - $vars[$use->var] = $use; + + if ($level instanceof Tolerant\Node\Expression\AnonymousFunctionCreationExpression && $level->anonymousFunctionUseClause !== null) { + foreach ($level->anonymousFunctionUseClause->useVariableNameList->getValues() as $use) { + $useName = $use->getName(); + if (empty($namePrefix) || strpos($useName, $namePrefix) !== false) { + $vars[$useName] = $use; } } } @@ -375,36 +407,35 @@ class CompletionProvider * @param string $namePrefix Prefix to filter * @return Node\Expr\Variable[] */ - private function findVariableDefinitionsInNode(Node $node, string $namePrefix = ''): array + private function findVariableDefinitionsInNode(Tolerant\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; + + $isAssignmentToVariable = function ($node) use ($namePrefix) { + return $node instanceof Tolerant\Node\Expression\AssignmentExpression + && $node->leftOperand instanceof Tolerant\Node\Expression\Variable + && (empty($namePrefix) || strpos($node->leftOperand->getName(), $namePrefix) !== false); + }; + $isNotFunctionLike = function($node) { + return !( + TolerantParserHelpers::isFunctionLike($node) || + $node instanceof Tolerant\Node\Statement\ClassDeclaration || + $node instanceof Tolerant\Node\Statement\InterfaceDeclaration || + $node instanceof Tolerant\Node\Statement\TraitDeclaration + ); + }; + + if ($isAssignmentToVariable($node)) { + $vars[] = $node->leftOperand; + } else { + foreach ($node->getDescendantNodes($isNotFunctionLike) as $descendantNode) { + if ($isAssignmentToVariable($descendantNode)) { + $vars[] = $descendantNode->leftOperand; } } } + return $vars; } } diff --git a/src/PhpDocument.php b/src/PhpDocument.php index 4ccedf3..399d2b5 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -240,7 +240,7 @@ class PhpDocument * Returns the node at a specified position * * @param Position $position - * @return Node|null + * @return Tolerant\Node|null */ public function getNodeAtPosition(Position $position) { diff --git a/src/TolerantDefinitionResolver.php b/src/TolerantDefinitionResolver.php index 6f1e1ca..0459a43 100644 --- a/src/TolerantDefinitionResolver.php +++ b/src/TolerantDefinitionResolver.php @@ -520,6 +520,7 @@ class TolerantDefinitionResolver implements DefinitionResolverInterface $n = $n->expression; } if ( + // TODO - clean this up ($n instanceof Tolerant\Node\Expression\AssignmentExpression && $n->operator->kind === Tolerant\TokenKind::EqualsToken) && $n->leftOperand instanceof Tolerant\Node\Expression\Variable && $n->leftOperand->getName() === $name ) { @@ -865,6 +866,10 @@ class TolerantDefinitionResolver implements DefinitionResolverInterface return new Types\Mixed; } + if ($expr instanceof Tolerant\Node\QualifiedName) { + return $this->resolveClassNameToType($expr); + } + return new Types\Mixed; }